Merge main into #7584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 23:25:07 +01:00
commit 35210289dd
175 files changed
+9968 -849

No files matched your search

+1
View File
@@ -55,6 +55,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
Generated
+13
View File
@@ -367,6 +367,7 @@ version = "0.1.0"
dependencies = [
"horizon-mods",
"serde_json",
"zip",
]
[[package]]
@@ -1497,6 +1498,18 @@ dependencies = [
"libm",
]
[[package]]
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"glam",
"png",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
+1
View File
@@ -12,6 +12,7 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
+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());
}
+1 -2
View File
@@ -119,8 +119,7 @@ impl Assets {
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
return Err(invalid("resources do not match Horizon's pinned data version").into());
}
let (registry, flags) =
BlockRegistry::from_facts_json_with_flags(File::open(root.join("facts.json"))?)?;
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
let bundle: Value =
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
let mut definitions = TagDefinitions::default();
+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,
+29 -17
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,
});
@@ -9214,20 +9216,30 @@ impl Renderer {
.contains(wgpu::TextureUsages::COPY_SRC)
{
let device = &self.device;
self.post_effects
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
.apply(
&self.device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
overlay.world_depth.then_some(&self.depth),
&frame.post_effects,
timing.as_mut(),
);
let post = self
.post_effects
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device));
// The chains a spectated mob applies compile in the background from the
// first frame in a world, not in the frame that first needs one.
post.preload(
&self.device,
&self.queue,
frame.post_effects.root,
&crate::post_chain::SPECTATOR_CHAINS,
target_texture.format(),
);
post.apply(
&self.device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
overlay.world_depth.then_some(&self.depth),
&frame.post_effects,
timing.as_mut(),
);
}
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -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());
}
}
+192 -34
View File
@@ -22,6 +22,7 @@
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
/// `PostChain.MAIN_TARGET_ID`.
const MAIN: &str = "minecraft:main";
@@ -539,8 +540,21 @@ pub struct PostEffects {
blit: Option<Pass>,
/// The `Globals` block.
globals: wgpu::Buffer,
/// Chains loading on a thread of their own ([`PostEffects::preload`]), for the
/// root they load from; `pending` until they arrive.
preloading: Option<(PathBuf, mpsc::Receiver<Preloaded>)>,
pending: HashSet<String>,
}
/// A chain [`PostEffects::preload`] loaded, by id.
type Preloaded = (String, Result<Option<Chain>, String>);
/// The chains a spectated entity applies (`GameRenderer.checkEntityPostEffect`): the
/// first frame through a creeper's eyes compiled its pipelines, a 0.1-0.9 s freeze on
/// the Mac mini.
pub const SPECTATOR_CHAINS: [&str; 3] =
["minecraft:creeper", "minecraft:spider", "minecraft:invert"];
fn resource_path(root: &Path, id: &str) -> PathBuf {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
root.join("assets")
@@ -612,6 +626,8 @@ impl PostEffects {
bilinear: make(wgpu::FilterMode::Linear),
chains: HashMap::new(),
failed: HashSet::new(),
preloading: None,
pending: HashSet::new(),
root: PathBuf::new(),
work: None,
staging: None,
@@ -632,6 +648,91 @@ impl PostEffects {
self.root = root.to_owned();
self.chains.clear();
self.failed.clear();
self.preloading = None;
self.pending.clear();
}
}
/// Loads the `ids` chains (namespaced) not loaded yet and compiles their pipelines for
/// `format`, on a thread of their own: `apply` leaves a chain out until it has
/// arrived instead of compiling it in the frame that first asks for it.
pub fn preload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
root: &Path,
ids: &[&str],
format: wgpu::TextureFormat,
) {
self.use_root(root);
if self.preloading.is_some() {
return;
}
let ids: Vec<String> = ids
.iter()
.filter(|id| !self.chains.contains_key(**id) && !self.failed.contains(**id))
.map(|id| normalise(id))
.collect();
if ids.is_empty() {
return;
}
let (tx, rx) = mpsc::channel();
let (device, queue, root) = (device.clone(), queue.clone(), root.to_owned());
let format = format.remove_srgb_suffix();
let spawned = std::thread::Builder::new()
.name("Post effects".into())
.spawn({
let ids = ids.clone();
move || {
for id in ids {
let mut chain = Self::load(&root, &device, &queue, &id);
if let Ok(Some(chain)) = &mut chain {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
translated.ensure_pipeline(&device, format);
}
}
}
if tx.send((id, chain)).is_err() {
return;
}
}
}
});
if spawned.is_ok() {
self.pending.extend(ids);
self.preloading = Some((self.root.clone(), rx));
}
}
/// Takes the chains `preload` has finished.
fn collect_preloaded(&mut self) {
let Some((root, rx)) = &self.preloading else {
return;
};
if *root != self.root {
self.preloading = None;
self.pending.clear();
return;
}
loop {
match rx.try_recv() {
Ok((id, loaded)) => {
self.pending.remove(&id);
let loaded = loaded.unwrap_or_else(|e| {
log::warn!("Failed to load post effect {id}: {e}");
self.failed.insert(id.clone());
None
});
self.chains.insert(id, loaded);
}
Err(mpsc::TryRecvError::Empty) => return,
Err(mpsc::TryRecvError::Disconnected) => {
self.preloading = None;
self.pending.clear();
return;
}
}
}
}
@@ -679,12 +780,12 @@ impl PostEffects {
/// `PostChain.load` for the file of `id`: `Ok(None)` when it does not exist.
fn load(
&self,
root: &Path,
device: &wgpu::Device,
queue: &wgpu::Queue,
id: &str,
) -> Result<Option<Chain>, String> {
let Ok(text) = std::fs::read_to_string(resource_path(&self.root, id)) else {
let Ok(text) = std::fs::read_to_string(resource_path(root, id)) else {
return Ok(None);
};
let config = Config::parse(&text)?;
@@ -711,17 +812,18 @@ impl PostEffects {
let mut passes = Vec::new();
for (n, p) in config.passes.iter().enumerate() {
let output = index(&p.output).ok_or("a target is missing")?;
let pass = match self.translated_pass(device, queue, n, p, output, &index, &mut images)
{
Ok(pass) => pass,
Err(e) => match self.standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
let pass =
match Self::translated_pass(root, device, queue, n, p, output, &index, &mut images)
{
Ok(pass) => pass,
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
passes.push(pass);
}
Ok(Some(Chain {
@@ -737,7 +839,7 @@ impl PostEffects {
/// A pass over the resources' own GLSL (`PostChain.createPass`).
#[allow(clippy::too_many_arguments)]
fn translated_pass(
&self,
root: &Path,
device: &wgpu::Device,
queue: &wgpu::Queue,
n: usize,
@@ -746,7 +848,7 @@ impl PostEffects {
index: &dyn Fn(&str) -> Option<usize>,
images: &mut Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
) -> Result<Pass, String> {
let program = crate::post_glsl::translate(&self.root, &p.vertex, &p.fragment)?;
let program = crate::post_glsl::translate(root, &p.vertex, &p.fragment)?;
let mut refs = Vec::new();
for input in &p.inputs {
let src = match &input.source {
@@ -761,7 +863,7 @@ impl PostEffects {
}
}
Source::Texture { location } => {
images.push(self.image(device, queue, location)?);
images.push(Self::image(root, device, queue, location)?);
Src::Image(images.len() - 1)
}
};
@@ -859,7 +961,6 @@ impl PostEffects {
/// A pass over Horizon's WGSL stand-ins for the vanilla post shaders.
fn standin_pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
n: usize,
@@ -911,14 +1012,13 @@ impl PostEffects {
/// `textures/effect/<location>.png` as a texture (`PostChain.createPass`'s `TextureInput`).
fn image(
&self,
root: &Path,
device: &wgpu::Device,
queue: &wgpu::Queue,
location: &str,
) -> Result<(wgpu::Texture, wgpu::TextureView, [u32; 2]), String> {
let (ns, path) = location.split_once(':').unwrap_or(("minecraft", location));
let file = self
.root
let file = root
.join("assets")
.join(ns)
.join("textures/effect")
@@ -1029,14 +1129,16 @@ impl PostEffects {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) -> bool {
self.use_root(req.root);
self.collect_preloaded();
let mut applied = Vec::new();
for effect in req.effects {
let id = normalise(effect);
if self.failed.contains(&id) || applied.contains(&id) {
// A chain still compiling on `preload`'s thread waits for it.
if self.failed.contains(&id) || applied.contains(&id) || self.pending.contains(&id) {
continue;
}
if !self.chains.contains_key(&id) {
let loaded = match self.load(device, queue, &id) {
let loaded = match Self::load(&self.root, device, queue, &id) {
Ok(chain) => chain,
Err(e) => {
log::warn!("Failed to load post effect {id}: {e}");
@@ -1930,6 +2032,26 @@ mod tests {
"output":"minecraft:main",
"uniforms":{"MineConfig":[{"name":"Scale","type":"float","value":1.0}]}}]}"#,
);
own_glsl(&root);
let mut effects = PostEffects::new(&device);
let [top, bottom] = run_halves(
&device,
&queue,
&mut effects,
&root,
&["minecraft:custom".to_owned()],
);
// Red (1, 0, 0) * the image (0.5, 1, 1) * 0.5 * depth 1, then again * 1: 0.125.
let near = |a: u8, b: u8| a.abs_diff(b) <= 3;
assert!(near(top[0], 32) && top[1] == 0 && top[3] == 255, "{top:?}");
// Green stays below it: 0.5 * 1 * 1.
assert!(bottom[0] == 0 && near(bottom[1], 128), "{bottom:?}");
assert!(effects.failed.is_empty());
std::fs::remove_dir_all(root).unwrap();
}
/// `core/quad.vsh`, `post/mine.fsh` and the `tint` image of `a_chain_runs_its_own_glsl`.
fn own_glsl(root: &Path) {
let shaders = root.join("assets/minecraft/shaders");
std::fs::create_dir_all(shaders.join("core")).unwrap();
std::fs::create_dir_all(shaders.join("post")).unwrap();
@@ -1960,20 +2082,60 @@ mod tests {
writer.write_image_data(&[128, 255, 255, 255]).unwrap();
}
std::fs::write(effect.join("tint.png"), png_bytes).unwrap();
}
/// A chain `preload` loaded arrives with its pipelines compiled on the loading
/// thread, and runs as one loaded in its frame does.
#[test]
fn a_preloaded_chain_arrives_compiled() {
let Some((device, queue)) = device() else {
return;
};
let root = scratch(
"preloaded",
r#"{"targets":{"half":{"width":4,"height":2}},"passes":[
{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/mine",
"inputs":[{"sampler_name":"In","target":"minecraft:main","bilinear":true},
{"sampler_name":"Depth","target":"minecraft:main","use_depth_buffer":true},
{"sampler_name":"Tint","location":"tint","width":1,"height":1}],
"output":"half",
"uniforms":{"MineConfig":[{"name":"Scale","type":"float","value":0.5}]}},
{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/mine",
"inputs":[{"sampler_name":"In","target":"half"},
{"sampler_name":"Depth","target":"half","use_depth_buffer":true},
{"sampler_name":"Tint","location":"tint","width":1,"height":1}],
"output":"minecraft:main",
"uniforms":{"MineConfig":[{"name":"Scale","type":"float","value":1.0}]}}]}"#,
);
own_glsl(&root);
let mut effects = PostEffects::new(&device);
let format = wgpu::TextureFormat::Rgba8Unorm;
effects.preload(&device, &queue, &root, &["minecraft:preloaded"], format);
assert!(effects.pending.contains("minecraft:preloaded"));
let started = std::time::Instant::now();
while !effects.pending.is_empty() {
assert!(started.elapsed().as_secs() < 60, "the chain never arrived");
std::thread::sleep(std::time::Duration::from_millis(1));
effects.collect_preloaded();
}
let chain = effects.chains["minecraft:preloaded"]
.as_ref()
.expect("loaded");
assert!(chain.passes.iter().all(|pass| {
pass.translated
.as_ref()
.is_some_and(|t| t.pipelines.contains_key(&format))
}));
let [top, bottom] = run_halves(
&device,
&queue,
&mut effects,
&root,
&["minecraft:custom".to_owned()],
&["minecraft:preloaded".to_owned()],
);
// Red (1, 0, 0) * the image (0.5, 1, 1) * 0.5 * depth 1, then again * 1: 0.125.
let near = |a: u8, b: u8| a.abs_diff(b) <= 3;
assert!(near(top[0], 32) && top[1] == 0 && top[3] == 255, "{top:?}");
// Green stays below it: 0.5 * 1 * 1.
assert!(bottom[0] == 0 && near(bottom[1], 128), "{bottom:?}");
assert!(effects.failed.is_empty());
std::fs::remove_dir_all(root).unwrap();
}
@@ -2024,20 +2186,16 @@ mod tests {
{
return;
}
let mut effects = PostEffects::new(&device);
effects.use_root(&root);
for name in ["blur", "creeper", "invert", "spider"] {
let id = format!("minecraft:{name}");
assert!(
effects.load(&device, &queue, &id).unwrap().is_some(),
PostEffects::load(&root, &device, &queue, &id)
.unwrap()
.is_some(),
"{id}"
);
}
// entity_outline needs targets and shaders the world pass has of its own.
assert!(
effects
.load(&device, &queue, "minecraft:entity_outline")
.is_err()
);
assert!(PostEffects::load(&root, &device, &queue, "minecraft:entity_outline").is_err());
}
}
+4
View File
@@ -129,6 +129,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);
+73 -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)));
}
@@ -1420,6 +1419,20 @@ pub fn mob_from_nbt(
crate::mobs::finalize::Attr::MovementSpeed,
v,
),
// A saved base other than the type's default is the
// attribute's base, which `/attribute ... base get` reads.
"minecraft:max_health"
if crate::mobs::finalize::default_value(
&m,
crate::mobs::finalize::Attr::MaxHealth,
) != Some(v) =>
{
crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MaxHealth,
v,
)
}
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
@@ -1573,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(),
@@ -3395,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()
{
+77 -15
View File
@@ -204,6 +204,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;
@@ -2080,6 +2081,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(
@@ -2279,6 +2295,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,
@@ -6442,7 +6460,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);
@@ -7329,6 +7354,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());
@@ -7343,7 +7369,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();
@@ -7439,6 +7467,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.
@@ -9022,6 +9053,10 @@ impl Game<'_> {
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
// `Gui.setScreen` clears the old screen's focus before it goes: the
// key that closed the container (Tab bound to the inventory) has
// already moved the focus, and it must not outlive the screen.
self.pad_clear_focus();
self.mod_browser_closed();
self.reset_inventory_clicks();
self.close_station();
@@ -11810,6 +11845,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()),
@@ -13411,23 +13447,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))
@@ -13835,8 +13870,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,
));
@@ -15829,6 +15864,7 @@ impl Game<'_> {
self.capture(false);
}
Screen::Creative => {
self.pad_clear_focus();
self.carried = None;
self.screen = Screen::Playing;
self.capture(true);
@@ -15941,6 +15977,7 @@ impl Game<'_> {
}
}
Screen::Creative => {
self.pad_clear_focus();
if let Some(c) = self.carried.take() {
let _ = self.give(c);
}
@@ -16274,10 +16311,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());
}
}
@@ -17369,6 +17403,25 @@ impl Game<'_> {
self.title_action(event_loop, action)?;
self.reopen_data_packs(event_loop)?;
}
// AbstractSelectionList.mouseClicked: the world list takes a
// click of any button, so the middle one selects its row
// without joining (only the left click's play icon or its
// click on the selected row does that).
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Middle,
..
} if self.title.is_some() && self.screen != Screen::Options => {
let size = self
.renderer
.as_ref()
.map(|r| [r.config.width, r.config.height])
.unwrap_or([1, 1]);
let b = HudBatch::new(size);
if let Some(title) = self.title.as_mut() {
title.select_world_at(self.cursor.0, self.cursor.1, (b.width, b.height));
}
}
WindowEvent::MouseInput { .. } if self.title.is_some() => {}
WindowEvent::MouseInput { state, button, .. } if self.book.is_some() => {
if state == ElementState::Pressed && button == MouseButton::Left {
@@ -18250,6 +18303,7 @@ impl<'a> Game<'a> {
);
Ok(Game {
world_biomes: None,
world_biomes_base: None,
res: res.clone(),
rest_loader: None,
pending_entry: None,
@@ -19055,6 +19109,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
+50 -1
View File
@@ -287,7 +287,8 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
// `/loot ... loot <id>` must find a content mod's tables too.
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
templates: templates_under(&root.join("worldgen/structure")),
entity_types,
enchantments: json_ids_in(&data.join("enchantment")),
@@ -364,6 +365,33 @@ fn ids_under(root: &Path, extension: &str) -> Vec<String> {
ids_in(root, extension, "minecraft")
}
/// The ids of the `.json` files in `data/<namespace>/<kind>` of every
/// namespace of `root` (vanilla's and the content mods'), sorted.
fn json_ids_of_every_namespace(root: &Path, kind: &str) -> Vec<String> {
let data = root.join("data");
let mut namespaces: std::collections::BTreeSet<String> = std::fs::read_dir(&data)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
// A prepared root's namespaces live in its data file, not on the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&data) {
namespaces.extend(
files
.iter()
.filter_map(|f| Some(f.split_once('/')?.0.to_owned())),
);
}
let mut out: Vec<String> = namespaces
.iter()
.flat_map(|ns| ids_in(&data.join(ns).join(kind), "json", ns))
.collect();
out.sort();
out
}
/// The templates of every namespace folder under `root` (vanilla's and the
/// content mods'), as `namespace:path`, sorted.
fn templates_under(root: &Path) -> Vec<String> {
@@ -1579,6 +1607,27 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// A content mod's loot tables are listed with vanilla's, so `/loot`
/// finds them.
#[test]
fn loot_tables_of_every_namespace_are_listed() {
let root = std::env::temp_dir().join(format!("horizon-loot-ids-{}", std::process::id()));
for dir in [
"data/minecraft/loot_table/chests",
"data/mymod/loot_table/rooms",
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("data/minecraft/loot_table/chests/a.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/rooms/b.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/notes.txt"), "").unwrap();
assert_eq!(
resource_ids(&root).loot_tables,
["minecraft:chests/a", "mymod:rooms/b"]
);
let _ = std::fs::remove_dir_all(&root);
}
/// Only the overworld clock's timelines count; a marker is a bare tick
/// count (hidden from commands) or an object that may show itself.
#[test]
+36 -4
View File
@@ -2342,6 +2342,37 @@ impl Game<'_> {
state,
)
}
/// `BlockInput.overwriteWithDefinedProperties`: restore explicit command
/// properties after normalizing the state against neighbouring blocks.
fn block_input_state(
&mut self,
pos: [i32; 3],
input: &BlockInput,
state: BlockStateId,
strict: bool,
) -> R<BlockStateId> {
if strict {
return Ok(state);
}
let updated = self.with_level(|b, level, ticks| {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
let updated = b.update_from_neighbour_shapes(level, ticks, at, state);
if b.is_air(updated) { state } else { updated }
});
let registry = self.pack.assets.registry;
let mut descriptor = registry
.state(updated)
.expect("registered block state")
.clone();
for (key, value) in &input.properties {
if descriptor.properties.contains_key(key) {
descriptor.properties.insert(key.clone(), value.clone());
}
}
registry
.resolve(&descriptor)
.map_err(|_| err("argument.block.id.invalid", &[&input.id]))
}
/// Place a state like BlockInput.place: false when nothing changed.
pub(super) fn place_state(
&mut self,
@@ -2410,6 +2441,7 @@ impl Game<'_> {
if ctx.has("destroy") && current != self.world.air {
self.destroy_block_dropping(pos)?;
}
let state = self.block_input_state(pos, &b, state, ctx.has("strict"))?;
let placed = self.place_state(pos, state, ctx.has("strict"))?;
let loaded = match &b.nbt {
Some(nbt) => self.load_block_entity_nbt(pos, nbt),
@@ -2957,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);
}
@@ -7,7 +7,7 @@ use horizon_client::{
text::Text,
worldborder::MAX_SIZE,
};
use horizon_core::random::RandomSource;
use horizon_core::{position::ChunkPos, random::RandomSource};
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
@@ -41,7 +41,8 @@ impl Game<'_> {
match id {
"forceload.query" if ctx.get("pos").is_none() => {
let mut forced: Vec<(i32, i32)> = self.streaming.forced.iter().copied().collect();
forced.sort_unstable();
// ForceLoadCommand sorts its LongStream before formatting chunks.
forced.sort_unstable_by_key(|&(x, z)| ChunkPos::new(x, z).pack());
if forced.is_empty() {
self.command_failure(Text::translate("commands.forceload.added.none", &[dim]));
return Ok(0);
@@ -574,10 +575,11 @@ impl Game<'_> {
let p = self.block_pos_arg(ctx, "pos")?;
let state = self.world.block(p[0], p[1], p[2]);
let name = self.data_block_name(p);
let table = format!("minecraft:blocks/{}", name.trim_start_matches("minecraft:"));
if !world_items!(self).has_loot_table(&table) {
let Some(table) =
world_items!(self).block_loot_table(self.pack.assets.registry, state)
else {
return Err(err("commands.drop.no_loot_table.block", &[&name]));
}
};
let tool = self.tool_stack(ctx)?;
let drops = match world_items!(self).loot_sequence(&table) {
Some(seq) => {
@@ -2,7 +2,10 @@
//! `/particle`, `/attribute`, `/ride` and `/spreadplayers`.
use super::{Arg, Context, Game, R, Target, err, wrap_degrees};
use horizon_client::{
mobs::{Damage, DamageKind, Who},
mobs::{
Damage, DamageKind, Who,
finalize::{self, Attr},
},
scoreboard_cmd::{Holder, Tracker},
settings::SOUND_SOURCES,
text::Text,
@@ -918,9 +921,13 @@ impl Game<'_> {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) else {
return Ok(0);
};
// Mobs keep three attributes as plain values.
// Core mob attributes also have cached gameplay fields.
let slot: Option<f64> = match short.as_str() {
"max_health" => Some(f64::from(m.attrs.max_health)),
"max_health" => Some(if id == "attribute.base.get" {
finalize::base_value(m, Attr::MaxHealth)
} else {
finalize::value(m, Attr::MaxHealth)
}),
"movement_speed" => Some(m.attrs.speed),
"attack_damage" => Some(f64::from(m.attrs.attack_damage)),
_ => None,
@@ -944,8 +951,9 @@ impl Game<'_> {
"attribute.base.set" => {
match short.as_str() {
"max_health" => {
m.attrs.max_health = value_arg as f32;
m.health = m.health.min(m.attrs.max_health);
// AttributeInstance keeps the raw base, clamps only
// the effective value, and does not change Health.
finalize::set_base(m, Attr::MaxHealth, value_arg);
}
"movement_speed" => m.attrs.speed = value_arg,
_ => m.attrs.attack_damage = value_arg as f32,
@@ -958,12 +966,15 @@ impl Game<'_> {
}
"attribute.base.reset" => {
match short.as_str() {
"max_health" => m.attrs.max_health = m.kind.max_health,
"max_health" => {
let default = f64::from(m.kind.max_health);
finalize::set_base(m, Attr::MaxHealth, default);
}
"movement_speed" => m.attrs.speed = m.kind.speed,
_ => m.attrs.attack_damage = m.kind.attack_damage,
}
let v = match short.as_str() {
"max_health" => f64::from(m.attrs.max_health),
"max_health" => finalize::base_value(m, Attr::MaxHealth),
"movement_speed" => m.attrs.speed,
_ => f64::from(m.attrs.attack_damage),
};
+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))
@@ -1736,6 +1736,14 @@ impl Game<'_> {
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
// ServerPlayer.onEffectAdded and onEffectUpdated send an effect
// the command gave as it was given: vanilla's console runs after
// the players' tick (`DedicatedServer.tickConnection`), so the
// next tick is the first to count it down. A player's own
// commands sync theirs the same way (`run_guest_command`).
if let Some(h) = self.lan.as_mut() {
h.hub.sync_all_effects();
}
}
}
}
+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);
}
+6 -1
View File
@@ -155,7 +155,12 @@ impl Game<'_> {
if let Some(id) = self.spawn_mob("minecraft:happy_ghast", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
// DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity, so ageBoundaryReached runs the ghastling setup
// (`babyGhastSetup`) while the mob is still being hatched: its
// goals, navigation and brain are a ghastling's before its first
// tick, not an adult's until the next aiStep.
horizon_client::mobs::finalize::set_age(m, -24000);
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
@@ -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
@@ -2500,6 +2500,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.
@@ -5096,6 +5115,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) {
@@ -6073,7 +6144,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,
@@ -6180,6 +6251,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
@@ -8468,7 +8564,7 @@ impl Game<'_> {
};
let items = self.items();
let known: Vec<String> = Ingredient::parse(&json, items)?
.1
.order
.into_iter()
.filter(|item| items.get(item).is_some())
.collect();
@@ -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);
@@ -55,7 +55,8 @@ impl Index {
self.stack(&s.item, s.count, components(s))
}
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
i.1.iter()
i.order
.iter()
.filter_map(|id| self.stack(id, 1, String::new()))
.collect()
}
+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
@@ -112,6 +112,17 @@ pub(super) struct MenuCursor {
key_restart: Option<u64>,
}
impl MenuCursor {
/// `Screen.clearFocus`: nothing is focused any more, and no click focus
/// is kept for the next Tab (`ComponentPath.applyFocus(false)`).
fn clear_focus(&mut self) {
self.focus = None;
self.origin = None;
self.slider_edit = false;
self.pending = None;
}
}
impl Game<'_> {
/// A menu, a screen, the title, a sign or a book is open: the pad drives
/// it instead of the player.
@@ -1041,6 +1052,15 @@ impl Game<'_> {
|| self.ui.options.selected_pad_action.is_some()))
}
/// `Screen.clearFocus`, which `Gui.setScreen` runs on the screen it takes
/// down: the keyboard's focus belongs to that screen. A key bound to the
/// container's own close key can move the focus and close the screen in
/// one press (Tab bound to the inventory), and vanilla clears the focus
/// before the next frame, so nothing stays highlighted.
pub(super) fn pad_clear_focus(&mut self) {
self.pad.menu.clear_focus();
}
/// A key pressed in a menu, before the screen takes it
/// (`Screen.keyPressed`): Tab, Shift+Tab and the arrows move the focus,
/// Enter, Space and keypad Enter press the focused widget; the focused
@@ -1543,6 +1563,23 @@ mod tests {
assert!(!osk_auto_opens(false, false, true, 5, false));
}
#[test]
fn clear_focus_leaves_nothing_focused() {
let mut menu = MenuCursor {
focus: Some(Target {
rect: [229.0, 98.0, 20.0, 18.0],
kind: Kind::Button,
}),
origin: Some([229.0, 98.0, 20.0, 18.0]),
slider_edit: true,
..MenuCursor::default()
};
menu.clear_focus();
assert!(menu.focus.is_none());
assert!(menu.origin.is_none());
assert!(!menu.slider_edit);
}
#[test]
fn field_hit_test() {
let t = |rect, kind| Target { rect, kind };
+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
+40 -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" => {
@@ -9096,6 +9123,10 @@ impl Game<'_> {
self.remote_close_local();
}
fn remote_close_local(&mut self) {
// `Gui.setScreen` clears the focus of the screen it takes down: the
// container's keyboard focus must not outlive it (a Tab bound to the
// inventory moves the focus and closes the container in one press).
self.pad_clear_focus();
self.recipe_ui.reset_screen();
if let Some(r) = self.remote.as_mut() {
r.menu = None;
@@ -1056,6 +1056,22 @@ impl Loading {
}
}
}
// AbstractSelectionList.mouseClicked: a world row takes a click
// of any button, so the middle one selects it without joining.
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Middle,
..
} => {
let batch = HudBatch::new(self.menu_size());
if let Some(title) = &mut self.menu.title {
title.select_world_at(
self.menu.cursor.0,
self.menu.cursor.1,
(batch.width, batch.height),
);
}
}
WindowEvent::MouseWheel { delta, .. } => {
let raw = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
@@ -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();
+162 -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();
@@ -4172,6 +4212,113 @@ mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
/// A hub with one placed guest, the speed effect registered, and what the
/// guest is sent.
fn hub_with_a_guest() -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
let config = crate::lan::NetConfig {
announce: false,
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
};
let mut sync = SyncData::empty();
sync.builtin.registries.insert(
"minecraft:mob_effect".to_string(),
horizon_protocol::registry::Ids::from_names(vec!["minecraft:speed".to_string()]),
);
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
[] as [(std::sync::Arc<str>, bool); 0],
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
let hub_config = HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 10,
simulation_distance: 10,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: false,
levels: Default::default(),
};
let mut hub = Hub::new(lan, sync, encoder, hub_config);
let (tx, rx) = std::sync::mpsc::channel();
let mut bob = Guest::new(
crate::lan::Joined {
conn: 1,
uuid: 1,
name: "Bob".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: crate::lan::PlayerSender::new(tx, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
1,
[0.5, 64.0, 0.5],
8,
);
bob.placed = true;
bob.dimension = "minecraft:overworld".into();
hub.guests.push(bob);
(hub, rx)
}
/// The durations of the effect packets the guest was sent.
fn sent_durations(rx: &std::sync::mpsc::Receiver<crate::lan::Out>) -> Vec<i32> {
let mut durations = Vec::new();
while let Ok(out) = rx.try_recv() {
let crate::lan::Out::Packet(body) = out else {
continue;
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
if let Ok(C::UpdateMobEffect(p)) = C::decode(id, r.rest()) {
durations.push(p.duration);
}
}
durations
}
/// `/effect give Bob minecraft:speed 30 1` from a dedicated server's
/// console (#7115). Vanilla runs the console after the players' tick
/// (`DedicatedServer.tickConnection`) and `ServerPlayer.onEffectAdded`
/// sends the effect at once: 30 s is 600 ticks, and the tick after, which
/// counts it down to 599, sends nothing (`LivingEntity.tickEffects` only
/// refreshes it at a multiple of 600 left). The console syncs right after
/// its command (`console_command`) for the same packet.
#[test]
fn an_effect_synced_after_its_command_is_sent_once_at_its_full_duration() {
let (mut hub, rx) = hub_with_a_guest();
hub.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
// The console's command, then its sync.
hub.sync_all_effects();
// The next tick: the guests' effects count down (`tick_guests`), then
// the hub syncs them (`tick_level`).
hub.guests[0].effects.tick();
hub.sync_all_effects();
hub.guests[0].effects.tick();
hub.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![600]);
// Without the sync after the command, the first sync comes after the
// tick has counted it down: what #7115's transcript shows.
let (mut late, rx) = hub_with_a_guest();
late.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
late.guests[0].effects.tick();
late.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![599]);
}
/// The owner's own shoulder parrots are sent when a slot changes.
#[test]
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
@@ -4200,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,
@@ -863,6 +863,11 @@ fn age_boundary_reached(m: &mut Mob) {
Species::Goat => set_base(m, Attr::AttackDamage, if baby { 1.0 } else { 2.0 }),
Species::Hoglin => set_base(m, Attr::AttackDamage, if baby { 0.5 } else { 6.0 }),
Species::SulfurCube if !baby => sulfur_size(m, 2),
// HappyGhast.ageBoundaryReached: adultGhastSetup or babyGhastSetup,
// then Animal's no-op. A hatched ghastling needs it before it is in
// the level (DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity), or its first tick flies the adult goals.
Species::HappyGhast => super::happy_ghast::ensure_setup(m),
_ => {}
}
}
+4 -2
View File
@@ -2155,9 +2155,11 @@ impl Goal for Breed {
return false;
}
let species = m.species();
// Wolf.canMate: both tame and standing.
// Wolf.canMate and Cat.canMate: both tame. The sitting pose sits on the
// partner side only, and only for a wolf (`wolf.isInSittingPose()`), so
// a tamed mob ordered to sit still walks off to breed.
let tamable = matches!(species, Species::Wolf | Species::Cat);
if tamable && (!m.extra.tame || m.extra.sitting) {
if tamable && !m.extra.tame {
return false;
}
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
@@ -667,4 +667,40 @@ mod tests {
fn travel_speed_is_five_thirds_of_the_flying_speed() {
assert!((f64::from(AIR_SPEED) - FLYING_SPEED * 5.0 / 3.0).abs() < 1e-7);
}
/// `HappyGhast.ageBoundaryReached` runs the setup at the age crossing, not
/// at the next tick: `DriedGhastBlock.spawnGhastling` makes its ghastling a
/// baby before `addFreshEntity`, and `AgeableMob.setAge` calls
/// `ageBoundaryReached` there. Without it the hatched ghastling joins the
/// level with the adult's three goals, its ground navigation and its brain
/// stopped, and only its first `aiStep` (`ensure_setup`) fixes that.
#[test]
fn a_hatched_ghastling_is_set_up_as_one_at_the_age_crossing() {
use crate::mobs::Species;
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
t.species = Species::HappyGhast;
let mut m = Mob::new(
1,
std::sync::Arc::new(t),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
// `Mob::new` registers the adult's goals (HappyGhastFloat, its tempt
// goal and FloatAround) for the age-zero ghast it makes.
assert_eq!(m.extra.scratch[0], 2, "an adult's setup at construction");
assert!(m.ai.goals.has("HappyGhastFloatGoal"));
crate::mobs::finalize::set_age(&mut m, -24000);
assert!(m.is_baby());
assert_eq!(m.extra.scratch[0], 1, "babyGhastSetup ran");
assert!(
!m.ai.goals.has("HappyGhastFloatGoal")
&& !m.ai.goals.has("RandomFloatAroundGoal")
&& !m.ai.goals.has("ForNonPathfinders"),
"a ghastling has no goals: its brain drives it"
);
assert_eq!(m.ai.nav.kind, crate::mobs::nav::NavKind::Flying);
assert!(m.ai.nav.can_float);
}
}
+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),
+45
View File
@@ -1874,6 +1874,51 @@ mod tests {
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 1.0])));
assert!(m.extra.sitting);
}
/// `Cat.canMate` and `Wolf.canMate`: the seeker only has to be tamed, so a
/// mob ordered to sit still goes looking for a mate. Only a wolf's
/// *partner* is refused while it sits (`wolf.isInSittingPose()`), which is
/// the one sitting check vanilla makes.
#[test]
fn a_sitting_tame_mob_still_looks_for_a_mate() {
use crate::mobs::goals::{Breed, Cx, Goal};
use crate::mobs::world::with_test_world;
let named = |species, name: &str, id: u32, sat: bool| {
let mut m = sitting(species, name);
m.id = id;
m.extra.in_love = 600;
m.extra.sitting = sat;
m
};
for (species, name, seeker_sat, partner_sat, breeds) in [
// A tamed cat pairs with a tamed cat, sitting or not.
(Species::Cat, "minecraft:cat", true, true, true),
(Species::Cat, "minecraft:cat", true, false, true),
// A wolf pairs unless the wolf it would take is sitting.
(Species::Wolf, "minecraft:wolf", true, false, true),
(Species::Wolf, "minecraft:wolf", true, true, false),
(Species::Wolf, "minecraft:wolf", false, true, false),
] {
let mut seeker = named(species, name, 1, seeker_sat);
let partner = named(species, name, 2, partner_sat);
let seen = [partner.seen(None)];
with_test_world(&seen, |w, _| {
let mut events = Vec::new();
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goal = Breed::new(1.0);
assert_eq!(
goal.can_use(&mut seeker, &mut cx),
breeds,
"{name}: seeker sits {seeker_sat}, partner sits {partner_sat}"
);
});
}
}
}
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
+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
@@ -345,7 +345,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 {
@@ -360,6 +360,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;
@@ -4566,6 +4611,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;
@@ -13874,6 +13924,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> {
@@ -13898,6 +13984,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> {
@@ -13920,6 +14061,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 {
@@ -14055,6 +14228,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.
@@ -1260,3 +1311,35 @@ pub trait Host {
unimplemented!("Host::recipe_book_category")
}
}
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
@@ -475,7 +475,7 @@ impl Book {
/// `ingredient`'s items as its display resolves them (the cycling order
/// of ghost slots and the overlay).
pub fn items_of(&self, ingredient: &Ingredient) -> Vec<String> {
ingredient.1.clone()
ingredient.order.clone()
}
/// `RecipeBookComponent.fillGhostRecipe` of a book type: the result and the
/// ingredients faded into the menu's slots, in the order the component
+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"]
);
}
+24
View File
@@ -3275,6 +3275,30 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
let next = create_world::next_tab_index(options.tab, key, shift)?;
Some(self.select_create_tab(next.min(create_world::tab_count() - 1)))
}
/// `AbstractSelectionList.mouseClicked`: the row under the pointer takes
/// a click of any button (`ObjectSelectionList.Entry.mouseClicked` accepts
/// them all), so a middle click selects a world and its Play, Edit and
/// Delete buttons hear of it (`WorldSelectionList.setSelected`), without
/// opening it. Only the left button joins, through [`Self::click`].
pub fn select_world_at(&mut self, x: f32, y: f32, size: (f32, f32)) -> bool {
let index = {
let Mode::Select { worlds, .. } = &self.mode else {
return false;
};
let Some((row, _)) = self.world_row_at(worlds, (x, y), size) else {
return false;
};
let Some((index, _)) = self.visible_worlds(worlds).nth(row) else {
return false;
};
index
};
if let Mode::Select { selected, .. } = &mut self.mode {
*selected = Some(index);
}
true
}
/// The action and whether to play `AbstractWidget.playButtonClickSound`.
/// Navigation buttons can return `Action::None` while still sounding.
pub fn click(&mut self, x: f32, y: f32, size: (f32, f32)) -> (Action, bool) {
+34
View File
@@ -719,6 +719,40 @@ fn classic_world_selection_is_silent_until_the_entry_launches() {
);
}
#[test]
fn middle_clicking_a_world_selects_it_without_joining() {
let mut f = Fixture::new();
f.world("World", "World");
f.title.enter_singleplayer();
let size = (480.0, 262.0);
let enabled = |f: &Fixture, label: &str| {
f.title
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.label == label)
.unwrap()
.enabled
};
assert!(!enabled(&f, "Edit"), "nothing is selected yet");
assert!(f.title.select_world_at(240.0, 60.0, size));
assert!(matches!(
f.title.mode,
Mode::Select {
selected: Some(0),
..
}
));
assert!(
enabled(&f, "Edit"),
"the selection callback reaches the footer"
);
// Again on the selected row: still only a selection, not a join.
assert!(f.title.select_world_at(240.0, 60.0, size));
assert!(matches!(f.title.mode, Mode::Select { .. }));
// Outside the rows (the footer) nothing is selected.
assert!(!f.title.select_world_at(240.0, 250.0, size));
}
#[test]
fn a_world_open_elsewhere_stays_closed_behind_a_toast() {
let mut f = Fixture::new();
@@ -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);
}
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more