mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Merge main into #7479
Co-Authored-By: Claude Opus 5.5 <[email protected]> # Conflicts: # crates/horizon-client-render/src/extras.rs # docs/extras.md
This commit is contained in:
commit
dbf5ea1396
77 files changed
+2855
-273
No files matched your search
Generated
+2
@@ -1319,6 +1319,7 @@ dependencies = [
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
@@ -1451,6 +1452,7 @@ dependencies = [
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::{
|
||||
};
|
||||
|
||||
mod component_predicate;
|
||||
mod foreign;
|
||||
mod special;
|
||||
pub use special::{SpecialKind, SpecialModel};
|
||||
|
||||
@@ -451,12 +452,21 @@ impl ItemModelNode {
|
||||
.collect::<Result<_>>()?,
|
||||
transformation: optional_transformation(m)?,
|
||||
},
|
||||
"minecraft:condition" if foreign::property_is_foreign(m)? => foreign::wrap(
|
||||
Self::decode(required(m, "on_false")?)?,
|
||||
optional_transformation(m)?,
|
||||
),
|
||||
"minecraft:condition" => Self::Condition {
|
||||
transformation: optional_transformation(m)?,
|
||||
property: ConditionProperty::decode(m)?,
|
||||
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
|
||||
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
|
||||
},
|
||||
"minecraft:select" if foreign::property_is_foreign(m)? => {
|
||||
// No case can match a property Horizon cannot read.
|
||||
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
|
||||
foreign::wrap(fallback.unwrap_or(Self::Empty), optional_transformation(m)?)
|
||||
}
|
||||
"minecraft:select" => {
|
||||
let transformation = optional_transformation(m)?;
|
||||
let property = SelectProperty::decode(m)?;
|
||||
@@ -484,6 +494,7 @@ impl ItemModelNode {
|
||||
}
|
||||
}
|
||||
"minecraft:range_dispatch" => {
|
||||
let foreign_property = foreign::property_is_foreign(m)?;
|
||||
let mut entries = list(required(m, "entries")?)?
|
||||
.iter()
|
||||
.map(|e| {
|
||||
@@ -496,6 +507,17 @@ impl ItemModelNode {
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// `Comparator.comparingDouble`: Double.compare, a total order.
|
||||
entries.sort_by(|a, b| f64::from(a.0).total_cmp(&f64::from(b.0)));
|
||||
if foreign_property {
|
||||
// A property Horizon cannot read is 0.
|
||||
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
|
||||
let chosen = last_index_less_or_equal(&entries, 0.0)
|
||||
.map(|i| entries.swap_remove(i).1)
|
||||
.or(fallback);
|
||||
return Ok(foreign::wrap(
|
||||
chosen.unwrap_or(Self::Empty),
|
||||
optional_transformation(m)?,
|
||||
));
|
||||
}
|
||||
Self::RangeDispatch {
|
||||
transformation: optional_transformation(m)?,
|
||||
property: NumericProperty::decode(m)?,
|
||||
@@ -506,12 +528,21 @@ impl ItemModelNode {
|
||||
.transpose()?,
|
||||
}
|
||||
}
|
||||
"minecraft:special" if SpecialModel::is_foreign(required(m, "model")?) => {
|
||||
// The mod's own renderer is not run: the base model stands in.
|
||||
Self::Model(ModelLeaf {
|
||||
model: ident(required(m, "base")?)?,
|
||||
transformation: optional_transformation(m)?,
|
||||
tints: Vec::new(),
|
||||
})
|
||||
}
|
||||
"minecraft:special" => Self::Special {
|
||||
base: ident(required(m, "base")?)?,
|
||||
transformation: optional_transformation(m)?,
|
||||
model: SpecialModel::decode(required(m, "model")?)?,
|
||||
},
|
||||
"minecraft:bundle/selected_item" => Self::BundleSelectedItem,
|
||||
other if foreign::is_foreign(other) => foreign::decode_node(other, m),
|
||||
_ => return Err(err("unknown item model type")),
|
||||
})
|
||||
}
|
||||
@@ -603,6 +634,8 @@ impl TintSource {
|
||||
"minecraft:firework" => Self::Firework(default()?),
|
||||
"minecraft:potion" => Self::Potion(default()?),
|
||||
"minecraft:team" => Self::Team(default()?),
|
||||
// A mod's own tint source, which its Java code computes: untinted here.
|
||||
other if foreign::is_foreign(other) => Self::Constant(-1),
|
||||
_ => return Err(err("unknown item tint source")),
|
||||
})
|
||||
}
|
||||
@@ -1717,5 +1750,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
Err(low as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod foreign_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Nodes and properties of other namespaces (a mod's, NeoForge's) decode to the closest vanilla behaviour.
|
||||
use super::{
|
||||
ItemModelNode, ModelLeaf, Result, TintSource, get, ident, identifier, list, object,
|
||||
optional_transformation,
|
||||
};
|
||||
use crate::{json::error as err, math::Mat4};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Keys a mod's node usually keeps its real model under, in order of preference.
|
||||
const MODEL_KEYS: [&str; 4] = ["model", "base_model", "base", "fallback"];
|
||||
|
||||
pub fn is_foreign(id: &str) -> bool {
|
||||
!id.starts_with("minecraft:")
|
||||
}
|
||||
|
||||
/// A composite carries the transformation a collapsed node had.
|
||||
pub fn wrap(node: ItemModelNode, transformation: Option<Mat4>) -> ItemModelNode {
|
||||
match transformation {
|
||||
None => node,
|
||||
Some(_) => ItemModelNode::Composite {
|
||||
models: vec![node],
|
||||
transformation,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn property_is_foreign(m: &Map<String, Value>) -> Result<bool> {
|
||||
let property = m
|
||||
.get("property")
|
||||
.ok_or_else(|| err("missing property"))
|
||||
.and_then(ident)?;
|
||||
Ok(is_foreign(&property))
|
||||
}
|
||||
|
||||
fn leaf(model: String, m: &Map<String, Value>) -> ItemModelNode {
|
||||
let tints = get(m, "tints")
|
||||
.and_then(|t| list(t).ok())
|
||||
.map(|t| {
|
||||
t.iter()
|
||||
.filter_map(|t| TintSource::decode(t).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ItemModelNode::Model(ModelLeaf {
|
||||
model,
|
||||
transformation: optional_transformation(m).ok().flatten(),
|
||||
tints,
|
||||
})
|
||||
}
|
||||
|
||||
/// A node of an unknown non-vanilla type: its nested node, else its model id, else nothing.
|
||||
pub fn decode_node(kind: &str, m: &Map<String, Value>) -> ItemModelNode {
|
||||
if kind == "neoforge:fluid_container" {
|
||||
return fluid_container(m);
|
||||
}
|
||||
for key in MODEL_KEYS {
|
||||
match m.get(key) {
|
||||
Some(nested @ Value::Object(_)) => {
|
||||
if let Ok(node) = ItemModelNode::decode(nested) {
|
||||
return wrap(node, optional_transformation(m).ok().flatten());
|
||||
}
|
||||
}
|
||||
Some(Value::String(id)) => {
|
||||
if let Ok(id) = identifier(id) {
|
||||
return leaf(id, m);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let by_suffix = m
|
||||
.iter()
|
||||
.filter(|(k, _)| k.ends_with("_model"))
|
||||
.find_map(|(_, v)| v.as_str().and_then(|id| identifier(id).ok()));
|
||||
match by_suffix {
|
||||
Some(id) => leaf(id, m),
|
||||
None => ItemModelNode::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
// The fluid is not drawn: only the base (and an unmasked cover) texture, taken as a model id (a bucket's).
|
||||
fn fluid_container(m: &Map<String, Value>) -> ItemModelNode {
|
||||
let textures = m.get("textures").and_then(|t| object(t).ok());
|
||||
let texture = |key: &str| {
|
||||
textures
|
||||
.and_then(|t| t.get(key))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| identifier(id).ok())
|
||||
};
|
||||
let mut layers = vec![texture("base")];
|
||||
if m.get("cover_is_mask") == Some(&Value::Bool(false)) {
|
||||
layers.push(texture("cover"));
|
||||
}
|
||||
let mut models: Vec<ItemModelNode> = layers
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|model| {
|
||||
ItemModelNode::Model(ModelLeaf {
|
||||
model,
|
||||
transformation: None,
|
||||
tints: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let transformation = optional_transformation(m).ok().flatten();
|
||||
match models.len() {
|
||||
0 => ItemModelNode::Empty,
|
||||
1 => wrap(models.remove(0), transformation),
|
||||
_ => ItemModelNode::Composite {
|
||||
models,
|
||||
transformation,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use super::*;
|
||||
|
||||
fn node(json: &str) -> Result<ItemModelNode> {
|
||||
ItemModelNode::decode(&serde_json::from_str(json).unwrap())
|
||||
}
|
||||
fn model_id(n: &ItemModelNode) -> &str {
|
||||
match n {
|
||||
ItemModelNode::Model(leaf) => &leaf.model,
|
||||
other => panic!("not a model: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_with_a_model_id_draws_that_model() {
|
||||
let n = node(r#"{"type":"mod:thing","model":"mod:item/thing","tints":[{"type":"mod:tint"}]}"#)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/thing");
|
||||
let ItemModelNode::Model(leaf) = n else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(leaf.tints, vec![TintSource::Constant(-1)]);
|
||||
let n = node(r#"{"type":"mod:thing","base_model":"mod:item/base"}"#).unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/base");
|
||||
let n = node(r#"{"type":"mod:thing","on_model":"mod:item/a"}"#).unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_with_a_nested_node_decodes_it() {
|
||||
let n = node(
|
||||
r#"{"type":"neoforge:trimmed_armor","base_model":{"type":"minecraft:model","model":"mod:item/boots"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/boots");
|
||||
let n = node(
|
||||
r#"{"type":"mod:x","model":{"type":"mod:y","model":"mod:item/inner"},"transformation":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ItemModelNode::Composite {
|
||||
models,
|
||||
transformation,
|
||||
} = n
|
||||
else {
|
||||
panic!("a transformation wraps the nested node")
|
||||
};
|
||||
assert!(transformation.is_some());
|
||||
assert_eq!(model_id(&models[0]), "mod:item/inner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_with_nothing_usable_is_empty() {
|
||||
assert!(matches!(
|
||||
node(r#"{"type":"mod:facade"}"#).unwrap(),
|
||||
ItemModelNode::Empty
|
||||
));
|
||||
assert!(matches!(
|
||||
node(r#"{"type":"mod:x","model":7}"#).unwrap(),
|
||||
ItemModelNode::Empty
|
||||
));
|
||||
assert!(ClientItem::from_json(r#"{"model":{"type":"ae2:facade"}}"#).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_vanilla_names_still_fail() {
|
||||
assert!(node(r#"{"type":"minecraft:nope"}"#).is_err());
|
||||
assert!(node(r#"{"type":"nope","model":"a:b"}"#).is_err());
|
||||
let leaf = r#"{"type":"minecraft:model","model":"a:b"}"#;
|
||||
for json in [
|
||||
format!(
|
||||
r#"{{"type":"minecraft:condition","property":"minecraft:nope","on_true":{leaf},"on_false":{leaf}}}"#
|
||||
),
|
||||
format!(
|
||||
r#"{{"type":"minecraft:select","property":"minecraft:nope","cases":[],"fallback":{leaf}}}"#
|
||||
),
|
||||
format!(
|
||||
r#"{{"type":"minecraft:range_dispatch","property":"minecraft:nope","entries":[],"fallback":{leaf}}}"#
|
||||
),
|
||||
] {
|
||||
assert!(node(&json).is_err(), "{json}");
|
||||
}
|
||||
assert!(
|
||||
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:nope"}}"#)
|
||||
.is_err()
|
||||
);
|
||||
assert!(ClientItem::from_json(r#"{"model":{"type":"minecraft:nope"}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn condition_property_is_false() {
|
||||
let n = node(
|
||||
r#"{"type":"minecraft:condition","property":"mod:flag",
|
||||
"on_true":{"type":"minecraft:model","model":"a:yes"},
|
||||
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "a:no");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_property_takes_the_fallback_or_nothing() {
|
||||
let n = node(
|
||||
r#"{"type":"minecraft:select","property":"mod:level",
|
||||
"cases":[{"when":"low","model":{"type":"minecraft:model","model":"a:low"}}],
|
||||
"fallback":{"type":"minecraft:model","model":"a:other"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "a:other");
|
||||
let n = node(r#"{"type":"minecraft:select","property":"mod:level","cases":[]}"#).unwrap();
|
||||
assert!(matches!(n, ItemModelNode::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_property_reads_zero() {
|
||||
let entry = |t: f32, m: &str| {
|
||||
format!(r#"{{"threshold":{t},"model":{{"type":"minecraft:model","model":"{m}"}}}}"#)
|
||||
};
|
||||
let fallback = r#","fallback":{"type":"minecraft:model","model":"a:fb"}"#;
|
||||
let make = |entries: &[String], fallback: &str| {
|
||||
node(&format!(
|
||||
r#"{{"type":"minecraft:range_dispatch","property":"mod:fill","entries":[{}]{fallback}}}"#,
|
||||
entries.join(",")
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let n = make(&[entry(0.2, "a:one"), entry(0.4, "a:two")], fallback);
|
||||
assert_eq!(model_id(&n), "a:fb");
|
||||
let n = make(
|
||||
&[
|
||||
entry(0.0, "a:zero"),
|
||||
entry(-1.0, "a:neg"),
|
||||
entry(0.5, "a:half"),
|
||||
],
|
||||
fallback,
|
||||
);
|
||||
assert_eq!(model_id(&n), "a:zero");
|
||||
let n = make(&[entry(0.2, "a:one")], "");
|
||||
assert!(matches!(n, ItemModelNode::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_type_keeps_its_base_model() {
|
||||
let n = node(
|
||||
r#"{"type":"minecraft:special","base":"mod:item/cube","model":{"type":"mod:energy_cube"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "mod:item/cube");
|
||||
let n =
|
||||
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:shield"}}"#)
|
||||
.unwrap();
|
||||
assert!(matches!(n, ItemModelNode::Special { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_container_draws_its_base_without_the_fluid() {
|
||||
let n = node(
|
||||
r#"{"type":"neoforge:fluid_container","fluid":"mod:gas","cover_is_mask":false,"flip_gas":true,
|
||||
"textures":{"base":"minecraft:item/bucket","fluid":"neoforge:item/mask/bucket_fluid"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model_id(&n), "minecraft:item/bucket");
|
||||
let n = node(
|
||||
r#"{"type":"neoforge:fluid_container","cover_is_mask":false,
|
||||
"textures":{"base":"a:item/flask","cover":"a:item/flask_cover"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ItemModelNode::Composite { models, .. } = n else {
|
||||
panic!("base and cover")
|
||||
};
|
||||
assert_eq!(models.len(), 2);
|
||||
assert!(matches!(
|
||||
node(r#"{"type":"neoforge:fluid_container"}"#).unwrap(),
|
||||
ItemModelNode::Empty
|
||||
));
|
||||
}
|
||||
@@ -77,6 +77,13 @@ fn one_of(v: &Value, names: &[&str]) -> Result<String> {
|
||||
}
|
||||
|
||||
impl SpecialModel {
|
||||
/// A special model type of another namespace, which Horizon has no renderer for.
|
||||
pub fn is_foreign(v: &Value) -> bool {
|
||||
v.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|t| super::identifier(t).ok())
|
||||
.is_some_and(|t| super::foreign::is_foreign(&t))
|
||||
}
|
||||
pub fn decode(v: &Value) -> Result<Self> {
|
||||
let m = object(v)?;
|
||||
let float_or = |key: &str, default: f32| get(m, key).map_or(Ok(default), float);
|
||||
|
||||
@@ -18,3 +18,13 @@ fn display_context_names_round_trip() {
|
||||
assert_eq!(DisplayContext::by_name(name).unwrap().name(), name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
|
||||
let tint = |json: &str| TintSource::decode(&serde_json::from_str(json).unwrap());
|
||||
assert_eq!(
|
||||
tint(r#"{"type":"ae2:storage_cell_state"}"#).unwrap(),
|
||||
TintSource::Constant(-1)
|
||||
);
|
||||
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
|
||||
}
|
||||
@@ -7,6 +7,7 @@ rust-version = "1.90"
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
|
||||
@@ -434,6 +434,25 @@ impl Biomes {
|
||||
}
|
||||
changed.then_some(out)
|
||||
}
|
||||
|
||||
/// This table (the biome files') with its colours passed through `f`, when
|
||||
/// they differ from `current`'s: what a refresh of a table already
|
||||
/// coloured by `f` once must not compound.
|
||||
pub fn recolored_from(
|
||||
&self,
|
||||
current: &Self,
|
||||
f: impl FnMut(&str, &mut BiomeColors),
|
||||
) -> Option<Self> {
|
||||
let out = self.with_colors(f).unwrap_or_else(|| self.clone());
|
||||
let tints = |b: &BiomeInfo| (b.grass, b.foliage, b.dry_foliage, b.water);
|
||||
let same = out.list.len() == current.list.len()
|
||||
&& out
|
||||
.list
|
||||
.iter()
|
||||
.zip(¤t.list)
|
||||
.all(|(a, b)| tints(a) == tints(b));
|
||||
(!same).then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 24-bit colour as sRGB components in 0..1.
|
||||
|
||||
@@ -146,6 +146,10 @@ pub struct Extras {
|
||||
/// built-in track and leaving the off ones out (`musicPage`, off by
|
||||
/// default: vanilla plays exactly as before).
|
||||
pub music_page: bool,
|
||||
/// Cosmetics (`horizon_cosmetics`, off unless enabled): the player's own, and the other
|
||||
/// players' (`cosmeticsMine`, `cosmeticsOthers`; both on by default).
|
||||
pub cosmetics_mine: bool,
|
||||
pub cosmetics_others: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -191,6 +195,8 @@ impl Default for Extras {
|
||||
wheel_cycles_settings: true,
|
||||
music_off: Vec::new(),
|
||||
music_page: false,
|
||||
cosmetics_mine: true,
|
||||
cosmetics_others: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,6 +291,8 @@ impl Extras {
|
||||
.collect()
|
||||
}
|
||||
"musicPage" => e.music_page = flag(e.music_page),
|
||||
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
|
||||
"cosmeticsOthers" => e.cosmetics_others = flag(e.cosmetics_others),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -292,7 +300,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -339,6 +347,8 @@ impl Extras {
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
self.music_off.join(","),
|
||||
bool_text(self.music_page),
|
||||
bool_text(self.cosmetics_mine),
|
||||
bool_text(self.cosmetics_others),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -486,11 +496,14 @@ pub enum Item {
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
WheelCyclesSettings,
|
||||
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
|
||||
CosmeticsMine,
|
||||
CosmeticsOthers,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
let mut rows = vec![
|
||||
Err("horizon.extras.header.lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
@@ -513,7 +526,15 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(MusicPage),
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
]
|
||||
];
|
||||
if horizon_cosmetics::enabled() {
|
||||
rows.extend([
|
||||
Err("horizon.extras.header.cosmetics"),
|
||||
Ok(CosmeticsMine),
|
||||
Ok(CosmeticsOthers),
|
||||
]);
|
||||
}
|
||||
rows
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
@@ -538,6 +559,8 @@ impl Item {
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -562,6 +585,8 @@ impl Item {
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -595,6 +620,8 @@ impl Item {
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
Item::CosmeticsMine => "My cosmetics",
|
||||
Item::CosmeticsOthers => "Other players' cosmetics",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -607,7 +634,7 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage => {
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | CosmeticsMine | CosmeticsOthers => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -665,6 +692,12 @@ impl Item {
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
Item::CosmeticsMine => {
|
||||
"Shows the cosmetics you wear on your own player, in third person and the inventory."
|
||||
}
|
||||
Item::CosmeticsOthers => {
|
||||
"Shows the cosmetics other Horizon players wear. Off, the game never asks what they wear."
|
||||
}
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
@@ -686,6 +719,7 @@ impl Item {
|
||||
| Item::Sort
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings => Some(Cost::None),
|
||||
Item::CosmeticsMine | Item::CosmeticsOthers => Some(Cost::Low),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -710,6 +744,8 @@ impl Item {
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::CosmeticsMine => e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => e.cosmetics_others,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -745,6 +781,8 @@ impl Item {
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => e.cosmetics_others = !e.cosmetics_others,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1085,6 +1085,8 @@ pub struct ParticleEngine {
|
||||
pub particle_level: u8,
|
||||
/// FireworkParticles.Starter instances.
|
||||
starters: Vec<Starter>,
|
||||
/// `ClientExplosionTracker` tracked explosions pending debris generation.
|
||||
tracked_explosions: Vec<TrackedExplosion>,
|
||||
/// Local sounds requested by particles (dripstone drips, firework bursts):
|
||||
/// (event, position, volume, pitch, distanceDelay).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32, bool)>,
|
||||
@@ -1135,6 +1137,13 @@ impl ParticleLightCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientExplosionTracker.ExplosionInfo`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct TrackedExplosion {
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
}
|
||||
impl ParticleEngine {
|
||||
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
|
||||
let mut engine = Self {
|
||||
@@ -1154,6 +1163,7 @@ impl ParticleEngine {
|
||||
set_sprites: Vec::new(),
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
tracked_explosions: Vec::new(),
|
||||
camera: [0.0; 3],
|
||||
particle_level: 0,
|
||||
sounds: Vec::new(),
|
||||
@@ -1440,16 +1450,27 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ClientExplosionTracker.track`: queue an explosion's block count and position
|
||||
/// for debris generation during `ClientLevel.tick`.
|
||||
pub fn track_explosion(&mut self, center: [f64; 3], radius: f32, block_count: usize) {
|
||||
if block_count > 0 {
|
||||
self.tracked_explosions.push(TrackedExplosion {
|
||||
center,
|
||||
radius,
|
||||
block_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `ClientPacketListener.handleExplosion` + `ClientExplosionTracker`: the
|
||||
/// explosion particle (the emitter unless `ServerExplosion.isSmall`) and up
|
||||
/// to 512 poof/smoke particles thrown from air inside the radius.
|
||||
/// explosion particle (the emitter unless `ServerExplosion.isSmall`) and
|
||||
/// debris tracked for `tick`.
|
||||
pub fn explosion(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
small: bool,
|
||||
block_count: usize,
|
||||
radius: f32,
|
||||
is_air: &dyn Fn([i32; 3]) -> bool,
|
||||
_is_air: &dyn Fn([i32; 3]) -> bool,
|
||||
) {
|
||||
let ty = if small {
|
||||
ParticleType::Explosion
|
||||
@@ -1457,7 +1478,7 @@ impl ParticleEngine {
|
||||
ParticleType::ExplosionEmitter
|
||||
};
|
||||
self.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
|
||||
self.explosion_blocks(center, radius, block_count, is_air);
|
||||
self.track_explosion(center, radius, block_count);
|
||||
}
|
||||
/// `LlamaSpit.recreateFromPacket`: seven spit particles thrown ahead of a
|
||||
/// new spit, each faster than the last along x and z.
|
||||
@@ -1471,60 +1492,6 @@ impl ParticleEngine {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `ClientExplosionTracker` with `Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES`.
|
||||
pub fn explosion_blocks(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
is_air: &dyn Fn([i32; 3]) -> bool,
|
||||
) {
|
||||
// ClientExplosionTracker.tick: only the All option throws the debris.
|
||||
if self.particle_level != 0 {
|
||||
return;
|
||||
}
|
||||
for _ in 0..block_count.min(512) {
|
||||
let r = &mut self.random;
|
||||
let d = Vec3::new(
|
||||
f64::from(r.next_float() * 2.0 - 1.0),
|
||||
f64::from(r.next_float() * 2.0 - 1.0),
|
||||
f64::from(r.next_float() * 2.0 - 1.0),
|
||||
)
|
||||
.normalize();
|
||||
let distance = r.next_float().cbrt() * radius;
|
||||
let local = [
|
||||
d.x * f64::from(distance),
|
||||
d.y * f64::from(distance),
|
||||
d.z * f64::from(distance),
|
||||
];
|
||||
let at = [
|
||||
center[0] + local[0],
|
||||
center[1] + local[1],
|
||||
center[2] + local[2],
|
||||
];
|
||||
if !is_air(ParticleLevel::block_at(at)) {
|
||||
continue;
|
||||
}
|
||||
let speed = 0.5 / (distance / radius + 0.1) * r.next_float() * r.next_float() + 0.3;
|
||||
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES: poof (scaling 0.5) or smoke, equally weighted.
|
||||
let (ty, scaling) = if r.next_int_bound(2).unwrap_or(0) == 0 {
|
||||
(ParticleType::Poof, 0.5)
|
||||
} else {
|
||||
(ParticleType::Smoke, 1.0)
|
||||
};
|
||||
let p = [
|
||||
center[0] + local[0] * scaling,
|
||||
center[1] + local[1] * scaling,
|
||||
center[2] + local[2] * scaling,
|
||||
];
|
||||
let v = [
|
||||
d.x * f64::from(speed),
|
||||
d.y * f64::from(speed),
|
||||
d.z * f64::from(speed),
|
||||
];
|
||||
self.add(&ParticleOptions::simple(ty), p, v);
|
||||
}
|
||||
}
|
||||
/// `ParticleEngine.createTrackingEmitter` (crits): 16 tries per tick for
|
||||
/// three ticks inside a quarter of the entity's box, ticked once at creation.
|
||||
pub fn tracking_emitter(
|
||||
@@ -3355,6 +3322,7 @@ impl ParticleEngine {
|
||||
}
|
||||
pub fn tick(&mut self, level: &ParticleLevel) {
|
||||
self.tick_starters();
|
||||
self.tick_explosions(level);
|
||||
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
|
||||
self.emit(&options, feet, width, height);
|
||||
if life > 1 {
|
||||
@@ -3388,6 +3356,81 @@ impl ParticleEngine {
|
||||
self.particles.push(p);
|
||||
}
|
||||
}
|
||||
/// `ClientExplosionTracker.tick`: throws up to 512 debris particles across all
|
||||
/// explosions tracked in this tick, weighted by each explosion's block count.
|
||||
fn tick_explosions(&mut self, level: &ParticleLevel) {
|
||||
self.tick_explosions_with(&|b| level.facts.is_air(level.world.block(b[0], b[1], b[2])));
|
||||
}
|
||||
fn tick_explosions_with(&mut self, is_air: &dyn Fn([i32; 3]) -> bool) {
|
||||
if self.tracked_explosions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let explosions = std::mem::take(&mut self.tracked_explosions);
|
||||
if self.particle_level != 0 {
|
||||
return;
|
||||
}
|
||||
let total_blocks: usize = explosions.iter().map(|e| e.block_count).sum();
|
||||
if total_blocks == 0 {
|
||||
return;
|
||||
}
|
||||
let total_particles = total_blocks.min(512);
|
||||
for _ in 0..total_particles {
|
||||
// `WeightedRandom.getRandomItem` draws its `nextInt(totalWeight)`
|
||||
// even when one explosion is tracked.
|
||||
let mut pick = self.random.next_int_bound(total_blocks as i32).unwrap_or(0) as usize;
|
||||
let mut explosion = &explosions[0];
|
||||
for exp in &explosions {
|
||||
if pick < exp.block_count {
|
||||
explosion = exp;
|
||||
break;
|
||||
}
|
||||
pick -= exp.block_count;
|
||||
}
|
||||
let r = &mut self.random;
|
||||
let d = Vec3::new(
|
||||
f64::from(r.next_float() * 2.0 - 1.0),
|
||||
f64::from(r.next_float() * 2.0 - 1.0),
|
||||
f64::from(r.next_float() * 2.0 - 1.0),
|
||||
)
|
||||
.normalize();
|
||||
let distance = r.next_float().cbrt() * explosion.radius;
|
||||
let local = [
|
||||
d.x * f64::from(distance),
|
||||
d.y * f64::from(distance),
|
||||
d.z * f64::from(distance),
|
||||
];
|
||||
let at = [
|
||||
explosion.center[0] + local[0],
|
||||
explosion.center[1] + local[1],
|
||||
explosion.center[2] + local[2],
|
||||
];
|
||||
if !is_air(ParticleLevel::block_at(at)) {
|
||||
continue;
|
||||
}
|
||||
let speed =
|
||||
0.5 / (distance / explosion.radius + 0.1) * r.next_float() * r.next_float() + 0.3;
|
||||
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES: poof (scaling 0.5) or smoke, equally weighted.
|
||||
let (ty, scaling) = if r.next_int_bound(2).unwrap_or(0) == 0 {
|
||||
(ParticleType::Poof, 0.5)
|
||||
} else {
|
||||
(ParticleType::Smoke, 1.0)
|
||||
};
|
||||
let p = [
|
||||
explosion.center[0] + local[0] * scaling,
|
||||
explosion.center[1] + local[1] * scaling,
|
||||
explosion.center[2] + local[2] * scaling,
|
||||
];
|
||||
self.add(
|
||||
&ParticleOptions::simple(ty),
|
||||
p,
|
||||
[
|
||||
d.x * f64::from(speed),
|
||||
d.y * f64::from(speed),
|
||||
d.z * f64::from(speed),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
|
||||
match p.behaviour {
|
||||
@@ -4668,4 +4711,41 @@ mod tests {
|
||||
assert_eq!(lifetime_alpha(a, 0, 40, 0.0), 0.0);
|
||||
assert!((lifetime_alpha(a, 40, 40, 0.0) - 0.6).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn explosion_tracker_tracks_and_ignores_zero_blocks() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
assert!(engine.tracked_explosions.is_empty());
|
||||
engine.track_explosion([1.0, 2.0, 3.0], 4.0, 100);
|
||||
engine.track_explosion([5.0, 6.0, 7.0], 2.0, 50);
|
||||
assert_eq!(engine.tracked_explosions.len(), 2);
|
||||
assert_eq!(engine.tracked_explosions[0].block_count, 100);
|
||||
assert_eq!(engine.tracked_explosions[1].block_count, 50);
|
||||
|
||||
// Zero block count explosions are not queued.
|
||||
engine.track_explosion([0.0; 3], 1.0, 0);
|
||||
assert_eq!(engine.tracked_explosions.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn explosion_tracker_caps_debris_at_512_across_multiple_explosions() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
// Queue two explosions near camera: 400 blocks and 300 blocks (total 700).
|
||||
engine.track_explosion([0.0, 0.0, 0.0], 4.0, 400);
|
||||
engine.track_explosion([2.0, 0.0, 2.0], 5.0, 300);
|
||||
assert_eq!(engine.tracked_explosions.len(), 2);
|
||||
|
||||
// In pure air, total particles spawned must be capped at 512.
|
||||
engine.tick_explosions_with(&|_| true);
|
||||
assert_eq!(engine.pending.len(), 512);
|
||||
assert!(engine.tracked_explosions.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn explosion_tracker_emits_debris_only_in_air() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.track_explosion([0.0, 64.0, 0.0], 4.0, 100);
|
||||
|
||||
// When no blocks are air, zero debris particles are emitted.
|
||||
engine.tick_explosions_with(&|_| false);
|
||||
assert_eq!(engine.pending.len(), 0);
|
||||
assert!(engine.tracked_explosions.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ autoexamples = false
|
||||
autotests = false
|
||||
|
||||
[dependencies]
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
@@ -129,6 +130,10 @@ windows-segment-heap = []
|
||||
name = "horizon-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "datapack_biome_sync"
|
||||
path = "tests/datapack_biome_sync.rs"
|
||||
|
||||
[[test]]
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
@@ -331,5 +331,10 @@
|
||||
"horizon.pages.open.split_setup": "Splitscreen …",
|
||||
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten."
|
||||
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten.",
|
||||
"horizon.extras.header.cosmetics": "Kosmetik",
|
||||
"horizon.extras.cosmetics_mine": "Meine Kosmetik",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Zeigt die Kosmetik, die du trägst, an deinem eigenen Spieler, in der dritten Person und im Inventar.",
|
||||
"horizon.extras.cosmetics_others": "Kosmetik anderer Spieler",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Zeigt die Kosmetik anderer Horizon-Spieler. Aus fragt das Spiel nie, was sie tragen."
|
||||
}
|
||||
@@ -473,5 +473,10 @@
|
||||
"horizon.repro.bundle": "Repro bundle: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "There is no recording to export yet.",
|
||||
"horizon.repro.replay_started": "Replay started: %s"
|
||||
"horizon.repro.replay_started": "Replay started: %s",
|
||||
"horizon.extras.header.cosmetics": "Cosmetics",
|
||||
"horizon.extras.cosmetics_mine": "My cosmetics",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Shows the cosmetics you wear on your own player, in third person and the inventory.",
|
||||
"horizon.extras.cosmetics_others": "Other players' cosmetics",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Shows the cosmetics other Horizon players wear. Off, the game never asks what they wear."
|
||||
}
|
||||
@@ -399,5 +399,10 @@
|
||||
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
|
||||
"horizon.extras.header.cosmetics": "Cosmétiques",
|
||||
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Affiche les cosmétiques que vous portez sur votre joueur, à la troisième personne et dans l'inventaire.",
|
||||
"horizon.extras.cosmetics_others": "Cosmétiques des autres",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Affiche les cosmétiques que portent les autres joueurs Horizon. Désactivé, le jeu ne demande jamais ce qu'ils portent."
|
||||
}
|
||||
@@ -270,13 +270,13 @@ fn main() -> Result<()> {
|
||||
let row = match recipe {
|
||||
Recipe::Shaped { cells, result, .. } => json!({
|
||||
"result": result.item.as_deref(), "count": result.count,
|
||||
"ingredients": cells.iter().flatten().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
|
||||
"ingredients": cells.iter().flatten().map(|i| sorted(&i.order)).collect::<Vec<_>>()}),
|
||||
Recipe::Shapeless {
|
||||
ingredients,
|
||||
result,
|
||||
} => json!({
|
||||
"result": result.item.as_deref(), "count": result.count,
|
||||
"ingredients": ingredients.iter().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
|
||||
"ingredients": ingredients.iter().map(|i| sorted(&i.order)).collect::<Vec<_>>()}),
|
||||
Recipe::Special(_) => json!({"special": true}),
|
||||
};
|
||||
recipe_rows.insert(name.clone(), row);
|
||||
@@ -285,14 +285,14 @@ fn main() -> Result<()> {
|
||||
recipe_rows.insert(
|
||||
c.id.clone(),
|
||||
json!({"result": &*c.result.item, "count": c.result.count,
|
||||
"ingredients": [sorted(&c.ingredient.1)]}),
|
||||
"ingredients": [sorted(&c.ingredient.order)]}),
|
||||
);
|
||||
}
|
||||
for s in stations.stonecutting.iter().filter(|s| ours(&s.id)) {
|
||||
recipe_rows.insert(
|
||||
s.id.clone(),
|
||||
json!({"result": &*s.result.item, "count": s.result.count,
|
||||
"ingredients": [sorted(&s.input.1)]}),
|
||||
"ingredients": [sorted(&s.input.order)]}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
mob_drops: true,
|
||||
world_seed,
|
||||
};
|
||||
let (items, _) = drops::die(&mut m, &death, &mut sequences);
|
||||
// Vanilla's entity tables all name a random sequence: no level random.
|
||||
let mut level = horizon_core::random::LegacyRandom::new(0);
|
||||
let (items, _) = drops::die(&mut m, &death, &mut sequences, &mut level);
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
|
||||
@@ -36,6 +36,22 @@ pub fn native_ingredient(
|
||||
crate::native_client_hooks::ingredient(kind, data, items);
|
||||
}
|
||||
|
||||
/// A mod's own ingredient kind, matching a concrete stack at craft time:
|
||||
/// the ingredient's `test` calls here with the kind, its JSON and the
|
||||
/// stack being matched, `accept` already true (the load-time list named
|
||||
/// the item). Set `accept` to false to refuse the stack — its components
|
||||
/// are the usual reason. The hook may be called many times a craft; the
|
||||
/// answer must be the same for the same stack. API @API:ingredient_test@.
|
||||
#[inline(never)]
|
||||
pub fn native_ingredient_test(
|
||||
kind: &str,
|
||||
data: &serde_json::Map<String, Value>,
|
||||
stack: &ItemStack,
|
||||
accept: &mut bool,
|
||||
) {
|
||||
crate::native_client_hooks::ingredient_test(kind, data, stack, accept);
|
||||
}
|
||||
|
||||
/// Keep first-seen order, drop the rest.
|
||||
fn dedup_keep_order(items: &mut Vec<String>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -45,11 +61,39 @@ fn dedup_keep_order(items: &mut Vec<String>) {
|
||||
/// A set of accepted items (expanded from an item, list or tag), and the
|
||||
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Ingredient(pub HashSet<String>, pub Vec<String>);
|
||||
pub struct Ingredient {
|
||||
/// The items it accepts, however the source shape named them.
|
||||
pub items: HashSet<String>,
|
||||
/// The same items in `HolderSet` order (`display`).
|
||||
pub order: Vec<String>,
|
||||
/// A mod's own kind, for matching at craft time: the kind and its
|
||||
/// JSON, as the load-time hook saw them (`native_ingredient_test`).
|
||||
pub custom: Option<(String, serde_json::Map<String, Value>)>,
|
||||
/// NeoForge's shapes, with their children parsed: matching a concrete
|
||||
/// stack asks each child its own `test` (a mod's kind among them),
|
||||
/// as on NeoForge — the flat list above only names items.
|
||||
pub children: Option<(Shape, Vec<Self>)>,
|
||||
}
|
||||
|
||||
/// How a shape's children combine at match time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Shape {
|
||||
/// Any child matching the stack is enough.
|
||||
Compound,
|
||||
/// Every child must match it.
|
||||
Intersection,
|
||||
/// The first child must, and the second must not.
|
||||
Difference,
|
||||
}
|
||||
impl Ingredient {
|
||||
/// The items of a list, in order.
|
||||
pub fn of(items: Vec<String>) -> Self {
|
||||
Self(items.iter().cloned().collect(), items)
|
||||
Self {
|
||||
items: items.iter().cloned().collect(),
|
||||
order: items,
|
||||
custom: None,
|
||||
children: None,
|
||||
}
|
||||
}
|
||||
pub fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
@@ -88,6 +132,9 @@ impl Ingredient {
|
||||
.or_else(|| o.get("ingredients"))
|
||||
.and_then(Value::as_array)
|
||||
};
|
||||
// The shapes' parsed children, for matching at craft time: every
|
||||
// arm either fills it or returns.
|
||||
let nested;
|
||||
let kept = match kind {
|
||||
// `CompoundIngredient`: any of its children matching the stack
|
||||
// is enough — the union of their items.
|
||||
@@ -98,12 +145,13 @@ impl Ingredient {
|
||||
.collect::<Option<_>>()?;
|
||||
let mut seen = Vec::new();
|
||||
for sub in &subs {
|
||||
for item in &sub.1 {
|
||||
for item in &sub.order {
|
||||
if !seen.contains(item) {
|
||||
seen.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
nested = Some((Shape::Compound, subs));
|
||||
seen
|
||||
}
|
||||
// `IntersectionIngredient`: every child must match the same
|
||||
@@ -113,10 +161,11 @@ impl Ingredient {
|
||||
.iter()
|
||||
.map(|s| Self::parse(s, items))
|
||||
.collect::<Option<_>>()?;
|
||||
nested = Some((Shape::Intersection, subs.clone()));
|
||||
subs.first()?
|
||||
.1
|
||||
.order
|
||||
.iter()
|
||||
.filter(|i| subs[1..].iter().all(|s| s.0.contains(*i)))
|
||||
.filter(|i| subs[1..].iter().all(|s| s.items.contains(*i)))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
@@ -124,9 +173,10 @@ impl Ingredient {
|
||||
"neoforge:difference" => {
|
||||
let base = Self::parse(o.get("base")?, items)?;
|
||||
let gone = Self::parse(o.get("subtracted")?, items)?;
|
||||
base.1
|
||||
nested = Some((Shape::Difference, vec![base.clone(), gone.clone()]));
|
||||
base.order
|
||||
.iter()
|
||||
.filter(|i| !gone.0.contains(*i))
|
||||
.filter(|i| !gone.items.contains(*i))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
@@ -136,14 +186,49 @@ impl Ingredient {
|
||||
let mut list = Vec::new();
|
||||
native_ingredient(k, o, &mut list);
|
||||
dedup_keep_order(&mut list);
|
||||
return (!list.is_empty()).then(|| Self::of(list));
|
||||
// The kind and its JSON stay with the ingredient: the
|
||||
// load-time list only names items, and matching a concrete
|
||||
// stack (its components) is the match-time hook's.
|
||||
return (!list.is_empty()).then(|| Self {
|
||||
order: list.clone(),
|
||||
items: list.iter().cloned().collect(),
|
||||
custom: Some((k.to_owned(), o.clone())),
|
||||
children: None,
|
||||
});
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
(!kept.is_empty()).then(|| Self::of(kept))
|
||||
(!kept.is_empty()).then(|| Self {
|
||||
items: kept.iter().cloned().collect(),
|
||||
order: kept,
|
||||
custom: None,
|
||||
children: nested,
|
||||
})
|
||||
}
|
||||
/// `Ingredient.test`: a stack the list names — and for a mod's own
|
||||
/// kind, one the match-time hook accepts (`ICustomIngredient.test`).
|
||||
pub fn test(&self, stack: &ItemStack) -> bool {
|
||||
self.0.contains(&*stack.item)
|
||||
match &self.children {
|
||||
// Each child its own `test`, as on NeoForge — a mod's kind
|
||||
// among them answers through its hook.
|
||||
Some((Shape::Compound, kids)) => kids.iter().any(|k| k.test(stack)),
|
||||
Some((Shape::Intersection, kids)) => kids.iter().all(|k| k.test(stack)),
|
||||
Some((Shape::Difference, kids)) => {
|
||||
kids.first().is_some_and(|k| k.test(stack))
|
||||
&& kids.get(1).is_none_or(|k| !k.test(stack))
|
||||
}
|
||||
None => {
|
||||
if !self.items.contains(&*stack.item) {
|
||||
return false;
|
||||
}
|
||||
if let Some((kind, data)) = &self.custom {
|
||||
let mut accept = true;
|
||||
native_ingredient_test(kind, data, stack, &mut accept);
|
||||
return accept;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PlacementInfo`: the ingredients to find and, per recipe slot, the index
|
||||
@@ -165,7 +250,7 @@ impl Placement {
|
||||
};
|
||||
for cell in cells {
|
||||
match cell {
|
||||
Some(i) if i.0.is_empty() => return Self::default(),
|
||||
Some(i) if i.items.is_empty() => return Self::default(),
|
||||
Some(i) => {
|
||||
out.slots.push(out.ingredients.len() as i32);
|
||||
out.ingredients.push(i);
|
||||
@@ -423,7 +508,7 @@ impl Special {
|
||||
// The result keeps the input's item: any item of the ingredient.
|
||||
let all: Vec<ItemStack> = if result.item.is_none() {
|
||||
input
|
||||
.1
|
||||
.order
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let mut stack = made.clone();
|
||||
@@ -443,7 +528,7 @@ impl Special {
|
||||
result,
|
||||
} => {
|
||||
// DyedSlotDemo: the targets cycle fastest, 16 stacks at most.
|
||||
let (targets, dyes) = (&target.1, &dye.1);
|
||||
let (targets, dyes) = (&target.order, &dye.order);
|
||||
let all: Vec<ItemStack> = (0..targets.len() * dyes.len())
|
||||
.map(|i| {
|
||||
let mut made = result.create();
|
||||
@@ -483,7 +568,7 @@ impl Special {
|
||||
let all = potions(result.create());
|
||||
// The source ingredient is shown as a lingering potion of each kind.
|
||||
let lingering = source
|
||||
.1
|
||||
.order
|
||||
.iter()
|
||||
.flat_map(|item| potions(ItemStack::new(item, 1)))
|
||||
.collect();
|
||||
@@ -1236,7 +1321,7 @@ pub struct Recipes {
|
||||
/// first lookup (furnaces look theirs up every tick).
|
||||
cook_index: std::sync::OnceLock<CookIndex>,
|
||||
}
|
||||
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, usize>>;
|
||||
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, Vec<usize>>>;
|
||||
/// `MinMaxBounds.Ints` as (min, max) with the given defaults.
|
||||
fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) {
|
||||
match v {
|
||||
@@ -1830,18 +1915,23 @@ impl Recipes {
|
||||
.map_or(0.0, |c| c.experience)
|
||||
}
|
||||
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
|
||||
// Every recipe an item names, not just the first: a mod's match-time
|
||||
// hook may refuse the one the first recipe would take (`Ingredient::test`).
|
||||
let index = self.cook_index.get_or_init(|| {
|
||||
let mut index = CookIndex::new();
|
||||
for (i, c) in self.cooking.iter().enumerate() {
|
||||
let of_kind = index.entry(c.kind.clone()).or_default();
|
||||
for item in &c.ingredient.0 {
|
||||
of_kind.entry(item.clone()).or_insert(i);
|
||||
for item in &c.ingredient.items {
|
||||
of_kind.entry(item.clone()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
index
|
||||
});
|
||||
let i = *index.get(kind)?.get(&*input.item)?;
|
||||
self.cooking.get(i)
|
||||
index
|
||||
.get(kind)?
|
||||
.get(&*input.item)?
|
||||
.iter()
|
||||
.find_map(|i| self.cooking.get(*i).filter(|c| c.ingredient.test(input)))
|
||||
}
|
||||
/// `cook` by scanning every recipe, as it was before the index.
|
||||
#[cfg(test)]
|
||||
@@ -1932,6 +2022,42 @@ mod tests {
|
||||
assert_eq!(list, vec!["b".to_string(), "a".to_string()]);
|
||||
}
|
||||
|
||||
/// NeoForge's shapes keep their children, and matching a concrete
|
||||
/// stack asks each child its own way — any for compound, all for
|
||||
/// intersection, base-but-not-subtracted for difference. A mod's own
|
||||
/// kind among the children answers through its hook the same way
|
||||
/// (proven on a staged build).
|
||||
#[test]
|
||||
fn nested_shapes_test_each_child_their_own_way() {
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let parse = |s: &str| {
|
||||
Ingredient::parse(&serde_json::from_str::<Value>(s).unwrap(), &items).unwrap()
|
||||
};
|
||||
let stack = |n: &str| ItemStack {
|
||||
item: n.into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let compound = parse(
|
||||
r#"{"type":"neoforge:compound","children":["minecraft:oak_planks","minecraft:birch_planks"]}"#,
|
||||
);
|
||||
assert!(compound.children.is_some(), "the children stay with it");
|
||||
assert!(compound.test(&stack("minecraft:oak_planks")));
|
||||
assert!(compound.test(&stack("minecraft:birch_planks")));
|
||||
assert!(!compound.test(&stack("minecraft:stone")));
|
||||
let inter = parse(
|
||||
r#"{"type":"neoforge:intersection","ingredients":["minecraft:oak_planks",["minecraft:oak_planks","minecraft:birch_planks"]]}"#,
|
||||
);
|
||||
assert!(inter.test(&stack("minecraft:oak_planks")));
|
||||
assert!(!inter.test(&stack("minecraft:birch_planks")));
|
||||
let diff = parse(
|
||||
r#"{"type":"neoforge:difference","base":["minecraft:oak_planks","minecraft:birch_planks"],"subtracted":"minecraft:birch_planks"}"#,
|
||||
);
|
||||
assert!(diff.test(&stack("minecraft:oak_planks")));
|
||||
assert!(!diff.test(&stack("minecraft:birch_planks")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neoforge_ingredient_shapes_parse() {
|
||||
let mut tags = HashMap::new();
|
||||
@@ -1952,7 +2078,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
// The tag's items come from a HashSet: their order is not fixed.
|
||||
let mut all = union.1.clone();
|
||||
let mut all = union.order.clone();
|
||||
all.sort();
|
||||
assert_eq!(
|
||||
all,
|
||||
@@ -1968,14 +2094,14 @@ mod tests {
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(both.1, ["minecraft:oak_planks"]);
|
||||
assert_eq!(both.order, ["minecraft:oak_planks"]);
|
||||
// Difference: the base without the subtracted.
|
||||
let no_oak = Ingredient::parse(
|
||||
&json(r##"{"type":"neoforge:difference","base":"#minecraft:planks","subtracted":"minecraft:oak_planks"}"##),
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(no_oak.1, ["minecraft:birch_planks"]);
|
||||
assert_eq!(no_oak.order, ["minecraft:birch_planks"]);
|
||||
// An empty intersection and an unknown kind fail the file, as today.
|
||||
let empty = Ingredient::parse(
|
||||
&json(
|
||||
|
||||
@@ -859,9 +859,11 @@ impl ExperienceOrb {
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
self.update_fluids(world, fluids);
|
||||
let block = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
self.position.y.floor() as i32,
|
||||
@@ -2040,7 +2042,16 @@ mod world_border_tests {
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
orb.tick(&world, &facts, &collision, None, true, &mut || 0.0, border);
|
||||
orb.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
border,
|
||||
&fluids,
|
||||
);
|
||||
for (what, east) in [
|
||||
("item", item.bounds().max_position().x),
|
||||
("orb", orb.bounds().max_position().x),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Dropped-item and armor-stand immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::{ArmorStand, ItemEntity};
|
||||
//! Dropped-item, experience-orb and armor-stand immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::{ArmorStand, ExperienceOrb, ItemEntity};
|
||||
use crate::{
|
||||
motion::{FluidResources, FluidTags, LAVA, WATER},
|
||||
world::World,
|
||||
@@ -29,7 +29,7 @@ struct EntityFluidWorld<'a> {
|
||||
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
|
||||
/// immersion heights, then the current of each fluid it sits in, added to
|
||||
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
|
||||
/// 0.0023333 slow). Items and armour stands call it for themselves; a boat
|
||||
/// 0.0023333 slow). Items, experience orbs and armour stands call it for themselves; a boat
|
||||
/// calls it from its own tick, before `floatBoat` scales the velocity.
|
||||
pub fn update_entity_fluids(
|
||||
bounds: Aabb,
|
||||
@@ -77,6 +77,20 @@ impl ItemEntity {
|
||||
)
|
||||
}
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
|
||||
/// orb's box and pushes its velocity by any water or lava current.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + f64::from(0.425f32),
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
)
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
/// `Entity.updateFluidHeightAndDoFluidPushing`: refreshes water and lava
|
||||
/// immersion over the stand's deflated box and pushes its velocity by any
|
||||
@@ -297,6 +311,92 @@ mod tests {
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
/// `Entity.baseTick` pushes an experience orb by the current it sits in: an
|
||||
/// orb in flowing water gains velocity along the flow and moves, while an orb
|
||||
/// in still source water does not gain horizontal velocity (#7442).
|
||||
#[test]
|
||||
fn experience_orbs_follow_flowing_water_but_still_water_has_no_horizontal_push() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let orb = ExperienceOrb {
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
let mut still = orb.clone();
|
||||
assert!(still.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids,
|
||||
));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = orb;
|
||||
assert!(carried.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids,
|
||||
));
|
||||
// Current pushes the orb, underwater drag damps before motion, then air
|
||||
// drag: `ExperienceOrb.tick`'s `0.98F`, a float widened to double.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - 0.014 * 0.99 * f64::from(0.98_f32)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids,
|
||||
);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
/// `Entity.baseTick` pushes a boat by the current it sits in: a boat box
|
||||
/// in water[level=1..7] gains velocity along the flow, and the same box in
|
||||
/// still source water does not. Vanilla's boat tick runs this before
|
||||
|
||||
@@ -368,6 +368,17 @@ pub fn mob_to_nbt(
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the death loot table, and its seed when set.
|
||||
if let Some(table) = &m.extra.death_loot_table {
|
||||
put(&mut tag, "DeathLootTable", string(table));
|
||||
}
|
||||
if m.extra.death_loot_seed != 0 {
|
||||
put(
|
||||
&mut tag,
|
||||
"DeathLootTableSeed",
|
||||
Tag::Long(m.extra.death_loot_seed),
|
||||
);
|
||||
}
|
||||
// `NeutralMob.addPersistentAngerSaveData`: the anger ends at an
|
||||
// absolute game time, and the target is the entity the mob is angry at.
|
||||
if persistent_anger(m.species()) {
|
||||
@@ -1497,6 +1508,9 @@ pub fn mob_from_nbt(
|
||||
// Mob.readAdditionalSaveData overrides what the constructor set (copper
|
||||
// golems and elder guardians start persistent).
|
||||
m.persistent = flag(tag, "PersistenceRequired");
|
||||
m.extra.death_loot_table = text(tag.get("DeathLootTable"))
|
||||
.and_then(|t| horizon_client_core::identifier::normalize_id(&t));
|
||||
m.extra.death_loot_seed = number(tag.get("DeathLootTableSeed")).unwrap_or(0);
|
||||
// `NeutralMob.readPersistentAngerSaveData`: the anger end time and the
|
||||
// target come back with the mob.
|
||||
if persistent_anger(m.species()) {
|
||||
@@ -4288,6 +4302,50 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
/// `Mob.addAdditionalSaveData` and `readAdditionalSaveData`:
|
||||
/// `DeathLootTable` and a set `DeathLootTableSeed` stay with the mob.
|
||||
#[test]
|
||||
fn a_death_loot_table_round_trips() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let summoned = Tag::compound([
|
||||
("id", string("minecraft:zombie")),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
("DeathLootTable", string("mymod:entities/boss")),
|
||||
("DeathLootTableSeed", Tag::Long(42)),
|
||||
]);
|
||||
let m = mob_from_nbt(&summoned, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
m.extra.death_loot_table.as_deref(),
|
||||
Some("mymod:entities/boss")
|
||||
);
|
||||
assert_eq!(m.extra.death_loot_seed, 42);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(
|
||||
text(tag.get("DeathLootTable")).as_deref(),
|
||||
Some("mymod:entities/boss")
|
||||
);
|
||||
assert_eq!(number(tag.get("DeathLootTableSeed")), Some(42));
|
||||
// None set: neither is written.
|
||||
let plain = Tag::compound([
|
||||
("id", string("minecraft:zombie")),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
]);
|
||||
let plain = mob_from_nbt(&plain, &types, 3, None).unwrap();
|
||||
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None);
|
||||
assert!(tag.get("DeathLootTable").is_none() && tag.get("DeathLootTableSeed").is_none());
|
||||
}
|
||||
|
||||
/// `Rabbit.addAdditionalSaveData` and `readAdditionalSaveData`: `MoreCarrotTicks`.
|
||||
#[test]
|
||||
fn a_rabbit_keeps_how_full_of_carrots_it_is() {
|
||||
|
||||
@@ -149,6 +149,15 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
ingredient: |kind, data, items| {
|
||||
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
|
||||
},
|
||||
ingredient_test: |kind, data, stack, accept| {
|
||||
crate::mod_api::dispatch_ingredient_test(
|
||||
crate::mod_api::mods(),
|
||||
kind,
|
||||
data,
|
||||
stack,
|
||||
accept,
|
||||
);
|
||||
},
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
@@ -95,6 +95,7 @@ mod composter;
|
||||
mod consume;
|
||||
mod control;
|
||||
mod copper_golems;
|
||||
mod cosmetics;
|
||||
mod creaking;
|
||||
mod creative_hotbar;
|
||||
mod creatures;
|
||||
@@ -2081,6 +2082,21 @@ impl PackResources {
|
||||
light,
|
||||
))
|
||||
}
|
||||
/// The workers' context over `biomes`: this pack's own when `biomes` is
|
||||
/// the table it was built with, else the same parts with that table.
|
||||
fn context_with(&self, biomes: Arc<Biomes>) -> Arc<WorkerContext> {
|
||||
let old = &self.context;
|
||||
if Arc::ptr_eq(&old.biomes, &biomes) {
|
||||
return old.clone();
|
||||
}
|
||||
Arc::new(WorkerContext {
|
||||
assets: old.assets.clone(),
|
||||
render: old.render.clone(),
|
||||
tables: old.tables.clone(),
|
||||
biomes,
|
||||
light: old.light.clone(),
|
||||
})
|
||||
}
|
||||
/// The pack tables over their parts, which `load_resources` builds on
|
||||
/// separate workers.
|
||||
fn assemble(
|
||||
@@ -2280,6 +2296,8 @@ struct Game<'a> {
|
||||
/// The biome table the world plays with: the game's own with the world's
|
||||
/// packs over it (`Biomes::with_packs`), `None` before a world opens.
|
||||
world_biomes: Option<Arc<Biomes>>,
|
||||
/// `world_biomes` as the biome files made it, before `native_biome_colors`.
|
||||
world_biomes_base: Option<Arc<Biomes>>,
|
||||
streaming: Streaming,
|
||||
lod: Option<horizon_client::lod::Lod>,
|
||||
lod_source_attempted: bool,
|
||||
@@ -2761,6 +2779,8 @@ struct Game<'a> {
|
||||
/// and mannequins) resolve through the services in the background.
|
||||
skin_profiles: RefCell<horizon_client::skins::Resolver>,
|
||||
skin_slots: horizon_client::skins::SkinSlots,
|
||||
/// Cosmetics on players (game/cosmetics.rs), off by default.
|
||||
cosmetics: cosmetics::Cosmetics,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
cart_models: minecarts::CartModels,
|
||||
@@ -11828,6 +11848,7 @@ impl Game<'_> {
|
||||
// The world's packs over the game's biomes (`Biomes::with_packs`),
|
||||
// the table its chunks were built with.
|
||||
self.world_biomes = self.packed_biomes();
|
||||
self.world_biomes_base = self.world_biomes.clone();
|
||||
horizon_client::crash::set_detail(
|
||||
"World",
|
||||
format!("{} ({})", level.name, save_root.display()),
|
||||
@@ -13336,6 +13357,7 @@ impl Game<'_> {
|
||||
self.remote.is_none(),
|
||||
&mut next_float,
|
||||
Some(self.border.geometry()),
|
||||
&self.res.motion().fluids,
|
||||
)
|
||||
});
|
||||
self.orbs = orbs;
|
||||
@@ -13852,8 +13874,8 @@ impl Game<'_> {
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
self.streaming
|
||||
.reload_resources(&mut self.world, self.pack.context.clone());
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
@@ -16293,10 +16315,7 @@ impl Game<'_> {
|
||||
_ => Some(self.streaming.lod_source()),
|
||||
};
|
||||
if let Some(source) = source {
|
||||
self.lod = Some(horizon_client::lod::Lod::new(
|
||||
source,
|
||||
self.pack.context.clone(),
|
||||
));
|
||||
self.lod = Some(horizon_client::lod::Lod::new(source, self.worker_context()));
|
||||
self.lod_observe.extend(self.world.chunks.keys().copied());
|
||||
}
|
||||
}
|
||||
@@ -18288,6 +18307,7 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
Ok(Game {
|
||||
world_biomes: None,
|
||||
world_biomes_base: None,
|
||||
res: res.clone(),
|
||||
rest_loader: None,
|
||||
pending_entry: None,
|
||||
@@ -18604,6 +18624,7 @@ impl<'a> Game<'a> {
|
||||
}),
|
||||
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
|
||||
skin_slots: horizon_client::skins::SkinSlots::new(horizon_client::hud::SKIN_SLOTS),
|
||||
cosmetics: Default::default(),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
@@ -19093,6 +19114,14 @@ impl Game<'_> {
|
||||
);
|
||||
(!Arc::ptr_eq(&table, self.res.biomes())).then_some(table)
|
||||
}
|
||||
/// What the chunk and far-terrain workers mesh with: the pack's context,
|
||||
/// over the world's biomes when it has its own.
|
||||
fn worker_context(&self) -> Arc<WorkerContext> {
|
||||
match &self.world_biomes {
|
||||
Some(table) => self.pack.context_with(table.clone()),
|
||||
None => self.pack.context.clone(),
|
||||
}
|
||||
}
|
||||
fn pack_files(&self) -> horizon_world::loot::PackOverlay<'_> {
|
||||
pack_files(&self.world_tables)
|
||||
}
|
||||
@@ -19157,6 +19186,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.world_tables = self.build_world_tables();
|
||||
self.recipe_tables.categories = None;
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
// Before the players are sent the reloaded data below.
|
||||
self.sync_recipe_content();
|
||||
|
||||
@@ -15,17 +15,72 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{identifier::Identifier, java_hash_map::JavaHashMap, nbt::Tag};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BossState {
|
||||
pub bars: BossBars,
|
||||
/// CustomBossEvents' default HashMap order, used only by the list reply.
|
||||
listing: JavaHashMap<Identifier, usize>,
|
||||
loaded: bool,
|
||||
/// What each viewer was last sent, so a change becomes one packet.
|
||||
sent: HashMap<u128, HashMap<u128, Offer>>,
|
||||
}
|
||||
|
||||
impl BossState {
|
||||
fn add_bar(&mut self, id: &str, name: Comp) -> bool {
|
||||
if !self.bars.add(id, name) {
|
||||
return false;
|
||||
}
|
||||
if let Ok(key) = Identifier::parse(id) {
|
||||
self.listing.put(key, self.bars.bars.len() - 1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn remove_bar(&mut self, id: &str) {
|
||||
self.bars.remove(id);
|
||||
if let Ok(key) = Identifier::parse(id) {
|
||||
self.listing.remove(&key);
|
||||
}
|
||||
// Vec removal shifts indices. Replacing existing map values keeps
|
||||
// its table and bucket chains intact, unlike reconstructing the map.
|
||||
for (index, bar) in self.bars.bars.iter().enumerate() {
|
||||
if let Ok(key) = Identifier::parse(&bar.id) {
|
||||
self.listing.put(key, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_listing(&mut self) {
|
||||
self.listing = JavaHashMap::default();
|
||||
for (index, bar) in self.bars.bars.iter().enumerate() {
|
||||
if let Ok(key) = Identifier::parse(&bar.id) {
|
||||
self.listing.put(key, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn listed_bars(&self) -> Vec<&bossbar::BossBar> {
|
||||
let mut bars: Vec<_> = self
|
||||
.listing
|
||||
.entries()
|
||||
.filter_map(|(_, index)| self.bars.bars.get(*index))
|
||||
.collect();
|
||||
// The existing permissive loader can retain invalid legacy IDs.
|
||||
// Preserve those entries rather than dropping them from the reply.
|
||||
if bars.len() != self.bars.bars.len() {
|
||||
for bar in &self.bars.bars {
|
||||
if !bars.iter().any(|listed| listed.id == bar.id) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
bars
|
||||
}
|
||||
}
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
Text::literal(n.to_string())
|
||||
}
|
||||
@@ -45,6 +100,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.bosses.bars = BossBars::unpack(data);
|
||||
}
|
||||
self.bosses.rebuild_listing();
|
||||
self.ui.registries.bossbars = self.bosses.bars.ids();
|
||||
}
|
||||
pub(super) fn save_bossbars(&self) {
|
||||
@@ -107,7 +163,7 @@ impl Game<'_> {
|
||||
return Err(err("commands.bossbar.create.failed", &[&bar]));
|
||||
}
|
||||
let name = self.component_json(ctx, "name")?;
|
||||
self.bosses.bars.add(&bar, name);
|
||||
self.bosses.add_bar(&bar, name);
|
||||
if let Some(b) = self.bosses.bars.get_mut(&bar) {
|
||||
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
|
||||
b.uuid = insecure_uuid(&mut self.world.random);
|
||||
@@ -124,9 +180,8 @@ impl Game<'_> {
|
||||
"bossbar.list" => {
|
||||
let names: Vec<Text> = self
|
||||
.bosses
|
||||
.bars
|
||||
.bars
|
||||
.iter()
|
||||
.listed_bars()
|
||||
.into_iter()
|
||||
.map(horizon_client::bossbar::BossBar::display_name)
|
||||
.collect();
|
||||
let n = names.len();
|
||||
@@ -154,7 +209,7 @@ impl Game<'_> {
|
||||
match id {
|
||||
"bossbar.remove" => {
|
||||
let s = shown(self);
|
||||
self.bosses.bars.remove(&bar);
|
||||
self.bosses.remove_bar(&bar);
|
||||
self.feedback(Text::translate("commands.bossbar.remove.success", &[s]));
|
||||
Ok(self.bosses.bars.bars.len() as i32)
|
||||
}
|
||||
@@ -677,3 +732,62 @@ struct Offer {
|
||||
fog: bool,
|
||||
scope: Scope,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod listing_tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(state: &BossState) -> Vec<&str> {
|
||||
state
|
||||
.listed_bars()
|
||||
.into_iter()
|
||||
.map(|bar| bar.id.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bossbar_listing_uses_identifier_hash_order_and_current_names() {
|
||||
let mut state = BossState::default();
|
||||
state.add_bar("audit:test", sb::literal("Title"));
|
||||
state.bars.get_mut("audit:test").unwrap().name = sb::literal("Other");
|
||||
state.add_bar("audit:second", sb::literal("Second"));
|
||||
assert_eq!(ids(&state), ["audit:second", "audit:test"]);
|
||||
let names: Vec<_> = state
|
||||
.listed_bars()
|
||||
.into_iter()
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
assert_eq!(names, [sb::literal("Second"), sb::literal("Other")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bossbar_listing_retains_resize_history_after_removals() {
|
||||
let mut state = BossState::default();
|
||||
state.add_bar("audit:test", sb::literal("Title"));
|
||||
state.add_bar("audit:second", sb::literal("Second"));
|
||||
for i in 0..11 {
|
||||
state.add_bar(&format!("audit:bar{i}"), sb::literal("Extra"));
|
||||
}
|
||||
for i in 0..11 {
|
||||
state.remove_bar(&format!("audit:bar{i}"));
|
||||
}
|
||||
// After the 13th put, HashMap keeps its 32-slot table. These keys
|
||||
// occupy buckets 11 and 24, rather than buckets 11 and 8 at size 16.
|
||||
assert_eq!(ids(&state), ["audit:test", "audit:second"]);
|
||||
state.remove_bar("audit:test");
|
||||
state.remove_bar("audit:second");
|
||||
assert!(ids(&state).is_empty());
|
||||
state.add_bar("audit:test", sb::literal("Again"));
|
||||
state.add_bar("audit:second", sb::literal("Second"));
|
||||
assert_eq!(ids(&state), ["audit:test", "audit:second"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bossbar_listing_rebuilds_for_the_loaded_store() {
|
||||
let mut state = BossState::default();
|
||||
state.bars.add("audit:test", sb::literal("Title"));
|
||||
state.bars.add("audit:second", sb::literal("Second"));
|
||||
state.rebuild_listing();
|
||||
assert_eq!(ids(&state), ["audit:second", "audit:test"]);
|
||||
}
|
||||
}
|
||||
@@ -2989,10 +2989,10 @@ impl Game<'_> {
|
||||
let old_level = self.experience.level;
|
||||
if add {
|
||||
if levels {
|
||||
self.experience.level = (self.experience.level + amount).max(0);
|
||||
if self.experience.level == 0 && amount < 0 {
|
||||
self.experience.progress = 0.0;
|
||||
}
|
||||
// Player.giveExperienceLevels handles the strict negative
|
||||
// boundary and emits the level-change hook itself.
|
||||
self.give_levels(amount);
|
||||
return true;
|
||||
} else {
|
||||
self.give_points(amount);
|
||||
}
|
||||
|
||||
@@ -737,8 +737,9 @@ impl Game<'_> {
|
||||
let mut tracker = Tracker::default();
|
||||
for u in &used {
|
||||
let name = self.item_display_name(&u.item);
|
||||
tracker.track(&holder(name), u.count as i32);
|
||||
let _ = u;
|
||||
// CommandResponseTracker.track(drop): one per stack, so the
|
||||
// command returns how many stacks it used, not how many items.
|
||||
tracker.track(&holder(name), 1);
|
||||
}
|
||||
let counts: Vec<u32> = used.iter().map(|u| u.count).collect();
|
||||
let (message, total) = tracker.message(
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Cosmetics on players ([docs/cosmetics.md](../../../../docs/cosmetics.md)): only Horizon
|
||||
//! clients draw them, off unless `horizon_cosmetics::enabled()`. What another player wears comes
|
||||
//! from the cosmetics service by UUID (`horizon_cosmetics::Cosmetics`, its own thread); this file
|
||||
//! puts the items on the avatar's bones, on the GPU skin path when the renderer takes it.
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_client::mobs::{Mob, Part, Polygon};
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item as the renderer takes it.
|
||||
struct Baked {
|
||||
item: Arc<Item>,
|
||||
model: Arc<Part>,
|
||||
/// Its texture's sprite (a 64×64 skin slot of the atlas).
|
||||
location: String,
|
||||
/// The frame it was last marked drawn in (its slot is kept).
|
||||
marked: Cell<u32>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
client: Option<horizon_cosmetics::Cosmetics>,
|
||||
/// By the item's address; each entry holds its item, so the address isn't reused.
|
||||
baked: RefCell<FxHashMap<usize, Baked>>,
|
||||
/// Items drawn whose texture isn't in the atlas yet.
|
||||
uploads: RefCell<Vec<Arc<Item>>>,
|
||||
frame: Cell<u32>,
|
||||
/// Kept between players so a frame of avatars allocates none: the wearer's body, an item.
|
||||
poses: RefCell<(Poses, Poses)>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
|
||||
local: bool,
|
||||
}
|
||||
|
||||
impl Cosmetics {
|
||||
fn client() -> (horizon_cosmetics::Cosmetics, bool) {
|
||||
if let Some(dir) = std::env::var_os("HORIZON_COSMETICS_LOCAL") {
|
||||
let local = horizon_cosmetics::Local::new(dir.into());
|
||||
return (horizon_cosmetics::Cosmetics::new(local, None), true);
|
||||
}
|
||||
let api = std::env::var("HORIZON_COSMETICS_API")
|
||||
.unwrap_or_else(|_| horizon_cosmetics::API.to_owned());
|
||||
let agent = format!("Horizon/{}", env!("CARGO_PKG_VERSION"));
|
||||
// Beside the downloaded skins (`assets/skins` in the game folder).
|
||||
let cache = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("assets/cosmetics");
|
||||
let http = horizon_cosmetics::Http::new(&api, &agent);
|
||||
(horizon_cosmetics::Cosmetics::new(http, Some(cache)), false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's model of an item's part.
|
||||
fn part(p: &horizon_cosmetics::Part) -> Part {
|
||||
Part {
|
||||
name: p.name.clone(),
|
||||
pose: p.pose,
|
||||
polygons: p
|
||||
.faces
|
||||
.iter()
|
||||
.map(|f| Polygon {
|
||||
normal: f.normal,
|
||||
vertices: f.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: p.children.iter().map(part).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture in the top-left corner of a skin slot's 64×64 RGBA.
|
||||
fn slot_image(item: &Item) -> Vec<u8> {
|
||||
let t = &item.texture;
|
||||
let mut rgba = vec![0; 64 * 64 * 4];
|
||||
for y in 0..t.height as usize {
|
||||
let row = &t.rgba[y * t.width as usize * 4..(y + 1) * t.width as usize * 4];
|
||||
rgba[y * 64 * 4..y * 64 * 4 + row.len()].copy_from_slice(row);
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
|
||||
/// what is worn on the back.
|
||||
fn covered(m: &Mob, slot: Slot) -> bool {
|
||||
match slot {
|
||||
Slot::Head => m.extra.head.is_some(),
|
||||
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
|
||||
Slot::Shoulder => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Once a frame, before drawing: starts the service's client on first use and puts the
|
||||
/// textures the last frame wanted into the atlas.
|
||||
pub(super) fn cosmetics_frame(&mut self) {
|
||||
if !horizon_cosmetics::enabled() {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.cosmetics;
|
||||
if c.client.is_none() {
|
||||
let (client, local) = Cosmetics::client();
|
||||
c.client = Some(client);
|
||||
c.local = local;
|
||||
}
|
||||
c.frame.set(c.frame.get().wrapping_add(1));
|
||||
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
|
||||
for item in uploads {
|
||||
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
|
||||
if !self.gui.atlas.sprites.contains_key(&location) {
|
||||
self.store_skin(location, slot_image(&item));
|
||||
}
|
||||
}
|
||||
// Items no player wears any more.
|
||||
self.cosmetics
|
||||
.baked
|
||||
.borrow_mut()
|
||||
.retain(|_, b| Arc::strong_count(&b.item) > 1);
|
||||
}
|
||||
|
||||
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
|
||||
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
|
||||
let client = self.cosmetics.client.as_ref()?;
|
||||
let extras = &self.settings.extras;
|
||||
if !(if own {
|
||||
extras.cosmetics_mine
|
||||
} else {
|
||||
extras.cosmetics_others
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let uuid = if own {
|
||||
self.remote.as_ref().map(|r| r.profile())?
|
||||
} else if &*m.kind.id == "minecraft:player" {
|
||||
self.remote_player_uuid(m.id)?
|
||||
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
|
||||
u128::from(m.id)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
client.frame().worn(uuid)
|
||||
}
|
||||
|
||||
/// The cosmetics `m` wears, on its bones. `gpu`: posed on the GPU (the mob pass's skin
|
||||
/// frame, begun); the bounds of what went there come back, for culling. Else the vertices
|
||||
/// go to `out`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn cosmetics_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
own: bool,
|
||||
base: glam::Mat4,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
use horizon_client::mobs::{anim, render};
|
||||
let worn = self.worn(m, own)?;
|
||||
if horizon_client::mobs::trader::is_invisible(m) {
|
||||
return None;
|
||||
}
|
||||
// The avatar's body, posed as it is drawn.
|
||||
let (key, _) = render::body(m);
|
||||
let body = m.kind.layer(&key).unwrap_or(&m.kind.model);
|
||||
let mut scratch = self.cosmetics.poses.borrow_mut();
|
||||
let (body_poses, poses) = &mut *scratch;
|
||||
body_poses.reset(body);
|
||||
let data = self.res.mob_data();
|
||||
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
|
||||
let ticks = m.age as f32 + alpha;
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut bounds = None;
|
||||
// Items on one bone (a back item and a shoulder pet both ride the body) share its matrix.
|
||||
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 3] = [None; 3];
|
||||
for item in worn.iter() {
|
||||
if covered(m, item.slot) {
|
||||
continue;
|
||||
}
|
||||
let mut baked = self.cosmetics.baked.borrow_mut();
|
||||
let b = baked
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| Baked {
|
||||
item: item.clone(),
|
||||
model: Arc::new(part(&item.root)),
|
||||
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
|
||||
marked: Cell::new(frame.wrapping_sub(1)),
|
||||
});
|
||||
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
|
||||
self.cosmetics.uploads.borrow_mut().push(item.clone());
|
||||
continue;
|
||||
};
|
||||
if b.marked.replace(frame) != frame {
|
||||
self.skin_downloads.borrow_mut().drawn(&b.location);
|
||||
}
|
||||
let known = bones.iter().flatten().find(|(b, _)| *b == item.bone);
|
||||
let bone = match known {
|
||||
Some(&(_, matrix)) => matrix,
|
||||
None => {
|
||||
let Some(matrix) =
|
||||
render::part_matrix(body, base, body_poses, item.bone.part(), 0.0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
|
||||
*free = Some((item.bone, matrix));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
};
|
||||
// The texture fills the top-left of its 64×64 slot.
|
||||
let (w, h) = (
|
||||
item.texture.width as f32 / 64.0,
|
||||
item.texture.height as f32 / 64.0,
|
||||
);
|
||||
let uv = [
|
||||
rect.u0,
|
||||
rect.v0,
|
||||
rect.u0 + (rect.u1 - rect.u0) * w,
|
||||
rect.v0 + (rect.v1 - rect.v0) * h,
|
||||
];
|
||||
poses.reset(&b.model);
|
||||
item.animate(ticks, |name, pose| *poses.get(name) = pose);
|
||||
if gpu {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&b.model, bone, poses, paint) {
|
||||
bounds = skin.bounds;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::render_model(
|
||||
&b.model,
|
||||
bone,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
bounds
|
||||
}
|
||||
}
|
||||
@@ -1489,7 +1489,7 @@ impl Game<'_> {
|
||||
// LootContextParams.ORIGIN of a death: the mob's position.
|
||||
let place = self.loot_place_at([m.position.x, m.position.y, m.position.z], None);
|
||||
let (items, skulls) = horizon_world::loot::with_native_loot_place(place, || {
|
||||
drops::die(m, &death, &mut self.rand_sequences)
|
||||
drops::die(m, &death, &mut self.rand_sequences, &mut self.world.random)
|
||||
});
|
||||
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
|
||||
// Creeper.droppedSkulls: one head per charged explosion.
|
||||
@@ -6651,6 +6651,25 @@ impl Game<'_> {
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& horizon_cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) =
|
||||
self.cosmetics_geometry(mob, false, base, alpha as f32, packed, gpu, out)
|
||||
{
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
None => (mark, Some((lo, hi))),
|
||||
});
|
||||
}
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
// Held items join the GPU path's draws, their silhouette too.
|
||||
|
||||
@@ -405,6 +405,7 @@ impl Game<'_> {
|
||||
self.res.biomes(),
|
||||
&enabled_features,
|
||||
));
|
||||
self.world_biomes_base = self.world_biomes.clone();
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let flags = Arc::new(Flags::default());
|
||||
flags
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
lan::{
|
||||
self, Lan, NetConfig,
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
data::{BIOME_REGISTRY, SyncData},
|
||||
entity::{EntityState, States},
|
||||
hub::{HostEvent, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
@@ -1253,12 +1253,20 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.level.enabled_features.clone()
|
||||
};
|
||||
if sync.features.iter().any(|f| !features.contains(f))
|
||||
// A world whose packs change biomes sends its own biome registry.
|
||||
let packed = self.world_biomes.clone().filter(|b| b.has_packs());
|
||||
if packed.is_some()
|
||||
|| 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())?;
|
||||
// The shared resource preload has vanilla flags and vanilla biomes.
|
||||
// Feature flags and biomes belong to this world, not to the cached
|
||||
// resources or the next world opened.
|
||||
let mut world_sync = SyncData::load_with(
|
||||
&self.options.resources,
|
||||
&self.pack_files(),
|
||||
packed.as_deref(),
|
||||
)?;
|
||||
world_sync.features = features;
|
||||
sync = Arc::new(world_sync);
|
||||
}
|
||||
@@ -1274,7 +1282,7 @@ impl Game<'_> {
|
||||
let registry: Vec<String> = sync
|
||||
.dynamic
|
||||
.registries
|
||||
.get("minecraft:worldgen/biome")
|
||||
.get(BIOME_REGISTRY)
|
||||
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
let mut encoder = ChunkEncoder::with_signs(
|
||||
|
||||
@@ -465,13 +465,8 @@ impl Game<'_> {
|
||||
}
|
||||
// A wind charge's burst throws none.
|
||||
if !p.block_particles.is_empty() {
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles.explosion_blocks(
|
||||
p.center,
|
||||
p.radius,
|
||||
p.block_count.max(0) as usize,
|
||||
&|b| facts.is_air(world.block(b[0], b[1], b[2])),
|
||||
);
|
||||
self.particles
|
||||
.track_explosion(p.center, p.radius, p.block_count.max(0) as usize);
|
||||
}
|
||||
// Entity.pushFromExplosion.
|
||||
if let (Some(push), Some(player)) = (p.knockback, self.player.as_mut()) {
|
||||
|
||||
@@ -537,4 +537,7 @@ impl Host for super::Game<'_> {
|
||||
) -> Option<(Option<ItemStack>, ItemStack)> {
|
||||
self.native_item_container_extract(stack, slot, max)
|
||||
}
|
||||
fn recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
|
||||
self.native_recipe_book_category(kind)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
//! game runs.
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::streaming::WorkerContext;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Native mods call it; nothing in Horizon does.
|
||||
@@ -17,22 +16,26 @@ impl Game<'_> {
|
||||
/// slow fade) and once when the player joins, not every frame: a rebuild
|
||||
/// takes the workers a few seconds. API 107.
|
||||
pub(super) fn native_refresh_biome_colors(&mut self) {
|
||||
let base = self.res.biomes().clone();
|
||||
let biomes = base
|
||||
.with_colors(|biome, colors| self.native_biome_colors(biome, colors))
|
||||
.map_or(base, Arc::new);
|
||||
if Arc::ptr_eq(&biomes, &self.pack.context.biomes) {
|
||||
return;
|
||||
// A world's own table is asked from the biome files' colours each
|
||||
// time, so a mod's colours do not stack; a plain world's is the pack's.
|
||||
if let (Some(base), Some(current)) = (&self.world_biomes_base, &self.world_biomes) {
|
||||
let Some(biomes) = base.recolored_from(current, |biome, colors| {
|
||||
self.native_biome_colors(biome, colors)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
self.world_biomes = Some(Arc::new(biomes));
|
||||
} else {
|
||||
let base = self.res.biomes().clone();
|
||||
let biomes = base
|
||||
.with_colors(|biome, colors| self.native_biome_colors(biome, colors))
|
||||
.map_or_else(|| base.clone(), Arc::new);
|
||||
if Arc::ptr_eq(&biomes, &self.pack.context.biomes) {
|
||||
return;
|
||||
}
|
||||
self.pack.context = self.pack.context_with(biomes);
|
||||
}
|
||||
let old = &self.pack.context;
|
||||
let context = Arc::new(WorkerContext {
|
||||
assets: old.assets.clone(),
|
||||
render: old.render.clone(),
|
||||
tables: old.tables.clone(),
|
||||
biomes,
|
||||
light: old.light.clone(),
|
||||
});
|
||||
self.pack.context = context.clone();
|
||||
let context = self.worker_context();
|
||||
self.clear_lod();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
}
|
||||
|
||||
@@ -882,7 +882,7 @@ use super::{
|
||||
};
|
||||
use horizon_client::mod_api::{
|
||||
self, NativeCamera, NativeCommand, NativeDispensed, NativeLifecycleContext, NativeServerPlayer,
|
||||
ServerTickPhase, WorldPhase,
|
||||
RecipeBookCategory, ServerTickPhase, WorldPhase,
|
||||
};
|
||||
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
|
||||
pub(crate) use horizon_client::mod_api::{
|
||||
@@ -2024,6 +2024,13 @@ fn chars_to_type(text: &str, canceled: impl FnMut(char) -> bool) -> Option<Strin
|
||||
|
||||
/// The hook points. Each does nothing in Horizon.
|
||||
impl Game<'_> {
|
||||
/// Declare categories for custom recipe types, with registered item icons.
|
||||
/// Asked lazily once per recipe table by `native_recipe_book_category`.
|
||||
/// Invalid declarations are logged and skipped; the first valid category
|
||||
/// owns its id and type. This does not yet add tabs or book displays.
|
||||
pub(super) fn native_recipe_book_categories(&self, categories: &mut Vec<RecipeBookCategory>) {
|
||||
mod_api::dispatch_recipe_book_categories(self, mod_api::mods(), categories);
|
||||
}
|
||||
/// Once a frame, in a world or not, after Horizon's extras.
|
||||
pub(super) fn native_frame(&mut self, elapsed: Duration) {
|
||||
mod_api::dispatch_frame(self, mod_api::mods(), elapsed);
|
||||
@@ -8523,6 +8530,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.recipes().custom.get(kind).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
/// The validated category and icon of a custom recipe type, or None for
|
||||
/// an undeclared/invalid type, before resources load, or outside a world.
|
||||
/// Rebuilt when the recipe table changes. Does not unlock any recipes.
|
||||
pub(super) fn native_recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
|
||||
if self.outside_world() || self.res.rest.get().is_none() {
|
||||
return None;
|
||||
}
|
||||
self.registered_recipe_book_category(kind)
|
||||
}
|
||||
/// An ingredient as a recipe's JSON names it: an item, a `#tag`, or a list
|
||||
/// of them, a bare id as `minecraft:` (as vanilla reads it); `test` says
|
||||
/// whether a stack fits. Items the game doesn't know are left out; None
|
||||
@@ -8548,7 +8564,7 @@ impl Game<'_> {
|
||||
};
|
||||
let items = self.items();
|
||||
let known: Vec<String> = Ingredient::parse(&json, items)?
|
||||
.1
|
||||
.order
|
||||
.into_iter()
|
||||
.filter(|item| items.get(item).is_some())
|
||||
.collect();
|
||||
|
||||
@@ -28,6 +28,7 @@ const MAX_SYNCED_RECIPES: usize = 1_000_000;
|
||||
#[derive(Default)]
|
||||
pub(super) struct RecipeTables {
|
||||
tables: Option<Arc<Recipes>>,
|
||||
pub(super) categories: Option<HashMap<String, horizon_client::recipe_book::RecipeBookCategory>>,
|
||||
known: (Option<u64>, Arc<[i32]>),
|
||||
/// The server's recipes of the types its native mods sent (`horizon:
|
||||
/// recipe_content`) over the window's own: what `Game::recipes` reads on
|
||||
@@ -54,7 +55,8 @@ impl Index {
|
||||
self.stack(&s.item, s.count, components(s))
|
||||
}
|
||||
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
|
||||
i.1.iter()
|
||||
i.order
|
||||
.iter()
|
||||
.filter_map(|id| self.stack(id, 1, String::new()))
|
||||
.collect()
|
||||
}
|
||||
@@ -97,6 +99,21 @@ fn recipe(id: String, kind: RecipeKind) -> Recipe {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn registered_recipe_book_category(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
) -> Option<horizon_client::recipe_book::RecipeBookCategory> {
|
||||
if self.recipe_tables.categories.is_none() {
|
||||
let mut declarations = Vec::new();
|
||||
self.native_recipe_book_categories(&mut declarations);
|
||||
self.recipe_tables.categories = Some(horizon_client::recipe_book::mod_categories(
|
||||
declarations,
|
||||
self.recipes(),
|
||||
self.items(),
|
||||
));
|
||||
}
|
||||
self.recipe_tables.categories.as_ref()?.get(kind).cloned()
|
||||
}
|
||||
/// `native_give`: `count` of `item`, refused outside a world, unless
|
||||
/// creative and for an item the game doesn't have.
|
||||
pub(super) fn recipe_give(&mut self, item: &str, count: u32) -> Result<(), String> {
|
||||
|
||||
@@ -341,7 +341,6 @@ impl Game<'_> {
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.clone();
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The new atlas's skin and map slots are blank: players and maps
|
||||
@@ -413,6 +412,7 @@ impl Game<'_> {
|
||||
// A pack that may replace leaf models falls back to `Off` before the
|
||||
// reload marks every section dirty.
|
||||
self.streaming.leaf_culling = self.effective_leaf_culling();
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.pending_uploads.clear();
|
||||
// New GPU textures: a renderer built from the reloaded resources; the
|
||||
|
||||
@@ -687,6 +687,10 @@ impl Game<'_> {
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if horizon_cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
|
||||
self.cosmetics_geometry(m, true, base, alpha as f32, packed, false, out);
|
||||
}
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
@@ -795,6 +799,10 @@ impl Game<'_> {
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
if horizon_cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
|
||||
self.cosmetics_geometry(&m, true, base, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
}
|
||||
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
|
||||
// wings' faces show from both sides (the elytra texture is empty on
|
||||
// the face that looks at the body).
|
||||
|
||||
@@ -373,6 +373,7 @@ impl Game<'_> {
|
||||
/// the server's recipes arrived, or a new session began.
|
||||
pub(super) fn forget_book(&mut self) {
|
||||
self.recipe_ui.book = None;
|
||||
self.recipe_tables.categories = None;
|
||||
}
|
||||
fn ensure_book(&mut self) {
|
||||
if self.recipe_ui.book.is_none() {
|
||||
|
||||
@@ -17,6 +17,7 @@ use horizon_client::{
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
hud::HudBatch,
|
||||
items::ItemStack,
|
||||
lan::data::{BIOME_REGISTRY, client_biomes},
|
||||
menu::Input,
|
||||
mobs::{Mob, MobType, Who},
|
||||
motion::{MotionWorld, PlayerMotion},
|
||||
@@ -832,8 +833,12 @@ impl Game<'_> {
|
||||
self.reset_tutorial();
|
||||
self.perf.world_opening();
|
||||
// A server's own biomes come over the connection; the last local
|
||||
// world's packed table must not name its ids.
|
||||
self.world_biomes = None;
|
||||
// world's packed table must not name its ids. The window's own
|
||||
// server thread keeps the one it just set.
|
||||
if local.is_none() {
|
||||
self.world_biomes = None;
|
||||
self.world_biomes_base = None;
|
||||
}
|
||||
let root = self.options.resources.clone();
|
||||
let builtin = builtin_registries(&root).ok_or("Resources are incomplete: registries.json is missing or invalid. Click \"Prepare again\" in the launcher (or run horizon-prepare), then try again.")?;
|
||||
let lang = Arc::new(horizon_client::net::load_language(&root));
|
||||
@@ -898,7 +903,7 @@ impl Game<'_> {
|
||||
let player_type = super::perspective::player_type(&root);
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
self.pack.context.clone(),
|
||||
self.worker_context(),
|
||||
self.res.threads(),
|
||||
);
|
||||
streaming.remote = true;
|
||||
@@ -1163,7 +1168,7 @@ impl Game<'_> {
|
||||
self.adv = Default::default();
|
||||
self.stats = Default::default();
|
||||
self.score.board = Default::default();
|
||||
self.streaming = Streaming::new(Arc::new(NoTerrain), self.pack.context.clone(), 1);
|
||||
self.streaming = Streaming::new(Arc::new(NoTerrain), self.worker_context(), 1);
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
self.player = None;
|
||||
@@ -2030,9 +2035,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn set_registries(&mut self, registries: Arc<Dynamic>) {
|
||||
// The server's biomes the game lacks (a world data pack's) join the
|
||||
// game's table, from the data it sent: colours, meshing and the
|
||||
// chunk format all go through that table. The window's own server
|
||||
// thread set the world's table already.
|
||||
if self.integrated.is_none() {
|
||||
let table = registries
|
||||
.registries
|
||||
.get(BIOME_REGISTRY)
|
||||
.and_then(|list| client_biomes(self.res.biomes(), list))
|
||||
.map(Arc::new);
|
||||
if table.is_some() || self.world_biomes.is_some() {
|
||||
self.world_biomes_base = table.clone();
|
||||
self.world_biomes = table;
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
}
|
||||
}
|
||||
let biomes: Vec<u16> = registries
|
||||
.registries
|
||||
.get("minecraft:worldgen/biome")
|
||||
.get(BIOME_REGISTRY)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.map(|(name, _)| world_biomes!(self).id(name))
|
||||
@@ -2110,7 +2132,7 @@ impl Game<'_> {
|
||||
self.stop_sounds_for_new_level();
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
self.pack.context.clone(),
|
||||
self.worker_context(),
|
||||
self.res.threads(),
|
||||
);
|
||||
streaming.remote = true;
|
||||
@@ -6374,7 +6396,12 @@ impl Game<'_> {
|
||||
illager::cast_particles(m, &mut mob_fx);
|
||||
}
|
||||
S::Evoker => illager::cast_particles(m, &mut mob_fx),
|
||||
S::Wither => wither::client_smoke(m, &mut mob_fx),
|
||||
S::Wither => {
|
||||
wither::client_smoke(m, &mut mob_fx);
|
||||
// `getInvulnerableTicks() > 0`: the charge's cloud,
|
||||
// after the heads' smoke as in `aiStep`.
|
||||
wither::charging_particles(m, &mut mob_fx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -6563,6 +6590,7 @@ impl Game<'_> {
|
||||
false,
|
||||
&mut next_float,
|
||||
border,
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
// `tick` set `previous` to the stepped position: keep the
|
||||
// pre-step one, as `xo` is in vanilla.
|
||||
@@ -9563,6 +9591,12 @@ impl Game<'_> {
|
||||
Some(r.glowing.contains(&(id as i32)))
|
||||
}
|
||||
/// The profile name of another player the server sent, by entity id.
|
||||
/// The UUID of another player's entity in the level.
|
||||
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = *r.level_players.get(&(id as i32))?;
|
||||
(uuid != r.profile).then_some(uuid)
|
||||
}
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
|
||||
@@ -756,6 +756,7 @@ impl Game<'_> {
|
||||
for (location, rgba) in ready {
|
||||
self.store_skin(location, rgba);
|
||||
}
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
/// `Hud.extractTabList`: the player list while the player-list key is
|
||||
/// held. A local world shows it with a joined LAN guest or a `list`
|
||||
|
||||
@@ -739,7 +739,7 @@ pub(super) fn make_streaming(
|
||||
registry: pack.assets.clone(),
|
||||
biomes: biomes.clone(),
|
||||
}),
|
||||
pack.context.clone(),
|
||||
pack.context_with(biomes.clone()),
|
||||
res.threads(),
|
||||
)
|
||||
} else if let Some(inner) = flat_vanilla {
|
||||
@@ -762,7 +762,7 @@ pub(super) fn make_streaming(
|
||||
registry: pack.assets.clone(),
|
||||
biomes: biomes.clone(),
|
||||
}),
|
||||
pack.context.clone(),
|
||||
pack.context_with(biomes.clone()),
|
||||
res.threads(),
|
||||
)
|
||||
} else if let Some(inner) = flat {
|
||||
@@ -773,7 +773,7 @@ pub(super) fn make_streaming(
|
||||
registry: pack.assets.clone(),
|
||||
biomes: biomes.clone(),
|
||||
}),
|
||||
pack.context.clone(),
|
||||
pack.context_with(biomes.clone()),
|
||||
res.threads(),
|
||||
)
|
||||
} else if options.test_terrain && dim == Dim::Overworld {
|
||||
@@ -785,7 +785,7 @@ pub(super) fn make_streaming(
|
||||
registry: pack.assets.clone(),
|
||||
biomes: biomes.clone(),
|
||||
}),
|
||||
pack.context.clone(),
|
||||
pack.context_with(biomes.clone()),
|
||||
res.threads(),
|
||||
)
|
||||
} else {
|
||||
@@ -834,7 +834,7 @@ pub(super) fn make_streaming(
|
||||
registry: pack.assets.clone(),
|
||||
biomes: biomes.clone(),
|
||||
}),
|
||||
pack.context.clone(),
|
||||
pack.context_with(biomes.clone()),
|
||||
res.threads(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -646,13 +646,17 @@ fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
|
||||
};
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
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);
|
||||
let Some(text) = file.bytes().ok().and_then(|b| std::str::from_utf8(b).ok()) else {
|
||||
continue;
|
||||
};
|
||||
match ClientItem::from_json(text) {
|
||||
Ok(item) => {
|
||||
out.insert(format!("{namespace}:{}", file.name), item);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: item definition {namespace}:{} not loaded: {e}",
|
||||
file.name
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,33 @@ pub const SYNCHRONIZED: &[&str] = &[
|
||||
"minecraft:block_transformer",
|
||||
"minecraft:worldgen/block_state_provider",
|
||||
];
|
||||
pub const BIOME_REGISTRY: &str = "minecraft:worldgen/biome";
|
||||
/// The table a client plays a server with: `known` and the biomes of the
|
||||
/// server's biome registry that it lacks, built from their data; None when
|
||||
/// there are none.
|
||||
pub fn client_biomes(
|
||||
known: &crate::biome::Biomes,
|
||||
registry: &[(String, Option<Tag>)],
|
||||
) -> Option<crate::biome::Biomes> {
|
||||
let files: Vec<(&str, Value)> = registry
|
||||
.iter()
|
||||
.filter(|(name, _)| !known.contains(name))
|
||||
.filter_map(|(name, data)| {
|
||||
Some((
|
||||
name.as_str(),
|
||||
crate::scoreboard::tag_to_json(data.as_ref()?),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
log::info!(
|
||||
"Horizon: the server has {} biomes the game lacks",
|
||||
files.len()
|
||||
);
|
||||
Some(known.with_packs(files.iter().map(|(n, v)| (*n, v))))
|
||||
}
|
||||
/// KnownPack.vanilla("core") for this version.
|
||||
pub const CORE_PACK: (&str, &str, &str) = ("minecraft", "core", horizon_protocol::GAME_VERSION);
|
||||
|
||||
@@ -194,6 +221,16 @@ impl SyncData {
|
||||
pub fn load(
|
||||
resources: &Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) -> Result<Self, String> {
|
||||
Self::load_with(resources, overlay, None)
|
||||
}
|
||||
|
||||
/// `load` with the open world's biomes: when its data packs added or
|
||||
/// replaced some, the biome registry is that table's, in its order.
|
||||
pub fn load_with(
|
||||
resources: &Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
biomes: Option<&crate::biome::Biomes>,
|
||||
) -> Result<Self, String> {
|
||||
use crate::spans::startup;
|
||||
// The recipe book, the recipe sync and the advancements read the
|
||||
@@ -222,6 +259,22 @@ impl SyncData {
|
||||
if PACK_REGISTRIES.contains(key) {
|
||||
pack_entries(path, overlay, &mut entries);
|
||||
}
|
||||
if *key == BIOME_REGISTRY
|
||||
&& let Some(table) = biomes.filter(|b| b.has_packs())
|
||||
{
|
||||
// A pack's biome goes with its data: no client knows it.
|
||||
let found: HashMap<_, _> = entries
|
||||
.drain(..)
|
||||
.map(|((ns, p), v, _)| (format!("{ns}:{p}"), v))
|
||||
.collect();
|
||||
for name in table.registry_order() {
|
||||
let (ns, p) = name.split_once(':').unwrap_or(("minecraft", name));
|
||||
let pack = table.pack_file(name);
|
||||
let json = pack.or_else(|| found.get(name)).cloned();
|
||||
let json = json.unwrap_or_else(|| Value::Object(Default::default()));
|
||||
entries.push(((ns.to_owned(), p.to_owned()), json, pack.is_some()));
|
||||
}
|
||||
}
|
||||
file_order(&mut entries);
|
||||
let mut e = Entries {
|
||||
names: Vec::new(),
|
||||
|
||||
@@ -138,7 +138,11 @@ fn flat(ing: &Value, items: &horizon_client::items::Items) -> Option<Value> {
|
||||
.and_then(|v| horizon_client::crafting::Ingredient::parse(v, items))
|
||||
})?;
|
||||
Some(Value::Array(
|
||||
parsed.1.iter().map(|i| Value::String(i.clone())).collect(),
|
||||
parsed
|
||||
.order
|
||||
.iter()
|
||||
.map(|i| Value::String(i.clone()))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -3546,6 +3546,7 @@ pub(crate) mod tests {
|
||||
living_fall: |_, _, _, _, _| {},
|
||||
living_jump: |_, _| {},
|
||||
ingredient: |_, _, _| {},
|
||||
ingredient_test: |_, _, _, _| {},
|
||||
});
|
||||
assert!(installed.is_ok());
|
||||
let kind = Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Stacked {
|
||||
let mut max = 0;
|
||||
for (key, &count) in self.keys.iter().zip(&self.amounts) {
|
||||
if count > max {
|
||||
if ing.0.contains(&**key) {
|
||||
if ing.items.contains(&**key) {
|
||||
max = count;
|
||||
}
|
||||
if max >= min {
|
||||
@@ -146,14 +146,16 @@ impl<'a> Picker<'a> {
|
||||
let items: Vec<usize> = (0..stacked.keys.len())
|
||||
.filter(|&i| {
|
||||
stacked.amounts[i] > 0
|
||||
&& ingredients.iter().any(|g| g.0.contains(&*stacked.keys[i]))
|
||||
&& ingredients
|
||||
.iter()
|
||||
.any(|g| g.items.contains(&*stacked.keys[i]))
|
||||
})
|
||||
.collect();
|
||||
let (ni, nt) = (ingredients.len(), items.len());
|
||||
let mut connection = vec![false; ni * nt];
|
||||
for (g, ing) in ingredients.iter().enumerate() {
|
||||
for (t, &key) in items.iter().enumerate() {
|
||||
if ing.0.contains(&*stacked.keys[key]) {
|
||||
if ing.items.contains(&*stacked.keys[key]) {
|
||||
connection[t * ni + g] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,6 +830,11 @@ pub fn anger_left(anger_end: i64, game_time: i64) -> i32 {
|
||||
/// Species-specific persistent state (variants, colours, taming, ageing...).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Extra {
|
||||
/// `Mob.lootTable`: the table its death rolls instead of its type's
|
||||
/// (saved as `DeathLootTable`).
|
||||
pub death_loot_table: Option<String>,
|
||||
/// `Mob.lootTableSeed` (`DeathLootTableSeed`): 0 for none.
|
||||
pub death_loot_seed: i64,
|
||||
/// `Rabbit.moreCarrotTicks`: how long it is full of carrots (saved as `MoreCarrotTicks`).
|
||||
pub more_carrot_ticks: i32,
|
||||
/// `Ocelot.DATA_TRUSTING`: it no longer runs from players.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! the experience orbs.
|
||||
use super::{Mob, Species, equine, gear, raider, render::DYE_NAMES, species};
|
||||
use crate::random_sequences::RandomSequences;
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::{
|
||||
loot::{EntityLootParams, LootDamage, LootData, LootEntity, LootStack},
|
||||
registry::BlockStateId,
|
||||
@@ -435,7 +435,14 @@ pub fn orb_value(amount: i32) -> i32 {
|
||||
/// `LivingEntity.die`'s drops for `m` (equipment it loses is removed from
|
||||
/// it). Returns the drops in spawn order and whether a charged creeper
|
||||
/// killer dropped a head (`Creeper.droppedSkulls`).
|
||||
pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop>, bool) {
|
||||
/// `level_random` is the level's random: what a table rolls with when it has
|
||||
/// neither a seed nor a random sequence (`LootContext.Builder.create`).
|
||||
pub fn die(
|
||||
m: &mut Mob,
|
||||
d: &Death,
|
||||
sequences: &mut RandomSequences,
|
||||
level_random: &mut LegacyRandom,
|
||||
) -> (Vec<Drop>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let this = loot_entity(m, d.vehicle.clone());
|
||||
let mut skulls = false;
|
||||
@@ -451,7 +458,14 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
|
||||
player: None,
|
||||
luck: 0.0,
|
||||
};
|
||||
let items = roll(d, "minecraft:charged_creeper/root", ¶ms, sequences);
|
||||
let items = roll(
|
||||
d,
|
||||
"minecraft:charged_creeper/root",
|
||||
¶ms,
|
||||
sequences,
|
||||
0,
|
||||
level_random,
|
||||
);
|
||||
skulls = items.iter().any(|i| !i.is_empty());
|
||||
out.extend(items.into_iter().filter(|i| !i.is_empty()).map(Drop::Item));
|
||||
}
|
||||
@@ -480,7 +494,14 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
|
||||
player: d.player.clone().filter(|_| killed_by_player),
|
||||
luck: if killed_by_player { d.luck } else { 0.0 },
|
||||
};
|
||||
let items = roll(d, &table, ¶ms, sequences);
|
||||
let items = roll(
|
||||
d,
|
||||
&table,
|
||||
¶ms,
|
||||
sequences,
|
||||
m.extra.death_loot_seed,
|
||||
level_random,
|
||||
);
|
||||
out.extend(items.into_iter().filter(|i| !i.is_empty()).map(Drop::Item));
|
||||
// Mob.dropCustomDeathLoot.
|
||||
let damage_params = EntityLootParams {
|
||||
@@ -579,25 +600,37 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
|
||||
/// The loot table `die` rolls for `m` (`LivingEntity.getLootTable`), when it
|
||||
/// rolls one: none with `doMobLoot` off, nor for a baby that isn't a monster.
|
||||
pub fn death_loot_table(m: &Mob, mob_drops: bool) -> Option<String> {
|
||||
// Mob.getLootTable: the mob's own `DeathLootTable`, else its type's.
|
||||
(mob_drops && (is_monster(m.species()) || !m.is_baby())).then(|| {
|
||||
format!(
|
||||
"minecraft:entities/{}",
|
||||
m.kind.id.trim_start_matches("minecraft:")
|
||||
)
|
||||
m.extra.death_loot_table.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"minecraft:entities/{}",
|
||||
m.kind.id.trim_start_matches("minecraft:")
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `LootTable.getRandomItems(params, seed, ...)`: a seed's own random, else
|
||||
/// the table's random sequence, else the level's random.
|
||||
fn roll(
|
||||
d: &Death,
|
||||
table: &str,
|
||||
params: &EntityLootParams,
|
||||
sequences: &mut RandomSequences,
|
||||
seed: i64,
|
||||
level_random: &mut LegacyRandom,
|
||||
) -> Vec<LootStack> {
|
||||
if seed != 0 {
|
||||
return d
|
||||
.loot
|
||||
.roll_entity(table, &mut LegacyRandom::new(seed), params);
|
||||
}
|
||||
match d.loot.random_sequence(table) {
|
||||
Some(id) => {
|
||||
let random = sequences.get(id, d.world_seed);
|
||||
d.loot.roll_entity(table, random, params)
|
||||
}
|
||||
None => Vec::new(),
|
||||
None => d.loot.roll_entity(table, level_random, params),
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.shouldDropExperience`: babies drop none, except monsters and
|
||||
@@ -646,3 +679,56 @@ pub fn base_experience(m: &mut Mob) -> i32 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod death_loot_table_tests {
|
||||
use super::*;
|
||||
use horizon_world::loot::{LootData, PackOverlay};
|
||||
|
||||
/// Mob.getLootTable and LootTable.getRandomItems: a mob's own
|
||||
/// `DeathLootTable` is rolled instead of its type's, with the level's
|
||||
/// random when the table has no random sequence (as a pack's tables
|
||||
/// mostly don't), and with its `DeathLootTableSeed` when set.
|
||||
#[test]
|
||||
fn a_mob_drops_its_own_death_loot_table() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("data").exists() {
|
||||
return;
|
||||
}
|
||||
let table = br#"{"type":"minecraft:entity","pools":[{"rolls":1,
|
||||
"entries":[{"type":"minecraft:item","name":"minecraft:diamond"}]}]}"#;
|
||||
let packs = [("loot_table/pack:boss".to_owned(), vec![table.to_vec()])];
|
||||
let loot = LootData::load(&root, &PackOverlay { files: &packs }).unwrap();
|
||||
let death = Death {
|
||||
loot: &loot,
|
||||
damage: None,
|
||||
player: None,
|
||||
luck: 0.0,
|
||||
vehicle: None,
|
||||
mob_drops: true,
|
||||
world_seed: 0,
|
||||
};
|
||||
let items = |seed: i64, own: bool| {
|
||||
let kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let mut m = Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
if own {
|
||||
m.extra.death_loot_table = Some("pack:boss".into());
|
||||
}
|
||||
m.extra.death_loot_seed = seed;
|
||||
let mut sequences = RandomSequences::default();
|
||||
let (out, _) = die(&mut m, &death, &mut sequences, &mut LegacyRandom::new(7));
|
||||
out.into_iter()
|
||||
.filter_map(|d| match d {
|
||||
Drop::Item(s) => Some(s.item),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(items(0, true), ["minecraft:diamond"]);
|
||||
assert_eq!(items(42, true), ["minecraft:diamond"]);
|
||||
// Without one, the type's table (a cow's beef and leather).
|
||||
assert!(items(0, false).iter().all(|i| i != "minecraft:diamond"));
|
||||
}
|
||||
}
|
||||
@@ -679,7 +679,12 @@ mod tests {
|
||||
] {
|
||||
let mut m = enderman();
|
||||
m.extra.carried = registry.default_state(block);
|
||||
let (out, _) = drops::die(&mut m, &death(true), &mut sequences);
|
||||
let (out, _) = drops::die(
|
||||
&mut m,
|
||||
&death(true),
|
||||
&mut sequences,
|
||||
&mut horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
let dropped: Vec<String> = out
|
||||
.into_iter()
|
||||
.flat_map(|d| match d {
|
||||
@@ -699,7 +704,12 @@ mod tests {
|
||||
// doMobLoot off: no dropCustomDeathLoot, so no block either.
|
||||
let mut m = enderman();
|
||||
m.extra.carried = registry.default_state("minecraft:grass_block");
|
||||
let (out, _) = drops::die(&mut m, &death(false), &mut sequences);
|
||||
let (out, _) = drops::die(
|
||||
&mut m,
|
||||
&death(false),
|
||||
&mut sequences,
|
||||
&mut horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|d| matches!(d, drops::Drop::CarriedBlock(_))),
|
||||
|
||||
@@ -236,11 +236,29 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let _ = powered;
|
||||
}
|
||||
if m.extra.counter > 0 {
|
||||
for _ in 0..3 {
|
||||
m.random.next_gaussian();
|
||||
m.random.next_float();
|
||||
m.random.next_gaussian();
|
||||
}
|
||||
charging_particles(m, events);
|
||||
}
|
||||
}
|
||||
/// `WitherBoss.aiStep`'s entity-effect particles while the wither is still
|
||||
/// charging (`getInvulnerableTicks() > 0`): three a tick in the
|
||||
/// (0.7, 0.7, 0.9) colour, a gaussian away from the body on x and z and up to
|
||||
/// `3.3 * getScale()` above its feet on y, all from the wither's own random.
|
||||
pub fn charging_particles(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if m.extra.counter <= 0 {
|
||||
return;
|
||||
}
|
||||
let spread = 3.3 * super::riders::scale(m);
|
||||
for _ in 0..3 {
|
||||
// `this.random`: a gaussian for x, a float for y, a gaussian for z.
|
||||
let at = [
|
||||
m.position.x + m.random.next_gaussian(),
|
||||
m.position.y + f64::from(m.random.next_float()) * spread,
|
||||
m.position.z + m.random.next_gaussian(),
|
||||
];
|
||||
events.push(MobEvent::ParticleColored {
|
||||
at,
|
||||
color: [0.7, 0.7, 0.9],
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `WitherBoss.customServerAiStep`: charging, then the side heads' targets
|
||||
@@ -474,4 +492,63 @@ mod tests {
|
||||
assert!(!shielded_from(Species::Wither, Species::Creeper));
|
||||
assert!(!shielded_from(Species::Zombie, Species::Wither));
|
||||
}
|
||||
/// A spawning wither rolled the three particles of `aiStep`'s
|
||||
/// `getInvulnerableTicks() > 0` block and dropped them on the floor: the
|
||||
/// potion-coloured (`0.7, 0.7, 0.9`) cloud around it never appeared (#7486).
|
||||
#[test]
|
||||
fn a_charging_wither_sheds_three_potion_coloured_particles_a_tick() {
|
||||
use crate::mobs::Species;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use std::sync::Arc;
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:wither".into();
|
||||
k.species = Species::Wither;
|
||||
let wither = |model_scale: f32| {
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(4.0, 64.0, -2.0), 0.0);
|
||||
m.extra.counter = INVULNERABLE;
|
||||
m.extra.model_scale = model_scale;
|
||||
m
|
||||
};
|
||||
for (model_scale, spread) in [(1.0_f32, 3.3_f64), (2.0, 6.6)] {
|
||||
let mut m = wither(model_scale);
|
||||
// The wither's own random, advanced the way vanilla's `aiStep`
|
||||
// does it, for the expected offsets.
|
||||
let mut probe = m.random.clone();
|
||||
let mut events = Vec::new();
|
||||
charging_particles(&mut m, &mut events);
|
||||
let mut want = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (dx, dy, dz) = (
|
||||
probe.next_gaussian(),
|
||||
f64::from(probe.next_float()) * spread,
|
||||
probe.next_gaussian(),
|
||||
);
|
||||
want.push([4.0 + dx, 64.0 + dy, -2.0 + dz]);
|
||||
}
|
||||
let got: Vec<([f64; 3], [f32; 3])> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
MobEvent::ParticleColored { at, color } => (*at, *color),
|
||||
other => panic!("not an entity-effect particle: {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got.len(), 3, "three a tick, as many as the heads' smoke");
|
||||
for (at, color) in &got {
|
||||
assert_eq!(*color, [0.7, 0.7, 0.9]);
|
||||
// Measured against the body, gaussians on x and z and a float
|
||||
// over [0, 3.3 * getScale()) on y.
|
||||
assert!((0.0..spread).contains(&(at[1] - 64.0)), "{at:?}");
|
||||
}
|
||||
let flat: Vec<[f64; 3]> = got.iter().map(|(at, _)| *at).collect();
|
||||
assert_eq!(flat, want, "scale {model_scale}");
|
||||
}
|
||||
// Once the charge ends (`getInvulnerableTicks` back to 0) the cloud
|
||||
// stops: the block is guarded, not unconditional.
|
||||
let mut m = wither(1.0);
|
||||
m.extra.counter = 0;
|
||||
let mut events = Vec::new();
|
||||
charging_particles(&mut m, &mut events);
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ 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::recipe_book::RecipeBookCategory;
|
||||
pub use crate::text::Text;
|
||||
pub use horizon_assets::item_model::DisplayContext;
|
||||
pub use horizon_core::{
|
||||
|
||||
+58
@@ -4542,6 +4542,15 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// A mod's own ingredient kind, matching a concrete stack at craft time:
|
||||
/// the ingredient's `test` calls here with the kind, its JSON and the
|
||||
/// stack being matched, `accept` already true (the load-time list named
|
||||
/// the item). Set `accept` to false to refuse the stack — its components
|
||||
/// are the usual reason. The hook may be called many times a craft; the
|
||||
/// answer must be the same for the same stack. API @API:ingredient_test@.
|
||||
fn ingredient_test(&self, event: &mut IngredientTestEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
|
||||
/// `slots` when the item is yours, for a backpack, a storage cell or a
|
||||
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
|
||||
@@ -4575,6 +4584,14 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// Declare categories for custom recipe types, with registered item icons.
|
||||
/// Asked lazily once per recipe table by `native_recipe_book_category`.
|
||||
/// Invalid declarations are logged and skipped; the first valid category
|
||||
/// owns its id and type. This does not yet add tabs or book displays.
|
||||
fn recipe_book_categories(&self, host: &dyn Host, event: &mut RecipeBookCategoriesEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// Additional first-person hand and item pose, including empty arms and maps.
|
||||
/// Add offsets in blocks and pitch, yaw and roll in degrees; multiply scale.
|
||||
/// API @API:render_hand_pose@.
|
||||
@@ -13996,6 +14013,32 @@ pub fn dispatch_explosion_knockback(
|
||||
mod_hook.explosion_knockback(host, &mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::ingredient_test`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct IngredientTestEvent<'a> {
|
||||
pub kind: &'a str,
|
||||
pub data: &'a serde_json::Map<String, Value>,
|
||||
pub stack: &'a ItemStack,
|
||||
pub accept: bool,
|
||||
}
|
||||
pub fn dispatch_ingredient_test(
|
||||
mods: &[Box<dyn Hooks>],
|
||||
kind: &str,
|
||||
data: &serde_json::Map<String, Value>,
|
||||
stack: &ItemStack,
|
||||
accept: &mut bool,
|
||||
) {
|
||||
let mut event = IngredientTestEvent {
|
||||
kind,
|
||||
data,
|
||||
stack,
|
||||
accept: *accept,
|
||||
};
|
||||
for mod_hook in super::entered(mods, "ingredient_test") {
|
||||
mod_hook.ingredient_test(&mut event);
|
||||
}
|
||||
*accept = event.accept;
|
||||
}
|
||||
/// What [`Hooks::item_container`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ItemContainerEvent<'a> {
|
||||
@@ -14080,6 +14123,21 @@ pub fn dispatch_movement_input(
|
||||
*jump = event.jump;
|
||||
*sneak = event.sneak;
|
||||
}
|
||||
/// What [`Hooks::recipe_book_categories`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct RecipeBookCategoriesEvent<'a> {
|
||||
pub categories: &'a mut Vec<RecipeBookCategory>,
|
||||
}
|
||||
pub fn dispatch_recipe_book_categories(
|
||||
host: &dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
categories: &mut Vec<RecipeBookCategory>,
|
||||
) {
|
||||
let mut event = RecipeBookCategoriesEvent { categories };
|
||||
for mod_hook in super::entered(mods, "recipe_book_categories") {
|
||||
mod_hook.recipe_book_categories(host, &mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::render_hand_pose`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct RenderHandPoseEvent<'a> {
|
||||
|
||||
@@ -1303,6 +1303,13 @@ pub trait Host {
|
||||
let _ = max;
|
||||
unimplemented!("Host::item_container_extract")
|
||||
}
|
||||
/// The validated category and icon of a custom recipe type, or None for
|
||||
/// an undeclared/invalid type, before resources load, or outside a world.
|
||||
/// Rebuilt when the recipe table changes. Does not unlock any recipes.
|
||||
fn recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
|
||||
let _ = kind;
|
||||
unimplemented!("Host::recipe_book_category")
|
||||
}
|
||||
}
|
||||
impl EntityRef {
|
||||
/// The entity's position (`Entity.position`): its feet, the middle of
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
use crate::{Result, blocks::MAP_COLORS};
|
||||
use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape};
|
||||
use horizon_mods::content::{
|
||||
Block, Content, CreativeTab, EntityType, MenuType, MobEffect, Rarity, StateProperty,
|
||||
Block, BlockShapes, Content, CreativeTab, EntityType, MenuType, MobEffect, Rarity,
|
||||
StateProperty,
|
||||
};
|
||||
use horizon_world::nbt::{self, Compound, JavaString, Limits, Tag};
|
||||
use serde::{Deserialize, Serialize, de};
|
||||
@@ -568,6 +569,61 @@ fn encode_shape(shape: &VoxelShape) -> Tag {
|
||||
Tag::Compound(c)
|
||||
}
|
||||
|
||||
fn resolve_shapes<'a>(
|
||||
spec: &BlockShapes,
|
||||
block: &str,
|
||||
states: &Range<u32>,
|
||||
order: &[(u32, u32)],
|
||||
combos: &[Vec<(String, String)>],
|
||||
properties: impl Fn(u32) -> Option<&'a J>,
|
||||
) -> Result<Vec<VoxelShape>> {
|
||||
let names = spec.property_names();
|
||||
let mut used = BTreeSet::new();
|
||||
let mut shapes = Vec::with_capacity(order.len());
|
||||
for &(state, combo) in order {
|
||||
let values = names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
combos[combo as usize]
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.as_str())
|
||||
.or_else(|| {
|
||||
match properties(states.start + state).and_then(|props| props.get(name)) {
|
||||
Some(J::Str(value)) => Some(value.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| format!("{block}: state lacks shape property {name}"))
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
let key = values.join(",");
|
||||
let boxes = if let Some(boxes) = spec.values.get(&key) {
|
||||
used.insert(key);
|
||||
boxes
|
||||
} else {
|
||||
spec.default
|
||||
.as_ref()
|
||||
.ok_or_else(|| format!("{block}: no shape for {key}"))?
|
||||
};
|
||||
let mut shape = VoxelShape::empty();
|
||||
for bounds in boxes {
|
||||
let part = VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(bounds[0], bounds[1], bounds[2]),
|
||||
Vec3::new(bounds[3], bounds[4], bounds[5]),
|
||||
))?;
|
||||
shape = shape.join_unoptimized(&part, ShapeOp::OR)?;
|
||||
}
|
||||
shapes.push(shape);
|
||||
}
|
||||
if used.len() != spec.values.len() {
|
||||
return Err(
|
||||
format!("{block}: shape values include an unknown property value tuple").into(),
|
||||
);
|
||||
}
|
||||
Ok(shapes)
|
||||
}
|
||||
|
||||
/// A block being added: its declaration and where its states go.
|
||||
struct NewBlock<'a> {
|
||||
decl: &'a Block,
|
||||
@@ -575,6 +631,7 @@ struct NewBlock<'a> {
|
||||
/// The template's item, if it has one.
|
||||
item: Option<String>,
|
||||
shapes: Option<Vec<VoxelShape>>,
|
||||
collision_shapes: Option<Vec<VoxelShape>>,
|
||||
default: u32,
|
||||
/// The declared state properties' combinations, every property at its
|
||||
/// default first (one empty combination when the block declares none):
|
||||
@@ -885,50 +942,7 @@ impl<'a> Plan<'a> {
|
||||
let states = range(&decl.template).ok_or_else(|| {
|
||||
format!("{}: template {} is not a block", decl.id, decl.template)
|
||||
})?;
|
||||
let shapes = if let Some(spec) = &decl.shapes {
|
||||
let Some(J::Arr(rows)) = facts.get("states") else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut used = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for id in states.clone() {
|
||||
let J::Arr(row) = &rows[id as usize] else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(J::Str(value)) = row
|
||||
.get(4)
|
||||
.and_then(|x| x.get("properties"))
|
||||
.and_then(|x| x.get(&spec.property))
|
||||
else {
|
||||
return Err(format!(
|
||||
"{}: template state lacks property {}",
|
||||
decl.id, spec.property
|
||||
)
|
||||
.into());
|
||||
};
|
||||
let boxes = spec.values.get(value).ok_or_else(|| {
|
||||
format!("{}: no shape for {}={value}", decl.id, spec.property)
|
||||
})?;
|
||||
used.insert(value.as_str());
|
||||
let mut shape = VoxelShape::empty();
|
||||
for b in boxes {
|
||||
let part = VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(b[0], b[1], b[2]),
|
||||
Vec3::new(b[3], b[4], b[5]),
|
||||
))?;
|
||||
shape = shape.join_unoptimized(&part, ShapeOp::OR)?;
|
||||
}
|
||||
out.push(shape);
|
||||
}
|
||||
if used.len() != spec.values.len() {
|
||||
return Err(format!(
|
||||
"{}: shape values include an unknown {} value",
|
||||
decl.id, spec.property
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Some(out)
|
||||
} else if let Some(size) = decl.thickness {
|
||||
let pipe_shapes = if let Some(size) = decl.thickness {
|
||||
let Some(J::Arr(rows)) = facts.get("states") else {
|
||||
unreachable!()
|
||||
};
|
||||
@@ -1032,6 +1046,20 @@ impl<'a> Plan<'a> {
|
||||
.map(|(name, property)| (name.clone(), property.values().len() as u32))
|
||||
.collect();
|
||||
let order = block_order(&states, properties, &declared, combos.len() as u32);
|
||||
let resolve = |spec: &BlockShapes| {
|
||||
resolve_shapes(spec, &decl.id, &states, &order, &combos, properties)
|
||||
};
|
||||
let shapes = if let Some(spec) = &decl.shapes {
|
||||
Some(resolve(spec)?)
|
||||
} else {
|
||||
pipe_shapes.map(|shapes: Vec<VoxelShape>| {
|
||||
order
|
||||
.iter()
|
||||
.map(|&(state, _)| shapes[state as usize].clone())
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let collision_shapes = decl.collision_shapes.as_ref().map(resolve).transpose()?;
|
||||
// The lookup from (template state, combination) to the state's row.
|
||||
let mut rows = vec![0; order.len()];
|
||||
for (position, &(state, combo)) in order.iter().enumerate() {
|
||||
@@ -1056,6 +1084,7 @@ impl<'a> Plan<'a> {
|
||||
rename,
|
||||
item: None,
|
||||
shapes,
|
||||
collision_shapes,
|
||||
default,
|
||||
combos,
|
||||
order,
|
||||
@@ -1190,7 +1219,7 @@ impl<'a> Plan<'a> {
|
||||
// One row per template state per combination the block declares,
|
||||
// so its states stay one run in registry order.
|
||||
for (next, (id, combo)) in (b.rename.first..).zip(b.state_combinations()) {
|
||||
let t = (id - b.rename.states.start) as usize;
|
||||
let state_index = (next - b.rename.first) as usize;
|
||||
let mut row = list[id as usize].clone();
|
||||
for r in &renames {
|
||||
row.rename(r, ids);
|
||||
@@ -1205,7 +1234,7 @@ impl<'a> Plan<'a> {
|
||||
}
|
||||
self.override_gameplay(b, combo, &mut row);
|
||||
if let Some(shapes) = &b.shapes {
|
||||
let shape = &shapes[t];
|
||||
let shape = &shapes[state_index];
|
||||
let boxes = shape
|
||||
.boxes()
|
||||
.iter()
|
||||
@@ -1227,6 +1256,9 @@ impl<'a> Plan<'a> {
|
||||
.collect();
|
||||
row.set("outline", J::Arr(boxes));
|
||||
row.set("outline_edges", J::Arr(shape_edges(shape)));
|
||||
}
|
||||
if let Some(shapes) = b.collision_shapes.as_ref().or(b.shapes.as_ref()) {
|
||||
let shape = &shapes[state_index];
|
||||
row.set(
|
||||
"full_collision",
|
||||
J::Bool(!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?),
|
||||
@@ -1567,22 +1599,29 @@ impl<'a> Plan<'a> {
|
||||
return Err("collision table has no shapes".into());
|
||||
};
|
||||
for block in &self.blocks {
|
||||
if let Some(shapes) = &block.shapes {
|
||||
// One shape per template state, shared by every combination
|
||||
// of it: the geometry is the template's, the properties are
|
||||
// the block's.
|
||||
let ids: Vec<i32> = shapes
|
||||
.iter()
|
||||
.map(|shape| {
|
||||
if let Some(shapes) = block.collision_shapes.as_ref().or(block.shapes.as_ref()) {
|
||||
let mut encoded = BTreeMap::new();
|
||||
for (offset, shape) in shapes.iter().enumerate() {
|
||||
let key: Vec<_> = shape
|
||||
.boxes()
|
||||
.iter()
|
||||
.map(|bounds| {
|
||||
[
|
||||
bounds.min(Axis::X),
|
||||
bounds.min(Axis::Y),
|
||||
bounds.min(Axis::Z),
|
||||
bounds.max(Axis::X),
|
||||
bounds.max(Axis::Y),
|
||||
bounds.max(Axis::Z),
|
||||
]
|
||||
.map(f64::to_bits)
|
||||
})
|
||||
.collect();
|
||||
let id = *encoded.entry(key).or_insert_with(|| {
|
||||
entries.push(encode_shape(shape));
|
||||
entries.len() as i32 - 1
|
||||
})
|
||||
.collect();
|
||||
for (at, &shape) in ids.iter().enumerate() {
|
||||
for combination in 0..block.rename.fanout {
|
||||
let row = block.rename.row(at as u32, combination);
|
||||
indices.push((block.rename.first as usize + row as usize, shape));
|
||||
}
|
||||
});
|
||||
indices.push((block.rename.first as usize + offset, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2543,6 +2543,112 @@ fn block_base_of(tag: &str, declarations: &[&str], facts: &str) -> (PathBuf, Vec
|
||||
(base, mods)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_shapes_follow_final_state_order_and_separate_collision() {
|
||||
let declaration = r#"{"template":"minecraft:test","item":false,
|
||||
"state_properties":{"active":{"type":"boolean"}},
|
||||
"shapes":{"properties":["axis","active"],
|
||||
"values":{"x,true":[[0,0,0,0.5,1,1]]},"default":[[0,0,0,1,1,1]]},
|
||||
"collision_shapes":{"properties":["active","axis"],
|
||||
"values":{"true,x":[[0,0,0,0.25,1,1]]},"default":[]}}"#;
|
||||
let (base, mods) = block_base("tuple-shapes", &[declaration]);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
let block = &plan.blocks[0];
|
||||
let shapes = block.shapes.as_ref().unwrap();
|
||||
let collisions = block.collision_shapes.as_ref().unwrap();
|
||||
assert_eq!(shapes.len(), 4);
|
||||
assert_eq!(collisions.len(), 4);
|
||||
for (index, (template, combo)) in block.state_combinations().enumerate() {
|
||||
let special = template == 0
|
||||
&& combo
|
||||
.iter()
|
||||
.any(|(name, value)| name == "active" && value == "true");
|
||||
assert_eq!(
|
||||
shapes[index].bounds().unwrap().max(Axis::X),
|
||||
if special { 0.5 } else { 1.0 }
|
||||
);
|
||||
assert_eq!(collisions[index].is_empty(), !special);
|
||||
if special {
|
||||
assert_eq!(collisions[index].bounds().unwrap().max(Axis::X), 0.25);
|
||||
}
|
||||
}
|
||||
let mut gameplay: J =
|
||||
serde_json::from_slice(&fs::read(base.join("gameplay-facts.json")).unwrap()).unwrap();
|
||||
plan.json("gameplay-facts.json", &mut gameplay).unwrap();
|
||||
let Some(J::Arr(rows)) = gameplay.get("states") else {
|
||||
panic!("states")
|
||||
};
|
||||
for (index, row) in rows[2..].iter().enumerate() {
|
||||
assert_eq!(row.get("full_collision"), Some(&J::Bool(false)));
|
||||
let Some(J::Arr(outline)) = row.get("outline") else {
|
||||
panic!("outline")
|
||||
};
|
||||
assert_eq!(outline.len(), shapes[index].boxes().len());
|
||||
}
|
||||
let mut tag = Tag::Compound(Compound::from([
|
||||
(
|
||||
JavaString::from("states"),
|
||||
Tag::List(vec![test_state_row("x"), test_state_row("y")]),
|
||||
),
|
||||
(JavaString::from("shapes"), Tag::List(Vec::new())),
|
||||
(
|
||||
JavaString::from("cached_shapes"),
|
||||
Tag::IntArray(vec![-1, -1]),
|
||||
),
|
||||
]));
|
||||
plan.nbt(&mut tag);
|
||||
plan.collision_shapes(&mut tag).unwrap();
|
||||
let Some(Tag::List(entries)) = tag.get("shapes") else {
|
||||
panic!("shapes")
|
||||
};
|
||||
let Some(Tag::IntArray(mapping)) = tag.get("cached_shapes") else {
|
||||
panic!("mapping")
|
||||
};
|
||||
for (index, shape) in collisions.iter().enumerate() {
|
||||
assert_eq!(entries[mapping[2 + index] as usize], encode_shape(shape));
|
||||
}
|
||||
fs::remove_dir_all(base.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_only_preserves_outline_and_shape_typos_are_errors() {
|
||||
let declaration = r#"{"template":"minecraft:test","collision_shapes":{"default":[]}}"#;
|
||||
let (base, mods) = block_base("collision-only", &[declaration]);
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
assert!(plan.blocks[0].shapes.is_none());
|
||||
assert!(
|
||||
plan.blocks[0]
|
||||
.collision_shapes
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(VoxelShape::is_empty)
|
||||
);
|
||||
let mut gameplay: J =
|
||||
serde_json::from_str(r#"{"states":[{"outline":[[0,0,0,1,1,1]]},{"outline":[]}]}"#).unwrap();
|
||||
plan.json("gameplay-facts.json", &mut gameplay).unwrap();
|
||||
let Some(J::Arr(rows)) = gameplay.get("states") else {
|
||||
panic!("states")
|
||||
};
|
||||
assert_eq!(rows[0].get("outline"), rows[2].get("outline"));
|
||||
assert_eq!(rows[1].get("outline"), rows[3].get("outline"));
|
||||
assert_eq!(rows[2].get("full_collision"), Some(&J::Bool(false)));
|
||||
fs::remove_dir_all(base.parent().unwrap()).unwrap();
|
||||
for (index, table) in [
|
||||
r#"{"properties":["axis","typo"],"default":[]}"#,
|
||||
r#"{"properties":["axis"],"values":{"bad":[]},"default":[]}"#,
|
||||
r#"{"properties":["axis"],"values":{"x":[]}}"#,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let declaration = format!(r#"{{"template":"minecraft:test","collision_shapes":{table}}}"#);
|
||||
let (base, mods) = block_base(&format!("collision-typo-{index}"), &[&declaration]);
|
||||
assert!(Plan::new(&base, &mods).is_err(), "{table}");
|
||||
fs::remove_dir_all(base.parent().unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A block's states are its template's times the combinations of the state
|
||||
/// properties it declares, in a fixed order with every default first.
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct NativeClientHooks {
|
||||
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
|
||||
pub living_jump: fn(u32, &str),
|
||||
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
|
||||
pub ingredient_test: fn(&str, &Map<String, Value>, &ItemStack, &mut bool),
|
||||
}
|
||||
|
||||
static HOOKS: OnceLock<NativeClientHooks> = OnceLock::new();
|
||||
@@ -94,3 +95,14 @@ pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<
|
||||
(hooks.ingredient)(kind, data, items);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ingredient_test(
|
||||
kind: &str,
|
||||
data: &Map<String, Value>,
|
||||
stack: &ItemStack,
|
||||
accept: &mut bool,
|
||||
) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.ingredient_test)(kind, data, stack, accept);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,66 @@ use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
/// A native mod's book category for one custom recipe type.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RecipeBookCategory {
|
||||
/// A namespaced category id outside `minecraft:`.
|
||||
pub id: String,
|
||||
/// A custom type present in the current recipe table.
|
||||
pub recipe_type: String,
|
||||
/// A registered item id used as the category's icon.
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
/// Validate declarations once per recipe table. The first valid declaration
|
||||
/// owns its category id and recipe type; vanilla categories are never replaced.
|
||||
pub fn mod_categories(
|
||||
declarations: Vec<RecipeBookCategory>,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
) -> HashMap<String, RecipeBookCategory> {
|
||||
let valid_id = |id: &str| {
|
||||
id.split_once(':').is_some_and(|(namespace, path)| {
|
||||
!namespace.is_empty()
|
||||
&& !path.is_empty()
|
||||
&& namespace.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.-".contains(&byte)
|
||||
})
|
||||
&& path.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"/_.-".contains(&byte)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut categories = HashMap::new();
|
||||
let mut ids = BTreeSet::new();
|
||||
for category in declarations {
|
||||
let reason = if !valid_id(&category.id) || category.id.starts_with("minecraft:") {
|
||||
Some("invalid or reserved category id")
|
||||
} else if !valid_id(&category.recipe_type)
|
||||
|| category.recipe_type.starts_with("minecraft:")
|
||||
|| !recipes.custom.contains_key(&category.recipe_type)
|
||||
{
|
||||
Some("unknown or vanilla recipe type")
|
||||
} else if !valid_id(&category.icon)
|
||||
|| category.icon == "minecraft:air"
|
||||
|| items.get(&category.icon).is_none()
|
||||
{
|
||||
Some("missing or unknown icon item")
|
||||
} else if ids.contains(&category.id) || categories.contains_key(&category.recipe_type) {
|
||||
Some("duplicate category id or recipe type")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
log::warn!("recipe book category {:?} skipped: {reason}", category);
|
||||
continue;
|
||||
}
|
||||
ids.insert(category.id.clone());
|
||||
categories.insert(category.recipe_type.clone(), category);
|
||||
}
|
||||
categories
|
||||
}
|
||||
|
||||
/// `RecipeBookType`: the menus with a recipe book and their own settings.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum BookType {
|
||||
@@ -415,7 +475,7 @@ impl Book {
|
||||
/// `ingredient`'s items as its display resolves them (the cycling order
|
||||
/// of ghost slots and the overlay).
|
||||
pub fn items_of(&self, ingredient: &Ingredient) -> Vec<String> {
|
||||
ingredient.1.clone()
|
||||
ingredient.order.clone()
|
||||
}
|
||||
/// `RecipeBookComponent.fillGhostRecipe` of a book type: the result and the
|
||||
/// ingredients faded into the menu's slots, in the order the component
|
||||
@@ -984,6 +1044,83 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mod_category_mapping_validates_ids_icons_and_conflicts() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-book-categories-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(
|
||||
root.join("items.json"),
|
||||
r#"{"minecraft:stone":{},"mymod:crusher":{},"minecraft:air":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
|
||||
let recipes = Recipes::from_sources(
|
||||
&root,
|
||||
&items,
|
||||
&PackOverlay::NONE,
|
||||
vec![
|
||||
(
|
||||
"mymod:crush".into(),
|
||||
serde_json::json!({"type":"mymod:crusher"}),
|
||||
),
|
||||
(
|
||||
"mymod:mix".into(),
|
||||
serde_json::json!({"type":"mymod:mixer"}),
|
||||
),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let declare = |id: &str, recipe_type: &str, icon: &str| RecipeBookCategory {
|
||||
id: id.into(),
|
||||
recipe_type: recipe_type.into(),
|
||||
icon: icon.into(),
|
||||
};
|
||||
let crusher = declare("mymod:crushing", "mymod:crusher", "mymod:crusher");
|
||||
let mixer = declare("mymod:mixing", "mymod:mixer", "minecraft:stone");
|
||||
let mapped = mod_categories(
|
||||
vec![
|
||||
declare("mymod:crushing", "mymod:crusher", ""),
|
||||
crusher.clone(),
|
||||
declare("mymod:duplicate", "mymod:crusher", "minecraft:stone"),
|
||||
declare("mymod:crushing", "mymod:mixer", "minecraft:stone"),
|
||||
mixer.clone(),
|
||||
],
|
||||
&recipes,
|
||||
&items,
|
||||
);
|
||||
assert_eq!(mapped.len(), 2);
|
||||
assert_eq!(mapped.get("mymod:crusher"), Some(&crusher));
|
||||
assert_eq!(mapped.get("mymod:mixer"), Some(&mixer));
|
||||
assert!(!mapped.contains_key("minecraft:crafting"));
|
||||
for invalid in [
|
||||
declare(
|
||||
"minecraft:crafting_misc",
|
||||
"mymod:crusher",
|
||||
"minecraft:stone",
|
||||
),
|
||||
declare("bare", "mymod:crusher", "minecraft:stone"),
|
||||
declare("mymod:", "mymod:crusher", "minecraft:stone"),
|
||||
declare(":category", "mymod:crusher", "minecraft:stone"),
|
||||
declare("mymod:Bad", "mymod:crusher", "minecraft:stone"),
|
||||
declare("mymod:bad:id", "mymod:crusher", "minecraft:stone"),
|
||||
declare("mymod:crushing", "mymod:absent", "minecraft:stone"),
|
||||
declare("mymod:crushing", "minecraft:smelting", "minecraft:stone"),
|
||||
declare("mymod:crushing", "mymod:crusher", ""),
|
||||
declare("mymod:crushing", "mymod:crusher", "minecraft:absent"),
|
||||
declare("mymod:crushing", "mymod:crusher", "minecraft:air"),
|
||||
] {
|
||||
assert!(
|
||||
mod_categories(vec![invalid.clone()], &recipes, &items).is_empty(),
|
||||
"{invalid:?}"
|
||||
);
|
||||
}
|
||||
assert!(mod_categories(Vec::new(), &recipes, &items).is_empty());
|
||||
let vanilla = Recipes::from_sources(&root, &items, &PackOverlay::NONE, Vec::new()).unwrap();
|
||||
assert!(mod_categories(vec![crusher], &vanilla, &items).is_empty());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn ing(items: &[&str]) -> Ingredient {
|
||||
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
|
||||
}
|
||||
|
||||
@@ -708,7 +708,7 @@ impl StationRecipes {
|
||||
addition,
|
||||
pattern,
|
||||
..
|
||||
} if i == id => Some((&base.1, &addition.1, pattern)),
|
||||
} if i == id => Some((&base.order, &addition.order, pattern)),
|
||||
_ => None,
|
||||
})?;
|
||||
if base.is_empty() || addition.is_empty() {
|
||||
@@ -1041,13 +1041,13 @@ mod tests {
|
||||
fn smithing_ingredient_parses_holders() {
|
||||
let items = sample_items();
|
||||
let a = ingredient(&serde_json::json!("minecraft:diamond_sword"), &items);
|
||||
assert_eq!(a.1, vec!["minecraft:diamond_sword"]);
|
||||
assert_eq!(a.order, vec!["minecraft:diamond_sword"]);
|
||||
let b = ingredient(
|
||||
&serde_json::json!(["minecraft:diamond_sword", "minecraft:diamond_axe"]),
|
||||
&items,
|
||||
);
|
||||
assert_eq!(
|
||||
b.1,
|
||||
b.order,
|
||||
vec!["minecraft:diamond_sword", "minecraft:diamond_axe"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! A data-pack biome is in the registry a server sends, with its data, at the
|
||||
//! place the chunk encoder numbers it by, and a client rebuilds it from that
|
||||
//! data.
|
||||
use horizon_client::{
|
||||
biome::{BiomeColors, Biomes},
|
||||
chunk::LevelShapes,
|
||||
lan::data::{BIOME_REGISTRY, SyncData, client_biomes},
|
||||
scoreboard::json_to_tag,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Removes the folder when the test ends, passed or not.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource folder with two biomes and 1x1 colormaps, loaded.
|
||||
fn write_base(root: &std::path::Path) -> Biomes {
|
||||
let maps = root.join("assets/minecraft/textures/colormap");
|
||||
let biome_dir = root.join("data/minecraft/worldgen/biome");
|
||||
std::fs::create_dir_all(&maps).unwrap();
|
||||
std::fs::create_dir_all(&biome_dir).unwrap();
|
||||
std::fs::write(root.join("registries.json"), "{}").unwrap();
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
let file = std::fs::File::create(maps.join(format!("{name}.png"))).unwrap();
|
||||
let mut png = png::Encoder::new(file, 1, 1);
|
||||
png.set_color(png::ColorType::Rgb);
|
||||
png.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = png.write_header().unwrap();
|
||||
writer.write_image_data(&[1, 2, 3]).unwrap();
|
||||
}
|
||||
for name in ["plains", "river"] {
|
||||
std::fs::write(biome_dir.join(format!("{name}.json")), "{}").unwrap();
|
||||
}
|
||||
Biomes::load(root).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_biome_is_sent_with_its_data_and_read_back_by_a_client() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sync-biomes-{}", std::process::id()));
|
||||
let _scratch = Scratch(root.clone());
|
||||
let base = write_base(&root);
|
||||
let pack: Value = serde_json::from_str(r##"{"effects":{"grass_color":"#123456"}}"##).unwrap();
|
||||
let world = base.with_packs([("minecraft:tundra_x", &pack)]);
|
||||
let sync =
|
||||
SyncData::load_with(&root, &horizon_world::loot::PackOverlay::NONE, Some(&world)).unwrap();
|
||||
|
||||
// The encoder numbers a biome by its place in this list: none may be
|
||||
// missing, and the pack's is not the first.
|
||||
let registry = &sync.dynamic.registries[BIOME_REGISTRY];
|
||||
let names: Vec<_> = registry.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["minecraft:plains", "minecraft:river", "minecraft:tundra_x"]
|
||||
);
|
||||
assert!(world.list.iter().all(|b| names.contains(&b.name.as_str())));
|
||||
|
||||
// A client that knows the vanilla pack still gets the pack biome's data.
|
||||
let sent = sync.registry_packets(true, &LevelShapes::VANILLA);
|
||||
let biomes = sent.iter().find(|r| r.registry == BIOME_REGISTRY).unwrap();
|
||||
assert!(biomes.entries[0].data.is_none(), "a known one is not sent");
|
||||
assert!(biomes.entries[2].data.is_some(), "the pack's biome is sent");
|
||||
|
||||
// The client builds its table from what arrived: the server's id for
|
||||
// the name, and the pack's colour.
|
||||
let received: Vec<_> = biomes
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| (e.id.clone(), e.data.clone()))
|
||||
.collect();
|
||||
let client = client_biomes(&base, &received).expect("the pack's biome is new to the client");
|
||||
let id = client.id("minecraft:tundra_x");
|
||||
assert_eq!(id, world.id("minecraft:tundra_x"));
|
||||
assert_eq!(client.get(id).grass, 0x123456);
|
||||
assert_ne!(
|
||||
client.get(id).grass,
|
||||
client.get(client.id("minecraft:plains")).grass
|
||||
);
|
||||
|
||||
// Nothing new to a client that knows them all: it keeps its own table.
|
||||
// even when the server sends data for them (another grass colour here).
|
||||
let other: Value = serde_json::from_str(r##"{"effects":{"grass_color":"#654321"}}"##).unwrap();
|
||||
let other = json_to_tag(&other);
|
||||
let known: Vec<_> = received
|
||||
.iter()
|
||||
.filter(|(n, _)| base.contains(n))
|
||||
.map(|(n, _)| (n.clone(), Some(other.clone())))
|
||||
.collect();
|
||||
assert!(client_biomes(&base, &known).is_none());
|
||||
|
||||
// Beside a new biome, a known one keeps the game's colour.
|
||||
let mut mixed = known.clone();
|
||||
mixed.extend(received.iter().filter(|(n, _)| !base.contains(n)).cloned());
|
||||
let client = client_biomes(&base, &mixed).unwrap();
|
||||
let plains = base.get(base.id("minecraft:plains")).grass;
|
||||
assert_ne!(plains, 0x654321);
|
||||
assert_eq!(client.get(client.id("minecraft:plains")).grass, plains);
|
||||
assert_eq!(client.get(client.id("minecraft:tundra_x")).grass, 0x123456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recolouring_twice_starts_from_the_biome_files_each_time() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-recolor-biomes-{}", std::process::id()));
|
||||
let _scratch = Scratch(root.clone());
|
||||
let base = write_base(&root);
|
||||
let grass = |b: &Biomes| b.get(b.id("minecraft:plains")).grass;
|
||||
let darker = |_: &str, c: &mut BiomeColors| c.grass -= 0x10;
|
||||
let once = base.recolored_from(&base, darker).expect("colours changed");
|
||||
assert_eq!(grass(&once), grass(&base) - 0x10);
|
||||
// The same colours again, asked from the base: nothing to rebuild.
|
||||
assert!(base.recolored_from(&once, darker).is_none());
|
||||
// Other colours replace the first ones rather than add to them.
|
||||
let lighter = |_: &str, c: &mut BiomeColors| c.grass += 0x10;
|
||||
let next = base.recolored_from(&once, lighter).unwrap();
|
||||
assert_eq!(grass(&next), grass(&base) + 0x10);
|
||||
}
|
||||
@@ -46,3 +46,35 @@ fn movement_input_writes_back_all_fields() {
|
||||
);
|
||||
assert_eq!((forward, strafe, jump, sneak), (-0.5, 0.25, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_book_categories_accept_mod_declarations() {
|
||||
use horizon_client::mod_api::{
|
||||
RecipeBookCategoriesEvent, RecipeBookCategory, dispatch_recipe_book_categories,
|
||||
};
|
||||
struct TestHost;
|
||||
impl Host for TestHost {}
|
||||
struct Categories;
|
||||
impl Hooks for Categories {
|
||||
fn recipe_book_categories(
|
||||
&self,
|
||||
_host: &dyn Host,
|
||||
event: &mut RecipeBookCategoriesEvent<'_>,
|
||||
) {
|
||||
event.categories.push(RecipeBookCategory {
|
||||
id: "mymod:crushing".into(),
|
||||
recipe_type: "mymod:crusher".into(),
|
||||
icon: "minecraft:stone".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut declarations = Vec::new();
|
||||
dispatch_recipe_book_categories(&TestHost, &[], &mut declarations);
|
||||
assert!(declarations.is_empty());
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Categories)];
|
||||
dispatch_recipe_book_categories(&TestHost, &mods, &mut declarations);
|
||||
assert_eq!(declarations.len(), 1);
|
||||
assert_eq!(declarations[0].id, "mymod:crushing");
|
||||
assert_eq!(declarations[0].recipe_type, "mymod:crusher");
|
||||
assert_eq!(declarations[0].icon, "minecraft:stone");
|
||||
}
|
||||
@@ -396,6 +396,74 @@ impl Transport for Http {
|
||||
}
|
||||
}
|
||||
|
||||
/// Item files in a folder instead of the service (`HORIZON_COSMETICS_LOCAL`): every player asked
|
||||
/// for wears the first item of each slot found there, by file name. For artists and the bench.
|
||||
pub struct Local {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Local {
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
/// The first item file of each slot, by file name.
|
||||
fn worn(&self) -> Vec<String> {
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut slots = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
for file in files {
|
||||
if let Some(item) = std::fs::read(&file).ok().and_then(|b| Item::parse(&b).ok())
|
||||
&& !slots.contains(&item.slot)
|
||||
&& file.file_stem().is_some_and(|s| *s == *item.id)
|
||||
{
|
||||
slots.push(item.slot);
|
||||
ids.push(item.id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for Local {
|
||||
fn post(&self, _: &str, body: &[u8]) -> Result<Vec<u8>, String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Asked {
|
||||
players: Vec<String>,
|
||||
}
|
||||
let asked: Asked = serde_json::from_slice(body).map_err(|e| e.to_string())?;
|
||||
let refs: Vec<_> = self
|
||||
.worn()
|
||||
.into_iter()
|
||||
.map(|id| serde_json::json!({"id": id, "hash": "local"}))
|
||||
.collect();
|
||||
let players: serde_json::Map<String, serde_json::Value> = asked
|
||||
.players
|
||||
.into_iter()
|
||||
.map(|p| (p, serde_json::Value::Array(refs.clone())))
|
||||
.collect();
|
||||
Ok(serde_json::json!({"ttl": 60, "players": players})
|
||||
.to_string()
|
||||
.into_bytes())
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> Result<Vec<u8>, String> {
|
||||
let id = path
|
||||
.trim_start_matches("/v1/items/")
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
std::fs::read(self.dir.join(format!("{id}.json"))).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fetch_tests.rs"]
|
||||
mod tests;
|
||||
@@ -11,9 +11,26 @@ mod fetch;
|
||||
mod item;
|
||||
mod pose;
|
||||
|
||||
pub use fetch::{BATCH, Clock, Cosmetics, Frame, Http, Transport, Worn};
|
||||
pub use fetch::{BATCH, Clock, Cosmetics, Frame, Http, Local, Transport, Worn};
|
||||
pub use item::{
|
||||
Animation, Axis, Bone, Error, FORMAT, Face, Item, MAX_PARTS, MAX_REACH, MAX_TEXTURE,
|
||||
MAX_VERTICES, Motion, Part, Slot, Texture,
|
||||
};
|
||||
pub use pose::{Pose, pose_matrix};
|
||||
|
||||
/// Cosmetics stay off until this is true: no request, no drawing, no settings.
|
||||
pub const ON_BY_DEFAULT: bool = false;
|
||||
|
||||
/// The cosmetics service (`HORIZON_COSMETICS_API` overrides it).
|
||||
pub const API: &str = "https://cosmetics.playhorizon.dev";
|
||||
|
||||
/// Whether cosmetics run: `ON_BY_DEFAULT`, or `HORIZON_COSMETICS=1` for one run (development,
|
||||
/// the bench). `HORIZON_COSMETICS=0` keeps them off.
|
||||
pub fn enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| match std::env::var("HORIZON_COSMETICS").as_deref() {
|
||||
Ok("1") => true,
|
||||
Ok("0") => false,
|
||||
_ => ON_BY_DEFAULT,
|
||||
})
|
||||
}
|
||||
@@ -362,6 +362,14 @@ fn registry_order(root: &Path, order: &[Arc<str>]) -> Vec<Arc<str>> {
|
||||
}
|
||||
/// Item ids standing for a loot table's `minecraft:dynamic` entries.
|
||||
const DYNAMIC: &str = "#dynamic:";
|
||||
/// The item id standing for a chosen `minecraft:loot_table` entry, whose
|
||||
/// table then rolls in full (`NestedLootTable`).
|
||||
const NESTED: &str = "#loot_table";
|
||||
thread_local! {
|
||||
/// The nested tables rolling now, outermost first: a table never rolls
|
||||
/// inside itself (`LootContext.pushVisitedElement`).
|
||||
static NESTED_ROLLS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
pub struct Items {
|
||||
pub items: HashMap<Arc<str>, ItemInfo>,
|
||||
pub order: Vec<Arc<str>>,
|
||||
@@ -1231,7 +1239,11 @@ impl Items {
|
||||
explosion_radius: None,
|
||||
this_entity: false,
|
||||
};
|
||||
ctx.evaluate(table, random)
|
||||
// LootTable.getRandomItemsRaw visits the table itself first.
|
||||
NESTED_ROLLS.with(|v| v.borrow_mut().push(id.to_owned()));
|
||||
let out = ctx.evaluate(table, random);
|
||||
NESTED_ROLLS.with(|v| v.borrow_mut().pop());
|
||||
out
|
||||
}
|
||||
/// The server random sequence a loot table draws from (`random_sequence`):
|
||||
/// vanilla's `LootContext.Builder.create` uses it instead of the level random.
|
||||
@@ -1646,6 +1658,21 @@ impl LootContext<'_> {
|
||||
));
|
||||
true
|
||||
}
|
||||
// NestedLootTable: chosen by its weight like an item; `choose`
|
||||
// then rolls its table (an id or inline) and applies its functions.
|
||||
"minecraft:loot_table" => {
|
||||
out.push((
|
||||
e["weight"].as_i64().unwrap_or(1) as i32,
|
||||
ItemStack {
|
||||
item: Arc::from(NESTED),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
e.clone(),
|
||||
));
|
||||
true
|
||||
}
|
||||
"minecraft:empty" => {
|
||||
out.push((
|
||||
e["weight"].as_i64().unwrap_or(1) as i32,
|
||||
@@ -1691,9 +1718,46 @@ impl LootContext<'_> {
|
||||
chosen
|
||||
};
|
||||
let (_, mut stack, modifiers) = candidates.swap_remove(pick);
|
||||
if &*stack.item == NESTED {
|
||||
self.nested(&modifiers, random, produced);
|
||||
return;
|
||||
}
|
||||
self.modifiers(&modifiers, &mut stack, random);
|
||||
produced.push(stack);
|
||||
}
|
||||
/// `NestedLootTable.createItemStack`: the entry's table rolls in full
|
||||
/// (its pools and its own functions), then the entry's functions apply to
|
||||
/// each stack. A table already rolling further out adds nothing.
|
||||
fn nested<R: RandomSource>(
|
||||
&self,
|
||||
entry: &Value,
|
||||
random: &mut R,
|
||||
produced: &mut Vec<ItemStack>,
|
||||
) {
|
||||
let (name, table) = match &entry["value"] {
|
||||
Value::String(name) => (name.as_str(), self.items.loot.get(name.as_str())),
|
||||
inline @ Value::Object(_) => ("<inline>", Some(inline)),
|
||||
_ => return,
|
||||
};
|
||||
let Some(table) = table else { return };
|
||||
let entered = NESTED_ROLLS.with(|v| {
|
||||
let mut v = v.borrow_mut();
|
||||
let fresh = !v.iter().any(|n| n == name);
|
||||
if fresh {
|
||||
v.push(name.to_owned());
|
||||
}
|
||||
fresh
|
||||
});
|
||||
if !entered {
|
||||
return;
|
||||
}
|
||||
let stacks = self.evaluate(table, random);
|
||||
NESTED_ROLLS.with(|v| v.borrow_mut().pop());
|
||||
for mut stack in stacks {
|
||||
self.modifiers(&entry["modifier"], &mut stack, random);
|
||||
produced.push(stack);
|
||||
}
|
||||
}
|
||||
/// An enchantment function's `options` (`HolderSet`): a tag, a list or
|
||||
/// one id; every enchantment when absent (`registryAccess().listElements`).
|
||||
fn enchant_pool(e: &crate::enchant::Enchantments, options: &Value) -> Vec<Arc<str>> {
|
||||
@@ -2206,6 +2270,40 @@ mod tests {
|
||||
assert!(!items.item_in_tag("test:missing", "test:apple"));
|
||||
}
|
||||
|
||||
/// `NestedLootTable`: a `loot_table` entry, by id or inline, rolls that
|
||||
/// table in full and applies its own functions to each stack; a table
|
||||
/// never rolls inside itself.
|
||||
#[test]
|
||||
fn loot_table_entries_roll_their_table() {
|
||||
let mut items = Items::for_tests(HashMap::new());
|
||||
let diamond = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:diamond"}]}]});
|
||||
items.loot = Arc::new(
|
||||
[
|
||||
("test:inner", diamond.clone()),
|
||||
("test:outer", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
|
||||
"value":"test:inner","modifier":[{"type":"minecraft:set_count","count":3}]}]}]})),
|
||||
("test:inline", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
|
||||
"value":diamond}]}]})),
|
||||
("test:loop", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
|
||||
"value":"test:loop"}]},{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stick"}]}]})),
|
||||
]
|
||||
.map(|(id, t)| (id.to_owned(), t))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let roll = |id: &str| {
|
||||
items
|
||||
.loot_table(id, false, None, &mut LegacyRandom::new(1))
|
||||
.into_iter()
|
||||
.map(|s| format!("{} {}", s.item, s.count))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(roll("test:outer"), ["minecraft:diamond 3"]);
|
||||
assert_eq!(roll("test:inline"), ["minecraft:diamond 1"]);
|
||||
// `test:loop` is already rolling: its own entry adds nothing.
|
||||
assert_eq!(roll("test:loop"), ["minecraft:stick 1"]);
|
||||
}
|
||||
|
||||
/// `Blocks.wallVariant`: a wall block without a table of its own drops
|
||||
/// from its standing block's (vanilla's tables: `blocks/torch` for
|
||||
/// `wall_torch`, `blocks/oak_sign` for `oak_wall_sign`...).
|
||||
|
||||
@@ -117,12 +117,56 @@ impl RandomTicks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-state outline and collision boxes, selected by one state property.
|
||||
/// Boxes selected by one property (legacy) or an ordered tuple of properties.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BlockShapes {
|
||||
#[serde(default)]
|
||||
pub property: String,
|
||||
#[serde(default)]
|
||||
pub properties: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub values: BTreeMap<String, Vec<[f64; 6]>>,
|
||||
pub default: Option<Vec<[f64; 6]>>,
|
||||
}
|
||||
|
||||
impl BlockShapes {
|
||||
pub fn property_names(&self) -> Vec<&str> {
|
||||
if self.property.is_empty() {
|
||||
self.properties.iter().map(String::as_str).collect()
|
||||
} else {
|
||||
vec![self.property.as_str()]
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), String> {
|
||||
let names = self.property_names();
|
||||
if (!self.property.is_empty() && !self.properties.is_empty())
|
||||
|| names.iter().any(|name| !state_name(name))
|
||||
|| names.iter().collect::<BTreeSet<_>>().len() != names.len()
|
||||
|| (names.is_empty() && !self.values.is_empty())
|
||||
|| (self.values.is_empty() && self.default.is_none())
|
||||
{
|
||||
return Err("shapes needs distinct properties and values, or a default".into());
|
||||
}
|
||||
for key in self.values.keys() {
|
||||
let values: Vec<_> = key.split(',').collect();
|
||||
if values.len() != names.len() || values.iter().any(|value| !state_name(value)) {
|
||||
return Err(format!("invalid shape key {key:?}: one value per property"));
|
||||
}
|
||||
}
|
||||
for boxes in self.values.values().chain(self.default.iter()) {
|
||||
if boxes.len() > 16
|
||||
|| boxes.iter().any(|bounds| {
|
||||
bounds.iter().any(|value| !value.is_finite())
|
||||
|| (0..3).any(|axis| bounds[axis] > bounds[axis + 3])
|
||||
})
|
||||
{
|
||||
return Err("shape needs at most 16 finite boxes with ordered bounds".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A state property a block declares on its own, beside its template's.
|
||||
@@ -163,6 +207,7 @@ struct BlockFile {
|
||||
#[serde(default)]
|
||||
properties: BlockProperties,
|
||||
shapes: Option<BlockShapes>,
|
||||
collision_shapes: Option<BlockShapes>,
|
||||
#[serde(default)]
|
||||
state_properties: BTreeMap<String, StateProperty>,
|
||||
#[serde(default)]
|
||||
@@ -196,6 +241,7 @@ pub struct Block {
|
||||
pub template: String,
|
||||
pub properties: BlockProperties,
|
||||
pub shapes: Option<BlockShapes>,
|
||||
pub collision_shapes: Option<BlockShapes>,
|
||||
/// State properties of its own, which its template does not have.
|
||||
pub state_properties: BTreeMap<String, StateProperty>,
|
||||
/// State properties overlaid on the template default.
|
||||
@@ -673,6 +719,7 @@ impl Content {
|
||||
template: file.template,
|
||||
properties: file.properties,
|
||||
shapes: file.shapes,
|
||||
collision_shapes: file.collision_shapes,
|
||||
state_properties: file.state_properties,
|
||||
default_state: file.default_state,
|
||||
item: file.item,
|
||||
@@ -856,19 +903,8 @@ fn check_block(file: &Block) -> Result<(), String> {
|
||||
file.template
|
||||
));
|
||||
}
|
||||
if let Some(shapes) = &file.shapes {
|
||||
if shapes.property.is_empty() || shapes.values.is_empty() {
|
||||
return Err("shapes needs a property and values".into());
|
||||
}
|
||||
for boxes in shapes.values.values() {
|
||||
if boxes.len() > 16
|
||||
|| boxes
|
||||
.iter()
|
||||
.any(|b| b.iter().any(|n| !n.is_finite()) || (0..3).any(|i| b[i] > b[i + 3]))
|
||||
{
|
||||
return Err("shape needs at most 16 finite boxes with ordered bounds".into());
|
||||
}
|
||||
}
|
||||
for shapes in file.shapes.iter().chain(file.collision_shapes.iter()) {
|
||||
shapes.check()?;
|
||||
}
|
||||
for (name, property) in &file.state_properties {
|
||||
if !state_name(name) {
|
||||
@@ -1096,6 +1132,56 @@ capabilities = ["registries"]
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
#[test]
|
||||
fn shape_tables_validate_tuples_defaults_and_collision() {
|
||||
let content = hmod("shape-tuples", MANIFEST_TEXT, &[(
|
||||
"data/ruby/horizon/block/a.json",
|
||||
br#"{"template":"minecraft:stone","shapes":{"properties":["facing","count"],"values":{"north,2":[[0,0,0,1,0.5,1]]},"default":[[0,0,0,1,1,1]]},"collision_shapes":{"default":[]}}"#,
|
||||
)]).unwrap();
|
||||
let block = &content.blocks[0];
|
||||
assert_eq!(
|
||||
block.shapes.as_ref().unwrap().property_names(),
|
||||
["facing", "count"]
|
||||
);
|
||||
assert_eq!(
|
||||
block.collision_shapes.as_ref().unwrap().default,
|
||||
Some(vec![])
|
||||
);
|
||||
for value in [
|
||||
r#"{"property":"axis","properties":["count"],"default":[]}"#,
|
||||
r#"{"properties":["axis","axis"],"default":[]}"#,
|
||||
r#"{"properties":["Axis"],"default":[]}"#,
|
||||
r#"{"properties":["axis","count"],"values":{"x":[]}}"#,
|
||||
r#"{"properties":["axis"],"values":{"":[]}}"#,
|
||||
r#"{"values":{"x":[]},"default":[]}"#,
|
||||
r#"{"default":[[1,0,0,0,1,1]]}"#,
|
||||
] {
|
||||
let shapes: BlockShapes = serde_json::from_str(value).unwrap();
|
||||
assert!(shapes.check().is_err(), "{value}");
|
||||
let declaration =
|
||||
format!(r#"{{"template":"minecraft:stone","collision_shapes":{value}}}"#);
|
||||
assert!(
|
||||
hmod(
|
||||
"bad-collision",
|
||||
MANIFEST_TEXT,
|
||||
&[("data/ruby/horizon/block/a.json", declaration.as_bytes(),)]
|
||||
)
|
||||
.is_err(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
let oversized = BlockShapes {
|
||||
property: String::new(),
|
||||
properties: vec![],
|
||||
values: BTreeMap::new(),
|
||||
default: Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]; 17]),
|
||||
};
|
||||
assert!(oversized.check().is_err());
|
||||
let mut nonfinite = oversized;
|
||||
nonfinite.default = Some(vec![[f64::NAN; 6]]);
|
||||
assert!(nonfinite.check().is_err());
|
||||
}
|
||||
|
||||
fn hmod(name: &str, manifest: &str, files: &[(&str, &[u8])]) -> Result<Content, String> {
|
||||
let dir = scratch(name);
|
||||
let path = dir.join("m.hmod");
|
||||
@@ -1201,6 +1287,7 @@ capabilities = ["registries"]
|
||||
id: "ruby:ruby_block".into(),
|
||||
template: "minecraft:iron_block".into(),
|
||||
shapes: None,
|
||||
collision_shapes: None,
|
||||
state_properties: BTreeMap::new(),
|
||||
default_state: BTreeMap::new(),
|
||||
properties: BlockProperties {
|
||||
|
||||
@@ -76,3 +76,21 @@ does the asking.
|
||||
hash (they never change for one); a file past the limits is left out and not asked for again.
|
||||
- **Offline**: a request that fails leaves every answer as it was, and the next one waits 15 s, then
|
||||
30 s, doubling up to 10 minutes. A player whose item didn't arrive is asked for again after 15 s.
|
||||
|
||||
## In the game
|
||||
|
||||
- **The flag**: `horizon_cosmetics::ON_BY_DEFAULT` is `false`. Until it is turned on nothing runs:
|
||||
no thread, no request, no setting, no drawing. `HORIZON_COSMETICS=1` turns them on for one run
|
||||
(development, the bench); `HORIZON_COSMETICS_API=<url>` points the game at another service, and
|
||||
`HORIZON_COSMETICS_LOCAL=<folder>` reads items from a folder instead: every player and every
|
||||
mannequin wears its first item of each slot (the art and the bench, without the network).
|
||||
- **Settings** (Extras, under "Cosmetics", shown only with the flag on): *My cosmetics* and *Other
|
||||
players' cosmetics*, both on. Off, the game asks for nothing about those players.
|
||||
- **Drawing** (`game/cosmetics.rs`): an item's texture takes one of the skin atlas's 64×64 slots
|
||||
and its boxes are posed on the wearer's bone each frame, through the same GPU skin path as the
|
||||
player model (the CPU path where that is off), lit and culled with the player. A helmet hides a
|
||||
head item, an elytra or cape a back item. The third person view and the
|
||||
inventory's player preview show your own.
|
||||
- **Cost**: a player with nothing to wear adds one cache lookup; with no cosmetic in view the frame
|
||||
does no cosmetics work beyond that. The bench scene `stress_avatars` (300 mannequins, 20×15)
|
||||
measures the cost with `HORIZON_COSMETICS=1 HORIZON_COSMETICS_LOCAL=…` against the same run off.
|
||||
@@ -19,6 +19,7 @@ again restores vanilla's behaviour completely.
|
||||
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` |
|
||||
| Sort order and its clicks | `crates/horizon-client/src/inv_sort.rs` (pure, unit tested) |
|
||||
| The music page's rows, and the pools it filters | `options_ui.rs` (`Page::Music`), `crates/horizon-client/src/music.rs` (`track_groups`, `MusicManager::set_off`), `sound_events.rs` (`sound_except`) |
|
||||
| Cosmetics (show mine, show others'), off with their flag | `extras.rs`, `crates/horizon-client/src/game/cosmetics.rs` ([cosmetics.md](cosmetics.md)) |
|
||||
| The settings page | `options_ui.rs` (`Page::Extras`): **Options → Horizon Settings... → Extras...** |
|
||||
|
||||
The settings are grouped by extra, under a header each, two buttons to a row as
|
||||
|
||||
@@ -155,6 +155,38 @@ signs `template_hanging_sign_rot_0` to `_rot_3`, `template_attached_hanging_sign
|
||||
`minecraft:wall_hanging_signs`, which vanilla's other sign tags and `minecraft:mineable/axe` include. Give the
|
||||
wall form a loot table of its own, or name it `<x>_wall_sign` so that it drops what `<x>_sign`'s table does.
|
||||
|
||||
A block can override its targeting outline with `shapes` and independently override movement
|
||||
collision with `collision_shapes`. Both take the same table format. Boxes are
|
||||
`[minX,minY,minZ,maxX,maxY,maxZ]` in block units (one block is 1), at most 16 per shape.
|
||||
Coordinates must be finite and each minimum must not exceed its maximum. `[]` is an empty shape.
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "minecraft:stone",
|
||||
"state_properties": {
|
||||
"facing": { "type": "enum", "values": ["north", "south"] },
|
||||
"count": { "type": "enum", "values": ["1", "2"] }
|
||||
},
|
||||
"shapes": {
|
||||
"properties": ["facing", "count"],
|
||||
"values": { "north,2": [[0,0,0,1,0.5,1]] },
|
||||
"default": [[0,0,0,1,1,1]]
|
||||
},
|
||||
"collision_shapes": { "default": [] }
|
||||
}
|
||||
```
|
||||
|
||||
`properties` names an ordered tuple of template and/or declared state properties; each `values` key
|
||||
lists their values in that order, separated by commas without spaces. A nonmatching tuple uses
|
||||
`default`. Without `default`, every tuple must be covered. A default-only table gives every state the
|
||||
same shape. The existing `{"property":"axis","values":{"x":[...],"y":[...],"z":[...]}}`
|
||||
format still works; use either `property` or `properties`, not both. Unknown properties, unreachable
|
||||
value tuples, malformed boxes and duplicate property names reject the mod with a load error rather
|
||||
than silently ignoring a typo. Omitted `shapes` preserves the template outline (or the connecting
|
||||
block's `thickness` outline). Omitted `collision_shapes` preserves the existing behavior: a declared
|
||||
outline also becomes collision, otherwise the template collision is retained. An explicit collision
|
||||
table can be used without an outline override, and overrides `thickness` collision too.
|
||||
|
||||
An item copies its template's data components, with `max_stack_size` and
|
||||
`rarity` changed, and any other 26.3 component set or removed in
|
||||
`components`, written as a data component patch is: `minecraft:<type>` sets
|
||||
|
||||
@@ -380,6 +380,14 @@ so the third warning names a `hook`, and `hook = "frame"` and `target = "…::na
|
||||
`HEAD` is named: the interior kinds may carry `cancellable` too, but what a `Break` skips there is less than the body.
|
||||
A mod's `priority` defaults to 0, as horizon-mixins defaults it. Neither warning stops the build.
|
||||
|
||||
### A mod's own ingredient kinds, matching at craft time
|
||||
|
||||
The load-time list only names items. Matching a concrete stack — its components, usually —
|
||||
is `native_ingredient_test(kind, data, stack, accept)`: the ingredient's `test` calls it with the
|
||||
kind and JSON the load-time hook saw, the stack being matched and the verdict, already `true`
|
||||
(the list named the item). Set `accept` to `false` to refuse the stack (`ICustomIngredient.test`).
|
||||
The hook may be called many times a craft; the answer must be the same for the same stack.
|
||||
|
||||
### The mod's crate
|
||||
|
||||
A `[[crate]]` is a Cargo package of the bundle's, staged as `native-crates/<mod>/<crate>/` and added to the
|
||||
@@ -486,6 +494,44 @@ position), `block_entity` (the broken block's entity as it was before the break,
|
||||
vanilla's `time_check` and `weather_check`). The game sets it for a block a player or an explosion breaks and for a
|
||||
mob's death loot; on other rolls `place` is `None`.
|
||||
|
||||
### Recipe book category declarations (first slice of #5501)
|
||||
|
||||
The pending `recipe_book_categories` API fragment adds a declaration hook and a
|
||||
lookup helper. It has no API number until stamping; do not require a guessed
|
||||
hooks version. Trait mods import `RecipeBookCategory` from `horizon_client::mod_api`
|
||||
and append declarations in `Hooks::recipe_book_categories`:
|
||||
|
||||
```rust,ignore
|
||||
event.categories.push(RecipeBookCategory {
|
||||
id: "mymod:crushing".into(),
|
||||
recipe_type: "mymod:crusher".into(),
|
||||
icon: "mymod:crusher".into(),
|
||||
});
|
||||
```
|
||||
|
||||
Mixin bundles use `native_recipe_book_categories(categories)` at the same hook
|
||||
point. `host.recipe_book_category("mymod:crusher")` (the mixin helper is
|
||||
`native_recipe_book_category`) returns the validated declaration, including its
|
||||
icon, or `None`. It does not grant recipes. Declarations are requested lazily at
|
||||
first lookup, cached for that recipe table, and invalidated on a new session,
|
||||
server recipe-content replacement or server `/reload`. No lookup means no hook
|
||||
call or per-frame work. A client validates against its own custom recipes unless
|
||||
`native_datapack_sync` has supplied the server's type (API 120).
|
||||
|
||||
Every field must be a fully namespaced resource id. Category ids and recipe types
|
||||
must be outside `minecraft:`; the type must occur in the current custom recipe
|
||||
table. The icon must name a registered, non-air item. Invalid ids, unknown types,
|
||||
missing/unknown icons, and duplicate category ids or recipe types are logged and
|
||||
skipped, never panicked on. The first valid declaration wins each id and type;
|
||||
an invalid declaration does not reserve either. This slice supports one category
|
||||
per type, not per-recipe category selectors.
|
||||
|
||||
**Not yet book UI support:** custom recipes still have no book displays or
|
||||
unlock/display-id synchronization. This slice deliberately leaves vanilla tabs,
|
||||
search, layouts and unlock behavior unchanged. Adding custom tabs, displaying and
|
||||
searching recipes there, and unlocking them like vanilla remain follow-up work
|
||||
for #5501; a declaration alone will not make a recipe visible.
|
||||
|
||||
### A mod's own ingredient kinds
|
||||
|
||||
A recipe's ingredient that names a type in a namespace outside `minecraft:` reaches
|
||||
|
||||
@@ -354,6 +354,7 @@ class StressSceneTests(unittest.TestCase):
|
||||
self.assertEqual(summons("stress_markings", "horse") + summons("stress_markings", "slime"), 1000)
|
||||
self.assertEqual(summons("stress_frames", "item_frame"), 512)
|
||||
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
|
||||
self.assertEqual(summons("stress_avatars", "mannequin"), 300)
|
||||
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
|
||||
*stress_scenes.origin("stress_empty")))
|
||||
# Only the run's first stress scene sets the game rules.
|
||||
@@ -363,7 +364,7 @@ class StressSceneTests(unittest.TestCase):
|
||||
def test_every_entity_is_near_enough_to_be_drawn_at_rd16(self):
|
||||
# Entity.shouldRenderAtSqrDistance with viewScale 2: a dropped item (0.25) within 32 blocks.
|
||||
from tools import stress_scenes
|
||||
for name in ("stress_mobs", "stress_items", "stress_frames", "stress_armor_stands"):
|
||||
for name in ("stress_mobs", "stress_items", "stress_frames", "stress_armor_stands", "stress_avatars"):
|
||||
_, at, _, _ = stress_scenes.scene(name)
|
||||
eye = (at[0], at[1] + 1.62, at[2])
|
||||
for line in stress_scenes.setup(name):
|
||||
|
||||
@@ -801,6 +801,9 @@ def items_and_loot():
|
||||
cmd('/loot replace block 3 206 3 container.8 loot minecraft:blocks/cobblestone'),
|
||||
cmd('/loot replace entity @s hotbar.5 loot minecraft:blocks/glass'),
|
||||
cmd('/loot spawn 3 210 3 loot minecraft:blocks/stone'),
|
||||
# NestedLootTable: the reward tables pick another table and roll it.
|
||||
cmd('/loot spawn 3 210 3 loot minecraft:chests/trial_chambers/reward'),
|
||||
cmd('/loot give @s loot minecraft:chests/trial_chambers/reward_ominous'),
|
||||
cmd('/loot give @s loot {pools:[{rolls:1,entries:[{type:"minecraft:item",name:"minecraft:apple",functions:[{type:"minecraft:set_count",count:4}]}]}]}'),
|
||||
cmd('/setblock 2 205 2 minecraft:stone'),
|
||||
cmd('/loot give @s mine 2 205 2'),
|
||||
@@ -1746,6 +1749,52 @@ def trial_spawner_passengers():
|
||||
return ops + setup('/fill 1 219 1 13 224 8 air', '/gamerule spawn_mobs false')
|
||||
|
||||
|
||||
def bossbar_list_order():
|
||||
"""CustomBossEvents lists Java HashMap values, with no sorting (#7469)."""
|
||||
ops = [
|
||||
cmd('/bossbar add audit:test "Title"'),
|
||||
cmd('/bossbar set audit:test name "Other"'),
|
||||
cmd('/bossbar add audit:second "Second"'),
|
||||
cmd('/bossbar list'),
|
||||
]
|
||||
for i in range(11):
|
||||
ops.append(cmd(f'/bossbar add audit:grow{i} "Extra {i}"'))
|
||||
for i in range(11):
|
||||
ops.append(cmd(f'/bossbar remove audit:grow{i}'))
|
||||
ops += [
|
||||
cmd('/bossbar list'),
|
||||
cmd('/bossbar remove audit:test'),
|
||||
cmd('/bossbar list'),
|
||||
cmd('/bossbar add audit:test "Again"'),
|
||||
cmd('/bossbar list'),
|
||||
cmd('/bossbar remove audit:test'),
|
||||
cmd('/bossbar remove audit:second'),
|
||||
cmd('/bossbar list'),
|
||||
]
|
||||
return ops
|
||||
|
||||
|
||||
def experience_level_boundaries():
|
||||
"""Exact zero retains progress/total; a negative result resets all three (#7515)."""
|
||||
def observe():
|
||||
return [cmd('/experience query @s levels'),
|
||||
cmd('/experience query @s points'),
|
||||
cmd('/data get entity @s XpLevel'),
|
||||
cmd('/data get entity @s XpP 1000000'),
|
||||
cmd('/data get entity @s XpTotal')]
|
||||
ops = []
|
||||
for start, delta in [(1, -1), (1, -2), (2, -1), (0, -1)]:
|
||||
ops += setup('/experience set @s 0 levels', '/experience set @s 0 points',
|
||||
'/experience add @s -2147483647 points', '/experience add @s 10 points',
|
||||
'/experience set @s 1 levels', '/experience set @s 3 points',
|
||||
f'/experience set @s {start} levels')
|
||||
ops += observe()
|
||||
ops.append(cmd(f'/experience add @s {delta} levels'))
|
||||
ops += observe()
|
||||
return ops + setup('/experience set @s 0 levels', '/experience set @s 0 points',
|
||||
'/experience add @s -2147483647 points')
|
||||
|
||||
|
||||
def mob_max_health_bounds():
|
||||
"""Raw bases survive effective clamping without changing Health (#7413)."""
|
||||
pig = '00000000-0000-0000-0000-000000000001'
|
||||
@@ -1818,6 +1867,7 @@ def groups():
|
||||
('client commands', client_commands()),
|
||||
('game state', game_state()),
|
||||
('player state', player_state()),
|
||||
('experience level boundaries', experience_level_boundaries()),
|
||||
('blocks and border', world_blocks()),
|
||||
('forceload query order', forceload_query_order()),
|
||||
('entities', entities()),
|
||||
@@ -1829,6 +1879,7 @@ def groups():
|
||||
('trial spawner passengers', trial_spawner_passengers()),
|
||||
('camel riders', camel_riders()),
|
||||
('scores, teams and bossbars', scores()),
|
||||
('bossbar list order', bossbar_list_order()),
|
||||
('execute', execute()),
|
||||
('levels', levels()),
|
||||
('end portal entities', end_portal_entities()),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"hooks": [{"name": "ingredient_test", "target": "horizon_client_lib::crafting::native_ingredient_test", "mod_api": {"contract": "listen"}}],
|
||||
"rust_summary": "a mod's own ingredient kind matching a concrete stack\n(`native_ingredient_test`, which the ingredient's test calls with the kind,\nits JSON, the stack and the verdict — the load-time list already named\nthe item).",
|
||||
"doc_summary": "a mod's own ingredient kind matching a concrete stack at craft time. The ingredient's test calls native_ingredient_test(kind, data, stack, accept) with the kind and JSON the load-time hook saw, the stack being matched and the verdict, already true (the load-time list named the item). Set accept to false to refuse the stack - its components are the usual reason (ICustomIngredient.test). The hook may be called many times a craft; the answer must be the same for the same stack. Bundles that use it need \"hooks\": @API:ingredient_test@.",
|
||||
"comments": ["crates/horizon-client/src/crafting.rs"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "recipe_book_categories",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_recipe_book_categories",
|
||||
"mod_api": { "contract": "listen" }
|
||||
}
|
||||
],
|
||||
"helpers": [
|
||||
{
|
||||
"name": "recipe_book_category",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_recipe_book_category",
|
||||
"mod_api": {}
|
||||
}
|
||||
],
|
||||
"rust_summary": "validated category declarations for custom recipe types (`native_recipe_book_categories`, `native_recipe_book_category`); display and unlock integration follows separately.",
|
||||
"doc_summary": "Declare a category id, custom recipe type and icon item with `native_recipe_book_categories`; read the validated mapping with `native_recipe_book_category`. This first slice does not add book tabs, displays or unlock synchronization. Bundles need hooks version @API:recipe_book_categories@."
|
||||
}
|
||||
@@ -71,7 +71,7 @@ ClientDebugSubscriber.ValueMap.forEach skip:java iteration helper
|
||||
ClientDebugSubscriber.ValueMaps.purgeExpired missing minor: expiry of server debug values; none are received
|
||||
ClientDebugSubscriber.ValueMaps.dropChunkAndBlocks missing minor: drops server debug values per chunk; none are received
|
||||
ClientDebugSubscriber.ValueWrapper.hasExpired skip:java timestamp check on a debug value wrapper
|
||||
ClientExplosionTracker.track inline crates/horizon-client-render/src/particles.rs::ParticleEngine::explosion gap: minor: the debris is thrown at once instead of queued for later ticks
|
||||
ClientExplosionTracker.track port crates/horizon-client-render/src/particles.rs::ParticleEngine::track_explosion gap: minor: queues explosions for debris generation during ClientExplosionTracker.tick
|
||||
ClientExplosionTracker.addParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::explosion_blocks gap: minor: always the default poof and smoke list; the explode packet's own block particle list is not read
|
||||
ClientHandshakePacketListenerImpl.switchState inline crates/horizon-client/src/net.rs::run login steps run in packet order in run(); no state machine
|
||||
ClientHandshakePacketListenerImpl.handleHello port crates/horizon-client/src/net.rs::run gap: minor: the session join and server check are in auth.rs (join_server); the encryption setup is in run
|
||||
|
||||
|
@@ -137,3 +137,19 @@ ENTITIES = [
|
||||
('minecart', 'Passengers:[{id:"minecraft:cow",UUID:[I;28673,0,0,4],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
|
||||
('horse', 'Tame:1b,Passengers:[{id:"minecraft:skeleton",UUID:[I;28673,0,0,5],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Passengers:[{id:"minecraft:bat",UUID:[I;28673,0,0,6],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]}]'),
|
||||
]
|
||||
|
||||
# Leads. A leashed mob saves `leash` as [I;x,y,z] (a fence knot at that block) or
|
||||
# {UUID:[I;..]} (a holder entity); vanilla restores the holder on the first tick after
|
||||
# loading and drops the lead if the holder is not found, so the knot and the holder
|
||||
# are summoned first, with fixed UUIDs, and within a few blocks of what they hold.
|
||||
# SETBLOCKS run before the entities.
|
||||
SETBLOCKS = ['setblock 40 -60 40 minecraft:oak_fence']
|
||||
_NOAI = 'NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b'
|
||||
_first = len(ENTITIES)
|
||||
ENTITIES += [
|
||||
('leash_knot', 'block_pos:[I;40,-60,40],Pos:[40.5d,-59.5d,40.5d]'),
|
||||
('cow', 'Pos:[42.5d,-60.0d,40.5d],leash:[I;40,-60,40]'),
|
||||
('pig', 'Pos:[44.5d,-60.0d,44.5d]'),
|
||||
('oak_boat', f'Pos:[44.5d,-60.0d,42.5d],leash:{{UUID:[I;28672,0,0,{_first + 2}]}}'),
|
||||
]
|
||||
LEADS = 2 # the leashed mobs above that vanilla must still have leashed when it saves
|
||||
@@ -179,6 +179,14 @@ def armor_stands(ox, oz):
|
||||
for row in range(20) for col in range(25)]
|
||||
|
||||
|
||||
def avatars(ox, oz):
|
||||
"""300 mannequins, the player model (20 x 15, 1.5 blocks apart): what cosmetics cost on many players
|
||||
(docs/cosmetics.md: `HORIZON_COSMETICS=1 HORIZON_COSMETICS_LOCAL=<items>` dresses every mannequin)."""
|
||||
return [f"summon mannequin {num(ox + 0.5 - 14.25 + 1.5 * col)} {FLOOR_Y + 1} {num(oz + 6 + 1.5 * row)} "
|
||||
"{NoGravity:1b,Silent:1b,Rotation:[180f,0f]}"
|
||||
for row in range(15) for col in range(20)]
|
||||
|
||||
|
||||
def chests(ox, oz):
|
||||
"""800 containers with block-entity models: four walls 25 wide and 8 high, one kind each."""
|
||||
return [f"fill {ox - 12} {FLOOR_Y + 1} {oz + 10 + 4 * i} {ox + 12} {FLOOR_Y + 8} {oz + 10 + 4 * i} {kind}"
|
||||
@@ -239,6 +247,7 @@ STRESS = [
|
||||
("stress_held", held),
|
||||
("stress_eyes", eyes),
|
||||
("stress_glowing", glowing),
|
||||
("stress_avatars", avatars),
|
||||
("stress_markings", markings),
|
||||
]
|
||||
NAMES = [name for name, _ in STRESS]
|
||||
|
||||
@@ -193,11 +193,16 @@ def entity_uuid(i):
|
||||
def entity_command(i, kind, extra):
|
||||
x = 3 + 3 * (i % 10)
|
||||
z = 36 + 3 * (i // 10)
|
||||
# `summon` places the entity at the command's coordinates, not at NBT Pos, so a
|
||||
# scene entry that has to stand somewhere (next to a fence) names it in Pos.
|
||||
at = re.search(r'Pos:\[(-?[0-9.]+)d,(-?[0-9.]+)d,(-?[0-9.]+)d\]', extra)
|
||||
if at:
|
||||
x, z = at.group(1), at.group(3)
|
||||
nbt = (f'UUID:{entity_uuid(i)},NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Tags:["rt"],'
|
||||
f'CustomName:{{text:"E{i}"}}' + (',' + extra if extra else ''))
|
||||
if kind in ('item_frame', 'glow_item_frame', 'painting'):
|
||||
nbt += f',block_pos:[I;{x},-60,{z}],Pos:[{x}.5d,-60.0d,{z}.5d]'
|
||||
return f'summon minecraft:{kind} {x} -60 {z} {{{nbt}}}'
|
||||
return f'summon minecraft:{kind} {x} {at.group(2) if at else -60} {z} {{{nbt}}}'
|
||||
|
||||
|
||||
def block_position(i):
|
||||
@@ -275,6 +280,10 @@ def build_world(directory):
|
||||
out = server.run(command)
|
||||
if command_failed(out):
|
||||
failed.append((command, out))
|
||||
for command in scene.SETBLOCKS:
|
||||
out = server.run(command)
|
||||
if command_failed(out):
|
||||
failed.append((command, out))
|
||||
for i, (kind, extra) in enumerate(scene.ENTITIES):
|
||||
command = entity_command(i, kind, extra)
|
||||
out = server.run(command)
|
||||
@@ -536,6 +545,10 @@ def compare_entities(vanilla, horizon, report, chunks=None):
|
||||
out[uuid] = e
|
||||
return out
|
||||
a, b = collect(vanilla), collect(horizon)
|
||||
# Vanilla drops a lead whose holder it cannot find, which would leave nothing to compare.
|
||||
leads = sum('leash' in e for e in a.values())
|
||||
if leads != scene.LEADS:
|
||||
report.append(('vanilla kept leads in the scene', scene.LEADS, leads))
|
||||
# Entities Horizon adds in the chunks vanilla never generated (chunk-generation
|
||||
# spawns) are not part of the scene; anywhere else they are a difference.
|
||||
vanilla_chunks = {chunk_of(e) for e in a.values()} | set(chunks or ())
|
||||
|
||||
@@ -4,8 +4,9 @@ both food and blocks (sweet and glow berries, carrots, potatoes), for sweet
|
||||
berry bushes, for spawn eggs used on a monster spawner, for shears carving
|
||||
pumpkins or cropping a growing plant's head (kelp, weeping and twisting vines,
|
||||
cave vines) to its top age, for copper golem statues turned to their next pose,
|
||||
for blocks placed where mobs and armor stands stand, for respawn anchors and for
|
||||
beds in the Nether and the End.
|
||||
for blocks placed where mobs and armor stands stand, for respawn anchors, for
|
||||
beds in the Nether and the End, and for dragon eggs, which a click moves somewhere
|
||||
else nearby (`DragonEggBlock.useWithoutItem`).
|
||||
|
||||
Every case has a block at the origin, something above it, and a player three
|
||||
blocks south, survival or creative, hungry or full, sneaking or not, who
|
||||
@@ -331,6 +332,32 @@ def jukebox_case(rng, states, case):
|
||||
'creative': True, 'sneaking': False, 'food': 20}
|
||||
|
||||
|
||||
def dragon_egg_case(rng, states, case):
|
||||
"""A dragon egg clicked with anything, or an empty hand (`DragonEggBlock.useWithoutItem`, which
|
||||
takes the click before the item is placed): `teleport` draws 16/8/16-wide offsets up to 1000
|
||||
times and moves the egg to the first air block inside the build height that has a block under it,
|
||||
sending level event 2015 with `BlockUtil.packDifferenceInPosition` of the move. A floor gives the
|
||||
search somewhere to land; in a bare sky only the cell above the egg has a block under it, and a
|
||||
block in that cell leaves the 1000 draws with nowhere to go and the egg where it was."""
|
||||
cells = [[0, 0, 0, states.get('dragon_egg')]]
|
||||
if rng.random() < 0.8:
|
||||
# A floor for the egg to land on: it only moves to air with a block under it.
|
||||
cells.append([0, -1, 0, states.get(rng.choice(['stone', 'dirt', 'grass_block', 'netherrack', 'sand']))])
|
||||
for dx in range(-2, 3):
|
||||
for dz in range(-2, 3):
|
||||
if (dx, dz) == (0, 0) or rng.random() < 0.15:
|
||||
continue
|
||||
cells.append([dx, -1, dz, states.get(rng.choice(['stone', 'dirt', 'oak_planks', 'glass',
|
||||
'snow_block', 'gravel']))])
|
||||
if rng.random() < 0.3:
|
||||
# Something above it, whose own block below is air, so the search never takes it.
|
||||
cells.append([0, 1, 0, states.get(rng.choice(['stone', 'oak_slab', 'glass']))])
|
||||
item = rng.choice(['-', '-', 'stick', 'stone', 'torch', 'dragon_egg'])
|
||||
case['use_item'] = {'item': '-' if item == '-' else d_plain(item, rng.choice([1, 16])),
|
||||
'creative': rng.random() < 0.2, 'sneaking': rng.random() < 0.15, 'food': 20}
|
||||
case['cells'] = cells
|
||||
|
||||
|
||||
# A block placed from an item carrying `minecraft:block_state`: the block and the properties the
|
||||
# component sets, none of them the ones the click would have chosen. The last two name a property the
|
||||
# block has no such thing as, and no property at all, which `BlockItemStateProperties.apply` skips.
|
||||
@@ -423,6 +450,12 @@ def cases(facts, count, seed):
|
||||
case['mode'] = 'dispense'
|
||||
yield case
|
||||
continue
|
||||
if rng.random() < 0.1:
|
||||
dragon_egg_case(rng, states, case)
|
||||
case['items'] = []
|
||||
case['mode'] = 'dispense'
|
||||
yield case
|
||||
continue
|
||||
if rng.random() < 0.06:
|
||||
block_state_case(rng, states, case)
|
||||
case['items'] = []
|
||||
|
||||
@@ -1172,6 +1172,7 @@ mod tests {
|
||||
pub(super) struct LootStack;
|
||||
pub(super) struct NativeLootContext<'a>(pub &'a ());
|
||||
pub(super) struct NativeDataMapType;
|
||||
pub(super) struct RecipeBookCategory;
|
||||
pub(super) struct NativeMapDecoration;
|
||||
pub(super) struct NativeMapInfo<'a>(pub &'a ());
|
||||
pub(super) struct NativePlayerRender<'a>(pub &'a ());
|
||||
|
||||
Reference in new issue
Block a user