Merge main into #6956

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 12:00:41 +01:00
commit 29792b2c0c
122 files changed
+4048 -943

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",
+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
}
}
+17 -9
View File
@@ -5557,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.
@@ -11532,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;
@@ -11615,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>,
) {
@@ -11636,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);
@@ -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();
@@ -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.
@@ -5165,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 {
@@ -5208,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,
@@ -5753,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))
@@ -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,
})
}
}
+94 -1
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
@@ -500,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))
}
@@ -631,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))
@@ -782,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
}
}
+80 -9
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()
@@ -3100,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
@@ -3113,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,
@@ -5065,6 +5065,32 @@ impl Game<'_> {
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
@@ -5648,6 +5674,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.
@@ -6932,7 +6974,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,
@@ -6940,6 +6984,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;
}
@@ -7202,8 +7251,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() {
+18 -5
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: [
+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,
+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,
+5
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)]
+6 -2
View File
@@ -2752,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",
+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);
}
}
+9 -11
View File
@@ -789,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)
+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,20 +2456,20 @@ 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;
}
/// 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 {
@@ -2478,7 +2478,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,
@@ -2488,7 +2488,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 {
@@ -2504,21 +2504,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 {
@@ -2527,14 +2527,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 {
@@ -2545,7 +2545,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 {
@@ -2555,7 +2555,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 {
@@ -2565,7 +2565,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(
@@ -2579,7 +2579,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;
@@ -2601,7 +2601,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 {
@@ -2616,7 +2616,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(
@@ -2659,16 +2659,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
@@ -2681,7 +2695,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 {
@@ -2689,7 +2703,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;
@@ -2717,42 +2731,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,
@@ -2763,25 +2777,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;
@@ -2790,25 +2804,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;
@@ -2882,14 +2896,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(
@@ -2921,7 +2935,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;
@@ -2939,12 +2953,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(
@@ -2972,7 +2986,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(
@@ -3017,13 +3031,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 {
@@ -3033,7 +3047,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 {
@@ -3073,7 +3087,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 {
@@ -3271,6 +3285,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
@@ -3294,7 +3319,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,
@@ -3328,7 +3353,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,
@@ -3341,7 +3366,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,
@@ -3353,7 +3378,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;
@@ -3361,7 +3386,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,
@@ -3423,7 +3448,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,
@@ -3544,7 +3569,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 {
@@ -3614,7 +3639,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,
@@ -3660,7 +3685,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;
@@ -3668,7 +3693,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,
@@ -3681,7 +3706,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(
@@ -3696,7 +3721,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 {
@@ -3706,7 +3731,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;
@@ -3750,7 +3775,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 {
@@ -3771,7 +3796,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 {
@@ -3782,7 +3807,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,
@@ -3794,7 +3819,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(
@@ -3842,7 +3867,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(
@@ -3891,7 +3916,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(
@@ -3938,18 +3963,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(
@@ -3961,7 +3986,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(
@@ -3973,7 +3998,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(
@@ -3985,12 +4010,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(
@@ -4003,26 +4028,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;
@@ -4046,13 +4071,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<'_>) {
@@ -4060,14 +4085,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 {
@@ -4076,7 +4101,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 {
@@ -4084,7 +4109,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;
@@ -4104,14 +4129,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 {
@@ -4119,13 +4144,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;
@@ -9091,6 +9116,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> {
@@ -10432,6 +10485,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);
}
}
@@ -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
+8 -6
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`.
@@ -2210,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
@@ -2287,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`,
+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()
@@ -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@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "A server player inserted a book into a lectern (`native_player_insert_lectern_book`).",
"doc_summary": "A server player inserted a book into a lectern: `native_player_insert_lectern_book(player, name, pos, book)` is heard after the block entity and inventory changed. Bundles need \"hooks\": @API:player_insert_lectern_book@."
"doc_summary": "A server player inserted a book into a lectern: `native_player_insert_lectern_book(player, name, pos, book)` is heard after the block entity and inventory changed. Bundles need \"hooks\": @API:native_player_insert_lectern_book@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "A server player changed an item frame (`native_player_item_frame_change`).",
"doc_summary": "A server player changed an item frame: `native_player_item_frame_change(player, name, entity_id, action, item, rotation)` is heard after its contents and comparator output changed. Bundles need \"hooks\": @API:player_item_frame_change@."
"doc_summary": "A server player changed an item frame: `native_player_item_frame_change(player, name, entity_id, action, item, rotation)` is heard after its contents and comparator output changed. Bundles need \"hooks\": @API:native_player_item_frame_change@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "An orb repaired a server player's item through Mending (`native_player_item_mend`).",
"doc_summary": "An orb repaired an item through Mending: `native_player_item_mend(player, name, item, repair, experience)` is heard after its damage changed. Bundles need \"hooks\": @API:player_item_mend@."
"doc_summary": "An orb repaired an item through Mending: `native_player_item_mend(player, name, item, repair, experience)` is heard after its damage changed. Bundles need \"hooks\": @API:native_player_item_mend@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "A server player changed a lectern page (`native_player_lectern_page_change`).",
"doc_summary": "A server player changed a lectern page: `native_player_lectern_page_change(player, name, pos, page)` is heard after the page and redstone pulse changed. Bundles need \"hooks\": @API:player_lectern_page_change@."
"doc_summary": "A server player changed a lectern page: `native_player_lectern_page_change(player, name, pos, page)` is heard after the page and redstone pulse changed. Bundles need \"hooks\": @API:native_player_lectern_page_change@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "A server player took a book from a lectern (`native_player_take_lectern_book`).",
"doc_summary": "A server player took a book from a lectern: `native_player_take_lectern_book(player, name, pos, book)` is heard after its block entity changed. Bundles need \"hooks\": @API:player_take_lectern_book@."
"doc_summary": "A server player took a book from a lectern: `native_player_take_lectern_book(player, name, pos, book)` is heard after its block entity changed. Bundles need \"hooks\": @API:native_player_take_lectern_book@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "The world spawn point changes (`native_spawn_change`).",
"doc_summary": "`native_spawn_change(pos, angle)` is told after `/setworldspawn` moves the world's spawn point, with its block position and rotation. Bundles need \"hooks\": @API:spawn_change@."
"doc_summary": "`native_spawn_change(pos, angle)` is told after `/setworldspawn` moves the world's spawn point, with its block position and rotation. Bundles need \"hooks\": @API:native_spawn_change@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "World border bounds change (`native_world_border_bounds_changed`).",
"doc_summary": "`native_world_border_bounds_changed()` is told after `/worldborder set` or `/worldborder add` changes the border size or starts its timed size change. It hears successful commands only. Bundles need \"hooks\": @API:world_border_bounds_changed@."
"doc_summary": "`native_world_border_bounds_changed()` is told after `/worldborder set` or `/worldborder add` changes the border size or starts its timed size change. It hears successful commands only. Bundles need \"hooks\": @API:native_world_border_bounds_changed@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "World border center change (`native_world_border_center_changed`).",
"doc_summary": "`native_world_border_center_changed()` is told after `/worldborder center` moves the border center. It hears successful commands only. Bundles need \"hooks\": @API:world_border_center_changed@."
"doc_summary": "`native_world_border_center_changed()` is told after `/worldborder center` moves the border center. It hears successful commands only. Bundles need \"hooks\": @API:native_world_border_center_changed@."
}
@@ -11,5 +11,5 @@
}
],
"rust_summary": "A server game rule changes (`native_world_game_rule_change`).",
"doc_summary": "`native_world_game_rule_change(player, name, rule, value)` is told after an operator changes a world game rule through the server's game rule screen, with its final value. Bundles need \"hooks\": @API:world_game_rule_change@."
"doc_summary": "`native_world_game_rule_change(player, name, rule, value)` is told after an operator changes a world game rule through the server's game rule screen, with its final value. Bundles need \"hooks\": @API:native_world_game_rule_change@."
}
+1 -2
View File
@@ -1,6 +1,5 @@
{
"hooks": [],
"rust_summary": "a helper that names the block a state belongs to (`native_state_block`).",
"doc_summary": "`native_state_block(state)` names the block a state belongs to (`BlockState.getBlock()`), on a server hook's level and on the game: `native_state_for_placement` is asked for every block placed, so a mod compares it with its own block to answer only for it. Bundles that use it need \"hooks\": @API:state_block@.",
"comments": ["crates/horizon-client/src/game/native_hooks.rs"]
"doc_summary": "`native_state_block(state)` names the block a state belongs to (`BlockState.getBlock()`), on a server hook's level and on the game: `native_state_for_placement` is asked for every block placed, so a mod compares it with its own block to answer only for it. Bundles that use it need \"hooks\": @API:state_block@."
}
+184
View File
@@ -0,0 +1,184 @@
"""Native macOS input for desktop harnesses. Never sends input through horizon-ctl.
Requires Accessibility permission for the invoking process. Only use while the person
at the desktop has agreed to let the harness take focus and move the pointer.
"""
import ctypes as c
import plistlib
import sys
import time
class Point(c.Structure):
_fields_ = [('x', c.c_double), ('y', c.c_double)]
class MacInput:
KEYS = {'w': 13, 'a': 0, 'escape': 53, 'e': 14}
def __init__(self, pid):
if sys.platform != 'darwin':
raise RuntimeError('native input harness currently supports macOS')
self.pid = pid
self.cg = c.CDLL('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
self.cf = c.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation')
self.ax = c.CDLL('/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices')
def bind(lib, name, result, *args):
fn = getattr(lib, name)
fn.restype, fn.argtypes = result, list(args)
bind(self.ax, 'AXIsProcessTrusted', c.c_bool)
if not self.ax.AXIsProcessTrusted():
raise RuntimeError('native input requires macOS Accessibility permission')
bind(self.ax, 'AXUIElementCreateApplication', c.c_void_p, c.c_int)
bind(self.ax, 'AXUIElementSetAttributeValue', c.c_int, c.c_void_p, c.c_void_p, c.c_void_p)
bind(self.ax, 'AXUIElementCopyAttributeValue', c.c_int, c.c_void_p, c.c_void_p, c.POINTER(c.c_void_p))
bind(self.ax, 'AXUIElementPerformAction', c.c_int, c.c_void_p, c.c_void_p)
bind(self.cf, 'CFArrayGetCount', c.c_long, c.c_void_p)
bind(self.cf, 'CFArrayGetValueAtIndex', c.c_void_p, c.c_void_p, c.c_long)
bind(self.cf, 'CFStringGetCString', c.c_bool, c.c_void_p, c.c_void_p, c.c_long, c.c_uint32)
bind(self.cf, 'CFStringCreateWithCString', c.c_void_p, c.c_void_p, c.c_char_p, c.c_uint32)
bind(self.cf, 'CFRelease', None, c.c_void_p)
bind(self.cg, 'CGWindowListCopyWindowInfo', c.c_void_p, c.c_uint32, c.c_uint32)
bind(self.cf, 'CFPropertyListCreateData', c.c_void_p, c.c_void_p, c.c_void_p, c.c_long, c.c_ulong, c.c_void_p)
bind(self.cf, 'CFDataGetLength', c.c_long, c.c_void_p)
bind(self.cf, 'CFDataGetBytePtr', c.c_void_p, c.c_void_p)
bind(self.cg, 'CGEventCreateKeyboardEvent', c.c_void_p, c.c_void_p, c.c_uint16, c.c_bool)
bind(self.cg, 'CGEventCreateMouseEvent', c.c_void_p, c.c_void_p, c.c_uint32, Point, c.c_uint32)
bind(self.cg, 'CGEventPostToPid', None, c.c_int, c.c_void_p)
bind(self.cg, 'CGEventPost', None, c.c_uint32, c.c_void_p)
bind(self.cg, 'CGEventSetIntegerValueField', None, c.c_void_p, c.c_int, c.c_int64)
bind(self.cg, 'CGEventCreate', c.c_void_p, c.c_void_p)
bind(self.cg, 'CGEventGetLocation', Point, c.c_void_p)
self.held = set()
def windows(self):
array = self.cg.CGWindowListCopyWindowInfo(1, 0) # On-screen windows, front to back.
data = None
try:
data = self.cf.CFPropertyListCreateData(None, array, 100, 0, None)
if not data:
raise RuntimeError('could not read native window list')
raw = c.string_at(self.cf.CFDataGetBytePtr(data), self.cf.CFDataGetLength(data))
return [w for w in plistlib.loads(raw) if w.get('kCGWindowLayer') == 0]
finally:
if data:
self.cf.CFRelease(data)
if array:
self.cf.CFRelease(array)
def activate(self):
app = self.ax.AXUIElementCreateApplication(self.pid)
key = self.cf.CFStringCreateWithCString(None, b'AXFrontmost', 0x08000100)
try:
yes = c.c_void_p.in_dll(self.cf, 'kCFBooleanTrue')
if self.ax.AXUIElementSetAttributeValue(app, key, yes) != 0:
raise RuntimeError('could not activate the test game')
finally:
self.cf.CFRelease(key)
self.cf.CFRelease(app)
end = time.monotonic() + 5
while time.monotonic() < end:
windows = self.windows()
if windows and windows[0].get('kCGWindowOwnerPID') == self.pid:
return
time.sleep(0.1)
raise RuntimeError('the test game never became the frontmost window')
def raise_window(self, title):
# A background split window may be mostly covered by Window 1. Raise it
# before clicking its content, like selecting it from the OS window menu.
app = self.ax.AXUIElementCreateApplication(self.pid)
keys = [self.cf.CFStringCreateWithCString(None, name, 0x08000100)
for name in (b'AXWindows', b'AXTitle', b'AXRaise')]
windows = c.c_void_p()
try:
if self.ax.AXUIElementCopyAttributeValue(app, keys[0], c.byref(windows)) != 0:
raise RuntimeError('could not read the test games native windows')
for i in range(self.cf.CFArrayGetCount(windows)):
window = self.cf.CFArrayGetValueAtIndex(windows, i)
name = c.c_void_p()
if self.ax.AXUIElementCopyAttributeValue(window, keys[1], c.byref(name)) != 0:
continue
try:
text = c.create_string_buffer(2048)
self.cf.CFStringGetCString(name, text, len(text), 0x08000100)
if title not in text.value.decode('utf-8'):
continue
if self.ax.AXUIElementPerformAction(window, keys[2]) != 0:
raise RuntimeError(f'could not raise {title}')
time.sleep(0.15)
return
finally:
self.cf.CFRelease(name)
raise RuntimeError(f'no accessible native window matching {title!r}')
finally:
if windows:
self.cf.CFRelease(windows)
for key in keys:
self.cf.CFRelease(key)
self.cf.CFRelease(app)
def window(self, title=None):
windows = [w for w in self.windows() if w.get('kCGWindowOwnerPID') == self.pid
and (title is None or title in w.get('kCGWindowName', ''))]
if not windows:
raise RuntimeError(f'no native test window matching {title!r}')
return windows[0]
def point(self, physical_size, x, y, title=None):
"""A fractional point in the content area, excluding the native title bar."""
b = self.window(title)['kCGWindowBounds']
scale = physical_size[0] / b['Width']
height = physical_size[1] / scale
return b['X'] + b['Width'] * x, b['Y'] + b['Height'] - height + height * y
def key(self, key, down):
event = self.cg.CGEventCreateKeyboardEvent(None, self.KEYS[key], down)
try:
# Target the test process; a disappearing test window must not type in another app.
self.cg.CGEventPostToPid(self.pid, event)
if down:
self.held.add(key)
else:
self.held.discard(key)
finally:
self.cf.CFRelease(event)
def tap(self, key):
self.key(key, True)
time.sleep(0.05)
self.key(key, False)
time.sleep(0.15)
def click(self, at, title=None):
self.activate()
if title is not None:
self.raise_window(title)
self.move(at)
for kind in (1, 2): # Left down, left up.
event = self.cg.CGEventCreateMouseEvent(None, kind, Point(*at), 0)
self.cg.CGEventPost(0, event)
self.cf.CFRelease(event)
time.sleep(0.05)
time.sleep(0.15)
def move(self, at, delta=None):
event = self.cg.CGEventCreateMouseEvent(None, 5, Point(*at), 0)
try:
if delta is not None:
self.cg.CGEventSetIntegerValueField(event, 4, delta[0])
self.cg.CGEventSetIntegerValueField(event, 5, delta[1])
self.cg.CGEventPost(0, event)
finally:
self.cf.CFRelease(event)
time.sleep(0.05)
def relative(self, dx, dy):
event = self.cg.CGEventCreate(None)
at = self.cg.CGEventGetLocation(event)
self.cf.CFRelease(event)
self.move((at.x + dx, at.y + dy), (dx, dy))
def release(self):
for key in list(self.held):
self.key(key, False)
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more