Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	tools/stress_scenes.py
This commit is contained in:
Ludovic Marie committed 2026-11-02 23:28:18 +01:00
commit 52d38ca94c
141 files changed
+7020 -720

No files matched your search

Generated
+1
View File
@@ -367,6 +367,7 @@ version = "0.1.0"
dependencies = [
"horizon-mods",
"serde_json",
"zip",
]
[[package]]
+10 -12
View File
@@ -61,19 +61,17 @@ trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with.
The launcher shows the logo of the mod loader an instance plays with, and offers the logos as
instance icons.
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
unmodified (the launcher scales it up by whole pixels).
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
fetches them from their own sites when an instance needs them
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
- `launcher/assets/logos/{fabric,quilt,neoforge,forge}.png` are the loaders' logos as the launcher
mockup on [playhorizon.dev/launcher](https://playhorizon.dev/launcher) shows them (horizon-site
`public/media/launcher/*.webp`, converted to PNG), each its project's own mark:
[Fabric](https://fabricmc.net/), [Quilt](https://quiltmc.org/) (its art is
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)),
[NeoForged](https://neoforged.net/) (its art, by Ridanisaurus, is
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)) and
[Minecraft Forge](https://minecraftforge.net/). They name the loader an instance runs.
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
the project endorses Horizon.
+3
View File
@@ -10,5 +10,8 @@ description = "cargo horizon-mod new/build/run/check: make Horizon content mods
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
[dev-dependencies]
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[lints]
workspace = true
+133 -7
View File
@@ -1,7 +1,8 @@
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
//!
//! - `new <path> [--name "Name"]`: a content mod from the template.
//! - `new <path> [--name "Name"]`: a content mod from the template; with
//! `--template native [--horizon <commit>]`, a native trait mod's bundle.
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
//! and `data/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -32,7 +33,7 @@ fn main() -> ExitCode {
Some("run") => run(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] [--template content|native [--horizon <commit>]] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -74,6 +75,11 @@ fn new(args: &[String]) -> Result<(), String> {
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
match flag(args, "--template") {
None | Some("content") => {}
Some("native") => return new_native(dir, &id, flag(args, "--horizon"), fill),
Some(_) => return Err("--template is content or native".into()),
}
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let files = [
(
@@ -114,11 +120,6 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
if flag(args, "--template").is_some_and(|t| t != "content") {
return Err(
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
);
}
for (file, text) in files {
let file = fill(file);
let file = file.as_str();
@@ -137,6 +138,67 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// A native trait mod's bundle in `dir` (docs/native-mods.md, "Trait mods"): its
/// `horizon-mixin.toml` and an `[api]` crate with one hook. It is what the launcher
/// builds (`native-build <dir>`) and packs as `mixins/<id>/` of a `.hnmod`
/// (docs/api/mod-files.md). `horizon` pins the Horizon commit it is made for.
fn new_native(
dir: &Path,
id: &str,
horizon: Option<&str>,
fill: impl Fn(&str) -> String,
) -> Result<(), String> {
// `[mod] id`, as `horizon-native.json` needs it: the template keeps to its characters.
if id.len() > 64 {
return Err(format!("mod id {id}: 64 characters at most"));
}
let (compatibility, rev) = match horizon {
Some(commit) if horizon_mods::index::native::revision(commit) => (
format!("[compatibility]\nhorizon_revision = \"{commit}\"\n"),
format!(", rev = \"{commit}\""),
),
Some(commit) => {
return Err(format!(
"--horizon {commit}: a Horizon commit is 40 lowercase hex digits"
));
}
// Unpinned, it builds on the instance's own Horizon.
None => (
"# The Horizon commit it is made for; unpinned, it builds on the instance's own.\n\
# [compatibility]\n# horizon_revision = \"<40 hex digits>\"\n"
.to_owned(),
String::new(),
),
};
let files = [
(
"horizon-mixin.toml",
include_str!("../template/native/horizon-mixin.toml"),
),
(
"api/Cargo.toml",
include_str!("../template/native/api.toml"),
),
("api/src/lib.rs", include_str!("../template/native/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
for (file, text) in files {
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
let text = fill(text)
.replace("{compatibility}", &compatibility)
.replace("{rev}", &rev);
std::fs::write(&to, text).map_err(|e| format!("{}: {e}", to.display()))?;
}
println!(
"Created {} (native mod id {id}). Next: horizon-launcher native-build {} --instance <instance> --trust-native-code",
dir.display(),
dir.display()
);
Ok(())
}
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
/// manifest and the files in `assets/` and `data/`.
fn build() -> Result<PathBuf, String> {
@@ -272,4 +334,68 @@ mod tests {
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_native_template_packs_into_an_hnmod_the_registry_accepts() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("horizon-mod-native-{}", std::process::id()));
let path = dir.join("machine-mod");
let commit = "0123456789abcdef0123456789abcdef01234567";
let args = [
path.display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
commit.into(),
];
new(&args).unwrap();
let api = std::fs::read_to_string(path.join("api/Cargo.toml")).unwrap();
assert!(api.contains(&format!("rev = \"{commit}\"")), "{api}");
// Packed as the launcher packs a mod folder: the manifest, then the bundle under
// `mixins/<id>/` (docs/api/mod-files.md).
let manifest = serde_json::json!({
"schema": 1, "id": "machine_mod", "version": "0.1.0", "game": "26.3",
"horizon": commit, "mixins": "b".repeat(40),
});
let hnmod = dir.join("machine_mod.hnmod");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&hnmod).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file("horizon-native.json", options).unwrap();
zip.write_all(manifest.to_string().as_bytes()).unwrap();
for file in [
"horizon-mixin.toml",
"api/Cargo.toml",
"api/src/lib.rs",
".gitignore",
] {
assert!(horizon_mods::index::native::allowed(file), "{file}");
zip.start_file(format!("mixins/machine_mod/{file}"), options)
.unwrap();
zip.write_all(&std::fs::read(path.join(file)).unwrap())
.unwrap();
}
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
dir.join("bad").display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
"main".into(),
];
assert!(new(&bad).unwrap_err().contains("40 lowercase hex"));
let free = dir.join("free");
new(&[
free.display().to_string(),
"--template".into(),
"native".into(),
])
.unwrap();
let mixin = std::fs::read_to_string(free.join("horizon-mixin.toml")).unwrap();
assert!(!mixin.contains("\nhorizon_revision"), "{mixin}");
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,9 @@
[package]
name = "mod-{id}"
version = "0.1.0"
edition = "2024"
[dependencies]
# The launcher points this at the Horizon source it builds the game from. Any other
# dependency must be a crate and version in Horizon's Cargo.lock.
horizon-client = { git = "https://github.com/horizon-rust/horizon"{rev} }
@@ -0,0 +1,9 @@
# {name}: a native trait mod (docs/native-mods.md, "Trait mods"). Build it with
# `horizon-launcher native-build <this folder> --instance <instance> --trust-native-code`.
[mod]
id = "{id}"
version = "0.1.0"
{compatibility}
[api]
path = "api"
@@ -0,0 +1,18 @@
//! The trait mod: the launcher links this crate into the game and calls `hooks()`. Each
//! method of `Hooks` is one of Horizon's hook points; implement the ones the mod needs
//! (docs/native-mods.md, "Trait mods").
use horizon_client::mod_api::{Hooks, Host, PlayerLoggedInEvent, Text};
pub fn hooks() -> Box<dyn Hooks> {
Box::new(Mod)
}
struct Mod;
impl Hooks for Mod {
/// A player joined the server (NeoForge's `PlayerLoggedInEvent`): greet them.
fn player_logged_in(&self, host: &mut dyn Host, event: &mut PlayerLoggedInEvent<'_>) {
let line = format!("{id} says hello, {}!", event.name);
host.tell(event.player, Text::literal(line));
}
}
+35
View File
@@ -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());
}
+19
View File
@@ -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(&current.list)
.all(|(a, b)| tints(a) == tints(b));
(!same).then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
@@ -383,7 +383,7 @@ impl Shulker {
out
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
/// (`level.noCollision` of the lid box at half progress, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
let bottom = [
f64::from(pos[0]) + 0.5,
+5 -3
View File
@@ -5390,11 +5390,12 @@ impl Renderer {
&& self.native.is_none()
}
/// Whether the eyes render type's layers may be posed on the GPU too
/// (`mobs::skin::SkinModels::eyes`): only with improved transparency off,
/// Whether the layers tagged `OIT_ENTITY` (the eyes render type's, horse
/// markings, a slime's outer cube) may be posed on the GPU too
/// (`mobs::skin::SkinModels::tagged`): only with improved transparency off,
/// where they are ordinary entity quads. On, `EntityBatch` sorts them into
/// its stages by the CPU vertices' `OIT_ENTITY` tag.
pub fn skins_eyes(&self) -> bool {
pub fn skins_tagged(&self) -> bool {
self.skins_entities() && !self.improved_transparency
}
@@ -8873,6 +8874,7 @@ impl Renderer {
camera: frame.camera,
entities: frame.entities,
spans: frame.entity_spans,
motion: frame.entity_motion,
atlas: self.hud.atlas_texture.as_ref(),
quad_indices: &self.indices,
});
@@ -70,6 +70,12 @@ pub struct WorldDrawn<'a> {
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API @API:world_drawn_motion@).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
@@ -380,6 +380,9 @@ struct Pass {
image_copies: u64,
/// The pack's images a compute pass's loads read as they were before it: copied first.
snapshots: u64,
/// `scale.<program>` (scale, offset x, offset y): the pass draws only that part of its
/// buffers and keeps the rest.
viewport: Option<[f32; 3]>,
}
impl Pass {
@@ -892,6 +895,20 @@ fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
})
}
/// The viewport of `scale.<program>` (scale, offset x, offset y) on buffers of `size`, in whole
/// pixels, at least one and inside them. Buffer rows are GL's, so the offsets count from GL's
/// bottom-left corner as they are.
fn viewport(size: [u32; 2], [scale, x, y]: [f32; 3]) -> [f32; 4] {
let [w, h] = size.map(|n| n as f32);
let (x, y) = ((x * w).floor().min(w - 1.0), (y * h).floor().min(h - 1.0));
[
x,
y,
(scale * w).floor().clamp(1.0, w - x),
(scale * h).floor().clamp(1.0, h - y),
]
}
fn attach(
view: &wgpu::TextureView,
ops: wgpu::Operations<wgpu::Color>,
@@ -1555,6 +1572,7 @@ fn build_pass(
compute: Vec::new(),
sink,
geometry: p.geometry_triangles,
viewport: (!final_pass && p.scale != [1.0, 0.0, 0.0]).then_some(p.scale),
image_copies,
snapshots: 0,
})
@@ -1688,6 +1706,7 @@ fn build_compute(
geometry: 0,
image_copies,
snapshots,
viewport: None,
})
}
@@ -3990,18 +4009,27 @@ impl PackPost {
.map(|t| &t[usize::from(self.main & 1 << i == 0)])
};
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
// A scaled pass keeps what its buffers hold outside its viewport.
let ops = if pass.viewport.is_some() {
wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}
} else {
overwrite
};
if final_pass {
attachments.push(attach(screen, overwrite));
shown = true;
} else if pass.sink {
attachments.push(attach(&targets.sink, overwrite));
attachments.push(attach(&targets.sink, ops));
} else {
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
attachments.resize(l + 1, None);
}
attachments[l] = view(buffer).and_then(|v| attach(v, overwrite));
attachments[l] = view(buffer).and_then(|v| attach(v, ops));
}
}
{
@@ -4018,6 +4046,10 @@ impl PackPost {
{
rp.set_bind_group(4, g, &[]);
}
if let Some(scale) = pass.viewport {
let [x, y, w, h] = viewport(out, scale);
rp.set_viewport(x, y, w, h, 0.0, 1.0);
}
rp.draw(0..6, 0..1);
}
self.main ^= pass.flip;
@@ -4273,6 +4305,27 @@ mod tests {
assert_eq!(&read.slice(..).get_mapped_range().unwrap()[..], &[0; 4]);
}
/// `scale.composite2 = 0.25 0.5 0.25` on a 1280×720 picture: Iris 1.11.7 draws x 640..960
/// and GL y 180..360 (a probe pack of our own). Rows are GL's here, so that is rows 180..360.
#[test]
fn a_scaled_pass_draws_its_part_from_gl_s_corner() {
assert_eq!(
viewport([1280, 720], [0.25, 0.5, 0.25]),
[640.0, 180.0, 320.0, 180.0]
);
assert_eq!(
viewport([1280, 720], [0.5, 0.0, 0.0]),
[0.0, 0.0, 640.0, 360.0]
);
// Whole pixels, at least one, and never past the buffer's edge.
assert_eq!(viewport([5, 3], [0.5, 0.0, 0.0]), [0.0, 0.0, 2.0, 1.0]);
assert_eq!(viewport([4, 4], [0.01, 0.0, 0.0]), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(
viewport([100, 100], [1.0, 0.5, 0.75]),
[50.0, 75.0, 50.0, 25.0]
);
}
#[test]
fn pack_formats_map_to_renderable_ones() {
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
+9 -1
View File
@@ -113,6 +113,13 @@ pub const MAX_ATLAS_SIZE: u32 = 16384;
/// framed maps, so the pool holds a wall; too few slots re-upload every map
/// every frame. 26.3's `MapTextureManager` keeps a texture per map instead.
pub const MAP_SLOTS: usize = 256;
/// The atlas sprite of map slot `slot` (`horizon/map{slot}`), named once:
/// every map shown asks for its slot's each frame.
pub fn map_slot_sprite(slot: usize) -> Option<&'static str> {
static NAMES: std::sync::LazyLock<Vec<String>> =
std::sync::LazyLock::new(|| (0..MAP_SLOTS).map(|i| format!("horizon/map{i}")).collect());
NAMES.get(slot).map(String::as_str)
}
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// A mod's own image (`native_mod_image`), drawn with `native_sprite`; big
@@ -701,7 +708,8 @@ impl HudAtlas {
/// icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
let name = map_slot_sprite(i).unwrap_or_default();
images.push((name.into(), 128, 128, vec![0; 128 * 128 * 4]));
}
// The minimap extra's image (game/extras.rs).
images.push((
+138 -58
View File
@@ -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());
}
}
+4
View File
@@ -130,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"
@@ -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)]}),
);
}
+148 -22
View File
@@ -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(
+38
View File
@@ -1293,6 +1293,20 @@ pub struct ArmorStand {
pub support: horizon_world::ground_support::GroundState,
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:name_tag" {
return false;
}
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
return false;
};
if self.health > 0.0 {
self.custom_name = Some(crate::component::name_text(name));
}
true
}
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// The `Pose` compound's keys.
pub const POSE_KEYS: [&'static str; 6] =
@@ -2694,6 +2708,30 @@ mod entity_collision_tests {
mod armor_stand_motion_tests {
use super::*;
#[test]
fn a_name_tag_names_a_live_stand_and_unnamed_tags_pass() {
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
let mut tag = ItemStack::new("minecraft:name_tag", 4);
assert!(!stand.name_with_tag(&tag));
assert!(stand.custom_name.is_none());
let name = horizon_world::nbt::Tag::compound([
("text", horizon_world::nbt::Tag::String("Bob".into())),
("color", horizon_world::nbt::Tag::String("gold".into())),
]);
tag.data_mut().custom_name = Some(name.clone());
assert!(stand.name_with_tag(&tag));
assert_eq!(stand.custom_name, Some(crate::component::name_text(&name)));
assert_eq!(stand.health, 20.0);
assert!(!stand.name_with_tag(&ItemStack::new("minecraft:name_tag", 3)));
stand.health = 0.0;
tag.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Other".into()));
assert!(
stand.name_with_tag(&tag),
"a dead target still handles the named tag"
);
assert_eq!(stand.custom_name, Some(crate::component::name_text(&name)));
}
#[test]
fn a_new_stand_starts_with_matching_previous() {
let at = Vec3::new(1.0, 2.0, 3.0);
+59 -3
View File
@@ -606,9 +606,8 @@ pub fn mob_to_nbt(
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
map.entry(key.into()).or_insert(value);
}
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
map.entry("ExplosionRadius".into()).or_insert(Tag::Byte(3));
map.insert("powered".into(), Tag::Byte(i8::from(m.extra.charged)));
map.insert("ignited".into(), Tag::Byte(i8::from(m.extra.flag)));
}
@@ -1587,6 +1586,7 @@ pub fn mob_from_nbt(
m.extra.charged = flag(tag, "powered");
// Creeper.readAdditionalSaveData: `ignited` lights the fuse.
m.extra.flag = flag(tag, "ignited");
m.max_swell = number(tag.get("Fuse")).unwrap_or(30) as i16 as i32;
}
if matches!(
m.species(),
@@ -3409,6 +3409,62 @@ mod tests {
use super::*;
use crate::items::ItemStack;
fn fused_creeper(fuse: Option<i16>) -> (Mob, Tag) {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:creeper".into();
ty.species = crate::mobs::Species::Creeper;
let types = [("minecraft:creeper".into(), kind)].into();
let mut tag = Tag::compound([
("id", string("minecraft:creeper")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("ignited", Tag::Byte(1)),
("NoAI", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
]);
if let (Some(fuse), Tag::Compound(c)) = (fuse, &mut tag) {
c.insert("Fuse".into(), Tag::Short(fuse));
}
(mob_from_nbt(&tag, &types, 1, None).unwrap(), tag)
}
#[test]
fn creeper_fuse_nbt_controls_explosion_tick_and_saves_runtime_value() {
for (fuse, boundary) in [
(Some(1), 1),
(None, 30),
(Some(40), 40),
(Some(0), 1),
(Some(-1), 1),
] {
let (mut mob, raw) = fused_creeper(fuse);
let mut events = Vec::new();
for tick in 1..=boundary {
crate::mobs::hostile::creeper_tick(&mut mob, &mut events);
assert_eq!(mob.removed, tick == boundary, "Fuse={fuse:?}, tick={tick}");
}
assert_eq!(
events
.iter()
.filter(|e| matches!(e, crate::mobs::MobEvent::Explode(..)))
.count(),
1
);
mob.max_swell = 7;
let saved = mob_to_nbt(&mob, [0; 4], Some(&raw), None);
assert_eq!(saved.get("Fuse"), Some(&Tag::Short(7)));
}
}
#[test]
fn creeper_fall_swell_uses_custom_fuse_without_a_zero_floor() {
for (fuse, expected) in [(Some(1), -4), (None, 25), (Some(40), 35)] {
let (mut mob, _) = fused_creeper(fuse);
mob.cause_fall_damage_from(100.0, 0.0, &mut Vec::new());
assert_eq!(mob.swell, expected, "Fuse={fuse:?}");
}
}
#[test]
fn mob_loading_accepts_default_namespace_and_preserves_custom_namespaces() {
let cow = crate::mobs::sensor_scan_tests::kind();
+9
View File
@@ -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()
{
+52 -15
View File
@@ -205,6 +205,7 @@ mod native_components;
mod native_criteria;
mod native_data_maps;
mod native_entities;
mod native_entity_refs;
mod native_explosions;
mod native_falling;
mod native_hooks;
@@ -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,
@@ -6445,7 +6463,14 @@ impl Game<'_> {
return Ok(true);
}
if !on_block && &*stack.item == "minecraft:bow" {
if self.creative || self.projectile_slot("minecraft:bow").is_some() {
// BowItem.use: ArrowNockEvent, then FAIL without ammunition.
let found_projectile = self.get_projectile(&stack).is_some();
let has_ammo = found_projectile || self.creative;
let player = horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid());
let name = self.acting_player_name().to_owned();
let (hand, mut cancel) = (self.hand, false);
self.native_arrow_nock(player, &name, &stack, hand, has_ammo, &mut cancel);
if has_ammo && !cancel {
self.start_using_item();
}
return Ok(true);
@@ -7332,6 +7357,7 @@ impl Game<'_> {
// What the blocks spawned (a dispensed minecart, a lit TNT, a
// placed boat) joins before the entities tick.
self.native_joins_flush(false, 0);
self.lan_mark_synced();
}
if running {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
@@ -7346,7 +7372,9 @@ impl Game<'_> {
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
self.tick_vehicle_watch();
let from = self.lan_synced_from();
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
self.lan_synced_since(from);
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
horizon_client::spans::time("tick_maps", || self.tick_maps());
self.tick_lodestone_compasses();
@@ -7442,6 +7470,9 @@ impl Game<'_> {
self.use_with_hand()?;
self.use_delay = 4;
}
if running {
horizon_client::spans::time("lan_note_joined", || self.lan_note_joined());
}
Ok(())
}
/// Keep the held-key state of toggled sneak and sprint in step with their mapping.
@@ -11817,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()),
@@ -13418,23 +13450,22 @@ impl Game<'_> {
}
/// BowItem.releaseUsing: power from charge time, arrows at power * 3.
fn release_bow(&mut self, stack: &ItemStack, ticks: u32) {
let arrow_slot = self.projectile_slot("minecraft:bow");
let has_ammo = arrow_slot.is_some() || self.creative;
let ammo = self.get_projectile(stack);
let mut charge = i32::try_from(ticks).unwrap_or(i32::MAX);
let mut canceled = false;
self.native_arrow_loose(stack, has_ammo, &mut charge, &mut canceled);
let Some(power) = item_use::bow_release_power(has_ammo, charge, canceled) else {
self.native_arrow_loose(stack, ammo.is_some(), &mut charge, &mut canceled);
let power = item_use::bow_release_power(ammo.is_some(), charge, canceled);
let (Some(power), Some(ammo)) = (power, ammo) else {
return;
};
let arrow_slot = ammo.slot;
let weapon = Some(stack.clone());
let Some(eye) = self.eye_position() else {
return;
};
// BowItem.shootProjectile: shootFromRotation(power * 3, inaccuracy 1).
let p = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let ammo = arrow_slot
.and_then(|i| self.stack_in(i).cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
let weapon = self.held().cloned();
let ammo = ammo.stack;
let (power_bonus, infinity) = self.bow_enchantments(&ammo.item);
let effect = dispenser::tipped_effect(&ammo).or_else(|| {
(&*ammo.item == "minecraft:spectral_arrow").then_some(("minecraft:glowing", 200))
@@ -13842,8 +13873,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,
));
@@ -16283,10 +16314,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());
}
}
@@ -18278,6 +18306,7 @@ impl<'a> Game<'a> {
);
Ok(Game {
world_biomes: None,
world_biomes_base: None,
res: res.clone(),
rest_loader: None,
pending_entry: None,
@@ -19084,6 +19113,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)
}
+22 -10
View File
@@ -547,11 +547,27 @@ impl Game<'_> {
return false;
}
let held = self.held().cloned();
if held
.as_ref()
.is_some_and(|h| &*h.item == "minecraft:name_tag")
{
return false;
if let Some(stack) = held.as_ref().filter(|h| &*h.item == "minecraft:name_tag") {
let stand = &mut self.armor_stands[i];
let alive = stand.health > 0.0;
if !stand.name_with_tag(stack) {
return false;
}
let id = stand.id;
let name = stand.custom_name.as_ref().map(|name| name.plain());
if alive {
// NameTagItem shrinks the stack even with infinite materials.
let mut rest = stack.clone();
rest.count -= 1;
*self.held_mut() = (rest.count > 0).then_some(rest);
if let (Some(player), Some(player_name), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone(), name)
{
self.native_player_name_entity(player, &player_name, id, &name);
}
}
self.swing_arm();
return true;
}
let a = &self.armor_stands[i];
let slot = match &held {
@@ -975,11 +991,7 @@ impl Game<'_> {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
self.armor_stand_vertices(a, base, packed, Some(&mut skin), out);
Some((mark, skin.bounds))
} else {
@@ -301,6 +301,8 @@ impl Game<'_> {
.get("minecraft:conduit_effect_block")
.cloned()
.unwrap_or_default();
// The lids this tick swept (`moveCollidedEntities`), for the player
// this client moves itself.
let mut shulker_pushes = Vec::new();
for &(pos, ticker) in &positions {
match ticker {
@@ -433,9 +435,15 @@ impl Game<'_> {
}
}
self.be_anims.present = positions;
// The lid's grown box collides and pushes here too: a client of a
// server owns its own player's position, so the server's
// `moveCollidedEntities` alone would be undone by the client's next
// move, and a lid the client cannot collide with never holds it up.
// Only the player: `tick_block_entities` has already pushed the mobs
// and the guests, and a second push would double the lift.
if self.remote.is_some() {
self.sync_shulkers();
self.push_shulker_entities(shulker_pushes);
self.push_shulker_local(shulker_pushes);
}
}
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
+4 -4
View File
@@ -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);
}
+2 -5
View File
@@ -226,11 +226,8 @@ impl Game<'_> {
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 {
models: &mut models,
frame: &mut frame,
bounds,
};
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,
+68 -16
View File
@@ -873,6 +873,8 @@ impl Game<'_> {
pub(super) fn tick_creatures(&mut self) -> Result<()> {
self.attack_ticks = self.attack_ticks.saturating_add(1);
self.tick_leads();
// Vanilla's spawning and raids run before ChunkMap.tick.
let from = self.lan_synced_from();
horizon_client::spans::time("spawn_natural", || self.spawn_natural());
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
if self.ui.rules.bool("spawn_mobs") {
@@ -882,6 +884,7 @@ impl Game<'_> {
self.tick_village_siege();
}
self.tick_raids();
self.lan_synced_since(from);
let ticked =
horizon_client::spans::time("tick_creature_entities", || self.tick_creature_entities());
self.dismount_underwater_mob_riders();
@@ -1783,6 +1786,19 @@ impl Game<'_> {
);
}
}
/// A mob's projectile enters the level.
fn launch_mob_projectile(&mut self, mut p: Projectile) {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
if p.kind == ProjectileKind::LlamaSpit {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
self.add_projectile(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
@@ -2074,6 +2090,23 @@ impl Game<'_> {
} => {
self.mob_hits_mob(target, amount, source);
}
MobEvent::AttackMobWithEffect {
target,
amount,
source,
effect,
ticks,
amplifier,
} => {
// CaveSpider, Husk and WitherSkeleton.doHurtTarget: the
// effect sits inside the branch where the hit landed, and
// `LivingEntity.addEffect` takes a mob as well as a player.
if self.mob_hits_mob(target, amount, source)
&& let Some(t) = self.mobs.iter_mut().find(|m| m.id == target)
{
t.add_effect(effect, ticks, amplifier);
}
}
MobEvent::CubeHurtPlayer { cube, amount, from } => {
// AbstractCubeMob.dealDamage: the attack sound (and its
// two pitch draws, made even for a silent cube, whose
@@ -2271,17 +2304,40 @@ impl Game<'_> {
}
}
}
MobEvent::Projectile(mut p) => {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
if p.kind == ProjectileKind::LlamaSpit {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
MobEvent::Projectile(p) => self.launch_mob_projectile(p),
MobEvent::BowShot {
mob,
weapon,
mut arrow,
} => {
self.mob_bow_ammo(mob, &weapon, &mut arrow);
self.launch_mob_projectile(arrow);
}
MobEvent::CrossbowShot { ammo, mut arrow } => {
if &*ammo.item == "minecraft:firework_rocket" {
// `CrossbowItem.createProjectile`: a rocket, from 0.15 under the eyes.
let mut at = arrow.position;
at.y -= f64::from(0.15f32) - 0.1;
self.launch_angled_rocket(at, arrow.velocity, &ammo, arrow.owner);
} else {
Self::arrow_from_ammo(&ammo, &mut arrow);
self.launch_mob_projectile(arrow);
}
}
MobEvent::CrossbowLoad {
mob,
weapon,
at,
pitch,
} => {
if self.mob_crossbow_ammo(mob, &weapon) {
self.audio.play(
"minecraft:item.crossbow.loading_end",
Some(at),
1.0,
pitch,
);
}
self.add_projectile(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
MobEvent::Spawn(mut m) => {
self.next_entity += 1;
@@ -6326,7 +6382,7 @@ impl Game<'_> {
Some(stored) => {
let mut models = self.skin_models.borrow_mut();
models.begin(&mut self.skin_frame.borrow_mut(), stored);
models.eyes = renderer.is_some_and(|r| r.skins_eyes());
models.tagged = renderer.is_some_and(|r| r.skins_tagged());
}
None => self.skin_frame.borrow_mut().clear_draws(),
}
@@ -6533,11 +6589,7 @@ impl Game<'_> {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
+125 -7
View File
@@ -15,6 +15,14 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
const CROSSBOW: &str = "minecraft:crossbow";
const FIREWORK_ROCKET: &str = "minecraft:firework_rocket";
const ARROW: &str = "minecraft:arrow";
/// What `getProjectile` returned: the ammunition and, while it is still the
/// stack found in the inventory, the slot it is taken from.
pub(super) struct Ammo {
pub(super) stack: ItemStack,
pub(super) slot: Option<usize>,
}
/// `ProjectileWeaponItem.ARROW_ONLY`, for the arrows Horizon shoots.
fn is_arrow(stack: &ItemStack) -> bool {
@@ -101,6 +109,115 @@ impl Game<'_> {
let hands = [OFFHAND_SLOT, self.selected].map(|slot| (slot, self.stack_in(slot)));
projectile_slot(weapon, hands, &self.inventory)
}
/// `Player.getProjectile` for `weapon`, as `LivingGetProjectileEvent` leaves
/// it: a creative player without arrows has `hasInfiniteMaterials`' arrow.
pub(super) fn get_projectile(&mut self, weapon: &ItemStack) -> Option<Ammo> {
let slot = self.projectile_slot(&weapon.item);
let vanilla = match slot {
Some(i) => self.stack_in(i).cloned(),
None => self.creative.then(|| ItemStack::new(ARROW, 1)),
};
if horizon_client::mod_api::mods().is_empty() {
return vanilla.map(|stack| Ammo { stack, slot });
}
let mut projectile = vanilla.clone();
// A client predicting its own use has the profile it logged in with.
let player = match self
.remote
.as_ref()
.filter(|_| self.lan_acting_uuid.is_none())
{
Some(r) => r.profile(),
None => horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
};
let name = self.own_profile_name();
self.native_living_get_projectile(
Some(player),
&name,
None,
"minecraft:player",
weapon,
&mut projectile,
);
let slot = slot.filter(|_| projectile == vanilla);
projectile.map(|stack| Ammo { stack, slot })
}
/// `AbstractSkeleton.performRangedAttack`'s `getProjectile`: the arrow
/// `mob` shot becomes the replaced ammunition's (`ProjectileUtil.getMobArrow`).
pub(super) fn mob_bow_ammo(&mut self, mob: u32, weapon: &ItemStack, arrow: &mut Projectile) {
if horizon_client::mod_api::mods().is_empty() {
return;
}
let Some(shooter) = self.mobs.iter().find(|m| m.id == mob) else {
return;
};
let kind = shooter.kind.id.clone();
let vanilla = Self::mob_vanilla_ammo(&shooter.extra, weapon);
let mut projectile = Some(vanilla.clone());
self.native_living_get_projectile(None, "", Some(mob), &kind, weapon, &mut projectile);
let Some(ammo) = projectile.filter(|ammo| *ammo != vanilla && is_arrow(ammo)) else {
return;
};
Self::arrow_from_ammo(&ammo, arrow);
}
/// `Monster.getProjectile`: the projectile `weapon` supports in the off
/// hand, then the main hand (`getHeldProjectile`), else a new arrow.
fn mob_vanilla_ammo(extra: &horizon_client::mobs::Extra, weapon: &ItemStack) -> ItemStack {
let supported =
|s: &ItemStack| is_arrow(s) || &*weapon.item == CROSSBOW && &*s.item == FIREWORK_ROCKET;
extra
.off_hand
.as_deref()
.map(|item| ItemStack::new(item, 1))
.into_iter()
.chain(extra.main_hand_stack().cloned())
.find(supported)
.unwrap_or_else(|| ItemStack::new(ARROW, 1))
}
/// `ProjectileUtil.getMobArrow`: `arrow` carries `ammo`'s item and effect.
pub(super) fn arrow_from_ammo(ammo: &ItemStack, arrow: &mut Projectile) {
if let ProjectileKind::Arrow { effect, .. } = &mut arrow.kind {
if &*ammo.item == "minecraft:spectral_arrow" {
*effect = Some(("minecraft:glowing", 200));
} else if effect.is_none()
&& let Some(tipped) = super::dispenser::tipped_effect(ammo)
{
// Stray.getArrow and Bogged.getArrow add their effect to the tipped arrow.
*effect = Some(tipped);
}
}
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(&horizon_client::arrows::drawn_ammo(ammo, false));
}
}
/// `CrossbowItem.onUseTick`'s `getProjectile` for a mob that finished
/// charging `weapon` (pillagers and piglins: `Monster.getProjectile`). The
/// replaced ammunition is what the crossbow holds; none leaves it uncharged
/// and returns false (`tryLoadProjectiles`).
pub(super) fn mob_crossbow_ammo(&mut self, mob: u32, weapon: &ItemStack) -> bool {
let Some(shooter) = self.mobs.iter().find(|m| m.id == mob) else {
return false;
};
let kind = shooter.kind.id.clone();
let mut projectile = Some(Self::mob_vanilla_ammo(&shooter.extra, weapon));
self.native_living_get_projectile(None, "", Some(mob), &kind, weapon, &mut projectile);
// A crossbow takes arrows and firework rockets only.
let projectile = projectile.filter(|ammo| is_arrow(ammo) || &*ammo.item == FIREWORK_ROCKET);
let Some(shooter) = self.mobs.iter_mut().find(|m| m.id == mob) else {
return false;
};
let Some(mut crossbow) = shooter.extra.main_hand_stack().cloned() else {
return false;
};
let loaded = projectile.is_some();
crossbow.data_mut().charged = projectile
.iter()
.map(|ammo| horizon_client::arrows::drawn_ammo(ammo, false))
.collect();
crossbow.tidy();
shooter.extra.set_main_hand_stack(Some(crossbow));
loaded
}
/// `CrossbowItem.use`: shoot when charged, else start charging.
pub(super) fn use_crossbow(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != CROSSBOW {
@@ -112,7 +229,7 @@ impl Game<'_> {
}
if stack.data().is_some_and(|d| !d.charged.is_empty()) {
self.shoot_crossbow();
} else if self.projectile_slot(CROSSBOW).is_some() || self.creative {
} else if self.get_projectile(stack).is_some() {
self.start_using_item();
}
true
@@ -145,12 +262,12 @@ impl Game<'_> {
} else {
1
};
let slot_ammo = self.projectile_slot(CROSSBOW);
let ammo = slot_ammo
.and_then(|i| self.stack_in(i).cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
if !self.creative {
let Some(i) = slot_ammo else { return };
let Some(ammo) = self.get_projectile(stack) else {
return;
};
if !self.creative
&& let Some(i) = ammo.slot
{
let cell = self.stack_in_mut(i);
if let Some(s) = cell.as_mut() {
s.count -= 1;
@@ -159,6 +276,7 @@ impl Game<'_> {
}
}
}
let ammo = ammo.stack;
// useAmmo: the extra multishot arrows and creative's are intangible.
let drawn = (0..shots)
.map(|i| horizon_client::arrows::drawn_ammo(&ammo, self.creative || i > 0))
+36 -18
View File
@@ -655,29 +655,29 @@ impl Dragon {
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
)
}
fn explosion_push(&mut self, center: [f64; 3], radius: f32, exposure: f32) {
fn explosion_knockback(
&self,
center: [f64; 3],
radius: f32,
exposure: f32,
) -> Option<[f64; 3]> {
let diameter = f64::from(radius * 2.0);
let p = self.position;
let at = |y: f64| [p.x - center[0], y - center[1], p.z - center[2]];
let len = |v: [f64; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
let dist = len(at(p.y)) / diameter;
if dist > 1.0 {
return;
return None;
}
// The dragon's eye is 0.85 of its 8 block height, in floats.
let away = at(p.y + f64::from(8.0f32 * 0.85f32));
let l = len(away);
// `Vec3.normalize` gives zero below 1.0E-5F, then `scale(power)`.
if l < 9.999_999_747_378_752e-6 {
return;
return Some([0.0; 3]);
}
let dir = [away[0] / l, away[1] / l, away[2] / l];
let power = (1.0 - dist) * f64::from(exposure);
self.velocity = Vec3::new(
self.velocity.x + dir[0] * power,
self.velocity.y + dir[1] * power,
self.velocity.z + dir[2] * power,
);
Some(away.map(|d| d / l * power))
}
/// `DragonHoldingPatternPhase.onCrystalDestroyed`: strafe `player` if
/// the dragon can attack them (`EnderDragon.canAttack`, on Peaceful too).
@@ -3234,7 +3234,12 @@ impl Crystal {
impl Game<'_> {
/// `ServerExplosion.hurtEntities`: crystals in reach are removed without
/// another blast; explosions always hurt dragons.
pub(super) fn explosion_hits_special(&mut self, center: [f64; 3], radius: f32) {
pub(super) fn explosion_hits_special(
&mut self,
center: [f64; 3],
radius: f32,
blocks: &[[i32; 3]],
) {
let diameter = f64::from(radius * 2.0);
let in_reach = |p: Vec3| {
((p.x - center[0]).powi(2) + (p.y - center[1]).powi(2) + (p.z - center[2]).powi(2))
@@ -3269,8 +3274,20 @@ impl Game<'_> {
for (part, amount) in hits {
self.hurt_dragon(part, amount, true, by, DamageKind::Explosion);
}
if let Some(d) = self.dragon.as_mut().filter(|d| d.alive()) {
d.explosion_push(center, radius, exposure);
let Some((id, mut push)) = self
.dragon
.as_ref()
.and_then(|d| Some((d.id, d.explosion_knockback(center, radius, exposure)?)))
else {
return;
};
self.native_explosion_knockback(self.dim.id(), center, blocks, Some(id), &mut push);
if let Some(d) = self.dragon.as_mut() {
d.velocity = Vec3::new(
d.velocity.x + push[0],
d.velocity.y + push[1],
d.velocity.z + push[2],
);
}
}
}
@@ -4662,13 +4679,14 @@ mod tests {
d.position = Vec3::new(0.0, 70.0, 0.0);
d.velocity = Vec3::ZERO;
// Power 5 right under the dragon: it is thrown up, away from the blast.
d.explosion_push([0.0, 66.0, 0.0], 5.0, 1.0);
assert!(d.velocity.y > 0.0 && d.velocity.x == 0.0 && d.velocity.z == 0.0);
let up = d.velocity.y;
let push = d.explosion_knockback([0.0, 66.0, 0.0], 5.0, 1.0).unwrap();
assert!(push[1] > 0.0 && push[0] == 0.0 && push[2] == 0.0);
// Out of reach or fully covered: no push.
d.explosion_push([0.0, 40.0, 0.0], 5.0, 1.0);
d.explosion_push([0.0, 66.0, 0.0], 5.0, 0.0);
assert_eq!(d.velocity.y, up);
assert_eq!(d.explosion_knockback([0.0, 40.0, 0.0], 5.0, 1.0), None);
assert_eq!(
d.explosion_knockback([0.0, 66.0, 0.0], 5.0, 0.0),
Some([0.0; 3])
);
}
#[test]
+62 -46
View File
@@ -148,6 +148,13 @@ fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
}
}
/// The blast as `native_explosion_knockback` tells it: its centre and the
/// blocks it is about to break.
struct ExplosionHookContext<'a> {
center: [f64; 3],
blocks: &'a [[i32; 3]],
}
/// An entity in the blast's reach: how far (as a share of the blast's
/// diameter), how much of it the rays see, and the knockback.
struct Hit {
@@ -156,28 +163,18 @@ struct Hit {
push: Vec3,
}
/// A mob in a blast's reach: `hurtServer` when its team allows the damage
/// (`hurtable`), then `pushFromExplosion` whatever that returned, so a mob
/// that refuses the hit (invulnerable, in its hurt cooldown, a wither and an
/// undead `cause`) is still pushed. `Entity.push` sets `needsSync`.
fn hurt_and_push_mob(
/// Apply blast damage when the mob's team and wither immunity allow it.
fn hurt_mob(
m: &mut horizon_client::mobs::Mob,
hurtable: bool,
cause: Option<horizon_client::mobs::Species>,
(amount, source): (f32, horizon_client::mobs::Damage),
push: [f64; 3],
) {
let shielded =
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
if hurtable && !shielded {
m.damage(amount, source);
}
m.velocity = Vec3::new(
m.velocity.x + push[0],
m.velocity.y + push[1],
m.velocity.z + push[2],
);
m.needs_sync = true;
}
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
@@ -389,15 +386,16 @@ impl Game<'_> {
///
/// `cause` is the blast's source as predicates see it, read before the
/// guests' turns swap their own combat state in.
pub(super) fn explosion_hurt_player(
fn explosion_hurt_player(
&mut self,
center: [f64; 3],
context: &ExplosionHookContext<'_>,
radius: f32,
damage_type: &str,
exploding: Option<u32>,
attacker: Option<&(u32, Text)>,
cause: Option<&horizon_client::advancements::EntityView>,
) -> Option<[f64; 3]> {
let ExplosionHookContext { center, blocks } = *context;
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
// piece, so two Blast Protection IV pieces cancel it).
@@ -481,14 +479,19 @@ impl Game<'_> {
.indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw);
}
}
if !spectator && !(creative && flying) {
let push = [dir[0] * power, dir[1] * power, dir[2] * power];
}
if !spectator {
let mut push = [dir[0] * power, dir[1] * power, dir[2] * power];
self.native_explosion_knockback(self.dim.id(), center, blocks, None, &mut push);
if let Some(p) = self.player.as_mut() {
p.velocity = Vec3::new(
p.velocity.x + push[0],
p.velocity.y + push[1],
p.velocity.z + push[2],
);
pushed = (!p.health.sync_velocity).then_some(push);
if !(creative && flying) {
pushed = (!p.health.sync_velocity).then_some(push);
}
}
}
self.combat.forget_noted(outcome.canceled);
@@ -544,11 +547,12 @@ impl Game<'_> {
radius: f32,
damage_type: &str,
interaction: BlockInteraction,
blocks: &[[i32; 3]],
) -> Vec<(u32, [f64; 3])> {
if radius < 1.0e-5 {
return Vec::new();
}
self.explosion_hits_special(center, radius);
self.explosion_hits_special(center, radius, blocks);
let diameter = f64::from(radius * 2.0);
let damage = |dist: f64, exposure: f32| {
let pow = (1.0 - dist) * f64::from(exposure);
@@ -568,8 +572,9 @@ impl Game<'_> {
// same for all of them (`onExplosionHit(this.source)`).
let attacker = self.blast_attacker();
let blast_source = self.combat.explosion_cause.clone();
let context = ExplosionHookContext { center, blocks };
let own = self.explosion_hurt_player(
center,
&context,
radius,
damage_type,
exploding,
@@ -588,7 +593,7 @@ impl Game<'_> {
for conn in guests {
let push = self.as_guest(&mut host, conn, |g| {
g.explosion_hurt_player(
center,
&context,
radius,
damage_type,
exploding,
@@ -641,6 +646,7 @@ impl Game<'_> {
}) = hit
{
let amount = damage(dist, exposure);
let entity = with_entity!(self, handle, e => e.id());
match handle {
EntityHandle::Mob(i) => {
let hurtable = teams_allow(self, self.mob_team(self.mobs[i].id));
@@ -662,49 +668,54 @@ impl Game<'_> {
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
// its base is 0 for every mob type, so iron golems and
// ravagers are pushed.
hurt_and_push_mob(
m,
hurtable,
cause,
(amount, source),
[push.x, push.y, push.z],
);
hurt_mob(m, hurtable, cause, (amount, source));
}
// 5 health, destroyed by the explosion damage (nether stars resist).
EntityHandle::Item(i) => {
destroyed = &*self.dropped[i].stack.item != "minecraft:nether_star"
&& amount >= 5.0;
if !destroyed {
self.dropped[i].push_from_explosion(push);
}
}
// `ExperienceOrb.hurtServer` takes the damage off their 5 health.
EntityHandle::Orb(i) => {
destroyed = self.orbs[i].hurt(amount);
if !destroyed {
self.orbs[i].push_from_explosion(push);
}
}
// `VehicleEntity.hurtServer` (explosions ignite TNT carts);
// a destroyed one is gone before the push.
EntityHandle::Cart(i) => {
let (id, len) = (self.carts[i].id, self.carts.len());
let len = self.carts.len();
self.hurt_cart(i, amount, false, true);
carts_gone += len - self.carts.len();
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
c.push_from_explosion(push);
}
}
// `VehicleEntity.hurtServer`: damage * 10, broken past 40.
EntityHandle::Boat(i) => {
let (id, len) = (self.boats[i].id, self.boats.len());
let len = self.boats.len();
self.hurt_boat(i, amount, false);
boats_gone += len - self.boats.len();
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
}
// `hurtServer` does nothing to the rest.
_ => {}
}
let mut velocity = [push.x, push.y, push.z];
self.native_explosion_knockback(
self.dim.id(),
center,
blocks,
Some(entity),
&mut velocity,
);
let push = Vec3::new(velocity[0], velocity[1], velocity[2]);
match handle {
EntityHandle::Item(_) | EntityHandle::Orb(_) if destroyed => {}
EntityHandle::Cart(_) => {
if let Some(c) = self.carts.iter_mut().find(|c| c.id == entity) {
c.push_from_explosion(push);
}
}
EntityHandle::Boat(_) => {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == entity) {
b.push_from_explosion(push);
}
}
// `hurtServer` does nothing to the rest; `pushFromExplosion` moves them.
other => with_entity_mut!(self, other, e => e.push_from_explosion(push)),
}
}
@@ -872,7 +883,8 @@ impl Game<'_> {
let mut targets = self.exploded_positions(center, radius, resists);
let dimension = self.dim.id();
self.native_explosion_detonate(dimension, center, &mut targets);
let pushed = self.explosion_hurt_entities(center, radius, damage_type, interaction);
let pushed =
self.explosion_hurt_entities(center, radius, damage_type, interaction, &targets);
if interaction != BlockInteraction::Keep {
// Util.shuffle(list, level.random)
for i in (2..=targets.len()).rev() {
@@ -1162,7 +1174,7 @@ mod tests {
fn a_mob_that_refuses_the_hit_is_still_pushed() {
use horizon_client::mobs::{Damage, DamageKind, Mob, Species};
let hit = (10.0, Damage::of(DamageKind::Explosion));
let push = [0.5, 0.25, -0.5];
let push = Vec3::new(0.5, 0.25, -0.5);
let mob = |id, species| {
let mut kind = (*zombie_type()).clone();
kind.species = species;
@@ -1175,26 +1187,30 @@ mod tests {
// An ordinary mob is hurt and pushed.
let mut m = mob(1, Species::Zombie);
let health = m.health;
hurt_and_push_mob(&mut m, true, None, hit, push);
hurt_mob(&mut m, true, None, hit);
m.push_from_explosion(push);
assert!(m.health < health);
pushed(&m);
// An invulnerable one refuses the hit, and is pushed all the same.
let mut m = mob(2, Species::Zombie);
m.invulnerable_flag = true;
let health = m.health;
hurt_and_push_mob(&mut m, true, None, hit, push);
hurt_mob(&mut m, true, None, hit);
m.push_from_explosion(push);
assert_eq!(m.health, health);
pushed(&m);
// A team mate of the exploding mob is not hurt, but pushed.
let mut m = mob(3, Species::Zombie);
let health = m.health;
hurt_and_push_mob(&mut m, false, None, hit, push);
hurt_mob(&mut m, false, None, hit);
m.push_from_explosion(push);
assert_eq!(m.health, health);
pushed(&m);
// A wither shrugs off a blast an undead caused, and is pushed.
let mut m = mob(4, Species::Wither);
let health = m.health;
hurt_and_push_mob(&mut m, true, Some(Species::Skeleton), hit, push);
hurt_mob(&mut m, true, Some(Species::Skeleton), hit);
m.push_from_explosion(push);
assert_eq!(m.health, health);
pushed(&m);
}
@@ -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
+1 -5
View File
@@ -473,11 +473,7 @@ impl Game<'_> {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
for &pose in poses {
if !self.skin_item(state, pose, light, lights, &mut skin) {
// Decided before any draw, the same for every pose.
+94 -12
View File
@@ -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,
@@ -189,6 +189,9 @@ pub(super) struct LanHost {
/// The entities the guests track, described again every tick
/// (`lan_entities`) over last tick's states.
pub(super) states: lan::entity::States,
/// The entity id counter when this tick's states were taken for guests
/// (`lan_note_joined`), None when no guest tracked them.
pub(super) states_next_entity: Option<u32>,
}
impl Game<'_> {
@@ -1250,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);
}
@@ -1271,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(
@@ -1326,6 +1337,7 @@ impl Game<'_> {
native_commands: None,
sleep_ignored: Default::default(),
states: Default::default(),
states_next_entity: None,
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -1691,15 +1703,17 @@ impl Game<'_> {
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
/// Only the entities whose id is above `after` (0: all of them).
fn lan_entities(
&mut self,
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
hub: &mut Hub,
out: &mut States,
drain_minecart_steps: bool,
after: u32,
) {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
// The lead's holder: the player the mob sees, another mob or a
// fence knot (game/lan_entities.rs).
let player_entity = mob_players
@@ -1733,7 +1747,7 @@ impl Game<'_> {
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
}
for d in &self.dropped {
for d in self.dropped.iter().filter(|d| d.id > after) {
let stack = hub.stack(&Some(d.stack.clone()));
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
e.position = [d.position.x, d.position.y, d.position.z];
@@ -1752,7 +1766,7 @@ impl Game<'_> {
}
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
}
for o in &self.orbs {
for o in self.orbs.iter().filter(|o| o.id > after) {
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
e.position = [o.position.x, o.position.y, o.position.z];
e.on_ground = o.on_ground;
@@ -1761,7 +1775,7 @@ impl Game<'_> {
e.meta
.push((8, serializer::INT, Value::Int(o.value as i32)));
}
self.lan_object_states(hub, out, drain_minecart_steps);
self.lan_object_states(hub, out, drain_minecart_steps, after);
}
/// Apply gameplay events produced while the hub handles guest packets.
@@ -2107,6 +2121,72 @@ impl Game<'_> {
self.lan = Some(host);
}
/// The entity id counter, once the entities Horizon keeps without one
/// have theirs, while guests track the level (None otherwise).
fn lan_ids_so_far(&mut self) -> Option<i32> {
if self.lan.as_ref().is_none_or(|h| h.hub.guests.is_empty()) {
return None;
}
self.lan_assign_ids();
Some(self.next_entity as i32)
}
/// ServerLevel.tick's ChunkMap.tick point, after the block ticks and
/// before the entities': what joined the level up to here (a falling
/// block, a dispensed arrow, the last tick's late joins) is paired and
/// first updated in this tick's pass (`Entities::synced`).
pub(super) fn lan_mark_synced(&mut self) {
if let Some(next) = self.lan_ids_so_far()
&& let Some(host) = self.lan.as_mut()
{
host.hub.synced = vec![(i32::MIN, next)];
}
}
/// The start of a phase vanilla runs before ChunkMap.tick and Horizon
/// among the entities' ticks (ServerChunkCache.tickChunks' spawning and
/// thunder, the raids): the counter to give `lan_synced_since`.
pub(super) fn lan_synced_from(&mut self) -> Option<i32> {
self.lan_ids_so_far()
}
/// The end of such a phase: what joined during it is first updated in
/// this tick's pass too.
pub(super) fn lan_synced_since(&mut self, from: Option<i32>) {
if let Some(from) = from
&& let Some(next) = self.lan_ids_so_far()
&& next > from
&& let Some(host) = self.lan.as_mut()
{
host.hub.synced.push((from, next));
}
}
/// The end of the level's tick: the entities added since its states were
/// taken for the guests (a falling block's drop, a spawner's mob, the
/// host's snowball) are noted as they joined, for the next tick's
/// tracking to pair them from (`Hub::note_joined`). Only they are
/// described (their ids are above the counter the states were taken at),
/// and nothing when the counter has not moved or no guest is in the level.
pub(super) fn lan_note_joined(&mut self) {
let Some(mut host) = self.lan.take() else {
return;
};
if let Some(next) = host.states_next_entity.take()
&& host.hub.guests.iter().any(|g| host.hub.here(g))
{
self.lan_assign_ids();
if self.next_entity != next {
let mut states = States::default();
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, false, next);
self.lan_hook_states(&host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
host.hub.note_joined(states.as_slice());
}
}
self.lan = Some(host);
}
/// Run the hub for this tick and apply what the guests did. While the
/// single player game is `paused` only the connections run
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
@@ -2164,10 +2244,12 @@ impl Game<'_> {
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(&guests, &mut host.hub.riding);
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true);
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
self.lan_hook_states(&host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
host.states_next_entity = Some(self.next_entity);
} else {
host.states_next_entity = None;
states.clear();
// Nobody can receive past movement. A returning viewer pairs
// from the cart's current position/rotation, as AddEntity does.
@@ -2411,7 +2493,7 @@ impl Game<'_> {
return;
}
// A dedicated server has no host player.
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false);
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false, 0);
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
self.lan_entity_uuids(&mut theirs);
theirs.as_slice()
@@ -1349,12 +1349,24 @@ impl Game<'_> {
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
// A block's or a double chest's menu counts among the openers of its
// lid (a vehicle keeps its own `Container`, an ender chest has none).
let counts_openers = matches!(slots, Slots::Block(_) | Slots::Double(_, _));
let hidden = self.native_hidden_slots(pos, items.len());
host.hub
.open_container(conn, menu, title, pos, slots, items);
if !hidden.is_empty() {
host.hub.hide_menu_slots(conn, hidden);
}
// `ContainerOpenersCounter.incrementOpeners` (`ChestBlockEntity.startOpen`,
// `ShulkerBoxBlockEntity.startOpen`): the lid opens for the guest, and
// when this is the first opener the open sound plays for everyone
// around, the opener included (`playSound(null, …)`, which the capture
// relay sends on to the guests). Without it the host's copy never
// learned of the open, so a guest's box opened silently.
if counts_openers && self.container_openers_changed(pos, true) {
self.container_edge_sound(pos, true);
}
true
}
+26 -23
View File
@@ -153,6 +153,7 @@ impl Game<'_> {
hub: &mut Hub,
out: &mut States,
drain_minecart_steps: bool,
after: u32,
) {
let guests: Vec<(u128, i32)> = hub
.guests
@@ -160,7 +161,7 @@ impl Game<'_> {
.filter(|g| hub.here(g))
.map(|g| (g.uuid, g.entity_id))
.collect();
for b in &self.boats {
for b in self.boats.iter().filter(|b| b.id > after) {
let p = b.position;
let mut e = state(b.id, &b.entity_type(), [p.x, p.y, p.z], b.yaw, b.on_ground);
// Leashable.setLeashedTo / dropLeash: who holds the boat's lead.
@@ -188,7 +189,7 @@ impl Game<'_> {
out.push(e);
}
let improvements = self.minecart_improvements();
for c in &mut self.carts {
for c in self.carts.iter_mut().filter(|c| c.id > after) {
let p = c.position;
let mut e = state(c.id, c.kind.id(), [p.x, p.y, p.z], c.yaw, c.on_ground());
e.pitch = c.pitch;
@@ -239,7 +240,7 @@ impl Game<'_> {
}
out.push(e);
}
for a in &self.armor_stands {
for a in self.armor_stands.iter().filter(|a| a.id > after) {
let p = a.position;
let mut e = state(
a.id,
@@ -310,7 +311,7 @@ impl Game<'_> {
e.attributes = self.stand_attributes(a.id, &a.equipment);
out.push(e);
}
for f in &self.item_frames {
for f in self.item_frames.iter().filter(|f| f.id > after) {
// ItemFrame.getAddEntityPacket: the block it hangs in and its direction.
let mut e = state(f.id, f.kind(), f.pos.map(f64::from), 0.0, false);
e.pitch = match f.facing {
@@ -343,7 +344,7 @@ impl Game<'_> {
}
out.push(e);
}
for (p, &id) in &self.knot_ids {
for (p, &id) in self.knot_ids.iter().filter(|&(_, &id)| id > after) {
// LeashFenceKnotEntity.getAddEntityPacket: the fence's block.
out.push(state(
id,
@@ -353,7 +354,7 @@ impl Game<'_> {
false,
));
}
for p in &self.paintings {
for p in self.paintings.iter().filter(|p| p.id > after) {
let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false);
// Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its
// 3D data value and yaw.
@@ -373,7 +374,7 @@ impl Game<'_> {
}
out.push(e);
}
for c in &self.cushions {
for c in self.cushions.iter().filter(|c| c.id > after) {
let p = c.position;
let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false);
// Cushion.DATA_COLOR, set unless it is the default white.
@@ -384,7 +385,7 @@ impl Game<'_> {
out.push(e);
}
// DATA_ITEM, `ItemStack.EMPTY` by default.
for o in self.ominous_spawners.iter().filter(|o| o.id != 0) {
for o in self.ominous_spawners.iter().filter(|o| o.id > after) {
let mut e = state(
o.id,
"minecraft:ominous_item_spawner",
@@ -401,8 +402,8 @@ impl Game<'_> {
));
out.push(e);
}
self.lan_projectile_states(hub, out);
for o in &self.others {
self.lan_projectile_states(hub, out, after);
for o in self.others.iter().filter(|o| o.id > after) {
let p = o.position;
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
e.pitch = o.pitch;
@@ -541,20 +542,20 @@ impl Game<'_> {
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
/// end crystals, as guests track them.
fn lan_projectile_states(&self, hub: &mut Hub, out: &mut States) {
fn lan_projectile_states(&self, hub: &mut Hub, out: &mut States, after: u32) {
let owner = |who: Option<horizon_client::mobs::Who>| match who {
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
Some(horizon_client::mobs::Who::Player) | None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
for f in &self.fangs.list {
for f in self.fangs.list.iter().filter(|f| f.id > after) {
out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false));
}
// LightningBolt: each client draws and flashes its own.
for b in self.bolts.iter().filter(|b| b.id != 0) {
for b in self.bolts.iter().filter(|b| b.id > after) {
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
}
for t in self.tnts.iter().filter(|t| t.id != 0) {
for t in self.tnts.iter().filter(|t| t.id > after) {
let mut e = state(t.id, "minecraft:tnt", at(t.position), 0.0, t.on_ground);
e.velocity = at(t.velocity);
// PrimedTnt.DATA_FUSE_ID, 80 by default; requiresPrecisePosition
@@ -565,7 +566,7 @@ impl Game<'_> {
e.precise = t.on_ground;
out.push(e);
}
for f in self.falling.iter().filter(|f| f.id != 0) {
for f in self.falling.iter().filter(|f| f.id > after) {
let mut e = state(f.id, "minecraft:falling_block", at(f.position), 0.0, false);
e.velocity = at(f.velocity);
// getAddEntityPacket: the block state; DATA_START_POS.
@@ -579,7 +580,7 @@ impl Game<'_> {
}
out.push(e);
}
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
for p in self.creatures.projectiles.iter().filter(|p| p.id > after) {
use horizon_client::projectiles::ProjectileKind as K;
// A spectral arrow is its own entity type.
let kind = if p.is_spectral_arrow() {
@@ -652,7 +653,9 @@ impl Game<'_> {
}
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
// (the potion's colour), which a plain bottle's type already has.
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
for (id, kind, stack, position, velocity) in
self.potions.thrown_tracked().filter(|(id, ..)| *id > after)
{
let mut e = state(id, kind, at(position), 0.0, false);
e.velocity = at(velocity);
if stack.extra.is_some() || stack.count != 1 {
@@ -665,12 +668,12 @@ impl Game<'_> {
out.push(e);
}
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
for (id, position, velocity) in self.end.eyes_tracked() {
for (id, position, velocity) in self.end.eyes_tracked().filter(|(id, ..)| *id > after) {
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
e.velocity = velocity;
out.push(e);
}
for t in self.tridents.iter().filter(|t| t.id != 0) {
for t in self.tridents.iter().filter(|t| t.id > after) {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
e.velocity = at(t.velocity);
@@ -691,7 +694,7 @@ impl Game<'_> {
}
out.push(e);
}
for r in self.rockets.iter().filter(|r| r.id != 0) {
for r in self.rockets.iter().filter(|r| r.id > after) {
let mut e = state(
r.id,
"minecraft:firework_rocket",
@@ -731,7 +734,7 @@ impl Game<'_> {
}
out.push(e);
}
for c in self.crystals.iter().filter(|c| c.id != 0) {
for c in self.crystals.iter().filter(|c| c.id > after) {
let mut e = state(c.id, "minecraft:end_crystal", at(c.position), 0.0, false);
// EndCrystal.DATA_BEAM_TARGET and DATA_SHOW_BOTTOM (true by default).
// The beam's default is listed so that clearing it (the respawn
@@ -757,13 +760,13 @@ impl Game<'_> {
),
None,
);
for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) {
for b in self.dragon_fireballs.iter().filter(|b| b.id > after) {
let p = b.position;
let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false);
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
out.push(e);
}
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
for b in self.breath_clouds.iter().filter(|b| b.id > after) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.waiting() {
@@ -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()) {
+4 -9
View File
@@ -7,7 +7,7 @@
use super::{Game, lan::LanHost};
use horizon_client::{
blocks::{Fluid, MAP_COLORS},
hud::{MAP_SLOTS, Rect},
hud::{MAP_SLOTS, Rect, map_slot_sprite},
items::ItemStack,
mesher::Vertex,
};
@@ -1682,7 +1682,7 @@ impl Game<'_> {
.gui
.atlas
.sprites
.get(&format!("horizon/map{slot}"))
.get(map_slot_sprite(slot)?)
.copied()?;
let m = self.maps.data.get_mut(&id)?;
if m.texture_dirty {
@@ -1744,14 +1744,9 @@ impl Game<'_> {
],
out,
);
let decorations = self
.maps
.data
.get(&id)
.map(|m| m.decorations.clone())
.unwrap_or_default();
let decorations = self.maps.data.get(&id).map_or(&[][..], |m| &m.decorations);
let mut count = 0;
for d in &decorations {
for d in decorations {
if show_only_frame && !shown_on_frame(d.sprite) {
continue;
}
+24
View File
@@ -495,6 +495,30 @@ impl Host for super::Game<'_> {
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
self.native_block_collision(pos)
}
fn entity(&self, id: u32) -> Option<EntityRef> {
self.native_entity(id)
}
fn level(&self, dimension: &str) -> Option<LevelRef> {
self.native_level(dimension)
}
fn entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.native_entity_position(entity)
}
fn entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
self.native_entity_block_position(entity)
}
fn entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
self.native_entity_rotation(entity)
}
fn entity_kind(&self, entity: EntityRef) -> Option<String> {
self.native_entity_kind(entity)
}
fn entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
self.native_entity_uuid(entity)
}
fn entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
self.native_entity_level(entity)
}
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
self.native_item_container_slots(stack)
}
+98 -21
View File
@@ -8,8 +8,8 @@
//! and the player's own in third person with the `ownNameTag` extra.
use super::{Game, view_vector};
use horizon_client::{
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
scoreboard as sb, text::Text,
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, mod_api,
save::uuid_text, scoreboard as sb, text::Text,
};
/// The world size of one text pixel in a name tag: `Matrix4f.scale(0.025F, -0.025F, 0.025F)`.
@@ -103,15 +103,27 @@ impl Game<'_> {
}
/// Each name tag this frame.
fn name_tags(&self, camera: glam::DVec3, alpha: f64) -> Vec<NameTag> {
let hooks = !mod_api::mods().is_empty();
let target = self.mob_hit().map(|(i, _)| self.mobs[i].id);
let viewer = self.player_team();
let mut tags = Vec::new();
let players = self.other_players();
let player_ids = hooks.then(|| {
players
.iter()
.map(|(player, _)| player.id)
.collect::<std::collections::HashSet<u32>>()
});
let named = self.mobs.iter().filter_map(|m| {
if player_ids.as_ref().is_some_and(|ids| ids.contains(&m.id)) {
return None;
}
// A mannequin with no name still shows its description.
let name = match &m.custom_name {
Some(name) => name.clone(),
None if m.species() == horizon_client::mobs::Species::Mannequin => String::new(),
None if hooks || m.species() == horizon_client::mobs::Species::Mannequin => {
String::new()
}
None => return None,
};
// The tag's 64 block distance is measured to the entity: copy the
@@ -180,7 +192,22 @@ impl Game<'_> {
let name_shown = sb::name_shown(team, viewer, view)
&& (player || m.name_visible || Some(m.id) == target)
&& (player || m.custom_name.is_some());
if !name_shown {
let mut visible = name_shown;
let mut text = None;
if hooks {
self.native_render_name_tag(
m.id,
if player {
"minecraft:player"
} else {
&m.kind.id
},
name_shown,
&mut visible,
&mut text,
);
}
if !visible {
if let Some(text) = description {
let l = self.world.light(
m.position.x.floor() as i32,
@@ -211,6 +238,9 @@ impl Game<'_> {
None => member,
}
.resolve_legacy();
let shown = text
.map(|text| Text::literal(text).resolve_legacy())
.unwrap_or(shown);
let mut lines = Vec::new();
// EntityRenderer.extractNameTags: the score shows within the
// below-name distance (10), under the name.
@@ -239,14 +269,17 @@ impl Game<'_> {
discrete,
});
}
drop(player_ids);
// Armor stands name themselves: an invisible one with a visible name
// is a hologram (its model is skipped, its name tag is not).
for a in &self.armor_stands {
let Some(name) = &a.custom_name else {
continue;
};
if !a.name_visible {
continue;
if !hooks {
let Some(_) = &a.custom_name else {
continue;
};
if !a.name_visible {
continue;
}
}
let height = match (a.marker, a.small) {
(true, _) => 0.0,
@@ -268,14 +301,31 @@ impl Game<'_> {
is_camera: false,
is_vehicle: false,
};
if !sb::name_shown(team, viewer, view) {
let name_shown =
a.custom_name.is_some() && a.name_visible && sb::name_shown(team, viewer, view);
let mut visible = name_shown;
let mut text = None;
if hooks {
self.native_render_name_tag(
a.id,
"minecraft:armor_stand",
name_shown,
&mut visible,
&mut text,
);
}
if !visible {
continue;
}
let name = a.custom_name.clone().unwrap_or_else(Text::empty);
let shown = match team {
Some(t) => t.formatted_name(name.clone()),
None => name.clone(),
}
.resolve_legacy();
let shown = text
.map(|text| Text::literal(text).resolve_legacy())
.unwrap_or(shown);
let mut lines = Vec::new();
let mut lift = 0.0;
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
@@ -302,19 +352,43 @@ impl Game<'_> {
}
// ItemFrameRenderer.shouldShowName/getNameTag: only the targeted
// frame's custom-named item, with the HUD visible (also glow frames).
if !self.ui.hide_gui
&& let Some((i, distance)) = self.frame_hit()
&& self.before_block(distance)
&& self.mob_hit().is_none_or(|(_, d)| d > distance)
&& let Some(name) = self.item_frames[i]
.item
.as_ref()
.and_then(|item| item.custom_name())
{
let frame = &self.item_frames[i];
let targeted_frame = (hooks || !self.ui.hide_gui)
.then(|| self.frame_hit())
.flatten()
.filter(|(_, distance)| {
self.before_block(*distance) && self.mob_hit().is_none_or(|(_, d)| d > *distance)
})
.map(|(index, _)| index);
let frames = if hooks {
0..self.item_frames.len()
} else {
targeted_frame.map_or(0..0, |index| index..index + 1)
};
for index in frames {
let frame = &self.item_frames[index];
let name = frame.item.as_ref().and_then(|item| item.custom_name());
if !hooks && name.is_none() {
continue;
}
let (lo, hi) = frame.bounds();
let center = glam::DVec3::from_array(std::array::from_fn(|a| (lo[a] + hi[a]) / 2.0));
if center.distance_squared(camera) < 64.0 * 64.0 {
let name_shown =
!self.ui.hide_gui && targeted_frame == Some(index) && name.is_some();
let mut visible = name_shown;
let mut text = None;
if hooks {
self.native_render_name_tag(
frame.id,
frame.kind(),
name_shown,
&mut visible,
&mut text,
);
}
if !visible {
continue;
}
// NAME_TAG is at the entity height (0.5), then the name
// submitter lifts it 0.5. ItemFrameRenderer offsets rendering
// 0.3 toward its facing and -0.25 vertically.
@@ -326,7 +400,10 @@ impl Game<'_> {
0.75,
f64::from(step[2] as f32 * 0.3),
),
lines: vec![(Text::literal(name).resolve_legacy(), 0.0)],
lines: vec![(
Text::literal(text.or(name).unwrap_or_default()).resolve_legacy(),
0.0,
)],
light: self.world.light(
center.x.floor() as i32,
center.y.floor() as i32,
@@ -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);
}
@@ -126,7 +126,7 @@ fn target_id(t: Target) -> Option<u32> {
#[allow(dead_code)]
impl Game<'_> {
/// Whether this game simulates the level `dimension`.
fn native_entity_level(&self, dimension: &str) -> bool {
fn native_simulates_level(&self, dimension: &str) -> bool {
(self.serving.is_some() || self.options.dedicated.is_some()) && dimension == self.dim.id()
}
/// Spawns an entity of `kind` (`minecraft:item_display`) at `pos` in the
@@ -143,7 +143,7 @@ impl Game<'_> {
pos: [f64; 3],
nbt: Compound,
) -> Option<u32> {
if !self.native_entity_level(dimension) || !position_ok(pos) {
if !self.native_simulates_level(dimension) || !position_ok(pos) {
return None;
}
let chunk = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
@@ -195,7 +195,7 @@ impl Game<'_> {
yaw: f32,
pitch: f32,
) -> bool {
if !self.native_entity_level(dimension)
if !self.native_simulates_level(dimension)
|| !position_ok(pos)
|| !yaw.is_finite()
|| !pitch.is_finite()
@@ -221,7 +221,7 @@ impl Game<'_> {
id: u32,
display: &NativeDisplay,
) -> bool {
if !self.native_entity_level(dimension) || !display.valid() {
if !self.native_simulates_level(dimension) || !display.valid() {
return false;
}
let is_display = self.others.iter().any(|o| {
@@ -250,7 +250,7 @@ impl Game<'_> {
/// another dimension. Mobs go with `native_remove_mob`. API
/// 59.
pub(super) fn native_remove_entity(&mut self, dimension: &str, id: u32) -> bool {
if !self.native_entity_level(dimension) || !self.others.iter().any(|o| o.id == id) {
if !self.native_simulates_level(dimension) || !self.others.iter().any(|o| o.id == id) {
return false;
}
self.others.retain(|o| o.id != id);
@@ -0,0 +1,114 @@
//! Entity and level handles for native server mods (docs/native-mods.md,
//! "Entity and level handles", #7387): `native_entity(id)` turns an event's
//! entity id into an `EntityRef`, and each `native_entity_*` helper reads the
//! entity a handle names. They read where the world is simulated: on a client,
//! with no world open, or for a handle of an earlier open, they return `None`.
//! The entities are those of the level in place (the one a hook runs in) and
//! the server's players, whatever their level.
use super::{Dim, Game, commands::Target};
use horizon_client::mod_api::{EntityRef, LevelRef};
impl Game<'_> {
/// What a handle names now, or None for a stale or foreign one.
fn native_entity_target(&self, entity: EntityRef) -> Option<Target> {
if self.remote.is_some() || self.native_lifecycle.epoch() != Some(entity.epoch()) {
return None;
}
let id = i32::try_from(entity.id()).ok()?;
let player = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.find(|g| g.placed && g.entity_id == id);
if let Some(g) = player {
// `as_guest` swaps the acting player's state into the game.
let acting = self.lan_acting == Some(g.conn) && self.player.is_some();
return Some(if acting {
Target::Player
} else {
Target::Guest(g.conn)
});
}
match self.level_entity(id)? {
Target::Mob(id) if !self.mobs.iter().any(|m| m.id == id && !m.removed) => None,
t => Some(t),
}
}
/// The entity `id` (the id events carry) as a handle for the `entity_*`
/// methods: None on a client, with no world open, or for no such entity
/// in the level in place or among the server's players. API
/// @API:entity_handles@.
pub(super) fn native_entity(&self, id: u32) -> Option<EntityRef> {
let entity = EntityRef::new(self.native_lifecycle.epoch()?, id);
self.native_entity_target(entity).map(|_| entity)
}
/// The level `dimension` (`minecraft:the_nether`) as a handle: None on a
/// client, with no world open, or for a dimension the server hasn't
/// loaded. API @API:entity_handles@.
pub(super) fn native_level(&self, dimension: &str) -> Option<LevelRef> {
if self.remote.is_some() {
return None;
}
let epoch = self.native_lifecycle.epoch()?;
let dim = std::iter::once(self.dim)
.chain(self.levels.parked_dims())
.find(|d| d.id() == dimension)?;
Some(LevelRef::new(epoch, dim.id()))
}
/// The entity's position (`Entity.position`): its feet, the middle of
/// its box for a painting or an item frame. API @API:entity_handles@.
pub(super) fn native_entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.target_position(self.native_entity_target(entity)?)
}
/// The block the entity's position is in (`Entity.blockPosition`). API
/// @API:entity_handles@.
pub(super) fn native_entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
self.native_entity_position(entity)
.map(|p| p.map(|v| v.floor() as i32))
}
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
/// for the kinds that don't turn. API @API:entity_handles@.
pub(super) fn native_entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
Some(self.target_rotation(self.native_entity_target(entity)?))
}
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
/// @API:entity_handles@.
pub(super) fn native_entity_kind(&self, entity: EntityRef) -> Option<String> {
Some(self.target_type(self.native_entity_target(entity)?))
}
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
/// entity that has none yet gets it now, as a new vanilla entity does.
/// API @API:entity_handles@.
pub(super) fn native_entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
let words = match self.native_entity_target(entity)? {
Target::Player => return self.command_guest(self.lan_acting?).map(|g| g.uuid),
Target::Guest(conn) => return self.command_guest(conn).map(|g| g.uuid),
_ => self.uuid_for(entity.id()),
};
Some(uuid_from_words(words))
}
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
pub(super) fn native_entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
let dim: Dim = self.target_level(self.native_entity_target(entity)?);
Some(LevelRef::new(entity.epoch(), dim.id()))
}
}
/// A saved UUID's four ints (`UUIDUtil.uuidFromIntArray`) as one number.
fn uuid_from_words(words: [i32; 4]) -> u128 {
words
.iter()
.fold(0, |uuid, w| (uuid << 32) | u128::from(*w as u32))
}
#[cfg(test)]
mod tests {
#[test]
fn uuid_words_are_most_significant_first() {
let words = [0x0102_0304, -1, 0, 0x7fff_ffff];
assert_eq!(
super::uuid_from_words(words),
0x0102_0304_ffff_ffff_0000_0000_7fff_ffff
);
}
}
+98 -2
View File
@@ -2493,6 +2493,25 @@ impl Game<'_> {
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
mod_api::dispatch_camera_shake(self, mod_api::mods(), partial, shake);
}
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
pub(super) fn native_render_name_tag(
&self,
entity_id: u32,
kind: &str,
vanilla_visible: bool,
visible: &mut bool,
text: &mut Option<String>,
) {
mod_api::dispatch_render_name_tag(
self,
mod_api::mods(),
entity_id,
kind,
vanilla_visible,
visible,
text,
);
}
/// Before one first-person hand and its displayed stack are drawn (RenderHandEvent).
/// Equip progress is the inverse arm height, including the item's swap animation scale.
/// Cancel skips that hand's default geometry, including its map and arm geometry.
@@ -5089,6 +5108,58 @@ impl Game<'_> {
mod_api::dispatch_entity_shoot_bow(self, mod_api::mods(), player, name, projectile, force);
}
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
/// or the player is in creative. Set `cancel` to fail the use.
/// @API:arrow_nock@.
pub(super) fn native_arrow_nock(
&mut self,
player: u128,
name: &str,
bow: &ItemStack,
hand: Hand,
has_ammo: bool,
cancel: &mut bool,
) {
mod_api::dispatch_arrow_nock(
self,
mod_api::mods(),
player,
name,
bow,
hand,
has_ammo,
cancel,
);
}
/// `LivingEntity.getProjectile(weapon)` (NeoForge's
/// `LivingGetProjectileEvent`) for a player (`player` and `name`) or a
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
/// load of a pillager or piglin. `projectile` is the vanilla
/// ammunition; replace it to choose another. A replaced stack is not taken
/// from the inventory. @API:living_get_projectile@.
pub(super) fn native_living_get_projectile(
&mut self,
player: Option<u128>,
name: &str,
entity: Option<u32>,
kind: &str,
weapon: &ItemStack,
projectile: &mut Option<ItemStack>,
) {
mod_api::dispatch_living_get_projectile(
self,
mod_api::mods(),
player,
name,
entity,
kind,
weapon,
projectile,
);
}
/// A guest's fall flying state changed on the server. The change has
/// already been applied. 222.
pub(super) fn native_entity_toggle_glide(&mut self, player: u128, name: &str, gliding: bool) {
@@ -6066,7 +6137,7 @@ impl Game<'_> {
/// release; `native_entity_shoot_bow` reports a spawned arrow afterward
/// with read-only force. Fires on the Game running the bow release, including
/// LAN guests; a remote client's own release path is not covered. Crossbows
/// and `ArrowNockEvent` are not covered. 318.
/// are not covered; `native_arrow_nock` is the bow's start. 318.
pub(super) fn native_arrow_loose(
&mut self,
stack: &ItemStack,
@@ -6173,6 +6244,31 @@ impl Game<'_> {
) {
mod_api::dispatch_explosion_detonate(self, mod_api::mods(), dimension, center, blocks);
}
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
/// `entity` (None for a player, whom `native_acting_player` names):
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
/// what is added to the entity's velocity; a player's client is sent the
/// same. Wind bursts too, except dragons and end crystals. End crystals
/// are also omitted from ordinary blasts: Horizon removes them without a
/// velocity to change. Server-side. @API:explosion_knockback@.
pub(super) fn native_explosion_knockback(
&mut self,
dimension: &str,
center: [f64; 3],
blocks: &[[i32; 3]],
entity: Option<u32>,
knockback_velocity: &mut [f64; 3],
) {
mod_api::dispatch_explosion_knockback(
self,
mod_api::mods(),
dimension,
center,
blocks,
entity,
knockback_velocity,
);
}
/// A player fills (`stack` an empty bucket) or empties a bucket at `pos`:
/// `BucketItem.use`, where it asks `Level.mayInteract`. Set `canceled`
/// and the bucket does nothing. Server-side, for every player of the
@@ -8452,7 +8548,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();
@@ -63,6 +63,12 @@ impl NativeLifecycle {
.is_some_and(|a| a.session == OpenState::Ready && a.context() == *context)
}
/// The epoch of the world open now, from the start of its open to the end
/// of its close: what a mod's entity and level handles carry.
pub(super) fn epoch(&self) -> Option<NativeWorldEpoch> {
self.attempt.map(|a| NativeWorldEpoch::new(a.epoch))
}
/// The open starts: a fresh process-wide epoch, never reused.
pub(super) fn begin_open(&mut self, authority: NativeAuthority) {
let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed);
@@ -54,7 +54,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()
}
+1 -1
View File
@@ -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
+65 -12
View File
@@ -14,6 +14,7 @@ use super::{
entity::{Entity, EntityHandle, with_entity, with_entity_mut},
};
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
use horizon_client::motion::MotionWorld;
use horizon_core::geometry::{Aabb, Axis, Direction, ShapeOp, Vec3, border::BorderGeometry};
use horizon_core::position::BlockPos;
use horizon_world::{
@@ -80,19 +81,10 @@ impl Game<'_> {
/// One box's push, a body at a time. The move resolves as a piston's does
/// (`Entity.move` stops at the blocks and the world border), without a
/// piston's per-tick travel limit or its noclip.
/// piston's per-tick travel limit or its noclip. The local player takes the
/// client's own move instead (`shulker_push_player`).
fn push_for_shulker(&mut self, pos: [i32; 3], facing: u8, lid: [f64; 6], steps: &StepHeights) {
let direction = Direction::ALL[usize::from(facing) % Direction::ALL.len()];
let [x, y, z] = direction.normal();
let movement = Vec3::new(
(lid[3] - lid[0] + 0.01) * f64::from(x),
(lid[4] - lid[1] + 0.01) * f64::from(y),
(lid[5] - lid[2] + 0.01) * f64::from(z),
);
let area = Aabb::new(
Vec3::new(lid[0], lid[1], lid[2]),
Vec3::new(lid[3], lid[4], lid[5]),
);
let (movement, area) = shulker_lid(facing, lid);
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.push_targets().for_each(|target| {
let Some(body) = self.push_body(target, at, steps) else {
@@ -101,6 +93,10 @@ impl Game<'_> {
if !body.bounds.intersects(area) {
return;
}
if target == Target::Player {
self.shulker_push_player(movement);
return;
}
let Ok(moved) = pushed_movement(
SceneCollisions {
world: &self.world,
@@ -111,6 +107,7 @@ impl Game<'_> {
[body.position.x, body.position.z],
body.bounds,
movement,
// `Entity.move(MoverType.SHULKER_BOX, ...)`: steps and reads the ground flag,
body.step_height,
body.on_ground,
) else {
@@ -122,6 +119,43 @@ impl Game<'_> {
});
}
/// `ShulkerBoxBlockEntity.moveCollidedEntities` for the player this game
/// moves itself: the lid's pushes, applied to its own body. A client owns
/// its player's position (the server thread's guest included), so the push
/// is the client's own `Entity.move(MoverType.SHULKER_BOX, …)`.
pub(super) fn push_shulker_local(&mut self, pushes: Vec<ShulkerPush>) {
for (_, facing, lid) in pushes {
let (movement, area) = shulker_lid(facing, lid);
let Some(player) = self.player.as_ref() else {
return;
};
if player.spectator || !player.bounds().intersects(area) {
continue;
}
self.shulker_push_player(movement);
}
}
/// `Entity.move(MoverType.SHULKER_BOX, …)` for the local player: an
/// ordinary move, so the collision scan steps it and its end writes the
/// ground state (`Entity.setOnGroundWithMovement`). A lid lifting the body
/// off the ground leaves it airborne, and the next tick's gravity lands it
/// on the lid instead of a stale ground flag holding it in place.
fn shulker_push_player(&mut self, movement: Vec3) {
let entities = self.player_entity_collisions();
if let Some(player) = self.player.as_mut() {
let _ = MotionWorld {
entities: &entities,
player,
scene: &mut self.world,
assets: &self.pack.assets,
collision_binding: self.collision,
resources: self.motion,
}
.shulker_box_push(movement);
}
}
fn push_for_piston(
&mut self,
(pos, piston, new_progress): Push,
@@ -534,6 +568,25 @@ impl Game<'_> {
}
}
/// The lid's push: the swept box in world space (`Shulker.
/// getProgressDeltaAabb`) and the movement out along the facing, the box's own
/// size plus 0.01 (`ShulkerBoxBlockEntity.moveCollidedEntities`).
fn shulker_lid(facing: u8, lid: [f64; 6]) -> (Vec3, Aabb) {
let direction = Direction::ALL[usize::from(facing) % Direction::ALL.len()];
let [x, y, z] = direction.normal();
(
Vec3::new(
(lid[3] - lid[0] + 0.01) * f64::from(x),
(lid[4] - lid[1] + 0.01) * f64::from(y),
(lid[5] - lid[2] + 0.01) * f64::from(z),
),
Aabb::new(
Vec3::new(lid[0], lid[1], lid[2]),
Vec3::new(lid[3], lid[4], lid[5]),
),
)
}
/// `Entity.move(MoverType.PISTON, movement)` for a pushed body: the movement
/// it makes. The world's blocks stop it, and so does the world border:
/// `Entity.collectCollidersIgnoringWorldBorder` adds the border's walls when
+36 -9
View File
@@ -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);
}
_ => {}
}
}
@@ -8097,14 +8124,14 @@ impl Game<'_> {
// BundleItem.use: always starts; the server drops the contents.
_ if bundle => true,
// BowItem.use: with an arrow, or in creative.
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
"minecraft:bow" => self.get_projectile(held).is_some() || self.creative,
// CrossbowItem.use: charging when not charged and with ammunition;
// a charged one shoots on the server.
"minecraft:crossbow" => {
if held.data().is_some_and(|d| !d.charged.is_empty()) {
return true;
}
self.creative || self.projectile_slot("minecraft:crossbow").is_some()
self.get_projectile(held).is_some()
}
// TridentItem.use: not about to break, Riptide only in water or rain.
"minecraft:trident" => {
@@ -214,6 +214,10 @@ impl Game<'_> {
at = [at[0] + dx, at[1] + dy, at[2] + dz];
}
let pitch = self.world.random.next_float() * 0.1 + 0.9;
// A server's `playSound(null, …)`: every player around, the opener too.
// `Audio::play` captures the sound while the world is open to the
// network and `lan_broadcast_effects` sends that tick to every guest,
// so nothing is queued separately (a second queue played it twice).
self.audio.play(sound, Some(at), 0.5, pitch);
}
/// The open or close sound of the container block at `key`, if it has one.
+19 -14
View File
@@ -226,8 +226,10 @@ impl Game<'_> {
multiplier: f32,
breeze: bool,
wind_charge: bool,
blocklike: bool,
blocks: &[[i32; 3]],
) -> Vec<(u32, [f64; 3])> {
// ServerExplosion.shouldAffectBlocklikeEntities
let blocklike = !breeze && !wind_charge && self.ui.rules.bool("mob_griefing");
let diameter = f64::from(radius * 2.0);
let distance = |p: [f64; 3]| {
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2))
@@ -249,7 +251,7 @@ impl Game<'_> {
// The player, then every other player in the level (LAN guests),
// each swapped in as the player. In a guest's turn (a mace's wind
// burst) the player is that guest.
let own = self.wind_push_player(center, radius, multiplier, breeze, wind_charge);
let own = self.wind_push_player(center, radius, multiplier, breeze, wind_charge, blocks);
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
if let Some(mut host) = self.lan.take() {
let guests: Vec<u32> = host
@@ -261,7 +263,7 @@ impl Game<'_> {
.collect();
for conn in guests {
let push = self.as_guest(&mut host, conn, |g| {
g.wind_push_player(center, radius, multiplier, breeze, wind_charge)
g.wind_push_player(center, radius, multiplier, breeze, wind_charge, blocks)
});
if let Some(Some(push)) = push {
pushed.push((conn, push));
@@ -295,14 +297,17 @@ impl Game<'_> {
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let power = (1.0 - dist) * f64::from(exposure) * multiplier;
let dir = direction([feet[0], feet[1] + entity.explosion_eye_height(), feet[2]]);
entity.push_from_explosion(Vec3::new(dir[0] * power, dir[1] * power, dir[2] * power));
let mut push = [dir[0] * power, dir[1] * power, dir[2] * power];
let id = Some(entity.id());
self.native_explosion_knockback(self.dim.id(), center, blocks, id, &mut push);
entity.push_from_explosion(Vec3::new(push[0], push[1], push[2]));
});
self.restore_entities(entities);
pushed
}
/// `ServerExplosion.hurtEntities` for the player (not a spectator, nor a
/// creative player flying): the push, less with explosion knockback
/// resistance, and `ServerPlayer.onExplosionHit`. Returns the push.
/// `ServerExplosion.hurtEntities` for the player: the push, less
/// explosion knockback resistance, and `ServerPlayer.onExplosionHit`.
/// Returns the push for players eligible for the explosion packet.
fn wind_push_player(
&mut self,
center: [f64; 3],
@@ -310,6 +315,7 @@ impl Game<'_> {
multiplier: f32,
breeze: bool,
wind_charge: bool,
blocks: &[[i32; 3]],
) -> Option<[f64; 3]> {
if self.spectator() {
return None;
@@ -327,7 +333,8 @@ impl Game<'_> {
+ (feet[2] - center[2]).powi(2))
.sqrt()
/ f64::from(radius * 2.0);
if dist > 1.0 || creative && p.flying {
let flying = creative && p.flying;
if dist > 1.0 {
return None;
}
let w = f64::from(p.dimensions.width) / 2.0;
@@ -344,11 +351,12 @@ impl Game<'_> {
);
let power = (1.0 - dist) * f64::from(exposure) * f64::from(multiplier) * (1.0 - resistance);
let l = (eye[0] * eye[0] + eye[1] * eye[1] + eye[2] * eye[2]).sqrt();
let push = if l < 1.0e-5 {
let mut push = if l < 1.0e-5 {
[0.0; 3]
} else {
eye.map(|d| d / l * power)
};
self.native_explosion_knockback(self.dim.id(), center, blocks, None, &mut push);
let p = self.player.as_mut()?;
p.velocity = Vec3::new(
p.velocity.x + push[0],
@@ -374,7 +382,7 @@ impl Game<'_> {
..EntityView::default()
});
self.adv.marks.blast = Some(([at.x, at.y, at.z], cause));
Some(push)
(!flying).then_some(push)
}
/// The burst: knockback, the blocks that react, the gust and the sound.
pub(super) fn wind_burst(
@@ -402,10 +410,7 @@ impl Game<'_> {
let ctx = horizon_client::sculk::Context::default();
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.wind_positions(center, radius);
// ServerExplosion.shouldAffectBlocklikeEntities
let blocklike = !breeze && !wind_charge && self.ui.rules.bool("mob_griefing");
let pushed =
self.wind_knockback(center, radius, multiplier, breeze, wind_charge, blocklike);
let pushed = self.wind_knockback(center, radius, multiplier, breeze, wind_charge, &targets);
let block_count = targets.len();
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
if !breeze || self.ui.rules.bool("mob_griefing") {
@@ -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(),
)
};
+13 -17
View File
@@ -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
),
}
}
}
@@ -1342,11 +1346,7 @@ mod tests {
};
state.submit(&r, &sprites, &NoHost, pose, &mut sink);
frame.clear_draws();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = Skin::new(&mut models, &mut frame);
if !state.submit_skinned(&r, &sprites, pose, (0x50_a0, lights, 1), &mut skin) {
assert!(frame.jobs.is_empty(), "{id}: left to the CPU after drawing");
left += 1;
@@ -1388,11 +1388,7 @@ mod tests {
"torch",
] {
frame.clear_draws();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = Skin::new(&mut models, &mut frame);
let state = resolve(&format!("minecraft:{id}"));
assert!(
state.submit_skinned(&r, &sprites, pose, (0, None, 0), &mut skin),
+53
View File
@@ -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(),
+169 -2
View File
@@ -257,7 +257,8 @@ struct ServerEntity {
/// The lead's holder the clients were told of.
leash: Option<i32>,
/// The level tick it was last seen in, its place in that tick's list, and
/// whether it was added in it (its first update is the next tick's).
/// whether it was added in it (its first update is the next tick's, but
/// for one in `Entities::synced`).
seen: u32,
index: usize,
added: bool,
@@ -831,6 +832,14 @@ pub struct Entities {
/// `entity-broadcast-range-percentage` (None: 100), which scales every
/// type's tracking range (MinecraftServer.getScaledTrackingDistance).
pub range_percent: Option<i32>,
/// ServerLevel.tick runs ChunkMap.tick after the block ticks, the raids
/// and ServerChunkCache.tickChunks' spawning and thunder, and before the
/// entities: an entity that joined the level by then (ids in these
/// ranges, each from after its first number up to its second; a player
/// aside) has its first update in the pass that pairs it, before it
/// moves. One the entities' own ticks added waits for the next pass, as
/// vanilla's waits for the next tick.
pub synced: Vec<(i32, i32)>,
states: crate::fast_hash::FxHashMap<i32, ServerEntity>,
/// The level tick, stamped on every entity still in the level.
tick: u32,
@@ -875,6 +884,58 @@ impl Entities {
self.states.is_empty()
}
/// Whether the entity `id` is tracked (it was in the last tick's pass).
pub fn tracks(&self, id: i32) -> bool {
self.states.contains_key(&id)
}
/// ChunkMap.addEntity tracks an entity as it joins the level, so its
/// clients are paired with it as it was then. An entity the game added
/// after the last tick's states were taken is first seen a tick later:
/// it starts from the state it joined with (`joined`), not from where
/// its first tick took it. Of the joined states, those of entities still
/// in the level and not tracked yet are kept, with what this tick's own
/// state carries for this tick alone (the minecart steps drained into it,
/// `needs_sync`).
pub fn joined_states(
&self,
entities: &[&EntityState],
mut joined: Vec<EntityState>,
) -> crate::fast_hash::FxHashMap<i32, EntityState> {
if joined.is_empty() {
return Default::default();
}
let now: crate::fast_hash::FxHashMap<i32, &EntityState> = entities
.iter()
.filter(|e| !self.tracks(e.id))
.map(|e| (e.id, *e))
.collect();
joined
.drain(..)
.filter_map(|mut j| {
let e = now.get(&j.id)?;
j.minecart_steps.clone_from(&e.minecart_steps);
j.needs_sync = e.needs_sync;
Some((j.id, j))
})
.collect()
}
/// Puts the `joined_states` in place of this tick's own.
pub fn starting_from<'a>(
entities: &mut [&'a EntityState],
joined: &'a crate::fast_hash::FxHashMap<i32, EntityState>,
) {
if joined.is_empty() {
return;
}
for e in entities.iter_mut() {
if let Some(j) = joined.get(&e.id) {
*e = j;
}
}
}
/// One tick of ChunkMap.tick for the entities in the level now and the
/// clients in it: each client first loses the entities that left its
/// range or the level (one ClientboundRemoveEntitiesPacket), then each
@@ -1093,7 +1154,15 @@ impl Entities {
continue;
};
self.packets.clear();
if !s.added {
// Paired as it joined, then its first changes go to everyone
// tracking it, those just paired with it included.
let synced = s.added
&& e.kind != "minecraft:player"
&& self
.synced
.iter()
.any(|&(after, through)| e.id > after && e.id <= through);
if !s.added || synced {
s.send_changes(e, riding, &mut self.packets);
}
s.added = false;
@@ -1123,6 +1192,13 @@ impl Entities {
packets.iter().map(play::Clientbound::encode).collect()
});
v.out.extend(bodies.iter().cloned());
if synced && !self.packets.is_empty() {
if !encoded {
encode_all(&self.packets, &mut self.bodies);
encoded = true;
}
v.out.extend(self.bodies.iter().cloned());
}
if v.tracker
.seen
.insert(e.id, Arc::clone(&s.tracked))
@@ -1731,6 +1807,97 @@ mod tests {
assert_eq!(c.out[0], c.out[1]);
}
/// ChunkMap.addEntity pairs an entity as it joined the level: one the
/// game added after the last tick's pass (an item a landing falling
/// block drops) is paired from where it joined, not from where its first
/// tick took it, and its first update moves it from there; what its
/// first tick alone carries (needs_sync) is kept. Once tracked, its own
/// state is used again.
#[test]
fn an_entity_added_after_the_pass_is_paired_as_it_joined() {
let mut level = Entities::default();
let mut c = Clients::new();
let mut joined = state(1.0);
joined.velocity = [0.0, 0.2, 0.0];
let mut first = state(1.5);
first.needs_sync = true;
let mut entities = vec![&first];
let joins = level.joined_states(&entities, vec![joined.clone()]);
assert!(joins[&7].needs_sync);
Entities::starting_from(&mut entities, &joins);
assert_eq!(entities[0].position, [1.0, 64.0, 0.0]);
c.tick(&mut level, &entities);
let add = body(play::Clientbound::AddEntity(AddEntity {
id: 7,
uuid: 1,
kind: 100,
position: [1.0, 64.0, 0.0],
velocity: [0.0, 0.2, 0.0],
pitch: 0,
yaw: 0,
head_yaw: 0,
data: 0,
}));
assert_eq!(c.out[0], [add]);
let later = state(2.0);
let entities = vec![&later];
assert!(level.joined_states(&entities, vec![joined]).is_empty());
c.tick(&mut level, &entities);
let moved = body(play::Clientbound::MoveEntityPos(MoveEntityPos(
MoveEntity {
id: 7,
delta: Some(VecDelta::Linear([4096, 0, 0])),
rotation: None,
on_ground: true,
},
)));
assert!(c.out[0].contains(&moved), "{:?}", c.out[0]);
}
/// An entity that joined before the tick's ChunkMap.tick point (a
/// falling block from a block tick, a naturally spawned mob) has its
/// first update in the pass that pairs it: tick 0 of its interval,
/// before it moves, which the client just paired with gets too (a move
/// by nothing: `tickCount % 60 == 0`). A falling block that lands within
/// 20 ticks is never sent a move. One added by the entities' ticks has
/// its first update in the next pass.
#[test]
fn an_entity_joined_before_the_chunk_map_tick_is_updated_as_it_is_paired() {
let mut sand = state(0.5);
sand.kind = "minecraft:falling_block".into();
let move_by = |dy: i16| {
body(play::Clientbound::MoveEntityPos(MoveEntityPos(
MoveEntity {
id: 7,
delta: Some(VecDelta::Linear([0, dy, 0])),
rotation: None,
on_ground: true,
},
)))
};
let passes = |synced: Vec<(i32, i32)>| {
let mut level = Entities {
synced,
..Entities::default()
};
let mut c = Clients::new();
c.tick(&mut level, &[&sand]);
let paired = c.out[0][1..].to_vec();
let mut fallen = sand.clone();
fallen.position[1] -= 0.04;
c.tick(&mut level, &[&fallen]);
(paired, c.out[0].clone())
};
// Updated in place as it was paired; its fall waits for tick 20.
for synced in [vec![(i32::MIN, 7)], vec![(i32::MIN, 3), (5, 9)]] {
assert_eq!(passes(synced), (vec![move_by(0)], vec![]));
}
// Its first update is the next pass's, after it fell.
for synced in [vec![], vec![(i32::MIN, 6)], vec![(7, 9)]] {
assert_eq!(passes(synced), (vec![], vec![move_by(-164)]));
}
}
#[test]
fn clients_lose_what_leaves_range_or_the_level_and_never_track_themselves() {
let mut level = Entities::default();
+55 -1
View File
@@ -356,6 +356,12 @@ pub struct Hub {
entity_types: std::collections::HashMap<String, i32>,
/// Each level's tracked entities, by dimension.
entities: std::collections::HashMap<String, Entities>,
/// Each level's entities that joined it after its tracking pass, as they
/// were then, and the server tick they were noted in (`note_joined`).
joined: std::collections::HashMap<String, (u64, Vec<EntityState>)>,
/// The ranges of entity ids taken this tick in the phases vanilla runs
/// before the level's ChunkMap.tick (`Entities::synced`).
pub synced: Vec<(i32, i32)>,
/// What one tick of entity tracking sends each guest, kept between ticks.
entity_out: Vec<Vec<Vec<u8>>>,
/// Each placed guest's entities gone this tick, where they were last sent (only while the
@@ -636,6 +642,8 @@ impl Hub {
item_ids: Default::default(),
entity_types: Default::default(),
entities: Default::default(),
joined: Default::default(),
synced: Vec::new(),
entity_out: Vec::new(),
entity_gone: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
@@ -2563,6 +2571,22 @@ impl Hub {
out
}
/// The entities of this level its tracking has not met yet, as they are at
/// the end of the tick that added them (after the tick's tracking pass:
/// falling blocks, primed TNT and block entities tick after it). The next
/// tick's pass pairs them as they were, as ChunkMap.addEntity would have;
/// a later one ignores them.
pub fn note_joined(&mut self, entities: &[EntityState]) {
let level = self.entities.get(&self.config.dimension);
let joined = entities
.iter()
.filter(|e| level.is_none_or(|l| !l.tracks(e.id)))
.cloned()
.collect();
self.joined
.insert(self.config.dimension.clone(), (self.ticks, joined));
}
/// All entities as one client sees them: the world's and the other players.
fn track_entities(&mut self, ctx: &mut TickCtx) {
let mut all: Vec<EntityState> = Vec::with_capacity(ctx.entities.len() + self.guests.len());
@@ -2611,12 +2635,18 @@ impl Hub {
})
.collect();
all.extend(guest_states);
let entities: Vec<&EntityState> = all.iter().chain(ctx.entities).collect();
let mut entities: Vec<&EntityState> = all.iter().chain(ctx.entities).collect();
// ChunkMap.tick: the level's entities, once for all its players.
let mut level = self
.entities
.remove(&self.config.dimension)
.unwrap_or_default();
let joined = level.joined_states(
&entities,
noted_last_tick(self.joined.remove(&self.config.dimension), self.ticks),
);
Entities::starting_from(&mut entities, &joined);
level.synced.clone_from(&self.synced);
let mut outs = std::mem::take(&mut self.entity_out);
let dimension = &self.config.dimension;
let here = |g: &Guest| g.placed && g.dimension == *dimension;
@@ -4101,6 +4131,16 @@ fn raw(id: i32, payload: Vec<u8>) -> C {
C::Unknown(id, payload)
}
/// The joined states `Hub::note_joined` kept, if it kept them in the last
/// server tick: a level nobody tracked since (its guests gone, or none in it)
/// would otherwise pair its entities from states long past.
fn noted_last_tick(noted: Option<(u64, Vec<EntityState>)>, ticks: u64) -> Vec<EntityState> {
match noted {
Some((tick, joined)) if tick + 1 == ticks => joined,
_ => Vec::new(),
}
}
/// ClientboundTickingStatePacket: the tick rate and whether the game is frozen.
fn ticking_state(rate: f32, frozen: bool) -> C {
let mut w = horizon_protocol::buf::Writer::new();
@@ -4307,6 +4347,20 @@ mod tests {
);
}
/// Joined states are used in the server tick after the one that noted
/// them, never later.
#[test]
fn joined_states_last_only_into_the_next_tick() {
let joined = vec![EntityState {
id: 7,
..EntityState::default()
}];
assert_eq!(noted_last_tick(Some((5, joined.clone())), 6), joined);
assert!(noted_last_tick(Some((5, joined.clone())), 5).is_empty());
assert!(noted_last_tick(Some((5, joined)), 9).is_empty());
assert!(noted_last_tick(None, 6).is_empty());
}
/// `getMaximumFlyingTicks`: 80 ticks at normal gravity, ceil(80 * 0.08 /
/// gravity) where it is weaker, and never a kick without gravity.
#[test]
+81
View File
@@ -17,6 +17,11 @@ pub trait Rules {
fn max_stack(&self, item: &str) -> u32;
/// The armor slot (0 head .. 3 feet) an item can be worn in.
fn armor_index(&self, item: &str) -> Option<usize>;
/// `ItemStack.getEquipmentSlot`: the `minecraft:equippable` component's
/// slot ("head", "chest", "legs", "feet", "mainhand", "offhand").
fn equip_slot(&self, item: &str) -> Option<&str> {
self.items().get(item)?.equip_slot.as_deref()
}
}
/// An open block container.
@@ -346,6 +351,23 @@ fn quick_move(view: &mut View, i: usize, rules: &dyn Rules) {
let mut rest = stack.clone();
rest.count -= 1;
(rest.count > 0).then_some(rest)
} else if rules.equip_slot(&stack.item) == Some("offhand") && view.get(45).is_none() {
// A shield or a totem goes to the empty off hand before the
// hotbar (InventoryMenu.quickMoveStack's OFFHAND branch).
let max = view.slot_max(45, &stack, rules);
let moved = stack.count.min(max);
view.set(
45,
Some(ItemStack {
count: moved,
..stack.clone()
}),
);
let rest = stack.count - moved;
(rest > 0).then_some(ItemStack {
count: rest,
..stack.clone()
})
} else if hotbar.contains(&i) {
move_to(view, stack, main, false, rules)
} else {
@@ -1042,4 +1064,63 @@ mod tests {
assert!(view.get(9).is_some());
}
}
struct EquipRules;
impl Rules for EquipRules {
fn items(&self) -> &Items {
unreachable!("a quick move reads no item catalog");
}
fn max_stack(&self, _: &str) -> u32 {
64
}
fn armor_index(&self, _: &str) -> Option<usize> {
None
}
fn equip_slot(&self, item: &str) -> Option<&str> {
(item == "minecraft:shield").then_some("offhand")
}
}
/// A shift-click in the inventory menu puts a shield in the empty off-hand
/// slot: `InventoryMenu.quickMoveStack` asks `getEquipmentSlotForItem` for
/// `EquipmentSlot.OFFHAND` and moves it to slot 45 before it tries the
/// hotbar. A full off hand, or an item with no `equippable` slot, still
/// goes to the hotbar.
#[test]
fn inventory_quick_move_equips_an_off_hand_item() {
let rules = EquipRules;
let mut g = guest();
let mut drag = None;
let mut view = View {
guest: &mut g,
container: None,
shulker: false,
hidden: &[],
};
// Menu slot 9 is the first main-inventory slot, 45 the off hand.
view.guest.inventory[9] = Some(ItemStack::new("minecraft:shield", 1));
click(&mut view, &mut drag, 9, 0, input::QUICK_MOVE, &rules);
assert_eq!(
view.get(45).as_ref().map(|s| &*s.item),
Some("minecraft:shield")
);
assert!(view.get(9).is_none());
assert!(view.get(36).is_none());
// The off hand is taken: the next shield goes to the hotbar instead.
view.guest.inventory[9] = Some(ItemStack::new("minecraft:shield", 1));
click(&mut view, &mut drag, 9, 0, input::QUICK_MOVE, &rules);
assert_eq!(
view.get(36).as_ref().map(|s| &*s.item),
Some("minecraft:shield")
);
assert!(view.get(9).is_none());
// An item that equips nowhere is untouched by the off-hand branch.
view.guest.inventory[10] = Some(ItemStack::new("minecraft:diamond", 3));
click(&mut view, &mut drag, 10, 0, input::QUICK_MOVE, &rules);
assert_eq!(view.get(37).as_ref().map(|s| s.count), Some(3));
assert_eq!(
view.get(45).as_ref().map(|s| &*s.item),
Some("minecraft:shield")
);
}
}
+5 -1
View File
@@ -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(),
))
}
+1
View File
@@ -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 {
+5 -3
View File
@@ -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;
}
}
+39 -3
View File
@@ -391,7 +391,7 @@ pub struct MobType {
pub model: Arc<Part>,
/// Every baked layer of this entity's models keyed by `model#layer`
/// (e.g. `sheep#wool`, `zombie_baby#main`).
pub layers: HashMap<String, Arc<Part>>,
pub layers: crate::fast_hash::FxHashMap<String, Arc<Part>>,
pub texture: String,
pub brain: Brain,
pub anim: Anim,
@@ -465,7 +465,7 @@ impl MobTypes {
};
let defaults = exact.get(&id).cloned().unwrap_or_default();
let attr = |k: &str| defaults.get(&format!("minecraft:{k}")).copied();
let mut layers = HashMap::new();
let mut layers = crate::fast_hash::FxHashMap::default();
if let Some(all) = models.as_object() {
for prefix in def.layer_prefixes() {
let start = format!("minecraft:{prefix}#");
@@ -1161,6 +1161,8 @@ pub struct Mob {
pub previous_limb_speed: f32,
pub swell: i32,
pub previous_swell: i32,
/// Creeper.maxSwell (`Fuse`): local NBT state, not synced entity data.
pub max_swell: i32,
pub ambient_timer: i32,
pub fall_distance: f64,
/// `Entity.stuckSpeedMultiplier`: the next move is scaled by it.
@@ -1545,6 +1547,18 @@ pub enum MobEvent {
amount: f32,
source: Damage,
},
/// A melee hit on another mob whose effect rides the hit:
/// `CaveSpider`, `Husk` and `WitherSkeleton.doHurtTarget` add it inside
/// the `super.doHurtTarget` branch, to any `LivingEntity`, so a mob the
/// hit lands on gets it too.
AttackMobWithEffect {
target: u32,
amount: f32,
source: Damage,
effect: &'static str,
ticks: i32,
amplifier: i32,
},
/// `AbstractCubeMob.dealDamage` on the player (`playerTouch`): the hit,
/// then, only when `hurtServer` let it land, the cube's
/// `entity.slime.attack` with its pitch drawn from the cube's random.
@@ -1613,6 +1627,27 @@ pub enum MobEvent {
vehicle: u32,
},
Projectile(crate::projectiles::Projectile),
/// An arrow `mob` shot with `weapon` (`AbstractSkeleton.performRangedAttack`):
/// the game asks for the ammunition (`getProjectile`) before it flies.
BowShot {
mob: u32,
weapon: crate::items::ItemStack,
arrow: crate::projectiles::Projectile,
},
/// `mob` finished charging `weapon` (`CrossbowItem.onUseTick`): the game
/// asks for the ammunition (`getProjectile`), and plays the end sound
/// at `at` only when the crossbow loaded.
CrossbowLoad {
mob: u32,
weapon: crate::items::ItemStack,
at: [f64; 3],
pitch: f32,
},
/// `arrow` shot from the `ammo` a mob's crossbow was charged with.
CrossbowShot {
ammo: crate::items::ItemStack,
arrow: crate::projectiles::Projectile,
},
/// Spawn a new mob (breeding, slime splitting, chicken eggs...).
Spawn(Box<Mob>),
/// Spawn a mob by type id at a position (conversions, splits).
@@ -2603,6 +2638,7 @@ impl Mob {
previous_limb_speed: 0.0,
swell: 0,
previous_swell: 0,
max_swell: 30,
// Mob.ambientSoundTime starts at 0.
ambient_timer: 0,
fall_distance: 0.0,
@@ -3968,7 +4004,7 @@ pub(crate) mod sensor_scan_tests {
..Default::default()
},
model: Arc::new(Part::parse("root", &json!({}))),
layers: HashMap::new(),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Rabbit,
+164 -29
View File
@@ -2346,9 +2346,12 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
let from = [m.position.x, m.position.y, m.position.z];
let source = Damage::mob(m.id, from);
let first_event = cx.events.len();
// `CaveSpider`, `Husk` and `WitherSkeleton.doHurtTarget` all test
// `target instanceof LivingEntity`, so the effect reaches a mob the hit
// lands on and not only the player.
match m.species() {
// CaveSpider.doHurtTarget: poison for 7 s on normal, 15 s on hard.
Species::CaveSpider if who == Who::Player && cx.w.difficulty.base >= 2 => {
Species::CaveSpider if cx.w.difficulty.base >= 2 => {
cx.events.push(MobEvent::EffectPlayer {
effect: "minecraft:poison",
ticks: if cx.w.difficulty.base == 2 { 7 } else { 15 } * 20,
@@ -2357,7 +2360,7 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
}
// Husk.doHurtTarget: hunger for 140 ticks per whole point of the
// local difficulty, only with an empty main hand.
Species::Husk if who == Who::Player && m.extra.main_hand.is_none() => {
Species::Husk if m.extra.main_hand.is_none() => {
let ticks = 140 * cx.w.difficulty.effective as i32;
if ticks > 0 {
cx.events.push(MobEvent::EffectPlayer {
@@ -2367,7 +2370,7 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
})
}
}
Species::WitherSkeleton if who == Who::Player => cx.events.push(MobEvent::EffectPlayer {
Species::WitherSkeleton => cx.events.push(MobEvent::EffectPlayer {
effect: "minecraft:wither",
ticks: 200,
amplifier: 0,
@@ -2415,33 +2418,45 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
seconds: 2,
});
}
// The effect picked above goes with the hit: it applies only when the
// target takes damage (a shield-blocked hit gives none), for a mob as for
// the player, since those `doHurtTarget`s take any `LivingEntity`.
let effect = cx.events[first_event..]
.iter()
.position(|e| matches!(e, MobEvent::EffectPlayer { .. }))
.and_then(|k| match cx.events.remove(first_event + k) {
MobEvent::EffectPlayer {
effect,
ticks,
amplifier,
} => Some((effect, ticks, amplifier)),
_ => None,
});
match who {
// The effect picked above goes with the hit: it applies only when
// the player takes damage (a shield-blocked hit gives none).
Who::Player => {
let effect = cx.events[first_event..]
.iter()
.position(|e| matches!(e, MobEvent::EffectPlayer { .. }))
.map(|k| cx.events.remove(first_event + k));
cx.events.push(match effect {
Some(MobEvent::EffectPlayer {
effect,
ticks,
amplifier,
}) => MobEvent::AttackPlayerWithEffect {
amount,
from,
effect,
ticks,
amplifier,
},
_ => MobEvent::AttackPlayer(amount, from),
});
}
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
target: id,
amount,
source,
Who::Player => cx.events.push(match effect {
Some((effect, ticks, amplifier)) => MobEvent::AttackPlayerWithEffect {
amount,
from,
effect,
ticks,
amplifier,
},
None => MobEvent::AttackPlayer(amount, from),
}),
Who::Mob(id) => cx.events.push(match effect {
Some((effect, ticks, amplifier)) => MobEvent::AttackMobWithEffect {
target: id,
amount,
source,
effect,
ticks,
amplifier,
},
None => MobEvent::AttackMob {
target: id,
amount,
source,
},
}),
}
// Mob.doHurtTarget ends with playAttackSound once the hit landed.
@@ -2978,6 +2993,126 @@ mod poison_effect_tests {
}
}
#[cfg(test)]
mod hit_effect_tests {
use super::*;
use crate::mobs::world::with_test_world;
use std::sync::Arc;
/// The effects of a cave spider's bite, a bare-handed husk's blow and a
/// wither skeleton's hit go to the `LivingEntity` the hit landed on:
/// `doHurtTarget` tests `target instanceof LivingEntity`, not `Player`.
/// Biting a mob carries the effect with the hit — poison for 140 ticks on
/// normal, hunger for 140 (effective difficulty 1.5), wither for 200 —
/// where the mob is hit 2. Without the fix each of these is a plain
/// `AttackMob` and the mob keeps no effect at all (0 ticks).
#[test]
fn a_melee_effect_rides_the_hit_onto_a_mob() {
let cases = [
("minecraft:cave_spider", "minecraft:poison", 140),
("minecraft:husk", "minecraft:hunger", 140),
("minecraft:wither_skeleton", "minecraft:wither", 200),
];
let mut carried: Vec<(&str, Option<(u32, &'static str, i32, i32)>)> = Vec::new();
let mut strays: Vec<(&str, bool, bool)> = Vec::new();
let ran = with_test_world(&[], |w, types| {
for (kind, _, _) in cases {
let mut m = Mob::new(
1,
Arc::new(types.types[kind].clone()),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
let (mut lr, mut events) = (horizon_core::random::LegacyRandom::new(0), Vec::new());
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
do_hurt_target(&mut m, Who::Mob(2), &mut cx);
carried.push((
kind,
events.iter().find_map(|e| match e {
MobEvent::AttackMobWithEffect {
target,
effect,
ticks,
amplifier,
..
} => Some((*target, *effect, *ticks, *amplifier)),
_ => None,
}),
));
strays.push((
kind,
events
.iter()
.any(|e| matches!(e, MobEvent::EffectPlayer { .. })),
events
.iter()
.any(|e| matches!(e, MobEvent::AttackMob { .. })),
));
}
});
if !ran {
return;
}
let expected: Vec<(&str, Option<(u32, &'static str, i32, i32)>)> = cases
.iter()
.map(|(kind, effect, ticks)| (*kind, Some((2, *effect, *ticks, 0))))
.collect();
assert_eq!(carried, expected);
for (kind, on_player, effectless) in strays {
assert!(!on_player, "{kind} left its effect on the player");
assert!(!effectless, "{kind} sent a second, effectless hit");
}
}
/// On easy the cave spider's poison timer is 0, so nothing rides the hit:
/// the mob it bites takes the damage alone (`AttackMob`, no effect).
#[test]
fn an_easy_cave_spiders_bite_carries_no_effect() {
let mut out: Option<Vec<String>> = None;
let ran = with_test_world(&[], |w, types| {
let mut easy = w.clone();
easy.difficulty = super::super::spawning::LocalDifficulty::new(1, 18000, 0, 0.0);
let mut m = Mob::new(
1,
Arc::new(types.types["minecraft:cave_spider"].clone()),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
let (mut lr, mut events) = (horizon_core::random::LegacyRandom::new(0), Vec::new());
let mut cx = Cx {
w: &easy,
lr: &mut lr,
events: &mut events,
};
do_hurt_target(&mut m, Who::Mob(2), &mut cx);
out = Some(
events
.iter()
.filter(|e| {
matches!(
e,
MobEvent::AttackMob { .. } | MobEvent::AttackMobWithEffect { .. }
)
})
.map(|e| match e {
MobEvent::AttackMob { target, .. } => format!("plain {target}"),
MobEvent::AttackMobWithEffect { effect, .. } => format!("effect {effect}"),
_ => unreachable!(),
})
.collect(),
);
});
if !ran {
return;
}
assert_eq!(out, Some(vec!["plain 2".to_owned()]));
}
}
#[cfg(test)]
mod same_vehicle_push_tests {
use super::*;
+103 -14
View File
@@ -123,6 +123,40 @@ pub struct Poses {
skipped: FxHashSet<u64>,
/// Parts with `visible` cleared: neither they nor their children draw.
hidden: FxHashSet<u64>,
/// Names this content: a fresh number at each change, kept by copies
/// ([`Poses::version`]).
version: u64,
}
/// The next [`Poses::version`].
static NEXT_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
thread_local! {
/// Spare `Poses` for [`Poses::pooled`].
static POOL: std::cell::RefCell<Vec<Poses>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// A [`Poses`] from this thread's pool, given back when dropped.
pub struct PooledPoses(Poses);
impl std::ops::Deref for PooledPoses {
type Target = Poses;
fn deref(&self) -> &Poses {
&self.0
}
}
impl std::ops::DerefMut for PooledPoses {
fn deref_mut(&mut self) -> &mut Poses {
&mut self.0
}
}
impl Drop for PooledPoses {
fn drop(&mut self) {
let poses = std::mem::take(&mut self.0);
// A few per thread: a mob's body and layers, a rider's.
let _ = POOL.try_with(|pool| {
let mut pool = pool.borrow_mut();
if pool.len() < 8 {
pool.push(poses);
}
});
}
}
impl Poses {
pub fn new(model: &Part) -> Self {
@@ -130,6 +164,35 @@ impl Poses {
poses.reset(model);
poses
}
/// Spare `Poses` of an earlier draw on this thread, to fill with `reset` or
/// `copy_from` before use: a mob farm poses every mob each frame, and fresh
/// maps grow (and rehash) part by part.
pub fn pooled() -> PooledPoses {
PooledPoses(
POOL.try_with(|pool| pool.borrow_mut().pop())
.ok()
.flatten()
.unwrap_or_default(),
)
}
/// Names the content: two `Poses` with the same version pose every part
/// alike (one is a copy of the other, neither changed since). A draw of
/// the same model under the same matrix with the same version can reuse
/// the first's part matrices (`skin::Skin::draw`).
pub fn version(&self) -> u64 {
self.version
}
fn changed(&mut self) {
self.version = NEXT_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
/// Makes this a copy of `other`, keeping this one's capacity.
pub fn copy_from(&mut self, other: &Poses) {
self.map.clone_from(&other.map);
self.dummy = other.dummy;
self.skipped.clone_from(&other.skipped);
self.hidden.clone_from(&other.hidden);
self.version = other.version;
}
/// Refills this for another model, keeping the map and the sets' capacity:
/// a mob draws its body and every equipment layer each frame.
pub fn reset(&mut self, model: &Part) {
@@ -145,8 +208,10 @@ impl Poses {
self.skipped.clear();
self.hidden.clear();
walk(model, &mut self.map);
self.changed();
}
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
self.changed();
match self.map.get_mut(&key(name)) {
Some(p) => p,
None => &mut self.dummy,
@@ -156,7 +221,9 @@ impl Poses {
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
}
pub fn skip_draw(&mut self, name: &str) {
self.skipped.insert(key(name));
if self.skipped.insert(key(name)) {
self.changed();
}
}
pub fn skips(&self, part: &Part) -> bool {
self.skips_named(&part.name)
@@ -179,10 +246,13 @@ impl Poses {
}
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
pub fn visible(&mut self, n: &str, v: bool) {
if v {
self.hidden.remove(&key(n));
} else if self.map.contains_key(&key(n)) {
self.hidden.insert(key(n));
let changed = if v {
self.hidden.remove(&key(n))
} else {
self.map.contains_key(&key(n)) && self.hidden.insert(key(n))
};
if changed {
self.changed();
}
}
pub fn is_hidden(&self, part: &Part) -> bool {
@@ -967,17 +1037,27 @@ pub fn setup(
let swing = |o: f32| -((pos * 2.0 + o).mcos() * 0.4) * sp;
let step = |o: f32| ((pos + o).msin() * 0.4).abs() * sp;
let legs = [
("hind", 0.0f32, 0.0f32),
("middle_hind", PI, PI),
("middle_front", PI / 2.0, PI / 2.0),
("front", PI * 3.0 / 2.0, PI * 3.0 / 2.0),
("right_hind_leg", "left_hind_leg", 0.0f32, 0.0f32),
("right_middle_hind_leg", "left_middle_hind_leg", PI, PI),
(
"right_middle_front_leg",
"left_middle_front_leg",
PI / 2.0,
PI / 2.0,
),
(
"right_front_leg",
"left_front_leg",
PI * 3.0 / 2.0,
PI * 3.0 / 2.0,
),
];
for (n, so, to) in legs {
for (right, left, so, to) in legs {
let (sw, st) = (swing(so), step(to));
p.get(&format!("right_{n}_leg"))[4] += sw;
p.get(&format!("left_{n}_leg"))[4] -= sw;
p.get(&format!("right_{n}_leg"))[5] += st;
p.get(&format!("left_{n}_leg"))[5] -= st;
p.get(right)[4] += sw;
p.get(left)[4] -= sw;
p.get(right)[5] += st;
p.get(left)[5] -= st;
}
}
_ => {
@@ -1033,6 +1113,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
player_model_visibility(&mut p, 0, true);
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
@@ -1060,6 +1141,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let arm = if right { "right_arm" } else { "left_arm" };
p.xrot("head", pitch);
@@ -1099,6 +1181,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let arm = if right { "right_arm" } else { "left_arm" };
p.xrot("head", pitch);
@@ -1152,6 +1235,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
enderman::anim(&seated, &state, &mut poses);
let right = *poses.get("right_leg");
@@ -1207,6 +1291,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let mut ground = make();
let mut flight = make();
@@ -1248,6 +1333,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let same_angle = |a: f32, b: f32| ((a - b + PI).rem_euclid(PI * 2.0) - PI).abs() < 1e-5;
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
@@ -1354,6 +1440,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let mut m = player();
m.extra.main_hand = Some("minecraft:shield".into());
@@ -1375,6 +1462,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
setup(&m, &state, &mut p, &HashMap::new());
assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6);
@@ -1403,6 +1491,7 @@ mod flight_pose_tests {
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
version: 0,
};
let mut m = player();
m.extra.main_hand = Some("minecraft:brush".into());
+1 -1
View File
@@ -591,7 +591,7 @@ mod tests {
..Default::default()
},
model,
layers: HashMap::new(),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Camel,
+4 -4
View File
@@ -331,9 +331,9 @@ pub fn creeper_client_tick(m: &mut Mob) {
if m.extra.flag {
m.extra.counter = 1;
}
m.swell = (m.swell + m.extra.counter).clamp(0, 30);
m.swell = (m.swell + m.extra.counter).max(0).min(m.max_swell);
}
/// Creeper.tick: advance the fuse and explode at 30.
/// Creeper.tick: advance the fuse and explode at maxSwell.
pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
if !m.alive() {
return;
@@ -352,8 +352,8 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
));
}
m.swell = (m.swell + dir).max(0);
if m.swell >= 30 {
m.swell = 30;
if m.swell >= m.max_swell {
m.swell = m.max_swell;
let radius = if m.extra.charged { 6.0 } else { 3.0 };
let name = match &m.custom_name {
Some(name) => crate::text::Text::literal(name.clone()),
+23 -7
View File
@@ -564,14 +564,21 @@ impl Goal for CrossbowAttack {
m.extra.using_item = 0;
m.extra.flag = false;
self.state = Crossbow::Charged;
let weapon = m
.extra
.main_hand_stack()
.cloned()
.unwrap_or_else(|| ItemStack::new("minecraft:crossbow", 1));
set_crossbow_charged(m, true);
// The attack delay is drawn before the sound's pitch, as before.
self.attack_delay = 20 + m.random.next_int_bound(20).unwrap_or(0);
cx.events.push(MobEvent::Sound(
"item.crossbow.loading_end",
m.center(),
1.0,
1.0 / (m.random.next_float() * 0.5 + 1.0) + 0.2,
));
let pitch = 1.0 / (m.random.next_float() * 0.5 + 1.0) + 0.2;
cx.events.push(MobEvent::CrossbowLoad {
mob: m.id,
at: m.center(),
pitch,
weapon,
});
}
}
Crossbow::Charged => {
@@ -596,6 +603,15 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
let Some(t) = cx.w.target_info(who) else {
return;
};
// CrossbowItem.shootAll: only the charged projectile flies.
let Some(ammo) = m
.extra
.main_hand_stack()
.and_then(|s| s.data())
.and_then(|d| d.charged.first().cloned())
else {
return;
};
let start = Vec3::new(m.position.x, m.eye_y() - 0.1, m.position.z);
let dx = t.position.x - m.position.x;
let dz = t.position.z - m.position.z;
@@ -620,7 +636,7 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
1.6,
(14 - i32::from(cx.w.difficulty.id) * 4) as f32,
);
cx.events.push(MobEvent::Projectile(p));
cx.events.push(MobEvent::CrossbowShot { ammo, arrow: p });
let pitch = 1.0 / (m.random.next_float() * 0.5 + 1.8) + 0.53;
cx.events.push(MobEvent::Sound(
"item.crossbow.shoot",
+1 -1
View File
@@ -1011,7 +1011,7 @@ impl Mob {
// Creeper.causeFallDamage: the fall swells the fuse, up to five ticks
// short of exploding.
if self.species() == super::Species::Creeper {
self.swell = (self.swell + (distance * 1.5) as i32).min(30 - 5);
self.swell = (self.swell + (distance * 1.5) as i32).min(self.max_swell - 5);
}
// AbstractHorse.causeFallDamage (llamas override it): a landing of more
// than a block thuds, and the horse's fall makes no generic fall sound.
+1 -1
View File
@@ -503,7 +503,7 @@ mod tests {
..Default::default()
},
model,
layers: HashMap::new(),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Rabbit,
+188 -80
View File
@@ -9,6 +9,7 @@ use super::{
};
use crate::mesher::Vertex;
use horizon_core::geometry::trig::Mth;
use std::borrow::Cow;
use std::f32::consts::PI;
/// A texture lookup into the shared atlas (name without namespace -> uv rect).
@@ -84,7 +85,7 @@ fn translucent_layer(m: &Mob, layer: &Layer) -> bool {
/// normal, at `EYES_TIER`) on a mob whose other draws are plain: spiders',
/// endermen's, phantoms', the creaking's and the copper golem's. The GPU path
/// poses them as [`super::skin::Skin`] `FLAT` draws when improved transparency
/// is off ([`super::skin::SkinModels::eyes`]).
/// is off ([`super::skin::SkinModels::tagged`]).
fn eyes_layer(m: &Mob, layer: &Layer) -> bool {
layer.full_bright
&& layer.scroll == 0.0
@@ -101,8 +102,8 @@ fn eyes_layer(m: &Mob, layer: &Layer) -> bool {
/// One draw of a model with a texture.
pub struct Layer {
pub model: String,
pub texture: String,
pub model: Cow<'static, str>,
pub texture: Cow<'static, str>,
pub color: u32,
pub full_bright: bool,
/// `RenderTypes.breezeWind`: the texture scrolls with the entity's age.
@@ -197,13 +198,44 @@ pub const PROFESSIONS: [&str; 15] = [
"toolsmith",
"weaponsmith",
];
fn baby_or(m: &Mob, adult: &str, baby: &str) -> String {
if m.is_baby() && m.kind.layer(baby).is_some() {
baby.to_owned()
fn baby_or<'a>(
m: &Mob,
adult: impl Into<Cow<'a, str>>,
baby: impl Into<Cow<'a, str>>,
) -> Cow<'a, str> {
let baby = baby.into();
if m.is_baby() && m.kind.layer(&baby).is_some() {
baby
} else {
adult.to_owned()
adult.into()
}
}
/// A name built from parts (`entity/villager/profession/farmer`), kept for the
/// process so that drawing a mob allocates no names each frame: they come from
/// a few tables (types, professions, levels, materials, variants). Past 4096
/// names on a thread (an unknown item's material from a server) a new one comes
/// back owned.
fn interned(args: std::fmt::Arguments) -> Cow<'static, str> {
use std::fmt::Write;
thread_local! {
static NAMES: std::cell::RefCell<(String, crate::fast_hash::FxHashSet<&'static str>)> =
std::cell::RefCell::default();
}
NAMES.with(|names| {
let (buffer, set) = &mut *names.borrow_mut();
buffer.clear();
let _ = buffer.write_fmt(args);
if let Some(name) = set.get(buffer.as_str()) {
return Cow::Borrowed(*name);
}
if set.len() >= 4096 {
return Cow::Owned(buffer.clone());
}
let name: &'static str = Box::leak(buffer.as_str().into());
set.insert(name);
Cow::Borrowed(name)
})
}
fn climate(m: &Mob) -> &'static str {
match m.extra.variant {
1 => "warm",
@@ -223,21 +255,21 @@ pub fn layers_shown(m: &Mob) -> bool {
!(avatar(m) && m.extra.spectator)
}
/// The model layer key and texture for the mob's body.
pub fn body(m: &Mob) -> (String, String) {
pub fn body(m: &Mob) -> (Cow<'static, str>, Cow<'_, str>) {
use Species as S;
let baby = m.is_baby();
let b = |s: &str| {
let b = |s: &str| -> Cow<'static, str> {
if baby {
format!("{s}_baby")
interned(format_args!("{s}_baby"))
} else {
s.to_owned()
interned(format_args!("{s}"))
}
};
if avatar(m) {
// AvatarRenderer: PlayerModel's slim layer for a slim skin.
return (
if m.slim { "player_slim#main" } else { "main" }.into(),
m.skin.clone().unwrap_or_else(|| m.kind.texture.clone()),
m.skin.as_deref().unwrap_or(&m.kind.texture).into(),
);
}
match m.species() {
@@ -248,24 +280,32 @@ pub fn body(m: &Mob) -> (String, String) {
"trader_llama"
};
(
baby_or(m, &format!("{name}#main"), &format!("{name}_baby#main")),
super::llama::texture(m),
baby_or(
m,
interned(format_args!("{name}#main")),
interned(format_args!("{name}_baby#main")),
),
super::llama::texture(m).into(),
)
}
s if super::equine::is_equine(s) => {
let name = super::equine::model_name(s);
(
baby_or(m, &format!("{name}#main"), &format!("{name}_baby#main")),
super::equine::texture(m),
baby_or(
m,
interned(format_args!("{name}#main")),
interned(format_args!("{name}_baby#main")),
),
super::equine::texture(m).into(),
)
}
S::Wolf => (
baby_or(m, "wolf#main", "wolf_baby#main"),
super::tame::texture(m),
super::tame::texture(m).into(),
),
S::Panda => (
baby_or(m, "panda#main", "panda_baby#main"),
super::panda::texture(m),
super::panda::texture(m).into(),
),
S::HappyGhast => (
baby_or(m, "happy_ghast#main", "happy_ghast_baby#main"),
@@ -277,9 +317,12 @@ pub fn body(m: &Mob) -> (String, String) {
),
S::Nautilus | S::ZombieNautilus => (
super::nautilus::model(m).into(),
super::nautilus::texture(m),
super::nautilus::texture(m).into(),
),
S::SulfurCube => super::sulfur_cube::body(m),
S::SulfurCube => {
let (model, texture) = super::sulfur_cube::body(m);
(model.into(), texture.into())
}
S::Sniffer => (
baby_or(m, "sniffer#main", "sniffer_baby#main"),
if baby {
@@ -288,7 +331,7 @@ pub fn body(m: &Mob) -> (String, String) {
"entity/sniffer/sniffer".into()
},
),
S::Shulker => ("shulker#main".into(), super::shulker::texture(m)),
S::Shulker => ("shulker#main".into(), super::shulker::texture(m).into()),
S::Vex => ("vex#main".into(), super::vex::texture(m).into()),
S::Wither => ("wither#main".into(), super::wither::texture(m).into()),
S::Ghast => (
@@ -301,11 +344,11 @@ pub fn body(m: &Mob) -> (String, String) {
),
S::Cat => (
baby_or(m, "cat#main", "cat_baby#main"),
super::tame::cat_texture(m),
super::tame::cat_texture(m).into(),
),
S::Frog => (
"frog#main".into(),
format!("entity/frog/frog_{}", climate(m)),
interned(format_args!("entity/frog/frog_{}", climate(m))),
),
// RabbitRenderer.getTextureLocation: the Toast name tag, else the
// variant (the killer bunny's id is 99).
@@ -325,14 +368,17 @@ pub fn body(m: &Mob) -> (String, String) {
};
(
baby_or(m, "rabbit#main", "rabbit_baby#main"),
b(&format!("entity/rabbit/rabbit_{name}")),
b(&interned(format_args!("entity/rabbit/rabbit_{name}"))),
)
}
// ParrotRenderer.getVariantTexture.
S::Parrot => {
let name = ["red_blue", "blue", "green", "yellow_blue", "grey"]
[usize::from(m.extra.variant).min(4)];
(m.kind.model_key(), format!("entity/parrot/parrot_{name}"))
(
m.kind.model_name().into(),
interned(format_args!("entity/parrot/parrot_{name}")),
)
}
// AxolotlRenderer.getTextureLocation.
S::Axolotl => {
@@ -340,7 +386,7 @@ pub fn body(m: &Mob) -> (String, String) {
["lucy", "wild", "gold", "cyan", "blue"][usize::from(m.extra.variant).min(4)];
(
baby_or(m, "axolotl#main", "axolotl_baby#main"),
b(&format!("entity/axolotl/axolotl_{name}")),
b(&interned(format_args!("entity/axolotl/axolotl_{name}"))),
)
}
// SalmonRenderer.submit: the model of the salmon's size.
@@ -377,7 +423,7 @@ pub fn body(m: &Mob) -> (String, String) {
// FoxRenderer.getTextureLocation: snow variant, sleeping, baby.
S::Fox => (
baby_or(m, "fox#main", "fox_baby#main"),
format!(
interned(format_args!(
"entity/fox/fox{}{}{}",
if m.extra.variant == 0 { "" } else { "_snow" },
if super::fox::flag(m, super::fox::SLEEPING) {
@@ -386,7 +432,7 @@ pub fn body(m: &Mob) -> (String, String) {
""
},
if baby { "_baby" } else { "" },
),
)),
),
S::Pig => {
let model = if baby {
@@ -396,7 +442,10 @@ pub fn body(m: &Mob) -> (String, String) {
} else {
"pig#main".into()
};
(model, b(&format!("entity/pig/pig_{}", climate(m))))
(
model,
b(&interned(format_args!("entity/pig/pig_{}", climate(m)))),
)
}
S::Cow => {
let model = match (m.extra.variant, baby) {
@@ -407,7 +456,10 @@ pub fn body(m: &Mob) -> (String, String) {
(_, true) => "cow_baby#main",
_ => "cow#main",
};
(model.into(), b(&format!("entity/cow/cow_{}", climate(m))))
(
model.into(),
b(&interned(format_args!("entity/cow/cow_{}", climate(m)))),
)
}
S::Mooshroom => (
baby_or(m, "mooshroom#main", "mooshroom_baby#main"),
@@ -427,7 +479,10 @@ pub fn body(m: &Mob) -> (String, String) {
};
(
model.into(),
b(&format!("entity/chicken/chicken_{}", climate(m))),
b(&interned(format_args!(
"entity/chicken/chicken_{}",
climate(m)
))),
)
}
S::Sheep => (
@@ -492,7 +547,7 @@ pub fn body(m: &Mob) -> (String, String) {
let texture = if layer.starts_with("bee_baby") {
b(texture)
} else {
texture.to_owned()
texture.into()
};
(layer, texture)
}
@@ -530,8 +585,11 @@ pub fn body(m: &Mob) -> (String, String) {
}
}
// CopperGolemOxidationLevels: the texture of the weathering.
S::CopperGolem => (m.kind.model_key(), super::copper_golem::texture(m)),
_ => (m.kind.model_key(), m.kind.texture.clone()),
S::CopperGolem => (
m.kind.model_name().into(),
super::copper_golem::texture(m).into(),
),
_ => (m.kind.model_name().into(), m.kind.texture.as_str().into()),
}
}
/// LivingEntityRenderer.getModelTint (tropical fish base colour).
@@ -555,7 +613,13 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
return v;
}
let white = 0xFFFF_FFFF;
let push = |v: &mut Vec<Layer>, model: &str, texture: &str, color: u32, full_bright: bool| {
fn push(
v: &mut Vec<Layer>,
model: impl Into<Cow<'static, str>>,
texture: impl Into<Cow<'static, str>>,
color: u32,
full_bright: bool,
) {
v.push(Layer {
model: model.into(),
texture: texture.into(),
@@ -563,7 +627,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
full_bright,
scroll: 0.0,
})
};
}
match m.species() {
// WolfArmorLayer, then WolfCollarLayer.
S::Wolf if m.extra.tame => {
@@ -590,7 +654,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
push(
&mut v,
"wolf_armor#main",
&format!("entity/wolf/wolf_armor_crackiness_{level}"),
interned(format_args!("entity/wolf/wolf_armor_crackiness_{level}")),
white,
false,
);
@@ -614,7 +678,13 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
if !m.is_baby()
&& let Some(texture) = &harness
{
push(&mut v, "happy_ghast_harness#main", texture, white, false);
push(
&mut v,
"happy_ghast_harness#main",
texture.clone(),
white,
false,
);
}
// RopesLayer: the ropes over a harnessed body while the ghast
// holds a quad lead (`isLeashHolder`), the baby's model for a baby.
@@ -655,24 +725,24 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
// LlamaDecorLayer: the carpet, or the trader's blanket.
S::Llama | S::TraderLlama => {
if let Some((model, texture)) = super::llama::decor(m) {
push(&mut v, &model, &texture, white, false);
push(&mut v, model, texture, white, false);
}
}
// HorseMarkingLayer, the saddle and the horse armour.
s if super::equine::is_equine(s) => {
let name = super::equine::model_name(s);
let body = if m.is_baby() {
format!("{name}_baby#main")
interned(format_args!("{name}_baby#main"))
} else {
format!("{name}#main")
interned(format_args!("{name}#main"))
};
if let Some(tex) = super::equine::markings(m) {
push(&mut v, &body, &tex, white, false);
push(&mut v, body, tex, white, false);
}
if m.extra.saddled && !m.is_baby() {
push(
&mut v,
&format!("{name}#saddle"),
interned(format_args!("{name}#saddle")),
super::equine::saddle_texture(s),
white,
false,
@@ -683,7 +753,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
.strip_prefix("minecraft:")
.and_then(|a| a.strip_suffix("_horse_armor"))
{
let tex = format!("entity/equipment/horse_body/{material}");
let tex = interned(format_args!("entity/equipment/horse_body/{material}"));
let color = if material == "leather" {
abgr(0xA06540)
} else {
@@ -695,7 +765,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
} else {
"horse_armor#main"
};
push(&mut v, layer, &tex, color, false);
push(&mut v, layer, tex, color, false);
}
}
// SimpleEquipmentLayer: pig and strider saddles.
@@ -742,7 +812,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
.as_deref()
.and_then(super::nautilus::armor_texture)
{
push(&mut v, "nautilus_armor#main", &texture, white, false);
push(&mut v, "nautilus_armor#main", texture, white, false);
}
if m.extra.saddled {
push(
@@ -803,11 +873,11 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
// from ModelLayers.COPPER_GOLEM, the body's, not COPPER_GOLEM_EYES.
S::CopperGolem => {
let eyes = super::copper_golem::eye_texture(m);
push(&mut v, &m.kind.model_key(), &eyes, white, true);
push(&mut v, m.kind.model_name(), eyes, white, true);
}
S::Spider | S::CaveSpider => push(
&mut v,
&m.kind.model_key(),
m.kind.model_name(),
"entity/spider/spider_eyes",
white,
true,
@@ -869,7 +939,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
push(
&mut v,
model,
&format!("entity/fish/tropical_{letter}_pattern_{n}"),
interned(format_args!("entity/fish/tropical_{letter}_pattern_{n}")),
abgr(DYE[m.extra.variant3 as usize & 15]),
false,
);
@@ -881,9 +951,9 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
"zombie_villager"
};
let model = if m.is_baby() {
format!("{base}_baby#main")
interned(format_args!("{base}_baby#main"))
} else {
format!("{base}#main")
interned(format_args!("{base}#main"))
};
let t = VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()];
let folder = if m.species() == S::Villager {
@@ -895,8 +965,8 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
let group = if m.is_baby() { "baby" } else { "type" };
push(
&mut v,
&model,
&format!("{folder}/{group}/{t}"),
model.clone(),
interned(format_args!("{folder}/{group}/{t}")),
white,
false,
);
@@ -904,8 +974,8 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
if !m.is_baby() && prof != "none" {
push(
&mut v,
&model,
&format!("{folder}/profession/{prof}"),
model.clone(),
interned(format_args!("{folder}/profession/{prof}")),
white,
false,
);
@@ -915,8 +985,8 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
[(m.extra.variant3.max(1) as usize - 1).min(4)];
push(
&mut v,
&model,
&format!("{folder}/profession_level/{level}"),
model,
interned(format_args!("{folder}/profession_level/{level}")),
white,
false,
);
@@ -935,6 +1005,10 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
if super::equine::is_equine(m.species()) || avatar(m) {
return;
}
let e = &m.extra;
if e.head.is_none() && e.chest.is_none() && e.legs.is_none() && e.feet.is_none() {
return;
}
let (model, _) = body(m);
let Some(base) = model.strip_suffix("#main") else {
return;
@@ -949,7 +1023,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
let Some(name) = item.as_deref().and_then(|i| i.strip_prefix("minecraft:")) else {
continue;
};
let Some(material) = name.strip_suffix(&format!("_{layer}")) else {
let Some(material) = name.strip_suffix(layer).and_then(|n| n.strip_suffix('_')) else {
continue;
};
let asset = match material {
@@ -962,7 +1036,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
} else {
"humanoid"
};
let model = format!("{base}#{layer}");
let model = interned(format_args!("{base}#{layer}"));
// EquipmentLayerRenderer.getColorForLayer: the leather layer takes
// the stack's dye colour, the colour it has when undyed otherwise.
let tint = if asset == "leather" {
@@ -972,7 +1046,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
};
v.push(Layer {
model: model.clone(),
texture: format!("entity/equipment/{folder}/{asset}"),
texture: interned(format_args!("entity/equipment/{folder}/{asset}")),
color: tint,
full_bright: false,
scroll: 0.0,
@@ -980,7 +1054,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
if asset == "leather" {
v.push(Layer {
model,
texture: format!("entity/equipment/{folder}/leather_overlay"),
texture: interned(format_args!("entity/equipment/{folder}/leather_overlay")),
color: 0xFFFF_FFFF,
full_bright: false,
scroll: 0.0,
@@ -991,7 +1065,11 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
impl super::MobType {
/// The main model layer key without the namespace.
pub fn model_key(&self) -> String {
super::species::def(self.species).model.to_owned()
self.model_name().to_owned()
}
/// `model_key` without the copy.
pub fn model_name(&self) -> &'static str {
super::species::def(self.species).model
}
}
/// The mob's root transform (LivingEntityRenderer.submit + setupRotations).
@@ -1193,7 +1271,8 @@ fn species_rotations(m: &Mob, alpha: f32) -> glam::Mat4 {
}
/// Creeper.getSwelling: the fuse's progress, past 1 in its last two ticks.
fn creeper_swelling(m: &Mob, alpha: f32) -> f32 {
(m.previous_swell as f32 + alpha * (m.swell - m.previous_swell) as f32) / 28.0
(m.previous_swell as f32 + alpha * (m.swell - m.previous_swell) as f32)
/ (m.max_swell - 2) as f32
}
/// LivingEntityRenderer.getOverlayCoords for the body: the hurt red, or a
/// swelling creeper's white flash. Layers take the red only (progress 0).
@@ -1765,7 +1844,7 @@ pub fn drawn_textures(m: &Mob, data: &MobData) -> Vec<String> {
let (model_key, texture) = body(m);
let cem_key = cem_key(&model_key);
let mut names = Vec::new();
push(&mut names, cem.layer(cem_key), texture);
push(&mut names, cem.layer(cem_key), texture.into_owned());
let mut all_layers = layers(m, m.age as f32);
if m.species() == Species::Warden {
for (model, texture, _) in super::warden::emissive_layers(m, 0.0) {
@@ -1778,12 +1857,12 @@ pub fn drawn_textures(m: &Mob, data: &MobData) -> Vec<String> {
});
}
}
let body_keys = [model_key.as_str(), cem_key];
let body_keys = [&*model_key, cem_key];
for (i, layer) in all_layers.iter().enumerate() {
let custom = cem
.layer(&layer.model)
.filter(|_| paints_with_jem(&all_layers, i, body_keys));
push(&mut names, custom, layer.texture.clone());
push(&mut names, custom, layer.texture.to_string());
}
names
}
@@ -1805,7 +1884,7 @@ fn cem_texture<'a>(
/// (`body`: eyes, collars, a villager's type and profession), and keep their own textures
/// over the new geometry.
fn paints_with_jem(layers: &[Layer], i: usize, body: [&str; 2]) -> bool {
let key = layers[i].model.as_str();
let key = &*layers[i].model;
!body.contains(&key) && layers[..i].iter().all(|l| l.model != key)
}
#[allow(clippy::too_many_arguments)]
@@ -1886,7 +1965,8 @@ fn render_visible(
}
atlas(emissive.overlay(drawn)?)
};
let mut poses = Poses::new(&model);
let mut poses = Poses::pooled();
poses.reset(&model);
anim::setup(m, &s, &mut poses, &data.animations);
if let Some(animation) = body_animation {
animation.run(m, &s, &mut poses);
@@ -1907,9 +1987,9 @@ fn render_visible(
}
}
// The GPU path takes the whole mob or none of it, so its draws keep their
// order: only plain opaque and cutout draws of models it can pose (no
// custom models, translucency, eyes or energy swirl, which the CPU path
// changes after drawing).
// order: only plain draws of models it can pose (no custom models,
// translucent body or energy swirl, which the CPU path changes after
// drawing), and with improved transparency off the tagged layers too.
let mut skin = skin;
let posable = match skin.as_deref_mut() {
Some(skin) => {
@@ -1920,7 +2000,9 @@ fn render_visible(
&& energy_swirl(m, s.age).is_none()
&& skin.supports(&model)
&& all_layers.iter().all(|layer| {
(skin.models.eyes && eyes_layer(m, layer)
// With improved transparency off, a translucent layer's tag
// is the only difference from a plain draw.
(skin.models.tagged && (eyes_layer(m, layer) || !layer.full_bright)
|| !layer.full_bright && !translucent_layer(m, layer))
&& cem.layer(&layer.model).is_none()
&& m.kind
@@ -1989,8 +2071,8 @@ fn render_visible(
}
// One Poses refilled for every layer (armour draws up to seven): it keeps
// its maps' capacity.
let mut layer_poses = Poses::default();
let body_keys = [model_key.as_str(), cem_key];
let mut layer_poses = Poses::pooled();
let body_keys = [&*model_key, cem_key];
for (i, layer) in all_layers.iter().enumerate() {
let custom = cem.layer(&layer.model);
// `layer.texture` stays the vanilla name (the villager hats below look for its
@@ -2010,10 +2092,16 @@ fn render_visible(
let Some((drawn, uv)) = sprite(texture).or_else(|| Some((texture, atlas(texture)?))) else {
continue;
};
layer_poses.reset(&part);
anim::setup(m, &s, &mut layer_poses, &data.animations);
if let Some(animation) = custom.and_then(|c| c.animation.as_ref()) {
animation.run(m, &s, &mut layer_poses);
if custom.is_none() && custom_body.is_none() && std::sync::Arc::ptr_eq(&part, &model) {
// A layer over the body's own model (a villager's type, profession
// and level, a spider's eyes): `setupAnim` gives the body's poses.
layer_poses.copy_from(&poses);
} else {
layer_poses.reset(&part);
anim::setup(m, &s, &mut layer_poses, &data.animations);
if let Some(animation) = custom.and_then(|c| c.animation.as_ref()) {
animation.run(m, &s, &mut layer_poses);
}
}
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
&& let Some((folder, variant)) = layer
@@ -2513,6 +2601,26 @@ pub fn held_item_stack(extra: &super::Extra, off_hand: bool) -> Option<crate::it
mod tests {
use super::*;
#[test]
fn creeper_swelling_uses_local_fuse_and_keeps_java_zero_division() {
let mut mob = Mob::new(
1,
crate::mobs::sensor_scan_tests::kind(),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
mob.previous_swell = 19;
mob.swell = 20;
assert_eq!(creeper_swelling(&mob, 0.5), 19.5 / 28.0);
mob.max_swell = 40;
assert_eq!(creeper_swelling(&mob, 0.5), 19.5 / 38.0);
mob.max_swell = 2;
assert!(creeper_swelling(&mob, 0.5).is_infinite());
mob.previous_swell = 0;
mob.swell = 0;
assert!(creeper_swelling(&mob, 0.5).is_nan());
}
#[test]
fn offhand_does_not_inherit_mainhand_components() {
use crate::items::ItemStack;
@@ -2592,7 +2700,7 @@ mod tests {
ambient_interval: 80,
flags: Flags::default(),
model,
layers: std::collections::HashMap::new(),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Camel,
@@ -3200,7 +3308,7 @@ mod scroll_tests {
layers(m, 0.0)
.into_iter()
.filter(|l| l.model.contains("#wool"))
.map(|l| (l.model, l.texture, l.color))
.map(|l| (l.model.into_owned(), l.texture.into_owned(), l.color))
.collect()
};
let dyed = abgr(sheep_wool_rgb(1));
+107 -37
View File
@@ -44,10 +44,11 @@ pub struct SkinModels {
/// Item models, the same way (`item_render::ItemRenderState::submit_skinned`).
items: FxHashMap<usize, (Arc<BakedItemModel>, Option<StoredItem>)>,
vertices: u32,
/// Whether the eyes render type's layers (spiders', endermen's...) are posed
/// here too, as [`SkinJob::FLAT`] draws (`gfx::Renderer::skins_eyes`); set
/// each frame.
pub eyes: bool,
/// Whether the layers improved transparency would draw in its stages are
/// posed here too (`gfx::Renderer::skins_tagged`; set each frame): the eyes
/// render type's (spiders', endermen's...) as [`SkinJob::FLAT`] draws, and
/// `entityTranslucent` layers (a horse's markings, a slime's outer cube).
pub tagged: bool,
}
/// A stored item model: its quads' vertices in order, by runs of quads that
@@ -288,9 +289,29 @@ pub struct Skin<'a> {
pub frame: &'a mut SkinFrame,
/// Camera-relative bounds of what this mob drew here (for culling).
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
/// The last walk, for a draw that repeats it.
last: Option<Walked>,
}
impl Skin<'_> {
/// One walk of [`Skin::draw`]: what it drew, and the jobs it pushed.
struct Walked {
model: usize,
base: glam::Mat4,
poses: u64,
jobs: std::ops::Range<usize>,
}
impl<'a> Skin<'a> {
/// One entity's draws into `frame`.
pub fn new(models: &'a mut SkinModels, frame: &'a mut SkinFrame) -> Self {
Self {
models,
frame,
bounds: None,
last: None,
}
}
/// Grows the bounds of what this entity drew here by `lo..hi` posed by `m`.
pub fn grow_bounds(&mut self, m: glam::Mat4, (lo, hi): (glam::Vec3, glam::Vec3)) {
let center = m.transform_point3((lo + hi) * 0.5);
@@ -318,9 +339,30 @@ impl Skin<'_> {
poses: &Poses,
paint: Paint,
) -> bool {
let key = Arc::as_ptr(model) as usize;
// A draw that repeats the last walk (a villager's type, profession and
// level over its body, a spider's eyes): the same model, matrix and
// poses give the same part matrices and parts left out, so its jobs
// are the last ones' over the same parts, with this paint.
if let Some(last) = &self.last
&& last.model == key
&& last.base == base
&& last.poses == poses.version()
{
for i in last.jobs.clone() {
let job = self.frame.jobs[i];
self.frame.jobs.push(SkinJob {
dst: self.frame.vertices,
..paint.job(job.first, job.count, job.part, job.cull)
});
self.frame.vertices += job.count;
}
return true;
}
let Some(stored) = self.models.entry(model, self.frame) else {
return false;
};
let jobs = self.frame.jobs.len();
let first_part = self.frame.parts.len() as u32;
self.frame.parts.resize(
self.frame.parts.len() + stored.parts.len(),
@@ -338,10 +380,40 @@ impl Skin<'_> {
index: 0,
};
walk.part(model, base, ModelNormals::new(base));
// The walk's first job never extends an earlier draw's: those have
// other parts.
self.last = Some(Walked {
model: key,
base,
poses: poses.version(),
jobs: jobs..self.frame.jobs.len(),
});
true
}
}
impl Paint {
/// A job drawing `count` stored vertices from `first`, posed by the parts
/// from `part`, in this paint (`dst` left 0).
fn job(self, first: u32, count: u32, part: u32, cull: u32) -> SkinJob {
SkinJob {
first,
count,
part,
dst: 0,
rect: self.uv,
scroll: self.scroll,
color: self.color,
light: self.light,
overlay: self.overlay,
glint: u32::from(self.overlay == super::render::ARMOR_GLINT),
mode: SkinJob::MODEL,
cull,
spare: 0,
}
}
}
struct Walk<'a> {
stored: &'a Stored,
poses: &'a Poses,
@@ -416,19 +488,13 @@ impl Walk<'_> {
job.count += stored.count;
} else {
self.jobs.push(SkinJob {
first,
count: stored.count,
part: self.first_part,
dst: *self.vertices,
rect: paint.uv,
scroll: paint.scroll,
color: paint.color,
light: paint.light,
overlay: paint.overlay,
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
mode: SkinJob::MODEL,
cull: u32::from(self.stored.cull),
spare: 0,
..paint.job(
first,
stored.count,
self.first_part,
u32::from(self.stored.cull),
)
});
}
*self.vertices += stored.count;
@@ -446,7 +512,8 @@ mod tests {
/// the bit and in the same order, for each mob it takes; the mobs it
/// leaves to the CPU draw exactly as before. Twice: with the eyes render
/// type's layers left to the CPU (improved transparency on), then posed
/// too (`SkinModels::eyes`). Needs the prepared resources.
/// too with a horse's markings and a slime's outer cube (`SkinModels::tagged`).
/// Needs the prepared resources.
#[test]
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -487,7 +554,7 @@ mod tests {
let (mut posed, mut kept) = (0, 0);
let mut ids: Vec<_> = types.types.keys().cloned().collect();
ids.sort();
// The mobs posed at least once, without and with the eyes layers.
// The mobs posed at least once, without and with the tagged layers.
let mut taken = [std::collections::BTreeSet::new(), Default::default()];
for eyes in [false, true] {
for (n, id) in ids.iter().enumerate() {
@@ -499,16 +566,25 @@ mod tests {
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
// A farmer: its type, profession and level layers repeat
// the body's draw (`Skin::draw` reuses its walk).
if matches!(
id.as_str(),
"minecraft:villager" | "minecraft:zombie_villager"
) {
m.extra.variant2 = 5;
m.extra.variant3 = 3;
}
// White field markings (`entityTranslucent`).
if id == "minecraft:horse" {
m.extra.variant2 = 2;
}
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
models.eyes = eyes;
models.tagged = eyes;
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = Skin::new(&mut models, &mut frame);
render::render_skinned(
&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin,
);
@@ -555,15 +631,17 @@ mod tests {
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 2 * 3 * 40, "only {posed} draws took the GPU path");
let added: Vec<_> = taken[1].difference(&taken[0]).copied().collect();
eprintln!("posed only with the eyes layers: {added:?}");
eprintln!("posed only with the tagged layers: {added:?}");
for id in [
"minecraft:spider",
"minecraft:cave_spider",
"minecraft:enderman",
"minecraft:phantom",
"minecraft:copper_golem",
"minecraft:horse",
"minecraft:slime",
] {
assert!(added.contains(&id), "{id} not posed with its eyes");
assert!(added.contains(&id), "{id} not posed with its tagged layers");
}
assert!(taken[0].is_subset(&taken[1]));
}
@@ -603,11 +681,7 @@ mod tests {
let mut frame = SkinFrame::default();
for (id, cull) in [("minecraft:bat", 1), ("minecraft:cow", 0)] {
models.begin(&mut frame, 0);
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = Skin::new(&mut models, &mut frame);
assert!(skin.draw(
&types.types[id].model,
glam::Mat4::IDENTITY,
@@ -680,11 +754,7 @@ mod tests {
);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
let mut skin = Skin::new(&mut models, &mut frame);
let paint = (uv, 0.0, 0xff80_c0ff, 0x70a0, overlay);
render::put(
&mut Some(&mut skin),
+14 -1
View File
@@ -905,7 +905,20 @@ pub fn perform_ranged_attack(m: &mut Mob, who: Who, cx: &mut Cx, power: f32) {
1.6,
(14 - id * 4) as f32,
);
cx.events.push(MobEvent::Projectile(p));
cx.events.push(MobEvent::BowShot {
mob: m.id,
// ProjectileUtil.getWeaponHoldingHand(this, Items.BOW).
weapon: if m.extra.using_off_hand {
m.extra
.off_hand
.as_ref()
.map(|item| crate::items::ItemStack::new(item, 1))
} else {
m.extra.main_hand_stack().cloned()
}
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:bow", 1)),
arrow: p,
});
let pitch = 1.0 / (m.random.next_float() * 0.4 + 0.8);
cx.events.push(MobEvent::Sound(
"entity.skeleton.shoot",
+2 -2
View File
@@ -877,7 +877,7 @@ mod tests {
fn villager() -> Mob {
use crate::mobs::{Anim, Brain, Flags, MobType, Part, Species};
use std::{collections::HashMap, sync::Arc};
use std::sync::Arc;
let kind = Arc::new(MobType {
id: "minecraft:villager".into(),
species: Species::Villager,
@@ -910,7 +910,7 @@ mod tests {
ambient_interval: 80,
flags: Flags::default(),
model: Arc::new(Part::parse("root", &serde_json::json!({}))),
layers: HashMap::new(),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Villager,
+82 -5
View File
@@ -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());
}
}
+53 -1
View File
@@ -344,7 +344,7 @@ pub enum NativeAuthority {
}
/// One open attempt of one world; it rejects a previous open's commands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct NativeWorldEpoch(u64);
impl NativeWorldEpoch {
@@ -359,6 +359,58 @@ impl NativeWorldEpoch {
}
}
/// An entity of the open world, for the `Host` methods that read or change one
/// (`entity.position(host)`). It holds the world's epoch and the entity's id: ids
/// are shared by every level and never reused while the world is open, and an
/// entity that changes dimension gets a new one, as in vanilla. A handle from an
/// earlier open, of an entity that is gone, or used on a client reads as `None`
/// and changes nothing. `Host::entity` makes one from an event's entity id.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct EntityRef {
epoch: NativeWorldEpoch,
id: u32,
}
impl EntityRef {
/// Create a handle for the game.
#[doc(hidden)]
pub fn new(epoch: NativeWorldEpoch, id: u32) -> Self {
Self { epoch, id }
}
/// The entity's id (the network id events carry).
pub fn id(self) -> u32 {
self.id
}
/// The open of the world the handle belongs to.
pub fn epoch(self) -> NativeWorldEpoch {
self.epoch
}
}
/// A level of the open world, by its dimension (`minecraft:the_nether`).
/// `Host::level` makes one, and `entity.level(host)` gives an entity's.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LevelRef {
epoch: NativeWorldEpoch,
dimension: &'static str,
}
impl LevelRef {
/// Create a handle for the game.
#[doc(hidden)]
pub fn new(epoch: NativeWorldEpoch, dimension: &'static str) -> Self {
Self { epoch, dimension }
}
/// The level's dimension (`minecraft:overworld`).
pub fn dimension(self) -> &'static str {
self.dimension
}
/// The open of the world the handle belongs to.
pub fn epoch(self) -> NativeWorldEpoch {
self.epoch
}
}
/// Non-paused authoritative server ticks since open (not game or frame time).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeTickId(u64);
+204 -1
View File
@@ -2477,7 +2477,7 @@ pub trait Hooks: Send + Sync {
/// release; `native_entity_shoot_bow` reports a spawned arrow afterward
/// with read-only force. Fires on the Game running the bow release, including
/// LAN guests; a remote client's own release path is not covered. Crossbows
/// and `ArrowNockEvent` are not covered. 318.
/// are not covered; `native_arrow_nock` is the bow's start. 318.
///
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
fn arrow_loose(&self, host: &mut dyn Host, event: &mut ArrowLooseEvent<'_>) -> Flow {
@@ -4512,12 +4512,45 @@ pub trait Hooks: Send + Sync {
fn living_jump(&self, event: &mut LivingJumpEvent<'_>) {
let _ = event;
}
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
/// or the player is in creative. Set `cancel` to fail the use.
/// @API:arrow_nock@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn arrow_nock(&self, host: &mut dyn Host, event: &mut ArrowNockEvent<'_>) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// Player FOV scale, after vanilla effects and before zoom and easing.
/// Invalid values or values outside 0.01..=10 revert to base. API @API:compute_fov_modifier@.
fn compute_fov_modifier(&self, host: &dyn Host, event: &mut ComputeFovModifierEvent<'_>) {
let _ = host;
let _ = event;
}
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
/// `entity` (None for a player, whom `native_acting_player` names):
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
/// what is added to the entity's velocity; a player's client is sent the
/// same. Wind bursts too, except dragons and end crystals. End crystals
/// are also omitted from ordinary blasts: Horizon removes them without a
/// velocity to change. Server-side. @API:explosion_knockback@.
///
/// Every mod gets `event.knockback_velocity` in order and may add, change or remove knockback_velocity.
fn explosion_knockback(&self, host: &mut dyn Host, event: &mut ExplosionKnockbackEvent<'_>) {
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
@@ -4534,6 +4567,18 @@ pub trait Hooks: Send + Sync {
let _ = event;
None
}
/// `LivingEntity.getProjectile(weapon)` (NeoForge's
/// `LivingGetProjectileEvent`) for a player (`player` and `name`) or a
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
/// load of a pillager or piglin. `projectile` is the vanilla
/// ammunition; replace it to choose another. A replaced stack is not taken
/// from the inventory. @API:living_get_projectile@.
///
/// Every mod gets `event.projectile` in order and may add, change or remove projectile.
fn living_get_projectile(&self, host: &mut dyn Host, event: &mut LivingGetProjectileEvent<'_>) {
let _ = host;
let _ = event;
}
/// Local player movement input, after reading it and before applying it.
fn movement_input(&self, host: &dyn Host, event: &mut MovementInputEvent) {
let _ = host;
@@ -4558,6 +4603,11 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
fn render_name_tag(&self, host: &dyn Host, event: &mut RenderNameTagEvent<'_>) {
let _ = host;
let _ = event;
}
/// A screen was observed closing, at most once per frame.
fn screen_closing(&self, host: &dyn Host, event: &mut ScreenClosingEvent<'_>) {
let _ = host;
@@ -13866,6 +13916,42 @@ pub fn dispatch_living_jump(mods: &[Box<dyn Hooks>], entity: u32, kind: &str) {
mod_hook.living_jump(&mut event);
}
}
/// What [`Hooks::arrow_nock`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ArrowNockEvent<'a> {
pub player: u128,
pub name: &'a str,
pub bow: &'a ItemStack,
pub hand: Hand,
pub has_ammo: bool,
}
pub fn dispatch_arrow_nock(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
bow: &ItemStack,
hand: Hand,
has_ammo: bool,
cancel: &mut bool,
) {
if *cancel {
return;
}
let mut event = ArrowNockEvent {
player,
name,
bow,
hand,
has_ammo,
};
for mod_hook in super::entered(mods, "arrow_nock") {
if matches!(mod_hook.arrow_nock(host, &mut event), Flow::Cancel) {
*cancel = true;
break;
}
}
}
/// What [`Hooks::compute_fov_modifier`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ComputeFovModifierEvent<'a> {
@@ -13890,6 +13976,61 @@ pub fn dispatch_compute_fov_modifier(
}
*modifier = event.modifier;
}
/// What [`Hooks::explosion_knockback`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ExplosionKnockbackEvent<'a> {
pub dimension: &'a str,
pub center: [f64; 3],
pub blocks: &'a [[i32; 3]],
pub entity: Option<u32>,
pub knockback_velocity: &'a mut [f64; 3],
}
pub fn dispatch_explosion_knockback(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
dimension: &str,
center: [f64; 3],
blocks: &[[i32; 3]],
entity: Option<u32>,
knockback_velocity: &mut [f64; 3],
) {
let mut event = ExplosionKnockbackEvent {
dimension,
center,
blocks,
entity,
knockback_velocity,
};
for mod_hook in super::entered(mods, "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> {
@@ -13912,6 +14053,38 @@ pub fn dispatch_item_container(
}
}
}
/// What [`Hooks::living_get_projectile`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct LivingGetProjectileEvent<'a> {
pub player: Option<u128>,
pub name: &'a str,
pub entity: Option<u32>,
pub kind: &'a str,
pub weapon: &'a ItemStack,
pub projectile: &'a mut Option<ItemStack>,
}
pub fn dispatch_living_get_projectile(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: Option<u128>,
name: &str,
entity: Option<u32>,
kind: &str,
weapon: &ItemStack,
projectile: &mut Option<ItemStack>,
) {
let mut event = LivingGetProjectileEvent {
player,
name,
entity,
kind,
weapon,
projectile,
};
for mod_hook in super::entered(mods, "living_get_projectile") {
mod_hook.living_get_projectile(host, &mut event);
}
}
/// What [`Hooks::movement_input`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct MovementInputEvent {
@@ -14032,6 +14205,36 @@ pub fn dispatch_render_item_in_frame(
}
}
}
/// What [`Hooks::render_name_tag`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNameTagEvent<'a> {
pub entity_id: u32,
pub kind: &'a str,
pub vanilla_visible: bool,
pub visible: bool,
pub text: &'a mut Option<String>,
}
pub fn dispatch_render_name_tag(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
entity_id: u32,
kind: &str,
vanilla_visible: bool,
visible: &mut bool,
text: &mut Option<String>,
) {
let mut event = RenderNameTagEvent {
entity_id,
kind,
vanilla_visible,
visible: *visible,
text,
};
for mod_hook in super::entered(mods, "render_name_tag") {
mod_hook.render_name_tag(host, &mut event);
}
*visible = event.visible;
}
/// What [`Hooks::screen_closing`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ScreenClosingEvent<'a> {
+83
View File
@@ -1212,6 +1212,57 @@ pub trait Host {
let _ = pos;
unimplemented!("Host::block_collision")
}
/// The entity `id` (the id events carry) as a handle for the `entity_*`
/// methods: None on a client, with no world open, or for no such entity
/// in the level in place or among the server's players. API
/// @API:entity_handles@.
fn entity(&self, id: u32) -> Option<EntityRef> {
let _ = id;
unimplemented!("Host::entity")
}
/// The level `dimension` (`minecraft:the_nether`) as a handle: None on a
/// client, with no world open, or for a dimension the server hasn't
/// loaded. API @API:entity_handles@.
fn level(&self, dimension: &str) -> Option<LevelRef> {
let _ = dimension;
unimplemented!("Host::level")
}
/// The entity's position (`Entity.position`): its feet, the middle of
/// its box for a painting or an item frame. API @API:entity_handles@.
fn entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
let _ = entity;
unimplemented!("Host::entity_position")
}
/// The block the entity's position is in (`Entity.blockPosition`). API
/// @API:entity_handles@.
fn entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
let _ = entity;
unimplemented!("Host::entity_block_position")
}
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
/// for the kinds that don't turn. API @API:entity_handles@.
fn entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
let _ = entity;
unimplemented!("Host::entity_rotation")
}
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
/// @API:entity_handles@.
fn entity_kind(&self, entity: EntityRef) -> Option<String> {
let _ = entity;
unimplemented!("Host::entity_kind")
}
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
/// entity that has none yet gets it now, as a new vanilla entity does.
/// API @API:entity_handles@.
fn entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
let _ = entity;
unimplemented!("Host::entity_uuid")
}
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
fn entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
let _ = entity;
unimplemented!("Host::entity_level")
}
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
/// empty ones included, kept in its `minecraft:container`. None for an
/// item without slots, and for an empty stack.
@@ -1253,3 +1304,35 @@ pub trait Host {
unimplemented!("Host::item_container_extract")
}
}
impl EntityRef {
/// The entity's position (`Entity.position`): its feet, the middle of
/// its box for a painting or an item frame. API @API:entity_handles@.
pub fn position(self, host: &dyn Host) -> Option<[f64; 3]> {
host.entity_position(self)
}
/// The block the entity's position is in (`Entity.blockPosition`). API
/// @API:entity_handles@.
pub fn block_position(self, host: &dyn Host) -> Option<[i32; 3]> {
host.entity_block_position(self)
}
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
/// for the kinds that don't turn. API @API:entity_handles@.
pub fn rotation(self, host: &dyn Host) -> Option<[f32; 2]> {
host.entity_rotation(self)
}
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
/// @API:entity_handles@.
pub fn kind(self, host: &dyn Host) -> Option<String> {
host.entity_kind(self)
}
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
/// entity that has none yet gets it now, as a new vanilla entity does.
/// API @API:entity_handles@.
pub fn uuid(self, host: &mut dyn Host) -> Option<u128> {
host.entity_uuid(self)
}
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
pub fn level(self, host: &dyn Host) -> Option<LevelRef> {
host.entity_level(self)
}
}
+9
View File
@@ -2059,6 +2059,15 @@ impl MotionWorld<'_, '_, '_> {
}
Ok(())
}
/// `Entity.move(MoverType.SHULKER_BOX, movement)`: a shulker box lid pushed
/// the player. An ordinary move — the collision scan steps and reads the
/// ground flag — whose end writes the ground state
/// (`Entity.setOnGroundWithMovement`): a lid lifting the body off the
/// ground leaves it airborne, and the next tick's gravity lands it on the
/// lid again instead of the client holding it on a stale ground.
pub fn shulker_box_push(&mut self, movement: Vec3) -> Result<()> {
entity_move::move_entity(self, MoverType::ShulkerBox, movement)
}
/// `LocalPlayer.move`: the move, then `updateAutoJump` with the float
/// horizontal displacement.
pub fn move_self(&mut self, movement: Vec3) -> Result<()> {
@@ -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);
}
}
+1 -1
View File
@@ -415,7 +415,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
+3 -3
View File
@@ -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);
}
+68 -7
View File
@@ -9,7 +9,8 @@
//! reported once.
//! - OQ-26: `smooth` runs per frame with the frame time; its fade times are seconds (1 by
//! default) to cover half of the remaining distance, `fadeIn` while rising, `fadeOut` while
//! falling. Each call keeps its own state, so the optional id is not needed.
//! falling. Each call keeps its own state, so the optional id is not needed. As in Iris
//! 1.11.7, the first argument is that id when it is written as a number and more follow.
use std::collections::{HashMap, HashSet};
@@ -318,12 +319,14 @@ impl Custom {
a.last().copied().unwrap_or(Val::scalar(0.0))
}
"smooth" => {
let (val, fade_in, fade_out) = match a.len() {
4 => (arg(1).x(), arg(2).x(), arg(3).x()),
3 => (x, arg(1).x(), arg(2).x()),
2 => (x, arg(1).x(), arg(1).x()),
_ => (x, 1.0, 1.0),
};
// A first argument written as a number names the call when more
// follow (`smooth(1, x, 2)`); any other is the value (`smooth(x, 2)`).
let named = a.len() > 1 && matches!(args[0], Expr::Num(_));
let rest = &a[usize::from(named)..];
let at = |i: usize| rest.get(i).map(Val::x);
let val = at(0).unwrap_or(0.0);
let fade_in = at(1).unwrap_or(1.0);
let fade_out = at(2).unwrap_or(fade_in);
let state = self.smooth.entry(*site).or_insert(val);
let fade = if val > *state { fade_in } else { fade_out };
if fade > 0.0 {
@@ -503,6 +506,9 @@ impl<'a> Parser<'a> {
}
}
}
if t == "smooth" {
smooth_arguments(&args)?;
}
*self.calls += 1;
return Ok(Expr::Call(t, args, *self.calls));
}
@@ -530,6 +536,26 @@ impl<'a> Parser<'a> {
}
}
/// The `smooth` calls Iris 1.11.7 resolves: `smooth(val)`, `smooth(val, fade)`,
/// `smooth(val, fadeIn, fadeOut)`, and the same with a number written first as the call's id
/// (`smooth(1, val, fadeIn, fadeOut)`). It drops a uniform with any other, which then reads 0.
fn smooth_arguments(args: &[Expr]) -> Result<(), String> {
let named = args.len() > 1 && matches!(args[0], Expr::Num(_));
if (1..=3).contains(&(args.len() - usize::from(named))) {
Ok(())
} else {
Err(format!(
"`smooth` with {} arguments{} (Iris reads it as 0)",
args.len(),
if named {
""
} else {
", the first not a number"
}
))
}
}
fn is_name(t: &str) -> bool {
t.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
}
@@ -650,6 +676,41 @@ mod tests {
assert_eq!(eval(&mut c, 0.6), 0.6);
}
/// Iris 1.11.7, with a probe pack of our own: a first argument written as a number is the
/// call's id when more follow (`smooth(1, 5, f)` reads 5, `smooth(5, f)` reads f); any
/// other first argument is the value (`smooth(1.5 * 2, 6.5)` reads 3).
#[test]
fn smooth_reads_a_leading_number_as_its_id() {
let mut c = custom(
"uniform.float.a = smooth(1, 5, 0.0001)\n\
uniform.float.b = smooth(5, 0.0001)\n\
uniform.float.c = smooth(2.5, 6, 0.0001)\n\
uniform.float.d = smooth((2), 6, 0.0001)\n\
uniform.float.e = smooth(y, 5, 0.0001)\n\
uniform.float.f = smooth(1.5 * 2, 6.5)\n\
uniform.float.g = smooth(-3, 6, 0.0001)\n\
uniform.float.h = smooth(7, y)\n\
uniform.float.i = smooth(4)\n",
);
assert!(c.notes.is_empty(), "{:?}", c.notes);
let v = c.eval(0.016, &|n: &str| (n == "y").then(|| Val::scalar(6.96)));
let x: Vec<f64> = v.iter().map(|v| v.v[0]).collect();
assert_eq!(x, [5.0, 0.0001, 6.0, 6.0, 6.96, 3.0, -3.0, 6.96, 4.0]);
}
/// Iris 1.11.7 drops a uniform whose `smooth` has four arguments and no number first, or
/// five: it reads 0 there, as a uniform Horizon leaves out does.
#[test]
fn smooth_calls_iris_does_not_resolve_are_left_out() {
let c = custom(
"uniform.float.a = smooth(y, 5, 0.0001, 0.0001)\n\
uniform.float.b = smooth(3, 6.5, 0.0001, 0.0001, 9)\n\
uniform.float.c = smooth(3, 6.5, 0.0001, 0.0001)\n",
);
assert_eq!(c.uniforms().map(|u| u.0).collect::<Vec<_>>(), ["c"]);
assert_eq!(c.notes.len(), 2, "{:?}", c.notes);
}
#[test]
fn bad_expressions_are_reported() {
let c = custom("uniform.float.a = (1 + \nuniform.mat4.m = 1\nuniform.float.ok = 2\n");
+81
View File
@@ -226,6 +226,9 @@ pub struct PostProgram {
/// A geometry stage folded into the vertex stage (`translate_geometry`): drawn non-indexed,
/// six vertices per quad, with this many instances. 0 without one.
pub geometry_triangles: u32,
/// `scale.<name>`: (scale, offset x, offset y), the part of its buffers a program draws, as
/// fractions of their size from GL's bottom-left corner; [1, 0, 0] (all of them) without.
pub scale: [f32; 3],
}
/// `size.buffer.<buffer>`: a size of its own, relative to the render size (floats) or in
@@ -951,6 +954,7 @@ impl Loader<'_> {
mipmaps: 0,
compute,
geometry_triangles: 0,
scale: FULL,
});
}
if !enabled(
@@ -1031,6 +1035,7 @@ impl Loader<'_> {
Some(PostProgram {
buffer_blends: buffer_blends(self.properties, &name),
flips: flips(self.properties, &name),
scale: scale(self.properties, &name, group),
name,
group,
program: Some(program),
@@ -1130,6 +1135,7 @@ impl Loader<'_> {
Some(PostProgram {
buffer_blends: buffer_blends(self.properties, &name),
flips: flips(self.properties, &name),
scale: scale(self.properties, &name, group),
name,
group,
program: Some(program),
@@ -1270,6 +1276,29 @@ fn buffer_blends(properties: &Properties, program: &str) -> Vec<(u32, Option<Ble
.collect()
}
/// A program drawing all of its buffers.
const FULL: [f32; 3] = [1.0, 0.0, 0.0];
/// `scale.<program>=<scale> [<offsetX> <offsetY>]` of a deferred or composite program (OptiFine's
/// "Composite render scale"); prepare programs are treated the same, unverified. Iris 1.11.7
/// applies the offsets too (a probe pack of our own, composite). A value that is not a scale in (0, 1] with offsets in [0, 1) draws all of them.
fn scale(properties: &Properties, program: &str, group: Group) -> [f32; 3] {
if !matches!(group, Group::Prepare | Group::Deferred | Group::Composite) {
return FULL;
}
let Some(value) = properties.get(&format!("scale.{program}")) else {
return FULL;
};
let numbers: Option<Vec<f32>> = value.split_whitespace().map(|n| n.parse().ok()).collect();
let fraction = |s: f32| s > 0.0 && s <= 1.0;
let offset = |o: f32| (0.0..1.0).contains(&o);
match numbers.as_deref() {
Some(&[s]) if fraction(s) => [s, 0.0, 0.0],
Some(&[s, x, y]) if fraction(s) && offset(x) && offset(y) => [s, x, y],
_ => FULL,
}
}
/// `flip.<program>.<buffer>=<bool>` keys of one program.
fn flips(properties: &Properties, program: &str) -> Vec<(u32, bool)> {
let prefix = format!("flip.{program}.");
@@ -1525,6 +1554,58 @@ mod tests {
assert!(post.programs.iter().any(|p| p.name == "composite3"));
}
#[test]
fn scale_keys_give_composite_style_programs_a_viewport() {
let names = [
"deferred",
"composite",
"composite1",
"composite2",
"composite3",
"final",
];
let files: Vec<(String, &str)> = names
.iter()
.flat_map(|n| {
[
(format!("shaders/{n}.vsh"), VSH),
(format!("shaders/{n}.fsh"), FSH),
]
})
.collect();
let files: Vec<(&str, &str)> = files.iter().map(|(n, t)| (n.as_str(), *t)).collect();
let pack = Pack::from_zip_bytes("t", zip(&files)).unwrap();
let properties = Properties::parse(
"scale.deferred = 0.5\nscale.composite = 0.25 0.5 0.25\nscale.composite1 = 2\n\
scale.composite2 = 0.5 0.5\nscale.final = 0.5\n",
&Macros::default(),
);
let post = load_post(
&pack,
"",
&properties,
&Macros::standard(),
&GameInputs::default(),
);
let scales: Vec<(&str, [f32; 3])> = post
.programs
.iter()
.map(|p| (p.name.as_str(), p.scale))
.collect();
assert_eq!(
scales,
[
("deferred", [0.5, 0.0, 0.0]),
("composite", [0.25, 0.5, 0.25]),
// Not a scale, an offset missing, a program that draws the screen: all of it.
("composite1", FULL),
("composite2", FULL),
("composite3", FULL),
("final", FULL),
]
);
}
#[test]
fn image_declarations_get_their_format_once() {
let csh = |decl: &str| {
+4 -1
View File
@@ -40,7 +40,10 @@ cargo horizon-mod build # only build: target/horizon-mod/my_mod.hmod
cargo horizon-mod check target/horizon-mod/my_mod.hmod # the registry's checks; --json for JSON
```
- `new <path> [--name "Name"]` writes the template. It makes content mods only: any other `--template` is refused.
- `new <path> [--name "Name"]` writes the template. `--template native [--horizon <commit>]` writes a native trait
mod's bundle instead: `horizon-mixin.toml` (pinned to that Horizon commit, else unpinned) and an `[api]` crate with
one hook, built with `horizon-launcher native-build <path> --trust-native-code`
([native-mods.md](native-mods.md#trait-mods-draft)). Any other `--template` is refused.
- `build` packs `horizon-mod.toml`, `assets/` and `data/` into `target/horizon-mod/<id>.hmod`, with the same checks
the game makes when it reads the content. It refuses a folder with a `Cargo.toml` (code is native now) and a
manifest without capability `registries`.
+41 -1
View File
@@ -2,6 +2,8 @@
API @API:compute_fov_modifier@ adds `native_compute_fov_modifier(player, base, modifier)` on the client each tick for the local player's FOV scale, after vanilla effects and before zoom and camera easing. `player` contains the calculation inputs and `base` is the original scale. Non-finite modifiers or values outside 0.01..=10 fall back to `base`. It does not affect a spectator camera on a mob. Bundles need "hooks": @API:compute_fov_modifier@.
The client hook `native_render_name_tag(entity_id, kind, vanilla_visible, visible, text)` runs at player and mob, armor stand, and item frame name decisions before shared geometry. Set `visible` to override vanilla and `text` to `Some` literal replacement text (NeoForge `RenderNameTagEvent`). Distance and entity availability still apply; the local player's tag is considered only when its own camera and settings gates allow it. Bundles need "hooks": @API:render_name_tag@.
Native mods are **mixin bundles** that change the game's own Rust source before it is compiled, with
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) (MIT, by levzzz5154, made for Horizon). A bundle
is a `horizon-mixin.toml` and a few `.rs` helpers that hook into named functions of the game (HEAD, BEFORE_CALL,
@@ -378,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
@@ -2039,6 +2049,8 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
- **`native_entity_pose_change(player, name, from, to)`**: a guest player's synchronized pose changed. Pose names are `STANDING`, `CROUCHING`, `SWIMMING`, `FALL_FLYING`, `SPIN_ATTACK` or `SLEEPING`.
- **`native_entity_toggle_glide(player, name, gliding)`**: a guest player's fall flying state changed after the server accepted or ended a glide.
- **`native_entity_shoot_bow(player, name, projectile, force)`**: a player fired an arrow from a bow or crossbow, after the arrow entered the level. `force` is the shooting power. This reports the completed shot.
- **`native_arrow_nock(player, name, bow, hand, has_ammo, cancel)`**: a player is about to start using a bow, where `BowItem.use` runs on the server (NeoForge's `ArrowNockEvent`). `has_ammo` is whether `getProjectile` found ammunition, always true in creative. Set `cancel` to fail the use: the bow is not drawn. Crossbows and tridents do not ask, and the client's own prediction does not.
- **`native_living_get_projectile(player, name, entity, kind, weapon, projectile)`**: the ammunition `LivingEntity.getProjectile(weapon)` returns (NeoForge's `LivingGetProjectileEvent`). Every mod gets the event with `projectile` set to the vanilla ammunition (the stack found in the hands or inventory, a mob's held projectile or a new arrow, a creative player's default arrow, or empty) and may change or empty it. It is asked on the bow's start and release, the crossbow's start and draw, the client's prediction of those starts, a skeleton's or illusioner's shot, and a pillager's or piglin's crossbow load (`entity` and `kind` name the mob, `player` is empty); a mob's arrow takes the replaced arrow's item and effect, a mob's crossbow also fires a replaced firework rocket, and an emptied stack leaves its crossbow uncharged and silent. A replaced stack is not taken from the inventory: the hook owns it.
- **`native_projectile_launch(player, name, entity, kind)`**: a projectile was added to the server level. `kind` is its entity type; `player` and `name` identify a player launcher, and are `None` and empty for other sources.
- **`native_entity_portal(dimension, entity, pos)`**: a server entity finished its portal wait and is beginning to leave through the portal block at `pos`. The destination follows the existing portal rules; the hook reports the transfer without changing it.
- **`native_furnace_extract(player, name, pos, item, count)`**: a server player takes furnace output at `pos`, after the output is removed; the player state is not swapped in.
@@ -2226,6 +2238,10 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
that is still there (`ServerLevel.tickBlock`), before its own `tick`; the same contract as `native_random_tick`.
- **`native_explosion_detonate(dimension, center, blocks)`** (API 22): the positions an explosion is about to break,
before any is hit; take out the ones to spare. Entities are hurt as before.
- **`native_explosion_knockback(dimension, center, blocks, entity, knockback_velocity)`**: a server explosion's
calculated velocity, before it is added to the entity. Replace `knockback_velocity` to change the push;
`entity` is None for a player, whom `native_acting_player` names. The player packet uses the same replacement. Wind bursts are heard too.
Bundles need `"hooks": @API:explosion_knockback@`.
- **`native_fill_bucket(stack, pos, canceled)`** and **`native_attack_entity(entity, kind, pos, canceled)`** (API 22):
a player's bucket at a block, and their blow on an entity, with the acting player swapped in. Canceled, the bucket
does nothing and the blow does no harm. A fluid or mob bucket also asks about the block beside the clicked face
@@ -2307,6 +2323,23 @@ A model that moves (a turning gun barrel, a thrown projectile) is an `item_displ
the mod's `.hmod` assets: summon it once, then move and pose it from a tick hook. Easing between ticks happens on each
client, so a call a tick looks smooth.
### Entity and level handles
`crates/horizon-client/src/game/native_entity_refs.rs` (#7387): an `EntityRef` names one entity of the open world and a
`LevelRef` one level. They are small `Copy` values a mod keeps, and their methods take the host, so a mod can hold
several at once: `host.entity(id)` makes one from the id an event carries, `host.level("minecraft:the_nether")` one from
a dimension.
- `entity.position(host)`, `block_position`, `rotation` (yaw and pitch), `kind` (`minecraft:zombie`), `uuid` (`&mut`:
an entity with no UUID yet gets its own) and `level` read the entity.
- An `EntityRef` holds the world's open (`NativeWorldEpoch`) and the entity's id, which no other entity of that open
gets. A handle of an earlier open, of an entity that is gone or moved to another level (it gets a new id there, as in
vanilla), or used on a client reads as `None`.
- The entities are those of the level in place (the one a hook runs in) and the server's players, whatever their level.
Each read is a `Host` method too (`host.entity_position(entity)`): a helper whose catalog entry names a `receiver`
(`entity`, `level`) also becomes a method of that handle.
### Mobs drawn by a mod
`crates/horizon-client/src/game/native_mob_draw.rs`: `native_mob_submit(mob, draw, drawn)` (client) is asked once a frame for
@@ -2537,7 +2570,9 @@ render thread with the frame's wgpu objects; what a mod draws there shows in tha
the first-person hand, the HUD and screens not yet. `WorldDrawn` holds the device, queue and encoder, the frame's
colour target (copy it out before sampling it), the world's reversed depth and its size, the camera's position and
its matrices with and without TAA's jitter, the frame's camera-relative entity quads with their runs by entity, the
entity atlas and the shared quad index buffer. Not called on the title screen's panorama.
entity atlas and the shared quad index buffer. Not called on the title screen's panorama. `motion` says which mob,
player or dropped item each run of the entity quads is, by its entity id, so a mod can match an entity's vertices
with last frame's (a motion blur's own motion vectors); `spans` only name the type.
- `native_hand_drawn(hand)`: the first-person hand and held item are drawn, after `native_world_drawn` and before
entity outlines, post effects, screen effects and the HUD, on every frame `native_world_drawn` runs. `HandDrawn`
holds the device, queue and encoder, the frame's colour target with the hand in it, the hand's camera-relative
@@ -2611,6 +2646,11 @@ the mod: a vanilla client decodes the count with vanilla's 1 to 99 and cannot sh
## Trait mods (draft)
`cargo horizon-mod new <path> --template native --horizon <commit>` writes a trait mod to start from: the bundle's
`horizon-mixin.toml`, pinned to that Horizon commit, and an `[api]` crate whose `hooks()` greets each player who
joins. Its files are what a bundle may carry ([api/mod-files.md](api/mod-files.md)), so the launcher builds the folder
as it is (`native-build <path>`) and packs it into a `.hnmod`.
A bundle declares its trait mod package by its path inside the bundle:
```toml
Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "In dieser Instanz sind keine Server gespeichert.",
"1 server in {name}": "1 Server in {name}",
"{count} servers in {name}": "{count} Server in {name}",
"Click again to remove": "Zum Entfernen erneut klicken"
"Click again to remove": "Zum Entfernen erneut klicken",
"Resource pack · on": "Ressourcenpaket · an",
"Resource pack · off": "Ressourcenpaket · aus",
"Shader pack · on": "Shaderpaket · an",
"Shader pack · off": "Shaderpaket · aus",
"1 pack in {name}": "1 Paket in {name}",
"{count} packs in {name}": "{count} Pakete in {name}",
"No resource packs. Find them on the Mods page under Modrinth.": "Keine Ressourcenpakete. Du findest welche auf der Mods-Seite unter Modrinth.",
"No mods in {name} yet.": "Noch keine Mods in {name}.",
"1 mod in {name}": "1 Mod in {name}",
"{count} mods in {name}": "{count} Mods in {name}",
"Find mods": "Mods finden",
"A copy of {name}": "Eine Kopie von {name}",
"A fresh game folder": "Ein neuer Spielordner",
"A new Horizon instance": "Eine neue Horizon-Instanz",
"A new modded instance": "Eine neue Mod-Instanz",
"A new vanilla instance": "Eine neue Vanilla-Instanz",
"Block and item icons come from your prepared game files once a version is prepared.": "Block- und Gegenstandssymbole kommen aus deinen vorbereiteten Spieldateien, sobald eine Version vorbereitet ist.",
"Changes apply the next time it starts. Worlds stay where they are.": "Änderungen gelten beim nächsten Start. Welten bleiben, wo sie sind.",
"Create instance": "Instanz erstellen",
"Edit {name}": "{name} bearbeiten",
"Fabric mods run here too": "Fabric-Mods laufen hier auch",
"For older packs": "Für ältere Modpacks",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Installiert das neueste stabile {loader} für Minecraft {version}.",
"Its mods, worlds and options": "Seine Mods, Welten und Optionen",
"Its own worlds and packs. Your options and player name come along.": "Eigene Welten und Pakete. Deine Optionen und dein Spielername kommen mit.",
"Light and fast to update": "Leicht und schnell aktualisiert",
"Makes a folder in {path}": "Legt einen Ordner in {path} an",
"Minecraft as Mojang ships it": "Minecraft, wie Mojang es ausliefert",
"Minecraft rebuilt in Rust, with Horizon mods": "Minecraft neu gebaut in Rust, mit Horizon-Mods",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "Name und Gruppe",
"No version matches “{search}”.": "Keine Version passt zu „{search}“.",
"Pick what it plays, then give it a name and an icon.": "Wähle, was sie spielt, dann gib ihr einen Namen und ein Symbol.",
"Play downloads the game from Mojang the first time, with the Java it needs.": "Spielen lädt das Spiel beim ersten Mal von Mojang, mit dem nötigen Java.",
"Save changes": "Änderungen speichern",
"The big modpacks' home": "Zuhause der großen Modpacks",
"{first} or {last}": "{first} oder {last}"
}
+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "No hay servidores guardados en esta instancia.",
"1 server in {name}": "1 servidor en {name}",
"{count} servers in {name}": "{count} servidores en {name}",
"Click again to remove": "Haz clic otra vez para quitar"
"Click again to remove": "Haz clic otra vez para quitar",
"Resource pack · on": "Paquete de recursos · activado",
"Resource pack · off": "Paquete de recursos · desactivado",
"Shader pack · on": "Paquete de shaders · activado",
"Shader pack · off": "Paquete de shaders · desactivado",
"1 pack in {name}": "1 paquete en {name}",
"{count} packs in {name}": "{count} paquetes en {name}",
"No resource packs. Find them on the Mods page under Modrinth.": "No hay paquetes de recursos. Búscalos en la página Mods, en Modrinth.",
"No mods in {name} yet.": "Aún no hay mods en {name}.",
"1 mod in {name}": "1 mod en {name}",
"{count} mods in {name}": "{count} mods en {name}",
"Find mods": "Buscar mods",
"A copy of {name}": "Una copia de {name}",
"A fresh game folder": "Una carpeta de juego nueva",
"A new Horizon instance": "Una nueva instancia de Horizon",
"A new modded instance": "Una nueva instancia con mods",
"A new vanilla instance": "Una nueva instancia vanilla",
"Block and item icons come from your prepared game files once a version is prepared.": "Los iconos de bloques y objetos salen de tus archivos del juego preparados, cuando prepares una versión.",
"Changes apply the next time it starts. Worlds stay where they are.": "Los cambios se aplican la próxima vez que se inicie. Los mundos se quedan donde están.",
"Create instance": "Crear instancia",
"Edit {name}": "Editar {name}",
"Fabric mods run here too": "Los mods de Fabric también funcionan aquí",
"For older packs": "Para modpacks más antiguos",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Instala el {loader} estable más reciente para Minecraft {version}.",
"Its mods, worlds and options": "Sus mods, mundos y opciones",
"Its own worlds and packs. Your options and player name come along.": "Sus propios mundos y paquetes. Tus opciones y tu nombre de jugador vienen contigo.",
"Light and fast to update": "Ligero y rápido de actualizar",
"Makes a folder in {path}": "Crea una carpeta en {path}",
"Minecraft as Mojang ships it": "Minecraft tal como lo publica Mojang",
"Minecraft rebuilt in Rust, with Horizon mods": "Minecraft reconstruido en Rust, con mods de Horizon",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "Nombre y grupo",
"No version matches “{search}”.": "Ninguna versión coincide con «{search}».",
"Pick what it plays, then give it a name and an icon.": "Elige qué juega y luego dale un nombre y un icono.",
"Play downloads the game from Mojang the first time, with the Java it needs.": "Jugar descarga el juego de Mojang la primera vez, con el Java que necesita.",
"Save changes": "Guardar cambios",
"The big modpacks' home": "El hogar de los grandes modpacks",
"{first} or {last}": "{first} o {last}"
}
+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "Aucun serveur enregistré dans cette instance.",
"1 server in {name}": "1 serveur dans {name}",
"{count} servers in {name}": "{count} serveurs dans {name}",
"Click again to remove": "Cliquer encore pour retirer"
"Click again to remove": "Cliquer encore pour retirer",
"Resource pack · on": "Pack de ressources · activé",
"Resource pack · off": "Pack de ressources · désactivé",
"Shader pack · on": "Pack de shaders · activé",
"Shader pack · off": "Pack de shaders · désactivé",
"1 pack in {name}": "1 pack dans {name}",
"{count} packs in {name}": "{count} packs dans {name}",
"No resource packs. Find them on the Mods page under Modrinth.": "Aucun pack de ressources. Trouves-en sur la page Mods, sous Modrinth.",
"No mods in {name} yet.": "Aucun mod dans {name} pour l'instant.",
"1 mod in {name}": "1 mod dans {name}",
"{count} mods in {name}": "{count} mods dans {name}",
"Find mods": "Trouver des mods",
"A copy of {name}": "Une copie de {name}",
"A fresh game folder": "Un nouveau dossier de jeu",
"A new Horizon instance": "Une nouvelle instance Horizon",
"A new modded instance": "Une nouvelle instance moddée",
"A new vanilla instance": "Une nouvelle instance vanilla",
"Block and item icons come from your prepared game files once a version is prepared.": "Les icônes de blocs et d'objets viennent de vos fichiers de jeu préparés, une fois une version préparée.",
"Changes apply the next time it starts. Worlds stay where they are.": "Les changements s'appliquent au prochain lancement. Les mondes restent où ils sont.",
"Create instance": "Créer l'instance",
"Edit {name}": "Modifier {name}",
"Fabric mods run here too": "Les mods Fabric y tournent aussi",
"For older packs": "Pour les packs plus anciens",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Installe le {loader} stable le plus récent pour Minecraft {version}.",
"Its mods, worlds and options": "Ses mods, mondes et options",
"Its own worlds and packs. Your options and player name come along.": "Ses propres mondes et packs. Vos options et votre pseudo suivent.",
"Light and fast to update": "Léger et vite à jour",
"Makes a folder in {path}": "Crée un dossier dans {path}",
"Minecraft as Mojang ships it": "Minecraft tel que Mojang le publie",
"Minecraft rebuilt in Rust, with Horizon mods": "Minecraft réécrit en Rust, avec les mods Horizon",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "Nom et groupe",
"No version matches “{search}”.": "Aucune version ne correspond à « {search} ».",
"Pick what it plays, then give it a name and an icon.": "Choisissez ce qu'elle lance, puis donnez-lui un nom et une icône.",
"Play downloads the game from Mojang the first time, with the Java it needs.": "Jouer télécharge le jeu chez Mojang la première fois, avec le Java qu'il lui faut.",
"Save changes": "Enregistrer",
"The big modpacks' home": "La maison des gros modpacks",
"{first} or {last}": "{first} ou {last}"
}
+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "このインスタンスに保存されたサーバーはありません。",
"1 server in {name}": "{name} のサーバー 1 件",
"{count} servers in {name}": "{name} のサーバー {count} 件",
"Click again to remove": "もう一度クリックして削除"
"Click again to remove": "もう一度クリックして削除",
"Resource pack · on": "リソースパック · オン",
"Resource pack · off": "リソースパック · オフ",
"Shader pack · on": "シェーダーパック · オン",
"Shader pack · off": "シェーダーパック · オフ",
"1 pack in {name}": "{name} のパック 1 件",
"{count} packs in {name}": "{name} のパック {count} 件",
"No resource packs. Find them on the Mods page under Modrinth.": "リソースパックがありません。Mods ページの Modrinth で探せます。",
"No mods in {name} yet.": "{name} にはまだ Mod がありません。",
"1 mod in {name}": "{name} の Mod 1 件",
"{count} mods in {name}": "{name} の Mod {count} 件",
"Find mods": "Mod を探す",
"A copy of {name}": "{name} のコピー",
"A fresh game folder": "新しいゲームフォルダー",
"A new Horizon instance": "新しい Horizon インスタンス",
"A new modded instance": "新しい Mod 入りインスタンス",
"A new vanilla instance": "新しいバニラインスタンス",
"Block and item icons come from your prepared game files once a version is prepared.": "ブロックとアイテムのアイコンは、バージョンを準備すると準備済みのゲームファイルから表示されます。",
"Changes apply the next time it starts. Worlds stay where they are.": "変更は次回の起動時に反映されます。ワールドはそのままです。",
"Create instance": "インスタンスを作成",
"Edit {name}": "{name} を編集",
"Fabric mods run here too": "Fabric の Mod もここで動きます",
"For older packs": "古いパック向け",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Minecraft {version} 用の最新の安定版 {loader} をインストールします。",
"Its mods, worlds and options": "その Mod、ワールド、設定",
"Its own worlds and packs. Your options and player name come along.": "専用のワールドとパック。設定とプレイヤー名は引き継がれます。",
"Light and fast to update": "軽量で更新が速い",
"Makes a folder in {path}": "{path} にフォルダーを作ります",
"Minecraft as Mojang ships it": "Mojang が配布するままの Minecraft",
"Minecraft rebuilt in Rust, with Horizon mods": "Rust で作り直した Minecraft。Horizon の Mod 付き",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "名前とグループ",
"No version matches “{search}”.": "「{search}」に一致するバージョンはありません。",
"Pick what it plays, then give it a name and an icon.": "何を遊ぶか選んでから、名前とアイコンを付けましょう。",
"Play downloads the game from Mojang the first time, with the Java it needs.": "初回のプレイ時に、必要な Java と一緒に Mojang からゲームをダウンロードします。",
"Save changes": "変更を保存",
"The big modpacks' home": "大型 Modpack の定番",
"{first} or {last}": "{first} または {last}"
}
+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "W tej instancji nie zapisano żadnych serwerów.",
"1 server in {name}": "1 serwer w {name}",
"{count} servers in {name}": "Serwery w {name}: {count}",
"Click again to remove": "Kliknij ponownie, aby usunąć"
"Click again to remove": "Kliknij ponownie, aby usunąć",
"Resource pack · on": "Paczka zasobów · wł.",
"Resource pack · off": "Paczka zasobów · wył.",
"Shader pack · on": "Paczka shaderów · wł.",
"Shader pack · off": "Paczka shaderów · wył.",
"1 pack in {name}": "1 paczka w {name}",
"{count} packs in {name}": "Paczki w {name}: {count}",
"No resource packs. Find them on the Mods page under Modrinth.": "Brak paczek zasobów. Znajdziesz je na stronie Mody, w zakładce Modrinth.",
"No mods in {name} yet.": "W {name} nie ma jeszcze modów.",
"1 mod in {name}": "1 mod w {name}",
"{count} mods in {name}": "Mody w {name}: {count}",
"Find mods": "Znajdź mody",
"A copy of {name}": "Kopia {name}",
"A fresh game folder": "Nowy folder gry",
"A new Horizon instance": "Nowa instancja Horizon",
"A new modded instance": "Nowa instancja z modami",
"A new vanilla instance": "Nowa instancja vanilla",
"Block and item icons come from your prepared game files once a version is prepared.": "Ikony bloków i przedmiotów pochodzą z przygotowanych plików gry, gdy przygotujesz wersję.",
"Changes apply the next time it starts. Worlds stay where they are.": "Zmiany zadziałają przy następnym uruchomieniu. Światy zostają na miejscu.",
"Create instance": "Utwórz instancję",
"Edit {name}": "Edytuj {name}",
"Fabric mods run here too": "Mody Fabric też tu działają",
"For older packs": "Do starszych paczek",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Instaluje najnowszy stabilny {loader} dla Minecraft {version}.",
"Its mods, worlds and options": "Jej mody, światy i opcje",
"Its own worlds and packs. Your options and player name come along.": "Własne światy i paczki. Twoje opcje i nazwa gracza przechodzą razem.",
"Light and fast to update": "Lekki i szybko aktualizowany",
"Makes a folder in {path}": "Tworzy folder w {path}",
"Minecraft as Mojang ships it": "Minecraft taki, jak wydaje go Mojang",
"Minecraft rebuilt in Rust, with Horizon mods": "Minecraft przepisany w Rust, z modami Horizon",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "Nazwa i grupa",
"No version matches “{search}”.": "Żadna wersja nie pasuje do „{search}”.",
"Pick what it plays, then give it a name and an icon.": "Wybierz, w co gra, potem nadaj jej nazwę i ikonę.",
"Play downloads the game from Mojang the first time, with the Java it needs.": "Graj pobiera grę od Mojang za pierwszym razem, razem z potrzebną Javą.",
"Save changes": "Zapisz zmiany",
"The big modpacks' home": "Dom dużych modpacków",
"{first} or {last}": "{first} lub {last}"
}
+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "Nenhum servidor salvo nesta instância.",
"1 server in {name}": "1 servidor em {name}",
"{count} servers in {name}": "{count} servidores em {name}",
"Click again to remove": "Clique de novo para remover"
"Click again to remove": "Clique de novo para remover",
"Resource pack · on": "Pacote de recursos · ligado",
"Resource pack · off": "Pacote de recursos · desligado",
"Shader pack · on": "Pacote de shaders · ligado",
"Shader pack · off": "Pacote de shaders · desligado",
"1 pack in {name}": "1 pacote em {name}",
"{count} packs in {name}": "{count} pacotes em {name}",
"No resource packs. Find them on the Mods page under Modrinth.": "Nenhum pacote de recursos. Encontre alguns na página Mods, em Modrinth.",
"No mods in {name} yet.": "Ainda não há mods em {name}.",
"1 mod in {name}": "1 mod em {name}",
"{count} mods in {name}": "{count} mods em {name}",
"Find mods": "Encontrar mods",
"A copy of {name}": "Uma cópia de {name}",
"A fresh game folder": "Uma pasta de jogo nova",
"A new Horizon instance": "Uma nova instância do Horizon",
"A new modded instance": "Uma nova instância com mods",
"A new vanilla instance": "Uma nova instância vanilla",
"Block and item icons come from your prepared game files once a version is prepared.": "Os ícones de blocos e itens vêm dos seus arquivos de jogo preparados, assim que uma versão for preparada.",
"Changes apply the next time it starts. Worlds stay where they are.": "As mudanças valem na próxima vez que iniciar. Os mundos ficam onde estão.",
"Create instance": "Criar instância",
"Edit {name}": "Editar {name}",
"Fabric mods run here too": "Mods do Fabric também rodam aqui",
"For older packs": "Para pacotes mais antigos",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Instala o {loader} estável mais recente para o Minecraft {version}.",
"Its mods, worlds and options": "Seus mods, mundos e opções",
"Its own worlds and packs. Your options and player name come along.": "Mundos e pacotes próprios. Suas opções e seu nome de jogador vêm junto.",
"Light and fast to update": "Leve e rápido de atualizar",
"Makes a folder in {path}": "Cria uma pasta em {path}",
"Minecraft as Mojang ships it": "Minecraft como a Mojang lança",
"Minecraft rebuilt in Rust, with Horizon mods": "Minecraft refeito em Rust, com mods do Horizon",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "Nome e grupo",
"No version matches “{search}”.": "Nenhuma versão corresponde a “{search}”.",
"Pick what it plays, then give it a name and an icon.": "Escolha o que ela joga e depois dê um nome e um ícone.",
"Play downloads the game from Mojang the first time, with the Java it needs.": "Jogar baixa o jogo da Mojang na primeira vez, com o Java de que ele precisa.",
"Save changes": "Salvar alterações",
"The big modpacks' home": "A casa dos grandes modpacks",
"{first} or {last}": "{first} ou {last}"
}
+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "В этой сборке нет сохранённых серверов.",
"1 server in {name}": "1 сервер в {name}",
"{count} servers in {name}": "Серверов в {name}: {count}",
"Click again to remove": "Нажмите ещё раз, чтобы удалить"
"Click again to remove": "Нажмите ещё раз, чтобы удалить",
"Resource pack · on": "Набор ресурсов · вкл.",
"Resource pack · off": "Набор ресурсов · выкл.",
"Shader pack · on": "Набор шейдеров · вкл.",
"Shader pack · off": "Набор шейдеров · выкл.",
"1 pack in {name}": "1 набор в {name}",
"{count} packs in {name}": "Наборов в {name}: {count}",
"No resource packs. Find them on the Mods page under Modrinth.": "Наборов ресурсов нет. Их можно найти на странице «Моды» в разделе Modrinth.",
"No mods in {name} yet.": "В {name} пока нет модов.",
"1 mod in {name}": "1 мод в {name}",
"{count} mods in {name}": "Модов в {name}: {count}",
"Find mods": "Найти моды",
"A copy of {name}": "Копия {name}",
"A fresh game folder": "Новая папка игры",
"A new Horizon instance": "Новая сборка Horizon",
"A new modded instance": "Новая сборка с модами",
"A new vanilla instance": "Новая ванильная сборка",
"Block and item icons come from your prepared game files once a version is prepared.": "Значки блоков и предметов берутся из подготовленных файлов игры, когда версия подготовлена.",
"Changes apply the next time it starts. Worlds stay where they are.": "Изменения вступят в силу при следующем запуске. Миры остаются на месте.",
"Create instance": "Создать сборку",
"Edit {name}": "Изменить {name}",
"Fabric mods run here too": "Моды Fabric тоже работают",
"For older packs": "Для старых сборок",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Устанавливает последний стабильный {loader} для Minecraft {version}.",
"Its mods, worlds and options": "Её моды, миры и настройки",
"Its own worlds and packs. Your options and player name come along.": "Свои миры и наборы. Ваши настройки и имя игрока переносятся.",
"Light and fast to update": "Лёгкий и быстро обновляется",
"Makes a folder in {path}": "Создаёт папку в {path}",
"Minecraft as Mojang ships it": "Minecraft в том виде, как его выпускает Mojang",
"Minecraft rebuilt in Rust, with Horizon mods": "Minecraft, переписанный на Rust, с модами Horizon",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "Название и группа",
"No version matches “{search}”.": "Нет версий по запросу «{search}».",
"Pick what it plays, then give it a name and an icon.": "Выберите, что она запускает, затем дайте ей название и значок.",
"Play downloads the game from Mojang the first time, with the Java it needs.": "При первом запуске игра скачивается у Mojang вместе с нужной Java.",
"Save changes": "Сохранить изменения",
"The big modpacks' home": "Дом больших сборок",
"{first} or {last}": "{first} или {last}"
}
+39 -1
View File
@@ -874,5 +874,43 @@
"No servers saved in this instance.": "Bu kurulumda kayıtlı sunucu yok.",
"1 server in {name}": "{name} içinde 1 sunucu",
"{count} servers in {name}": "{name} içinde {count} sunucu",
"Click again to remove": "Kaldırmak için tekrar tıklayın"
"Click again to remove": "Kaldırmak için tekrar tıklayın",
"Resource pack · on": "Kaynak paketi · açık",
"Resource pack · off": "Kaynak paketi · kapalı",
"Shader pack · on": "Gölgelendirici paketi · açık",
"Shader pack · off": "Gölgelendirici paketi · kapalı",
"1 pack in {name}": "{name} içinde 1 paket",
"{count} packs in {name}": "{name} içinde {count} paket",
"No resource packs. Find them on the Mods page under Modrinth.": "Kaynak paketi yok. Modlar sayfasında Modrinth altında bulabilirsiniz.",
"No mods in {name} yet.": "{name} içinde henüz mod yok.",
"1 mod in {name}": "{name} içinde 1 mod",
"{count} mods in {name}": "{name} içinde {count} mod",
"Find mods": "Mod bul",
"A copy of {name}": "{name} kopyası",
"A fresh game folder": "Yeni bir oyun klasörü",
"A new Horizon instance": "Yeni bir Horizon kurulumu",
"A new modded instance": "Yeni bir modlu kurulum",
"A new vanilla instance": "Yeni bir vanilla kurulum",
"Block and item icons come from your prepared game files once a version is prepared.": "Blok ve eşya simgeleri, bir sürüm hazırlandığında hazırlanmış oyun dosyalarınızdan gelir.",
"Changes apply the next time it starts. Worlds stay where they are.": "Değişiklikler bir sonraki başlatışta geçerli olur. Dünyalar yerinde kalır.",
"Create instance": "Kurulum oluştur",
"Edit {name}": "{name} düzenle",
"Fabric mods run here too": "Fabric modları burada da çalışır",
"For older packs": "Eski paketler için",
"Horizon": "Horizon",
"Installs the newest stable {loader} for Minecraft {version}.": "Minecraft {version} için en yeni kararlı {loader} sürümünü kurar.",
"Its mods, worlds and options": "Modları, dünyaları ve seçenekleri",
"Its own worlds and packs. Your options and player name come along.": "Kendi dünyaları ve paketleri. Seçeneklerin ve oyuncu adın da gelir.",
"Light and fast to update": "Hafif ve hızlı güncellenir",
"Makes a folder in {path}": "{path} içinde bir klasör oluşturur",
"Minecraft as Mojang ships it": "Mojang'ın yayımladığı haliyle Minecraft",
"Minecraft rebuilt in Rust, with Horizon mods": "Rust ile yeniden yazılmış Minecraft, Horizon modlarıyla",
"Minecraft {version}": "Minecraft {version}",
"Name and group": "Ad ve grup",
"No version matches “{search}”.": "“{search}” ile eşleşen sürüm yok.",
"Pick what it plays, then give it a name and an icon.": "Ne oynatacağını seç, sonra bir ad ve simge ver.",
"Play downloads the game from Mojang the first time, with the Java it needs.": "Oyna, oyunu ilk seferde gereken Java ile birlikte Mojang'dan indirir.",
"Save changes": "Değişiklikleri kaydet",
"The big modpacks' home": "Büyük mod paketlerinin evi",
"{first} or {last}": "{first} veya {last}"
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more