Merge upstream main into native/arrow-loose

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
[email protected]andClaude Sonnet 5.5 committed 2026-11-02 12:01:23 +01:00
commit 828ddff14f
143 files changed
+4803 -1202

No files matched your search

+51 -12
View File
@@ -125,19 +125,51 @@ pub const RULES: &[Rule] = &[
b("universal_anger", "mobs", false),
b("water_source_conversion", "updates", true),
];
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
RULES
.iter()
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
}
pub fn rule(name: &str) -> Option<&'static Rule> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
RULES.iter().find(|r| r.name == name)
available_rules(true).find(|r| r.name == name)
}
/// Current values (defaults unless set).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameRules {
values: BTreeMap<&'static str, Value>,
minecart_improvements: bool,
}
impl GameRules {
pub fn with_features(features: &[String]) -> Self {
Self {
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
..Self::default()
}
}
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
/// newly enabled rules and discard rules absent from the target feature set.
pub fn copy_with_features(&self, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
for (&name, &value) in &self.values {
rules.set(name, value);
}
rules
}
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
available_rules(self.minecart_improvements)
}
pub fn get(&self, name: &str) -> Option<Value> {
let r = rule(name)?;
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
return None;
}
Some(self.values.get(r.name).copied().unwrap_or(r.default))
}
pub fn bool(&self, name: &str) -> bool {
@@ -160,7 +192,9 @@ impl GameRules {
}
/// Set a rule; false if the name or value type is wrong.
pub fn set(&mut self, name: &str, value: Value) -> bool {
let Some(r) = rule(name) else { return false };
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
return false;
};
let ok = match (r.default, value) {
(Value::Bool(_), Value::Bool(_)) => true,
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
@@ -176,8 +210,7 @@ impl GameRules {
/// Every rule's value by name without namespace, as `save::Level` holds
/// them (booleans as 0 or 1).
pub fn by_name(&self) -> BTreeMap<String, i32> {
RULES
.iter()
self.available_rules()
.map(|r| {
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
Value::Bool(b) => i32::from(b),
@@ -189,8 +222,8 @@ impl GameRules {
}
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
pub fn to_nbt(&self) -> NamedTag {
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
.iter()
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
.available_rules()
.map(|r| {
let v = self.values.get(r.name).copied().unwrap_or(r.default);
let tag = match v {
@@ -212,11 +245,14 @@ impl GameRules {
}
}
pub fn from_nbt(root: &Tag) -> Self {
let mut rules = Self::default();
Self::from_nbt_with_features(root, &[])
}
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
let Some(data) = root.get("data") else {
return rules;
};
for r in RULES {
for r in rules.available_rules() {
let key = format!("minecraft:{}", r.name);
let value = data.get(&key).or_else(|| data.get(r.name));
match (r.default, value) {
@@ -232,15 +268,18 @@ impl GameRules {
rules
}
pub fn load(world: &Path) -> Self {
Self::load_with_features(world, &[])
}
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
return Self::default();
return Self::with_features(features);
};
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
return Self::default();
return Self::with_features(features);
};
match nbt::read_named(&raw, Limits::default()) {
Ok((root, _)) => Self::from_nbt(&root.tag),
Err(_) => Self::default(),
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
Err(_) => Self::with_features(features),
}
}
pub fn save(&self, world: &Path) -> crate::Result<()> {
+5 -1
View File
@@ -121,7 +121,11 @@ impl DataPack {
}
/// The packs in `<world>/datapacks`, sorted by name.
pub fn discover(world: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs = Vec::new();
+104 -62
View File
@@ -90,18 +90,23 @@ fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
/// their entries on the CPU (and without them every section is a call of its own anyway);
/// nor where vanilla frames draw flat (`flat`'s `min_sections` below `usize::MAX`: Metal,
/// Intel Gen11/Gen12's Vulkan driver, `HORIZON_FLAT_DRAWS=1`), as flat draws read
/// translucent quads in the section arena.
fn translucent_batchable(
info: &wgpu::AdapterInfo,
multi_draw: bool,
flat_min_sections: Option<usize>,
) -> bool {
multi_draw
&& info.backend != wgpu::Backend::Gl
&& flat_min_sections.is_none_or(|min| min == usize::MAX)
/// their entries on the CPU (and without them every section is a call of its own anyway).
/// Frames drawn flat (Metal, Intel Gen11/Gen12's Vulkan driver) take it too: their
/// translucent calls then come one per page of its arena rather than about one per section.
fn translucent_batchable(info: &wgpu::AdapterInfo, multi_draw: bool) -> bool {
multi_draw && info.backend != wgpu::Backend::Gl
}
/// The vertices flat draws bind for a call's page key: a page of the section arena, or of
/// the translucent arena up to its last range in use (`flat::TRANSLUCENT_KEYS`).
fn flat_vertices<'a>(
arena: &'a arena::Arena,
translucent: Option<&'a arena::Arena>,
) -> impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy {
move |key| match (translucent, key.checked_sub(flat::TRANSLUCENT_KEYS)) {
(Some(translucent), Some(page)) => translucent.binding(page),
_ => arena.buffer(key).slice(..),
}
}
/// Whether translucent quads have an arena of their own: with Translucent Batching
@@ -498,8 +503,11 @@ fn move_off_page<'a>(
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
flat.remove(id);
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
*budget = budget.saturating_sub(bytes);
@@ -3707,7 +3715,7 @@ impl Renderer {
flat_wanted,
);
// Sections as one index list per frame where multi-draw is emulated.
let flat = flat_wanted.then(|| {
let mut flat = flat_wanted.then(|| {
let buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
@@ -3743,11 +3751,7 @@ impl Renderer {
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
let batchable = translucent_batchable(
&adapter.get_info(),
multi_draw,
flat.as_ref().map(|f| f.min_sections),
);
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
crate::graphics::offer_translucent_batching(batchable);
let translucent_batching = batchable && translucent_batching;
// A new renderer runs no shader pack: the game loads the picked one on its
@@ -3763,6 +3767,9 @@ impl Renderer {
flat.is_some(),
translucent_split(translucent_batching, flat.is_some(), shader_pack, false),
);
if let (Some(flat), Some(arena)) = (&mut flat, &translucent_arena) {
flat.set_translucent_page_vertices(arena.page_vertices());
}
// Without indirect draws (OpenGL before 4.0 without indirect execution, or
// before 4.2 + GL_ARB_shader_draw_parameters without indirect first instance)
// even creating an indirect buffer fails; one draw per section reads
@@ -4085,7 +4092,11 @@ impl Renderer {
match flat {
Some(flat) => {
pass.set_pipeline(&flat.translucent_mark);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
flat.draw(
pass,
flat.translucent_calls(),
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
pass.set_bind_group(0, &self.group, &[]);
}
None => {
@@ -4478,17 +4489,23 @@ impl Renderer {
} else {
None
};
// Flat draws name the blocks of its range in `arena`: split, its solid
// quads alone (no frame draws flat then: `translucent_split`).
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
f.add(
&self.device,
encoder,
&mut self.section_uploads,
page,
start,
main,
)
// Flat draws name the blocks of its ranges by its id: its range in `arena` and,
// split, its translucent quads' in `translucent_arena` (`flat::TRANSLUCENT_KEYS`).
let translucent_key = (split && translucent > 0)
.then(|| (translucent_at.0 + flat::TRANSLUCENT_KEYS, translucent_at.1));
let flat_id = self.flat.as_mut().and_then(|f| {
let ranges = [(main > 0).then_some((page, start, main))]
.into_iter()
.chain([translucent_key.map(|(key, first)| (key, first, translucent_vertices))]);
let mut id = None;
for (key, first, vertices) in ranges.flatten() {
let uploads = &mut self.section_uploads;
match id {
None => id = Some(f.add(&self.device, encoder, uploads, key, first, vertices)),
Some(id) => f.place(&self.device, encoder, uploads, id, key, first, vertices),
}
}
id
});
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
@@ -4721,7 +4738,13 @@ impl Renderer {
_ => COMPACT_BUDGET,
};
let mut budget = left.min(cap);
if !move_off_page(arena, encoder, None, meshes, page, part, &mut budget) {
// Flat draws name translucent quads' blocks by their section's id; indices have none.
let flat = match part {
Part::Translucent => self.flat.as_mut(),
_ => None,
}
.map(|f| (f, &self.device, &mut self.section_uploads));
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
// New ranges took the room: both pages stay, drawn, until a later try.
arena.reopen();
self.shrink_gate.wait(now, SHRINK_RETRY);
@@ -4901,6 +4924,9 @@ impl Renderer {
self.flat.is_some(),
split,
);
if let (Some(flat), Some(arena)) = (&mut self.flat, &self.translucent_arena) {
flat.set_translucent_page_vertices(arena.page_vertices());
}
}
/// Whether every section and far tile was dropped since the last call, to be
/// laid out again (`follow_translucent_layout`): the game meshes them again,
@@ -5856,7 +5882,11 @@ impl Renderer {
arena::free_later(self.graveyard.advance());
self.shrink_translucent(left);
if let Some(flat) = &mut self.flat {
flat.trim(|page| self.arena.has_page(page));
let translucent = self.translucent_arena.as_ref();
flat.trim(|key| match key.checked_sub(flat::TRANSLUCENT_KEYS) {
Some(page) => translucent.is_some_and(|a| a.has_page(page)),
None => self.arena.has_page(key),
});
}
if self.suspended {
return Ok(false);
@@ -7220,8 +7250,6 @@ impl Renderer {
|| pack_terrain
|| shadow_view.is_some()) =>
{
// They read translucent quads in the section arena (`translucent_split`).
debug_assert!(self.translucent_arena.is_none(), "flat draws, split");
let sections = &self.sections;
let lods = &self.lods;
let sorted = &self.sorted_indices;
@@ -7238,6 +7266,7 @@ impl Renderer {
[shadow_first, shadow_first + shadow_solid].map(|d| d as usize),
[opaque_end, cutout_start],
phase2,
self.translucent_arena.is_some(),
visible
.iter()
.chain(&shadow_visible)
@@ -7482,9 +7511,11 @@ impl Renderer {
{
pass.set_pipeline(pipeline);
let [first, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[first..translucent], |p| {
self.arena.buffer(p)
});
flat.draw(
&mut pass,
&flat.calls[first..translucent],
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
} else if !flat_frame {
pass.set_pipeline(&shadow.solid);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -7505,9 +7536,11 @@ impl Renderer {
{
pass.set_pipeline(pipeline);
let [_, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
self.arena.buffer(p)
});
flat.draw(
&mut pass,
&flat.calls[translucent..],
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
} else if !flat_frame {
pass.set_pipeline(&shadow.translucent);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -7670,7 +7703,7 @@ impl Renderer {
// is phase 1's.
if flat_frame && let Some(flat) = &self.flat {
pass.set_pipeline(&flat.solid_mirror);
let vertices = |p| self.arena.buffer(p);
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices);
} else {
pass.set_pipeline(&self.solid_mirror);
@@ -7858,7 +7891,7 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
Some(flat) => {
let vertices = |p| self.arena.buffer(p);
let vertices = flat_vertices(&self.arena, self.translucent_arena.as_ref());
// Without a pack, on Metal, the opaque pass, then the cutout one
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
// quads for it, and over all of phase 2's.
@@ -8333,7 +8366,11 @@ impl Renderer {
// Flat section draws take the flat pipelines.
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
pass.set_pipeline(pipeline);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
flat.draw(
pass,
flat.translucent_calls(),
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
return;
}
pass.set_pipeline(oit.terrain(stage));
@@ -8397,9 +8434,11 @@ impl Renderer {
}
Some(flat) => {
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
flat.draw(&mut pass, flat.translucent_calls(), |p| {
self.arena.buffer(p)
});
flat.draw(
&mut pass,
flat.translucent_calls(),
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
self.mark_translucent_terrain(
@@ -8580,10 +8619,16 @@ impl Renderer {
}],
});
rp.set_bind_group(5, &indices, &[]);
let vertices = |p| self.arena.buffer(p);
let vertices =
flat_vertices(&self.arena, self.translucent_arena.as_ref());
flat.draw_geometry(rp, calls, vertices, page, instances);
}
None => flat.draw_with(rp, calls, |p| self.arena.buffer(p), page),
None => flat.draw_with(
rp,
calls,
flat_vertices(&self.arena, self.translucent_arena.as_ref()),
page,
),
}
}
// A geometry stage reads flat draws' indices; without them it draws nothing.
@@ -9654,23 +9699,20 @@ mod tests {
assert!(!super::gpu_culling_requested(&discrete, Some("0")));
}
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's,
/// and only where frames without a shader pack never draw flat.
/// Translucent Batching is offered with indirect multi-draws that aren't OpenGL's, flat
/// frames (Metal, Intel Gen11/Gen12 on Vulkan) included.
#[test]
fn translucent_batching_is_offered_where_vanilla_frames_never_draw_flat() {
fn translucent_batching_is_offered_with_indirect_multi_draws_except_on_opengl() {
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
let mut metal = adapter(wgpu::DeviceType::IntegratedGpu);
metal.backend = wgpu::Backend::Metal;
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
gl.backend = wgpu::Backend::Gl;
let batchable = super::translucent_batchable;
// No flat draws, or flat draws for a shader pack's terrain alone.
assert!(batchable(&vulkan, true, None));
assert!(batchable(&vulkan, true, Some(usize::MAX)));
// Vanilla frames drawn flat past MIN_SECTIONS (Metal), or all of them.
assert!(!batchable(&vulkan, true, Some(super::flat::MIN_SECTIONS)));
assert!(!batchable(&vulkan, true, Some(0)));
assert!(!batchable(&vulkan, false, None));
assert!(!batchable(&gl, true, None));
assert!(!batchable(&gl, true, Some(usize::MAX)));
assert!(batchable(&vulkan, true));
assert!(batchable(&metal, true));
assert!(!batchable(&vulkan, false));
assert!(!batchable(&gl, true));
}
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
+131 -13
View File
@@ -157,21 +157,24 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
/// (vertices of `stride`) with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena. The indices go in
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
let indices = if split && !flat {
Self::translucent_indices(device, 1 << 16)
} else {
(Self::indices(device, 1 << 16, flat), None)
}
Self::indices(device, 1 << 16, flat)
};
(
indices,
split.then(|| Self::translucent(device, stride, 1 << 18)),
)
}
fn with_usage(
device: &wgpu::Device,
@@ -927,6 +930,121 @@ mod tests {
}
}
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
/// put them at another section's origin.
#[test]
fn moved_translucent_quads_keep_their_section_s_flat_id() {
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
.into(),
),
});
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
flat.set_translucent_page_vertices(translucent.page_vertices());
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
assert_eq!(
alloc(&mut translucent, &device, &queue, GRAIN),
Some((1, 0))
);
translucent.release(0, rest, GRAIN);
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
// Its solid quads in the section arena's page 0, its translucent ones here.
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
flat.place(
&device,
&mut encoder,
&mut uploads,
id,
key(0),
at,
2 * GRAIN,
);
let mut section = SectionGpu {
page: 0,
start: 0,
translucent_at: (0, at),
solid: 64,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: Some(id),
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
translucent.close(0);
let mut budget = u64::MAX;
assert!(move_off_page(
&mut translucent,
&mut encoder,
Some((&mut flat, &device, &mut uploads)),
std::iter::once(&mut section),
0,
Part::Translucent,
&mut budget
));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let (page, start) = section.translucent_at;
assert_eq!(page, 1);
assert_eq!(section.flat_id, Some(id));
let named =
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
for grain in start / GRAIN..(start / GRAIN + 2) {
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
}
// The section arena's table and the other section's grain are untouched.
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
assert_eq!(named(0, other / GRAIN), other_id);
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
@@ -1028,9 +1146,9 @@ mod tests {
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
/// are the index pages large, unless flat draws (`flat`) bind them as
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
/// `translucent_split`), translucent quads keep to the section arena.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
@@ -1050,9 +1168,9 @@ mod tests {
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert_eq!(storage, flat, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
+75 -12
View File
@@ -48,6 +48,10 @@ struct Plan {
}
const QUADS: u32 = u32::MAX;
const GROUP_LIMIT: u32 = 65535;
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
/// Batching): its page plus this. The section arena's pages are their own keys, all far
/// below it. A call names its key: its vertices come from that arena's page.
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
struct PageTable {
/// Section id of every block of the page.
@@ -67,6 +71,7 @@ pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
/// A run of the frame's index list drawn from one arena page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Call {
/// The page's key (`TRANSLUCENT_KEYS`).
pub page: u32,
pub first: u32,
pub count: u32,
@@ -98,6 +103,9 @@ pub struct Flat {
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
translucent_blocks_per_page: u64,
/// Block tables by page key.
pages: Vec<Option<PageTable>>,
free_ids: Vec<u32>,
next_id: u32,
@@ -218,7 +226,14 @@ fn buffer(
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(16),
usage: usage | wgpu::BufferUsages::COPY_DST,
// Tests read the tables back.
usage: usage
| wgpu::BufferUsages::COPY_DST
| if cfg!(test) {
wgpu::BufferUsages::COPY_SRC
} else {
wgpu::BufferUsages::empty()
},
mapped_at_creation: false,
})
}
@@ -242,6 +257,7 @@ fn plan(
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
key: impl Fn(u32, Option<u32>) -> u32,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
@@ -273,6 +289,7 @@ fn plan(
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
let page = key(page, sorted);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -451,6 +468,7 @@ impl Flat {
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
pages: Vec::new(),
free_ids: Vec::new(),
next_id: 0,
@@ -479,8 +497,8 @@ impl Flat {
.max(1),
}
}
/// A section uploaded to `vertices` vertices from `start` of `page`: its
/// id, which its blocks now name (written through `uploads` in `encoder`).
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
/// its id, which its blocks now name (written through `uploads` in `encoder`).
pub fn add(
&mut self,
device: &wgpu::Device,
@@ -494,6 +512,28 @@ impl Flat {
self.next_id += 1;
self.next_id - 1
});
self.place(device, encoder, uploads, id, page, start, vertices);
id
}
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
/// a range that moved.
#[allow(clippy::too_many_arguments)]
pub fn place(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
page: u32,
start: u64,
vertices: u64,
) {
let blocks = if page >= TRANSLUCENT_KEYS {
self.translucent_blocks_per_page
} else {
self.blocks_per_page
};
let page = page as usize;
if self.pages.len() <= page {
self.pages.resize_with(page + 1, || None);
@@ -502,7 +542,7 @@ impl Flat {
blocks: buffer(
device,
"Section ids by block",
self.blocks_per_page * 4,
blocks * 4,
wgpu::BufferUsages::STORAGE,
),
group: None,
@@ -515,7 +555,19 @@ impl Flat {
first * 4,
bytemuck::cast_slice(&ids),
);
id
}
/// The block table of the page keyed `page`, for tests that read it back.
#[cfg(test)]
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize]
.as_ref()
.expect("a page table")
.blocks
}
/// The most vertices a page of the translucent arena holds: its block tables' size.
/// Tables made before keep theirs, so this goes with `clear`.
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
@@ -525,7 +577,7 @@ impl Flat {
self.free_ids.clear();
self.next_id = 0;
}
/// Forgets the tables of pages the arena released.
/// Forgets the tables of pages the arenas released (`live`, by page key).
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
for (i, page) in self.pages.iter_mut().enumerate() {
if page.is_some() && !live(i as u32) {
@@ -556,6 +608,7 @@ impl Flat {
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
translucent_arena: bool,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
@@ -609,6 +662,11 @@ impl Flat {
shadow,
passes,
self.range_align,
// Translucent draws, and only they, have sorted indices.
|page, sorted| match sorted {
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
_ => page,
},
&mut self.calls,
&mut self.range_data,
&mut self.segments,
@@ -801,7 +859,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
self.draw_with(pass, calls, vertices, |_, _| {});
}
@@ -811,7 +869,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -828,7 +886,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
instances: u32,
) {
@@ -842,7 +900,7 @@ impl Flat {
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
@@ -857,13 +915,13 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
page: u32,
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
let table = self.pages[page as usize]
.as_ref()
.and_then(|t| t.group.as_ref())
.expect("prepared page");
pass.set_vertex_buffer(0, vertices(page).slice(..));
pass.set_vertex_buffer(0, vertices(page));
pass.set_bind_group(1, table, &[]);
}
}
@@ -912,6 +970,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -960,6 +1019,7 @@ mod tests {
[draws.len(); 2],
[3, 1],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -995,6 +1055,7 @@ mod tests {
[camera, camera + 2],
[camera, 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1023,6 +1084,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1140,6 +1202,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
align,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
+14 -7
View File
@@ -132,7 +132,7 @@ pub const PACK_ICON_SIZE: u32 = 128;
/// size of the largest trim texture.
pub const TRIM_SLOTS: usize = 32;
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
decode_bytes(&fs::read(path)?)
decode_bytes(&horizon_core::prepared_files::read(path)?)
}
/// A PNG in memory as RGBA pixels.
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
@@ -184,9 +184,10 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
}
/// A PNG's width and height, from its header alone.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
let mut header = [0u8; 24];
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
.ok()?
.try_into()
.ok()?;
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
.then(|| (field(16), field(20)))
@@ -365,7 +366,8 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
return None;
}
};
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let mcmeta =
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
// armour layers are twice as wide as they are tall.
@@ -463,7 +465,10 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
// Animated strips keep their first frame and animate,
// like the packed files.
if path.starts_with("block/") || path.starts_with("item/") {
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
first_frame(image, animation.as_ref())
@@ -960,7 +965,9 @@ impl HudAtlas {
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let mcmeta =
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta"))
.ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
@@ -44,7 +44,7 @@ impl ParsedModels {
}
}
let parsed = crate::par::par_map(&files, |(_, path)| {
let text = fs::read_to_string(path).ok()?;
let text = horizon_core::prepared_files::read_to_string(path).ok()?;
CuboidModel::from_json(&text).ok().map(Arc::new)
});
let models = files
@@ -89,8 +89,8 @@ impl ParsedModels {
let read = || -> Result<CuboidModel> {
let (namespace, path) =
name.split_once(':').ok_or("invalid model identifier")?;
let text = fs::read_to_string(
root.join(format!("assets/{namespace}/models/{path}.json")),
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{namespace}/models/{path}.json")),
)?;
Ok(CuboidModel::from_json(&text)?)
};
@@ -196,8 +196,8 @@ impl StateModels {
// Vanilla's BlockStateModelLoader: a definition that does not
// read or bind is logged and its block gets no model; the other
// blocks still load.
let loaded = fs::read_to_string(
root.join(format!("assets/{namespace}/blockstates/{path}.json")),
let loaded = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{namespace}/blockstates/{path}.json")),
)
.map_err(|e| e.to_string())
.and_then(|text| BlockStateDefinition::from_json(&text).map_err(|e| e.to_string()))
+14 -1
View File
@@ -126,7 +126,6 @@ impl Opt {
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
| Opt::RotateWithMinecarts
// Saved, but no Horizon system reads them yet: no Realms, no
// friends list, no directional audio, no now-playing toast.
| Opt::RealmsNotifications
@@ -937,6 +936,7 @@ impl Opt {
| Opt::RealmsNotifications
| Opt::InGameNotification
| Opt::DirectionalAudio
| Opt::RotateWithMinecarts
| Opt::NarratorHotkey => {
let key = self.file_key().unwrap();
s.file
@@ -1512,6 +1512,8 @@ pub struct OptionsUi {
/// (`update_pack_infos`) differs from the settings'.
pack_reselected: bool,
pub in_world: bool,
/// AccessibilityOptionsScreen: the loaded world's experimental feature.
pub minecart_improvements: bool,
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
@@ -1576,6 +1578,7 @@ impl Default for OptionsUi {
pack_focus: Instant::now(),
pack_reselected: false,
in_world: false,
minecart_improvements: false,
center_language: false,
graphics: Default::default(),
split_profiles: Default::default(),
@@ -3236,6 +3239,14 @@ impl OptionsUi {
if widget.list {
widget.y += previous_scroll - self.scroll;
}
if let WKind::Button {
act: Act::Cycle(Opt::RotateWithMinecarts),
active,
..
} = &mut widget.kind
{
*active = self.in_world && self.minecart_improvements;
}
}
self.row_bands = rows
.bands
@@ -3385,6 +3396,8 @@ impl OptionsUi {
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
// The controller picks a tile up between presses: the screen's size from the draw.
self.split_setup.size = (batch.width, batch.height);
self.split_setup.draw(batch, atlas, cursor, self.in_world);
return;
}
+297 -4
View File
@@ -1,5 +1,6 @@
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
use crate::{
gamepad::Button as PadButton,
graphics_style::STYLE,
hud::{HudAtlas, HudBatch, rgba},
lang,
@@ -48,12 +49,28 @@ pub struct SplitUi {
/// Names: the names in the views' corners are shown (the game sets it), and pressed.
pub names_shown: bool,
pub toggle_names: bool,
/// P1 has a controller (the game sets it), and Give Up Pad pressed: it's offered to join.
pub p1_pad: bool,
pub give_up_pad: bool,
edit: Option<Draft>,
selected: Option<usize>,
/// A tile held by the mouse: whose, and where the pointer is (`None` until it moves).
drag: Option<(usize, Option<(f32, f32)>)>,
/// The screen's size at the last press: where a drag's release lands.
size: (f32, f32),
/// A player picked up with the controller (A), moved with the bumpers.
held: Option<Held>,
/// Dropped or put back: the player whose tile the controller's focus goes to.
refocus: Option<usize>,
/// The screen's size: at the last draw (the options screen sets it) or press.
pub(crate) size: (f32, f32),
}
/// A player picked up with the controller: where it could go, the one picked so far, and the
/// draft with it there (the map shows it).
struct Held {
player: usize,
spots: Vec<(String, Drop)>,
at: Option<usize>,
preview: Option<Draft>,
}
/// Where a dragged player goes: a place (`Spot`), or a place among a window's players.
@@ -73,6 +90,8 @@ enum Target {
Identify,
/// The players' names in their views' corners, on or off.
Names,
/// P1's controller freed and offered to join (handed to a friend).
GiveUpPad,
Revert,
Apply,
Done,
@@ -124,9 +143,113 @@ impl SplitUi {
self.selected = None;
self.apply = None;
self.drag = None;
self.held = None;
}
/// The draft shown: with a held player where the controller put it, else the changes so
/// far, else the arrangement now.
fn draft(&self) -> Option<&Draft> {
self.edit.as_ref().or(self.live.as_ref())
let preview = self.held.as_ref().and_then(|h| h.preview.as_ref());
preview.or(self.edit.as_ref()).or(self.live.as_ref())
}
/// The controller (`pad_menu`), A at `at`: on a player's tile, picks it up. True when it did
/// (the press is taken).
pub fn pad_pick_up(&mut self, at: (f32, f32)) -> bool {
let (w, h) = self.size;
let hit = self
.buttons(w, h)
.into_iter()
.rev()
.find(|([x, y, bw, bh], _, _, active)| *active && ui::hovered(at, *x, *y, *bw, *bh));
let Some((_, _, Target::Player(i), _)) = hit else {
return false;
};
self.selected = Some(i);
let spots = self.stops(i);
self.held = Some(Held {
player: i,
spots,
at: None,
preview: None,
});
true
}
/// The controller while a player is held: the bumpers or left and right cycle where it goes,
/// as when joining (the map shows it there), A drops it there (into the draft), B puts it
/// back. True when the button was taken: every button, while one is held.
pub fn pad_button(&mut self, button: PadButton) -> bool {
let Some(held) = &self.held else {
return false;
};
let player = held.player;
match button {
PadButton::LeftBumper
| PadButton::RightBumper
| PadButton::DPadLeft
| PadButton::DPadRight => {
let n = held.spots.len();
if n == 0 {
return true;
}
let back = matches!(button, PadButton::LeftBumper | PadButton::DPadLeft);
let next = match held.at {
None if back => n - 1,
None => 0,
Some(k) if back => (k + n - 1) % n,
Some(k) => (k + 1) % n,
};
let (player, drop) = (held.player, held.spots[next].1.clone());
let open = self.open_now();
let preview = self.edit.clone().or(self.live.clone()).map(|mut draft| {
match drop {
Drop::At(spot) => draft.place_player(player, spot),
Drop::Place(n, k) => {
draft.place_player(player, Spot::Window(n));
draft.put_at(player, k);
}
}
draft.drop_empty(&open);
draft
});
if let Some(held) = &mut self.held {
held.at = Some(next);
held.preview = preview;
}
}
PadButton::South => {
let held = self.held.take().expect("held");
// One highlight after: the focus on the player where it landed, not selected too.
(self.selected, self.refocus) = (None, Some(held.player));
match held.at.map(|k| held.spots[k].1.clone()) {
Some(Drop::At(spot)) => self.place(held.player, spot),
Some(Drop::Place(n, k)) => self.place_at(held.player, n, k),
None => {}
}
}
PadButton::East => {
(self.selected, self.refocus) = (None, Some(player));
self.held = None;
}
// While one is held, the focus stays put: A drops, it presses nothing else.
_ => {}
}
true
}
/// Whether a player is held (the controller's left and right cycle it).
pub fn holding(&self) -> bool {
self.held.is_some()
}
/// The tile of the player just dropped or put back, for the controller's focus. Taken once.
pub fn take_refocus(&mut self) -> Option<[f32; 4]> {
let i = self.refocus.take()?;
let (w, h) = self.size;
self.buttons(w, h)
.into_iter()
.find(|b| b.2 == Target::Player(i))
.map(|b| b.0)
}
fn map(&self, w: f32, h: f32) -> split_map::Map {
if self.idle {
@@ -270,8 +393,18 @@ impl SplitUi {
Target::Switch,
!self.on || self.names.len() <= 1,
);
// P1's controller handed to a friend: it's offered to join.
let give_up = (
[90.0, 6.0, 80.0, 20.0],
tr("give_up_pad", &[]),
Target::GiveUpPad,
true,
);
if !self.on || self.idle {
let mut out = vec![switch];
if self.on && self.p1_pad {
out.push(give_up);
}
row(&mut out, h - 27.0, vec![done]);
return out;
}
@@ -329,6 +462,9 @@ impl SplitUi {
true,
);
out.extend([switch, names]);
if self.p1_pad {
out.push(give_up);
}
// A square for how 2 (or 3) players share a window, while a window has that many (drawn
// as the layout; the label is for the control socket and the tests).
let mut footer = Vec::new();
@@ -373,6 +509,7 @@ impl SplitUi {
Target::Switch => tr("help.switch", &[]),
Target::Identify => tr("help.identify", &[]),
Target::Names => tr("help.names", &[]),
Target::GiveUpPad => tr("help.give_up_pad", &[]),
Target::Revert if *active => tr("help.revert", &[]),
Target::Apply if *active => tr("help.apply", &[]),
Target::Revert | Target::Apply => tr("help.unchanged", &[]),
@@ -542,7 +679,9 @@ impl SplitUi {
([x, y, bw, bh], label, target, active): &Button,
) {
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
// While a player is held, only its tile is lit: the controller's pointer stays where
// the pick-up was, over whichever tile is there now.
let hover = *active && self.held.is_none() && ui::hovered(cursor, x, y, bw, bh);
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
if matches!(target, Target::Player(_)) {
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
@@ -571,6 +710,16 @@ impl SplitUi {
*active,
hover || selected,
);
// The held player: "< Name >", the arrows lit, for left and right.
if matches!(target, Target::Player(i) if self.held.as_ref().is_some_and(|h| h.player == *i))
{
let (half, cx) = (HudBatch::text_width(atlas, label) / 2.0, x + bw / 2.0);
let ty = (y + (bh - 8.0) / 2.0).floor();
let lit = rgba(255, 255, 85, 255);
let lw = HudBatch::text_width(atlas, "<");
batch.text(atlas, "<", (cx - half - 4.0 - lw).floor(), ty, lit, true);
batch.text(atlas, ">", (cx + half + 4.0).ceil(), ty, lit, true);
}
// A layout's square: its panes, in a 14 x 10 window; Auto a letter. Its name for the
// control socket, which lists widgets by what they say.
if glyph.is_some() {
@@ -601,7 +750,19 @@ impl SplitUi {
buttons: &[Button],
) {
let (w, h) = (batch.width, batch.height);
let held = self.held.as_ref().map(|h| {
let who = self
.names
.get(h.player)
.cloned()
.unwrap_or_else(|| tr("this_player", &[]));
match h.at {
Some(k) => tr("note.held_at", &[&who, &h.spots[k].0]),
None => tr("note.held", &[&who]),
}
});
let note = match self.selected {
_ if held.is_some() => held.unwrap_or_default(),
None => tr("note.select", &[]),
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
Some(i) => tr(
@@ -651,6 +812,10 @@ impl SplitUi {
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
self.size = (w, h);
// The mouse takes over from a player held with the controller.
if !drag {
self.held = None;
}
if drag {
if let Some((_, at)) = &mut self.drag {
*at = Some((x, y));
@@ -690,6 +855,7 @@ impl SplitUi {
}
Target::Identify => self.identify = true,
Target::Names => self.toggle_names = true,
Target::GiveUpPad => self.give_up_pad = true,
Target::Revert => self.edit = None,
Target::Apply => {
// Windows emptied in the draft close now, not as players moved.
@@ -1237,6 +1403,133 @@ mod tests {
// Alone: neither.
assert_eq!(labels(&screen(1)).count(), 0);
}
fn centre(ui: &SplitUi, label: &str) -> (f32, f32) {
let [x, y, w, h] = rect(ui, label);
(x + w / 2.0, y + h / 2.0)
}
#[test]
fn a_controller_picks_a_player_up_moves_it_and_drops_it() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
// Nothing moves until the bumpers do; then the map shows it there: the places in Window 1
// first, then a new window.
assert_eq!(ui.draft().unwrap().players, [1, 1]);
for _ in 0..3 {
assert!(ui.pad_button(PadButton::RightBumper));
}
assert_eq!(ui.draft().unwrap().players, [1, 2]);
assert_eq!(ui.edit, None);
// Right again: filling the TV; A drops it there, into the draft.
assert!(ui.pad_button(PadButton::RightBumper));
assert!(ui.pad_button(PadButton::South));
let draft = ui.edit.as_ref().unwrap();
assert_eq!(draft.players, [1, 2]);
assert_eq!(draft.windows[1].placement, Placement::Fill("TV".into()));
// Not held any more: the buttons go back to the menu.
assert!(!ui.pad_button(PadButton::RightBumper));
}
#[test]
fn a_controller_moves_a_player_to_a_place_among_the_others() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
let stops: Vec<String> = ui
.held
.as_ref()
.unwrap()
.spots
.iter()
.map(|s| s.0.clone())
.collect();
assert_eq!(
&stops[..3],
["Window 1 (1 of 2)", "Window 1 (2 of 2)", "New window"]
);
assert!(
!stops.iter().any(|s| s.starts_with("Swap")),
"no swaps: it joins"
);
// RB to the first place: before P1.
assert!(ui.pad_button(PadButton::RightBumper));
assert!(ui.pad_button(PadButton::South));
assert_eq!(ui.edit.as_ref().unwrap().order, [1, 0]);
}
#[test]
fn idle_replaces_preview_but_keeps_the_switch_and_pad_handoff() {
let mut ui = screen(1);
ui.on = true;
ui.idle = true;
ui.p1_pad = true;
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen idle");
assert!(ui.map(SIZE.0, SIZE.1).2.is_empty());
let buttons = ui.buttons(SIZE.0, SIZE.1);
assert!(buttons.iter().any(|b| b.2 == Target::GiveUpPad));
assert!(
!buttons
.iter()
.any(|b| matches!(b.2, Target::Player(_) | Target::Apply | Target::Revert))
);
press(&mut ui, "Give Up Pad");
assert!(ui.give_up_pad);
ui.idle = false;
assert!(ui.status_lines().is_none());
assert_eq!(ui.map(SIZE.0, SIZE.1).2.len(), 1);
ui.on = false;
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen is off.");
}
#[test]
fn give_up_pad_shows_only_while_p1_has_one() {
let mut ui = screen(1);
ui.on = true;
let shown = |ui: &SplitUi| {
ui.buttons(SIZE.0, SIZE.1)
.iter()
.any(|b| b.2 == Target::GiveUpPad)
};
assert!(!shown(&ui));
ui.p1_pad = true;
assert!(shown(&ui));
press(&mut ui, "Give Up Pad");
assert!(ui.give_up_pad);
}
#[test]
fn b_puts_a_held_player_back() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
ui.pad_button(PadButton::RightBumper);
assert!(ui.pad_button(PadButton::East));
assert_eq!(ui.edit, None);
assert_eq!(ui.draft().unwrap().players, [1, 1]);
}
#[test]
fn dropped_or_put_back_one_highlight_and_the_focus_follows() {
let mut ui = screen(2);
ui.size = SIZE;
assert!(ui.pad_pick_up(centre(&ui, "P2")));
assert!(ui.pad_button(PadButton::RightBumper)); // to a new window
assert!(ui.pad_button(PadButton::South));
// Not selected (the focus is its highlight), and the focus goes to its new tile.
assert_eq!(ui.selected, None);
assert_eq!(ui.take_refocus(), Some(rect(&ui, "P2")));
assert_eq!(ui.take_refocus(), None);
// Put back: the same.
assert!(ui.pad_pick_up(centre(&ui, "P2")));
assert!(ui.pad_button(PadButton::East));
assert_eq!(
(ui.selected, ui.take_refocus()),
(None, Some(rect(&ui, "P2")))
);
}
#[test]
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
// The language table is process-wide. Run this test alone in a child so
+13 -11
View File
@@ -1,13 +1,7 @@
//! Shared nearest-sampled sprite array for block and item textures.
use crate::Result;
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
use std::{
collections::BTreeMap,
fs::{self, File},
io::BufReader,
path::Path,
sync::Arc,
};
use std::{collections::BTreeMap, path::Path, sync::Arc};
#[derive(Clone)]
pub struct SpriteAnimation {
@@ -427,7 +421,9 @@ impl Sprites {
per_tile: bool,
) -> Result<Vec<Option<u32>>> {
let path = crate::atlas_sources::texture_path(root, name, "textures");
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
@@ -536,7 +532,9 @@ impl Sprites {
Self::decode_file(&path, name)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
let metadata = match horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
@@ -694,9 +692,12 @@ impl Sprites {
}
}
/// A PNG's width, height and pixels as RGBA.
/// A PNG's width, height and pixels as RGBA. From the prepared data file
/// when it holds the texture: no file opened per texture, nor for its absent
/// LabPBR maps.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
let bytes = horizon_core::prepared_files::read(path)?;
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
@@ -792,6 +793,7 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
+5 -4
View File
@@ -176,7 +176,7 @@ impl TrimTexture {
let Some(palette) = &self.palette else {
return Some((w, h, pixels));
};
let base = fs::read(path.with_extension("png.mcmeta"))
let base = horizon_core::prepared_files::read(&path.with_extension("png.mcmeta"))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|m| {
@@ -241,9 +241,10 @@ pub fn slot_size(root: &Path) -> (u32, u32) {
/// The width and height in a PNG's IHDR chunk.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
let mut head = [0u8; 24];
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
let head: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
.ok()?
.try_into()
.ok()?;
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
}
@@ -34,6 +34,7 @@
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.split.leave": "Leave",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.split_setup": "Splitscreen Setup",
@@ -362,6 +363,21 @@
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
"horizon.splitscreen.setup.give_up_pad": "Give Up Pad",
"horizon.splitscreen.prompt.controller_connected": "Controller connected",
"horizon.splitscreen.prompt.controller_disconnected": "Controller disconnected",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnect it, or press a button on another",
"horizon.splitscreen.prompt.join.one": "%s wants to join",
"horizon.splitscreen.prompt.join.many": "%s controllers want to join",
"horizon.splitscreen.prompt.join.yes": "Join",
"horizon.splitscreen.prompt.join.as_p1": "Play as P1",
"horizon.splitscreen.prompt.join.no": "No",
"horizon.splitscreen.prompt.lost": "%s: controller disconnected",
"horizon.splitscreen.prompt.lost.use_another": "Use another",
"horizon.splitscreen.prompt.lost.leave": "Leave",
"horizon.splitscreen.setup.help.give_up_pad": "Your controller stops driving you and is offered to join (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A drops it here, B puts it back.",
"horizon.splitscreen.setup.note.held": "%s picked up: left/right picks where, A drops, B puts it back.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Block Entities": "Block Entities",
@@ -347,6 +347,21 @@
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
"horizon.splitscreen.setup.give_up_pad": "Céder la manette",
"horizon.splitscreen.prompt.controller_connected": "Manette connectée",
"horizon.splitscreen.prompt.controller_disconnected": "Manette déconnectée",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Reconnectez-la, ou appuyez sur un bouton d'une autre",
"horizon.splitscreen.prompt.join.one": "%s veut rejoindre",
"horizon.splitscreen.prompt.join.many": "%s manettes veulent rejoindre",
"horizon.splitscreen.prompt.join.yes": "Rejoindre",
"horizon.splitscreen.prompt.join.as_p1": "Jouer en J1",
"horizon.splitscreen.prompt.join.no": "Non",
"horizon.splitscreen.prompt.lost": "%s : manette déconnectée",
"horizon.splitscreen.prompt.lost.use_another": "Utiliser une autre",
"horizon.splitscreen.prompt.lost.leave": "Quitter",
"horizon.splitscreen.setup.help.give_up_pad": "Votre manette ne vous contrôle plus et peut rejoindre la partie (A).",
"horizon.splitscreen.setup.note.held_at": "%s vers %s : A le dépose ici, B le remet en place.",
"horizon.splitscreen.setup.note.held": "%s est pris : gauche/droite choisit où, A dépose, B remet en place.",
"Culling": "Occlusion",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
"Leaf Culling": "Occlusion feuillage",
+11 -28
View File
@@ -104,22 +104,20 @@ impl Tree {
let Ok(namespaces) = std::fs::read_dir(data) else {
return tree;
};
let mut files = Vec::new();
for ns in namespaces.flatten() {
let root = ns.path().join("advancement");
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
}
// One JSON per id, the packs' over the game's own.
// One JSON per id, the packs' over the game's own. Through `json_folder`: from
// the prepared data file, and read once while a load holds the folder.
let mut json: BTreeMap<String, Value> = BTreeMap::new();
files.sort();
for (id, path) in files {
let Ok(text) = std::fs::read_to_string(&path) else {
for ns in namespaces.flatten() {
let Ok(folder) = horizon_world::json_folder::read(&ns.path().join("advancement"))
else {
continue;
};
let Ok(v) = serde_json::from_str::<Value>(&text) else {
continue;
};
json.insert(id, v);
let ns = ns.file_name().to_string_lossy().into_owned();
for file in &folder.files {
if let Ok(v) = serde_json::from_slice::<Value>(file.bytes().unwrap_or_default()) {
json.insert(format!("{ns}:{}", file.name), v);
}
}
}
for (id, files) in overlay.kind("advancement") {
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
@@ -249,21 +247,6 @@ impl InventoryIndex {
out
}
}
fn collect(root: &Path, dir: &Path, ns: &str, out: &mut Vec<(String, std::path::PathBuf)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
collect(root, &p, ns, out);
} else if p.extension().is_some_and(|x| x == "json") {
let rel = p.strip_prefix(root).unwrap_or(&p).with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
out.push((format!("{ns}:{rel}"), p));
}
}
}
fn parse(id: &str, v: &Value) -> Option<Advancement> {
let criteria: Vec<Criterion> = v["criteria"]
.as_object()?
+3 -2
View File
@@ -4,6 +4,7 @@
//! when the block goes, and despawning. The tick stops at an entity it hits:
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
//! spends the arrow or turns it back and ends the tick.
use crate::entity_rules::EntityRules;
use crate::{
blocks::BlockFacts,
collisions::SceneCollisions,
@@ -777,9 +778,9 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.was_touching_water {
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
}
if physics && !self.st.in_ground && !self.p.no_gravity {
if physics && !self.st.in_ground {
// Entity.applyGravity: AbstractArrow.getDefaultGravity, none with NoGravity.
self.add_velocity(-0.05);
self.add_velocity(-self.p.get_gravity());
}
// Projectile.tick, then Entity.baseTick's fluid and fire phases.
if !self.st.has_been_shot {
+1
View File
@@ -59,6 +59,7 @@ pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod entity_rules;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
+24 -5
View File
@@ -4,6 +4,7 @@
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
//! `HoneyBlock.entityInside` when falling against a honey block's side, and
//! `BubbleColumnBlock.entityInside` (lift / drag, including the surface jump).
use crate::entity_rules::EntityRules;
use crate::{
blocks::{BlockFacts, Fluid},
collisions::SceneCollisions,
@@ -135,6 +136,26 @@ impl ItemEnvironmentTick {
self.lava_hurt && (!self.alive || tick.is_multiple_of(10))
}
}
impl crate::entity_rules::EntityRules for ItemEntity {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `ItemEntity.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
impl crate::entity_rules::EntityRules for ExperienceOrb {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `ExperienceOrb.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.03
}
}
/// An armor stand keeps no `NoGravity` flag yet, so it takes the defaults.
impl crate::entity_rules::EntityRules for ArmorStand {}
impl ItemEntity {
pub const WIDTH: f64 = 0.25;
pub fn bounds(&self) -> Aabb {
@@ -263,9 +284,7 @@ impl ItemEntity {
self.velocity.z * 0.95,
);
} else {
if !self.no_gravity {
self.velocity.y -= 0.04;
}
self.velocity.y -= self.get_gravity();
}
let horizontal = self.velocity.x * self.velocity.x + self.velocity.z * self.velocity.z;
// ItemEntity.tick: skip move 3 of 4 ticks while grounded with no
@@ -856,8 +875,8 @@ impl ExperienceOrb {
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
} else if !colliding && !self.no_gravity {
self.velocity.y -= 0.03;
} else if !colliding {
self.velocity.y -= self.get_gravity();
}
if facts.get(block).fluid == Fluid::Lava {
// ExperienceOrb.tick: lava throws the orb back out.
+33
View File
@@ -0,0 +1,33 @@
//! PURPOSE: `Entity`'s gravity and fluid-push rules, decided once for every kind.
//! WHY: each kind used to check its own `NoGravity` flag and pick its own
//! number, so a kind that forgot the check ignored the flag (#6481).
//! REFS: vanilla `Entity.isNoGravity`, `getDefaultGravity`, `getGravity`,
//! `applyGravity`, `isPushedByFluid`; issue #6509, slice #6544.
//! LOGS: none. READS-BY: the kinds' ticks in this crate and in `game/`.
//! ENDS-WHEN: kinds share one stored `Entity` state and this folds into it.
/// The rules vanilla's `Entity` answers for every kind. A kind overrides one
/// only where its vanilla class does; the defaults are `Entity`'s.
pub trait EntityRules {
/// `Entity.isNoGravity` (`NoGravity` in the save).
fn is_no_gravity(&self) -> bool {
false
}
/// `Entity.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.0
}
/// `Entity.getGravity`: none with `NoGravity`, else the kind's default.
/// `applyGravity` subtracts this from the vertical velocity.
fn get_gravity(&self) -> f64 {
if self.is_no_gravity() {
0.0
} else {
self.get_default_gravity()
}
}
/// `Entity.isPushedByFluid`.
fn is_pushed_by_fluid(&self) -> bool {
true
}
}
+69 -10
View File
@@ -2610,6 +2610,9 @@ struct Game<'a> {
saving_indicator: horizon_client::hud_overlays::SavingIndicator,
/// This game's world is autosaving this tick (`MinecraftServer.isSaving`).
autosaving: bool,
/// `MinecraftServer.ticksUntilAutosave`: the ticks left before the next
/// autosave, counted down a tick at a time.
ticks_until_autosave: i64,
applied_modifiers: Vec<(String, String)>,
experience: Experience,
orbs: Vec<ExperienceOrb>,
@@ -5554,9 +5557,7 @@ impl Game<'_> {
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
if !f.no_gravity {
f.velocity.y -= 0.04;
}
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
let mut wanted = f.velocity;
// Entity.move: a cobweb it was in scales this move, which then
// starts from rest.
@@ -7298,12 +7299,19 @@ impl Game<'_> {
self.world.weather.tick_levels();
self.tick_weather_effects();
}
self.autosaving = self.ticks.is_multiple_of(6000);
// MinecraftServer.tickServer's countdown, then autoSave: three
// hundred seconds of ticks at the rate the server runs at now.
self.ticks_until_autosave -= 1;
self.autosaving = self.ticks_until_autosave <= 0;
if let Some(serving) = &self.serving {
serving.set_saving(self.autosaving);
}
if self.autosaving {
self.save_game_with(false)?;
self.ticks_until_autosave = self.next_autosave_interval();
if let Some(serving) = &self.serving {
serving.set_saving(false);
}
}
if running {
self.advance_clock();
@@ -11522,11 +11530,19 @@ impl Game<'_> {
// attack strength scales the base damage.
let enchant_bonus = self.enchanted_damage(target_kind, damage) - damage;
let mut amount = damage * (0.2 + scale * scale * 0.8);
// Player.attack decides the critical hit only for a hit with base or
// enchantment damage: checked before the mace's smash bonus is added.
let damaging = amount > 0.0 || enchant_bonus * scale > 0.0;
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
amount += self.smash_damage_bonus(target_kind);
let crit = living && self.critical_attack(scale);
let vanilla_crit = living && self.critical_attack(scale);
let mut crit = vanilla_crit;
let mut multiplier = if vanilla_crit { 1.5 } else { 1.0 };
if living && damaging {
self.native_critical_hit(target_kind, vanilla_crit, &mut crit, &mut multiplier);
}
if crit {
amount *= 1.5;
amount *= multiplier;
}
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
amount += enchant_bonus * scale;
@@ -11605,16 +11621,18 @@ impl Game<'_> {
/// strength, then strong or weak, or the sweep around the vehicle's
/// box `area`. Before the hit resets the charge (`Player.onAttack`).
pub(super) fn vehicle_attack_sounds(&mut self, area: Aabb, magic: Option<i32>) {
self.attack_effects(area, None, mace::Spared::Nothing, magic);
self.attack_effects(area, None, false, mace::Spared::Nothing, magic);
}
/// `Player.attack` after a target that is not a mob took the hit:
/// the sprint knockback sound, a living target's critical hit (`living`:
/// its entity id, which makes the hit critical when the player falls),
/// the sweep around the target's box `area` and the strong or weak sound.
/// its entity id; `crit`: whether `melee_damage` decided the hit critical,
/// mods' `critical_hit` included), the sweep around the target's box `area`
/// and the strong or weak sound.
pub(super) fn attack_effects(
&mut self,
area: Aabb,
living: Option<i32>,
crit: bool,
spared: mace::Spared,
magic: Option<i32>,
) {
@@ -11626,7 +11644,7 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
}
let crit = living.is_some() && self.critical_attack(scale);
let crit = living.is_some() && crit;
let sweep = self.sweep_attack_position(scale, crit);
if let Some(at) = sweep {
self.sweep_attack(at, from, Some((area, spared)), scale);
@@ -15325,6 +15343,25 @@ impl Game<'_> {
fn save_game(&mut self) -> Result<()> {
self.save_game_with(true)
}
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds
/// of ticks at the rate the server runs at now - its measured rate while
/// a `/tick sprint` runs - never fewer than a hundred.
fn next_autosave_interval(&self) -> i64 {
let rate = if self.clock.sprinting() {
1.0e9 / (self.clock.average_nanos() + 1) as f32
} else {
self.clock.rate()
};
autosave_interval(rate)
}
/// `MinecraftServer.onTickRateChanged`: a rate that asks for a shorter
/// wait than the one already running shortens it.
pub(super) fn on_tick_rate_changed(&mut self) {
let interval = self.next_autosave_interval();
if interval < self.ticks_until_autosave {
self.ticks_until_autosave = interval;
}
}
/// The autosave doesn't `wait` for the chunks to be on disk: every
/// generated chunk is saved, hundreds at a time, encoded and written on
/// the region writer meanwhile.
@@ -16983,6 +17020,10 @@ impl Game<'_> {
}
}
}
/// `computeNextAutosaveInterval`'s arithmetic: `Math.max(100, (int)(rate * 300.0F))`.
fn autosave_interval(rate: f32) -> i64 {
i64::from((rate * 300.0) as i32).max(100)
}
impl ApplicationHandler for Game<'_> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
@@ -18917,6 +18958,8 @@ impl<'a> Game<'a> {
tool_highlight: Default::default(),
saving_indicator: Default::default(),
autosaving: false,
// `computeNextAutosaveInterval` at the clock's 20 ticks a second.
ticks_until_autosave: 6000,
applied_modifiers: Vec::new(),
experience: Experience::default(),
orbs: Vec::new(),
@@ -21714,6 +21757,22 @@ mod tests {
use super::*;
use horizon_client::container_screen::{Button, Clicks, Release, Target};
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds of
/// ticks at the rate the server runs at, never fewer than a hundred.
#[test]
fn the_autosave_waits_for_three_hundred_seconds_of_ticks() {
// 20 ticks a second is the six thousand ticks a world autosaved on
// before the rate could be changed (`/tick rate`).
assert_eq!(autosave_interval(20.0), 6000);
assert_eq!(autosave_interval(10.0), 3000);
assert_eq!(autosave_interval(40.0), 12000);
assert_eq!(autosave_interval(1000.0), 300_000);
// `Math.max(100, ...)`: a very low rate still autosaves every hundred
// ticks, and a frozen clock (0) does too.
assert_eq!(autosave_interval(0.5), 150);
assert_eq!(autosave_interval(0.0), 100);
}
/// `NativeScreenView.id` (the `screen_render_post` hook) is a mod's
/// contract: every `Screen` has its own id, and it does not change.
/// `Screen::id`'s match makes a new variant give itself one; this list
@@ -660,11 +660,12 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
let (_, _, crit, magic) = self.melee_damage("minecraft:armor_stand", true);
let hurt = self.hit_armor_stand(i, |g, area, id| {
g.attack_effects(
area,
Some(id as i32),
crit,
super::mace::Spared::Stand(id),
magic.then_some(id as i32),
);
+7 -12
View File
@@ -2,7 +2,8 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector};
use super::{Game, InteractTarget, Keys, entity::Entity, entity_culling::at, view_vector};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
blocks::Fluid,
collisions::{EntityCollisions, SceneCollisions},
@@ -586,17 +587,10 @@ impl Game<'_> {
}
// `Entity.baseTick`: the boat is immersed and pushed by the current it
// sits in (`isPushedByFluid`), before `floatBoat` scales the velocity.
let _ = horizon_client::entities::update_entity_fluids(
b.bounds(),
b.position,
b.position.y + HEIGHT,
&mut b.velocity,
&self.world,
&self.res.motion().fluids,
);
let _ = b.push_by_fluid(&self.world, &self.res.motion().fluids);
// floatBoat
// `-getGravity()`: none with NoGravity.
let mut vspeed = if b.no_gravity { 0.0 } else { -0.04 };
let mut vspeed = 0.0 - b.get_gravity();
let mut buoyancy = 0.0;
let inv_friction: f32;
let obstacles = self.boat_move_obstacles(&b);
@@ -683,11 +677,12 @@ impl Game<'_> {
b.velocity.z += f64::from(r.cos() * acceleration);
b.paddling = [right && !left || up, left && !right || up];
}
// Entity.move: collisions zero the blocked components.
// Entity.move: collisions zero the blocked components, the world border's
// among them (`CollisionGetter.borderCollision`).
let mut scene = EntityCollisions {
blocks: scene,
entities: &obstacles,
border: None,
border: Some((self.border.geometry(), [b.position.x, b.position.z])),
};
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
b.position = Vec3::new(
+2 -1
View File
@@ -1335,7 +1335,8 @@ impl Game<'_> {
}
/// Load per-world command state when a world opens.
pub(super) fn load_world_state(&mut self) {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.rules =
GameRules::load_with_features(&self.world_root, &self.level.enabled_features);
self.ui.clock = WorldClock::default();
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = self.level.difficulty_id();
+2 -2
View File
@@ -164,7 +164,7 @@ impl Game<'_> {
if self.client_mod_command(command) {
return true;
}
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
let parse = d.parse(command, &self.ui.registries);
let result = match parse.executable() {
Err(e) => {
@@ -1128,7 +1128,7 @@ impl Game<'_> {
}
}
fn cmd_help(&mut self, ctx: &Context) -> R<i32> {
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
let level = self.ui.registries.permission;
match ctx.get("command") {
None => {
@@ -226,7 +226,7 @@ impl Game<'_> {
/// Run one command line with `source` (no error reporting): the entry
/// for functions and `execute run`.
pub(in crate::game) fn execute_line(&mut self, line: &str, source: Source) -> R<i32> {
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
let parse = d.parse(line, &self.ui.registries);
let (ctx, _) = parse.executable()?;
let ctx = ctx.clone();
@@ -81,6 +81,9 @@ impl Game<'_> {
_ => 20.0,
};
self.clock.set_rate(rate);
// MinecraftServer.setTickRate: the autosave wait already
// running is shortened when the new rate asks for less.
self.on_tick_rate_changed();
self.feedback(t(
"commands.tick.rate.success",
&[lit(format_fixed(f64::from(rate), 1))],
+47 -4
View File
@@ -1711,6 +1711,16 @@ impl Game<'_> {
self.creatures.projectiles.push(p);
self.native_projectile_launch(player, &name, id, kind);
}
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
/// one puff over a sheared mooshroom, half its `height` up, shown here and to
/// the players within range.
pub(super) fn mooshroom_shear_puff(&mut self, at: [f64; 3], height: f64) {
let at = [at[0], at[1] + 0.5 * height, at[2]];
let options = ParticleOptions::simple(ParticleType::Explosion);
self.particles
.send_particles(&options, at, [0.0; 3], 0.0, 1);
self.lan_level_particles(None, &options, at, [0.0; 3], 0.0, 1, None);
}
/// `Entity.sendBubbleColumnParticles` at the top of the column block `pos`: two
/// splashes and two bubbles over it, which the level random places, shown here
/// and to the players within range.
@@ -1751,6 +1761,13 @@ impl Game<'_> {
MobEvent::BubbleColumnParticles { pos } => {
self.bubble_column_particles(BlockPos::new(pos[0], pos[1], pos[2]))
}
MobEvent::LandingDust { state, at, count } => self.landing_dust(
None,
state,
Vec3::new(at[0], at[1], at[2]),
count,
f64::from(0.15_f32),
),
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Drown { mob, at } => {
@@ -1781,6 +1798,19 @@ impl Game<'_> {
self.lan_level_particles(None, &options, at, spread, speed, count, None);
}
}
// The LAN packet has no item for a particle yet: the host's window only.
MobEvent::ItemParticles {
item,
at,
spread,
speed,
count,
} => {
let options =
self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
self.particles
.send_particles(&options, at, spread, speed, count);
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -5145,6 +5175,8 @@ impl Game<'_> {
// `Some(true)` for the four spell particles, `Some(false)` for the two
// smoke puffs of a cow that already holds effects.
let mut stew_particles: Option<bool> = None;
// `MushroomCow.shear`: where, and how tall, the mooshroom that was sheared stood.
let mut shear_puff: Option<([f64; 3], f64)> = None;
// `Ocelot.mobInteract`: whether the fish won its trust.
let mut ocelot_trust: Option<bool> = None;
match species {
@@ -5188,6 +5220,10 @@ impl Game<'_> {
"minecraft:red_mushroom"
};
drops.push((mushroom.into(), 5));
shear_puff = Some((
[m.position.x, m.position.y, m.position.z],
f64::from(m.height()),
));
m.removed = true;
let setup = SpawnSetup {
yaw: m.yaw,
@@ -5733,6 +5769,9 @@ impl Game<'_> {
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
if let Some((at, height)) = shear_puff {
self.mooshroom_shear_puff(at, height);
}
let mut drops: Vec<ItemStack> = drops
.into_iter()
.map(|(item, count)| ItemStack::new(&item, count))
@@ -6458,7 +6497,9 @@ impl Game<'_> {
v.layer |= u32::from(tier) << 16;
}
}
if is_player {
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
@@ -6479,9 +6520,11 @@ impl Game<'_> {
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
self.mob_block_geometry(mob, camera, alpha, packed, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
if layers {
self.mob_block_geometry(mob, camera, alpha, packed, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
@@ -637,6 +637,8 @@ struct ServerState {
max_players: usize,
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
game_mode: i32,
/// The mutable `force-gamemode` server property (`DedicatedServer.forceGameMode`).
force_game_mode: bool,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -1005,6 +1007,7 @@ impl Game<'_> {
broadcast_rcon: props.broadcast_rcon_to_ops,
op_level: props.op_permission_level,
game_mode: props.game_mode,
force_game_mode: props.force_game_mode,
report: None,
view: (
props.view_distance.clamp(2, 32) as u32,
@@ -89,6 +89,22 @@ impl Game<'_> {
self.set_default_game_mode(state.game_mode);
return json!({ "value": GAME_MODES[state.game_mode as usize] });
}
"serversettings/force_game_mode" => {
return json!({ "value": state.force_game_mode });
}
"serversettings/force_game_mode/set" => {
let Some(force) = body["value"].as_bool() else {
return json!({ "error": "force game mode must be a boolean" });
};
rpc(format!(
"Update force game mode from {} to {}",
state.force_game_mode, force
));
self.store_property("force-gamemode", force.to_string());
state.force_game_mode = force;
self.set_force_game_mode(force);
return json!({ "value": state.force_game_mode });
}
"serversettings/difficulty" => {
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
@@ -1951,6 +1951,7 @@ impl Game<'_> {
.play("minecraft:entity.mooshroom.shear", sound_at, 1.0, 1.0);
// convertTo(COW): the cow takes its place, then the mushrooms drop.
self.convert_mob(i, "minecraft:cow");
self.mooshroom_shear_puff([at.x, at.y, at.z], height);
let drops = self.sequence_loot(
"minecraft:shearing/mooshroom",
&format!("minecraft:shearing/mooshroom/{variant}"),
+3 -1
View File
@@ -1389,7 +1389,7 @@ impl Game<'_> {
let hurt = self.hit_special(id, amount, true, DamageKind::PlayerAttack, None);
// The sweep and the sounds read the charge, which the hit then spends.
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
self.attack_effects(area, None, false, super::mace::Spared::Nothing, None);
}
self.attack_ticks = 0;
true
@@ -3304,6 +3304,7 @@ impl Game<'_> {
head_yaw: d.yaw,
on_ground: false,
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
minecart_steps: None,
data: 0,
meta,
defaults: Vec::new(),
@@ -3316,6 +3317,7 @@ impl Game<'_> {
leash: None,
spectator: false,
watching: false,
hidden: None,
})
}
}
+98 -10
View File
@@ -13,6 +13,7 @@ use super::{
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
};
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
use horizon_client::entity_rules::EntityRules;
use horizon_client::mobs::{Mob, Species};
use horizon_client::projectiles::Projectile;
use horizon_core::geometry::{Aabb, Direction, Vec3};
@@ -47,7 +48,7 @@ pub(super) struct Blast {
pub(super) spared: Option<u32>,
}
pub(super) trait Entity {
pub(super) trait Entity: EntityRules {
/// `Entity.getId`.
fn id(&self) -> u32;
fn position(&self) -> Vec3;
@@ -134,6 +135,51 @@ pub(super) trait Entity {
fn is_supported_by(&self, _pos: BlockPos) -> bool {
false
}
/// `Entity.getEyeY`, where `updateFluidHeightAndDoFluidPushing` reads the
/// eye's fluid; the top of the box unless a kind sets it.
fn fluid_eye_y(&self) -> f64 {
self.bounds().max_position().y
}
/// `Entity.updateFluidHeightAndDoFluidPushing`: its water and lava heights
/// (`(0, 0)` for a kind that is not pushed by fluid), and the current of
/// each fluid added to its velocity. One body for every kind.
fn push_by_fluid(
&mut self,
world: &horizon_client::world::World,
fluids: &horizon_client::motion::FluidResources,
) -> (f64, f64) {
if !self.is_pushed_by_fluid() {
return (0.0, 0.0);
}
let (bounds, at, eye) = (self.bounds(), self.position(), self.fluid_eye_y());
horizon_client::entities::update_entity_fluids(
bounds,
at,
eye,
self.velocity_mut(),
world,
fluids,
)
}
/// `BubbleColumnBlock.entityInside` through `applyEffectsFromBlocks`: a
/// column lifts or drags every kind alike.
fn push_by_bubble_column(
&mut self,
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
facts: &horizon_client::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
) -> bool {
let bounds = self.bounds();
horizon_client::motion::bubble_column_push(
world,
registry,
facts,
collision,
bounds,
self.velocity_mut(),
)
}
/// `Entity.getPistonPushReaction`.
fn get_piston_push_reaction(&self) -> PushReaction {
PushReaction::PushPull
@@ -396,16 +442,11 @@ impl Entity for Mob {
|| (self.species() == Species::Warden
&& horizon_client::mobs::warden::digging_or_emerging(self))
}
/// The kind's own box, not the baby's or the slime's.
fn explosion_box(&self) -> Aabb {
let (feet, half) = (self.position, self.kind.width / 2.0);
Aabb::new(
Vec3::new(feet.x - half, feet.y, feet.z - half),
Vec3::new(feet.x + half, feet.y + self.kind.height, feet.z + half),
)
}
/// `getEyeHeight` of the mob as it stands: a baby's, a slime's or a
/// phantom's own scaled eye height, not the adult kind's. The blast's box
/// is its real `bounds`, so it needs no override.
fn explosion_eye_height(&self) -> f64 {
self.kind.eye_height
self.eye_height()
}
/// `Entity.push` sets `needsSync`.
fn push_from_explosion(&mut self, push: Vec3) {
@@ -505,6 +546,10 @@ impl Entity for ExperienceOrb {
impl Entity for Boat {
stored_motion!();
/// `Boat.getEyeY`: the top of the 0.5625 box.
fn fluid_eye_y(&self) -> f64 {
self.position.y + super::boats::HEIGHT
}
fn portal_size(&self) -> Option<(f32, f32)> {
Some((super::boats::WIDTH as f32, super::boats::HEIGHT as f32))
}
@@ -636,6 +681,10 @@ impl Entity for ArmorStand {
impl Entity for PrimedTnt {
stored_motion!();
/// `PrimedTnt` eye height: `0.98 * 0.85`.
fn fluid_eye_y(&self) -> f64 {
self.position.y + f64::from(0.98f32 * 0.85f32)
}
/// `PrimedTnt` is 0.98 across.
fn portal_size(&self) -> Option<(f32, f32)> {
Some((0.98, 0.98))
@@ -787,3 +836,42 @@ impl Entity for ThrownTrident {
false
}
}
impl EntityRules for Rocket {}
impl EntityRules for ThrownTrident {}
impl EntityRules for Boat {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `AbstractBoat.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
impl EntityRules for Minecart {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `AbstractMinecart.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
if self.in_water { 0.005 } else { 0.04 }
}
}
impl EntityRules for PrimedTnt {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `PrimedTnt.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
impl EntityRules for FallingBlock {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// `FallingBlockEntity.getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
0.04
}
}
@@ -1363,4 +1363,38 @@ mod tests {
add_or_append_stack(&mut stacks, pick(5), [0, 0, 0], &max);
assert_eq!(stacks.len(), 2);
}
/// A blast and a burst of wind cast their rays at, and push from the eyes of,
/// the mob as it stands: a baby's and a size-4 slime's own box and eye
/// height, not the adult kind's (`Entity.getBoundingBox`, `getEyeHeight`).
#[test]
fn a_scaled_mob_is_blasted_by_its_own_box_and_eye_height() {
use crate::game::entity::Entity;
use horizon_client::mobs::{Mob, Species};
let mob = |species| {
let mut kind = (*zombie_type()).clone();
kind.species = species;
Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0)
};
let adult = mob(Species::Zombie);
assert_eq!(adult.explosion_box(), adult.bounds());
let mut baby = mob(Species::Zombie);
baby.extra.age = -24000;
let mut slime = mob(Species::Slime);
slime.extra.size = 4;
for m in [&baby, &slime] {
let b = Entity::explosion_box(m);
assert_eq!(b, m.bounds());
assert_eq!(Entity::explosion_eye_height(m), m.eye_height());
assert_ne!(
f64::from(m.height()),
f64::from(adult.height()),
"the case must scale the box"
);
assert_eq!(
b.max_position().y - b.min_position().y,
f64::from(m.height())
);
}
}
}
+18 -1
View File
@@ -245,7 +245,9 @@ impl Game<'_> {
}
/// `FireworkRocketItem.useOn`: launch from the clicked point.
pub(super) fn launch_firework(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:firework_rocket" {
if &*stack.item != "minecraft:firework_rocket"
|| use_on_passes(self.player.as_ref().is_some_and(|p| p.fall_flying))
{
return false;
}
let at = Vec3::new(
@@ -716,3 +718,18 @@ impl Game<'_> {
}
}
}
/// `FireworkRocketItem.useOn` returns PASS while the player is fall flying,
/// leaving the boost to `use` (a guest's UseItemOn must not launch a rocket).
fn use_on_passes(fall_flying: bool) -> bool {
fall_flying
}
#[cfg(test)]
mod tests {
#[test]
fn use_on_passes_only_while_gliding() {
assert!(super::use_on_passes(true));
assert!(!super::use_on_passes(false));
}
}
+106 -32
View File
@@ -81,6 +81,7 @@ pub(super) fn reload_sync_data(
let packs = !overlay.files.is_empty() || kept.has_pack_entries();
let sync = SyncData::load(root, overlay).map(|mut sync| {
sync.keep_registries(root, kept);
sync.features.clone_from(&kept.features);
Arc::new(sync)
});
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = Some((packs, sync.clone()));
@@ -1286,7 +1287,21 @@ impl Game<'_> {
config: HubConfig,
host: Option<u128>,
) -> Result<()> {
let sync = sync_data(&self.options.resources, &self.pack_files())?;
let mut sync = sync_data(&self.options.resources, &self.pack_files())?;
let features = if self.level.enabled_features.is_empty() {
vec!["minecraft:vanilla".to_owned()]
} else {
self.level.enabled_features.clone()
};
if sync.features.iter().any(|f| !features.contains(f))
|| features.iter().any(|f| !sync.features.contains(f))
{
// The shared resource preload has vanilla flags. Feature flags belong
// to this world, not to the cached resources or the next world opened.
let mut world_sync = SyncData::load(&self.options.resources, &self.pack_files())?;
world_sync.features = features;
sync = Arc::new(world_sync);
}
self.sync_dialog_ids(&sync);
// The table the world plays with (`world_biomes!`): a world with
// biome data packs sends their ids past the built-in list, as the
@@ -1695,6 +1710,15 @@ impl Game<'_> {
}
}
/// Apply the default mode to connected players when forcing it is enabled.
pub(super) fn set_force_game_mode(&mut self, force: bool) {
if let Some(host) = self.lan.as_mut() {
host.settings.force_game_mode = force;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
}
}
/// New command access and game mode forcing settings of the open world.
pub(super) fn lan_apply_settings(&mut self, guest_commands: bool, force: bool) {
if let Some(server) = &self.integrated {
@@ -1706,11 +1730,9 @@ impl Game<'_> {
let effective = guest_commands && (!self.serving() || self.allow_commands());
if let Some(host) = self.lan.as_mut() {
host.settings.guest_commands = guest_commands;
host.settings.force_game_mode = force;
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
}
self.set_force_game_mode(force);
self.resend_commands_filtered();
}
@@ -1773,11 +1795,12 @@ impl Game<'_> {
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
fn lan_entities(
&self,
&mut self,
host_entity: Option<i32>,
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
hub: &mut Hub,
out: &mut States,
drain_minecart_steps: bool,
) {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
@@ -1843,7 +1866,7 @@ impl Game<'_> {
e.meta
.push((8, serializer::INT, Value::Int(o.value as i32)));
}
self.lan_object_states(host_entity, hub, out);
self.lan_object_states(host_entity, hub, out, drain_minecart_steps);
}
/// Apply gameplay events produced while the hub handles guest packets.
@@ -2191,11 +2214,22 @@ impl Game<'_> {
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
self.lan_entities(
host_entity,
&host.mob_players,
&mut host.hub,
&mut states,
true,
);
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
} else {
states.clear();
// Nobody can receive past movement. A returning viewer pairs
// from the cart's current position/rotation, as AddEntity does.
for cart in &mut self.carts {
cart.lerp_steps.clear();
}
}
// ServerEntity.sendChanges clears needsSync once it has looked.
for m in &mut self.mobs {
@@ -2430,7 +2464,7 @@ impl Game<'_> {
return;
}
// A dedicated server has no host player.
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs, false);
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
self.lan_entity_uuids(&mut theirs);
theirs.as_slice()
@@ -2566,6 +2600,12 @@ impl Game<'_> {
EquipmentSlot::Body => 65,
EquipmentSlot::Saddle => 68,
};
// The mods hear a server player's break (a creative player's gear takes no wear).
if !self.creative
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_destroy_item(uuid, &name, &stack.item, equipment_slot_name(slot));
}
let Some(at) = self
.player
.as_ref()
@@ -3021,30 +3061,33 @@ impl Game<'_> {
),
out,
);
self.player_elytra_geometry(
m,
m.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
if let Some(g) = host.hub.guest(*conn) {
self.player_ears_geometry(m, &g.name, camera, alpha as f32, packed, out);
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
if horizon_client::mobs::render::layers_shown(m) {
self.player_elytra_geometry(
m,
m.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
if let Some(g) = host.hub.guest(*conn) {
self.player_ears_geometry(m, &g.name, camera, alpha as f32, packed, out);
}
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
if let Some(g) = host.hub.guest(*conn) {
self.stuck_in_body_geometry(m, g.entity_id, camera, alpha as f32, packed, out);
}
self.held_item_geometry(m, camera, alpha, packed, true, out);
self.player_pumpkin_geometry(
m,
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
camera,
alpha,
packed,
out,
);
}
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
if let Some(g) = host.hub.guest(*conn) {
self.stuck_in_body_geometry(m, g.entity_id, camera, alpha as f32, packed, out);
}
self.held_item_geometry(m, camera, alpha, packed, true, out);
self.player_pumpkin_geometry(
m,
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
camera,
alpha,
packed,
out,
);
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
let guest = host.hub.guest(*conn);
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
@@ -3097,9 +3140,25 @@ pub(super) fn living_flags(using: Option<(usize, u32)>) -> i8 {
using.map_or(0, |(slot, _)| if slot == OFFHAND_SLOT { 3 } else { 1 })
}
/// The name a mod hears for an equipment slot (the armor stand hook's names, plus
/// the two slots a mount's gear uses).
fn equipment_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
use horizon_world::player_inventory::EquipmentSlot;
match slot {
EquipmentSlot::MainHand => "mainhand",
EquipmentSlot::OffHand => "offhand",
EquipmentSlot::Head => "head",
EquipmentSlot::Chest => "chest",
EquipmentSlot::Legs => "legs",
EquipmentSlot::Feet => "feet",
EquipmentSlot::Body => "body",
EquipmentSlot::Saddle => "saddle",
}
}
#[cfg(test)]
mod tests {
use super::{OFFHAND_SLOT, living_flags};
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags};
/// `LivingEntity.startUsingItem` sets flag 1 for the use and flag 2 for
/// the off hand, so an off-hand use is 3, not 2. A reader takes bit 0 as
@@ -3110,4 +3169,19 @@ mod tests {
assert_eq!(living_flags(Some((0, 12))), 1);
assert_eq!(living_flags(Some((OFFHAND_SLOT, 12))), 3);
}
/// The names a mod matches on for a broken item's slot (a contract).
#[test]
fn a_broken_items_slot_is_named_for_mods() {
use horizon_world::player_inventory::EquipmentSlot::*;
let named: Vec<_> = [MainHand, OffHand, Head, Chest, Legs, Feet, Body, Saddle]
.map(equipment_slot_name)
.to_vec();
assert_eq!(
named,
[
"mainhand", "offhand", "head", "chest", "legs", "feet", "body", "saddle"
]
);
}
}
@@ -2384,8 +2384,10 @@ impl Game<'_> {
host.hub.send_to(
conn,
&C::GameRuleValues(play::GameRuleValues {
entries: horizon_client::gamerules::RULES
.iter()
entries: self
.ui
.rules
.available_rules()
.map(|r| play::GameRuleEntry {
name: format!("minecraft:{}", r.name),
value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(),
@@ -3310,7 +3312,7 @@ impl Game<'_> {
};
let skip = usize::from(text.starts_with('/'));
let command = &text[skip..];
let d = vanilla::game_dispatcher();
let d = vanilla::game_dispatcher_with_features(&self.level.enabled_features);
// The completions come from the server's players and lists, not from the host's own registries: a
// dedicated server has no local player (they would list "Player"), and a LAN world's guests are not in
// the host's list.
+5 -3
View File
@@ -21,7 +21,8 @@ impl Game<'_> {
/// CommandDispatcher.parse with the guest's permission level.
fn parse_as(&mut self, permission: u8, command: &str) -> ParseResults {
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
let parse = vanilla::game_dispatcher().parse(command, &self.ui.registries);
let parse = vanilla::game_dispatcher_with_features(&self.level.enabled_features)
.parse(command, &self.ui.registries);
self.ui.registries.permission = level;
parse
}
@@ -33,8 +34,9 @@ impl Game<'_> {
permission: u8,
command: &str,
) -> Vec<(String, String)> {
self.parse_as(permission, command)
.signable_arguments(vanilla::game_dispatcher())
self.parse_as(permission, command).signable_arguments(
vanilla::game_dispatcher_with_features(&self.level.enabled_features),
)
}
/// ServerGamePacketListenerImpl.detectRateSpam: a chat message or command adds 20 to the
+16 -2
View File
@@ -40,6 +40,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
head_yaw: 0.0,
on_ground,
velocity: [0.0; 3],
minecart_steps: None,
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
@@ -52,6 +53,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
leash: None,
spectator: false,
watching: false,
hidden: None,
}
}
@@ -155,10 +157,11 @@ impl Game<'_> {
/// Boats, minecarts, armor stands, item frames, paintings and the
/// entities kept unsimulated, as guests track them.
pub(super) fn lan_object_states(
&self,
&mut self,
host_entity: Option<i32>,
hub: &mut Hub,
out: &mut States,
drain_minecart_steps: bool,
) {
let guests: Vec<(u128, i32)> = hub
.guests
@@ -193,10 +196,21 @@ impl Game<'_> {
e.defaults.push((13, serializer::INT, Value::Int(0)));
out.push(e);
}
for c in &self.carts {
let improvements = self.minecart_improvements();
for c in &mut self.carts {
let p = c.position;
let mut e = state(c.id, c.kind.id(), [p.x, p.y, p.z], c.yaw, c.on_ground());
e.pitch = c.pitch;
e.velocity = [c.velocity.x, c.velocity.y, c.velocity.z];
if improvements {
// Joining another dimension only takes a spawn snapshot; its
// regular tracking tick still owns the unsent steps.
e.minecart_steps = Some(if drain_minecart_steps {
std::mem::take(&mut c.lerp_steps)
} else {
Vec::new()
});
}
e.meta = vehicle_meta(c.hurt_time, c.hurt_dir, c.damage);
match c.kind {
// MinecartFurnace.DATA_ID_FUEL.
+11 -1
View File
@@ -561,7 +561,17 @@ impl Game<'_> {
else {
return;
};
let mut rules = horizon_client::gamerules::GameRules::default();
// The response itself lists the server's available rules; the client
// may not own this world's level.dat (including on the server thread).
let features = if entries.iter().any(|e| {
horizon_client::gamerules::rule(&e.name)
.is_some_and(|r| r.name == horizon_client::gamerules::MAX_MINECART_SPEED.name)
}) {
vec![horizon_client::gamerules::MINECART_IMPROVEMENTS.to_owned()]
} else {
Vec::new()
};
let mut rules = horizon_client::gamerules::GameRules::with_features(&features);
for entry in entries {
rules.set_string(&entry.name, &entry.value);
}
+319 -97
View File
@@ -4,7 +4,8 @@
//! `MinecartItem` placement, `VehicleEntity` breaking, the chest, hopper,
//! furnace and TNT variants, saving, and `AbstractMinecartRenderer`'s old
//! rail-following render with the display block.
use super::{Game, Hit, InteractTarget, Keys};
use super::{Game, Hit, InteractTarget, Keys, entity::Entity};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
@@ -99,7 +100,17 @@ pub(super) struct Minecart {
pub velocity: Vec3,
pub yaw: f32,
pub previous_yaw: f32,
pub pitch: f32,
pub previous_pitch: f32,
/// Minecart's client-only accumulated turn and the passenger's eased turn.
pub rotation_offset: f32,
pub player_rotation_offset: f32,
pub passenger_yaw_delta: f32,
/// Experimental track steps, drained by the server entity tracker.
pub lerp_steps: Vec<horizon_protocol::packets::play::MinecartStep>,
pub flipped: bool,
/// `AbstractMinecart.onRails`: transitions into rails need a position snap.
on_rails: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
/// The horizontal speed the cart wanted when the collision stopped it:
@@ -270,6 +281,33 @@ pub(super) struct Rail {
powered: bool,
}
impl Minecart {
/// `Minecart.tick`: stationary rotation does not turn the passenger.
pub(super) fn accumulate_rotation(&mut self) {
self.passenger_yaw_delta = 0.0;
if self.kind == CartKind::Rideable && self.position.subtract(self.previous).length() > 0.01
{
self.rotation_offset +=
((f64::from(self.yaw) - f64::from(self.previous_yaw)) % 360.0) as f32;
self.rotation_offset %= 360.0;
}
}
/// `Minecart.positionRider`: double rotLerp, then round back to float.
/// Only yaw follows; the player's pitch and manual look remain their own.
pub(super) fn rotate_passenger(&mut self, yaw: &mut f32) {
let mut delta =
(f64::from(self.rotation_offset) - f64::from(self.player_rotation_offset)) % 360.0;
if delta >= 180.0 {
delta -= 360.0;
}
if delta < -180.0 {
delta += 360.0;
}
let next = (f64::from(self.player_rotation_offset) + 0.5 * delta) as f32;
let previous = *yaw;
*yaw -= next - self.player_rotation_offset;
self.passenger_yaw_delta = *yaw - previous;
self.player_rotation_offset = next;
}
/// `Entity.onGround`.
pub(super) fn on_ground(&self) -> bool {
self.on_ground
@@ -283,7 +321,14 @@ impl Minecart {
velocity: Vec3::ZERO,
yaw: 0.0,
previous_yaw: 0.0,
pitch: 0.0,
previous_pitch: 0.0,
rotation_offset: 0.0,
player_rotation_offset: 0.0,
passenger_yaw_delta: 0.0,
lerp_steps: Vec::new(),
flipped: false,
on_rails: false,
on_ground: false,
horizontal_collision: false,
collision_speed: 0.0,
@@ -379,8 +424,13 @@ impl Game<'_> {
/// `getCurrentBlockPosOrRailBelow`.
fn rail_block(&self, p: Vec3) -> [i32; 3] {
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if self.rail(x, y - 1, z).is_some() {
[x, y - 1, z]
let below = if self.minecart_improvements() {
(p.y - 0.1 - f64::from(1.0e-5_f32)).floor() as i32
} else {
y - 1
};
if self.rail(x, below, z).is_some() {
[x, below, z]
} else {
[x, y, z]
}
@@ -539,9 +589,11 @@ impl Game<'_> {
c.fall_distance = 0.0;
}
}
// Entity.move ends with applyEffectsFromBlocks: a bubble column lifts
// or drags the cart (tick_cart runs it a second time, like vanilla).
self.cart_block_effects(c);
// OldMinecartBehavior applies block effects here and at tick end.
// NewMinecartBehavior applies them after recording each track step.
if !self.minecart_improvements() {
self.cart_block_effects(c);
}
}
fn max_speed(&self, c: &Minecart) -> f64 {
let base = if c.in_water { 0.2 } else { 0.4 };
@@ -712,23 +764,26 @@ impl Game<'_> {
}
/// `AbstractMinecart`: `minecart_improvements` picks the smoother
/// physics.
fn minecart_improvements(&self) -> bool {
self.level
.enabled_features
pub(super) fn minecart_improvements(&self) -> bool {
self.remote
.as_ref()
.map_or(&self.level.enabled_features, |remote| &remote.features)
.iter()
.any(|f| f == "minecraft:minecart_improvements")
}
/// `NewMinecartBehavior.getMaxSpeed`: the game rule over water's half,
/// every cart alike (the old behavior's furnace bonus is its own).
fn max_speed_new(&self, c: &Minecart) -> f64 {
if c.in_water { 0.2 } else { 0.4 }
f64::from(self.ui.rules.int("max_minecart_speed")) * if c.in_water { 0.5 } else { 1.0 }
/ 20.0
}
/// `AbstractMinecart.getRedstoneDirection` (over the old port's
/// conductor proxy): which way a powered rail pushes a resting cart.
fn redstone_direction(&self, pos: [i32; 3], shape: Shape) -> Vec3 {
let (dx, dz) = match shape {
Shape::NorthSouth | Shape::AscendingNorth | Shape::AscendingSouth => (0, 1),
_ => (1, 0),
Shape::NorthSouth => (0, 1),
Shape::EastWest => (1, 0),
_ => return Vec3::ZERO,
};
// The push points away from the signal's side (getRedstoneDirection).
if self.conductor(pos[0] - dx, pos[1], pos[2] - dz) {
@@ -750,12 +805,110 @@ impl Game<'_> {
_ => false,
}
}
/// `NewMinecartBehavior.setRotation`: preserve the cart's facing when
/// motion reverses, including the pitch sign on a slope.
fn set_cart_rotation(c: &mut Minecart, mut yaw: f32, mut pitch: f32) {
if (175.0..=185.0).contains(&f64::from((yaw - c.yaw).abs())) {
c.flipped = !c.flipped;
yaw -= 180.0;
pitch = -pitch;
}
c.yaw = yaw % 360.0;
c.pitch = pitch.clamp(-45.0, 45.0) % 360.0;
}
/// `NewMinecartBehavior.MinecartStep`: wire rotations use ROTATION_BYTE.
fn record_cart_step(c: &mut Minecart, weight: f32) {
use horizon_protocol::buf::pack_degrees;
c.lerp_steps
.push(horizon_protocol::packets::play::MinecartStep {
position: c.position.components(),
movement: c.velocity.components(),
yaw: pack_degrees(c.yaw),
pitch: pack_degrees(c.pitch),
weight,
});
}
/// `NewMinecartBehavior.moveAlongTrack`: each step has its own rotation
/// and interpolation weight, including the final off-rail move.
fn finish_cart_step(&self, c: &mut Minecart, old: Vec3, initial: Vec3) {
let delta = old.vector_to(c.position);
let length = delta.length();
if length > f64::from(1.0e-5_f32) {
if delta.horizontal_distance_squared() > f64::from(1.0e-5_f32) {
let yaw = 180.0 - java_degrees(delta.z, delta.x) as f32
+ if c.flipped { 180.0 } else { 0.0 };
let pitch = if c.on_ground && !c.on_rails {
0.0
} else {
(90.0 - java_degrees(delta.horizontal_distance(), delta.y) as f32)
* if c.flipped { -1.0 } else { 1.0 }
};
Self::set_cart_rotation(c, yaw, pitch);
} else if !c.on_rails {
c.pitch = if c.on_ground {
0.0
} else {
c.pitch + 0.2 * ((-c.pitch + 180.0).rem_euclid(360.0) - 180.0)
};
}
Self::record_cart_step(c, length.min(self.max_speed_new(c)) as f32);
} else if initial.horizontal_distance_squared() > 0.0 {
Self::record_cart_step(c, 1.0);
}
}
/// `NewMinecartBehavior.adjustToRails`: snap only on the first tick or
/// when entering rails; curves project onto the segment between exits.
fn adjust_cart_to_rails(c: &mut Minecart, pos: [i32; 3], shape: Shape) {
let (e0, e1) = shape.exits();
let exit =
|e: [i32; 3]| Vec3::new(f64::from(e[0]), f64::from(e[1]), f64::from(e[2])).scale(0.5);
let old = c.position;
let (exit0, exit1) = (exit(e0), exit(e1));
let center = Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
);
let mut out = Vec3::new(exit0.x, 0.0, exit0.z);
let mut into = Vec3::new(exit1.x, 0.0, exit1.z);
if c.velocity.length() > f64::from(1.0e-5_f32) && c.velocity.dot(out) < c.velocity.dot(into)
|| Self::is_descending(into, shape)
{
std::mem::swap(&mut out, &mut into);
}
let mut yaw = 180.0 - java_degrees(out.z, out.x) as f32;
if exit0.x != exit1.x && exit0.z != exit1.z {
let along = exit1.subtract(exit0);
let from = c.position.subtract(center).subtract(exit0);
let projected = along.scale(along.dot(from) / along.dot(along));
c.position = center.add_vector(exit0).add_vector(projected);
yaw = 180.0 - java_degrees(projected.z, projected.x) as f32;
} else {
if exit0.z != exit1.z {
c.position.x = center.x;
}
if exit0.x != exit1.x {
c.position.z = center.z;
}
c.position.y = center.y;
}
let pitch = if shape.is_slope() {
c.position.y += center.add_vector(into).distance(c.position) + 0.1;
if c.flipped { 45.0 } else { -45.0 }
} else {
c.position.y += 0.1;
0.0
};
Self::set_cart_rotation(c, yaw + if c.flipped { 180.0 } else { 0.0 }, pitch);
let distance = old.distance(c.position);
if distance > 0.0 {
Self::record_cart_step(c, if c.first_tick { 0.0 } else { distance as f32 });
}
}
/// `NewMinecartBehavior.moveAlongTrack`: the iteration keeps moving
/// while distance is left, over as many rails as it takes, the speed
/// recomputed per rail (slope, halt, boost).
fn move_along_track_new(&mut self, c: &mut Minecart, _rail: &Rail, intent: [f64; 2]) {
c.fall_distance = 0.0;
let ridden = self.cart_is_vehicle(c.id);
fn move_along_track_new(&mut self, c: &mut Minecart, intent: [f64; 2]) {
// `TrackIteration`: the slope, halt and boost speeds each apply once
// per tick, and the iteration keeps moving while distance is left.
let mut first = true;
@@ -763,27 +916,38 @@ impl Game<'_> {
let mut slope_done = false;
let mut halted = false;
let mut boosted = false;
for _ in 0..16 {
if !first && movement_left <= 1.0e-5 {
break;
}
let mut step_start = c.previous;
while first || movement_left > f64::from(1.0e-5_f32) {
let initial = c.velocity;
let pos = self.rail_block(c.position);
let Some(rail) = self.rail(pos[0], pos[1], pos[2]) else {
c.on_rails = false;
self.come_off_track(c);
self.finish_cart_step(c, step_start, initial);
if first || c.position.distance(step_start) > f64::from(1.0e-5_f32) {
self.cart_block_effects(c);
self.cart_block_effects(c);
}
return;
};
let initial_h = c.velocity.x.hypot(c.velocity.z);
let mut v = c.velocity;
if !c.on_rails {
c.on_rails = true;
Self::adjust_cart_to_rails(c, pos, rail.shape);
}
c.fall_distance = 0.0;
step_start = c.position;
if &*rail.name == "minecraft:activator_rail" {
self.activate_cart(c, rail.powered);
}
let ridden = self.cart_is_vehicle(c.id);
let initial_h = c.velocity.horizontal_distance();
let mut v = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
// calculateSlopeSpeed (once per tick): the slide grows with the
// speed, so a descending cart keeps its pace.
if !slope_done {
let base: f64 = if c.in_water {
0.0078125 * 0.2
} else {
0.0078125
};
let slide = base.max(initial_h * 0.02);
let before = v.x.hypot(v.z);
let slide =
0.0078125_f64.max(initial_h * 0.02) * if c.in_water { 0.2 } else { 1.0 };
let before = v.horizontal_distance_squared();
match rail.shape {
Shape::AscendingEast => v.x -= slide,
Shape::AscendingWest => v.x += slide,
@@ -791,7 +955,10 @@ impl Game<'_> {
Shape::AscendingSouth => v.z -= slide,
_ => {}
}
slope_done = v.x.hypot(v.z) != before;
slope_done = v.horizontal_distance_squared() != before;
if !slope_done {
v = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
}
}
// calculatePlayerInputSpeed (first iteration).
if first
@@ -799,10 +966,14 @@ impl Game<'_> {
&& (intent[0] != 0.0 || intent[1] != 0.0)
&& v.x * v.x + v.z * v.z < 0.01
{
let l = (intent[0] * intent[0] + intent[1] * intent[1]).sqrt();
v.x += intent[0] / l * 0.001;
v.z += intent[1] / l * 0.001;
halted = true;
let input = Vec3::new(intent[0], 0.0, intent[1])
.normalize()
.scale(0.001);
let pushed = v.add_vector(input);
if pushed.horizontal_distance_squared() != v.horizontal_distance_squared() {
v = pushed;
halted = true;
}
}
// calculateHaltTrackSpeed (until the input halted).
if !halted && &*rail.name == "minecraft:powered_rail" && !rail.powered {
@@ -816,34 +987,45 @@ impl Game<'_> {
}
// applyNaturalSlowdown and the speed cap (first iteration).
if first {
c.velocity = v;
v = self.natural_slowdown(c, ridden, 0.975);
let len = v.x.hypot(v.z);
if len > 0.0 {
let speed = len.min(self.max_speed_new(c));
v = Vec3::new(v.x / len * speed, v.y, v.z / len * speed);
if v.length_squared() > 0.0 {
let speed = v.length().min(self.max_speed_new(c));
v = v.normalize().scale(speed);
}
}
// calculateBoostTrackSpeed (once per tick).
if !boosted && &*rail.name == "minecraft:powered_rail" && rail.powered {
let len = v.x.hypot(v.z);
let before = v;
let len = v.length();
if len > 0.01 {
v = Vec3::new(v.x / len * (len + 0.06), v.y, v.z / len * (len + 0.06));
v = v.normalize().scale(len + 0.06);
} else {
let dir = self.redstone_direction(pos, rail.shape);
if dir != Vec3::ZERO {
v = Vec3::new(dir.x * 0.2, v.y, dir.z * 0.2);
v = dir.scale(len + 0.2);
}
}
boosted = true;
boosted = v.horizontal_distance_squared() != before.horizontal_distance_squared();
if !boosted {
v = before;
}
}
c.velocity = v;
let new_h = v.x.hypot(v.z);
let new_h = v.horizontal_distance();
movement_left = if first {
new_h
} else {
movement_left + (new_h - initial_h)
};
movement_left = self.new_step_along_track(c, pos, &rail, movement_left);
self.finish_cart_step(c, step_start, initial);
// NewMinecartBehavior.moveAlongTrack applies block effects twice,
// after recording the step, including a stationary first step.
if first || c.position.distance(step_start) > f64::from(1.0e-5_f32) {
self.cart_block_effects(c);
self.cart_block_effects(c);
}
first = false;
}
}
@@ -856,13 +1038,13 @@ impl Game<'_> {
rail: &Rail,
mut movement_left: f64,
) -> f64 {
if movement_left < 1.0e-5 {
if movement_left < f64::from(1.0e-5_f32) {
return 0.0;
}
let old = c.position;
let (e0, e1) = rail.shape.exits();
let movement = Vec3::new(c.velocity.x, 0.0, c.velocity.z);
if movement.x.hypot(movement.z) < 1.0e-5 {
if movement.length() < f64::from(1.0e-5_f32) {
c.velocity = Vec3::ZERO;
return 0.0;
}
@@ -874,9 +1056,9 @@ impl Game<'_> {
}
let out_len = out_dir.x.hypot(out_dir.z);
let mut out_pos = Vec3::new(
f64::from(pos[0]) + 0.5 + out_dir.x + out_dir.x / out_len * 1.0e-5,
f64::from(pos[0]) + 0.5 + out_dir.x + out_dir.x / out_len * f64::from(1.0e-5_f32),
f64::from(pos[1]) + 0.1,
f64::from(pos[2]) + 0.5 + out_dir.z + out_dir.z / out_len * 1.0e-5,
f64::from(pos[2]) + 0.5 + out_dir.z + out_dir.z / out_len * f64::from(1.0e-5_f32),
);
if in_hill && !Self::is_descending(c.velocity, rail.shape) {
out_pos.y += 1.0;
@@ -891,7 +1073,7 @@ impl Game<'_> {
old.add_vector(
movement
.normalize()
.scale(movement_left * std::f64::consts::SQRT_2),
.scale(movement_left * f64::from(std::f32::consts::SQRT_2)),
)
} else {
old.add_vector(movement.normalize().scale(movement_left))
@@ -904,10 +1086,14 @@ impl Game<'_> {
}
self.move_cart(c, old.vector_to(new_pos));
if in_hill {
let new_block = self.rail_block(c.position);
let new_block = [
new_pos.x.floor() as i32,
new_pos.y.floor() as i32,
new_pos.z.floor() as i32,
];
if let Some(new_rail) = self.rail(new_block[0], new_block[1], new_block[2]) {
// restAtVShape: opposing slopes rest a slow cart.
if c.velocity.length() < 0.005_f64.sqrt()
if c.velocity.length_squared() < 0.005
&& new_rail.shape.is_slope()
&& Self::is_descending(c.velocity, rail.shape)
&& !Self::is_descending(c.velocity, new_rail.shape)
@@ -928,7 +1114,9 @@ impl Game<'_> {
}
}
// The cart did not move but the step would have: it is stuck.
if c.position.distance(old) < 1.0e-5 && new_pos.distance(old) > 1.0e-5 {
if c.position.distance(old) < f64::from(1.0e-5_f32)
&& new_pos.distance(old) > f64::from(1.0e-5_f32)
{
c.velocity = Vec3::ZERO;
return 0.0;
}
@@ -937,7 +1125,11 @@ impl Game<'_> {
}
/// `comeOffTrack`.
fn come_off_track(&self, c: &mut Minecart) {
let max = self.max_speed(c);
let max = if self.minecart_improvements() {
self.max_speed_new(c)
} else {
self.max_speed(c)
};
c.velocity.x = c.velocity.x.clamp(-max, max);
c.velocity.z = c.velocity.z.clamp(-max, max);
if c.on_ground {
@@ -963,13 +1155,11 @@ impl Game<'_> {
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it,
/// and `onInsideBubbleColumn` resets the fall distance.
fn cart_block_effects(&self, c: &mut Minecart) {
if horizon_client::motion::bubble_column_push(
if c.push_by_bubble_column(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
c.bounds(),
&mut c.velocity,
) {
c.fall_distance = 0.0;
}
@@ -989,62 +1179,71 @@ impl Game<'_> {
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
c.first_tick = false;
c.tick_count += 1;
c.previous = c.position;
c.previous_yaw = c.yaw;
c.previous_pitch = c.pitch;
c.hurt_time = (c.hurt_time - 1).max(0);
c.damage = (c.damage - 1.0).max(0.0);
// applyGravity
if !c.no_gravity {
c.velocity.y -= if c.in_water { 0.005 } else { 0.04 };
}
let pos = self.rail_block(c.position);
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
if self.minecart_improvements() {
self.move_along_track_new(c, &rail, intent);
// NewMinecartBehavior.tick snaps a newly created cart before gravity.
if self.minecart_improvements() && c.first_tick {
let pos = self.rail_block(c.position);
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
c.on_rails = true;
Self::adjust_cart_to_rails(c, pos, rail.shape);
} else {
self.move_along_track(c, pos, &rail, intent);
c.on_rails = false;
}
}
// applyGravity
c.velocity.y -= c.get_gravity();
let pos = self.rail_block(c.position);
if self.minecart_improvements() {
self.move_along_track_new(c, intent);
} else if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
self.move_along_track(c, pos, &rail, intent);
if &*rail.name == "minecraft:activator_rail" {
self.activate_cart(c, rail.powered);
}
} else {
self.come_off_track(c);
}
// Yaw from the movement, flipped when it turns around.
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
if xd * xd + zd * zd > 0.001 {
c.yaw = zd.atan2(xd).to_degrees() as f32;
if c.flipped {
c.yaw += 180.0;
c.first_tick = false;
// The old behavior derives yaw once per tick; the experiment has
// already recorded each step's yaw and pitch.
if !self.minecart_improvements() {
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
if xd * xd + zd * zd > 0.001 {
c.yaw = zd.atan2(xd).to_degrees() as f32;
if c.flipped {
c.yaw += 180.0;
}
}
let diff = (c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0;
if !(-170.0..170.0).contains(&diff) {
c.yaw += 180.0;
c.flipped = !c.flipped;
}
c.yaw %= 360.0;
}
let diff = (c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0;
if !(-170.0..170.0).contains(&diff) {
c.yaw += 180.0;
c.flipped = !c.flipped;
if !self.minecart_improvements() {
self.cart_block_effects(c);
}
c.yaw %= 360.0;
// applyEffectsFromBlocks: a bubble column lifts or drags the cart.
self.cart_block_effects(c);
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
// AbstractMinecart.tick's updateFluidInteraction.
// AbstractMinecart.tick's updateFluidInteraction. The experiment still
// uses this fallback until its per-move pickup/push retry is ported.
self.push_from_ticked_cart(c);
// AbstractMinecart.tick: updateFluidInteraction, a stream pushes the cart.
c.in_water = horizon_client::entities::update_entity_fluids(
c.bounds(),
c.position,
c.bounds().max_position().y,
&mut c.velocity,
&self.world,
&self.res.motion().fluids,
)
.0 > 0.0;
c.in_water = c.push_by_fluid(&self.world, &self.res.motion().fluids).0 > 0.0;
// updateFluidInteraction: being in water resets the fall distance.
if c.in_water {
c.fall_distance = 0.0;
}
if self.minecart_improvements() {
// MinecartTNT.tick reads the final delta after AbstractMinecart.tick,
// including the pushes and fluid interaction after the track steps.
c.collision_speed = c.velocity.horizontal_distance_squared();
}
// Variants.
match c.kind {
CartKind::Furnace => {
@@ -2129,11 +2328,17 @@ impl Game<'_> {
let Some(model) = self.cart_models.models.get(&c.kind) else {
continue;
};
let (x, y, z) = (
c.previous.x + (c.position.x - c.previous.x) * alpha,
c.previous.y + (c.position.y - c.previous.y) * alpha,
c.previous.z + (c.position.z - c.previous.z) * alpha,
);
let lerp = self.remote_minecart_lerp(c.id, a);
let new_render = self.minecart_improvements() || lerp.is_some();
let (x, y, z) = if let Some((p, _, _)) = lerp {
(p[0], p[1], p[2])
} else {
(
c.previous.x + (c.position.x - c.previous.x) * alpha,
c.previous.y + (c.position.y - c.previous.y) * alpha,
c.previous.z + (c.position.z - c.previous.z) * alpha,
)
};
// LevelExtractor.isEntityVisible: the cart the player rides always draws.
if self.riding != Some(c.id) {
let d = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
@@ -2162,7 +2367,9 @@ impl Game<'_> {
c.previous_yaw + ((c.yaw - c.previous_yaw + 180.0).rem_euclid(360.0) - 180.0) * a;
let mut pitch = 0.0f32;
let mut offset = glam::DVec3::ZERO;
if let Some(on) = self.rail_pos(x, y, z) {
if new_render {
(yaw, pitch) = lerp.map_or((c.yaw, c.pitch), |(_, yaw, pitch)| (yaw, pitch));
} else if let Some(on) = self.rail_pos(x, y, z) {
let front = self.rail_pos_offs(x, y, z, 0.3).unwrap_or(on);
let back = self.rail_pos_offs(x, y, z, -0.3).unwrap_or(on);
offset = glam::DVec3::new(on[0] - x, (front[1] + back[1]) / 2.0 - y, on[2] - z);
@@ -2182,10 +2389,19 @@ impl Game<'_> {
.wrapping_add(seed.wrapping_mul(98_761));
let jitter = |shift: i64| (((seed >> shift & 7) as f32 + 0.5) / 8.0 - 0.5) * 0.004;
let p = glam::DVec3::new(x, y, z) + offset - camera;
let mut base = glam::Mat4::from_translation(
p.as_vec3() + glam::Vec3::new(jitter(16), jitter(20) + 0.375, jitter(24)),
) * glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
* glam::Mat4::from_rotation_z((-pitch).to_radians());
let jittered = p.as_vec3() + glam::Vec3::new(jitter(16), jitter(20), jitter(24));
let mut base = if new_render {
// AbstractMinecartRenderer.newRender translates the model
// after rotating, so the seat follows the pitched cart.
glam::Mat4::from_translation(jittered)
* glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_rotation_z((-pitch).to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.375, 0.0))
} else {
glam::Mat4::from_translation(jittered + glam::Vec3::new(0.0, 0.375, 0.0))
* glam::Mat4::from_rotation_y((180.0 - yaw).to_radians())
* glam::Mat4::from_rotation_z((-pitch).to_radians())
};
let hurt = c.hurt_time as f32 - a;
if hurt > 0.0 {
let damage = (c.damage - a).max(0.0);
@@ -2402,6 +2618,12 @@ fn tnt_ignites_by_collision(horizontal_collision: bool, speed_sqr: f64) -> bool
horizontal_collision && speed_sqr >= 0.009_999_999_776_482_582
}
/// NewMinecartBehavior's `(float) (Math.atan2(y, x) * 180.0 / Math.PI)` before
/// the cast: HotSpot's atan2, multiplied then divided in Java's order.
fn java_degrees(y: f64, x: f64) -> f64 {
horizon_core::jmath::atan2(y, x) * 180.0 / std::f64::consts::PI
}
#[cfg(test)]
mod tests {
#[test]
@@ -224,4 +224,29 @@ impl horizon_client::mod_api::LevelWriteHost for LevelTicks<'_, &mut super::Game
self.level
.native_set_block_property(self.ticks, pos, key, value, flags)
}
fn play_sound(
&mut self,
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool {
self.level
.native_play_sound(sound, at, source, volume, pitch)
}
fn send_particles(
&mut self,
options: &horizon_client::mod_api::ParticleOptions,
state: Option<BlockStateId>,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: i32,
) -> bool {
self.level
.native_send_particles(options, state, at, spread, speed, count)
}
}
+30
View File
@@ -328,6 +328,33 @@ impl Host for super::Game<'_> {
fn built_data(&self, mod_id: &str) -> Option<PathBuf> {
self.native_built_data(mod_id)
}
fn on(&self, mod_id: &str) -> bool {
self.native_on(mod_id)
}
fn mod_key(&self, mod_id: &str, name: &str, default: &str) -> String {
self.native_mod_key(mod_id, name, default)
}
fn setting(&self, mod_id: &str, key: &str) -> Option<String> {
self.native_setting(mod_id, key)
}
fn set_setting(&self, mod_id: &str, key: &str, value: &str) {
self.native_set_setting(mod_id, key, value)
}
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str) -> bool {
self.native_set_block_property(pos, key, value)
}
fn set_block_property_with(
&mut self,
pos: [i32; 3],
key: &str,
value: &str,
flags: i32,
) -> bool {
self.native_set_block_property_with(pos, key, value, flags)
}
fn config_dir(&self) -> Result<PathBuf, String> {
Self::native_config_dir()
}
fn panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
self.native_panel_items(mod_id, items)
}
@@ -450,6 +477,9 @@ impl Host for super::Game<'_> {
) -> u32 {
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
}
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
self.native_hide_player(uuid, level)
}
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
self.native_item_stack(batch, stack, at, size)
}
+137 -88
View File
@@ -528,7 +528,7 @@ pub(super) use horizon_world::level::flags;
pub(crate) const NATIVE_HOOKS_API: u32 = 153;
impl GameLevel<'_> {
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. @API:native_hopper_pickup_item@.
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. @API:hopper_pickup_item@.
#[inline(always)]
pub(super) fn native_hopper_pickup_item(
&self,
@@ -550,7 +550,7 @@ impl GameLevel<'_> {
}
/// A broken block is about to drop `experience` points at `pos`. The
/// vanilla block drop rule has been checked; change the amount before its
/// orbs are awarded. Server-side. @API:native_block_exp@.
/// orbs are awarded. Server-side. @API:block_exp@.
#[inline(always)]
pub(super) fn native_block_exp(
&self,
@@ -577,7 +577,7 @@ impl GameLevel<'_> {
/// A flowing fluid at `pos` is about to change level. `fluid` is `water`
/// or `lava`; the levels are 0 through 8. Set `cancel` to keep the old
/// level. Server-side. @API:native_fluid_level_change@.
/// level. Server-side. @API:fluid_level_change@.
#[inline(always)]
pub(super) fn native_fluid_level_change(
&self,
@@ -601,7 +601,7 @@ impl GameLevel<'_> {
}
/// A non-persistent leaf block at distance 7 is about to decay at `pos`.
/// Set `cancel` to keep it. Server-side. @API:native_leaves_decay@.
/// Set `cancel` to keep it. Server-side. @API:leaves_decay@.
#[inline(always)]
pub(super) fn native_leaves_decay(
&self,
@@ -614,7 +614,7 @@ impl GameLevel<'_> {
/// Farmland at `pos` is about to change moisture from `from` to `to`.
/// Set `cancel` to keep its old moisture. Server-side.
/// @API:native_moisture_change@.
/// @API:moisture_change@.
#[inline(always)]
pub(super) fn native_moisture_change(
&self,
@@ -628,7 +628,7 @@ impl GameLevel<'_> {
}
/// A sponge at `pos` absorbed water and became wet. Server-side.
/// @API:native_sponge_absorb@.
/// @API:sponge_absorb@.
#[inline(always)]
pub(super) fn native_sponge_absorb(&self, dimension: LevelDimension, pos: BlockPos) {
mod_api::dispatch_sponge_absorb(self, mod_api::mods(), dimension, pos);
@@ -636,7 +636,7 @@ impl GameLevel<'_> {
/// A block at `pos` is about to fade from `from` to `to` because of
/// melting or coral death. Set `cancel` to keep it. Server-side.
/// @API:native_block_fade@.
/// @API:block_fade@.
#[inline(always)]
pub(super) fn native_block_fade(
&self,
@@ -651,7 +651,7 @@ impl GameLevel<'_> {
/// Precipitation is about to form or add to a block at `pos`. `from` and
/// `to` are block state IDs. Set `cancel` to keep the old state. Server-side.
/// @API:native_block_form@.
/// @API:block_form@.
#[inline(always)]
pub(super) fn native_block_form(
&self,
@@ -665,7 +665,7 @@ impl GameLevel<'_> {
}
/// The Ender Dragon fight is about to place its first dragon egg at `pos`.
/// Set `cancel` to leave the block unchanged. Server-side. @API:native_dragon_egg_form@.
/// Set `cancel` to leave the block unchanged. Server-side. @API:dragon_egg_form@.
#[inline(always)]
pub(super) fn native_dragon_egg_form(
&self,
@@ -718,7 +718,7 @@ impl GameLevel<'_> {
/// On an authoritative server, fire is about to be placed at `pos` by
/// flint and steel, a fire charge, lava, lightning or fire spread. Set
/// `player` is the acting server player when there is one. Set `cancel` to
/// leave the position unchanged. @API:native_block_ignite@.
/// leave the position unchanged. @API:block_ignite@.
#[inline(always)]
pub(super) fn native_block_ignite(
&self,
@@ -740,7 +740,7 @@ impl GameLevel<'_> {
}
/// On an authoritative server, fire is about to burn the block at `pos`
/// away. Set `cancel` to keep that block. @API:native_block_burn@.
/// away. Set `cancel` to keep that block. @API:block_burn@.
#[inline(always)]
pub(super) fn native_block_burn(
&self,
@@ -753,7 +753,7 @@ impl GameLevel<'_> {
/// On an authoritative server, a note block at `pos` is about to sound.
/// `player` is the acting server player when there is one. Set `cancel` to silence it.
/// @API:native_note_play@.
/// @API:note_play@.
#[inline(always)]
pub(super) fn native_note_play(
&self,
@@ -777,7 +777,7 @@ impl GameLevel<'_> {
}
/// On an authoritative server, a bell at `pos` is about to ring. Set
/// `cancel` to stop the ring and its raider reveal. @API:native_bell_ring@.
/// `cancel` to stop the ring and its raider reveal. @API:bell_ring@.
#[inline(always)]
pub(super) fn native_bell_ring(
&self,
@@ -791,7 +791,7 @@ impl GameLevel<'_> {
/// On an authoritative server, a cauldron at `pos` is about to change
/// fill level from `from_level` to `to_level` for `reason`. Set `cancel`
/// to leave it unchanged. @API:native_cauldron_level_changed@.
/// to leave it unchanged. @API:cauldron_level_changed@.
#[inline(always)]
pub(super) fn native_cauldron_level_changed(
&self,
@@ -3438,7 +3438,7 @@ impl Game<'_> {
/// A server player selected a new hotbar slot. The player's state is not
/// swapped in, and helpers that look them up through `self.lan` may find
/// nothing while a guest packet is being handled. Set `cancel` to keep
/// the old slot; the client is sent that slot again. @API:native_player_item_held@.
/// the old slot; the client is sent that slot again. @API:player_item_held@.
pub(super) fn native_player_item_held(
&mut self,
player: u128,
@@ -3461,7 +3461,7 @@ impl Game<'_> {
/// the hotbar. `pos` is zero for an entity; `entity_id` is zero for a
/// block. The player's state is swapped in, though helpers that look them
/// up through `self.lan` may find nothing while a guest packet is handled.
/// Set `cancel` to leave the inventory and client unchanged. @API:native_player_pick_item@.
/// Set `cancel` to leave the inventory and client unchanged. @API:player_pick_item@.
pub(super) fn native_player_pick_item(
&mut self,
player: u128,
@@ -3558,7 +3558,7 @@ impl Game<'_> {
/// after mending has taken its share. Change `amount` to change the points
/// added. The player's state is not swapped in, so helpers that look them up
/// through `self.lan` may find nothing while a guest packet is handled.
/// Server-side. @API:native_player_exp_change@.
/// Server-side. @API:player_exp_change@.
pub(super) fn native_player_exp_change(&mut self, player: u128, name: &str, amount: &mut i32) {
mod_api::dispatch_player_exp_change(self, mod_api::mods(), player, name, amount);
}
@@ -3566,7 +3566,7 @@ impl Game<'_> {
/// item id and `ticks` its duration. The player's state is not swapped in,
/// so helpers that look them up through `self.lan` may find nothing while a
/// guest packet is handled. Set `cancel` to skip starting the cooldown.
/// Server-side. @API:native_player_item_cooldown@.
/// Server-side. @API:player_item_cooldown@.
pub(super) fn native_player_item_cooldown(
&mut self,
player: u128,
@@ -3588,7 +3588,7 @@ impl Game<'_> {
/// A recipe is about to be unlocked for a server player. Set `cancel` to
/// keep it locked. The player's state is not swapped in, so helpers that
/// look them up through `self.lan` may find nothing while a guest packet
/// is handled. Server-side. @API:native_player_recipe_discovered@.
/// is handled. Server-side. @API:player_recipe_discovered@.
pub(super) fn native_player_recipe_discovered(
&mut self,
player: u128,
@@ -3610,7 +3610,7 @@ impl Game<'_> {
/// position. The player's state is not swapped in, so helpers that look
/// them up through `self.lan` may find nothing while a guest packet is
/// handled. Set `cancel` to keep the old point. Server-side.
/// @API:native_player_spawn_point_set@.
/// @API:player_spawn_point_set@.
pub(super) fn native_player_spawn_point_set(
&mut self,
player: u128,
@@ -3636,7 +3636,7 @@ impl Game<'_> {
/// The player's state is not swapped in, so helpers that look them up
/// through `self.lan` may find nothing while a guest packet is handled.
/// Set `cancel` to keep them in their current place. Server-side.
/// @API:native_player_portal@.
/// @API:player_portal@.
pub(super) fn native_player_portal(
&mut self,
player: u128,
@@ -3688,7 +3688,7 @@ impl Game<'_> {
}
/// A sculk sensor or shrieker received a vibration for `event` at `pos`.
/// Set `cancel` to suppress its response. Server-side.
/// @API:native_block_receive_game@.
/// @API:block_receive_game@.
pub(super) fn native_block_receive_game(
&mut self,
dimension: &str,
@@ -3709,7 +3709,7 @@ impl Game<'_> {
}
/// A dispenser sheared `entity` of kind `kind` at the block in front of
/// its own block at `pos`, after the shearing effects. Server-side.
/// @API:native_block_shear_entity@.
/// @API:block_shear_entity@.
pub(super) fn native_block_shear_entity(
&mut self,
dimension: &str,
@@ -3722,7 +3722,7 @@ impl Game<'_> {
/// A dispenser equipped `item` on the first eligible `target` in front
/// of its block at `pos`. `target` is `player`, `mob` or `armor_stand`.
/// The equip, item consumption, sound and game event have completed.
/// Server-side. @API:native_block_dispense_armor@.
/// Server-side. @API:block_dispense_armor@.
pub(super) fn native_block_dispense_armor(
&mut self,
dimension: &str,
@@ -3763,7 +3763,7 @@ impl Game<'_> {
}
/// A server player is about to open an inventory supplied by a block at
/// `pos`. Set `cancel` to leave the inventory closed. The player's state
/// is not swapped in. @API:native_inventory_block_start@.
/// is not swapped in. @API:inventory_block_start@.
pub(super) fn native_inventory_block_start(
&mut self,
player: u128,
@@ -3776,19 +3776,19 @@ impl Game<'_> {
/// A sculk catalyst at `pos` has begun blooming after taking experience.
/// The catalyst block state has changed. Server-side.
/// @API:native_sculk_bloom@.
/// @API:sculk_bloom@.
pub(super) fn native_sculk_bloom(&mut self, dimension: &str, pos: [i32; 3]) {
mod_api::dispatch_sculk_bloom(self, mod_api::mods(), dimension, pos);
}
/// A projectile hit the target block at `pos`, which now has `strength`
/// output power. Server-side. @API:native_target_hit@.
/// output power. Server-side. @API:target_hit@.
pub(super) fn native_target_hit(&mut self, dimension: &str, pos: [i32; 3], strength: i32) {
mod_api::dispatch_target_hit(self, mod_api::mods(), dimension, pos, strength);
}
/// A vault at `pos` changed to `state`. Its block state is updated.
/// Server-side. @API:native_vault_change_state@.
/// Server-side. @API:vault_change_state@.
pub(super) fn native_vault_change_state(
&mut self,
dimension: &str,
@@ -3799,7 +3799,7 @@ impl Game<'_> {
}
/// A dispenser at `pos` failed to dispense `item`. Server-side.
/// @API:native_block_failed_dispense@.
/// @API:block_failed_dispense@.
pub(super) fn native_block_failed_dispense(
&mut self,
dimension: &str,
@@ -3811,7 +3811,7 @@ impl Game<'_> {
/// A beacon at `pos` activated or deactivated after its beam was checked.
/// `active` says whether it now has a complete pyramid. Server-side.
/// @API:native_beacon_state_change@.
/// @API:beacon_state_change@.
pub(super) fn native_beacon_state_change(
&mut self,
dimension: &str,
@@ -3823,7 +3823,7 @@ impl Game<'_> {
/// An anvil at `pos` took damage, changing its block from `from` to `to`.
/// `to` is air when the anvil broke. Server-side.
/// @API:native_anvil_damaged@.
/// @API:anvil_damaged@.
pub(super) fn native_anvil_damaged(
&mut self,
dimension: &str,
@@ -3835,7 +3835,7 @@ impl Game<'_> {
}
/// A server guest's block-breaking animation changed to `progress` at
/// `pos` (0 through 9). @API:native_block_break_progress_update@.
/// `pos` (0 through 9). @API:block_break_progress_update@.
pub(super) fn native_block_break_progress_update(
&mut self,
player: u128,
@@ -3855,7 +3855,7 @@ impl Game<'_> {
/// A guest's block inventory lock check at `pos`. `locked` is the
/// vanilla result and may be changed. The guest's state is not swapped in.
/// @API:native_block_lock_check@.
/// @API:block_lock_check@.
pub(super) fn native_block_lock_check(
&mut self,
player: u128,
@@ -3924,7 +3924,7 @@ impl Game<'_> {
/// A server player changed their client option for the dominant hand. `left` is true for
/// left-handed. Their state is not swapped in, and helpers looking them up through `self.lan`
/// may find nothing during guest packet handling. Server-side.
/// @API:native_player_change_main_hand@.
/// @API:player_change_main_hand@.
pub(super) fn native_player_change_main_hand(&mut self, player: u128, name: &str, left: bool) {
mod_api::dispatch_player_change_main_hand(self, mod_api::mods(), player, name, left);
}
@@ -3944,7 +3944,7 @@ impl Game<'_> {
/// A server player put a plant into a flower pot, after the potted block
/// state was stored and the item consumed. `plant` is the item id and
/// `pos` the pot position. Helpers looking up the guest through `self.lan`
/// may find nothing during packet handling. Server-side. @API:player_flower_pot_manipulate@.
/// may find nothing during packet handling. Server-side. @API:native_player_flower_pot_manipulate@.
pub(super) fn native_player_flower_pot_manipulate(
&mut self,
player: u128,
@@ -3964,7 +3964,7 @@ impl Game<'_> {
/// A server player inserted a book into a lectern, after the block entity
/// and inventory changed. `book` is the item id. Helpers looking up a
/// guest through `self.lan` may find nothing during packet handling.
/// Server-side. @API:player_insert_lectern_book@.
/// Server-side. @API:native_player_insert_lectern_book@.
pub(super) fn native_player_insert_lectern_book(
&mut self,
player: u128,
@@ -3984,7 +3984,7 @@ impl Game<'_> {
/// A server player took the book from a lectern, after the menu and block
/// entity changed. `book` is the removed item id. Helpers looking up a
/// guest through `self.lan` may find nothing during packet handling.
/// Server-side. @API:player_take_lectern_book@.
/// Server-side. @API:native_player_take_lectern_book@.
pub(super) fn native_player_take_lectern_book(
&mut self,
player: u128,
@@ -3997,7 +3997,7 @@ impl Game<'_> {
/// A server player changed the page of a lectern, after its page and
/// redstone pulse changed. `page` is the zero-based new page. Helpers
/// looking up a guest through `self.lan` may find nothing during packet
/// handling. Server-side. @API:player_lectern_page_change@.
/// handling. Server-side. @API:native_player_lectern_page_change@.
pub(super) fn native_player_lectern_page_change(
&mut self,
player: u128,
@@ -4018,7 +4018,7 @@ impl Game<'_> {
/// changed. `action` is `put`, `rotate`, `remove_item` or `break`; `item`
/// is empty when no item remains, and `rotation` is the resulting step.
/// Helpers looking up the guest through `self.lan` may find nothing during
/// packet handling. Server-side. @API:player_item_frame_change@.
/// packet handling. Server-side. @API:native_player_item_frame_change@.
pub(super) fn native_player_item_frame_change(
&mut self,
player: u128,
@@ -4042,7 +4042,7 @@ impl Game<'_> {
/// Mending repaired a server player's item with experience from an orb.
/// `repair` is durability restored and `experience` is the orb's value.
/// Helpers looking up the guest through `self.lan` may find nothing during
/// packet handling. Server-side. @API:player_item_mend@.
/// packet handling. Server-side. @API:native_player_item_mend@.
pub(super) fn native_player_item_mend(
&mut self,
player: u128,
@@ -4247,7 +4247,7 @@ impl Game<'_> {
/// A server player is about to travel through an End gateway at `gateway`.
/// Set `cancel` to leave them at the entrance; the gateway is not consumed
/// and its cooldown does not start. Helpers may find no guest through
/// `self.lan` during its packet. Server-side. @API:native_player_teleport_end_gateway@.
/// `self.lan` during its packet. Server-side. @API:player_teleport_end_gateway@.
pub(super) fn native_player_teleport_end_gateway(
&mut self,
player: u128,
@@ -4267,7 +4267,7 @@ impl Game<'_> {
/// A server player's recipe book requested a known recipe be placed in
/// their crafting grid. `recipe` is the recipe id; `all` is the shift
/// click form. The player's state may not be available through `self.lan`
/// while a guest packet is handled. Server-side. @API:native_player_recipe_book_click@.
/// while a guest packet is handled. Server-side. @API:player_recipe_book_click@.
pub(super) fn native_player_recipe_book_click(
&mut self,
player: u128,
@@ -4287,7 +4287,7 @@ impl Game<'_> {
/// A server player's XP orb pickup cooldown changed to `ticks`. It is
/// reported after the value changes; the player's state may not be found
/// through `self.lan` while a guest packet is handled. Server-side.
/// @API:native_player_exp_cooldown_change@.
/// @API:player_exp_cooldown_change@.
pub(super) fn native_player_exp_cooldown_change(
&mut self,
player: u128,
@@ -4298,7 +4298,7 @@ impl Game<'_> {
}
/// A server player changed their client language option to `locale`.
/// The player's state may not be found through `self.lan` during a guest
/// packet. Server-side. @API:native_player_locale_change@.
/// packet. Server-side. @API:player_locale_change@.
pub(super) fn native_player_locale_change(&mut self, player: u128, name: &str, locale: &str) {
mod_api::dispatch_player_locale_change(self, mod_api::mods(), player, name, locale);
}
@@ -4306,7 +4306,7 @@ impl Game<'_> {
/// position changed. `entity` is its network id; `from` and `to` are
/// coordinates in `dimension`. This hook reports command teleports that
/// use Horizon's same-level target path; it does not change vanilla's
/// movement. @API:native_entity_teleport@.
/// movement. @API:entity_teleport@.
pub(super) fn native_entity_teleport(
&mut self,
dimension: &str,
@@ -4319,13 +4319,13 @@ impl Game<'_> {
/// A server entity finished its portal wait and is beginning to leave
/// `dimension` through `pos`. The destination follows the portal rules.
/// @API:native_entity_portal@.
/// @API:entity_portal@.
pub(super) fn native_entity_portal(&mut self, dimension: &str, entity: u32, pos: [i32; 3]) {
mod_api::dispatch_entity_portal(self, mod_api::mods(), dimension, entity, pos);
}
/// A server projectile was added to the level. `player` is set only for
/// a player launch; other sources have an empty `name`. @API:native_projectile_launch@.
/// a player launch; other sources have an empty `name`. @API:projectile_launch@.
pub(super) fn native_projectile_launch(
&mut self,
player: Option<u128>,
@@ -4337,7 +4337,7 @@ impl Game<'_> {
}
/// A player bow or crossbow launched an arrow with `force`, after the
/// projectile entered the level. @API:native_entity_shoot_bow@.
/// projectile entered the level. @API:entity_shoot_bow@.
pub(super) fn native_entity_shoot_bow(
&mut self,
player: u128,
@@ -4349,13 +4349,13 @@ impl Game<'_> {
}
/// A guest's fall flying state changed on the server. The change has
/// already been applied. @API:native_entity_toggle_glide@.
/// already been applied. @API:entity_toggle_glide@.
pub(super) fn native_entity_toggle_glide(&mut self, player: u128, name: &str, gliding: bool) {
mod_api::dispatch_entity_toggle_glide(self, mod_api::mods(), player, name, gliding);
}
/// A guest player's synchronized pose changed from `from` to `to`.
/// @API:native_entity_pose_change@.
/// @API:entity_pose_change@.
pub(super) fn native_entity_pose_change(
&mut self,
player: u128,
@@ -4367,19 +4367,19 @@ impl Game<'_> {
}
/// A dropped item entity was successfully added to the server level.
/// @API:native_item_spawn@.
/// @API:item_spawn@.
pub(super) fn native_item_spawn(&mut self, entity: u32, item: &str, count: i32, pos: [f64; 3]) {
mod_api::dispatch_item_spawn(self, mod_api::mods(), entity, item, count, pos);
}
/// A mob became tamed after its owner and persistent state were set.
/// @API:native_entity_tame@.
/// @API:entity_tame@.
pub(super) fn native_entity_tame(&mut self, entity: u32, kind: &str, owner: Option<u128>) {
mod_api::dispatch_entity_tame(self, mod_api::mods(), entity, kind, owner);
}
/// Two mobs completed a breeding action. `child` is absent when vanilla
/// lays an egg instead of creating a baby. @API:native_entity_breed@.
/// lays an egg instead of creating a baby. @API:entity_breed@.
pub(super) fn native_entity_breed(&mut self, parent: u32, partner: u32, child: Option<u32>) {
mod_api::dispatch_entity_breed(self, mod_api::mods(), parent, partner, child);
}
@@ -4404,7 +4404,7 @@ impl Game<'_> {
/// A server player took an output from a furnace at `pos`; `item` is the
/// output item id and `count` is how many were taken. The player's state is
/// not swapped in, so helpers that look them up through `self.lan` may find
/// nothing during guest packet handling. @API:native_furnace_extract@.
/// nothing during guest packet handling. @API:furnace_extract@.
pub(super) fn native_furnace_extract(
&mut self,
player: u128,
@@ -4415,7 +4415,7 @@ impl Game<'_> {
) {
mod_api::dispatch_furnace_extract(self, mod_api::mods(), player, name, pos, item, count);
}
/// A server player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. The player's state is not swapped in. @API:native_enchant_item@.
/// A server player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. The player's state is not swapped in. @API:enchant_item@.
pub(super) fn native_enchant_item(
&mut self,
player: u128,
@@ -4426,7 +4426,7 @@ impl Game<'_> {
) {
mod_api::dispatch_enchant_item(self, mod_api::mods(), player, name, pos, item, levels);
}
/// Enchanting offers were calculated for a server player at `pos`. Change `costs` before they are sent to the menu. @API:native_prepare_item_enchant@.
/// Enchanting offers were calculated for a server player at `pos`. Change `costs` before they are sent to the menu. @API:prepare_item_enchant@.
pub(super) fn native_prepare_item_enchant(
&mut self,
player: u128,
@@ -4436,7 +4436,7 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_item_enchant(self, mod_api::mods(), player, name, pos, costs);
}
/// An anvil result was prepared for a server player at `pos`. Change `result` or its level `cost` before it is sent. @API:native_prepare_anvil@.
/// An anvil result was prepared for a server player at `pos`. Change `result` or its level `cost` before it is sent. @API:prepare_anvil@.
pub(super) fn native_prepare_anvil(
&mut self,
player: u128,
@@ -4447,7 +4447,7 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_anvil(self, mod_api::mods(), player, name, pos, cost, result);
}
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:native_prepare_grindstone@.
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
pub(super) fn native_prepare_grindstone(
&mut self,
player: u128,
@@ -4457,7 +4457,7 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_grindstone(self, mod_api::mods(), player, name, pos, result);
}
/// A smithing result was prepared for a server player at `pos`. Change it before it is sent. @API:native_prepare_smithing@.
/// A smithing result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_smithing@.
pub(super) fn native_prepare_smithing(
&mut self,
player: u128,
@@ -4467,7 +4467,7 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_smithing(self, mod_api::mods(), player, name, pos, result);
}
/// A crafting-grid result was prepared for a server player; `pos` is the crafting table, or None for the inventory grid. Change `result` before it is sent. @API:native_prepare_item_craft@.
/// A crafting-grid result was prepared for a server player; `pos` is the crafting table, or None for the inventory grid. Change `result` before it is sent. @API:prepare_item_craft@.
pub(super) fn native_prepare_item_craft(
&mut self,
player: u128,
@@ -4477,7 +4477,7 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_item_craft(self, mod_api::mods(), player, name, pos, result);
}
/// A server player selected trade `index` from `villager` in the trading menu. @API:native_trade_select@.
/// A server player selected trade `index` from `villager` in the trading menu. @API:trade_select@.
pub(super) fn native_trade_select(
&mut self,
player: u128,
@@ -4489,7 +4489,7 @@ impl Game<'_> {
}
/// The server changed its weather parameters. `raining` and `thundering`
/// are the requested final states. Set `cancel` to keep the previous
/// weather parameters. Server-side. @API:native_weather_change@.
/// weather parameters. Server-side. @API:weather_change@.
pub(super) fn native_weather_change(
&mut self,
dimension: &str,
@@ -4509,7 +4509,7 @@ impl Game<'_> {
/// The server changed its thunder state through `/weather`. Set `cancel`
/// to keep the previous thunder state and timer. Server-side.
/// @API:native_thunder_change@.
/// @API:thunder_change@.
pub(super) fn native_thunder_change(
&mut self,
dimension: &str,
@@ -4521,7 +4521,7 @@ impl Game<'_> {
/// A server lightning bolt is about to be created. `cause` is `weather`,
/// `trident` or `command`; `player` is the responsible player when known.
/// Set `cancel` to prevent the bolt and its effects. @API:native_lightning_strike@.
/// Set `cancel` to prevent the bolt and its effects. @API:lightning_strike@.
pub(super) fn native_lightning_strike(
&mut self,
player: Option<u128>,
@@ -4556,7 +4556,7 @@ impl Game<'_> {
}
/// The overworld clock is about to jump, from sleep or `/time`. Set
/// `cancel` to keep `from`. @API:native_time_skip@.
/// `cancel` to keep `from`. @API:time_skip@.
pub(super) fn native_time_skip(
&mut self,
dimension: &str,
@@ -4598,13 +4598,13 @@ impl Game<'_> {
/// A Nether portal was built for a dimension travel, or an End platform
/// was made for an arrival. `pos` is the lower corner of the Nether
/// portal rectangle or the End platform origin. The completed blocks are
/// already in the world. @API:native_portal_create@.
/// already in the world. @API:portal_create@.
pub(super) fn native_portal_create(&mut self, dimension: &str, pos: [i32; 3]) {
mod_api::dispatch_portal_create(self, mod_api::mods(), dimension, pos);
}
/// A raid wave has been spawned at `pos`; `wave` is its one-based wave
/// number and `raiders` counts the raiders, not the mobs they ride. @API:native_raid_spawn_wave@.
/// number and `raiders` counts the raiders, not the mobs they ride. @API:raid_spawn_wave@.
pub(super) fn native_raid_spawn_wave(
&mut self,
raid: i32,
@@ -4616,25 +4616,25 @@ impl Game<'_> {
}
/// A raid at `pos` changed to `state` (`triggered`, `victory`, `loss` or
/// `stopped`). @API:native_raid_state_change@.
/// `stopped`). @API:raid_state_change@.
pub(super) fn native_raid_state_change(&mut self, raid: i32, pos: [i32; 3], state: &str) {
mod_api::dispatch_raid_state_change(self, mod_api::mods(), raid, pos, state);
}
/// The server completed a world save cycle. `wait` says chunk writes were
/// flushed before returning. @API:native_world_saved@.
/// flushed before returning. @API:world_saved@.
pub(super) fn native_world_saved(&mut self, wait: bool) {
mod_api::dispatch_world_saved(self, mod_api::mods(), wait);
}
/// Entities from `chunk` finished loading into the server world. `count`
/// is the number of saved entity records read. @API:native_entities_load@.
/// is the number of saved entity records read. @API:entities_load@.
pub(super) fn native_entities_load(&mut self, dimension: &str, chunk: [i32; 2], count: usize) {
mod_api::dispatch_entities_load(self, mod_api::mods(), dimension, chunk, count);
}
/// Entities in `chunk` left the active server world and were queued for
/// saving. `count` is the number of entity records. @API:native_entities_unload@.
/// saving. `count` is the number of entity records. @API:entities_unload@.
pub(super) fn native_entities_unload(
&mut self,
dimension: &str,
@@ -4644,13 +4644,13 @@ impl Game<'_> {
mod_api::dispatch_entities_unload(self, mod_api::mods(), dimension, chunk, count);
}
/// A generic world game event was fired at `pos`. @API:native_game_event@.
/// A generic world game event was fired at `pos`. @API:game_event@.
pub(super) fn native_game_event(&mut self, event: &str, pos: [f64; 3]) {
mod_api::dispatch_game_event(self, mod_api::mods(), event, pos);
}
/// A guest asked to complete `command`; `suggestions` may be added to or
/// filtered. `range` is the replaced byte range. @API:native_tab_complete@.
/// filtered. `range` is the replaced byte range. @API:tab_complete@.
pub(super) fn native_tab_complete(
&mut self,
player: u128,
@@ -4671,18 +4671,18 @@ impl Game<'_> {
}
/// A server broadcast line is about to be logged and sent. Change `line`
/// or set `cancel` to drop it. @API:native_broadcast_message@.
/// or set `cancel` to drop it. @API:broadcast_message@.
pub(super) fn native_broadcast_message(&self, line: &mut Text, cancel: &mut bool) {
mod_api::dispatch_broadcast_message(self, mod_api::mods(), line, cancel);
}
/// The dedicated server's whitelist changed to `enabled`. @API:native_whitelist_state_update@.
/// The dedicated server's whitelist changed to `enabled`. @API:whitelist_state_update@.
pub(super) fn native_whitelist_state_update(&mut self, enabled: bool) {
mod_api::dispatch_whitelist_state_update(self, mod_api::mods(), enabled);
}
/// A mob entered love mode after player interaction. `player` is the
/// feeder when known. @API:native_entity_enter_love_mode@.
/// feeder when known. @API:entity_enter_love_mode@.
pub(super) fn native_entity_enter_love_mode(
&mut self,
entity: u32,
@@ -4693,7 +4693,7 @@ impl Game<'_> {
}
/// A mob conversion completed; both ids and entity types are reported.
/// @API:native_entity_transform@.
/// @API:entity_transform@.
pub(super) fn native_entity_transform(
&mut self,
entity: u32,
@@ -4712,39 +4712,39 @@ impl Game<'_> {
}
/// A thrown splash or lingering potion applied its hit effects.
/// @API:native_potion_splash@.
/// @API:potion_splash@.
pub(super) fn native_potion_splash(&mut self, item: &str, pos: [f64; 3]) {
mod_api::dispatch_potion_splash(self, mod_api::mods(), item, pos);
}
/// A firework rocket completed its explosion effects. @API:native_firework_explode@.
/// A firework rocket completed its explosion effects. @API:firework_explode@.
pub(super) fn native_firework_explode(&mut self, entity: u32, pos: [f64; 3], bursts: usize) {
mod_api::dispatch_firework_explode(self, mod_api::mods(), entity, pos, bursts);
}
/// An experience bottle broke and its experience was awarded.
/// @API:native_exp_bottle@.
/// @API:exp_bottle@.
pub(super) fn native_exp_bottle(&mut self, pos: [f64; 3], experience: u32) {
mod_api::dispatch_exp_bottle(self, mod_api::mods(), pos, experience);
}
/// A player ignited a creeper with an item. @API:native_creeper_ignite@.
/// A player ignited a creeper with an item. @API:creeper_ignite@.
pub(super) fn native_creeper_ignite(&mut self, entity: u32, player: u128, item: &str) {
mod_api::dispatch_creeper_ignite(self, mod_api::mods(), entity, player, item);
}
/// Lightning charged a creeper. @API:native_creeper_power@.
/// Lightning charged a creeper. @API:creeper_power@.
pub(super) fn native_creeper_power(&mut self, entity: u32, lightning: [f64; 3]) {
mod_api::dispatch_creeper_power(self, mod_api::mods(), entity, lightning);
}
/// A mob broke the door at `pos`. @API:native_entity_break_door@.
/// A mob broke the door at `pos`. @API:entity_break_door@.
pub(super) fn native_entity_break_door(&mut self, entity: u32, kind: &str, pos: [i32; 3]) {
mod_api::dispatch_entity_break_door(self, mod_api::mods(), entity, kind, pos);
}
/// A mob changed a block at `pos`, after the new state was applied.
/// @API:native_entity_change_block@.
/// @API:entity_change_block@.
pub(super) fn native_entity_change_block(
&mut self,
entity: u32,
@@ -4757,7 +4757,7 @@ impl Game<'_> {
}
/// A bee entered the hive at `pos` and was stored there.
/// @API:native_entity_enter_block@.
/// @API:entity_enter_block@.
pub(super) fn native_entity_enter_block(
&mut self,
entity: u32,
@@ -5082,6 +5082,32 @@ impl Game<'_> {
) {
mod_api::dispatch_arrow_loose(self, mod_api::mods(), stack, has_ammo, charge, canceled);
}
/// NeoForge's `CriticalHitEvent`: a player's melee hit on a living
/// `target_kind` (`minecraft:zombie`, `minecraft:armor_stand`) that deals
/// damage, where `Player.attack` decides whether it is critical.
/// `vanilla_critical` is vanilla's answer; `critical` starts as it and may
/// be forced or denied (as in vanilla a critical hit prevents the sweep, so
/// a forced crit loses it; a denied crit loses the crit sound and
/// particles); `damage_multiplier` starts at 1.5 for vanilla's critical and
/// 1.0 otherwise, and scales the damage only while `critical` ends true. Not fired for a
/// target that is no living entity (a dragon part) or when the hit does
/// no damage. Fires on the Game that runs the attack. @API:critical_hit@.
pub(super) fn native_critical_hit(
&mut self,
target_kind: &str,
vanilla_critical: bool,
critical: &mut bool,
damage_multiplier: &mut f32,
) {
mod_api::dispatch_critical_hit(
self,
mod_api::mods(),
target_kind,
vanilla_critical,
critical,
damage_multiplier,
);
}
/// `Item.releaseUsing` of `stack`: `entity` let it go with `time_left`
/// ticks of its `native_use_duration` left, so it was held for
/// `duration - time_left` (a bow-like item shoots by that charge). Set
@@ -5665,6 +5691,22 @@ impl Game<'_> {
pub(super) fn native_player_totem(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_totem(self, mod_api::mods(), player, name);
}
/// The item `item` of the server player `player` (`name`) broke in `slot`
/// (`mainhand`, `offhand`, `head`, `chest`, `legs`, `feet`, `body` or
/// `saddle`): `LivingEntity.onEquippedItemBroken`, Bukkit's
/// `PlayerItemBreakEvent`. Listen only; armour has a slot, not a hand.
/// The player's state is swapped in. Once for each break, on a server; a
/// creative player and the host of a LAN world are not heard.
/// @API:native_player_destroy_item@.
pub(super) fn native_player_destroy_item(
&mut self,
player: u128,
name: &str,
item: &str,
slot: &str,
) {
mod_api::dispatch_player_destroy_item(self, mod_api::mods(), player, name, item, slot);
}
/// `player`'s melee blow killed the mob `entity` of `kind` (other deaths
/// aren't told yet). It only hears, after the blow landed. Server-side.
/// API 40.
@@ -6949,7 +6991,9 @@ impl Game<'_> {
true
}
/// Draw a stack and its slot decorations, except the count, at `at`,
/// `size` GUI pixels square. False for an unknown item. API @API:item_stack@.
/// `size` GUI pixels square. False when nothing was drawn: an unknown item
/// or an empty stack (air, or a count of 0, as `ItemStack.isEmpty`).
/// API @API:item_stack@.
pub(super) fn native_item_stack(
&self,
batch: &mut HudBatch,
@@ -6957,6 +7001,11 @@ impl Game<'_> {
at: [f32; 2],
size: f32,
) -> bool {
// GuiGraphicsExtractor.item and itemDecorations draw nothing for an
// empty stack.
if &*stack.item == "minecraft:air" || stack.count == 0 {
return false;
}
if self.res.items().get(&stack.item).is_none() {
return false;
}
@@ -7219,8 +7268,8 @@ impl Game<'_> {
/// (`config/<id>.json`, or a folder of its own). The same folder on the
/// window, its server thread and a LAN host; a dedicated server's is in
/// its own folder, beside `server.properties`. Made when missing. No
/// `self`, so it works from any hook (`Game::native_config_dir()`), the
/// level's too. API 26.
/// `self`, so a mixin helper calls `Game::native_config_dir()` and a trait
/// mod `Host::config_dir`. API 26.
pub(super) fn native_config_dir() -> Result<PathBuf, String> {
config_dir(&Self::game_dir())
}
@@ -343,6 +343,26 @@ impl Game<'_> {
}
true
}
/// Hides that player (a vanish) from the players below permission
/// `level`, or shows them again (None): from the next tick those players
/// lose them from the Tab list and the world (their client stops
/// tracking the player, as it does a spectator), and don't read their
/// joining or leaving; players of `level` or more still see them, and
/// a player made an operator meanwhile sees them again. By UUID: kept
/// for a player who is off (who joins hidden), until a mod sets it again
/// or the server stops. Works anywhere on a server, inside a player's
/// hook too; false on a client and in a world nobody else can join.
/// API @API:hide_player@.
pub(super) fn native_hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
let Some(host) = &mut self.lan else {
return false;
};
match level {
Some(level) => host.hub.hidden.insert(uuid, level),
None => host.hub.hidden.remove(&uuid),
};
true
}
/// Shows `nickname` in place of that player's name in chat (their
/// messages, `/me`, `/msg`) and in the Tab list; None shows their name
/// again. Hovering the shown name gives the real one, which stays in the
@@ -400,6 +420,7 @@ impl Game<'_> {
/// `[discord] events` lists `announce`: `name` and `uuid` say who it is
/// about (empty and 0 for no one), `message` is the line (`§` codes colour it).
/// Only on a dedicated server; false elsewhere and for an empty message.
/// API @API:server_event@.
pub(super) fn native_server_event(&self, name: &str, uuid: u128, message: &str) -> bool {
if self.options.dedicated.is_none() || message.trim().is_empty() {
return false;
@@ -121,8 +121,10 @@ impl Game<'_> {
self.screen = Screen::Options;
self.options_from_title = from_title;
self.options_to_game = false;
let minecart_improvements = self.minecart_improvements();
let ui = &mut self.ui.options;
ui.open_at(!from_title && self.title.is_none(), page);
ui.minecart_improvements = minecart_improvements;
let root = self.options.resources.clone();
ui.languages = lang::available(&[&root]);
self.refresh_pack_lists();
@@ -413,6 +413,26 @@ impl Game<'_> {
}
return self.handle_option_events(vec![Event::Changed]);
}
// Splitscreen Setup: A picks up the focused player's tile, then left/right (or LB/RB)
// cycle where it goes, A drops it, B puts it back (`split_ui`).
if self.screen == Screen::Options && self.ui.options.page() == Page::SplitSetup {
let setup = &mut self.ui.options.split_setup;
if setup.pad_button(button) {
// Dropped or put back: the focus follows the player to its tile.
if let Some(rect) = setup.take_refocus() {
let kind = Kind::Button;
self.pad_focus_on(event_loop, Target { rect, kind });
}
return Ok(());
}
if button == Button::South
&& self.pad_focusing()
&& let Some(focus) = self.pad.menu.focus
&& self.ui.options.split_setup.pad_pick_up(focus.center())
{
return Ok(());
}
}
// The on-screen keyboard takes every button while it is open.
if let Some(keyboard) = self.pad.menu.osk.as_mut() {
if let Some(out) = keyboard.button(button) {
@@ -523,6 +543,14 @@ impl Game<'_> {
keyboard.step(button);
return;
}
// Splitscreen Setup with a player held: the stick cycles it as the D-pad does.
if self.screen == Screen::Options
&& self.ui.options.page() == Page::SplitSetup
&& self.ui.options.split_setup.holding()
{
self.ui.options.split_setup.pad_button(button);
return;
}
if self.pad_focus_mode() {
let targets = self.pad_targets();
if !targets.is_empty() {
+61 -26
View File
@@ -246,10 +246,16 @@ impl Game<'_> {
// 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,
),
None => {
let mut entity = self.player_camera_entity()?;
if let Some(position) = self.remote_minecart_rider_position(partial) {
// Already evaluated at partial: do not linearly interpolate
// between tick endpoints across a corner a second time.
entity.position = position;
entity.previous = position;
}
(entity, self.player.as_ref()?.shift_key_down)
}
};
let mut view = self.view;
let kind = match self.camera_type {
@@ -352,7 +358,14 @@ impl Game<'_> {
let ride_turn = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
.map_or(0.0, |b| b.delta_rotation);
.map_or_else(
|| {
self.riding
.and_then(|id| self.carts.iter().find(|c| c.id == id))
.map_or(0.0, |c| c.passenger_yaw_delta)
},
|b| b.delta_rotation,
);
let entity = CameraEntity {
position: [p.position.x, p.position.y, p.position.z],
previous: [
@@ -659,28 +672,31 @@ impl Game<'_> {
&self.player_armor_layers(),
out,
);
let chest = self.armor[1].as_ref().map(|s| &*s.item);
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
if !self.own_invisible() {
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
if horizon_client::mobs::render::layers_shown(m) {
let chest = self.armor[1].as_ref().map(|s| &*s.item);
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
if !self.own_invisible() {
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
}
self.player_ears_geometry(
m,
&self.own_profile_name(),
camera,
alpha as f32,
packed,
out,
);
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(m, self.own_entity_id(), camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// CustomHeadLayer: a pumpkin, skull or other head item.
self.head_item_geometry(m, camera, alpha, packed, true, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(m, Some(who), alpha as f32, camera, packed, out);
}
self.player_ears_geometry(
m,
&self.own_profile_name(),
camera,
alpha as f32,
packed,
out,
);
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(m, self.own_entity_id(), camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// CustomHeadLayer: a pumpkin, skull or other head item.
self.head_item_geometry(m, camera, alpha, packed, true, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(m, Some(who), alpha as f32, camera, packed, out);
// LivingEntity.isCurrentlyGlowing: the Glowing effect.
if self.potions.effects.get("glowing").is_some() {
let color = super::glowing::team_color(self.own_team());
@@ -1499,6 +1515,25 @@ mod preview_tests {
assert!(!hidden.is_empty(), "the armour still draws");
assert!(hidden.iter().all(|v| v.uv[0] >= 0.5), "only the armour");
assert_eq!(hidden.len() * 2, shown.len());
// AvatarRenderer.shouldRenderLayers: a spectator has no armour layer.
let mut ghost = m.clone();
ghost.extra.spectator = true;
assert!(!horizon_client::mobs::render::layers_shown(&ghost));
let mut with_armour = Vec::new();
let mut without = Vec::new();
for (armour, out) in [(&armour[..], &mut with_armour), (&[][..], &mut without)] {
horizon_client::mobs::render::render_layers(
&ghost,
1.0,
glam::DVec3::ZERO,
&atlas,
0xF0F0,
&data,
armour,
out,
);
}
assert!(without.is_empty() && with_armour.is_empty(), "no armour");
}
#[test]
+88 -16
View File
@@ -223,13 +223,13 @@ struct Lerp {
steps: u32,
}
/// `NewMinecartBehavior.MinecartStep` (its xRot is not kept: a minecart here
/// pitches along its rails).
/// `NewMinecartBehavior.MinecartStep`, including the server's rail pitch.
#[derive(Clone, Copy)]
struct CartStep {
position: Vec3,
movement: Vec3,
yaw: f32,
pitch: f32,
weight: f32,
}
@@ -255,15 +255,16 @@ impl CartLerp {
position: Vec3::ZERO,
movement: Vec3::ZERO,
yaw: 0.0,
pitch: 0.0,
weight: 0.0,
},
}
}
/// `NewMinecartBehavior.getCartLerpPosition`, `getCartLerpMovements` and
/// `getCartLerpYRot` at `partial`: the step the weights put this moment
/// in, and how far along it, between it and the one before.
fn at(&self, partial: f32) -> (Vec3, Vec3, f32) {
/// `getCartLerpYRot` / `getCartLerpXRot` at `partial`: the step the weights
/// put this moment in, and how far along it from the one before.
fn at(&self, partial: f32) -> (Vec3, Vec3, f32, f32) {
let alpha = ((3 - self.delay) as f32 + partial) / 3.0;
let mut count_up = 0.0f32;
let mut in_step = 1.0f32;
@@ -300,6 +301,7 @@ impl CartLerp {
lerp(previous.position, current.position),
lerp(previous.movement, current.movement),
previous.yaw + in_step * wrap_degrees(current.yaw - previous.yaw),
previous.pitch + in_step * wrap_degrees(current.pitch - previous.pitch),
)
}
@@ -314,6 +316,7 @@ impl CartLerp {
position: c.position,
movement: c.velocity,
yaw: c.yaw,
pitch: c.pitch,
weight: 0.0,
};
self.current.clear();
@@ -326,10 +329,11 @@ impl CartLerp {
if self.current.is_empty() {
return false;
}
let (position, movement, yaw) = self.at(1.0);
let (position, movement, yaw, pitch) = self.at(1.0);
c.position = position;
c.velocity = movement;
c.yaw = yaw;
c.pitch = pitch;
true
}
}
@@ -427,6 +431,8 @@ enum OwnProfile {
pub(super) struct Remote {
pub session: Session,
pub brand: String,
/// This connection's configured feature flags, carried through reconfiguration.
pub(super) features: Vec<String>,
/// The server announced it runs mods' quiet commands.
pub quiet_commands: bool,
/// The server announced it carries mod messages.
@@ -725,6 +731,15 @@ impl Game<'_> {
None => horizon_client::health::hurt_sound("hurt"),
}
}
/// Offline local profiles never borrow the account that started P1's game.
pub(super) fn auth(&self) -> Option<std::sync::Arc<horizon_client::auth::AuthSession>> {
if self.options.split_guest {
None
} else {
horizon_client::auth::current()
}
}
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
self.connect_server_with_cookies(address, name, None)
@@ -815,7 +830,7 @@ impl Game<'_> {
);
}
// A signed-in account always plays under its own name.
let auth = horizon_client::auth::current();
let auth = self.auth();
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
let user = auth.as_ref().map_or_else(
|| {
@@ -948,6 +963,7 @@ impl Game<'_> {
self.remote = Some(Remote {
session,
brand: "unknown".into(),
features: vec!["minecraft:vanilla".into()],
quiet_commands: false,
mod_net: false,
far_terrain: None,
@@ -1789,6 +1805,11 @@ impl Game<'_> {
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
@@ -2683,10 +2704,14 @@ impl Game<'_> {
if r.cart_lerps.contains_key(&id) {
// A minecart on NewMinecartBehavior keeps Entity's NO_OP
// InterpolationHandler: a teleport puts it there at once.
if path.is_some()
&& let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32)
{
c.position = at;
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32) {
if path.is_some() {
c.position = at;
}
if let Some((yaw, pitch)) = rotation {
c.yaw = yaw;
c.pitch = pitch;
}
}
} else {
// AbstractInterpolationHandler.interpolateTo: without a
@@ -3209,9 +3234,17 @@ impl Game<'_> {
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
self.spawn_entity(p.id, &kind, p.position, rotation);
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
self.remote_motion(p.id, p.velocity);
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
if let Some(r) = self.remote.as_mut() {
r.cart_lerps.remove(&p.id);
r.lerps.remove(&p.id);
}
if !self.spawn_remote_object(
p.id,
&kind,
p.position,
(rotation[0], rotation[1]),
p.data,
) {
self.spawn_remote_projectile(
p.id,
&kind,
@@ -3221,6 +3254,14 @@ impl Game<'_> {
p.data,
);
}
// Entity.recreateFromPacket: objects must exist before applying motion.
self.remote_motion(p.id, p.velocity);
if self.minecart_improvements()
&& self.carts.iter().any(|c| c.id == p.id as u32)
&& let Some(r) = self.remote.as_mut()
{
r.cart_lerps.insert(p.id, CartLerp::new());
}
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
self.move_remote_interaction(p.id, p.position);
}
@@ -4911,7 +4952,10 @@ impl Game<'_> {
};
match &mut r.own_profile {
OwnProfile::Idle => {
let Some(auth) = horizon_client::auth::current() else {
let Some(auth) = (!self.options.split_guest)
.then(horizon_client::auth::current)
.flatten()
else {
r.own_profile = OwnProfile::Done(None);
return;
};
@@ -5280,6 +5324,8 @@ impl Game<'_> {
t.velocity = v;
} else if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id as u32) {
o.velocity = v;
} else if let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32) {
c.velocity = v;
} else if let Some(p) = self
.creatures
.projectiles
@@ -6543,10 +6589,25 @@ impl Game<'_> {
}
}
}
/// `AbstractMinecartRenderer.newExtractState`: sample the same weighted
/// steps at the frame's partial tick, including corners within one tick.
pub(super) fn remote_minecart_lerp(
&self,
id: u32,
partial: f32,
) -> Option<([f64; 3], f32, f32)> {
let lerp = self.remote.as_ref()?.cart_lerps.get(&(id as i32))?;
if lerp.current.is_empty() {
return None;
}
let (p, _, yaw, pitch) = lerp.at(partial);
Some(([p.x, p.y, p.z], yaw, pitch))
}
/// `ClientPacketListener.handleMinecartAlongTrack`: the steps join the ones
/// the minecart has queued, if it runs `NewMinecartBehavior`.
fn remote_move_minecart(&mut self, p: play::MoveMinecartAlongTrack) {
if !self.carts.iter().any(|c| c.id == p.id as u32) {
if !self.minecart_improvements() || !self.carts.iter().any(|c| c.id == p.id as u32) {
return;
}
let Some(r) = self.remote.as_mut() else {
@@ -6561,6 +6622,7 @@ impl Game<'_> {
position: at(s.position),
movement: at(s.movement),
yaw: horizon_protocol::buf::unpack_degrees(s.yaw),
pitch: horizon_protocol::buf::unpack_degrees(s.pitch),
weight: s.weight,
}));
}
@@ -7060,6 +7122,16 @@ impl Game<'_> {
if passes {
return false;
}
// Minecart.interact resets the passenger's easing baseline on
// a successful boarding click, not on SetPassengers (commands
// can mount a player without this interaction).
if let Some(c) = self
.carts
.iter_mut()
.find(|c| c.id as i32 == hit.id && c.kind == super::minecarts::CartKind::Rideable)
{
c.player_rotation_offset = c.rotation_offset;
}
// MinecartCommandBlock.interact on the client: the editor opens.
if self.carts.iter().any(|c| {
c.id as i32 == hit.id && c.kind == super::minecarts::CartKind::CommandBlock
@@ -180,7 +180,7 @@ impl Game<'_> {
/// (isLocalPlayer), and only when the key pair changed: a new session
/// (random id, chain from link 0) the server hears of at once.
fn update_chat_session(&mut self) {
let Some(account) = auth::current() else {
let Some(account) = self.auth() else {
return;
};
let Some(r) = self.remote.as_mut() else {
@@ -20,9 +20,10 @@ impl Game<'_> {
id: i32,
kind: &str,
position: [f64; 3],
yaw: f32,
rotation: (f32, f32),
data: i32,
) -> bool {
let (yaw, pitch) = rotation;
let id = id as u32;
let at = Vec3::new(position[0], position[1], position[2]);
let block = position.map(|v| v.floor() as i32);
@@ -64,6 +65,8 @@ impl Game<'_> {
let mut c = super::minecarts::Minecart::new(id, cart, at);
c.yaw = yaw;
c.previous_yaw = yaw;
c.pitch = pitch;
c.previous_pitch = pitch;
self.carts.push(c);
} else if let Some((wood, chest)) = super::boats::any_boat_wood(kind)
&& self.boat_models.has(wood, chest)
@@ -322,9 +325,11 @@ impl Game<'_> {
for c in &mut self.carts {
c.previous = c.position;
c.previous_yaw = c.yaw;
c.previous_pitch = c.pitch;
if !lerp_cart(c) {
c.position = step(c.id, c.position);
}
c.accumulate_rotation();
if c.kind == super::minecarts::CartKind::Furnace
&& c.fuel > 0
&& self.world.random.next_int_bound(4).unwrap_or(1) == 0
@@ -611,15 +611,16 @@ impl Game<'_> {
// fuse down (the swell and flash follow it) between the server's
// updates (every 10 ticks), and a client discards it when the fuse
// runs out; only the server explodes it.
let (world, collision, assets, facts) = (
let (world, collision, assets, facts, fluids) = (
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
);
self.tnts.retain_mut(|t| {
// A fluid lookup that fails leaves it where its move put it.
let _ = t.tick(world, collision, assets, facts);
let _ = t.tick(world, collision, assets, facts, fluids);
t.fuse > 0
});
// FallingBlockEntity.tick runs on a client too: the block falls by
@@ -162,6 +162,9 @@ impl Game<'_> {
if passengers.contains(&me) {
if self.riding != Some(vehicle_id) {
self.riding = Some(vehicle_id);
if let Some(c) = self.carts.iter_mut().find(|c| c.id == vehicle_id) {
c.passenger_yaw_delta = 0.0;
}
if let Some(p) = self.player.as_mut() {
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
@@ -187,6 +190,22 @@ impl Game<'_> {
}
}
/// Camera.alignWithEntity's NewMinecartBehavior branch: sample the
/// vehicle's curved path at this frame, then apply the player's seat
/// attachment. Camera's own eye-height interpolation still runs afterwards.
pub(super) fn remote_minecart_rider_position(&self, partial: f32) -> Option<[f64; 3]> {
if !self.minecart_improvements() {
return None;
}
let id = self.riding?;
self.carts
.iter()
.find(|c| c.id == id && c.kind == super::minecarts::CartKind::Rideable)?;
let (mut position, _, _) = self.remote_minecart_lerp(id, partial)?;
position[1] += super::minecarts::SEAT;
Some(position)
}
/// The local player's tick in its vehicle, in place of its own movement:
/// false when it rides nothing.
pub(super) fn remote_ride(&mut self, keys: Keys) -> bool {
@@ -222,6 +241,17 @@ impl Game<'_> {
// In front of a mob that shares the boat.
Some(self.player_boat_seat(i))
} else {
// Minecart.positionRider runs on the client after the cart tick.
// The offset belongs to the cart, so switching vehicles cannot
// transfer the previous vehicle's pending turn.
let rotate =
self.minecart_improvements() && self.settings.file.bool("rotateWithMinecart");
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
c.passenger_yaw_delta = 0.0;
if rotate && c.kind == super::minecarts::CartKind::Rideable {
c.rotate_passenger(&mut self.yaw);
}
}
// A minecart: the server moves it, the rider sits in it.
let cart = self.carts.iter().find(|c| c.id == id).map(|c| {
c.position
@@ -264,6 +264,9 @@ impl Game<'_> {
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
let label = match (id, &additions) {
(14, Some((label, _))) => label.clone(),
_ if self.options.split_guest && key == "menu.disconnect" => {
Text::translate("horizon.split.leave", &[])
}
_ => Text::translate(key, &[]),
};
ui::button(batch, atlas, x, y, bw, 20.0, &label, active, hover);
+43 -7
View File
@@ -49,6 +49,7 @@ use winit::{
window::WindowId,
};
mod frames;
mod hud;
mod pads;
mod players;
@@ -144,9 +145,17 @@ pub(super) struct Split<'a> {
/// Whose game runs now (0 = P1), and P1's buttons while another's does.
turn: usize,
host_widgets: widgets::Saved,
/// `HORIZON_SPLIT_STATS`: when the frame times were last printed.
stats: Option<Instant>,
/// For the readout: where a frame's wall time goes (the turns with their renders, presents,
/// the GPU wait, outside `about_to_wait`), summed since the last print, and when this frame
/// started.
spent: [Duration; 4],
spent_frames: u32,
frame_started: Option<Instant>,
/// The controllers while several play (`pads`), and P1's.
pads: PadRouter,
p1_pad: Option<PadId>,
p1_pad: pads::P1Pad<PadId>,
/// Pads whose buttons still count as the press that gave them: released first.
fresh: Vec<PadId>,
/// Controllers offered to join, each since when: several may join at once.
@@ -191,8 +200,12 @@ impl<'a> Split<'a> {
res: None,
turn: 0,
host_widgets: Default::default(),
stats: std::env::var_os("HORIZON_SPLIT_STATS").map(|_| Instant::now()),
spent: Default::default(),
spent_frames: 0,
frame_started: None,
pads: Default::default(),
p1_pad: None,
p1_pad: Default::default(),
fresh: Vec::new(),
offers: Vec::new(),
lost: Vec::new(),
@@ -505,7 +518,10 @@ impl<'a> Split<'a> {
for pad in pad_joins {
match self.join(Some(pad), None) {
Ok(()) => changed = true,
Err(e) => log::warn!("Horizon: splitscreen: a player could not join: {e}"),
Err(e) => {
self.restore_given_up_pad(pad);
log::warn!("Horizon: splitscreen: a player could not join: {e}");
}
}
}
if self.in_world() && self.test_players > 0 {
@@ -530,7 +546,8 @@ impl<'a> Split<'a> {
/// Each window shows its players' frames, then the GPU wait, once, for all of them. Only
/// the game's window waits for the display (vsync): windows each waiting would divide the rate.
fn present(&mut self) {
/// When the windows had presented (before the GPU wait), for the frame-time readout.
fn present(&mut self) -> Instant {
let views = self.views();
let renderers: Vec<_> = std::iter::once(&self.host.renderer)
.chain(self.guests.iter().map(|g| &g.game.renderer))
@@ -547,6 +564,7 @@ impl<'a> Split<'a> {
surface.compositor.set_vsync(vsync && k == paced);
surface.compositor.present(&frames);
}
let presented = Instant::now();
// One player's CPU work overlapped the GPU drawing another's.
let renderers = std::iter::once(&mut self.host.renderer)
.chain(self.guests.iter_mut().map(|g| &mut g.game.renderer));
@@ -554,6 +572,7 @@ impl<'a> Split<'a> {
renderer.paced_elsewhere = true;
renderer.wait_in_flight();
}
presented
}
}
@@ -722,11 +741,11 @@ impl ApplicationHandler for Split<'_> {
// output (rumble, triggers) through the split's reader for P1's turn.
let pad_joins = self.route_pads(true);
self.turn(0);
if let Some(id) = self.p1_pad {
if let Some(id) = self.p1_pad.owner {
self.pads.lend_output(&mut self.host.pad, id);
}
self.host.about_to_wait(event_loop);
if self.p1_pad.is_some() {
if self.p1_pad.owner.is_some() {
self.pads.reclaim_output(&mut self.host.pad);
}
if self.admit(event_loop, pad_joins) {
@@ -734,6 +753,11 @@ impl ApplicationHandler for Split<'_> {
}
return;
}
let started = Instant::now();
if let Some(previous) = self.frame_started.replace(started) {
// What the last frame left after its own work: events, the limiter, the OS.
self.spent[3] += started.saturating_duration_since(previous);
}
let pad_joins = self.route_pads(false);
self.turn(0);
self.host.about_to_wait(event_loop);
@@ -786,7 +810,19 @@ impl ApplicationHandler for Split<'_> {
}
}
self.turn(0);
self.present();
let rendered = Instant::now();
let presented = self.present();
let waited = Instant::now();
for (k, d) in [rendered - started, presented - rendered, waited - presented]
.into_iter()
.enumerate()
{
self.spent[k] += d;
}
// The rest of the frame (the limiter, the OS) counts as outside, from `waited` on.
self.spent[3] = self.spent[3].saturating_sub(waited - started);
self.spent_frames += 1;
self.print_stats();
// One pace for all: the fastest any player wants, so an idle one (vanilla's AFK limit)
// can't slow the others. With vsync the display paces anything at or above its refresh
// rate; the limiter on top would miss refreshes.
@@ -0,0 +1,44 @@
//! The frame-time readout (`HORIZON_SPLIT_STATS=1`, docs/extras.md). The players render one
//! after another: on threads, their renders slowed each other down (the GPU driver's per-process
//! state) more than they overlapped.
use super::*;
impl<'a> Split<'a> {
/// `HORIZON_SPLIT_STATS=1`: every 5 s, where a frame's time goes (the players' turns with
/// their renders, the windows' presents, the GPU wait, outside the loop) and each player's
/// frame rate and CPU and GPU time a frame, in the log.
pub(super) fn print_stats(&mut self) {
let Some(since) = self.stats else {
return;
};
if since.elapsed() < Duration::from_secs(5) {
return;
}
self.stats = Some(Instant::now());
let seats: Vec<usize> = (0..self.players()).map(|i| self.seat_of(i)).collect();
let games =
std::iter::once(&mut self.host).chain(self.guests.iter_mut().map(|g| &mut *g.game));
let frames = self.spent_frames.max(1);
let [turns, presents, wait, outside] =
std::mem::take(&mut self.spent).map(|d| d.as_secs_f64() * 1000.0 / f64::from(frames));
self.spent_frames = 0;
let mut line = format!(
" a frame {:.2} ms: turns {turns:.2} (with the renders), presents {presents:.2}, gpu wait {wait:.2}, outside {outside:.2}.",
turns + presents + wait + outside,
);
for (i, game) in games.enumerate() {
let s = game.frame_stats.summary();
let ms = |k: &str| s[k]["avg_ms"].as_f64().unwrap_or(0.0);
line += &format!(
" P{}: {:.0} fps, cpu {:.2} ms (render {:.2} ms), gpu {:.2} ms",
seats[i],
s["avg_fps"].as_f64().unwrap_or(0.0),
ms("cpu"),
s["phases_avg_ms"]["render"].as_f64().unwrap_or(0.0),
ms("gpu"),
);
game.frame_stats.reset();
}
log::info!("Horizon: splitscreen frames:{line}");
}
}
+12 -4
View File
@@ -58,7 +58,7 @@ fn panel(batch: &mut HudBatch, atlas: &horizon_client::hud::HudAtlas, [px, py, p
pub(super) struct Prompt {
pub(super) ask: Ask,
pub(super) title: String,
pub(super) choices: Vec<(Button, &'static str)>,
pub(super) choices: Vec<(Button, String)>,
}
/// What a prompt asks: a controller may join (every view, a strip at the top: nobody stops
@@ -70,12 +70,20 @@ pub(super) enum Ask {
Lost,
}
/// A splitscreen prompt's text in the selected language (`horizon.splitscreen.prompt.*`).
pub(super) fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> String {
horizon_client::lang::tr(&format!("horizon.splitscreen.prompt.{key}"), args)
}
/// The question on a waiting player's view.
pub(super) fn lost_prompt(name: &str) -> Prompt {
Prompt {
ask: Ask::Lost,
title: format!("{name}: controller disconnected"),
choices: vec![(Button::South, "Use another"), (Button::East, "Leave")],
title: tr("lost", &[&name]),
choices: vec![
(Button::South, tr("lost.use_another", &[])),
(Button::East, tr("lost.leave", &[])),
],
}
}
@@ -119,7 +127,7 @@ impl Game<'_> {
let glyphs: Vec<_> = prompt
.choices
.iter()
.map(|&(b, what)| (b.label(style), b.color(style), what))
.map(|(b, what)| (b.label(style), b.color(style), what.as_str()))
.collect();
let line: f32 = glyphs
.iter()
+140 -24
View File
@@ -1,6 +1,7 @@
//! Controllers: which player each drives, joining with one, and one disconnecting (its player
//! waits for another). One reader for every player's game (`PadRouter`) while splitscreen
//! watches the controllers; otherwise P1 reads them itself, as without splitscreen.
use super::hud::tr;
use super::*;
use crate::game::pad::{PadEvent, PadId};
use horizon_client::gamepad::{Button, PadState};
@@ -8,6 +9,57 @@ use horizon_client::gamepad::{Button, PadState};
/// How long a controller's offer to join stays up.
const OFFER: Duration = Duration::from_secs(10);
/// P1's controller, and one P1 gave away (Give Up Pad) while it's offered to join: until the
/// other player is in the world, cancelling, declining or the offer running out gives that same
/// pad back, never another player's.
pub(super) struct P1Pad<Id> {
pub(super) owner: Option<Id>,
released: Option<Id>,
}
impl<Id> Default for P1Pad<Id> {
fn default() -> Self {
Self {
owner: None,
released: None,
}
}
}
impl<Id: Copy + Eq> P1Pad<Id> {
pub(super) fn claim(&mut self, id: Id) {
self.owner = Some(id);
if self.released == Some(id) {
self.released = None;
}
}
fn release(&mut self) -> Option<Id> {
let id = self.owner.take()?;
self.released = Some(id);
Some(id)
}
fn cancel(&mut self, id: Id) -> bool {
if self.released != Some(id) {
return false;
}
self.released = None;
if self.owner.is_some() {
return false;
}
self.owner = Some(id);
true
}
pub(super) fn joined(&mut self, id: Id) {
if self.released == Some(id) {
self.released = None;
}
}
fn gone(&mut self, id: Id) {
if self.owner == Some(id) {
self.owner = None;
}
self.joined(id);
}
}
/// A player whose controller disconnected, waiting for one: its seat (1 = P1), and the
/// controller's id and kind (`PadRouter::uuid`) to know it back.
pub(super) struct Lost<Id> {
@@ -72,7 +124,7 @@ impl Split<'_> {
self.offers.clear();
self.fresh.clear();
self.lost.clear();
self.p1_pad = None;
self.p1_pad = P1Pad::default();
self.pads = PadRouter::default();
}
}
@@ -98,7 +150,7 @@ impl Split<'_> {
/// `solo`: no compositor, P1 alone (its controller's output is lent to it after this).
pub(super) fn route_pads(&mut self, solo: bool) -> Vec<PadId> {
let now = Instant::now();
let owner_before = self.p1_pad;
let owner_before = self.p1_pad.owner;
let offers_before = self.offers.len();
let mut joins = Vec::new();
let mut presses = Vec::new();
@@ -116,7 +168,7 @@ impl Split<'_> {
}
self.forget_lost();
for (id, button) in presses {
if Some(id) == self.p1_pad || self.guests.iter().any(|g| g.pad == Some(id)) {
if Some(id) == self.p1_pad.owner || self.guests.iter().any(|g| g.pad == Some(id)) {
continue;
}
// Pressed, then gone in the same frame.
@@ -140,10 +192,10 @@ impl Split<'_> {
in_world,
p1_waits,
self.host.settings.controller.enabled,
self.p1_pad.is_some(),
self.p1_pad.owner.is_some(),
);
if claims_p1 {
self.p1_pad = Some(id);
self.p1_pad.claim(id);
self.fresh.push(id);
continue;
}
@@ -158,14 +210,16 @@ impl Split<'_> {
joins.push(id);
}
Button::North
if self.p1_pad.is_none() && self.host.settings.controller.enabled =>
if self.p1_pad.owner.is_none() && self.host.settings.controller.enabled =>
{
self.offers.remove(k);
self.p1_pad = Some(id);
self.p1_pad.claim(id);
self.fresh.push(id);
}
// No: a pad P1 gave up goes back to P1.
Button::East => {
self.offers.remove(k);
self.restore_given_up_pad(id);
}
_ => {}
},
@@ -178,18 +232,28 @@ impl Split<'_> {
}
}
let in_world = self.in_world();
// An offer that ran out: a pad P1 gave up goes back to P1.
let expired: Vec<_> = self
.offers
.iter()
.filter(|(_, since)| now - *since > OFFER || !in_world)
.map(|(id, _)| *id)
.collect();
for id in expired {
self.restore_given_up_pad(id);
}
self.offers
.retain(|(_, since)| now - *since <= OFFER && in_world);
if self.offers.is_empty() {
self.prompt_join(None);
} else if self.offers.len() != offers_before
|| self.p1_pad != owner_before
|| self.p1_pad.owner != owner_before
|| !joins.is_empty()
{
self.ask_to_join();
}
// Each player's glyphs follow its own controller (its game never reads the pads itself).
if let Some(id) = self.p1_pad {
if let Some(id) = self.p1_pad.owner {
self.host.pad.style = self.pads.style(id);
}
for g in &mut self.guests {
@@ -201,10 +265,10 @@ impl Split<'_> {
// lends it its reader for P1's turn (`lend_output`), so no second reader opens. Only
// when nothing is lent does P1's own reader close: releasing a lent one each frame
// would stop its rumble a frame after it starts.
if !lends_output(solo, self.p1_pad.is_some()) {
if !lends_output(solo, self.p1_pad.owner.is_some()) {
self.host.pad.release_hardware();
}
self.host.pad.feed = Some(self.pad_state(self.p1_pad));
self.host.pad.feed = Some(self.pad_state(self.p1_pad.owner));
for i in 0..self.guests.len() {
let state = self.pad_state(self.guests[i].pad);
self.guests[i].game.pad.feed = Some(state);
@@ -212,6 +276,27 @@ impl Split<'_> {
joins
}
/// P1's Give Up Pad: its controller stops driving P1 and is offered to join, for a friend
/// to press A (P1 plays on with the keyboard and mouse).
pub(super) fn give_up_pad(&mut self) {
let Some(id) = self.p1_pad.release() else {
return;
};
self.offers.retain(|(o, _)| *o != id);
self.offers.push((id, Instant::now()));
self.host.pad.release_hardware();
self.host.pad.feed = Some(PadState::default());
self.ask_to_join();
}
/// A pad P1 gave up whose offer ended without a player in the world: P1's again.
pub(super) fn restore_given_up_pad(&mut self, id: PadId) {
if self.pads.connected(id) && self.p1_pad.cancel(id) {
// The Cancel/No press belongs to the transfer, not P1's menu.
self.fresh.push(id);
}
}
/// A controller disconnected: its offer and fresh press go; its player waits for one
/// (P1 keeps the keyboard and mouse).
fn pad_gone(&mut self, id: PadId) {
@@ -222,13 +307,14 @@ impl Split<'_> {
self.ask_to_join();
}
let uuid = self.pads.uuid(id);
if self.p1_pad == Some(id) {
self.p1_pad = None;
let owned = self.p1_pad.owner == Some(id);
self.p1_pad.gone(id);
if owned {
if self.in_world() {
self.lost.push(Lost { seat: 1, id, uuid });
self.host.split_hud.notice = Some(Notice::new(
"Controller disconnected",
"Reconnect it, or press a button on another",
tr("controller_disconnected", &[]),
tr("controller_disconnected.hint", &[]),
));
}
return;
@@ -254,7 +340,7 @@ impl Split<'_> {
fn give_pad(&mut self, seat: usize, id: PadId) {
self.fresh.push(id);
let hud = if seat == 1 {
self.p1_pad = Some(id);
self.p1_pad.claim(id);
&mut self.host.split_hud
} else {
let Some(g) = self.guests.iter_mut().find(|g| g.seat == seat) else {
@@ -266,7 +352,7 @@ impl Split<'_> {
if hud.prompt.as_ref().is_some_and(|p| p.ask == Ask::Lost) {
hud.prompt = None;
}
hud.notice = Some(Notice::new("Controller connected", ""));
hud.notice = Some(Notice::new(tr("controller_connected", &[]), ""));
}
/// A button on a controller nobody has while players wait for one: true when it answered
@@ -300,7 +386,7 @@ impl Split<'_> {
/// Nobody waits for a player who left or has a controller again (another way), nor for P1
/// out of a world.
fn forget_lost(&mut self) {
let p1 = self.in_world() && self.p1_pad.is_none();
let p1 = self.in_world() && self.p1_pad.owner.is_none();
let guests = &self.guests;
self.lost.retain(|l| match l.seat {
1 => p1,
@@ -311,14 +397,14 @@ impl Split<'_> {
/// Every player sees the controllers waiting to join, whatever it is doing.
fn ask_to_join(&mut self) {
let title = match self.offers.as_slice() {
[(id, _)] => format!("{} wants to join", self.pads.name(*id)),
offers => format!("{} controllers want to join", offers.len()),
[(id, _)] => tr("join.one", &[&self.pads.name(*id)]),
offers => tr("join.many", &[&offers.len()]),
};
let mut choices = vec![(Button::South, "Join")];
if self.p1_pad.is_none() && self.host.settings.controller.enabled {
choices.push((Button::North, "Play as P1"));
let mut choices = vec![(Button::South, tr("join.yes", &[]))];
if self.p1_pad.owner.is_none() && self.host.settings.controller.enabled {
choices.push((Button::North, tr("join.as_p1", &[])));
}
choices.push((Button::East, "No"));
choices.push((Button::East, tr("join.no", &[])));
self.prompt_join(Some(Prompt {
ask: Ask::Join,
title,
@@ -474,4 +560,34 @@ mod tests {
let waiting = [lost(2, [0; 16])];
assert_eq!(claim(&waiting, [0; 16], Button::North, true), Claim::Wait);
}
#[test]
fn a_pending_transfer_can_be_canceled_without_claiming_an_unrelated_pad() {
let mut p1 = P1Pad::default();
p1.claim(7);
assert_eq!(p1.release(), Some(7));
assert_eq!(p1.owner, None);
assert!(!p1.cancel(8));
assert!(p1.cancel(7));
assert_eq!(p1.owner, Some(7));
assert!(!p1.cancel(7));
}
#[test]
fn entering_the_world_commits_the_transfer_and_reclaim_does_not_steal_a_pad() {
let mut p1 = P1Pad::default();
p1.claim(7);
p1.release();
p1.joined(7);
assert!(!p1.cancel(7));
assert_eq!(p1.owner, None);
p1.claim(7);
p1.release();
p1.claim(8);
assert!(!p1.cancel(7));
assert_eq!(p1.owner, Some(8));
p1.release();
p1.gone(8);
assert!(!p1.cancel(8));
}
}
@@ -350,6 +350,12 @@ impl Split<'_> {
for k in (0..self.guests.len()).rev() {
let g = &mut self.guests[k];
g.entered |= g.game.remote.is_some() && g.game.player.is_some();
// In the world: a pad P1 gave up is this player's for good.
if g.entered
&& let Some(id) = g.pad
{
self.p1_pad.joined(id);
}
let g = &self.guests[k];
// A player picking a profile has no connection yet.
let playing = g.name.is_none() || g.game.remote.is_some();
@@ -384,6 +390,12 @@ impl Split<'_> {
self.turn(0);
}
let guest = self.guests.remove(k);
// Gone before it got into the world: a pad P1 gave up goes back to P1.
if !guest.entered
&& let Some(id) = guest.pad
{
self.restore_given_up_pad(id);
}
if self.focus > k + 1 {
self.focus -= 1;
}
+17 -1
View File
@@ -130,7 +130,16 @@ impl<'a> Split<'a> {
self.refresh_monitors();
let live = self.draft();
let (monitors, main) = self.setup_monitors();
let names = (0..self.players()).map(|i| self.name(i)).collect();
// A player whose controller disconnected says so on its tile.
let names = (0..self.players())
.map(|i| {
let name = self.name(i);
match self.lost.iter().any(|l| l.seat == self.seat_of(i)) {
true => format!("{name} (no controller)"),
false => name,
}
})
.collect();
let seats: Vec<usize> = (0..self.players()).map(|i| self.seat_of(i)).collect();
let moved = std::mem::replace(&mut self.setup_seats, seats) != self.setup_seats;
let setup = &mut self.host.ui.options.split_setup;
@@ -140,14 +149,21 @@ impl<'a> Split<'a> {
}
let applied = setup.apply.take();
let identify = std::mem::take(&mut setup.identify);
let give_up = std::mem::take(&mut setup.give_up_pad);
if std::mem::take(&mut setup.toggle_names) {
let e = &mut self.host.settings.extras;
e.splitscreen_names = !e.splitscreen_names;
self.host.settings.save(&Game::options_path());
}
let names_shown = self.host.settings.extras.splitscreen_names;
if give_up {
self.give_up_pad();
}
// Give Up Pad shows while P1 has a controller in a world others can join.
let p1_pad = self.p1_pad.owner.is_some() && self.in_world();
let setup = &mut self.host.ui.options.split_setup;
setup.names_shown = names_shown;
setup.p1_pad = p1_pad;
setup.idle = self.surfaces.is_empty();
setup.live = Some(live);
setup.monitors = monitors;
+28 -176
View File
@@ -1,7 +1,8 @@
//! Primed TNT (`PrimedTnt`): 80-tick fuse, gravity and drag, explosion of
//! power 4, `TntRenderer` swell and white flash, and `TntBlock.useItemOn`
//! (flint and steel / fire charge).
use super::Game;
use super::{Game, entity::Entity};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
Result, collisions::SceneCollisions, items::ItemStack, mesher::Vertex, mobs::Who, text::Text,
};
@@ -48,11 +49,10 @@ impl PrimedTnt {
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
assets: &horizon_client::assets::Assets,
facts: &horizon_client::blocks::BlockFacts,
fluids: &horizon_client::motion::FluidResources,
) -> Result<Vec<BlockPos>> {
self.previous = self.position;
if !self.no_gravity {
self.velocity.y -= 0.04;
}
self.velocity.y -= self.get_gravity();
let mut scene = SceneCollisions {
world,
binding: collision,
@@ -132,27 +132,7 @@ impl PrimedTnt {
self.fuse -= 1;
if self.fuse > 0 {
// PrimedTnt updates immersion after moving and decrementing its fuse.
let mut fluids =
horizon_world::entity_fluid::EntityFluidInteraction::new([WATER, LAVA]);
let mut fluid_world = TntFluidWorld {
tnt: self,
world,
assets,
facts,
};
fluids
.update(&mut fluid_world, false, 4096)
.map_err(|e| std::io::Error::other(e.to_string()))?;
fluids.apply_current(&mut fluid_world, WATER, 0.014)?;
fluids.apply_current(
&mut fluid_world,
LAVA,
if world.fast_lava {
0.007
} else {
0.0023333333333333335
},
)?;
self.push_by_fluid(world, fluids);
}
Ok(tops)
}
@@ -265,6 +245,7 @@ impl Game<'_> {
self.collision,
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
)?;
for pos in tops {
self.bubble_column_particles(pos);
@@ -469,148 +450,6 @@ impl Game<'_> {
}
}
use horizon_world::{
entity_fluid::{
CurrentTarget, FluidInteractionWorld, FluidMembership, FluidSections, FluidTagId,
},
fluid::{FluidHeightWorld, FluidKind, FluidState},
fluid_flow::{FluidFlowWorld, flow_at},
};
const WATER: FluidTagId = FluidTagId(0);
const LAVA: FluidTagId = FluidTagId(1);
struct TntFluidWorld<'a> {
tnt: &'a mut PrimedTnt,
world: &'a horizon_client::world::World,
assets: &'a horizon_client::assets::Assets,
facts: &'a horizon_client::blocks::BlockFacts,
}
impl TntFluidWorld<'_> {
fn state(&self, p: BlockPos) -> horizon_world::registry::BlockStateId {
self.world.block(p.x, p.y, p.z)
}
fn fluid_at(&self, p: BlockPos) -> std::io::Result<FluidState> {
FluidState::from_block(
self.assets
.registry
.state(self.state(p))
.ok_or_else(|| std::io::Error::other("missing TNT fluid state"))?,
)
}
}
impl FluidMembership for TntFluidWorld<'_> {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
match tag {
WATER => matches!(kind, FluidKind::Water | FluidKind::FlowingWater),
LAVA => matches!(kind, FluidKind::Lava | FluidKind::FlowingLava),
_ => false,
}
}
}
impl CurrentTarget for TntFluidWorld<'_> {
type Error = std::io::Error;
fn is_player_class(&self) -> bool {
false
}
fn velocity(&mut self) -> std::io::Result<Vec3> {
Ok(self.tnt.velocity)
}
fn add_delta_movement(&mut self, v: Vec3) -> std::io::Result<()> {
self.tnt.velocity = Vec3::new(
self.tnt.velocity.x + v.x,
self.tnt.velocity.y + v.y,
self.tnt.velocity.z + v.z,
);
Ok(())
}
}
impl FluidInteractionWorld for TntFluidWorld<'_> {
type Error = std::io::Error;
type Chunk = (i32, i32);
fn interaction_box(&mut self) -> std::io::Result<Option<Aabb>> {
Ok(Some(
self.tnt.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
))
}
fn loaded_chunk(&mut self, x: i32, z: i32) -> std::io::Result<Option<Self::Chunk>> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some((x, z)))
}
fn chunk_sections(&mut self, _: Self::Chunk) -> std::io::Result<FluidSections<'_>> {
let layout = self.world.layout();
Ok(FluidSections {
minimum_section_y: layout.min_y() / 16,
has_fluid: horizon_client::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
self.tnt.position.y
}
fn block_x(&self) -> i32 {
self.tnt.position.x.floor() as i32
}
fn block_z(&self) -> i32 {
self.tnt.position.z.floor() as i32
}
fn eye_y(&mut self) -> std::io::Result<f64> {
Ok(self.tnt.position.y + f64::from(0.98f32 * 0.85f32))
}
fn fluid_state(&mut self, p: BlockPos) -> std::io::Result<FluidState> {
self.fluid_at(p)
}
fn fluid_height(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<f32> {
s.height_at(self, [p.x, p.y, p.z])
}
fn camera_height(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<f32> {
s.camera_height_at(self, [p.x, p.y, p.z])
}
fn flow(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<Vec3> {
flow_at(self, s, p)
}
}
impl FluidHeightWorld for TntFluidWorld<'_> {
type Error = std::io::Error;
fn fluid(&mut self, p: [i32; 3]) -> std::io::Result<FluidState> {
self.fluid_at(BlockPos::new(p[0], p[1], p[2]))
}
fn sturdy_down(&mut self, p: [i32; 3]) -> std::io::Result<bool> {
Ok(self
.facts
.get(self.state(BlockPos::new(p[0], p[1], p[2])))
.sturdy_full
& 1
!= 0)
}
}
impl FluidFlowWorld for TntFluidWorld<'_> {
type Error = std::io::Error;
type Block = horizon_world::registry::BlockStateId;
fn fluid(&mut self, p: BlockPos) -> std::io::Result<FluidState> {
self.fluid_at(p)
}
fn block(&mut self, p: BlockPos) -> std::io::Result<Self::Block> {
Ok(self.state(p))
}
fn blocks_fluid_flow(&self, s: Self::Block) -> bool {
self.assets
.blocks_fluid_flow
.contains(&self.assets.registry.state(s).unwrap().name)
}
fn is_ice_class(&self, s: Self::Block) -> bool {
self.facts
.get(s)
.classes
.iter()
.any(|c| c.as_ref() == "IceBlock")
}
fn is_face_sturdy(
&mut self,
s: Self::Block,
_: BlockPos,
d: horizon_core::geometry::Direction,
) -> std::io::Result<bool> {
Ok(self.facts.get(s).sturdy_full & (1 << d as u8) != 0)
}
}
impl Game<'_> {
/// `positionRider` for the mobs riding primed TNT: the passenger attachment
/// (the top of its 0.98 box), less the rider's own vehicle attachment.
@@ -713,6 +552,7 @@ mod tests {
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
@@ -740,7 +580,8 @@ mod tests {
guest: None,
power: 4.0,
};
tnt.tick(&world, &collision, &assets, &facts).unwrap();
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap();
assert_eq!(
tnt.position.x, at.x,
"current is applied after motion, not before"
@@ -751,13 +592,15 @@ mod tests {
tnt.velocity
);
assert_eq!(tnt.fuse, 79);
tnt.tick(&world, &collision, &assets, &facts).unwrap();
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap();
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
assert!(tnt.on_ground);
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 1;
tnt.tick(&world, &collision, &assets, &facts).unwrap();
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap();
assert_eq!(tnt.fuse, 0);
assert_eq!(
tnt.velocity.x, 0.0,
@@ -767,7 +610,8 @@ mod tests {
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 80;
tnt.tick(&world, &collision, &assets, &facts).unwrap();
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap();
assert_eq!(
tnt.velocity.x, 0.0,
"still water must not add horizontal motion"
@@ -782,7 +626,8 @@ mod tests {
.unwrap();
tnt.on_ground = false;
tnt.velocity = Vec3::ZERO;
tnt.tick(&world, &collision, &assets, &facts).unwrap();
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap();
assert!(
tnt.position.y < at.y && tnt.velocity.y < 0.0,
"TNT sinks; water does not add buoyancy"
@@ -800,6 +645,7 @@ mod tests {
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
@@ -837,7 +683,8 @@ mod tests {
power: 4.0,
};
for _ in 0..10 {
tnt.tick(&world, &collision, &assets, &facts).unwrap();
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap();
}
assert!(
tnt.velocity.y > 0.0,
@@ -857,7 +704,8 @@ mod tests {
tnt.velocity = Vec3::ZERO;
tnt.on_ground = false;
for _ in 0..4 {
tnt.tick(&world, &collision, &assets, &facts).unwrap();
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap();
}
assert!(
tnt.position.y < at.y,
@@ -880,6 +728,7 @@ mod tests {
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
@@ -914,7 +763,10 @@ mod tests {
let mut tops = Vec::new();
let mut tnt = primed();
for _ in 0..30 {
tops.extend(tnt.tick(&world, &collision, &assets, &facts).unwrap());
tops.extend(
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap(),
);
}
assert!(!tops.is_empty(), "it reaches the top of the column");
assert!(
@@ -926,7 +778,7 @@ mod tests {
let mut tnt = primed();
for _ in 0..10 {
assert!(
tnt.tick(&world, &collision, &assets, &facts)
tnt.tick(&world, &collision, &assets, &facts, &fluids)
.unwrap()
.is_empty()
);
@@ -177,8 +177,6 @@ impl WorldRequest {
o.flat_settings(),
o.single_biome(),
)?;
// WorldCreationUiState.getGameRules, written with the level.
level.game_rules = o.rules.by_name();
// The DataPackConfig the screen chose: the picked packs on,
// everything else disabled — the feature packs left out, and the
// world packs the Data Packs screen held back (`install` copies
@@ -207,16 +205,13 @@ impl WorldRequest {
// picked feature packs over vanilla's (`FeatureFlags.REGISTRY`'s
// codec writes the whole set, `minecraft:vanilla` included, and
// omits the tag when it is the default).
let mut flags = vec!["minecraft:vanilla".to_owned()];
flags.extend(
packs
.iter()
.filter(|p| o.data_packs.contains(&p.id))
.flat_map(|p| p.features()),
);
flags.sort();
flags.dedup();
level.enabled_features = flags;
level.enabled_features = o.enabled_features(&options.resources);
// CreateWorldScreen copies the edited rules with the final flags,
// even when the player never opened the rules screen.
level.game_rules = o
.rules
.copy_with_features(&level.enabled_features)
.by_name();
}
// Worlds made without the Create New World screen (--world, the
// control socket) take its default: commands on for Creative only.
@@ -6,7 +6,7 @@
use crate::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
gamerules::{self, GameRules, Rule, Value},
hud::{HudAtlas, HudBatch},
lang,
text::{Style, Text, named_color},
@@ -153,8 +153,8 @@ impl RulesScreen {
fn build(rules: &GameRules, in_world: bool) -> Self {
// setInitialFocus: the search box.
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
let numbers = RULES
.iter()
let numbers = rules
.available_rules()
.filter(|r| matches!(r.default, Value::Int(_)))
.map(|r| {
let mut b = EditBox::new(0.0, 0.0, 44.0, 20.0);
@@ -181,7 +181,7 @@ impl RulesScreen {
let filter = self.search.value.to_lowercase();
let has = |s: &str| s.to_lowercase().contains(&filter);
let mut categories: Vec<(&'static str, Vec<&'static Rule>)> = Vec::new();
for r in RULES {
for r in self.values.available_rules() {
let key = name_key(r);
let description = format!("{key}.description");
let matches = has(r.name)
@@ -238,8 +238,8 @@ impl RulesScreen {
.any(|(name, b)| gamerules::rule(name).is_none_or(|r| parse(r, &b.value).is_none()))
}
fn changes(&self) -> Vec<(&'static str, Value)> {
RULES
.iter()
self.values
.available_rules()
.filter_map(|r| {
let value = self.values.get(r.name)?;
(Some(value) != self.initial.get(r.name)).then_some((r.name, value))
@@ -636,6 +636,7 @@ impl RulesScreen {
#[cfg(test)]
mod tests {
use super::*;
use crate::gamerules::RULES;
#[test]
fn a_switch_narrates_its_name_value_description_and_next_value() {
+21 -32
View File
@@ -469,11 +469,12 @@ impl FileSprites {
cache.insert(sprite.to_owned(), translucent);
}
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
.ok()
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
.and_then(|m| m.get("animation").map(Value::to_string))
.and_then(|a| AnimationMetadata::from_json(&a).ok());
let animation =
horizon_core::prepared_files::read_to_string(&png.with_extension("png.mcmeta"))
.ok()
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
.and_then(|m| m.get("animation").map(Value::to_string))
.and_then(|a| AnimationMetadata::from_json(&a).ok());
let (w, h) = match &animation {
Some(a) => {
let [w, h] = a.frame_size(iw as i32, ih as i32);
@@ -542,40 +543,28 @@ pub struct ItemRenderer {
pub cit: Option<cit::CitModels>,
}
/// Every `items/**/*.json` client item definition, by identifier.
/// Every `items/**/*.json` client item definition, by identifier. Through
/// `json_folder`: from the prepared data file when the root has one.
fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
fn walk(dir: &Path, rel: &str, namespace: &str, out: &mut BTreeMap<String, ClientItem>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
continue;
};
let rel = if rel.is_empty() {
name
} else {
format!("{rel}/{name}")
};
if path.is_dir() {
walk(&path, &rel, namespace, out);
} else if let Some(id) = rel.strip_suffix(".json")
&& let Some(item) = fs::read_to_string(&path)
.ok()
.and_then(|t| ClientItem::from_json(&t).ok())
{
out.insert(format!("{namespace}:{id}"), item);
}
}
}
let mut out = BTreeMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let Ok(folder) = horizon_world::json_folder::read(&ns.path().join("items")) else {
continue;
};
let namespace = ns.file_name().to_string_lossy().into_owned();
walk(&ns.path().join("items"), "", &namespace, &mut out);
for file in &folder.files {
if let Some(item) = file
.bytes()
.ok()
.and_then(|b| std::str::from_utf8(b).ok())
.and_then(|t| ClientItem::from_json(t).ok())
{
out.insert(format!("{namespace}:{}", file.name), item);
}
}
}
out
}
+18
View File
@@ -103,6 +103,20 @@ impl CommandTree {
builtin: &Builtin,
extra: &Value,
) -> Commands {
self.packet_with_features(level, dedicated, builtin, extra, &[])
}
pub fn packet_with_features(
&self,
level: u8,
dedicated: bool,
builtin: &Builtin,
extra: &Value,
features: &[String],
) -> Commands {
let minecart_improvements = features
.iter()
.any(|f| f == crate::gamerules::MINECART_IMPROVEMENTS);
let mut nodes = vec![Node {
name: String::new(),
kind: 0,
@@ -123,6 +137,7 @@ impl CommandTree {
&mut Vec::new(),
level,
dedicated,
minecart_improvements,
&suggestions,
&mut nodes,
&mut paths,
@@ -183,6 +198,7 @@ fn fill(
path: &mut Vec<String>,
level: u8,
dedicated: bool,
minecart_improvements: bool,
suggestions: &HashMap<&str, &'static str>,
nodes: &mut Vec<Node>,
paths: &mut HashMap<Vec<String>, usize>,
@@ -215,6 +231,7 @@ fn fill(
if required > level
|| path.len() == 1
&& path[0] == "gamerule"
&& !minecart_improvements
&& EXPERIMENTAL_RULES.contains(&name.as_str())
{
continue;
@@ -255,6 +272,7 @@ fn fill(
path,
level,
dedicated,
minecart_improvements,
suggestions,
nodes,
paths,
+11 -16
View File
@@ -91,21 +91,16 @@ fn qualified(id: &str) -> String {
}
}
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted.
fn walk(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Value>) {
let Ok(read) = std::fs::read_dir(dir) else {
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
/// prepared data file, and read once for the tables a load builds from the same folder.
fn walk(dir: &Path, out: &mut BTreeMap<String, Value>) {
let Ok(folder) = horizon_world::json_folder::read(dir) else {
return;
};
for entry in read.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() {
walk(&path, &format!("{prefix}{name}/"), out);
} else if let Some(stem) = name.strip_suffix(".json")
&& let Ok(text) = std::fs::read_to_string(&path)
&& let Ok(value) = serde_json::from_str::<Value>(&text)
{
out.insert(format!("{prefix}{stem}"), value);
for file in &folder.files {
if let Ok(value) = serde_json::from_slice::<Value>(file.bytes().unwrap_or_default()) {
out.insert(file.name.clone(), value);
}
}
}
@@ -226,7 +221,7 @@ impl SyncData {
for key in SYNCHRONIZED {
let path = key.trim_start_matches("minecraft:");
let mut found = BTreeMap::new();
walk(&root.join(path), "", &mut found);
walk(&root.join(path), &mut found);
let mut entries: Vec<_> = found
.into_iter()
.map(|(p, v)| (("minecraft".to_owned(), p), v, false))
@@ -389,7 +384,7 @@ impl SyncData {
};
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
let mut own = BTreeMap::new();
walk(&root.join("tags/dialog"), "", &mut own);
walk(&root.join("tags/dialog"), &mut own);
for (name, value) in own {
Self::add_tag_values(files.entry(qualified(&name)).or_default(), &value);
}
@@ -497,7 +492,7 @@ impl SyncData {
continue;
}
let mut files = BTreeMap::new();
walk(&dir, "", &mut files);
walk(&dir, &mut files);
if files.is_empty() {
continue;
}
+121 -5
View File
@@ -47,6 +47,9 @@ pub struct EntityState {
pub head_yaw: f32,
pub on_ground: bool,
pub velocity: [f64; 3],
/// NewMinecartBehavior steps drained from the simulation since its last
/// export. None selects the ordinary entity movement packets.
pub minecart_steps: Option<Vec<play::MinecartStep>>,
/// ClientboundAddEntityPacket.data (item frame direction, block state ...).
pub data: i32,
/// SynchedEntityData entries that differ from the defaults.
@@ -80,6 +83,9 @@ pub struct EntityState {
/// `ServerPlayer.getCamera() != this`: a spectator watching through
/// another entity is shown to nobody (`ServerPlayer.broadcastToPlayer`).
pub watching: bool,
/// A player a native mod hid (`native_hide_player`): only clients whose
/// player has at least this permission level track it.
pub hidden: Option<u8>,
}
impl EntityState {
@@ -95,6 +101,7 @@ impl EntityState {
(self.yaw, self.pitch, self.head_yaw) = (0.0, 0.0, 0.0);
self.on_ground = false;
self.velocity = [0.0; 3];
self.minecart_steps = None;
self.data = 0;
self.meta.clear();
self.defaults.clear();
@@ -107,6 +114,7 @@ impl EntityState {
self.leash = None;
self.spectator = false;
self.watching = false;
self.hidden = None;
}
}
@@ -232,6 +240,8 @@ struct ServerEntity {
head: i8,
/// lastSentMovement.
velocity: [f64; 3],
/// NewMinecartBehavior.lerpSteps accumulated across tracking intervals.
minecart_steps: Vec<play::MinecartStep>,
/// trackedDataValues: the entity data the clients hold.
meta: Vec<(u8, i32, Value)>,
equipment: Vec<(u8, Option<Stack>)>,
@@ -259,6 +269,8 @@ struct ServerEntity {
spectator: bool,
/// Likewise for a spectator who watches through a camera.
watching: bool,
/// Likewise for a player a native mod hid.
hidden: Option<u8>,
}
/// SectionPos.of(entity).
@@ -307,6 +319,7 @@ impl ServerEntity {
rotation: (pack_degrees(e.pitch), pack_degrees(e.yaw)),
head: pack_degrees(e.head_yaw),
velocity: e.velocity,
minecart_steps: e.minecart_steps.clone().unwrap_or_default(),
meta: e.meta.clone(),
equipment: e.equipment.clone(),
attributes: e.attributes.clone(),
@@ -322,6 +335,7 @@ impl ServerEntity {
section: section_of(e.position),
spectator: e.spectator,
watching: e.watching,
hidden: e.hidden,
}
}
@@ -384,6 +398,11 @@ impl ServerEntity {
/// full position sync every 400 or when the entity lands or leaves the
/// ground), velocity for types that track it, changed data at once.
fn send_changes(&mut self, e: &EntityState, riding: &Riding, out: &mut Vec<play::Clientbound>) {
if let Some(steps) = &e.minecart_steps {
self.minecart_steps.extend_from_slice(steps);
} else {
self.minecart_steps.clear();
}
let passengers = riding.passengers(e.id);
if passengers != self.passengers.as_slice() {
out.push(riding.set_passengers(e.id));
@@ -428,6 +447,14 @@ impl ServerEntity {
self.send_equipment(e, out);
return;
}
if e.minecart_steps.is_some() {
self.send_dirty(e, dirty, out);
self.send_minecart(e, rotation, rotate, out);
self.send_head(e, out);
self.tick += 1;
self.send_equipment(e, out);
return;
}
self.teleport_delay += 1;
let d: f64 = (0..3).map(|k| (e.position[k] - self.base[k]).powi(2)).sum();
let send_position = self.arrow || d >= 7.629_394_5e-6 || self.tick.is_multiple_of(60);
@@ -527,6 +554,49 @@ impl ServerEntity {
self.send_equipment(e, out);
}
/// ServerEntity.handleMinecartPosRot: send every recorded substep in order.
/// Only an empty queue uses the fallback endpoint (e.g. a stationary cart
/// teleported by a command); this never replaces a curved track's steps.
fn send_minecart(
&mut self,
e: &EntityState,
rotation: (i8, i8),
rotate: bool,
out: &mut Vec<play::Clientbound>,
) {
if self.minecart_steps.is_empty() {
let distance: f64 = (0..3).map(|k| (e.position[k] - self.base[k]).powi(2)).sum();
let motion: f64 = (0..3)
.map(|k| (e.velocity[k] - self.velocity[k]).powi(2))
.sum();
if distance >= 7.629_394_531_25e-6
|| self.tick.is_multiple_of(60)
|| rotate
|| motion > 1.0e-7
{
self.minecart_steps.push(play::MinecartStep {
position: e.position,
movement: e.velocity,
yaw: rotation.1,
pitch: rotation.0,
weight: 1.0,
});
}
}
if !self.minecart_steps.is_empty() {
out.push(play::Clientbound::MoveMinecartAlongTrack(
play::MoveMinecartAlongTrack {
id: e.id,
steps: std::mem::take(&mut self.minecart_steps),
},
));
}
// Vanilla updates these even when no packet was needed; its
// lastSentMovement is deliberately unchanged in this branch.
self.rotation = rotation;
self.base = e.position;
}
/// ServerEntity.sendDirtyEntityData: the changed entity data, then the
/// attribute instances that changed since they were last sent
/// (`AttributeMap.getAttributesToSync`).
@@ -691,6 +761,9 @@ pub struct Viewer<'a> {
pub entity_id: i32,
/// Its player is in spectator mode: it tracks the other spectators too.
pub spectator: bool,
/// Its player's permission level: players a native mod hid from lower
/// levels are not tracked by it.
pub permission: u8,
/// It accepted a move since the last tick (ChunkMap.move): every pair
/// of it is checked again.
pub moved: bool,
@@ -717,17 +790,20 @@ struct Probe {
chunk: u32,
spectator: bool,
watching: bool,
hidden: Option<u8>,
}
/// `ServerPlayer.broadcastToPlayer` (`Entity`'s is `true`): to a spectator,
/// a player is shown only while its camera is itself; to anyone else, a
/// spectator is hidden.
fn broadcast_to_player(viewer_spectator: bool, p: &Probe) -> bool {
if viewer_spectator {
/// spectator is hidden. A player a native mod hid is shown only to players
/// of at least its level.
fn broadcast_to_player(v: &Viewer, p: &Probe) -> bool {
let shown = if v.spectator {
!p.watching
} else {
!p.spectator
}
};
shown && p.hidden.is_none_or(|level| v.permission >= level)
}
/// TrackedEntity.updatePlayer: within the type's range (capped by the view
@@ -740,7 +816,7 @@ fn broadcast_to_player(viewer_spectator: bool, p: &Probe) -> bool {
fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool {
let range = f64::from(p.range.min(v.view_distance * 16));
let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z);
dx * dx + dz * dz <= range * range && broadcast_to_player(v.spectator, p) && {
dx * dx + dz * dz <= range * range && broadcast_to_player(v, p) && {
let held = &mut held[p.chunk as usize];
if *held == 0 {
*held = 1 + u8::from(v.chunks.contains(&chunks[p.chunk as usize]));
@@ -840,10 +916,12 @@ impl Entities {
|| section != s.section
|| e.spectator != s.spectator
|| e.watching != s.watching
|| e.hidden != s.hidden
{
s.section = section;
s.spectator = e.spectator;
s.watching = e.watching;
s.hidden = e.hidden;
self.moved.push(index);
self.is_moved[index] = true;
}
@@ -870,6 +948,7 @@ impl Entities {
chunk,
spectator: e.spectator,
watching: e.watching,
hidden: e.hidden,
});
}
// TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its
@@ -1077,6 +1156,7 @@ mod tests {
head_yaw: 0.0,
on_ground: true,
velocity: [0.0; 3],
minecart_steps: None,
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
@@ -1089,6 +1169,7 @@ mod tests {
leash: None,
spectator: false,
watching: false,
hidden: None,
}
}
@@ -1531,6 +1612,7 @@ mod tests {
positions: [[f64; 3]; 2],
moved: [bool; 2],
spectators: [bool; 2],
permissions: [u8; 2],
/// The first client's entities gone, where they were last sent.
gone: Vec<[f64; 3]>,
}
@@ -1544,6 +1626,7 @@ mod tests {
positions: [[0.0, 64.0, 0.0]; 2],
moved: [false; 2],
spectators: [false; 2],
permissions: [0; 2],
gone: Vec::new(),
}
}
@@ -1557,6 +1640,7 @@ mod tests {
Viewer {
entity_id: 1,
spectator: self.spectators[0],
permission: self.permissions[0],
moved: self.moved[0],
position: self.positions[0],
view_distance: 10,
@@ -1568,6 +1652,7 @@ mod tests {
Viewer {
entity_id: 2,
spectator: self.spectators[1],
permission: self.permissions[1],
moved: self.moved[1],
position: self.positions[1],
view_distance: 10,
@@ -1782,6 +1867,37 @@ mod tests {
assert!(c.trackers[0].is_tracking(2));
}
/// A player a native mod hid is tracked only by clients whose player has
/// at least its level, and by everyone again once shown.
#[test]
fn a_hidden_player_is_tracked_only_from_its_level_up() {
let mut level = Entities::default();
let mut c = Clients::new();
c.permissions = [0, 3];
let mut op = state(0.0);
(op.id, op.uuid, op.kind) = (9, 9, "minecraft:player".into());
op.hidden = Some(3);
c.tick(&mut level, &[&op]);
c.tick(&mut level, &[&op]);
assert!(
!c.trackers[0].is_tracking(9),
"a player of level 0 doesn't see it"
);
assert!(c.trackers[1].is_tracking(9), "an operator of level 3 does");
// Hidden after it was seen: the client below the level loses it.
op.hidden = None;
c.tick(&mut level, &[&op]);
assert!(c.trackers[0].is_tracking(9));
op.hidden = Some(4);
c.tick(&mut level, &[&op]);
assert!(!c.trackers[0].is_tracking(9) && !c.trackers[1].is_tracking(9));
// A client whose player was made an operator (its pairs checked again).
c.permissions = [4, 3];
c.moved = [true, false];
c.tick(&mut level, &[&op]);
assert!(c.trackers[0].is_tracking(9) && !c.trackers[1].is_tracking(9));
}
#[test]
fn pairing_and_unpairing_are_kept_for_the_game() {
let mut level = Entities::default();
+4
View File
@@ -184,6 +184,9 @@ pub struct Guest {
/// It accepted a move since the last entity range checks
/// (ServerChunkCache.move), which then check all of its pairs again.
pub moved: bool,
/// The players a native mod hid from it (`Hub::hidden`) that its player
/// list no longer has.
pub tab_hidden: crate::fast_hash::FxHashSet<u128>,
/// The client finished loading terrain (ServerboundPlayerLoadedPacket).
pub loaded: bool,
/// Since it joined or respawned, the client hasn't said it loaded the level
@@ -404,6 +407,7 @@ impl Guest {
replay: None,
changes: Default::default(),
moved: false,
tab_hidden: Default::default(),
loaded: false,
loading: true,
load_timeout: CLIENT_LOADED_TIMEOUT,
+127 -19
View File
@@ -234,6 +234,10 @@ pub struct Hub {
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
/// only players of at least that permission level see them, in the
/// player list, the world and their joining and leaving.
pub hidden: crate::fast_hash::FxHashMap<u128, u8>,
pub lan: Lan,
pub config: HubConfig,
pub encoder: ChunkEncoder,
@@ -576,6 +580,7 @@ impl Hub {
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
native_tracking: Vec::new(),
hidden: Default::default(),
teams: Vec::new(),
nicknames: std::collections::HashMap::new(),
}
@@ -846,6 +851,72 @@ impl Hub {
}
}
/// Whether a player of `permission` sees the player `uuid`: not one a
/// native mod hid from players below a higher level.
pub fn sees(&self, permission: u8, uuid: u128) -> bool {
self.hidden
.get(&uuid)
.is_none_or(|&level| permission >= level)
}
/// The player lists after `hidden` or a permission changed: each placed
/// player loses the players hidden from it (ClientboundPlayerInfoRemove)
/// and gets back those it sees again (an ADD_PLAYER entry). A change
/// checks the client's entity pairs again, so the tracker follows too.
/// Nothing runs while nobody is hidden.
fn sync_hidden(&mut self) {
if self.hidden.is_empty() && self.guests.iter().all(|g| g.tab_hidden.is_empty()) {
return;
}
let candidates: Vec<usize> = (0..self.guests.len())
.filter(|&j| {
let u = self.guests[j].uuid;
self.guests[j].placed
&& (self.hidden.contains_key(&u)
|| self.guests.iter().any(|g| g.tab_hidden.contains(&u)))
})
.collect();
for i in 0..self.guests.len() {
if !self.guests[i].placed {
continue;
}
for &j in &candidates {
let (viewer, other) = (&self.guests[i], &self.guests[j]);
if i == j {
continue;
}
let hide = !self.sees(viewer.permission, other.uuid);
if hide == viewer.tab_hidden.contains(&other.uuid) {
continue;
}
let packet = if hide {
C::PlayerInfoRemove(play::PlayerInfoRemove {
uuids: vec![other.uuid],
})
} else {
C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries: vec![self.player_info(
other.uuid,
&other.name,
other.game_mode,
other.latency.load(Ordering::Relaxed),
)],
})
};
let uuid = other.uuid;
let viewer = &mut self.guests[i];
viewer.send(&packet);
if hide {
viewer.tab_hidden.insert(uuid);
} else {
viewer.tab_hidden.remove(&uuid);
}
viewer.moved = true;
}
}
}
fn player_info(&self, uuid: u128, name: &str, mode: i32, latency: i32) -> play::PlayerInfo {
let guest = self.guests.iter().find(|g| g.uuid == uuid);
play::PlayerInfo {
@@ -985,17 +1056,21 @@ impl Hub {
self.send_permission_level(i);
self.send_initial_recipe_book(i);
// PlayerList.placeNewPlayer broadcasts the join line before
// players.add: the others read it, the newcomer doesn't.
self.broadcast_except(
conn,
&C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.joined",
vec![display_name(self.guests[i].uuid, &name)],
),
overlay: false,
}),
);
// players.add: the others read it, the newcomer doesn't (nor those
// a native mod hides it from).
let joined = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.joined",
vec![display_name(self.guests[i].uuid, &name)],
),
overlay: false,
})
.encode();
for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) {
if self.sees(g.permission, uuid) {
g.sender.send(joined.clone());
}
}
self.guests[i].placed = true;
self.resend_teleport(i, None);
let g = &self.guests[i];
@@ -1008,7 +1083,15 @@ impl Hub {
if let Some(host) = ctx.host {
entries.push(self.player_info(host.uuid, &host.name, host.game_mode, 0));
}
// Players a native mod hid from the newcomer stay out of its list,
// and a hidden newcomer out of theirs.
let permission = self.guests[i].permission;
let mut hidden = Vec::new();
for o in self.guests.iter().filter(|o| o.conn != conn && o.placed) {
if !self.sees(permission, o.uuid) {
hidden.push(o.uuid);
continue;
}
entries.push(self.player_info(
o.uuid,
&o.name,
@@ -1016,15 +1099,24 @@ impl Hub {
o.latency.load(Ordering::Relaxed),
));
}
self.guests[i].tab_hidden.extend(hidden);
self.guests[i].send(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries,
}));
let newcomer = play::PlayerInfoUpdate {
let newcomer = C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: Self::INFO_ACTIONS,
entries: vec![self.player_info(uuid, &name, mode, 0)],
};
self.broadcast(&C::PlayerInfoUpdate(newcomer));
})
.encode();
let level = self.hidden.get(&uuid).copied();
for g in self.guests.iter_mut().filter(|g| g.placed) {
if level.is_some_and(|l| g.permission < l) {
g.tab_hidden.insert(uuid);
} else {
g.sender.send(newcomer.clone());
}
}
self.send_level_info(i, ctx, true);
// ServerLevel.addNewPlayer: the chunk tracking starts at the player.
let g = &mut self.guests[i];
@@ -1251,11 +1343,12 @@ impl Hub {
match &self.sync.commands {
Some(tree) => {
let dedicated = self.config.dedicated && !self.config.integrated;
tree.packet_with(
tree.packet_with_features(
permission,
dedicated,
&self.sync.builtin,
&self.mod_commands.0,
&self.sync.features,
)
}
None => play::Commands {
@@ -1600,6 +1693,7 @@ impl Hub {
}
}
self.sync_containers(ctx.world);
self.sync_hidden();
self.track_entities(ctx);
for i in 0..self.guests.len() {
if self.here(&self.guests[i]) {
@@ -1674,10 +1768,20 @@ impl Hub {
self.broadcast(&C::RemoveEntities(play::RemoveEntities {
ids: vec![g.entity_id],
}));
self.system_message(yellow_names(
"multiplayer.player.left",
vec![display_name(g.uuid, &g.name)],
));
let left = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.left",
vec![display_name(g.uuid, &g.name)],
),
overlay: false,
})
.encode();
for o in self.guests.iter_mut().filter(|o| o.placed) {
if o.tab_hidden.remove(&g.uuid) {
continue;
}
o.sender.send(left.clone());
}
g
}
@@ -2326,9 +2430,11 @@ impl Hub {
vertical_collision: false,
horizontal_collision: false,
needs_sync: false,
minecart_steps: None,
leash: None,
spectator: false,
watching: false,
hidden: None,
}
}
@@ -2424,6 +2530,7 @@ impl Hub {
);
state.spectator = g.spectator();
state.watching = g.watching;
state.hidden = self.hidden.get(&g.uuid).copied();
stuck_meta(&mut state, g.stuck);
shoulder_meta(&mut state, g.shoulders);
state
@@ -2452,6 +2559,7 @@ impl Hub {
.map(|(g, (out, gone))| Viewer {
entity_id: g.entity_id,
spectator: g.spectator(),
permission: g.permission,
moved: std::mem::take(&mut g.moved),
position: g.position,
view_distance: g.view_distance,
+29
View File
@@ -1212,6 +1212,11 @@ pub struct Mob {
/// The packs' random texture variants it wears (`random_textures::choose`).
pub random_textures: random_textures::Choice,
}
impl crate::entity_rules::EntityRules for Mob {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
}
/// A mannequin's `description` and `hide_description` (`Mannequin.updateDescription`):
/// the default `entity.minecraft.mannequin.label`, none at all, or a component.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -1284,12 +1289,28 @@ pub enum MobEvent {
speed: f64,
count: u32,
},
/// `ServerLevel.sendParticles` of an `ItemParticleOption`: `count` crumbs of
/// `item`, drawn in this window.
ItemParticles {
item: &'static str,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
},
Sound(&'static str, [f64; 3], f32, f32),
/// `Entity.sendBubbleColumnParticles`: a mob at the top of the bubble column
/// block at `pos` (the splash and bubble particles over it).
BubbleColumnParticles {
pos: [i32; 3],
},
/// `LivingEntity.checkFallDamage`: `count` crumbs of the block `state` a mob
/// landed on, thrown up at `at`.
LandingDust {
state: horizon_world::registry::BlockStateId,
at: [f64; 3],
count: i32,
},
/// `UseBonemeal`: the villager's bone meal grows the crop at `pos`
/// (`BoneMealItem.growCrop`, then level event 1505).
Bonemeal {
@@ -3041,6 +3062,14 @@ impl Mob {
if crack(before) != crack(after) {
self.outbox
.push(MobEvent::Sound("item.wolf_armor.crack", at, 1.0, 1.0));
// Twenty scutes fly off, a block above the wolf's feet.
self.outbox.push(MobEvent::ItemParticles {
item: "minecraft:armadillo_scute",
at: [at[0], at[1] + 1.0, at[2]],
spread: [0.2, 0.1, 0.2],
speed: 0.1,
count: 20,
});
}
}
}
+71 -2
View File
@@ -1504,6 +1504,15 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.species() == Species::Warden {
super::warden::do_push(m, w, who, events);
}
// IronGolem.doPush: a monster that is not a creeper becomes its target
// one time in 20, and the draw is made for those alone, then the push.
if m.species() == Species::IronGolem
&& let Who::Mob(id) = who
&& w.seen(id).is_some_and(super::golem::golem_prey)
&& m.random.next_int_bound(20).unwrap_or(1) == 0
{
m.ai.target = Some(who);
}
// Entity.push: passengers of the same vehicle (a rider and its mount
// among them) never push each other.
if same {
@@ -2743,8 +2752,12 @@ pub fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
set.insert(leaked);
leaked
}
/// `getSwimSound`.
pub fn swim_sound(m: &Mob) -> &'static str {
/// `getSwimSound`. A skeleton horse's own override counts the steps it takes
/// through the water, so it needs the mob, not just its species.
pub fn swim_sound(m: &mut Mob) -> &'static str {
if m.species() == Species::SkeletonHorse {
return super::equine::skeleton_horse_swim_sound(m);
}
match m.species() {
Species::Drowned => "entity.drowned.swim",
Species::Axolotl => "entity.axolotl.swim",
@@ -3631,3 +3644,59 @@ mod silverfish_walk_target_tests {
let _ = ran;
}
}
#[cfg(test)]
mod iron_golem_push_tests {
use super::*;
use crate::mobs::world::with_test_world;
use std::sync::Arc;
fn mob(id: u32, species: Species, category: &str) -> Mob {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.species = species;
kind.category = category.into();
Mob::new(id, Arc::new(kind), Vec3::ZERO, 0.0)
}
/// `IronGolem.doPush`: a monster that is not a creeper and bumps the golem
/// becomes its target one time in 20, and the draw is made for those alone,
/// so a creeper or an animal leaves the golem's random alone.
#[test]
fn an_iron_golem_targets_the_monster_that_bumps_it() {
// CI has no prepared 26.3 data, so a world that does not build skips
// the whole check rather than asserting the data exists.
let hits = |victim: Mob| -> Option<u32> {
let seen = victim.seen(None);
let mut hits = 0;
let ran = with_test_world(&[seen], |w, _| {
let mut golem = mob(1, Species::IronGolem, "monster");
for _ in 0..400 {
let mut events = Vec::new();
push_entities(&mut golem, w, &mut events);
if golem.ai.target.is_some() {
hits += 1;
golem.ai.target = None;
}
}
});
ran.then_some(hits)
};
let Some(zombie) = hits(mob(2, Species::Zombie, "monster")) else {
return;
};
assert!(
zombie > 0,
"a zombie that bumps the golem is taken as its target"
);
assert_eq!(
hits(mob(3, Species::Creeper, "monster")),
Some(0),
"IronGolem.doPush never targets a creeper"
);
assert_eq!(
hits(mob(4, Species::Cow, "creature")),
Some(0),
"a pushed animal is not an Enemy"
);
}
}
+94
View File
@@ -478,6 +478,46 @@ pub fn hoof_steps(s: Species) -> bool {
| Species::ZombieHorse
)
}
/// `Entity.isVehicle`: someone on its back, the player steering it
/// (`rider`) or a mob that boarded it (`extra.rider`).
pub fn is_vehicle(m: &Mob) -> bool {
m.rider.is_some() || m.extra.rider.is_some()
}
/// `SkeletonHorse.getSwimSound`: an undead horse splashes its own way. In the
/// water it swims; on the ground a horse with nobody on it splashes, and a
/// ridden one counts on the same `gallopSoundCounter` its hoof steps use, so
/// it splashes for the first five steps, then gallops through the water every
/// third and swims in between.
pub fn skeleton_horse_swim_sound(m: &mut Mob) -> &'static str {
if !m.on_ground {
return "entity.skeleton_horse.swim";
}
if !is_vehicle(m) {
return "entity.skeleton_horse.step_water";
}
m.ai.steps.gallop += 1;
let count = m.ai.steps.gallop;
if count > 5 && count % 3 == 0 {
"entity.skeleton_horse.gallop_water"
} else if count <= 5 {
"entity.skeleton_horse.step_water"
} else {
"entity.skeleton_horse.swim"
}
}
/// `SkeletonHorse.playSwimSound`: a skeleton horse standing on the ground
/// splashes at 0.3, and one in the water at `min(0.1, f * 25.0)` - which
/// saturates at 0.1 for any speed a swim sound plays at.
pub fn swim_volume(m: &Mob, f: f32) -> f32 {
if m.species() != Species::SkeletonHorse {
return f;
}
if m.on_ground {
0.3
} else {
(f * 25.0).min(0.1)
}
}
/// `AbstractHorse.playStepSound` (and `Horse.playGallopSound`) on the block
/// `pos`: silent on liquids; a ridden horse walks on wood for five steps,
/// then gallops every third; unridden, wood or the plain step by age.
@@ -1012,4 +1052,58 @@ mod tests {
assert_eq!(rears(Species::Llama, "minecraft:llama", 60), 0);
assert_eq!(rears(Species::Camel, "minecraft:camel", 60), 0);
}
/// `SkeletonHorse.getSwimSound`: an undead horse has its own water sounds,
/// and one with someone on it counts its water steps on the same
/// `gallopSoundCounter` its hoof steps use - five splashes, then a gallop
/// through the water every third step and a swim sound in between.
#[test]
fn a_skeleton_horse_splashes_and_swims_with_its_own_sounds() {
let sound = |m: &mut Mob| crate::mobs::ai::swim_sound(m);
let mut m = equine(1, Species::SkeletonHorse, "minecraft:skeleton_horse");
// In the water it swims; every other animal's default is the generic
// splash (Entity.getSwimSound).
m.on_ground = false;
assert_eq!(sound(&mut m), "entity.skeleton_horse.swim");
// Standing on the ground with nobody on it, it splashes.
m.on_ground = true;
assert_eq!(sound(&mut m), "entity.skeleton_horse.step_water");
m.extra.rider = Some(9);
let ridden: Vec<&str> = (0..9).map(|_| sound(&mut m)).collect();
assert_eq!(
ridden,
[
"entity.skeleton_horse.step_water",
"entity.skeleton_horse.step_water",
"entity.skeleton_horse.step_water",
"entity.skeleton_horse.step_water",
"entity.skeleton_horse.step_water",
"entity.skeleton_horse.gallop_water",
"entity.skeleton_horse.swim",
"entity.skeleton_horse.swim",
"entity.skeleton_horse.gallop_water",
]
);
assert_eq!(m.ai.steps.gallop, 9);
// A plain horse keeps the generic splash.
let mut horse = equine(2, Species::Horse, "minecraft:horse");
assert_eq!(sound(&mut horse), "entity.generic.swim");
}
/// `SkeletonHorse.playSwimSound`: 0.3 on the ground, and no more than 0.1
/// in the water, where every other animal keeps the volume
/// `waterSwimSound` works out from its speed.
#[test]
fn a_skeleton_horses_splash_is_quiet_in_the_water() {
let mut m = equine(1, Species::SkeletonHorse, "minecraft:skeleton_horse");
m.on_ground = true;
assert_eq!(swim_volume(&m, 0.35), 0.3);
m.on_ground = false;
assert_eq!(swim_volume(&m, 0.35), 0.1);
assert_eq!(swim_volume(&m, 1.0), 0.1);
assert!((swim_volume(&m, 0.001) - 0.025).abs() < 1.0e-6);
let mut horse = equine(2, Species::Horse, "minecraft:horse");
horse.on_ground = false;
assert_eq!(swim_volume(&horse, 0.35), 0.35);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, random::RandomSource};
/// `Enemy` monsters an iron golem goes after (never creepers).
fn golem_prey(s: &super::Seen) -> bool {
pub(super) fn golem_prey(s: &super::Seen) -> bool {
s.hostile && s.species != Species::Creeper
}
/// `IronGolem.registerGoals`, without `ResetUniversalAngerTargetGoal` (that
@@ -171,4 +171,54 @@ mod tests {
Some("minecraft:saddle".into())
);
}
/// `Wolf.actuallyHurt`: each time the armor's crackiness level changes it
/// plays the crack sound and sends twenty armadillo scute particles.
#[test]
fn a_wolfs_armor_throws_scutes_as_it_cracks() {
use crate::mobs::MobEvent;
let mut wolf = mob("minecraft:wolf", Species::Wolf);
wolf.extra.head = Some("minecraft:wolf_armor".into());
// Levels change below 95%, 69% and 32% of the 64 points left, that is
// after 4, 20 and 44 points of wear.
let hurt = |wolf: &mut Mob, damage: f32| {
wolf.hurt_wolf_armor(damage);
std::mem::take(&mut wolf.outbox)
};
let scutes = |events: &[MobEvent]| {
events
.iter()
.filter(|e| {
matches!(
e,
MobEvent::ItemParticles {
item: "minecraft:armadillo_scute",
at,
spread,
speed,
count: 20,
} if *at == [0.0, 1.0, 0.0] && *spread == [0.2, 0.1, 0.2] && *speed == 0.1
)
})
.count()
};
let cracks = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::Sound("item.wolf_armor.crack", ..)))
.count()
};
// Three points: still the first level.
let events = hurt(&mut wolf, 3.0);
assert_eq!((cracks(&events), scutes(&events)), (0, 0));
// The fourth point cracks it, with the sound and the scutes together.
let events = hurt(&mut wolf, 1.0);
assert_eq!((cracks(&events), scutes(&events)), (1, 1));
// Within the level: nothing.
let events = hurt(&mut wolf, 10.0);
assert_eq!((cracks(&events), scutes(&events)), (0, 0));
// Over 69%: the next level.
let events = hurt(&mut wolf, 6.0);
assert_eq!((cracks(&events), scutes(&events)), (1, 1));
}
}
+77 -11
View File
@@ -21,6 +21,29 @@ use horizon_world::{
registry::BlockStateId,
};
/// How many block particles a landing throws up
/// (`(int)(min(0.2F + power / 15, 2.5) * 150)`), none when the fall is within
/// `safe_fall_distance` (`power = max(floor(fall + 1.0E-6 - safe), 0)`).
fn landing_dust_count(fall_distance: f64, safe_fall_distance: f64) -> Option<i32> {
let power = f64::from(((fall_distance + 1.0e-6 - safe_fall_distance).floor() as i32).max(0));
(power > 0.0).then(|| (150.0 * (f64::from(0.2_f32) + power / 15.0).min(2.5)) as i32)
}
/// Where the dust starts: at the mob, or, when its own block is not the one it
/// landed on (`landing`), on that block's face toward the mob.
fn landing_dust_at(position: Vec3, landing: [i32; 3]) -> Vec3 {
if landing[0] == position.x.floor() as i32 && landing[2] == position.z.floor() as i32 {
return position;
}
let dx = position.x - f64::from(landing[0]) - 0.5;
let dz = position.z - f64::from(landing[2]) - 0.5;
let scale = dx.abs().max(dz.abs());
Vec3::new(
f64::from(landing[0]) + 0.5 + dx / scale * 0.5,
position.y,
f64::from(landing[2]) + 0.5 + dz / scale * 0.5,
)
}
/// `Entity.moveDist`, `flyDist`, `nextStep` and `Parrot.nextFlap`.
#[derive(Clone, Copy, Debug)]
pub struct Steps {
@@ -661,11 +684,31 @@ impl Mob {
}
if self.on_ground {
if self.fall_distance > 0.0 {
self.landing_dust(w, events);
self.fall_on_block(w, events);
}
self.fall_distance = 0.0;
}
}
/// The block particles `LivingEntity.checkFallDamage` sends up from the
/// block a mob lands on, by the power of the fall.
fn landing_dust(&self, w: &MobWorld, events: &mut Vec<MobEvent>) {
let pos = self.block_on_legacy(w);
let state = w.world.block(pos[0], pos[1], pos[2]);
let Some(count) = landing_dust_count(self.fall_distance, self.kind.safe_fall_distance)
else {
return;
};
if w.facts.is_air(state) {
return;
}
let at = landing_dust_at(self.position, pos);
events.push(MobEvent::LandingDust {
state,
at: [at.x, at.y, at.z],
count,
});
}
/// Entity.updateFluidInteraction: the fluid state, then touching water
/// resets the fall and entering it (after the first tick) splashes.
pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
@@ -746,25 +789,23 @@ impl Mob {
self.ai.steps.next_step = self.next_step();
} else if self.fluids.in_water {
self.ai.steps.next_step = self.next_step();
// Entity.waterSwimSound
// Entity.waterSwimSound, and SkeletonHorse.playSwimSound's
// mapping of the volume it works out.
let v = self.velocity;
let volume = 1.0_f32.min(
let speed =
(v.x * v.x * f64::from(0.2_f32) + v.y * v.y + v.z * v.z * f64::from(0.2_f32))
.sqrt() as f32
* 0.35,
);
.sqrt() as f32;
let volume = super::equine::swim_volume(self, 1.0_f32.min(speed * 0.35));
if !super::ai::movement_sounds(self) {
return;
}
let pitch = 1.0 + (self.random.next_float() - self.random.next_float()) * 0.4;
// Entity.playSwimSound asks getSwimSound for its sound even
// when the mob is silent, and a skeleton horse counts there.
let sound = super::ai::swim_sound(self);
if !self.silent {
let at = [self.position.x, self.position.y, self.position.z];
events.push(MobEvent::Sound(
super::ai::swim_sound(self),
at,
volume,
pitch,
));
events.push(MobEvent::Sound(sound, at, volume, pitch));
}
}
} else if w.facts.is_air(state)
@@ -1905,4 +1946,29 @@ mod tests {
"a ridden horse gains 0.04 a tick, got {lift}"
);
}
/// `LivingEntity.checkFallDamage`'s dust: nothing within the safe distance,
/// `150 * min(0.2 + power / 15, 2.5)` block particles beyond it.
#[test]
fn a_landing_throws_up_dust_by_the_power_of_the_fall() {
// Within the safe distance of 3, or by a hair over it: power 0.
assert_eq!(landing_dust_count(2.5, 3.0), None);
assert_eq!(landing_dust_count(3.0, 3.0), None);
// A fall of 7: power 4, 150 * (0.2 + 4 / 15) = 70.
assert_eq!(landing_dust_count(7.0, 3.0), Some(70));
// A long fall is capped at 150 * 2.5.
assert_eq!(landing_dust_count(60.0, 3.0), Some(375));
// A mob that takes no fall damage still has a safe distance of its own.
assert_eq!(landing_dust_count(7.0, 10.0), None);
}
/// Over the block it lands on the dust starts at the mob; straddling
/// into another column it is pulled to that block's near edge.
#[test]
fn landing_dust_is_pulled_to_the_block_it_landed_on() {
let on = Vec3::new(4.3, 65.0, 7.6);
assert_eq!(landing_dust_at(on, [4, 64, 7]), on);
// The mob stands over block (4, 7) but its feet block is (5, 7).
let straddling = landing_dust_at(Vec3::new(5.1, 65.0, 7.5), [4, 64, 7]);
assert_eq!(straddling, Vec3::new(5.0, 65.0, 7.5));
}
}
+8
View File
@@ -197,6 +197,12 @@ fn climate(m: &Mob) -> &'static str {
fn avatar(m: &Mob) -> bool {
matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin")
}
/// `AvatarRenderer.shouldRenderLayers`: a spectating player or mannequin
/// draws no layers at all (armour, held items, head item, cape, wings,
/// parrots, ears, stuck arrows), only its translucent head.
pub fn layers_shown(m: &Mob) -> bool {
!(avatar(m) && m.extra.spectator)
}
/// The model layer key and texture for the mob's body.
pub fn body(m: &Mob) -> (String, String) {
use Species as S;
@@ -1796,6 +1802,8 @@ fn render_visible(
out: &mut Vec<Vertex>,
skin: Option<&mut super::skin::Skin>,
) {
// The armour is a layer: none for a spectator.
let extra = if layers_shown(m) { extra } else { &[] };
let overlay = u32::from(red_overlay(m));
let s = anim::state(m, alpha);
let (model_key, texture) = body(m);
+20
View File
@@ -29,6 +29,7 @@ pub use crate::hud_layout::{GuiDraw, HealthInputs};
pub use crate::items::ItemStack;
pub use crate::merchant::MerchantOffer;
pub use crate::music::{Music, MusicLayer};
pub use crate::particles::{ParticleOptions, ParticleType};
pub use crate::text::Text;
pub use horizon_assets::item_model::DisplayContext;
pub use horizon_core::{
@@ -454,6 +455,25 @@ pub trait LevelTicksHost: LevelHost {
pub trait LevelWriteHost: LevelTicksHost + LevelRandomHost {
/// Change a block property through `native_set_block_property`.
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str, flags: i32) -> bool;
/// Play a sound for nearby players through `native_play_sound`.
fn play_sound(
&mut self,
sound: &str,
at: [f64; 3],
source: usize,
volume: f32,
pitch: f32,
) -> bool;
/// `ServerLevel.sendParticles` through `native_send_particles`.
fn send_particles(
&mut self,
options: &ParticleOptions,
state: Option<BlockStateId>,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: i32,
) -> bool;
}
macro_rules! level_attachment_host {
+164 -85
View File
@@ -2456,7 +2456,7 @@ pub trait Hooks: Send + Sync {
}
/// An anvil at `pos` took damage, changing its block from `from` to `to`.
/// `to` is air when the anvil broke. Server-side.
/// @API:native_anvil_damaged@.
/// @API:anvil_damaged@.
fn anvil_damaged(&self, host: &mut dyn Host, event: &mut AnvilDamagedEvent<'_>) {
let _ = host;
let _ = event;
@@ -2478,13 +2478,13 @@ pub trait Hooks: Send + Sync {
}
/// A beacon at `pos` activated or deactivated after its beam was checked.
/// `active` says whether it now has a complete pyramid. Server-side.
/// @API:native_beacon_state_change@.
/// @API:beacon_state_change@.
fn beacon_state_change(&self, host: &mut dyn Host, event: &mut BeaconStateChangeEvent<'_>) {
let _ = host;
let _ = event;
}
/// On an authoritative server, a bell at `pos` is about to ring. Set
/// `cancel` to stop the ring and its raider reveal. @API:native_bell_ring@.
/// `cancel` to stop the ring and its raider reveal. @API:bell_ring@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn bell_ring(&self, level: &dyn LevelHost, event: &mut BellRingEvent) -> Flow {
@@ -2493,7 +2493,7 @@ pub trait Hooks: Send + Sync {
Flow::Continue
}
/// A server guest's block-breaking animation changed to `progress` at
/// `pos` (0 through 9). @API:native_block_break_progress_update@.
/// `pos` (0 through 9). @API:block_break_progress_update@.
fn block_break_progress_update(
&self,
host: &mut dyn Host,
@@ -2503,7 +2503,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
}
/// On an authoritative server, fire is about to burn the block at `pos`
/// away. Set `cancel` to keep that block. @API:native_block_burn@.
/// away. Set `cancel` to keep that block. @API:block_burn@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn block_burn(&self, level: &dyn LevelHost, event: &mut BlockBurnEvent) -> Flow {
@@ -2519,21 +2519,21 @@ pub trait Hooks: Send + Sync {
/// A dispenser equipped `item` on the first eligible `target` in front
/// of its block at `pos`. `target` is `player`, `mob` or `armor_stand`.
/// The equip, item consumption, sound and game event have completed.
/// Server-side. @API:native_block_dispense_armor@.
/// Server-side. @API:block_dispense_armor@.
fn block_dispense_armor(&self, host: &mut dyn Host, event: &mut BlockDispenseArmorEvent<'_>) {
let _ = host;
let _ = event;
}
/// A broken block is about to drop `experience` points at `pos`. The
/// vanilla block drop rule has been checked; change the amount before its
/// orbs are awarded. Server-side. @API:native_block_exp@.
/// orbs are awarded. Server-side. @API:block_exp@.
fn block_exp(&self, level: &dyn LevelHost, event: &mut BlockExpEvent<'_>) {
let _ = level;
let _ = event;
}
/// A block at `pos` is about to fade from `from` to `to` because of
/// melting or coral death. Set `cancel` to keep it. Server-side.
/// @API:native_block_fade@.
/// @API:block_fade@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn block_fade(&self, level: &dyn LevelHost, event: &mut BlockFadeEvent) -> Flow {
@@ -2542,14 +2542,14 @@ pub trait Hooks: Send + Sync {
Flow::Continue
}
/// A dispenser at `pos` failed to dispense `item`. Server-side.
/// @API:native_block_failed_dispense@.
/// @API:block_failed_dispense@.
fn block_failed_dispense(&self, host: &mut dyn Host, event: &mut BlockFailedDispenseEvent<'_>) {
let _ = host;
let _ = event;
}
/// Precipitation is about to form or add to a block at `pos`. `from` and
/// `to` are block state IDs. Set `cancel` to keep the old state. Server-side.
/// @API:native_block_form@.
/// @API:block_form@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn block_form(&self, level: &dyn LevelHost, event: &mut BlockFormEvent) -> Flow {
@@ -2560,7 +2560,7 @@ pub trait Hooks: Send + Sync {
/// On an authoritative server, fire is about to be placed at `pos` by
/// flint and steel, a fire charge, lava, lightning or fire spread. Set
/// `player` is the acting server player when there is one. Set `cancel` to
/// leave the position unchanged. @API:native_block_ignite@.
/// leave the position unchanged. @API:block_ignite@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn block_ignite(&self, level: &dyn LevelHost, event: &mut BlockIgniteEvent<'_>) -> Flow {
@@ -2570,7 +2570,7 @@ pub trait Hooks: Send + Sync {
}
/// A guest's block inventory lock check at `pos`. `locked` is the
/// vanilla result and may be changed. The guest's state is not swapped in.
/// @API:native_block_lock_check@.
/// @API:block_lock_check@.
///
/// Return `Flow::Cancel` to take it: this sets `locked`, and later mods are skipped.
fn block_lock_check(&self, host: &mut dyn Host, event: &mut BlockLockCheckEvent<'_>) -> Flow {
@@ -2580,7 +2580,7 @@ pub trait Hooks: Send + Sync {
}
/// A sculk sensor or shrieker received a vibration for `event` at `pos`.
/// Set `cancel` to suppress its response. Server-side.
/// @API:native_block_receive_game@.
/// @API:block_receive_game@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn block_receive_game(
@@ -2594,7 +2594,7 @@ pub trait Hooks: Send + Sync {
}
/// A dispenser sheared `entity` of kind `kind` at the block in front of
/// its own block at `pos`, after the shearing effects. Server-side.
/// @API:native_block_shear_entity@.
/// @API:block_shear_entity@.
fn block_shear_entity(&self, host: &mut dyn Host, event: &mut BlockShearEntityEvent<'_>) {
let _ = host;
let _ = event;
@@ -2616,7 +2616,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
}
/// A server broadcast line is about to be logged and sent. Change `line`
/// or set `cancel` to drop it. @API:native_broadcast_message@.
/// or set `cancel` to drop it. @API:broadcast_message@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn broadcast_message(&self, host: &dyn Host, event: &mut BroadcastMessageEvent<'_>) -> Flow {
@@ -2631,7 +2631,7 @@ pub trait Hooks: Send + Sync {
}
/// On an authoritative server, a cauldron at `pos` is about to change
/// fill level from `from_level` to `to_level` for `reason`. Set `cancel`
/// to leave it unchanged. @API:native_cauldron_level_changed@.
/// to leave it unchanged. @API:cauldron_level_changed@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn cauldron_level_changed(
@@ -2674,16 +2674,30 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A player ignited a creeper with an item. @API:native_creeper_ignite@.
/// A player ignited a creeper with an item. @API:creeper_ignite@.
fn creeper_ignite(&self, host: &mut dyn Host, event: &mut CreeperIgniteEvent<'_>) {
let _ = host;
let _ = event;
}
/// Lightning charged a creeper. @API:native_creeper_power@.
/// Lightning charged a creeper. @API:creeper_power@.
fn creeper_power(&self, host: &mut dyn Host, event: &mut CreeperPowerEvent) {
let _ = host;
let _ = event;
}
/// NeoForge's `CriticalHitEvent`: a player's melee hit on a living
/// `target_kind` (`minecraft:zombie`, `minecraft:armor_stand`) that deals
/// damage, where `Player.attack` decides whether it is critical.
/// `vanilla_critical` is vanilla's answer; `critical` starts as it and may
/// be forced or denied (as in vanilla a critical hit prevents the sweep, so
/// a forced crit loses it; a denied crit loses the crit sound and
/// particles); `damage_multiplier` starts at 1.5 for vanilla's critical and
/// 1.0 otherwise, and scales the damage only while `critical` ends true. Not fired for a
/// target that is no living entity (a dragon part) or when the hit does
/// no damage. Fires on the Game that runs the attack. @API:critical_hit@.
fn critical_hit(&self, host: &mut dyn Host, event: &mut CriticalHitEvent<'_>) {
let _ = host;
let _ = event;
}
/// The server's difficulty changed, from `from` to `to` (`peaceful`,
/// `easy`, `normal`, `hard`; NeoForge's `DifficultyChangeEvent`). It is
/// vanilla's `MinecraftServer.setDifficulty`, which `/difficulty`, a
@@ -2696,7 +2710,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
}
/// The Ender Dragon fight is about to place its first dragon egg at `pos`.
/// Set `cancel` to leave the block unchanged. Server-side. @API:native_dragon_egg_form@.
/// Set `cancel` to leave the block unchanged. Server-side. @API:dragon_egg_form@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn dragon_egg_form(&self, level: &dyn LevelHost, event: &mut DragonEggFormEvent) -> Flow {
@@ -2704,7 +2718,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// A server player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. The player's state is not swapped in. @API:native_enchant_item@.
/// A server player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. The player's state is not swapped in. @API:enchant_item@.
fn enchant_item(&self, host: &mut dyn Host, event: &mut EnchantItemEvent<'_>) {
let _ = host;
let _ = event;
@@ -2732,42 +2746,42 @@ pub trait Hooks: Send + Sync {
Flow::Continue
}
/// Entities from `chunk` finished loading into the server world. `count`
/// is the number of saved entity records read. @API:native_entities_load@.
/// is the number of saved entity records read. @API:entities_load@.
fn entities_load(&self, host: &mut dyn Host, event: &mut EntitiesLoadEvent<'_>) {
let _ = host;
let _ = event;
}
/// Entities in `chunk` left the active server world and were queued for
/// saving. `count` is the number of entity records. @API:native_entities_unload@.
/// saving. `count` is the number of entity records. @API:entities_unload@.
fn entities_unload(&self, host: &mut dyn Host, event: &mut EntitiesUnloadEvent<'_>) {
let _ = host;
let _ = event;
}
/// A mob broke the door at `pos`. @API:native_entity_break_door@.
/// A mob broke the door at `pos`. @API:entity_break_door@.
fn entity_break_door(&self, host: &mut dyn Host, event: &mut EntityBreakDoorEvent<'_>) {
let _ = host;
let _ = event;
}
/// Two mobs completed a breeding action. `child` is absent when vanilla
/// lays an egg instead of creating a baby. @API:native_entity_breed@.
/// lays an egg instead of creating a baby. @API:entity_breed@.
fn entity_breed(&self, host: &mut dyn Host, event: &mut EntityBreedEvent) {
let _ = host;
let _ = event;
}
/// A mob changed a block at `pos`, after the new state was applied.
/// @API:native_entity_change_block@.
/// @API:entity_change_block@.
fn entity_change_block(&self, host: &mut dyn Host, event: &mut EntityChangeBlockEvent<'_>) {
let _ = host;
let _ = event;
}
/// A bee entered the hive at `pos` and was stored there.
/// @API:native_entity_enter_block@.
/// @API:entity_enter_block@.
fn entity_enter_block(&self, host: &mut dyn Host, event: &mut EntityEnterBlockEvent<'_>) {
let _ = host;
let _ = event;
}
/// A mob entered love mode after player interaction. `player` is the
/// feeder when known. @API:native_entity_enter_love_mode@.
/// feeder when known. @API:entity_enter_love_mode@.
fn entity_enter_love_mode(
&self,
host: &mut dyn Host,
@@ -2778,25 +2792,25 @@ pub trait Hooks: Send + Sync {
}
/// A server entity finished its portal wait and is beginning to leave
/// `dimension` through `pos`. The destination follows the portal rules.
/// @API:native_entity_portal@.
/// @API:entity_portal@.
fn entity_portal(&self, host: &mut dyn Host, event: &mut EntityPortalEvent<'_>) {
let _ = host;
let _ = event;
}
/// A guest player's synchronized pose changed from `from` to `to`.
/// @API:native_entity_pose_change@.
/// @API:entity_pose_change@.
fn entity_pose_change(&self, host: &mut dyn Host, event: &mut EntityPoseChangeEvent<'_>) {
let _ = host;
let _ = event;
}
/// A player bow or crossbow launched an arrow with `force`, after the
/// projectile entered the level. @API:native_entity_shoot_bow@.
/// projectile entered the level. @API:entity_shoot_bow@.
fn entity_shoot_bow(&self, host: &mut dyn Host, event: &mut EntityShootBowEvent<'_>) {
let _ = host;
let _ = event;
}
/// A mob became tamed after its owner and persistent state were set.
/// @API:native_entity_tame@.
/// @API:entity_tame@.
fn entity_tame(&self, host: &mut dyn Host, event: &mut EntityTameEvent<'_>) {
let _ = host;
let _ = event;
@@ -2805,25 +2819,25 @@ pub trait Hooks: Send + Sync {
/// position changed. `entity` is its network id; `from` and `to` are
/// coordinates in `dimension`. This hook reports command teleports that
/// use Horizon's same-level target path; it does not change vanilla's
/// movement. @API:native_entity_teleport@.
/// movement. @API:entity_teleport@.
fn entity_teleport(&self, host: &mut dyn Host, event: &mut EntityTeleportEvent<'_>) {
let _ = host;
let _ = event;
}
/// A guest's fall flying state changed on the server. The change has
/// already been applied. @API:native_entity_toggle_glide@.
/// already been applied. @API:entity_toggle_glide@.
fn entity_toggle_glide(&self, host: &mut dyn Host, event: &mut EntityToggleGlideEvent<'_>) {
let _ = host;
let _ = event;
}
/// A mob conversion completed; both ids and entity types are reported.
/// @API:native_entity_transform@.
/// @API:entity_transform@.
fn entity_transform(&self, host: &mut dyn Host, event: &mut EntityTransformEvent<'_>) {
let _ = host;
let _ = event;
}
/// An experience bottle broke and its experience was awarded.
/// @API:native_exp_bottle@.
/// @API:exp_bottle@.
fn exp_bottle(&self, host: &mut dyn Host, event: &mut ExpBottleEvent) {
let _ = host;
let _ = event;
@@ -2897,14 +2911,14 @@ pub trait Hooks: Send + Sync {
let _ = event;
None
}
/// A firework rocket completed its explosion effects. @API:native_firework_explode@.
/// A firework rocket completed its explosion effects. @API:firework_explode@.
fn firework_explode(&self, host: &mut dyn Host, event: &mut FireworkExplodeEvent) {
let _ = host;
let _ = event;
}
/// A flowing fluid at `pos` is about to change level. `fluid` is `water`
/// or `lava`; the levels are 0 through 8. Set `cancel` to keep the old
/// level. Server-side. @API:native_fluid_level_change@.
/// level. Server-side. @API:fluid_level_change@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn fluid_level_change(
@@ -2936,7 +2950,7 @@ pub trait Hooks: Send + Sync {
/// A server player took an output from a furnace at `pos`; `item` is the
/// output item id and `count` is how many were taken. The player's state is
/// not swapped in, so helpers that look them up through `self.lan` may find
/// nothing during guest packet handling. @API:native_furnace_extract@.
/// nothing during guest packet handling. @API:furnace_extract@.
fn furnace_extract(&self, host: &mut dyn Host, event: &mut FurnaceExtractEvent<'_>) {
let _ = host;
let _ = event;
@@ -2954,12 +2968,12 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A generic world game event was fired at `pos`. @API:native_game_event@.
/// A generic world game event was fired at `pos`. @API:game_event@.
fn game_event(&self, host: &mut dyn Host, event_: &mut GameEventEvent<'_>) {
let _ = host;
let _ = event_;
}
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. @API:native_hopper_pickup_item@.
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. @API:hopper_pickup_item@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn hopper_pickup_item(
@@ -2987,7 +3001,7 @@ pub trait Hooks: Send + Sync {
}
/// A server player is about to open an inventory supplied by a block at
/// `pos`. Set `cancel` to leave the inventory closed. The player's state
/// is not swapped in. @API:native_inventory_block_start@.
/// is not swapped in. @API:inventory_block_start@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn inventory_block_start(
@@ -3032,13 +3046,13 @@ pub trait Hooks: Send + Sync {
None
}
/// A dropped item entity was successfully added to the server level.
/// @API:native_item_spawn@.
/// @API:item_spawn@.
fn item_spawn(&self, host: &mut dyn Host, event: &mut ItemSpawnEvent<'_>) {
let _ = host;
let _ = event;
}
/// A non-persistent leaf block at distance 7 is about to decay at `pos`.
/// Set `cancel` to keep it. Server-side. @API:native_leaves_decay@.
/// Set `cancel` to keep it. Server-side. @API:leaves_decay@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn leaves_decay(&self, level: &dyn LevelHost, event: &mut LeavesDecayEvent) -> Flow {
@@ -3048,7 +3062,7 @@ pub trait Hooks: Send + Sync {
}
/// A server lightning bolt is about to be created. `cause` is `weather`,
/// `trident` or `command`; `player` is the responsible player when known.
/// Set `cancel` to prevent the bolt and its effects. @API:native_lightning_strike@.
/// Set `cancel` to prevent the bolt and its effects. @API:lightning_strike@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn lightning_strike(&self, host: &mut dyn Host, event: &mut LightningStrikeEvent<'_>) -> Flow {
@@ -3088,7 +3102,7 @@ pub trait Hooks: Send + Sync {
}
/// Farmland at `pos` is about to change moisture from `from` to `to`.
/// Set `cancel` to keep its old moisture. Server-side.
/// @API:native_moisture_change@.
/// @API:moisture_change@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn moisture_change(&self, level: &dyn LevelHost, event: &mut MoistureChangeEvent) -> Flow {
@@ -3286,6 +3300,17 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// The item `item` of the server player `player` (`name`) broke in `slot`
/// (`mainhand`, `offhand`, `head`, `chest`, `legs`, `feet`, `body` or
/// `saddle`): `LivingEntity.onEquippedItemBroken`, Bukkit's
/// `PlayerItemBreakEvent`. Listen only; armour has a slot, not a hand.
/// The player's state is swapped in. Once for each break, on a server; a
/// creative player and the host of a LAN world are not heard.
/// @API:native_player_destroy_item@.
fn player_destroy_item(&self, host: &mut dyn Host, event: &mut PlayerDestroyItemEvent<'_>) {
let _ = host;
let _ = event;
}
/// A server player's fishing rod, with `state`: 0 the cast
/// (`FishingRodItem.use` throwing the bobber), 1 a fish biting
/// (`FishingHook.catchingFish`), 2 the reel-in of a bite, which gives the
@@ -3309,7 +3334,7 @@ pub trait Hooks: Send + Sync {
/// A server player put a plant into a flower pot, after the potted block
/// state was stored and the item consumed. `plant` is the item id and
/// `pos` the pot position. Helpers looking up the guest through `self.lan`
/// may find nothing during packet handling. Server-side. @API:player_flower_pot_manipulate@.
/// may find nothing during packet handling. Server-side. @API:native_player_flower_pot_manipulate@.
fn player_flower_pot_manipulate(
&self,
host: &mut dyn Host,
@@ -3343,7 +3368,7 @@ pub trait Hooks: Send + Sync {
/// A server player inserted a book into a lectern, after the block entity
/// and inventory changed. `book` is the item id. Helpers looking up a
/// guest through `self.lan` may find nothing during packet handling.
/// Server-side. @API:player_insert_lectern_book@.
/// Server-side. @API:native_player_insert_lectern_book@.
fn player_insert_lectern_book(
&self,
host: &mut dyn Host,
@@ -3356,7 +3381,7 @@ pub trait Hooks: Send + Sync {
/// changed. `action` is `put`, `rotate`, `remove_item` or `break`; `item`
/// is empty when no item remains, and `rotation` is the resulting step.
/// Helpers looking up the guest through `self.lan` may find nothing during
/// packet handling. Server-side. @API:player_item_frame_change@.
/// packet handling. Server-side. @API:native_player_item_frame_change@.
fn player_item_frame_change(
&self,
host: &mut dyn Host,
@@ -3368,7 +3393,7 @@ pub trait Hooks: Send + Sync {
/// Mending repaired a server player's item with experience from an orb.
/// `repair` is durability restored and `experience` is the orb's value.
/// Helpers looking up the guest through `self.lan` may find nothing during
/// packet handling. Server-side. @API:player_item_mend@.
/// packet handling. Server-side. @API:native_player_item_mend@.
fn player_item_mend(&self, host: &mut dyn Host, event: &mut PlayerItemMendEvent<'_>) {
let _ = host;
let _ = event;
@@ -3376,7 +3401,7 @@ pub trait Hooks: Send + Sync {
/// A server player changed the page of a lectern, after its page and
/// redstone pulse changed. `page` is the zero-based new page. Helpers
/// looking up a guest through `self.lan` may find nothing during packet
/// handling. Server-side. @API:player_lectern_page_change@.
/// handling. Server-side. @API:native_player_lectern_page_change@.
fn player_lectern_page_change(
&self,
host: &mut dyn Host,
@@ -3438,7 +3463,7 @@ pub trait Hooks: Send + Sync {
/// A server player took the book from a lectern, after the menu and block
/// entity changed. `book` is the removed item id. Helpers looking up a
/// guest through `self.lan` may find nothing during packet handling.
/// Server-side. @API:player_take_lectern_book@.
/// Server-side. @API:native_player_take_lectern_book@.
fn player_take_lectern_book(
&self,
host: &mut dyn Host,
@@ -3559,7 +3584,7 @@ pub trait Hooks: Send + Sync {
}
/// On an authoritative server, a note block at `pos` is about to sound.
/// `player` is the acting server player when there is one. Set `cancel` to silence it.
/// @API:native_note_play@.
/// @API:note_play@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn note_play(&self, level: &dyn LevelHost, event: &mut NotePlayEvent<'_>) -> Flow {
@@ -3629,7 +3654,7 @@ pub trait Hooks: Send + Sync {
/// A server player changed their client option for the dominant hand. `left` is true for
/// left-handed. Their state is not swapped in, and helpers looking them up through `self.lan`
/// may find nothing during guest packet handling. Server-side.
/// @API:native_player_change_main_hand@.
/// @API:player_change_main_hand@.
fn player_change_main_hand(
&self,
host: &mut dyn Host,
@@ -3675,7 +3700,7 @@ pub trait Hooks: Send + Sync {
/// after mending has taken its share. Change `amount` to change the points
/// added. The player's state is not swapped in, so helpers that look them up
/// through `self.lan` may find nothing while a guest packet is handled.
/// Server-side. @API:native_player_exp_change@.
/// Server-side. @API:player_exp_change@.
fn player_exp_change(&self, host: &mut dyn Host, event: &mut PlayerExpChangeEvent<'_>) {
let _ = host;
let _ = event;
@@ -3683,7 +3708,7 @@ pub trait Hooks: Send + Sync {
/// A server player's XP orb pickup cooldown changed to `ticks`. It is
/// reported after the value changes; the player's state may not be found
/// through `self.lan` while a guest packet is handled. Server-side.
/// @API:native_player_exp_cooldown_change@.
/// @API:player_exp_cooldown_change@.
fn player_exp_cooldown_change(
&self,
host: &mut dyn Host,
@@ -3696,7 +3721,7 @@ pub trait Hooks: Send + Sync {
/// item id and `ticks` its duration. The player's state is not swapped in,
/// so helpers that look them up through `self.lan` may find nothing while a
/// guest packet is handled. Set `cancel` to skip starting the cooldown.
/// Server-side. @API:native_player_item_cooldown@.
/// Server-side. @API:player_item_cooldown@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_item_cooldown(
@@ -3711,7 +3736,7 @@ pub trait Hooks: Send + Sync {
/// A server player selected a new hotbar slot. The player's state is not
/// swapped in, and helpers that look them up through `self.lan` may find
/// nothing while a guest packet is being handled. Set `cancel` to keep
/// the old slot; the client is sent that slot again. @API:native_player_item_held@.
/// the old slot; the client is sent that slot again. @API:player_item_held@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_item_held(&self, host: &mut dyn Host, event: &mut PlayerItemHeldEvent<'_>) -> Flow {
@@ -3721,7 +3746,7 @@ pub trait Hooks: Send + Sync {
}
/// A server player changed their client language option to `locale`.
/// The player's state may not be found through `self.lan` during a guest
/// packet. Server-side. @API:native_player_locale_change@.
/// packet. Server-side. @API:player_locale_change@.
fn player_locale_change(&self, host: &mut dyn Host, event: &mut PlayerLocaleChangeEvent<'_>) {
let _ = host;
let _ = event;
@@ -3765,7 +3790,7 @@ pub trait Hooks: Send + Sync {
/// the hotbar. `pos` is zero for an entity; `entity_id` is zero for a
/// block. The player's state is swapped in, though helpers that look them
/// up through `self.lan` may find nothing while a guest packet is handled.
/// Set `cancel` to leave the inventory and client unchanged. @API:native_player_pick_item@.
/// Set `cancel` to leave the inventory and client unchanged. @API:player_pick_item@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_pick_item(&self, host: &mut dyn Host, event: &mut PlayerPickItemEvent<'_>) -> Flow {
@@ -3786,7 +3811,7 @@ pub trait Hooks: Send + Sync {
/// The player's state is not swapped in, so helpers that look them up
/// through `self.lan` may find nothing while a guest packet is handled.
/// Set `cancel` to keep them in their current place. Server-side.
/// @API:native_player_portal@.
/// @API:player_portal@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_portal(&self, host: &mut dyn Host, event: &mut PlayerPortalEvent<'_>) -> Flow {
@@ -3797,7 +3822,7 @@ pub trait Hooks: Send + Sync {
/// A server player's recipe book requested a known recipe be placed in
/// their crafting grid. `recipe` is the recipe id; `all` is the shift
/// click form. The player's state may not be available through `self.lan`
/// while a guest packet is handled. Server-side. @API:native_player_recipe_book_click@.
/// while a guest packet is handled. Server-side. @API:player_recipe_book_click@.
fn player_recipe_book_click(
&self,
host: &mut dyn Host,
@@ -3809,7 +3834,7 @@ pub trait Hooks: Send + Sync {
/// A recipe is about to be unlocked for a server player. Set `cancel` to
/// keep it locked. The player's state is not swapped in, so helpers that
/// look them up through `self.lan` may find nothing while a guest packet
/// is handled. Server-side. @API:native_player_recipe_discovered@.
/// is handled. Server-side. @API:player_recipe_discovered@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_recipe_discovered(
@@ -3857,7 +3882,7 @@ pub trait Hooks: Send + Sync {
/// position. The player's state is not swapped in, so helpers that look
/// them up through `self.lan` may find nothing while a guest packet is
/// handled. Set `cancel` to keep the old point. Server-side.
/// @API:native_player_spawn_point_set@.
/// @API:player_spawn_point_set@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_spawn_point_set(
@@ -3906,7 +3931,7 @@ pub trait Hooks: Send + Sync {
/// A server player is about to travel through an End gateway at `gateway`.
/// Set `cancel` to leave them at the entrance; the gateway is not consumed
/// and its cooldown does not start. Helpers may find no guest through
/// `self.lan` during its packet. Server-side. @API:native_player_teleport_end_gateway@.
/// `self.lan` during its packet. Server-side. @API:player_teleport_end_gateway@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn player_teleport_end_gateway(
@@ -3953,18 +3978,18 @@ pub trait Hooks: Send + Sync {
/// A Nether portal was built for a dimension travel, or an End platform
/// was made for an arrival. `pos` is the lower corner of the Nether
/// portal rectangle or the End platform origin. The completed blocks are
/// already in the world. @API:native_portal_create@.
/// already in the world. @API:portal_create@.
fn portal_create(&self, host: &mut dyn Host, event: &mut PortalCreateEvent<'_>) {
let _ = host;
let _ = event;
}
/// A thrown splash or lingering potion applied its hit effects.
/// @API:native_potion_splash@.
/// @API:potion_splash@.
fn potion_splash(&self, host: &mut dyn Host, event: &mut PotionSplashEvent<'_>) {
let _ = host;
let _ = event;
}
/// An anvil result was prepared for a server player at `pos`. Change `result` or its level `cost` before it is sent. @API:native_prepare_anvil@.
/// An anvil result was prepared for a server player at `pos`. Change `result` or its level `cost` before it is sent. @API:prepare_anvil@.
///
/// Return `Some` to set `result`; the first mod returning `Some` wins.
fn prepare_anvil(
@@ -3976,7 +4001,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
None
}
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:native_prepare_grindstone@.
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
///
/// Return `Some` to set `result`; the first mod returning `Some` wins.
fn prepare_grindstone(
@@ -3988,7 +4013,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
None
}
/// A crafting-grid result was prepared for a server player; `pos` is the crafting table, or None for the inventory grid. Change `result` before it is sent. @API:native_prepare_item_craft@.
/// A crafting-grid result was prepared for a server player; `pos` is the crafting table, or None for the inventory grid. Change `result` before it is sent. @API:prepare_item_craft@.
///
/// Return `Some` to set `result`; the first mod returning `Some` wins.
fn prepare_item_craft(
@@ -4000,12 +4025,12 @@ pub trait Hooks: Send + Sync {
let _ = event;
None
}
/// Enchanting offers were calculated for a server player at `pos`. Change `costs` before they are sent to the menu. @API:native_prepare_item_enchant@.
/// Enchanting offers were calculated for a server player at `pos`. Change `costs` before they are sent to the menu. @API:prepare_item_enchant@.
fn prepare_item_enchant(&self, host: &mut dyn Host, event: &mut PrepareItemEnchantEvent<'_>) {
let _ = host;
let _ = event;
}
/// A smithing result was prepared for a server player at `pos`. Change it before it is sent. @API:native_prepare_smithing@.
/// A smithing result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_smithing@.
///
/// Return `Some` to set `result`; the first mod returning `Some` wins.
fn prepare_smithing(
@@ -4018,26 +4043,26 @@ pub trait Hooks: Send + Sync {
None
}
/// A server projectile was added to the level. `player` is set only for
/// a player launch; other sources have an empty `name`. @API:native_projectile_launch@.
/// a player launch; other sources have an empty `name`. @API:projectile_launch@.
fn projectile_launch(&self, host: &mut dyn Host, event: &mut ProjectileLaunchEvent<'_>) {
let _ = host;
let _ = event;
}
/// A raid wave has been spawned at `pos`; `wave` is its one-based wave
/// number and `raiders` counts the raiders, not the mobs they ride. @API:native_raid_spawn_wave@.
/// number and `raiders` counts the raiders, not the mobs they ride. @API:raid_spawn_wave@.
fn raid_spawn_wave(&self, host: &mut dyn Host, event: &mut RaidSpawnWaveEvent) {
let _ = host;
let _ = event;
}
/// A raid at `pos` changed to `state` (`triggered`, `victory`, `loss` or
/// `stopped`). @API:native_raid_state_change@.
/// `stopped`). @API:raid_state_change@.
fn raid_state_change(&self, host: &mut dyn Host, event: &mut RaidStateChangeEvent<'_>) {
let _ = host;
let _ = event;
}
/// A sculk catalyst at `pos` has begun blooming after taking experience.
/// The catalyst block state has changed. Server-side.
/// @API:native_sculk_bloom@.
/// @API:sculk_bloom@.
fn sculk_bloom(&self, host: &mut dyn Host, event: &mut SculkBloomEvent<'_>) {
let _ = host;
let _ = event;
@@ -4061,13 +4086,13 @@ pub trait Hooks: Send + Sync {
let _ = event;
}
/// A sponge at `pos` absorbed water and became wet. Server-side.
/// @API:native_sponge_absorb@.
/// @API:sponge_absorb@.
fn sponge_absorb(&self, level: &dyn LevelHost, event: &mut SpongeAbsorbEvent) {
let _ = level;
let _ = event;
}
/// A guest asked to complete `command`; `suggestions` may be added to or
/// filtered. `range` is the replaced byte range. @API:native_tab_complete@.
/// filtered. `range` is the replaced byte range. @API:tab_complete@.
///
/// Every mod gets `event.suggestions` in order and may add, change or remove suggestions.
fn tab_complete(&self, host: &mut dyn Host, event: &mut TabCompleteEvent<'_>) {
@@ -4075,14 +4100,14 @@ pub trait Hooks: Send + Sync {
let _ = event;
}
/// A projectile hit the target block at `pos`, which now has `strength`
/// output power. Server-side. @API:native_target_hit@.
/// output power. Server-side. @API:target_hit@.
fn target_hit(&self, host: &mut dyn Host, event: &mut TargetHitEvent<'_>) {
let _ = host;
let _ = event;
}
/// The server changed its thunder state through `/weather`. Set `cancel`
/// to keep the previous thunder state and timer. Server-side.
/// @API:native_thunder_change@.
/// @API:thunder_change@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn thunder_change(&self, host: &mut dyn Host, event: &mut ThunderChangeEvent<'_>) -> Flow {
@@ -4091,7 +4116,7 @@ pub trait Hooks: Send + Sync {
Flow::Continue
}
/// The overworld clock is about to jump, from sleep or `/time`. Set
/// `cancel` to keep `from`. @API:native_time_skip@.
/// `cancel` to keep `from`. @API:time_skip@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn time_skip(&self, host: &mut dyn Host, event: &mut TimeSkipEvent<'_>) -> Flow {
@@ -4099,7 +4124,7 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// A server player selected trade `index` from `villager` in the trading menu. @API:native_trade_select@.
/// A server player selected trade `index` from `villager` in the trading menu. @API:trade_select@.
fn trade_select(&self, host: &mut dyn Host, event: &mut TradeSelectEvent<'_>) {
let _ = host;
let _ = event;
@@ -4119,14 +4144,14 @@ pub trait Hooks: Send + Sync {
let _ = event;
}
/// A vault at `pos` changed to `state`. Its block state is updated.
/// Server-side. @API:native_vault_change_state@.
/// Server-side. @API:vault_change_state@.
fn vault_change_state(&self, host: &mut dyn Host, event: &mut VaultChangeStateEvent<'_>) {
let _ = host;
let _ = event;
}
/// The server changed its weather parameters. `raining` and `thundering`
/// are the requested final states. Set `cancel` to keep the previous
/// weather parameters. Server-side. @API:native_weather_change@.
/// weather parameters. Server-side. @API:weather_change@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn weather_change(&self, host: &mut dyn Host, event: &mut WeatherChangeEvent<'_>) -> Flow {
@@ -4134,13 +4159,13 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// The dedicated server's whitelist changed to `enabled`. @API:native_whitelist_state_update@.
/// The dedicated server's whitelist changed to `enabled`. @API:whitelist_state_update@.
fn whitelist_state_update(&self, host: &mut dyn Host, event: &mut WhitelistStateUpdateEvent) {
let _ = host;
let _ = event;
}
/// The server completed a world save cycle. `wait` says chunk writes were
/// flushed before returning. @API:native_world_saved@.
/// flushed before returning. @API:world_saved@.
fn world_saved(&self, host: &mut dyn Host, event: &mut WorldSavedEvent) {
let _ = host;
let _ = event;
@@ -9137,6 +9162,34 @@ pub fn dispatch_creeper_power(
mod_hook.creeper_power(host, &mut event);
}
}
/// What [`Hooks::critical_hit`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct CriticalHitEvent<'a> {
pub target_kind: &'a str,
pub vanilla_critical: bool,
pub critical: bool,
pub damage_multiplier: f32,
}
pub fn dispatch_critical_hit(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
target_kind: &str,
vanilla_critical: bool,
critical: &mut bool,
damage_multiplier: &mut f32,
) {
let mut event = CriticalHitEvent {
target_kind,
vanilla_critical,
critical: *critical,
damage_multiplier: *damage_multiplier,
};
for mod_hook in mods {
mod_hook.critical_hit(host, &mut event);
}
*critical = event.critical;
*damage_multiplier = event.damage_multiplier;
}
/// What [`Hooks::difficulty_changed`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct DifficultyChangedEvent<'a> {
@@ -10478,6 +10531,32 @@ pub fn dispatch_player_commands_sent(
mod_hook.player_commands_sent(host, &mut event);
}
}
/// What [`Hooks::player_destroy_item`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerDestroyItemEvent<'a> {
pub player: u128,
pub name: &'a str,
pub item: &'a str,
pub slot: &'a str,
}
pub fn dispatch_player_destroy_item(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
item: &str,
slot: &str,
) {
let mut event = PlayerDestroyItemEvent {
player,
name,
item,
slot,
};
for mod_hook in mods {
mod_hook.player_destroy_item(host, &mut event);
}
}
/// What [`Hooks::player_fish`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct PlayerFishEvent<'a> {
+53 -1
View File
@@ -487,6 +487,45 @@ pub trait Host {
/// (`native-data/<id>/` in the build's version folder); None when the game's own path is
/// unknown. API 41.
fn built_data(&self, mod_id: &str) -> Option<PathBuf>;
/// Whether the player left mod `mod_id` on in Options > Mods. A mod
/// turned off does nothing (check this first in each hook) until the
/// launcher's next build leaves it out.
fn on(&self, mod_id: &str) -> bool;
/// The key the player chose for a mod's key `name`, else `default`
/// (`key.keyboard.z`), as the WebAssembly mods' keys were kept
/// (`key.<mod id>.<name>` in `horizon-mods.txt`).
fn mod_key(&self, mod_id: &str, name: &str, default: &str) -> String;
/// A mod's setting `key` (one file per key in `native_data_dir`).
fn setting(&self, mod_id: &str, key: &str) -> Option<String>;
/// Keeps a mod's setting `key`.
fn set_setting(&self, mod_id: &str, key: &str, value: &str);
/// Sets property `key` of the block at `pos` to `value`, as a furnace's
/// tick sets its `lit` (`Level.setBlock` with `UPDATE_ALL`: neighbours
/// and clients are told); true when the block changed, false when the
/// block has no such property or value, or the property already has
/// `value` (as `Level.setBlock` for an unchanged state). The block
/// entity stays. API 18.
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str) -> bool;
/// `native_set_block_property` with `Level.setBlock`'s `flags`, as a
/// brewing stand's tick sets its bottles with `flags::UPDATE_CLIENTS`.
/// Players get the write whatever the flags (`flags`); false when this
/// window isn't a server, and in each case `native_set_block_property`
/// returns false. API 33.
fn set_block_property_with(
&mut self,
pos: [i32; 3],
key: &str,
value: &str,
flags: i32,
) -> bool;
/// The game's `config/` folder beside `options.txt`, shared by every mod
/// as on Fabric and NeoForge: a mod names its own files in it
/// (`config/<id>.json`, or a folder of its own). The same folder on the
/// window, its server thread and a LAN host; a dedicated server's is in
/// its own folder, beside `server.properties`. Made when missing. No
/// `self`, so a mixin helper calls `Game::native_config_dir()` and a trait
/// mod `Host::config_dir`. API 26.
fn config_dir(&self) -> Result<PathBuf, String>;
/// Shows the panel beside container screens with these items (at most
/// 8192), as the game's own slots in pages, with a search box; the
/// player's clicks, searches and keys over items come back as
@@ -664,7 +703,20 @@ pub trait Host {
item: Option<&str>,
max: u32,
) -> u32;
/// Hides that player (a vanish) from the players below permission
/// `level`, or shows them again (None): from the next tick those players
/// lose them from the Tab list and the world (their client stops
/// tracking the player, as it does a spectator), and don't read their
/// joining or leaving; players of `level` or more still see them, and
/// a player made an operator meanwhile sees them again. By UUID: kept
/// for a player who is off (who joins hidden), until a mod sets it again
/// or the server stops. Works anywhere on a server, inside a player's
/// hook too; false on a client and in a world nobody else can join.
/// API @API:hide_player@.
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool;
/// Draw a stack and its slot decorations, except the count, at `at`,
/// `size` GUI pixels square. False for an unknown item. API @API:item_stack@.
/// `size` GUI pixels square. False when nothing was drawn: an unknown item
/// or an empty stack (air, or a count of 0, as `ItemStack.isEmpty`).
/// API @API:item_stack@.
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool;
}
+5
View File
@@ -48,6 +48,8 @@ pub enum Event {
ModNet,
/// The server samples far terrain for this client (`lod_net`).
FarTerrain(crate::lod_net::Offer),
/// ClientConfigurationPacketListenerImpl.handleEnabledFeatures.
Features(Vec<String>),
/// Configuration finished: the play state begins with these registries.
Joined {
registries: Arc<Dynamic>,
@@ -898,6 +900,9 @@ fn configure(
packs: vec![],
}))?
}
cfg::Clientbound::UpdateEnabledFeatures(p) => {
let _ = send.send(Event::Features(p.features));
}
cfg::Clientbound::RegistryData(r) => registries.add(r),
cfg::Clientbound::UpdateTags(t) => registries.add_tags(&t),
cfg::Clientbound::CodeOfConduct(c) => {
+20 -17
View File
@@ -3,6 +3,7 @@
//! snowballs, eggs, ender pearls, gravity 0.03) and fireballs
//! (`AbstractHurtingProjectile`: no gravity, 0.1 acceleration, 0.95 inertia).
//! The game applies the effects of a hit.
use crate::entity_rules::EntityRules;
use crate::mobs::Who;
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -273,6 +274,23 @@ fn axis_of(d: u8) -> u8 {
_ => 0,
}
}
impl crate::entity_rules::EntityRules for Projectile {
fn is_no_gravity(&self) -> bool {
self.no_gravity
}
/// Each projectile class's `getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
match self.kind {
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => 0.05,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. } => 0.0,
ProjectileKind::LlamaSpit => 0.06,
ProjectileKind::Potion { .. } => 0.05,
_ => 0.03,
}
}
}
impl Projectile {
/// `Entity.getBoundingBox`: its type's square box standing on its position.
pub fn bounds(&self) -> Aabb {
@@ -443,21 +461,6 @@ impl Projectile {
| ProjectileKind::WitherSkull { .. }
)
}
/// `Entity.getGravity`: none with `NoGravity`, else the kind's default.
fn gravity(&self) -> f64 {
if self.no_gravity {
return 0.0;
}
match self.kind {
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => 0.05,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. } => 0.0,
ProjectileKind::LlamaSpit => 0.06,
ProjectileKind::Potion(_) => 0.05,
_ => 0.03,
}
}
/// Projectile.shoot: normalized direction plus triangular noise, times speed.
pub fn shoot(
&mut self,
@@ -858,7 +861,7 @@ impl Projectile {
// ThrowableProjectile: gravity, then inertia. Its drag is a
// float in vanilla (0.8F in water, `getAirDrag` 0.99F), so
// widens to the double that scales the motion.
self.velocity.y -= self.gravity();
self.velocity.y -= self.get_gravity();
let inertia = f64::from(if water { 0.8_f32 } else { 0.99_f32 });
self.velocity = Vec3::new(
self.velocity.x * inertia,
@@ -927,7 +930,7 @@ impl Projectile {
self.position = to;
self.velocity = Vec3::new(
self.velocity.x * 0.99,
self.velocity.y * 0.99 - self.gravity(),
self.velocity.y * 0.99 - self.get_gravity(),
self.velocity.z * 0.99,
);
self.update_rotation();
+48
View File
@@ -3767,6 +3767,13 @@ impl Level {
})
.collect();
}
if let Some(packs) = data.get("DataPacks") {
let ids = |key| match packs.get(key) {
Some(Tag::List(ids)) => ids.iter().filter_map(|id| text(Some(id))).collect(),
_ => Vec::new(),
};
level.data_packs = Some((ids("Enabled"), ids("Disabled")));
}
let mut rules: BTreeMap<String, i32> = GAME_RULES
.iter()
.map(|(k, v)| ((*k).to_owned(), *v))
@@ -3781,6 +3788,26 @@ impl Level {
}
}
}
// GameRules(enabledFeatures, map) removes disabled experimental rules
// and supplies their defaults when enabled but absent from saved data.
let minecart_rule = &crate::gamerules::MAX_MINECART_SPEED;
if level
.enabled_features
.iter()
.any(|f| f == crate::gamerules::MINECART_IMPROVEMENTS)
{
let crate::gamerules::Value::Int(default) = minecart_rule.default else {
unreachable!()
};
let value = rules
.entry(minecart_rule.name.to_owned())
.or_insert(default);
if !(minecart_rule.min..=minecart_rule.max).contains(value) {
*value = default;
}
} else {
rules.remove(minecart_rule.name);
}
level.game_rules = rules;
// Seed: world_gen_settings.dat (26.3) or Data.WorldGenSettings.
level.seed = read_saved_data(dir, "world_gen_settings")
@@ -4427,6 +4454,27 @@ impl Level {
};
put(&mut rules, &format!("minecraft:{name}"), tag);
}
if self
.enabled_features
.iter()
.any(|f| f == crate::gamerules::MINECART_IMPROVEMENTS)
{
let rule = &crate::gamerules::MAX_MINECART_SPEED;
let crate::gamerules::Value::Int(default) = rule.default else {
unreachable!()
};
let value = self
.game_rules
.get(rule.name)
.copied()
.filter(|v| (rule.min..=rule.max).contains(v))
.unwrap_or(default);
put(
&mut rules,
&format!("minecraft:{}", rule.name),
Tag::Int(value),
);
}
write_saved_data(dir, "game_rules", rules)?;
}
if write_player {
+9 -2
View File
@@ -3446,11 +3446,12 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
return Action::None;
};
self.create_return_to_main = false;
let options = WorldOptions::of_world(
let mut options = WorldOptions::of_world(
&level,
world.commands,
&self.saves.join(&world.folder),
);
options.restore_feature_packs(&self.resources, &level);
self.mode = Mode::Create {
name: level.name,
seed: level.seed.to_string(),
@@ -3575,7 +3576,10 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
// CreateWorldScreen.openGameRulesScreen over a copy of the rules.
(Mode::Create { options, .. }, Some(29)) => {
*self.rules.get_mut() = Some(RulesScreen::for_creation(&options.rules));
let rules = options
.rules
.copy_with_features(&options.enabled_features(&self.resources));
*self.rules.get_mut() = Some(RulesScreen::for_creation(&rules));
Action::None
}
// CreateBuffetWorldScreen for the single-biome preset.
@@ -4303,6 +4307,9 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
fn close_rules(&mut self, exit: Exit) {
*self.rules.get_mut() = None;
if let (Exit::Apply(changes), Mode::Create { options, .. }) = (exit, &mut self.mode) {
options.rules = options
.rules
.copy_with_features(&options.enabled_features(&self.resources));
for (name, value) in changes {
options.rules.set(name, value);
}
@@ -88,12 +88,74 @@ impl WorldOptions {
.flatten(),
structures: generation.structures,
bonus_chest: generation.bonus_chest,
rules: crate::gamerules::GameRules::load(dir),
rules: crate::gamerules::GameRules::load_with_features(dir, &level.enabled_features),
data_packs,
pack_dir,
..Self::default()
}
}
/// Re-Create's repository enables the original world's feature packs.
pub(super) fn restore_feature_packs(
&mut self,
resources: &std::path::Path,
level: &crate::save::Level,
) {
let packs = self.available_packs(resources);
if let Some((enabled, disabled)) = &level.data_packs {
let mut selected: Vec<_> = enabled
.iter()
.filter(|id| id.as_str() == "vanilla" || packs.iter().any(|p| &p.id == *id))
.cloned()
.collect();
for id in &self.data_packs {
if !selected.contains(id) && !disabled.contains(id) {
selected.push(id.clone());
}
}
self.data_packs = selected;
}
let provided: Vec<_> = packs
.iter()
.filter(|p| self.data_packs.contains(&p.id))
.flat_map(|p| p.features())
.collect();
let mut missing: Vec<_> = level
.enabled_features
.iter()
.filter(|f| f.as_str() != "minecraft:vanilla" && !provided.contains(f))
.cloned()
.collect();
for pack in packs.iter().filter(|p| !p.id.starts_with("file/")) {
let required = pack.features();
if required.iter().any(|f| missing.contains(f))
&& required.iter().all(|f| level.enabled_features.contains(f))
&& !self.data_packs.contains(&pack.id)
{
self.data_packs.push(pack.id.clone());
missing.retain(|f| !required.contains(f));
}
}
}
fn available_packs(&self, resources: &std::path::Path) -> Vec<crate::datapack::DataPack> {
let mut packs = crate::datapack::feature_packs(resources);
if let Some(dir) = &self.pack_dir {
packs.extend(crate::datapack::discover_folder(dir));
}
packs
}
/// WorldDataConfiguration's selected feature packs over vanilla's flags.
pub fn enabled_features(&self, resources: &std::path::Path) -> Vec<String> {
let mut features = vec!["minecraft:vanilla".to_owned()];
features.extend(
self.available_packs(resources)
.iter()
.filter(|pack| self.data_packs.contains(&pack.id))
.flat_map(|pack| pack.features()),
);
features.sort();
features.dedup();
features
}
/// `getDifficulty`: Hardcore is always Hard.
pub fn difficulty(&self) -> usize {
if self.hardcore { 3 } else { self.difficulty }
+2
View File
@@ -1030,6 +1030,7 @@ fn guests_see_each_other_and_the_world_entities() {
head_yaw: 0.0,
on_ground: true,
velocity: [0.0; 3],
minecart_steps: None,
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
@@ -1042,6 +1043,7 @@ fn guests_see_each_other_and_the_world_entities() {
leash: None,
spectator: false,
watching: false,
hidden: None,
};
let first = thread::spawn(move || {
let mut bot = connect(port, "Alex", false)?;
+29 -3
View File
@@ -5,7 +5,7 @@ use super::{
Dispatcher,
args::{ArgType, Resource, integer},
};
use horizon_client_core::gamerules::{RULES, Value};
use horizon_client_core::gamerules::{MINECART_IMPROVEMENTS, Value, available_rules};
/// Permission levels (Commands.LEVEL_*).
pub const ALL: u8 = 0;
@@ -41,7 +41,10 @@ pub fn build_with(dev: bool) -> Dispatcher {
/// [`build_with`] for a server of the given kind (Commands.CommandSelection):
/// an integrated server's tree, or with `dedicated` a dedicated server's.
pub fn build_for(dev: bool, dedicated: bool) -> Dispatcher {
let mut d = registered(dev);
build_for_features(dev, dedicated, false)
}
fn build_for_features(dev: bool, dedicated: bool, minecart_improvements: bool) -> Dispatcher {
let mut d = registered_with_features(dev, minecart_improvements);
if dedicated {
dedicated_selection(&mut d);
}
@@ -72,6 +75,9 @@ fn dedicated_selection(d: &mut Dispatcher) {
/// [`vanilla_order`](super::vanilla_order) puts children in vanilla's order
/// (tools/generate_command_order.py compares the two).
pub fn registered(dev: bool) -> Dispatcher {
registered_with_features(dev, false)
}
fn registered_with_features(dev: bool, minecart_improvements: bool) -> Dispatcher {
let mut d = Dispatcher::new();
let root = 0;
@@ -292,7 +298,7 @@ pub fn registered(dev: bool) -> Dispatcher {
// gamerule <rule> [<value>] (short and namespaced names)
let gr = d.literal(root, "gamerule");
d.requires(gr, GAMEMASTERS);
for rule in RULES {
for rule in available_rules(minecart_improvements) {
for name in [rule.name.to_owned(), format!("minecraft:{}", rule.name)] {
let r = d.literal(gr, &name);
d.executes(r, "gamerule");
@@ -576,6 +582,26 @@ pub fn game_dispatcher() -> &'static Dispatcher {
DEDICATED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
)
}
/// The server's tree for this world's enabled features. Cache entries are
/// separate from vanilla-only worlds and from each server/command selection.
pub fn game_dispatcher_with_features(features: &[String]) -> &'static Dispatcher {
if !features.iter().any(|f| f == MINECART_IMPROVEMENTS) {
return game_dispatcher();
}
static TREES: [std::sync::OnceLock<Dispatcher>; 8] = [const { std::sync::OnceLock::new() }; 8];
let dev = std::env::var_os("HORIZON_DEV_COMMANDS").is_some();
let horizon = HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
let dedicated = DEDICATED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
TREES[usize::from(dev) | usize::from(horizon) << 1 | usize::from(dedicated) << 2].get_or_init(
|| {
let mut d = build_for_features(dev, dedicated, true);
if horizon {
horizon_commands(&mut d, 0);
}
d
},
)
}
/// Whether this process is a dedicated server, whose tree has the dedicated
/// commands (`/op`, `/ban`, `/whitelist`...). Single player's server thread
/// and a LAN world are integrated servers: vanilla registers none of them.
+66 -1
View File
@@ -71,12 +71,16 @@ impl PreparedFiles {
/// The bytes of `name` (`/`-separated, from the root); `None` when this
/// doesn't hold it (read it from the folder).
pub fn read(&self, name: &str) -> Option<io::Result<Vec<u8>>> {
self.read_head(name, u64::MAX)
}
/// At most the first `max` bytes of `name`, as [`PreparedFiles::read`].
pub fn read_head(&self, name: &str, max: u64) -> Option<io::Result<Vec<u8>>> {
let i = self
.entries
.binary_search_by(|e| self.name(e).cmp(name))
.ok()?;
let (offset, len) = self.entries[i].data;
let mut bytes = vec![0; len as usize];
let mut bytes = vec![0; len.min(max) as usize];
Some(read_at(&self.file, &mut bytes, offset).map(|()| bytes))
}
/// The files under `dir` (`/`-separated, from the root) and its
@@ -207,6 +211,39 @@ pub fn find(path: &Path) -> Option<(Arc<PreparedFiles>, String)> {
})
}
/// `path`'s bytes: from its prepared root's data file when that holds it,
/// where a file the data file doesn't have is missing (`NotFound`, without
/// asking the disk: a texture's `.mcmeta` or LabPBR maps, mostly absent);
/// else from the disk.
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
match find(path) {
Some((files, rel)) if holds_file(&rel) => files
.read(&rel)
.unwrap_or_else(|| Err(io::ErrorKind::NotFound.into())),
_ => fs::read(path),
}
}
/// At most the first `max` bytes of `path`, from where [`read`] takes them:
/// a header, without reading the whole file.
pub fn read_head(path: &Path, max: u64) -> io::Result<Vec<u8>> {
match find(path) {
Some((files, rel)) if holds_file(&rel) => files
.read_head(&rel, max)
.unwrap_or_else(|| Err(io::ErrorKind::NotFound.into())),
_ => {
let mut bytes = Vec::new();
io::Read::read_to_end(&mut io::Read::take(File::open(path)?, max), &mut bytes)?;
Ok(bytes)
}
}
}
/// [`read`] as UTF-8 text.
pub fn read_to_string(path: &Path) -> io::Result<String> {
String::from_utf8(read(path)?).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
/// Write `root`'s file from its folders: through a temporary file renamed
/// into place, so a reader sees the old file or the new one. Nothing is
/// written for a root without `horizon-resources.json`.
@@ -457,4 +494,32 @@ mod tests {
assert!(find(&root.join("worldgen/structure")).is_none());
let _ = fs::remove_dir_all(&root);
}
/// Under a root with a file, a file it holds reads from it and a file it
/// doesn't is missing even when the disk has one now; a file outside its
/// folders, or a sound, reads from the disk.
#[test]
fn read_takes_the_file_for_what_it_holds() {
let root = root("read");
write(&root).unwrap();
let stick = root.join("data/minecraft/recipe/stick.json");
fs::write(&stick, b"changed on disk").unwrap();
assert_eq!(read(&stick).unwrap(), b"{\"stick\":1}");
let added = root.join("assets/minecraft/textures/block/stone_n.png");
fs::write(&added, b"added after").unwrap();
assert_eq!(read(&added).unwrap_err().kind(), io::ErrorKind::NotFound);
assert_eq!(read(&root.join("facts.json")).unwrap(), b"{}");
let sound = root.join("assets/minecraft/sounds/ambient/cave1.ogg");
assert_eq!(read(&sound).unwrap(), b"OggS");
assert_eq!(read_to_string(&stick).unwrap(), "{\"stick\":1}");
// A header: from the file for what it holds, from the disk for the rest.
assert_eq!(read_head(&stick, 4).unwrap(), b"{\"st");
assert_eq!(read_head(&stick, 100).unwrap(), b"{\"stick\":1}");
assert_eq!(read_head(&sound, 2).unwrap(), b"Og");
assert_eq!(
read_head(&added, 2).unwrap_err().kind(),
io::ErrorKind::NotFound
);
let _ = fs::remove_dir_all(&root);
}
}
+13 -28
View File
@@ -517,33 +517,18 @@ pub(crate) fn load_tag_lists(
overlay: &PackOverlay,
) -> Result<HashMap<String, Vec<String>>> {
let mut raw: HashMap<String, Vec<String>> = HashMap::new();
fn walk(
dir: &Path,
base: &Path,
namespace: &str,
raw: &mut HashMap<String, Vec<String>>,
) -> Result<()> {
for entry in fs::read_dir(dir)?.flatten() {
let p = entry.path();
if p.is_dir() {
walk(&p, base, namespace, raw)?;
} else if p.extension().is_some_and(|e| e == "json") {
let name = format!(
"{namespace}:{}",
p.strip_prefix(base)?
.with_extension("")
.to_string_lossy()
.replace('\\', "/")
);
let v: Value = serde_json::from_slice(&fs::read(&p)?)?;
let values = v["values"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| e.as_str().or_else(|| e["id"].as_str()).map(str::to_owned))
.collect();
raw.insert(name, values);
}
/// Through `json_folder`: from the prepared data file, and read once for
/// the tables a load builds from the same tags.
fn walk(dir: &Path, namespace: &str, raw: &mut HashMap<String, Vec<String>>) -> Result<()> {
for file in &horizon_world::json_folder::read(dir)?.files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
let values = v["values"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| e.as_str().or_else(|| e["id"].as_str()).map(str::to_owned))
.collect();
raw.insert(format!("{namespace}:{}", file.name), values);
}
Ok(())
}
@@ -551,7 +536,7 @@ pub(crate) fn load_tag_lists(
for namespace in crate::content_namespaces::tag_namespaces(root) {
let dir = root.join(format!("data/{namespace}/tags/{kind}"));
if dir.exists() {
walk(&dir, &dir, &namespace, &mut raw)?;
walk(&dir, &namespace, &mut raw)?;
}
}
// The packs' entries join the game's own, in pack order; a `replace`
@@ -59,6 +59,15 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
.ok_or_else(|| invalid_params("Not a string"))?,
),
),
"serversettings/force_game_mode" => game.force_game_mode(client, None),
"serversettings/force_game_mode/set" => game.force_game_mode(
client,
Some(
argument
.as_bool()
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"allowlist" => Ok(lists.allowlist()),
"bans" => Ok(lists.bans()),
"ip_bans" => Ok(lists.ip_bans()),
@@ -290,6 +299,29 @@ struct Game<'a> {
}
impl Game<'_> {
/// `MinecraftServerSettingsServiceImpl.forceGameMode` and `setForceGameMode`.
fn force_game_mode(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
let mut request = json!({ "method": "serversettings/force_game_mode" });
if let Some(value) = value {
request["method"] = json!("serversettings/force_game_mode/set");
request["value"] = json!(value);
}
let reply = self.ask(&format!("management {client} {request}"))?;
if let Some(error) = reply["error"].as_str() {
return Err((INTERNAL_ERROR, Some(error.to_owned())));
}
if let Some(value) = value {
crate::config::update(
&self.shared.dir,
"properties",
"force-gamemode",
toml::Value::Boolean(value),
)
.map_err(|e| (INTERNAL_ERROR, Some(e)))?;
}
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.getGameMode` and `setGameMode`.
fn game_mode(&self, client: u32, value: Option<&str>) -> Result<Value, Failure> {
let mut request = json!({ "method": "serversettings/game_mode" });
+10 -2
View File
@@ -209,8 +209,16 @@ While a window has two or three players, a square at the bottom picks how they s
by side); Apply saves it (`splitscreenTwo`, `splitscreenThree`). **Names** (top left,
`splitscreenNames`, on by default) keeps each player's name in the corner of their view; off,
it shows only for a moment when something changes. **Identify** makes every view say which
window it is in. Controller pick-up in Setup and joining P1 on an external server follow
separately.
window it is in.
With a controller, A on a player's tile picks it up: left and right (or LB and RB) cycle where
it goes, the map showing it there, A drops it, B puts it back. **Give Up Pad** (top, while P1
has a controller in a world) frees P1's controller and offers it to join, for a friend to
press A; if nobody joins, the offer runs out, or it's declined, it drives P1 again. A player
whose controller disconnected shows "(no controller)" on its tile. Other players' pause menus
say **Leave** for disconnecting. `HORIZON_SPLIT_STATS=1` logs every 5 s where a frame's time
goes (each player's turn and render, the presents, the GPU wait) and each player's frame rate.
Joining P1 on an external server follows separately.
## Shared rules
+39 -11
View File
@@ -1583,10 +1583,10 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
every mod, so a later mod sees the value an earlier one set. It runs before `native_time_skip`, which hears the jump
to the time chosen here and may still cancel it; the players wake either way. Bundles need
`"hooks": @API:sleep_finished_time@`.
- **`native_world_border_bounds_changed()`**: `/worldborder set` or `/worldborder add` changed the border size or started a timed size change. Bundles need `"hooks": @API:world_border_bounds_changed@`.
- **`native_world_border_center_changed()`**: `/worldborder center` moved the border center. It hears successful commands only. Bundles need `"hooks": @API:world_border_center_changed@`.
- **`native_world_game_rule_change(player, name, rule, value)`**: an operator changed a game rule through the server game rules screen; it is heard after the final value is applied. Bundles need `"hooks": @API:world_game_rule_change@`.
- **`native_spawn_change(pos, angle)`**: `/setworldspawn` moved the world's spawn point, after its position and rotation are applied. Bundles need `"hooks": @API:spawn_change@`.
- **`native_world_border_bounds_changed()`**: `/worldborder set` or `/worldborder add` changed the border size or started a timed size change. Bundles need `"hooks": @API:native_world_border_bounds_changed@`.
- **`native_world_border_center_changed()`**: `/worldborder center` moved the border center. It hears successful commands only. Bundles need `"hooks": @API:native_world_border_center_changed@`.
- **`native_world_game_rule_change(player, name, rule, value)`**: an operator changed a game rule through the server game rules screen; it is heard after the final value is applied. Bundles need `"hooks": @API:native_world_game_rule_change@`.
- **`native_spawn_change(pos, angle)`**: `/setworldspawn` moved the world's spawn point, after its position and rotation are applied. Bundles need `"hooks": @API:native_spawn_change@`.
- **`native_portal_create(dimension, pos)`**: a Nether portal built for dimension travel or the End arrival platform was completed in `dimension`; `pos` is its lower corner or platform origin. Bundles need `"hooks": @API:portal_create@`.
- **`native_raid_spawn_wave(raid, pos, wave, raiders)`**: a raid wave is about to be spawned at `pos`; `raiders` is the count Horizon will spawn.
- **`native_raid_state_change(raid, pos, state)`**: a raid at `pos` was triggered or changed to `victory`, `loss`, or `stopped`.
@@ -2124,7 +2124,7 @@ another mod's hook object is not exposed.
Trait dispatch follows dependency order, with ties kept in bundle order. Mixin injection order remains
horizon-mixins' priority, mod ID, then injection ID; dependency order does not change injection order.
A bundle with an `[api]` package may declare its own HEAD injection in the trait:
A bundle with an `[api]` package may declare its own injection in the trait:
```toml
[[injection]]
@@ -2139,10 +2139,36 @@ The generated trait method is `inject_<mod_id>_<trait_method>` (hyphens in the m
underscores). Its event, as a catalog hook's, is that name in CamelCase plus `Event`
(`InjectExampleBeforeTickEvent`), with the bindings as fields under the same rules. Implement it on the object
returned by that bundle's `hooks()`; it is called only for that bundle's object. Types must be
public and available from both the target and `horizon_client::mod_api`. Only HEAD listen is
supported, or HEAD with `cancellable = true` for a unit-returning target: that method returns
`Flow`, and `Flow::Cancel` returns early from the target. Do not specify `helper` on these entries.
Other injection kinds are rejected. Trait injections can target only functions in `horizon-client`;
public and available from both the target and `horizon_client::mod_api`. The kinds are HEAD listen,
or HEAD with `cancellable = true` for a unit-returning target (that method returns `Flow`, and
`Flow::Cancel` returns early from the target), and RETURN, MODIFY_ARG and MODIFY_EXPRESSION_VALUE.
For those three, the binding whose `value` is `$value` (its type is the returned, argument or
expression type, as horizon-mixins requires) becomes a plain event field. The method changes it in
place and returns nothing; the changed value is what the target returns, passes or uses. The selector,
`require`, `ordinal` and `argument` are horizon-mixins' own fields. Before the mods are installed, the
value passes through unchanged. A borrowed `$value` works only where Rust's elision ties the result to
one input. `cancellable` is HEAD-only. Other kinds are rejected. Do not specify `helper` on these entries.
```toml
[[injection]]
id = "points"
target = "horizon_client::game::example"
kind = "MODIFY_ARG"
selector = "self.give_points"
require = 1
ordinal = 0
argument = 0
trait_method = "points"
bindings = [{ name = "points", ty = "i32", value = "$value" }]
```
```rust
fn inject_example_points(&self, event: &mut InjectExamplePointsEvent) {
event.points *= 2;
}
```
Trait injections can target only functions in `horizon-client`;
ordinary mixin helpers in other crates continue to work as before. The generated helper has the same priority and ordering as an
ordinary helper of that mod: priority descending, mod id then injection id. It is not automatically
ordered after other mixins. Catalog hooks retain the mixin-before-trait order below.
@@ -2184,7 +2210,8 @@ Level hooks use access-specific hosts rather than `Host`. `LevelHost` provides
`state_with_property`. `LevelRandomHost` provides vanilla's `Level.random` so
mod draws come in vanilla's order. `LevelTicksHost` adds scheduling through
`native_schedule_block_tick` and `native_schedule_fluid_tick`, while
`LevelWriteHost` adds random access and `native_set_block_property`. Whole-block writes wait for a
`LevelWriteHost` adds random access, `native_set_block_property`, `native_play_sound` and
`native_send_particles`. Whole-block writes wait for a
later slice that creates block entities, as vanilla's `setBlock` does. The game owns
the tick queue; mods do not pass it back to these helpers.
For frame hooks, implement `RenderHooks` and return it from `Hooks::render`. The ordered
@@ -2261,7 +2288,8 @@ handler, conditional serialization or sync APIs.
`Host` exposes the documented `Game` helpers listed under `helpers` in
`native-hooks.json`, without their `native_` prefix. Each keeps the helper's
receiver and documentation. Level services stay on the level hosts above.
receiver and documentation; a helper without `self` (`config_dir`) takes
`&self` in `Host`. Level services stay on the level hosts above.
The following documented `Game` helpers are not in the trait yet:
- `spawn_mob`, `mob`, `mobs`, `block_in_tag`, `item_in_tag`,
+179 -51
View File
@@ -1,4 +1,4 @@
//! Trait-backed HEAD injections, generated in the incoming copy and staged game.
//! Trait-backed injections, generated in the incoming copy and staged game.
use super::{api, Bundle, MANIFEST};
use quote::{format_ident, quote};
use std::{
@@ -14,7 +14,18 @@ pub(super) struct Injection {
/// The bindings' struct, as each catalog hook's (`InjectExampleTickEvent`).
event: syn::Ident,
parameters: Vec<(syn::Ident, syn::Type)>,
cancellable: bool,
contract: Contract,
}
/// What a trait method may do with its event.
enum Contract {
/// HEAD: observe and change `&mut` bindings.
Listen,
/// Cancellable HEAD on a unit target, where `Flow::Cancel` returns early.
Cancel,
/// RETURN, MODIFY_ARG or MODIFY_EXPRESSION_VALUE: the ident names the `$value` binding,
/// which is moved into the event and returned after the call.
Value(syn::Ident),
}
/// Turns a binding's type into its event field's: a `&mut` primitive scalar becomes the value,
@@ -136,12 +147,6 @@ pub(super) fn prepare(mods: &[Bundle], source: &Path) -> Result<Vec<Injection>,
if target_crate != "horizon_client" {
return Err(format!("trait injection target {target:?} is in crate {target_crate:?}; only horizon-client targets are supported"));
}
if table.get("kind").and_then(toml::Value::as_str) != Some("HEAD") {
return Err(format!(
"{}: trait_method supports only HEAD injections",
path.display()
));
}
if table.contains_key("helper") {
return Err(format!(
"{}: trait_method cannot also specify helper",
@@ -165,6 +170,7 @@ pub(super) fn prepare(mods: &[Bundle], source: &Path) -> Result<Vec<Injection>,
};
let mut parameters = Vec::new();
let mut seen = HashSet::new();
let mut value = None;
if let Some(bindings) = table.get("bindings") {
for binding in bindings.as_array().ok_or("bindings must be an array")? {
let binding = binding.as_table().ok_or("binding must be a table")?;
@@ -183,9 +189,40 @@ pub(super) fn prepare(mods: &[Bundle], source: &Path) -> Result<Vec<Injection>,
.ok_or("binding needs a type")?;
let ty: syn::Type =
syn::parse_str(ty).map_err(|e| format!("invalid binding type: {e}"))?;
if binding.get("value").and_then(toml::Value::as_str) == Some("$value") {
value = Some(ident.clone());
}
parameters.push((ident, ty));
}
}
let kind = table
.get("kind")
.and_then(toml::Value::as_str)
.unwrap_or_default();
let contract = match (kind, cancellable) {
("HEAD", false) => Contract::Listen,
("HEAD", true) => Contract::Cancel,
("RETURN" | "MODIFY_ARG" | "MODIFY_EXPRESSION_VALUE", false) => {
Contract::Value(value.ok_or_else(|| {
format!(
"{}: a {kind} trait_method needs a `$value` binding",
path.display()
)
})?)
}
(_, true) => {
return Err(format!(
"{}: cancellable trait_method needs HEAD",
path.display()
));
}
_ => {
return Err(format!(
"{}: trait_method supports HEAD, RETURN, MODIFY_ARG and MODIFY_EXPRESSION_VALUE",
path.display()
));
}
};
let event = format_ident!(
"{}Event",
name.split('_')
@@ -201,7 +238,7 @@ pub(super) fn prepare(mods: &[Bundle], source: &Path) -> Result<Vec<Injection>,
name: ident,
event,
parameters,
cancellable,
contract,
};
let helper_path = format!("horizon-trait-injection-{index}.rs");
let helper_file = bundle.dir.join(&helper_path);
@@ -251,27 +288,34 @@ impl Injection {
.zip(&scalars)
.filter(|(_, scalar)| **scalar)
.map(|((n, _), _)| quote!(*#n = event.#n;));
let call = quote! {{
let call = quote! {
let mut event = crate::mod_api::#event { #(#inits),* };
let result = owner.#name(&mut event);
let _flow = owner.#name(&mut event);
#(#write_back)*
result
}};
};
// Before the mods are installed (an injection into code `main_with` runs first), or
// with fewer mods than the build had, the injection does nothing.
let (output, result, absent) = if self.cancellable {
(
// with fewer mods than the build had, the injection does nothing or passes the value on.
let (output, result, absent) = match &self.contract {
Contract::Listen => (quote!(), call, quote!()),
Contract::Cancel => (
quote!(-> core::ops::ControlFlow<()>),
quote! {
if #call == crate::mod_api::Flow::Cancel {
#call
if _flow == crate::mod_api::Flow::Cancel {
return core::ops::ControlFlow::Break(());
}
core::ops::ControlFlow::Continue(())
},
quote!(core::ops::ControlFlow::Continue(())),
)
} else {
(quote!(), quote!(#call;), quote!())
),
Contract::Value(value) => {
let ty = self
.parameters
.iter()
.find(|(n, _)| n == value)
.map(|(_, t)| t);
(quote!(-> #ty), quote!(#call event.#value), quote!(#value))
}
};
quote! {
fn horizon_trait_dispatch(#(#params),*) #output {
@@ -287,10 +331,9 @@ impl Injection {
fn declaration(&self) -> String {
let name = &self.name;
let event = &self.event;
let (output, default) = if self.cancellable {
(quote!(-> Flow), quote!(Flow::Continue))
} else {
(quote!(), quote!())
let (output, default) = match self.contract {
Contract::Cancel => (quote!(-> Flow), quote!(Flow::Continue)),
Contract::Listen | Contract::Value(_) => (quote!(), quote!()),
};
let elided = match self.lifetimes().0 {
(_, true) => quote!(<'_, '_>),
@@ -306,7 +349,14 @@ impl Injection {
let (types, scalars) = self
.parameters
.iter()
.map(|(_, ty)| lifetimes.field(ty))
.map(|(name, ty)| match &self.contract {
Contract::Value(value) if name == value => {
let mut field = ty.clone();
lifetimes.visit_type_mut(&mut field);
(field, false)
}
_ => lifetimes.field(ty),
})
.unzip();
((lifetimes.a, lifetimes.b), types, scalars)
}
@@ -405,6 +455,29 @@ mod tests {
(root, bundle)
}
fn compile_and_run(root: &Path, program: &str) {
let source = root.join("proof.rs");
let binary = root.join(format!("proof-bin{}", std::env::consts::EXE_SUFFIX));
fs::write(&source, program).unwrap();
let compiler = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let compiled = std::process::Command::new(compiler)
.args(["--edition", "2024"])
.arg(&source)
.arg("-o")
.arg(&binary)
.output()
.unwrap();
assert!(
compiled.status.success(),
"{}",
String::from_utf8_lossy(&compiled.stderr)
);
assert!(std::process::Command::new(binary)
.status()
.unwrap()
.success());
}
#[test]
fn owner_dispatch_and_cancel_are_generated_from_bindings() {
let (root, bundle) = fixture("cancel", "[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = 'HEAD'\ntrait_method = 'tick'\ncancellable = true\nbindings = [{ name = 'amount', ty = '&mut i32', value = '&mut *amount' }]\n");
@@ -453,26 +526,62 @@ mod tests {
}}
"#
);
let source = root.join("proof.rs");
let binary = root.join(format!("proof-bin{}", std::env::consts::EXE_SUFFIX));
fs::write(&source, program).unwrap();
let compiler = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let compiled = std::process::Command::new(compiler)
.args(["--edition", "2024"])
.arg(&source)
.arg("-o")
.arg(&binary)
.output()
.unwrap();
assert!(
compiled.status.success(),
"{}",
String::from_utf8_lossy(&compiled.stderr)
compile_and_run(&root, &program);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn value_injection_returns_what_the_owner_changed() {
let (root, bundle) = fixture("value", "[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = 'RETURN'\ntrait_method = 'total'\nbindings = [{ name = 'total', ty = 'i32', value = '$value' }, { name = 'calls', ty = '&mut u32', value = '&mut *calls' }]\n");
let injections = prepare(&[bundle], &root).unwrap();
let helper = fs::read_to_string(root.join("horizon-trait-injection-0.rs")).unwrap();
let stage_dir = root.join("stage/crates/horizon-client/src/mod_api");
fs::create_dir_all(&stage_dir).unwrap();
fs::write(
stage_dir.join("generated.rs"),
"pub trait Hooks: Send + Sync {\n}\n",
)
.unwrap();
stage(&root.join("stage"), &injections).unwrap();
let output = fs::read_to_string(stage_dir.join("generated.rs")).unwrap();
let program = format!(
r#"
mod mod_api {{
use std::sync::atomic::{{AtomicBool, Ordering}};
use std::sync::OnceLock;
{output}
struct Owner;
impl Hooks for Owner {{
fn inject_example_total(&self, event: &mut InjectExampleTotalEvent) {{
event.total = event.total * 2 + 1;
event.calls += 1;
}}
}}
struct Other;
impl Hooks for Other {{
fn inject_example_total(&self, _: &mut InjectExampleTotalEvent) {{ panic!("wrong owner") }}
}}
pub static INSTALLED: AtomicBool = AtomicBool::new(false);
pub fn mods() -> &'static [Box<dyn Hooks>] {{
static MODS: OnceLock<Vec<Box<dyn Hooks>>> = OnceLock::new();
if !INSTALLED.load(Ordering::SeqCst) {{
return &[];
}}
MODS.get_or_init(|| vec![Box::new(Owner), Box::new(Other)])
}}
}}
{helper}
fn main() {{
let mut calls = 0;
assert_eq!(horizon_trait_dispatch(20, &mut calls), 20);
assert_eq!(calls, 0);
mod_api::INSTALLED.store(true, std::sync::atomic::Ordering::SeqCst);
assert_eq!(horizon_trait_dispatch(20, &mut calls), 41);
assert_eq!(calls, 1);
}}
"#
);
assert!(std::process::Command::new(binary)
.status()
.unwrap()
.success());
compile_and_run(&root, &program);
fs::remove_dir_all(root).unwrap();
}
@@ -619,12 +728,31 @@ mod tests {
}
#[test]
fn unsupported_injection_is_rejected() {
let (root, bundle) = fixture("return", "[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = 'RETURN'\ntrait_method = 'tick'\n");
assert!(prepare(&[bundle], &root)
.err()
.unwrap()
.contains("only HEAD"));
fs::remove_dir_all(root).unwrap();
fn trait_injection_kinds_are_checked() {
for (kind, extra, expected) in [
(
"AFTER_CALL",
"",
"supports HEAD, RETURN, MODIFY_ARG and MODIFY_EXPRESSION_VALUE",
),
(
"RETURN",
"bindings = [{ name = 'amount', ty = 'i32', value = 'amount' }]\n",
"`$value` binding",
),
(
"MODIFY_ARG",
"cancellable = true\nbindings = [{ name = 'points', ty = 'i32', value = '$value' }]\n",
"cancellable trait_method needs HEAD",
),
] {
let (root, bundle) = fixture(
"kinds",
&format!("[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = '{kind}'\ntrait_method = 'tick'\n{extra}"),
);
let error = prepare(&[bundle], &root).err().unwrap();
assert!(error.contains(expected), "{kind}: {error}");
fs::remove_dir_all(root).unwrap();
}
}
}
+28
View File
@@ -316,6 +316,34 @@
"name": "built_data",
"target": "horizon_client::game::native_hooks::Game<'_>::native_built_data"
},
{
"name": "on",
"target": "horizon_client::game::native_hooks::Game<'_>::native_on"
},
{
"name": "mod_key",
"target": "horizon_client::game::native_hooks::Game<'_>::native_mod_key"
},
{
"name": "setting",
"target": "horizon_client::game::native_hooks::Game<'_>::native_setting"
},
{
"name": "set_setting",
"target": "horizon_client::game::native_hooks::Game<'_>::native_set_setting"
},
{
"name": "set_block_property",
"target": "horizon_client::game::native_hooks::Game<'_>::native_set_block_property"
},
{
"name": "set_block_property_with",
"target": "horizon_client::game::native_hooks::Game<'_>::native_set_block_property_with"
},
{
"name": "config_dir",
"target": "horizon_client::game::native_hooks::Game<'_>::native_config_dir"
},
{
"name": "panel_items",
"target": "horizon_client::game::native_hooks::Game<'_>::native_panel_items"
+1 -1
View File
@@ -44,7 +44,7 @@ impl MobIndex {
struct Game {
mobs: Vec<Entity>, mob_index: MobIndex, dropped: Vec<Entity>, falling: Vec<Entity>, tnts: Vec<Entity>, orbs: Vec<Entity>,
creatures: Creatures, dragon_fireballs: Vec<Entity>, tridents: Vec<Entity>,
rockets: Vec<Entity>, remote: Option<Remote>,
rockets: Vec<Entity>, carts: Vec<Entity>, remote: Option<Remote>,
potions: Potions, end: End,
}
impl Game {
+21 -1
View File
@@ -1,5 +1,7 @@
import importlib.util
import json
import re
import shutil
import subprocess
import tempfile
import unittest
@@ -70,7 +72,7 @@ class StampTests(unittest.TestCase):
self.assertIn("API 9 adds a; requires 9.", docs)
self.assertIn("NATIVE_HOOKS_API: u32 = 9;", source)
hooks = json.loads((self.root / stamp.CATALOG).read_text())["hooks"]
self.assertEqual([(h["name"], h["version"]) for h in hooks], [("z", 1), ("a", 1)])
self.assertEqual([(h["name"], h["version"]) for h in hooks], [("a", 1), ("z", 1)])
self.assertFalse(stamp.stamp(False))
def test_helper_only_gets_a_number(self):
@@ -121,6 +123,24 @@ class StampTests(unittest.TestCase):
stamp.stamp(False)
self.assertEqual((self.root / stamp.SOURCE).read_text(), before)
def test_the_repository_stamps_every_marker(self):
# A copy of this checkout's pending fragments and the files they stamp: stamping must
# succeed and leave no marker, or the stamp workflow fails or ships unnumbered docs.
repo = self.original
fragments = [p.relative_to(repo) for p in (repo / stamp.FRAGMENTS).glob("*.json")]
comments = {Path(c) for p in fragments for c in json.loads((repo / p).read_text()).get("comments", [])}
stamped = [stamp.SOURCE, stamp.DOCS, *stamp.MOD_API, *comments]
for path in [stamp.CATALOG, *stamped, *fragments]:
if (repo / path).exists():
(self.root / path).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(repo / path, self.root / path)
self.git("add", ".")
self.git("commit", "-qm", "base")
self.assertFalse(stamp.stamp(False))
left = {str(p): re.findall(r"@API:\w+@", (self.root / p).read_text())
for p in stamped if (self.root / p).exists()}
self.assertEqual({p: m for p, m in left.items() if m}, {})
if __name__ == "__main__":
unittest.main()
+14 -2
View File
@@ -25,7 +25,7 @@ done
mkdir -p .local "$shared/prepared"
for entry in "$shared"/*; do
name=$(basename "$entry")
case "$name" in prepared|sounds|*.log) continue ;; esac
case "$name" in prepared|sounds|languages|*.log) continue ;; esac
if [ -L ".local/$name" ] || [ ! -e ".local/$name" ]; then ln -sfn "$entry" ".local/$name"; fi
done
@@ -84,7 +84,7 @@ fi
# the same neighbours as in the shared folder.
for entry in "$shared"/*; do
name=$(basename "$entry")
case "$name" in prepared|sounds|*.log) continue ;; esac
case "$name" in prepared|sounds|languages|*.log) continue ;; esac
[ -e "$prepared/$name" ] || ln -sfn "$entry" "$prepared/$name"
done
while read -r name; do
@@ -104,6 +104,18 @@ if [ -f "$shared/sounds/$version/sounds.json" ] && [ -d "$assets" ]; then
else cp -R --reflink=auto "$shared/sounds/$version/$entry" "$assets/"; fi
done
fi
# The languages (horizon-prepare languages copies them from the official launcher's asset store, which no runner
# has) depend only on the Minecraft version too: kept once in $shared/languages/<version>/ (assets/pack.mcmeta, the
# language list, and assets/minecraft/lang/) and cloned into each preparation that lacks them. Without them a
# preparation has en_us alone, and pack_screen's harness, which draws ten languages, fails.
languages="$shared/languages/$version/assets"
if [ -d "$languages/minecraft/lang" ] && [ -d "$assets" ] && [ ! -e "$prepared/horizon-client/$version/assets/pack.mcmeta" ]; then
if [ "$(uname)" = Darwin ]; then
cp -cR "$languages/minecraft/lang/." "$assets/lang/" && cp -c "$languages/pack.mcmeta" "$assets/../"
else
cp -R --reflink=auto "$languages/minecraft/lang/." "$assets/lang/" && cp --reflink=auto "$languages/pack.mcmeta" "$assets/../"
fi
fi
touch "$prepared"
# The four newest preparations stay.
ls -1dt "$shared/prepared"/*/ 2>/dev/null | tail -n +5 | while read -r old; do rm -rf "$old"; done
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "critical_hit",
"target": "horizon_client::game::native_hooks::Game<'_>::native_critical_hit",
"neoforge": "CriticalHitEvent",
"side": "both",
"cancellable": false,
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A player's melee hit on a living target decides whether it is critical (`native_critical_hit`).",
"doc_summary": "A player's damaging melee hit on a living target: `native_critical_hit(target_kind, vanilla_critical, critical, damage_multiplier)` may force or deny the critical hit and change its multiplier (1.5 for a vanilla crit, else 1.0); the sweep, sound and particles follow the decided result. Not fired for a dragon part or a hit that does no damage. Bundles need \"hooks\": @API:critical_hit@."
}
@@ -0,0 +1,9 @@
{
"hooks": [],
"helpers": [
{"name": "hide_player", "target": "horizon_client::game::native_server::Game<'_>::native_hide_player"}
],
"rust_summary": "hiding a player from the players below a permission level (`native_hide_player`).",
"doc_summary": "hiding a player (a vanish): `native_hide_player(uuid, level)` hides that player, from the next tick, from the players below permission `level` (None shows them again): they leave those players' Tab list and world (their clients stop tracking them, as they do a spectator) and those players don't read them joining or leaving. Players of `level` or more still see them; a player made an operator sees them again. It is kept by UUID, for a player who is off too, until a mod sets it again or the server stops. Server only; false on a client. Bundles need \"hooks\": @API:hide_player@.",
"comments": ["crates/horizon-client/src/game/native_server.rs"]
}
+1 -1
View File
@@ -4,5 +4,5 @@
{"name": "item_stack", "target": "horizon_client::game::native_hooks::Game<'_>::native_item_stack"}
],
"rust_summary": "drawing a component-bearing item stack and its decorations on a screen (`native_item_stack`).",
"doc_summary": "drawing an item stack on a screen: `native_item_stack(batch, stack, at, size)` draws its model, glint, damage bar, cooldown and mod decorations, but not its count. It returns false for an unknown item. Bundles need \"hooks\": @API:item_stack@."
"doc_summary": "drawing an item stack on a screen: `native_item_stack(batch, stack, at, size)` draws its model, glint, damage bar, cooldown and mod decorations, but not its count. It draws nothing and returns false for an unknown item or an empty stack (air, or a count of 0). Bundles need \"hooks\": @API:item_stack@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_destroy_item",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_destroy_item",
"neoforge": "none (NeoForge's PlayerDestroyItemEvent carries the original stack and a hand, which armour has not; this hook only listens and names the slot instead. Bukkit's PlayerItemBreakEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a player's item breaking (`native_player_destroy_item`).",
"doc_summary": "a player's item breaking: `native_player_destroy_item(player, name, item, slot)` is heard for a server player whose tool, shield, rod or armour piece ran out of durability (`LivingEntity.onEquippedItemBroken`, Bukkit's `PlayerItemBreakEvent`), with the item that broke and the slot it was in (`mainhand`, `offhand`, `head`, `chest`, `legs`, `feet`, `body`, `saddle`); armour has a slot rather than a hand. `native_item_damaged` is the wear, this is only the break. Listen only. The player's state is swapped in. Not heard for a creative player or for the host of a LAN world. Bundles that use it need `\"hooks\": @API:native_player_destroy_item@`."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "A server player put a plant in a flower pot (`native_player_flower_pot_manipulate`).",
"doc_summary": "A server player put a plant into a flower pot: `native_player_flower_pot_manipulate(player, name, pos, plant)` is heard after the potted state is stored and the item consumed. Bundles need \"hooks\": @API:player_flower_pot_manipulate@."
"doc_summary": "A server player put a plant into a flower pot: `native_player_flower_pot_manipulate(player, name, pos, plant)` is heard after the potted state is stored and the item consumed. Bundles need \"hooks\": @API:native_player_flower_pot_manipulate@."
}
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more