Merge main into #7557

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 23:24:46 +01:00
commit 3abeb4f845
287 files changed
+20069 -2912

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",
+21 -12
View File
@@ -36,6 +36,17 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## Hugeicons
`crates/horizon-theme/icons/glass/*.svg` are icons from the free set of
[Hugeicons](https://github.com/hugeicons/hugeicons) (`@hugeicons/core-free-icons` 4.4.0), under
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
stroke in `currentColor`.
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
trademark, used only on the button that opens Horizon's Discord server.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
@@ -50,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());
}
+203 -2
View File
@@ -182,13 +182,38 @@ impl LimiterClock for SystemClock {
i64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(i64::MAX)
}
fn park(&mut self, nanos: i64) {
std::thread::sleep(std::time::Duration::from_nanos(nanos.max(0) as u64));
let end = self.nanos().saturating_add(nanos);
park_in_steps(
end,
|| self.nanos(),
|step| {
std::thread::sleep(std::time::Duration::from_nanos(step as u64));
},
);
}
fn spin(&mut self) {
std::hint::spin_loop();
}
}
/// The longest single sleep of [`SystemClock::park`].
const PARK_STEP_NS: i64 = 1_000_000;
/// Sleeps until `now()` reaches `end`, at most [`PARK_STEP_NS`] at a time. macOS lets
/// a sleep run late by a share of its length (timer coalescing: 1.8-3.5 ms on a 14 ms
/// sleep, 0.25 ms on 1 ms), more than the limiter's overshoot margin can learn (capped
/// at 2 ms): a 60 FPS cap gave frames of 17-20 ms. Short steps keep the lateness to
/// the last step's.
fn park_in_steps(end: i64, mut now: impl FnMut() -> i64, mut sleep: impl FnMut(i64)) {
loop {
let left = end - now();
if left <= 0 {
return;
}
sleep(left.min(PARK_STEP_NS));
}
}
/// The real clock without busy waiting: where vanilla spins on
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
/// steps, so a capped frame rate leaves the core idle.
@@ -218,6 +243,18 @@ pub fn end_frame(
}
}
/// [`end_frame`] on a fixed grid of frame slots ([`FramerateLimiter::limit_on_grid`]):
/// Horizon's Efficient pacing.
pub fn end_frame_on_grid(
limiter: &mut FramerateLimiter,
framerate_limit: i32,
clock: &mut impl LimiterClock,
) {
if framerate_limit < 260 {
limiter.limit_on_grid(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
@@ -248,6 +285,37 @@ impl FramerateLimiter {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
self.wait_until(target_time_ns, clock);
self.last_frame_time = clock.nanos();
}
/// Horizon's Efficient pacing: [`Self::limit_display_fps`]'s wait, but each frame's
/// slot starts where the last one's ended (its deadline), not when the thread woke
/// up. Vanilla's way adds every late wake-up to the frame (on the Mac, 50 us sleeps
/// running 0.2 ms late made a 144 FPS cap 139); on a fixed grid the cap holds on
/// average. A frame that ends past its slot, or wakes up more than half a frame
/// late, starts the grid again from now: no frame is rushed out to catch up.
pub fn limit_on_grid(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let period = 1_000_000_000 / i64::from(framerate_limit);
if framerate_limit != self.last_framerate_limit {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
let deadline = self.last_frame_time + period;
if clock.nanos() >= deadline {
self.last_frame_time = clock.nanos();
return;
}
self.wait_until(deadline, clock);
let woke = clock.nanos();
self.last_frame_time = if woke - deadline > period / 2 {
woke
} else {
deadline
};
}
/// `limitDisplayFPS`'s wait: sleep to within the learned overshoot and half a
/// millisecond of `target_time_ns`, then spin.
fn wait_until(&mut self, target_time_ns: i64, clock: &mut impl LimiterClock) {
loop {
let remaining = target_time_ns - clock.nanos();
if remaining <= 0 {
@@ -271,7 +339,6 @@ impl FramerateLimiter {
clock.spin();
}
}
self.last_frame_time = clock.nanos();
}
pub fn last_frame_time(&self) -> i64 {
self.last_frame_time
@@ -678,6 +745,140 @@ mod tests {
assert_eq!(calculate_scale([1920, 1080], 3, true), 4);
assert_eq!(gui_scaled_size([1921, 1081], 4), [481, 271]);
}
/// A clock whose sleeps run late by a quarter of their length, as macOS's do,
/// sleeping as [`SystemClock::park`] does.
struct Coalescing {
now: std::cell::Cell<i64>,
}
impl LimiterClock for Coalescing {
fn nanos(&mut self) -> i64 {
self.now.get()
}
fn park(&mut self, nanos: i64) {
let now = &self.now;
park_in_steps(
now.get() + nanos,
|| now.get(),
|step| {
now.set(now.get() + step + step / 4);
},
);
}
fn spin(&mut self) {
self.now.set(self.now.get() + 1000);
}
}
#[test]
fn a_frame_cap_holds_when_sleeps_run_late() {
let mut clock = Coalescing {
now: std::cell::Cell::new(0),
};
let mut limiter = FramerateLimiter::new(0);
let mut last = 0;
for frame in 0..120 {
// The frame's own work.
clock.now.set(clock.now.get() + 2_000_000);
limiter.limit_display_fps(60, &mut clock);
let period = clock.now.get() - last;
last = clock.now.get();
assert!(
(16_666_666..16_666_666 + 100_000).contains(&period),
"frame {frame}: {period} ns"
);
}
}
/// A clock whose sleeps and spins each run up to `late` ns late (a different
/// share each time), as a loaded machine's do.
struct Late {
now: i64,
late: i64,
seed: u64,
}
impl Late {
fn new(late: i64) -> Self {
Self {
now: 0,
late,
seed: 0x9e37_79b9_7f4a_7c15,
}
}
fn lateness(&mut self) -> i64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 1000) as i64 * self.late / 1000
}
}
impl LimiterClock for Late {
fn nanos(&mut self) -> i64 {
self.now
}
fn park(&mut self, nanos: i64) {
self.now += nanos + self.lateness();
}
fn spin(&mut self) {
self.now += 50_000 + self.lateness();
}
}
/// Frame periods of 144 FPS frames whose work takes `work(frame)` ns.
fn grid_periods(late: i64, work: impl Fn(usize) -> i64) -> Vec<i64> {
let mut clock = Late::new(late);
let mut limiter = FramerateLimiter::new(0);
let mut ends = Vec::new();
for frame in 0..300 {
clock.now += work(frame);
limiter.limit_on_grid(144, &mut clock);
ends.push(clock.now);
}
ends.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn the_grid_holds_the_cap_when_wake_ups_run_late() {
let period = 1_000_000_000 / 144;
for late in [0, 50_000, 200_000, 1_000_000] {
let periods = grid_periods(late, |_| 2_000_000);
let mean = periods[10..].iter().sum::<i64>() / (periods.len() - 10) as i64;
assert!(
(mean - period).abs() < 10_000,
"{late} ns late: mean period {mean} ns"
);
// Each slot waits at least half a frame: the grid never rushes one out.
assert!(
periods.iter().all(|&p| p > period / 2),
"{late} ns late: {periods:?}"
);
}
// Vanilla's way adds the lateness to the frames: the cap isn't held.
let mut clock = Late::new(400_000);
let mut limiter = FramerateLimiter::new(0);
for _ in 0..300 {
clock.now += 2_000_000;
limiter.limit_display_fps(144, &mut clock);
}
assert!(
clock.now / 300 > period + 50_000,
"vanilla: {} ns",
clock.now / 300
);
}
#[test]
fn a_long_frame_starts_the_grid_again_without_a_burst() {
let period = 1_000_000_000 / 144;
// Frame 100 works 30 ms: four slots go by.
let periods = grid_periods(
50_000,
|frame| if frame == 100 { 30_000_000 } else { 2_000_000 },
);
assert!(periods[99] >= 30_000_000);
for (i, &p) in periods.iter().enumerate().skip(100) {
// On the grid a slot is a late wake-up (50 us at most) short or long.
assert!(
(period - 100_000..period + 100_000).contains(&p),
"frame {}: {p} ns",
i + 1
);
}
}
#[test]
fn limiter_paces_frames_at_the_target() {
let mut limiter = FramerateLimiter::new(0);
+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,
+113 -19
View File
@@ -828,6 +828,12 @@ pub struct Renderer {
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
sky: wgpu::RenderPipeline,
/// The main sky without the sunrise fan, which `sunrise_pipeline` draws as
/// geometry over it. The mirror keeps `sky`: its per-pixel fan is cheap at
/// the reflection's size and needs no second projection.
sky_base: wgpu::RenderPipeline,
sunrise_pipeline: wgpu::RenderPipeline,
sunrise_buffer: wgpu::Buffer,
dark_disc: wgpu::RenderPipeline,
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
@@ -2373,6 +2379,47 @@ impl Renderer {
false,
None,
);
let sky_base = pipeline(
"Sky base",
"vs_sky",
"fs_sky_base",
&[],
tri,
None,
false,
false,
None,
);
let sunrise_attributes = wgpu::vertex_attr_array![0=>Float32x4, 1=>Float32];
let sunrise_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &sunrise_attributes,
})];
let sunrise_blend = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
// The sky's HDR alpha is the destination's; the fan only tints it.
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
};
let sunrise_pipeline = pipeline(
"Sunrise fan",
"vs_sunrise",
"fs_sunrise",
&sunrise_buffers,
tri,
None,
false,
false,
Some(sunrise_blend),
);
let dark_disc = pipeline(
"Dark disc",
"vs_sky",
@@ -3704,6 +3751,12 @@ impl Renderer {
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sunrise_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Sunrise fan vertices"),
size: (sunrise_fan::VERTICES * 5 * std::mem::size_of::<f32>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
@@ -3884,6 +3937,9 @@ impl Renderer {
cull,
flat,
sky,
sky_base,
sunrise_pipeline,
sunrise_buffer,
dark_disc,
depth_reset,
lines,
@@ -5091,8 +5147,8 @@ impl Renderer {
if self.detail_columns.remove(&(x, z)) == Some(complete) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
}
self.occlusion.remove_columns(&gone);
let kept = |key: &SectionKey| !gone.contains(&(key.0, key.2));
let before = self.block_entities.len();
self.block_entities.retain(|key, _| kept(key));
@@ -5334,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
}
@@ -6225,6 +6282,27 @@ impl Renderer {
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
// The sunrise fan as sixteen triangles. Its clip positions use the sky's
// own view (no TAA jitter, bob or spin: `fs_sky`'s `view_ray` has none),
// and its apex weight interpolates the barycentric the shader used to
// compute for every sky pixel.
if frame.sky.sunrise_color[3] > 0.001 && !frame.title && frame.skybox {
let sky_view_proj = views.primary().view_proj(Mat4::IDENTITY);
let mut mesh = [[0.0f32; 5]; sunrise_fan::VERTICES];
for (to, from) in mesh
.iter_mut()
.zip(sunrise_fan::mesh(frame.sky.sunrise_color[3], sun))
{
let clip = sky_view_proj * glam::Vec4::new(from[0], from[1], from[2], 1.0);
*to = [clip.x, clip.y, clip.z, clip.w, from[3]];
}
self.uploads.buffer(
&mut encoder,
&self.sunrise_buffer,
0,
bytemuck::cast_slice(&mesh),
);
}
laps.lap("r_targets");
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
// bob in its pose rather than the projection (same product).
@@ -7851,8 +7929,13 @@ impl Renderer {
{
Some((sky, native)) => sky.draw(&mut pass, native),
None => {
pass.set_pipeline(&self.sky);
pass.set_pipeline(&self.sky_base);
pass.draw(0..3, 0..1);
if frame.sky.sunrise_color[3] > 0.001 {
pass.set_pipeline(&self.sunrise_pipeline);
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
pass.draw(0..sunrise_fan::VERTICES as u32, 0..1);
}
}
}
}
@@ -8791,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,
});
@@ -9132,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.
@@ -13,6 +13,7 @@
//! frame has (vanilla's partial update), so what it opens shows at once; what
//! it closes goes at the next full search.
use super::SectionKey;
use crate::fast_hash::FxHashSet;
use crate::mesher::ALL_VISIBLE;
use glam::DVec3;
use std::collections::{HashMap, VecDeque};
@@ -589,6 +590,19 @@ impl Occlusion {
}
}
}
/// `remove_column` for each of `gone`. When they are every column it holds (a
/// teleport drops the whole view), the old graph says nothing about what arrives
/// next: it starts over as `clear` does instead of seeing through each removed
/// section one edit at a time (some 90 000 at render distance 32).
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) {
if self.columns.map.keys().all(|column| gone.contains(column)) {
self.clear();
return;
}
for &(x, z) in gone {
self.remove_column(x, z);
}
}
pub fn clear(&mut self) {
self.columns.map.clear();
self.edits.clear();
@@ -1313,6 +1327,46 @@ mod tests {
println!("search_cost: best {best:.2} ms over 12, {reached} reached");
}
/// Removed columns see through every face: the graph is the one of a world that
/// never had them, whether some columns go or all of them (a teleport).
#[test]
fn removed_columns_leave_the_graph_of_a_world_without_them() {
fn without(o: &Occlusion, gone: &FxHashSet<(i32, i32)>) -> Occlusion {
let mut fresh = Occlusion::new(0, 6);
for (&(x, z), column) in &o.columns.map {
if !gone.contains(&(x, z)) {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
}
fresh
}
let camera = at((0, 2, 0));
let mut o = Occlusion::new(0, 6);
random_world(&mut o, 0x51ed_2701, 900, 6);
o.settle(camera, 6);
let some: FxHashSet<(i32, i32)> = (-6..=6)
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
.collect();
let mut expected = without(&o, &some);
o.remove_columns(&some);
o.settle(camera, 6);
expected.settle(camera, 6);
let graph = |o: &Occlusion| o.graph.as_ref().expect("searched").directions.clone();
assert_eq!(graph(&o), graph(&expected));
let all: FxHashSet<(i32, i32)> = o.columns.map.keys().copied().collect();
o.remove_columns(&all);
assert!(o.contains((40, 3, 40)), "no graph from the old view culls");
random_world(&mut o, 0x2545_f491, 900, 6);
let mut arrived = Occlusion::new(0, 6);
random_world(&mut arrived, 0x2545_f491, 900, 6);
o.settle(camera, 6);
arrived.settle(camera, 6);
assert_eq!(graph(&o), graph(&arrived));
}
/// The worker's graph is the one the render thread would compute.
#[test]
fn the_worker_matches_a_search_on_the_render_thread() {
@@ -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());
@@ -15,6 +15,11 @@ use glam::Vec3;
pub const TRIANGLES: usize = 16;
/// One vertex per triangle corner, each as `(position, weight)`: the apex
/// weighs 1, the rim 0, so the rasterizer interpolates the barycentric apex
/// weight the shader used to compute per pixel.
pub const VERTICES: usize = TRIANGLES * 3;
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
/// Then per rim vertex: xyz `r × A`.
@@ -29,6 +34,19 @@ pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
out
}
/// The fan as triangle-list vertices in camera space, with the apex at
/// weight 1 and each rim vertex at 0 (`VERTICES`).
pub fn mesh(alpha: f32, sun: Vec3) -> [[f32; 4]; VERTICES] {
let (apex, rim) = vertices(alpha, sun);
let mut out = [[0.0; 4]; VERTICES];
for i in 0..TRIANGLES {
out[i * 3] = apex.extend(1.0).to_array();
out[i * 3 + 1] = rim[i].extend(0.0).to_array();
out[i * 3 + 2] = rim[(i + 1) % TRIANGLES].extend(0.0).to_array();
}
out
}
/// The fan's apex and rim in camera space, as vanilla builds them: the
/// fan turned to the horizon the sun sets on (or rises from), its rim
/// tilted by the alpha.
@@ -208,6 +226,21 @@ mod tests {
best
}
#[test]
fn mesh_puts_the_apex_weight_on_the_apex_of_every_triangle() {
let (apex, rim) = vertices(0.7, Vec3::new(0.0, 0.6, -0.8));
let mesh = mesh(0.7, Vec3::new(0.0, 0.6, -0.8));
assert_eq!(mesh.len(), VERTICES);
for i in 0..TRIANGLES {
assert_eq!(&mesh[i * 3][..3], apex.to_array());
assert_eq!(mesh[i * 3][3], 1.0);
assert_eq!(&mesh[i * 3 + 1][..3], rim[i].to_array());
assert_eq!(mesh[i * 3 + 1][3], 0.0);
assert_eq!(&mesh[i * 3 + 2][..3], rim[(i + 1) % TRIANGLES].to_array());
assert_eq!(mesh[i * 3 + 2][3], 0.0);
}
}
#[test]
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
let mut hits = 0;
+8 -5
View File
@@ -245,7 +245,8 @@ impl GraphicsApi {
/// What the frame limiter does in the last half millisecond before a frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Pacing {
/// Sleep the whole wait: no CPU spent spinning, a little less precise.
/// Sleep the whole wait: no CPU spent spinning, a little less precise; frames
/// keep to a fixed grid of slots, so late wake-ups don't lower the frame rate.
#[default]
Efficient,
/// Vanilla's `FramerateLimiter`: sleep, then spin until the deadline.
@@ -1230,10 +1231,12 @@ mod tests {
assert_eq!(g.lod_distance_on(&discrete), distance);
}
assert!(
crate::graphics_ui::rows(crate::graphics_ui::Level::Simple)
.iter()
.any(|row| matches!(row, crate::graphics_ui::Row::Setting(s)
if s.text.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks")))),
crate::graphics_ui::rows()
.into_iter()
.flat_map(|(_, groups)| groups.into_iter().flat_map(|(_, rows)| rows))
.any(|r| r
.text
.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks"))),
"the LOD Distance description names the cap"
);
}
+238 -209
View File
@@ -1,14 +1,19 @@
//! The look of Horizon's graphics settings screens, in one place: sizes,
//! colours and the widgets they draw with. The screens (`graphics_ui`) only
//! lay out rows and handle input; a menu theme replaces this module.
//! The look of Horizon's graphics settings screen, in one place: sizes,
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// What a setting costs in frame time, shown beside its description.
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cost {
None,
@@ -28,217 +33,241 @@ impl Cost {
Cost::Saves => "Saves time",
}
}
}
pub struct Style {
/// Width of a setting's button or slider.
pub control_width: f32,
/// Space between the control and its description.
pub gap: f32,
/// Widest description column; narrower on small screens.
pub max_text_width: f32,
/// Vertical distance between setting rows.
pub row_height: f32,
pub header_height: f32,
/// Top and bottom bands holding the title and the buttons.
pub band: f32,
pub title_color: u32,
pub header_color: u32,
pub description_color: u32,
/// Colour of each `Cost`, in its order.
pub cost_colors: [u32; 5],
}
/// `hud::rgba` with full alpha, usable in constants.
const fn opaque(r: u8, g: u8, b: u8) -> u32 {
0xFF00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32
}
pub const STYLE: Style = Style {
control_width: 150.0,
gap: 8.0,
max_text_width: 250.0,
row_height: 25.0,
header_height: 16.0,
band: 33.0,
title_color: u32::MAX,
header_color: opaque(255, 255, 85),
description_color: opaque(170, 170, 170),
cost_colors: [
opaque(120, 200, 120),
opaque(170, 220, 120),
opaque(240, 200, 90),
opaque(240, 110, 90),
opaque(85, 255, 85),
],
};
impl Style {
/// Width of the description column on a screen `screen_width` wide.
pub fn text_width(&self, screen_width: f32) -> f32 {
(screen_width - self.control_width - self.gap - 24.0).clamp(120.0, self.max_text_width)
}
/// Left edge of a row's control, centring control and description.
pub fn row_x(&self, screen_width: f32) -> f32 {
let total = self.control_width + self.gap + self.text_width(screen_width);
((screen_width - total) / 2.0).floor()
}
pub fn background(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
ui::list_background(batch, atlas, top, bottom, in_world);
}
pub fn frame(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
ui::separators(batch, atlas, top, bottom, in_world);
}
pub fn title(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, self.title_color);
}
pub fn header(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text, x: f32, y: f32) {
batch.text_styled(atlas, text, x, y + 4.0, self.header_color, true);
}
pub fn button(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
active: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
}
pub fn slider(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
ui::slider(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
active,
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
cost: Cost,
text: &str,
) {
let tag = Text::literal(lang::get(cost.label()));
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let text = lang::get(text);
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
atlas,
&Text::literal(line.clone()),
lx,
y + i as f32 * 10.0,
self.description_color,
true,
);
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
}
}
}
/// Greedy word wrap; the first line is `indent` narrower.
fn wrap(atlas: &HudAtlas, text: &str, width: f32, indent: f32) -> Vec<String> {
let mut lines = vec![String::new()];
for word in text.split_whitespace() {
let room = if lines.len() == 1 {
width - indent
} else {
width
};
let line = lines.last().expect("one line");
let candidate = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if atlas.font.width(&candidate) <= room || line.is_empty() {
*lines.last_mut().expect("one line") = candidate;
} else {
lines.push(word.to_owned());
}
}
lines
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
#[cfg(test)]
mod tests {
use super::*;
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
pub const TITLED: f32 = 300.0;
pub const ROW: f32 = 20.0;
/// A group's name over its panel, and a page's over its groups while searching.
pub const GROUP_ROW: f32 = 14.0;
pub const PAGE_ROW: f32 = 16.0;
/// Space under a group's panel, and more before the next page's name.
pub const GROUP_GAP: f32 = 10.0;
pub const PAGE_GAP: f32 = 6.0;
/// The control on the right of a row (a button, a box or a slider), and the
/// left column: the search field, the page list and the buttons.
pub const CONTROL_W: f32 = 112.0;
pub const CONTROL_H: f32 = 16.0;
pub const SEARCH_H: f32 = 16.0;
/// Vanilla's scroller: the column `ui::Scroll::press` takes.
pub const SCROLLER_W: f32 = 6.0;
/// The strip under the plate that describes a setting.
pub const DOCK_H: f32 = 50.0;
pub const BUTTON_H: f32 = 20.0;
/// A row's name and control, from its group panel's edges.
pub const INSET: f32 = 10.0;
#[test]
fn every_cost_has_a_label_and_a_colour() {
let all = [Cost::None, Cost::Low, Cost::Medium, Cost::High, Cost::Saves];
assert_eq!(all.len(), STYLE.cost_colors.len());
for (i, cost) in all.into_iter().enumerate() {
assert_eq!(cost as usize, i);
assert!(!cost.label().is_empty());
pub fn fill(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, h, color);
}
/// A 1 px ring inside `rect`: the keyboard or controller focus, in white as vanilla draws it.
pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, 1.0, color);
batch.fill(atlas, x, y + h - 1.0, w, 1.0, color);
batch.fill(atlas, x, y + 1.0, 1.0, h - 2.0, color);
batch.fill(atlas, x + w - 1.0, y + 1.0, 1.0, h - 2.0, color);
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control.
pub fn fit(atlas: &HudAtlas, text: &str, width: f32) -> String {
if atlas.font.width(text) <= width {
return text.to_owned();
}
let mut out = String::new();
for c in text.chars() {
if atlas.font.width(&format!("{out}{c}...")) > width {
break;
}
out.push(c);
}
format!("{}...", out.trim_end())
}
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
};
ring(batch, atlas, rect, edge);
if on {
tick(batch, atlas, rect[0] + 1.0, rect[1] + 2.0, edge);
}
}
/// A pixel tick, 10 by 8, over its own shadow.
fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
const TICK: [&str; 8] = [
" #",
" ##",
" ## ",
"# ## ",
"## ## ",
" ## ## ",
" ### ",
" # ",
];
for (j, row) in TICK.iter().enumerate() {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
}
}
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
pub fn button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
(hover, live, primary): (bool, bool, bool),
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(batch, atlas, label, x + 2.0, ty, w - 4.0, color);
}
/// A slider: its value over a thin track filled in the accent up to the handle at `position`.
pub fn slider(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
position: f32,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
ui::centered_text(batch, atlas, value, x + 2.0, y + 1.0, w - 4.0, color);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
pub fn cycler(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
well(batch, atlas, rect);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(true, true) => WHITE,
(true, false) => SOFT,
};
batch.text_styled(atlas, value, x + 9.0, ty, color, true);
}
/// A screen's title, centred at the top (split-screen profiles).
pub fn title(batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, u32::MAX);
}
/// The cost's colour in the HUD's encoding: the square beside its name.
pub fn cost_color(cost: Cost) -> u32 {
hex(cost.rgb(), 255)
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
//! HDR calibration (the graphics screen's HDR page, Calibrate...): full screen, one step at a time, the way
//! display calibration tools do it. HDR output draws each step's test picture straight in
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
//! shapes the game's picture, so what the player sees is the display alone:
+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((
+11 -3
View File
@@ -278,10 +278,18 @@ impl Column {
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
let water = |span: &Span| {
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
self.light_open_water_where(|state| {
let kind = kinds.get(state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
};
});
}
/// [`Column::light_open_water`] for a caller that knows which states are
/// water without `BlockFacts`: the save decoder, whose palette already
/// names water apart from lava. Saved columns skipping this lit their
/// water a level darker per block, which drew a band through the sea
/// where they met the generator's columns.
pub fn light_open_water_where(&mut self, is_water: impl Fn(BlockStateId) -> bool) {
let water = |span: &Span| is_water(span.state);
if !self.spans.last().is_some_and(water) {
return;
}
+75 -112
View File
@@ -2032,6 +2032,9 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => {
self.graphics.typing()
}
_ => self.settings_search_active(),
}
}
@@ -2136,11 +2139,8 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(
page,
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
) {
self.graphics.reset();
if let Some(at) = Self::graphics_page(page) {
self.graphics.open(at);
}
if page == Page::SplitSetup {
self.split_setup.reset();
@@ -2149,12 +2149,35 @@ impl OptionsUi {
self.split_profiles.reset();
}
}
/// The graphics screen level of `page`, if it is one.
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
/// What the graphics screen's actions do to these options.
fn graphics_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::graphics_ui::Action>,
) -> Vec<Event> {
use crate::graphics_ui::Action;
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
Page::Graphics => Some(crate::graphics_ui::Page::General),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Page::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Page::Hdr),
_ => None,
}
}
@@ -2333,7 +2356,7 @@ impl OptionsUi {
}
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
// buttons; each opens one of Horizon's settings pages.
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
@@ -2420,7 +2443,7 @@ impl OptionsUi {
rows
}
Page::Video => {
// Horizon's graphics screens, Simple and Advanced.
// Horizon's graphics settings screen, at General and at Advanced.
let mut rows = vec![Row::Buttons(
button(
0.0,
@@ -3388,10 +3411,9 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
if Self::graphics_page(self.page()).is_some() {
self.graphics.binding = self.selected_key;
self.graphics
.draw(level, s, batch, atlas, cursor, self.in_world);
self.graphics.draw(s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
@@ -3753,8 +3775,7 @@ impl OptionsUi {
let outcome = c.click((x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
if Self::graphics_page(self.page()).is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
@@ -3764,36 +3785,10 @@ impl OptionsUi {
};
return self.bind_mouse(s, button);
}
let mut events = Vec::new();
for action in self
let actions = self
.graphics
.click(level, s, (x, y), size, framebuffer, drag, right)
{
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::Done => events.extend(self.back(s)),
}
}
return events;
.click(s, (x, y), size, framebuffer, drag, right);
return self.graphics_events(s, actions);
}
if self.page() == Page::Packs {
let mut events = Vec::new();
@@ -4201,6 +4196,7 @@ impl OptionsUi {
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
/// ignores it, and honouring it toggled the clicked option on every later wheel
/// event, from anywhere on the screen, while the list never scrolled.
/// The graphics screen has its own wheel (`GraphicsUi::wheel`).
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -4211,38 +4207,9 @@ impl OptionsUi {
) -> Vec<Event> {
self.size = size;
self.framebuffer = framebuffer;
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
let actions = self
.graphics
.wheel(level, s, cursor, size, framebuffer, amount);
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
return events;
if Self::graphics_page(self.page()).is_some() {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
@@ -4322,8 +4289,8 @@ impl OptionsUi {
if self.resource_error.is_some() {
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
self.graphics.scroll(level, s, size.1, amount);
if Self::graphics_page(self.page()).is_some() {
self.graphics.scroll(s, size, amount);
return;
}
match &mut self.pack_screen {
@@ -4394,7 +4361,7 @@ impl OptionsUi {
}
/// Escape or Done: leave the current page, applying page results.
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
/// set before; both go back to Graphics: HDR.
/// set before; both go back to the graphics screen's HDR page.
fn calibration_outcome(
&mut self,
s: &mut Settings,
@@ -4505,6 +4472,8 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if self.settings_search_active() {
self.settings_search.insert_text(text);
self.scroll = 0.0;
@@ -4536,6 +4505,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
if self.settings_search_active() {
if self.settings_search.shortcut(shortcut, clipboard) {
self.scroll = 0.0;
@@ -4646,37 +4618,11 @@ impl OptionsUi {
return Vec::new();
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
if Self::graphics_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
let (actions, handled) = self.graphics.key_modifiers(level, s, key, shift, size, fb);
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
let (actions, handled) = self.graphics.key_modifiers(s, key, text, shift, size, fb);
let mut events = self.graphics_events(s, actions);
if key == K::Escape {
events.extend(self.back(s));
return events;
@@ -7191,6 +7137,22 @@ mod tests {
assert!(!events.iter().any(|e| matches!(e, Event::Volume(0, _))));
}
#[test]
fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
use crate::graphics_ui::{H, Item, Target};
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Graphics);
let size = (640.0, 360.0);
s.horizon.chunk_updates = 16;
ui.graphics.focus = Some(Target::Item(Item::Horizon(H::ChunkUpdates)));
ui.graphics.can_change_value = true;
let before = ui.graphics.scroll_amount();
ui.wheel(&mut s, size, (320.0, 150.0), [1920, 1080], -1.0);
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.graphics.scroll_amount() > before);
}
#[test]
fn horizon_pages_slider_and_wheel_parity() {
use winit::keyboard::KeyCode as K;
@@ -7223,13 +7185,14 @@ mod tests {
// 2. Horizon Graphics page through OptionsUi:
// Tab onto ChunkUpdates slider, ArrowRight adjusts value, Space toggles can_change_value
ui.show(Page::GraphicsAdvanced);
ui.show(Page::Graphics);
ui.graphics.open(crate::graphics_ui::Page::Performance);
s.horizon.chunk_updates = 10;
ui.size = size;
ui.framebuffer = [854, 480];
// Tab into controls
for _ in 0..15 {
for _ in 0..80 {
ui.key(&mut s, &atlas, K::Tab, None);
if ui.graphics.focus
== Some(crate::graphics_ui::Target::Item(
+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());
}
}
@@ -3,10 +3,10 @@
//! pause menu (Splitscreen Settings), playing on until the new one can join. A profile is a name
//! (kept in the game folder), with its own options. The
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
//! can't play (`refused`). Built from vanilla's widgets, as Horizon's own pages are.
//! can't play (`refused`). Built from vanilla's widgets.
use crate::{
gamepad::{Button as PadButton, GlyphStyle},
graphics_style::STYLE,
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang,
text::Text,
@@ -241,7 +241,7 @@ impl SplitProfiles {
};
let buttons = self.buttons(w, h);
if let Some(name) = &self.forgetting {
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr(
@@ -272,7 +272,7 @@ impl SplitProfiles {
line(batch, why, 176.0, rgba(255, 85, 85, 255));
}
} else if let Some(field) = &mut self.new {
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
@@ -293,7 +293,7 @@ impl SplitProfiles {
),
None => tr("horizon.pages.split_profiles", "Who's playing?"),
};
STYLE.title(batch, atlas, &Text::literal(title));
graphics_style::title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
use crate::{
gamepad::Button as PadButton,
graphics_style::STYLE,
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang,
split_layout::{Draft, Monitor, Placement, Spot, Warning, ordered},
@@ -568,7 +568,7 @@ impl SplitUi {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr("horizon.pages.split_setup", &[])),
@@ -99,6 +99,10 @@ pub trait ChunkGenerator: Send + Sync + 'static {
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
/// it keeps between jobs only to go faster.
fn idle(&self, _worker: &mut Self::Worker) {}
/// Every worker has had nothing to do for [`IDLE_RELEASE`]: give back the
/// caches the workers share, which only spare work for chunks generated
/// next to each other in a run. Results do not change.
fn all_idle(&self) {}
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
fn decorates(&self) -> bool {
false
@@ -149,6 +153,16 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
/// What the generator's shared caches hold: (name, entries, bytes, if
/// known), for `horizon-ctl memory`.
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
Vec::new()
}
/// A proto chunk's heap bytes by part, and its sections by bits per
/// block, for `horizon-ctl memory` (`ProtoChunk::heap_bytes`).
fn proto_heap_bytes(&self, _proto: &Proto) -> ([usize; 6], [usize; 16]) {
([0; 6], [0; 16])
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -399,12 +413,29 @@ impl Mailbox {
done.pop_front()
}
}
/// [`Streaming::proto_census`].
#[derive(Debug, Default)]
pub struct ProtoCensus {
pub protos: usize,
/// Packed blocks, section palettes and lists, heightmaps, other terrain
/// data, biomes, the box and its structure lists.
pub bytes: [usize; 6],
pub sections_by_bits: [usize; 16],
pub light_views: usize,
pub light_view_bytes: usize,
/// `ChunkGenerator::cache_census`.
pub caches: Vec<(&'static str, usize, Option<usize>)>,
}
pub struct Streaming {
shared: Arc<Shared>,
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
/// `ChunkGenerator::proto_heap_bytes`, for the memory census.
proto_bytes: Arc<dyn Fn(&Proto) -> ([usize; 6], [usize; 16]) + Send + Sync>,
/// `ChunkGenerator::cache_census`.
caches: Arc<dyn Fn() -> Vec<(&'static str, usize, Option<usize>)> + Send + Sync>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
@@ -824,6 +855,7 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
self.generator.all_idle();
}
}
impl<G: ChunkGenerator> Jobs<G> {
@@ -961,9 +993,19 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
let proto_bytes = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.proto_heap_bytes(p))
};
let caches = {
let generator = generator.clone();
Arc::new(move || generator.cache_census())
};
Self {
shared,
non_air_sections,
proto_bytes,
caches,
jobs,
workers,
order: 0,
@@ -1482,6 +1524,26 @@ impl Streaming {
&& self.finishing.is_empty()
&& self.lighting.is_empty())
}
/// The proto chunks held (not checked out): how many, their heap bytes by
/// part and their sections by bits per block; and the light views of
/// them with their bytes. For `horizon-ctl memory`.
pub fn proto_census(&self) -> ProtoCensus {
let mut census = ProtoCensus::default();
for proto in self.protos.values().flatten() {
let (bytes, bits) = (self.proto_bytes)(proto);
census.protos += 1;
for (sum, b) in census.bytes.iter_mut().zip(bytes) {
*sum += b;
}
for (sum, b) in census.sections_by_bits.iter_mut().zip(bits) {
*sum += b;
}
}
census.light_views = self.light_views.len();
census.light_view_bytes = self.light_views.values().map(|v| v.heap_bytes()).sum();
census.caches = (self.caches)();
census
}
/// How much work each stage holds: queued jobs, then what is in flight or
/// waiting at each step from generation to meshing.
pub fn stages(&self) -> [(&'static str, usize); 7] {
+27 -5
View File
@@ -646,21 +646,43 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
// gfx/native_sky.rs builds that stage from this section).
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
var color = globals.fog_color.rgb;
let dist = disc_distance(d, 16.0);
if dist.x >= 0.0 {
color = sky_fog(globals.sky_color.rgb, dist);
}
var color = sky_along_ray_base(d);
let w = sunrise_weight(d);
if w > 0.0 {
color = mix(color, globals.sunrise.rgb, w * globals.sunrise.a);
}
return color;
}
// The sky without the sunrise fan: the renderer rasterizes the fan's sixteen
// triangles over this colour instead of testing them in every sky pixel.
fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
var color = globals.fog_color.rgb;
let dist = disc_distance(d, 16.0);
if dist.x >= 0.0 {
color = sky_fog(globals.sky_color.rgb, dist);
}
return color;
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
@fragment fn fs_sky_base(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray_base(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
// The sunrise/sunset fan as geometry: the clip position is projected by the
// CPU with the sky's unjittered view, and the apex weight (1 at the apex, 0 at
// the rim) interpolates exactly the barycentric the per-pixel test computed.
struct SunriseOut {
@builtin(position) clip: vec4<f32>,
@location(0) weight: f32,
}
@vertex fn vs_sunrise(@location(0) clip: vec4<f32>, @location(1) weight: f32) -> SunriseOut {
return SunriseOut(clip, weight);
}
@fragment fn fs_sunrise(in: SunriseOut) -> @location(0) vec4<f32> {
return vec4(globals.sunrise.rgb, in.weight * globals.sunrise.a);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
+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"
@@ -88,7 +88,6 @@
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR-Einstellungen …",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
@@ -129,16 +128,10 @@
"Medium cost": "Mittlere Kosten",
"High cost": "Hohe Kosten",
"Saves time": "Spart Zeit",
"Rendering": "Rendering",
"Display": "Anzeige",
"Preferences": "Präferenzen",
"Overlay": "Einblendung",
"HDR": "HDR",
"Graphics": "Grafik",
"Graphics: Advanced": "Grafik: Erweitert",
"Graphics: HDR": "Grafik: HDR",
"Advanced...": "Erweitert …",
"Simple...": "Einfach …",
"Done": "Fertig",
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
@@ -93,7 +93,6 @@
"Resolution Scale": "Resolution Scale",
"Anti-aliasing": "Anti-aliasing",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR Settings...",
"Paper White": "Paper White",
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
@@ -134,16 +133,8 @@
"Medium cost": "Medium cost",
"High cost": "High cost",
"Saves time": "Saves time",
"Rendering": "Rendering",
"Display": "Display",
"Preferences": "Preferences",
"Overlay": "Overlay",
"HDR": "HDR",
"Graphics": "Graphics",
"Graphics: Advanced": "Graphics: Advanced",
"Graphics: HDR": "Graphics: HDR",
"Advanced...": "Advanced...",
"Simple...": "Simple...",
"Done": "Done",
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
@@ -216,6 +207,32 @@
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
"General": "General",
"Advanced": "Advanced",
"Undo": "Undo",
"Search options": "Search options",
"Shortcut": "Shortcut",
"Distance": "Distance",
"Display": "Display",
"Interface": "Interface",
"Sky and weather": "Sky and weather",
"Lighting and entities": "Lighting and entities",
"Textures": "Textures",
"Image": "Image",
"Chunk loading": "Chunk loading",
"Culling": "Culling",
"Power": "Power",
"Frames": "Frames",
"Overlay": "Overlay",
"Output": "Output",
"Brightness": "Brightness",
"How far you see, the window and the HUD.": "How far you see, the window and the HUD.",
"The sky and weather, lighting, textures and the image.": "The sky and weather, lighting, textures and the image.",
"Chunk loading, culling and power use.": "Chunk loading, culling and power use.",
"How frames reach the screen, and the performance overlay.": "How frames reach the screen, and the performance overlay.",
"Brighter-than-white output on an HDR display.": "Brighter-than-white output on an HDR display.",
"Results for \"%s\"": "Results for \"%s\"",
"No settings match.": "No settings match.",
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
"horizon.extras.header.lighting": "Lighting",
"horizon.extras.header.third_person": "Third person",
@@ -376,7 +393,6 @@
"horizon.splitscreen.setup.help.give_up_pad": "Your controller stops driving you and is offered to join (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A drops it here, B puts it back.",
"horizon.splitscreen.setup.note.held": "%s picked up: left/right picks where, A drops, B puts it back.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Block Entities": "Block Entities",
"Shulker Boxes": "Shulker Boxes",
@@ -82,7 +82,6 @@
"Resolution Scale": "Escala de resolución",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Reescalado",
"HDR Settings...": "Ajustes HDR...",
"Paper White": "Blanco de papel",
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
@@ -122,16 +121,8 @@
"Medium cost": "Coste medio",
"High cost": "Coste alto",
"Saves time": "Ahorra tiempo",
"Rendering": "Renderizado",
"Display": "Pantalla",
"Preferences": "Preferencias",
"Overlay": "Superposición",
"HDR": "HDR",
"Graphics": "Gráficos",
"Graphics: Advanced": "Gráficos: avanzados",
"Graphics: HDR": "Gráficos: HDR",
"Advanced...": "Avanzados...",
"Simple...": "Simples...",
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
@@ -207,5 +198,31 @@
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…",
"General": "General",
"Advanced": "Avanzado",
"Undo": "Deshacer",
"Search options": "Buscar opciones",
"Shortcut": "Atajo",
"Distance": "Distancia",
"Display": "Pantalla",
"Interface": "Interfaz",
"Sky and weather": "Cielo y clima",
"Lighting and entities": "Iluminación y entidades",
"Textures": "Texturas",
"Image": "Imagen",
"Chunk loading": "Carga de chunks",
"Culling": "Descarte",
"Power": "Energía",
"Frames": "Fotogramas",
"Overlay": "Superposición",
"Output": "Salida",
"Brightness": "Brillo",
"How far you see, the window and the HUD.": "Hasta dónde ves, la ventana y el HUD.",
"The sky and weather, lighting, textures and the image.": "El cielo y el clima, la iluminación, las texturas y la imagen.",
"Chunk loading, culling and power use.": "Carga de chunks, descarte y consumo de energía.",
"How frames reach the screen, and the performance overlay.": "Cómo llegan los fotogramas a la pantalla, y la superposición de rendimiento.",
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
"Results for \"%s\"": "Resultados de \"%s\"",
"No settings match.": "Ningún ajuste coincide."
}
@@ -88,7 +88,6 @@
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling": "Upscaling",
"HDR Settings...": "Options HDR...",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
@@ -129,16 +128,10 @@
"Medium cost": "Coût moyen",
"High cost": "Coût élevé",
"Saves time": "Fait gagner du temps",
"Rendering": "Rendu",
"Display": "Affichage",
"Preferences": "Préférences",
"Overlay": "Surimpression",
"HDR": "HDR",
"Graphics": "Graphismes",
"Graphics: Advanced": "Graphismes : avancés",
"Graphics: HDR": "Graphismes : HDR",
"Advanced...": "Avancé...",
"Simple...": "Simple...",
"Done": "Terminé",
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
@@ -88,7 +88,6 @@
"Resolution Scale": "Skala rozdzielczości",
"Anti-aliasing": "Antyaliasing",
"Upscaling": "Skalowanie",
"HDR Settings...": "Ustawienia HDR…",
"Paper White": "Punkt bieli",
"Peak Brightness": "Jasność maks.",
"Black Level": "Poziom czerni",
@@ -129,16 +128,10 @@
"Medium cost": "Średni koszt",
"High cost": "Wysoki koszt",
"Saves time": "Oszczędza czas",
"Rendering": "Renderowanie",
"Display": "Wyświetlanie",
"Preferences": "Preferencje",
"Overlay": "Nakładka",
"HDR": "HDR",
"Graphics": "Grafika",
"Graphics: Advanced": "Grafika: zaawansowane",
"Graphics: HDR": "Grafika: HDR",
"Advanced...": "Zaawansowane…",
"Simple...": "Proste…",
"Done": "Gotowe",
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
@@ -211,6 +204,30 @@
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"General": "Ogólne",
"Advanced": "Zaawansowane",
"Undo": "Cofnij",
"Search options": "Szukaj opcji",
"Shortcut": "Skrót",
"Distance": "Odległość",
"Interface": "Interfejs",
"Sky and weather": "Niebo i pogoda",
"Lighting and entities": "Oświetlenie i byty",
"Textures": "Tekstury",
"Image": "Obraz",
"Chunk loading": "Wczytywanie chunków",
"Culling": "Pomijanie",
"Power": "Zasilanie",
"Frames": "Klatki",
"Output": "Wyjście",
"Brightness": "Jasność",
"How far you see, the window and the HUD.": "Jak daleko widzisz, okno i HUD.",
"The sky and weather, lighting, textures and the image.": "Niebo i pogoda, oświetlenie, tekstury i obraz.",
"Chunk loading, culling and power use.": "Wczytywanie chunków, pomijanie i zużycie energii.",
"How frames reach the screen, and the performance overlay.": "Jak klatki trafiają na ekran oraz nakładka wydajności.",
"Brighter-than-white output on an HDR display.": "Obraz jaśniejszy niż biel na ekranie HDR.",
"Results for \"%s\"": "Wyniki dla \"%s\"",
"No settings match.": "Żadne ustawienie nie pasuje.",
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
"horizon.extras.header.lighting": "Oświetlenie",
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
@@ -291,7 +308,6 @@
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij",
"Culling": "Pomijanie",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
"Leaf Culling": "Pomijanie liści",
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
@@ -81,7 +81,6 @@
"Resolution Scale": "Масштаб разрешения",
"Anti-aliasing": "Сглаживание",
"Upscaling": "Апскейл",
"HDR Settings...": "Настройки HDR…",
"Paper White": "Белая точка",
"Peak Brightness": "Пиковая яркость",
"Black Level": "Уровень чёрного",
@@ -121,16 +120,8 @@
"Medium cost": "Средние затраты",
"High cost": "Много затрат",
"Saves time": "Экономит время",
"Rendering": "Рендеринг",
"Display": "Изображение",
"Preferences": "Предпочтения",
"Overlay": "Оверлей",
"HDR": "HDR",
"Graphics": "Графика",
"Graphics: Advanced": "Графика: подробно",
"Graphics: HDR": "Графика: HDR",
"Advanced...": "Подробно…",
"Simple...": "Просто…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.",
"Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.",
@@ -206,5 +197,31 @@
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…",
"General": "Общие",
"Advanced": "Дополнительно",
"Undo": "Отменить",
"Search options": "Поиск настроек",
"Shortcut": "Сочетание клавиш",
"Distance": "Дальность",
"Display": "Экран",
"Interface": "Интерфейс",
"Sky and weather": "Небо и погода",
"Lighting and entities": "Освещение и сущности",
"Textures": "Текстуры",
"Image": "Изображение",
"Chunk loading": "Загрузка чанков",
"Culling": "Отсечение",
"Power": "Питание",
"Frames": "Кадры",
"Overlay": "Оверлей",
"Output": "Вывод",
"Brightness": "Яркость",
"How far you see, the window and the HUD.": "Дальность обзора, окно и HUD.",
"The sky and weather, lighting, textures and the image.": "Небо и погода, освещение, текстуры и изображение.",
"Chunk loading, culling and power use.": "Загрузка чанков, отсечение и энергопотребление.",
"How frames reach the screen, and the performance overlay.": "Как кадры попадают на экран, и оверлей производительности.",
"Brighter-than-white output on an HDR display.": "Вывод ярче белого на HDR-дисплее.",
"Results for \"%s\"": "Результаты для «%s»",
"No settings match.": "Нет подходящих настроек."
}
@@ -82,7 +82,6 @@
"Resolution Scale": "Çözünürlük Ölçeği",
"Anti-aliasing": "Kenar Yumuşatma",
"Upscaling": "Yükseltme",
"HDR Settings...": "HDR Ayarları...",
"Paper White": "Referans Beyaz",
"Peak Brightness": "Tepe Parlaklığı",
"Black Level": "Siyah Seviyesi",
@@ -122,16 +121,8 @@
"Medium cost": "Orta Performans Maliyeti",
"High cost": "Yüksek Performans Maliyeti",
"Saves time": "Zaman Kazandırır",
"Rendering": "Görüntüleme",
"Display": "Ekran",
"Preferences": "Tercihler",
"Overlay": "Bilgi Katmanı",
"HDR": "HDR",
"Graphics": "Grafikler",
"Graphics: Advanced": "Grafikler: Gelişmiş",
"Graphics: HDR": "Grafikler: HDR",
"Advanced...": "Gelişmiş...",
"Simple...": "Basit...",
"Done": "Tamam",
"This feature is not yet available in Horizon.": "Bu özellik henüz Horizon'da mevcut değil.",
"Your display or system doesn't offer HDR output.": "Ekranınız veya sisteminiz HDR çıkışını desteklemiyor.",
@@ -202,5 +193,31 @@
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…",
"General": "Genel",
"Advanced": "Gelişmiş",
"Undo": "Geri al",
"Search options": "Seçeneklerde ara",
"Shortcut": "Kısayol",
"Distance": "Mesafe",
"Display": "Ekran",
"Interface": "Arayüz",
"Sky and weather": "Gökyüzü ve hava",
"Lighting and entities": "Işıklandırma ve varlıklar",
"Textures": "Dokular",
"Image": "Görüntü",
"Chunk loading": "Chunk yükleme",
"Culling": "Ayıklama",
"Power": "Güç",
"Frames": "Kareler",
"Overlay": "Kaplama",
"Output": "Çıkış",
"Brightness": "Parlaklık",
"How far you see, the window and the HUD.": "Ne kadar uzağı gördüğün, pencere ve HUD.",
"The sky and weather, lighting, textures and the image.": "Gökyüzü ve hava, ışıklandırma, dokular ve görüntü.",
"Chunk loading, culling and power use.": "Chunk yükleme, ayıklama ve güç kullanımı.",
"How frames reach the screen, and the performance overlay.": "Karelerin ekrana nasıl ulaştığı ve performans kaplaması.",
"Brighter-than-white output on an HDR display.": "HDR ekranda beyazdan parlak çıkış.",
"Results for \"%s\"": "\"%s\" için sonuçlar",
"No settings match.": "Eşleşen ayar yok."
}
@@ -88,7 +88,6 @@
"Resolution Scale": "Масштаб зображення",
"Anti-aliasing": "Згладжування",
"Upscaling": "Апскейлінг",
"HDR Settings...": "Налаштування HDR…",
"Paper White": "Біла точка",
"Peak Brightness": "Пік яскравості",
"Black Level": "Рівень чорного",
@@ -129,16 +128,10 @@
"Medium cost": "Середні витрати",
"High cost": "Високі витрати",
"Saves time": "Заощаджує час",
"Rendering": "Промальовування",
"Display": "Дисплей",
"Preferences": "Уподобання",
"Overlay": "Накладка",
"HDR": "HDR",
"Graphics": "Графіка",
"Graphics: Advanced": "Графіка: розширені",
"Graphics: HDR": "Графіка: HDR",
"Advanced...": "Розширені…",
"Simple...": "Прості…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
@@ -211,6 +204,30 @@
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"General": "Загальні",
"Advanced": "Додатково",
"Undo": "Скасувати",
"Search options": "Пошук налаштувань",
"Shortcut": "Сполучення клавіш",
"Distance": "Дальність",
"Interface": "Інтерфейс",
"Sky and weather": "Небо й погода",
"Lighting and entities": "Освітлення й сутності",
"Textures": "Текстури",
"Image": "Зображення",
"Chunk loading": "Завантаження чанків",
"Culling": "Відсікання",
"Power": "Живлення",
"Frames": "Кадри",
"Output": "Вивід",
"Brightness": "Яскравість",
"How far you see, the window and the HUD.": "Як далеко видно, вікно й HUD.",
"The sky and weather, lighting, textures and the image.": "Небо й погода, освітлення, текстури й зображення.",
"Chunk loading, culling and power use.": "Завантаження чанків, відсікання й енергоспоживання.",
"How frames reach the screen, and the performance overlay.": "Як кадри потрапляють на екран, і оверлей продуктивності.",
"Brighter-than-white output on an HDR display.": "Вивід, яскравіший за білий, на HDR-дисплеї.",
"Results for \"%s\"": "Результати для «%s»",
"No settings match.": "Немає відповідних налаштувань.",
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
"horizon.extras.header.lighting": "Освітлення",
"horizon.extras.header.third_person": "Вигляд від третьої особи",
@@ -291,7 +308,6 @@
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити",
"Culling": "Відсікання",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
"Leaf Culling": "Відсікання листя",
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
@@ -82,7 +82,6 @@
"Resolution Scale": "渲染比例",
"Anti-aliasing": "抗锯齿",
"Upscaling": "放大",
"HDR Settings...": "HDR 设置…",
"Paper White": "纸白亮度",
"Peak Brightness": "峰值亮度",
"Black Level": "黑位",
@@ -123,16 +122,8 @@
"Medium cost": "中等开销",
"High cost": "高开销",
"Saves time": "节省时间",
"Rendering": "渲染",
"Display": "显示",
"Preferences": "偏好",
"Overlay": "叠加层",
"HDR": "HDR",
"Graphics": "图形",
"Graphics: Advanced": "图形:高级",
"Graphics: HDR": "图形:HDR",
"Advanced...": "高级…",
"Simple...": "简单…",
"Done": "完成",
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
@@ -211,6 +202,32 @@
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…",
"General": "常规",
"Advanced": "高级",
"Undo": "撤销",
"Search options": "搜索选项",
"Shortcut": "快捷键",
"Distance": "距离",
"Display": "显示",
"Interface": "界面",
"Sky and weather": "天空与天气",
"Lighting and entities": "光照与实体",
"Textures": "纹理",
"Image": "图像",
"Chunk loading": "区块加载",
"Culling": "剔除",
"Power": "电源",
"Frames": "帧",
"Overlay": "叠加层",
"Output": "输出",
"Brightness": "亮度",
"How far you see, the window and the HUD.": "视距、窗口与 HUD。",
"The sky and weather, lighting, textures and the image.": "天空与天气、光照、纹理与图像。",
"Chunk loading, culling and power use.": "区块加载、剔除与功耗。",
"How frames reach the screen, and the performance overlay.": "帧如何送达屏幕,以及性能叠加层。",
"Brighter-than-white output on an HDR display.": "在 HDR 显示器上输出比白色更亮的画面。",
"Results for \"%s\"": "“%s”的结果",
"No settings match.": "没有匹配的设置。",
"horizon.pages.split_settings": "分屏设置",
"horizon.pages.split_setup": "分屏",
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
@@ -308,7 +325,6 @@
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
"Culling": "剔除",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
"Leaf Culling": "树叶剔除",
"Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。",
@@ -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()
{
+78 -15
View File
@@ -204,10 +204,12 @@ 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;
mod native_inventories;
mod native_item_container;
mod native_item_energy;
mod native_item_fluids;
mod native_lifecycle;
@@ -2079,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(
@@ -2278,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,
@@ -6441,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);
@@ -7328,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());
@@ -7342,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();
@@ -7438,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.
@@ -9021,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();
@@ -11809,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()),
@@ -13410,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))
@@ -13834,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,
));
@@ -15828,6 +15864,7 @@ impl Game<'_> {
self.capture(false);
}
Screen::Creative => {
self.pad_clear_focus();
self.carried = None;
self.screen = Screen::Playing;
self.capture(true);
@@ -15940,6 +15977,7 @@ impl Game<'_> {
}
}
Screen::Creative => {
self.pad_clear_focus();
if let Some(c) = self.carried.take() {
let _ = self.give(c);
}
@@ -16273,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());
}
}
@@ -17368,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 {
@@ -18249,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,
@@ -19054,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
@@ -94,6 +94,29 @@ impl Game<'_> {
.integrated
.as_ref()
.and_then(|i| i.call(|g| (g.level_census(), g.section_addresses())));
// The server thread's generation: proto chunks waiting for their
// neighbours, and the light jobs' views of them.
let generation = self.integrated.as_ref().and_then(|i| {
i.call(|g| {
let c = g.streaming.proto_census();
let [blocks, lists, heightmaps, other, biomes, records] = c.bytes;
json!({
"protos": c.protos,
"protos_mb": mb(c.bytes.iter().sum::<usize>()),
"proto_blocks_mb": mb(blocks),
"proto_section_lists_mb": mb(lists),
"proto_heightmaps_mb": mb(heightmaps),
"proto_other_terrain_mb": mb(other),
"proto_biomes_mb": mb(biomes),
"proto_records_mb": mb(records),
"proto_sections_by_bits": c.sections_by_bits,
"light_views": c.light_views,
"light_views_mb": mb(c.light_view_bytes),
"caches": c.caches.iter().map(|(name, n, bytes)| json!([name, n, bytes.map(mb)])).collect::<Vec<_>>(),
"stages": g.streaming.stages().iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
})
})
});
// The window's sections the server thread holds too (lan/handoff.rs).
let shared = server.as_ref().map(|(_, at)| {
let (sections, bytes) = shared(self.all_worlds(), at);
@@ -136,6 +159,7 @@ impl Game<'_> {
"client_columns": census_json(&client),
"server_columns": server.as_ref().map(|(c, _)| census_json(c)),
"shared_with_server": shared,
"server_generation": generation,
"far_tiles": self.lod.as_ref().map(|lod| {
let (active, cached) = lod.stats();
json!({ "active": active, "cached": cached })
+42
View File
@@ -495,4 +495,46 @@ 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)
}
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
self.native_item_container_insert(stack, item)
}
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
self.native_item_container_extract(stack, slot, max)
}
}
+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
);
}
}
+110 -2
View File
@@ -2493,6 +2493,25 @@ impl Game<'_> {
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
mod_api::dispatch_camera_shake(self, mod_api::mods(), partial, shake);
}
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
pub(super) fn native_render_name_tag(
&self,
entity_id: u32,
kind: &str,
vanilla_visible: bool,
visible: &mut bool,
text: &mut Option<String>,
) {
mod_api::dispatch_render_name_tag(
self,
mod_api::mods(),
entity_id,
kind,
vanilla_visible,
visible,
text,
);
}
/// Before one first-person hand and its displayed stack are drawn (RenderHandEvent).
/// Equip progress is the inverse arm height, including the item's swap animation scale.
/// Cancel skips that hand's default geometry, including its map and arm geometry.
@@ -2827,6 +2846,18 @@ impl Game<'_> {
) {
mod_api::dispatch_item_energy_buffer(self, mod_api::mods(), item, buffer);
}
/// 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
/// the item without slots, and vanilla's items have none. Asked at each
/// call of the item container helpers (`native_item_container_slots`,
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
pub(super) fn native_item_container(&self, item: &str, slots: &mut Option<u32>) {
mod_api::dispatch_item_container(self, mod_api::mods(), item, slots);
}
/// A server tick of the block entity at `pos`, in vanilla's order among
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
/// and change its saved compound `nbt`, its item slots `items` (keep
@@ -5077,6 +5108,58 @@ impl Game<'_> {
mod_api::dispatch_entity_shoot_bow(self, mod_api::mods(), player, name, projectile, force);
}
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
/// or the player is in creative. Set `cancel` to fail the use.
/// @API:arrow_nock@.
pub(super) fn native_arrow_nock(
&mut self,
player: u128,
name: &str,
bow: &ItemStack,
hand: Hand,
has_ammo: bool,
cancel: &mut bool,
) {
mod_api::dispatch_arrow_nock(
self,
mod_api::mods(),
player,
name,
bow,
hand,
has_ammo,
cancel,
);
}
/// `LivingEntity.getProjectile(weapon)` (NeoForge's
/// `LivingGetProjectileEvent`) for a player (`player` and `name`) or a
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
/// load of a pillager or piglin. `projectile` is the vanilla
/// ammunition; replace it to choose another. A replaced stack is not taken
/// from the inventory. @API:living_get_projectile@.
pub(super) fn native_living_get_projectile(
&mut self,
player: Option<u128>,
name: &str,
entity: Option<u32>,
kind: &str,
weapon: &ItemStack,
projectile: &mut Option<ItemStack>,
) {
mod_api::dispatch_living_get_projectile(
self,
mod_api::mods(),
player,
name,
entity,
kind,
weapon,
projectile,
);
}
/// A guest's fall flying state changed on the server. The change has
/// already been applied. 222.
pub(super) fn native_entity_toggle_glide(&mut self, player: u128, name: &str, gliding: bool) {
@@ -6054,7 +6137,7 @@ impl Game<'_> {
/// release; `native_entity_shoot_bow` reports a spawned arrow afterward
/// with read-only force. Fires on the Game running the bow release, including
/// LAN guests; a remote client's own release path is not covered. Crossbows
/// and `ArrowNockEvent` are not covered. 318.
/// are not covered; `native_arrow_nock` is the bow's start. 318.
pub(super) fn native_arrow_loose(
&mut self,
stack: &ItemStack,
@@ -6161,6 +6244,31 @@ impl Game<'_> {
) {
mod_api::dispatch_explosion_detonate(self, mod_api::mods(), dimension, center, blocks);
}
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
/// `entity` (None for a player, whom `native_acting_player` names):
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
/// what is added to the entity's velocity; a player's client is sent the
/// same. Wind bursts too, except dragons and end crystals. End crystals
/// are also omitted from ordinary blasts: Horizon removes them without a
/// velocity to change. Server-side. @API:explosion_knockback@.
pub(super) fn native_explosion_knockback(
&mut self,
dimension: &str,
center: [f64; 3],
blocks: &[[i32; 3]],
entity: Option<u32>,
knockback_velocity: &mut [f64; 3],
) {
mod_api::dispatch_explosion_knockback(
self,
mod_api::mods(),
dimension,
center,
blocks,
entity,
knockback_velocity,
);
}
/// A player fills (`stack` an empty bucket) or empties a bucket at `pos`:
/// `BucketItem.use`, where it asks `Level.mayInteract`. Set `canceled`
/// and the bucket does nothing. Server-side, for every player of the
@@ -8440,7 +8548,7 @@ impl Game<'_> {
};
let items = self.items();
let known: Vec<String> = Ingredient::parse(&json, items)?
.1
.order
.into_iter()
.filter(|item| items.get(item).is_some())
.collect();
@@ -0,0 +1,370 @@
//! Items held by item stacks for native mods (docs/native-mods.md): a mod's
//! backpacks, storage cells and toolboxes (`native_item_container`), read,
//! filled and emptied. NeoForge's `Capabilities.Item.ITEM` is a
//! `ResourceHandler<ItemResource>` reached through an `ItemAccess`: it acts on
//! one item of a stack at a time and may change it. So do these, as the item
//! fluid and energy helpers do: they take a stack and answer without changing
//! it, and the caller shrinks its stack by one and hands out the changed item
//! they return. The contents live in `minecraft:container`, as a shulker box
//! item keeps them. They hold no world state, so they work on any side.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_world::nbt::Tag;
/// `ItemContainerContents`' limit on slots.
const MAX_SLOTS: u32 = 256;
/// `Container.getMaxStackSize`.
const CONTAINER_MAX: u32 = 99;
/// The slots of one item of `stack`, empty ones included; slots past `size`
/// are dropped, as the item has no room for them.
fn contents(stack: &ItemStack, size: u32) -> Vec<Option<ItemStack>> {
let mut slots = vec![None; size as usize];
for (slot, item) in stack.data().map_or(&[][..], |d| &d.container[..]) {
if let Some(s) = slots.get_mut(usize::from(*slot))
&& item.count > 0
{
*s = Some(item.clone());
}
}
slots
}
/// One item of `stack`, unchanged.
fn one_of(stack: &ItemStack) -> ItemStack {
let mut one = stack.clone();
one.count = 1;
one
}
/// One item of `stack` holding `slots`, its other data kept. An empty item
/// carries nothing, so it stacks with a new one.
fn with_contents(stack: &ItemStack, slots: &[Option<ItemStack>]) -> ItemStack {
let mut one = one_of(stack);
let data = one.data_mut();
data.container = slots
.iter()
.enumerate()
.filter_map(|(i, s)| Some((i as u8, s.clone().filter(|s| s.count > 0)?)))
.collect();
// What a server sent for the stack no longer describes it; kept, an
// emptied item would not stack with a new one.
data.wire = Default::default();
one.tidy();
one
}
/// `SimpleContainer.addItem` on one item of `stack`: `item` tops up the
/// slots that hold the same item and components, then goes into the first
/// empty ones. Unlike `SimpleContainer`, which puts the whole rest in the
/// first empty slot and cuts it to the limit, no slot takes more than its
/// limit (`limit`), and what doesn't fit stays out. A shulker box never goes
/// in (`Item.canFitInsideContainerItems`). The count that went in, and the
/// item it becomes.
fn insert_one(
stack: &ItemStack,
size: Option<u32>,
item: &ItemStack,
limit: u32,
) -> Option<(u32, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
if item.count == 0 || !horizon_client::menu::fits_inside_container_items(item) {
return Some((0, one_of(stack)));
}
let mut slots = contents(stack, size);
let mut left = item.count;
for held in slots.iter_mut().flatten() {
if left == 0 {
break;
}
if held.same_item_same_components(item) {
let room = limit.saturating_sub(held.count).min(left);
held.count += room;
left -= room;
}
}
for slot in slots.iter_mut().filter(|s| s.is_none()) {
if left == 0 {
break;
}
let put = limit.min(left);
*slot = Some(ItemStack {
count: put,
..item.clone()
});
left -= put;
}
let moved = item.count - left;
if moved == 0 {
return Some((0, one_of(stack)));
}
Some((moved, with_contents(stack, &slots)))
}
/// Up to `max` out of `slot` of one item of `stack`: what came out (none
/// when nothing did), and the item it becomes.
fn extract_one(
stack: &ItemStack,
size: Option<u32>,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
let mut slots = contents(stack, size);
let Some(Some(held)) = slots.get_mut(slot) else {
return Some((None, one_of(stack)));
};
let take = held.count.min(max);
if take == 0 {
return Some((None, one_of(stack)));
}
let taken = ItemStack {
count: take,
..held.clone()
};
held.count -= take;
Some((Some(taken), with_contents(stack, &slots)))
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The slots a mod gave item `item`, clamped; none for any other item.
fn item_container_size(&self, item: &str) -> Option<u32> {
if item.starts_with("minecraft:") {
return None;
}
let mut slots = None;
self.native_item_container(item, &mut slots);
slots.filter(|s| *s >= 1).map(|s| s.min(MAX_SLOTS))
}
/// `Container.getMaxStackSize(stack)`: the container's 99, or the stack's
/// own limit (its `max_stack_size` component, else the item's) when less.
fn item_container_limit(&self, stack: &ItemStack) -> u32 {
let own = match stack
.data()
.and_then(|d| d.other("minecraft:max_stack_size"))
{
Some(Tag::Int(n)) => (*n).clamp(1, 99) as u32,
_ => self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack),
};
own.min(CONTAINER_MAX)
}
/// `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.
pub(in crate::game) fn native_item_container_slots(
&self,
stack: &ItemStack,
) -> Option<Vec<Option<ItemStack>>> {
let size = self.item_container_size(&stack.item)?;
(stack.count > 0).then(|| contents(stack, size))
}
/// `ResourceHandler.insert`: as much of `item` as fits into one item of
/// `stack`, topping up the slots that hold the same item and components
/// first, then the first empty ones (`SimpleContainer.addItem`'s order),
/// no slot past its limit (99, or the item's own when less). A shulker
/// box never goes in. The count that went in, and the single item it
/// becomes (the item itself when none did). The stack given is not
/// changed: shrink it by one and hand out the result, or leave the result
/// unused to simulate. None where the item has no slots.
pub(in crate::game) fn native_item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
let size = self.item_container_size(&stack.item);
insert_one(stack, size, item, self.item_container_limit(item))
}
/// `ResourceHandler.extract`: up to `max` out of slot `slot` of one item
/// of `stack`. What came out (none when the slot is empty or past the
/// item's slots), and the single item it becomes. The stack given is not
/// changed, as with `native_item_container_insert`. None where the item
/// has no slots.
pub(in crate::game) fn native_item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = self.item_container_size(&stack.item);
extract_one(stack, size, slot, max)
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::JavaString;
use std::sync::Arc;
fn bag(count: u32) -> ItemStack {
ItemStack::new("mymod:bag", count)
}
fn of(item: &str, count: u32) -> ItemStack {
ItemStack::new(item, count)
}
/// (slot, item, count) of what one item holds.
fn held(stack: &ItemStack, size: u32) -> Vec<(usize, String, u32)> {
contents(stack, size)
.into_iter()
.enumerate()
.filter_map(|(i, s)| s.map(|s| (i, s.item.to_string(), s.count)))
.collect()
}
#[test]
fn an_insert_tops_up_equal_stacks_before_empty_slots() {
let (_, one) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 10), 64).unwrap();
let (_, one) = insert_one(&one, Some(3), &of("minecraft:dirt", 5), 64).unwrap();
let (moved, one) = insert_one(&one, Some(3), &of("minecraft:stone", 60), 64).unwrap();
// 54 top up slot 0 to 64, the other 6 take the first empty slot.
assert_eq!(moved, 60);
assert_eq!(
held(&one, 3),
[
(0, "minecraft:stone".into(), 64),
(1, "minecraft:dirt".into(), 5),
(2, "minecraft:stone".into(), 6),
]
);
}
#[test]
fn no_slot_passes_its_limit_and_the_rest_stays_out() {
// 16 per slot (ender pearls), two slots: 32 of 40 go in.
let (moved, one) =
insert_one(&bag(1), Some(2), &of("minecraft:ender_pearl", 40), 16).unwrap();
assert_eq!(moved, 32);
assert_eq!(
held(&one, 2),
[
(0, "minecraft:ender_pearl".into(), 16),
(1, "minecraft:ender_pearl".into(), 16),
]
);
// Full: nothing goes in, and the item comes back as it was.
assert_eq!(
insert_one(&one, Some(2), &of("minecraft:ender_pearl", 1), 16).unwrap(),
(0, one.clone())
);
}
#[test]
fn different_components_never_share_a_slot() {
let mut named = of("minecraft:stone", 1);
named.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Rock"))),
);
let (_, one) = insert_one(&bag(1), Some(2), &of("minecraft:stone", 1), 64).unwrap();
let (moved, one) = insert_one(&one, Some(2), &named, 64).unwrap();
assert_eq!(moved, 1);
assert_eq!(held(&one, 2).len(), 2);
}
#[test]
fn a_shulker_box_never_goes_in() {
let shulker = of("minecraft:red_shulker_box", 1);
assert_eq!(
insert_one(&bag(1), Some(9), &shulker, 1).unwrap(),
(0, bag(1))
);
// Another container item does.
assert_eq!(insert_one(&bag(1), Some(9), &bag(1), 64).unwrap().0, 1);
}
#[test]
fn an_extract_takes_up_to_max_and_an_emptied_bag_is_new_again() {
let (_, full) = insert_one(&bag(1), Some(4), &of("minecraft:apple", 20), 64).unwrap();
let (taken, rest) = extract_one(&full, Some(4), 0, 15).unwrap();
assert_eq!(taken.map(|t| t.count), Some(15));
assert_eq!(held(&rest, 4), [(0, "minecraft:apple".into(), 5)]);
let (taken, empty) = extract_one(&rest, Some(4), 0, 64).unwrap();
assert_eq!(
taken.map(|t| (t.item.to_string(), t.count)),
Some(("minecraft:apple".into(), 5))
);
assert_eq!(empty, bag(1));
// An empty slot, or one past the bag's slots, gives nothing.
assert_eq!(extract_one(&empty, Some(4), 0, 1).unwrap(), (None, bag(1)));
assert_eq!(
extract_one(&full, Some(4), 7, 1).unwrap(),
(None, full.clone())
);
}
#[test]
fn the_stack_given_stays_and_one_item_comes_back() {
let stack = bag(3);
let (moved, one) = insert_one(&stack, Some(1), &of("minecraft:stone", 1), 64).unwrap();
assert_eq!((moved, one.count, stack.count), (1, 1, 3));
assert_eq!(stack, bag(3));
}
#[test]
fn items_without_slots_hold_nothing() {
assert_eq!(
insert_one(&bag(1), None, &of("minecraft:stone", 1), 64),
None
);
assert_eq!(extract_one(&bag(1), None, 0, 1), None);
assert_eq!(
insert_one(&bag(0), Some(3), &of("minecraft:stone", 1), 64),
None
);
// Nothing to insert moves nothing.
assert_eq!(
insert_one(&bag(1), Some(3), &of("minecraft:stone", 0), 64).unwrap(),
(0, bag(1))
);
}
#[test]
fn slots_past_the_size_are_dropped() {
// A mod that shrank its bag after it was filled.
let (_, big) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 64 * 3), 64).unwrap();
assert_eq!(held(&big, 2).len(), 2);
assert_eq!(extract_one(&big, Some(2), 2, 64).unwrap().0, None);
}
#[test]
fn an_emptied_bag_from_a_server_stacks_with_a_new_one() {
let mut sent = bag(2);
sent.data_mut().wire = horizon_client::item_data::Wire::new(bag(2), Arc::from(vec![1u8]));
let (_, filled) = insert_one(&sent, Some(1), &of("minecraft:stone", 3), 64).unwrap();
let (_, emptied) = extract_one(&filled, Some(1), 0, 3).unwrap();
assert!(emptied.same_item_same_components(&bag(1)));
}
#[test]
fn a_bag_keeps_the_rest_of_its_data() {
let mut stack = bag(1);
stack.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Kit"))),
);
let (_, filled) = insert_one(&stack, Some(2), &of("minecraft:stone", 2), 64).unwrap();
assert!(
filled
.data()
.and_then(|d| d.other("minecraft:custom_name"))
.is_some()
);
assert_eq!(held(&filled, 2), [(0, "minecraft:stone".into(), 2)]);
let (_, emptied) = extract_one(&filled, Some(2), 0, 2).unwrap();
assert_eq!(emptied, stack);
}
}
@@ -63,6 +63,12 @@ impl NativeLifecycle {
.is_some_and(|a| a.session == OpenState::Ready && a.context() == *context)
}
/// The epoch of the world open now, from the start of its open to the end
/// of its close: what a mod's entity and level handles carry.
pub(super) fn epoch(&self) -> Option<NativeWorldEpoch> {
self.attempt.map(|a| NativeWorldEpoch::new(a.epoch))
}
/// The open starts: a fresh process-wide epoch, never reused.
pub(super) fn begin_open(&mut self, authority: NativeAuthority) {
let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed);
@@ -54,7 +54,8 @@ impl Index {
self.stack(&s.item, s.count, components(s))
}
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
i.1.iter()
i.order
.iter()
.filter_map(|id| self.stack(id, 1, String::new()))
.collect()
}
+1 -1
View File
@@ -611,7 +611,7 @@ impl Game<'_> {
}
horizon_client::graphics::Pacing::Efficient => {
let mut clock = horizon_client::window::SleepingClock(self.frame_clock);
horizon_client::window::end_frame(&mut self.limiter, limit, &mut clock);
horizon_client::window::end_frame_on_grid(&mut self.limiter, limit, &mut clock);
}
}
}
+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.
+1 -1
View File
@@ -449,7 +449,7 @@ impl Game<'_> {
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
/// The HDR settings key (`Graphics::hdr_key`): opens Graphics: HDR from the game, where
/// The HDR settings key (`Graphics::hdr_key`): opens the graphics screen's HDR page from the game, where
/// the display offers HDR; Done goes back to the game.
pub(super) fn hdr_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.hdr_input(name))
+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,
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more