mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #6956
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
29792b2c0c
122 files changed
+4048
-943
No files matched your search
@@ -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<()> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,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@."
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user