mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Merge main into #7584
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
35210289dd
175 files changed
+9968
-849
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -12,6 +12,7 @@ members = [
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
"crates/horizon-cosmetics",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
|
||||
+10
-12
@@ -61,19 +61,17 @@ trademarks; Horizon is not affiliated with AMD.
|
||||
|
||||
## Mod loader logos
|
||||
|
||||
The launcher shows the logo of the mod loader an instance plays with.
|
||||
The launcher shows the logo of the mod loader an instance plays with, and offers the logos as
|
||||
instance icons.
|
||||
|
||||
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
|
||||
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
|
||||
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
|
||||
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
|
||||
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
|
||||
unmodified (the launcher scales it up by whole pixels).
|
||||
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
|
||||
fetches them from their own sites when an instance needs them
|
||||
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
|
||||
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
|
||||
- `launcher/assets/logos/{fabric,quilt,neoforge,forge}.png` are the loaders' logos as the launcher
|
||||
mockup on [playhorizon.dev/launcher](https://playhorizon.dev/launcher) shows them (horizon-site
|
||||
`public/media/launcher/*.webp`, converted to PNG), each its project's own mark:
|
||||
[Fabric](https://fabricmc.net/), [Quilt](https://quiltmc.org/) (its art is
|
||||
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)),
|
||||
[NeoForged](https://neoforged.net/) (its art, by Ridanisaurus, is
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)) and
|
||||
[Minecraft Forge](https://minecraftforge.net/). They name the loader an instance runs.
|
||||
|
||||
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
|
||||
the project endorses Horizon.
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -434,6 +434,25 @@ impl Biomes {
|
||||
}
|
||||
changed.then_some(out)
|
||||
}
|
||||
|
||||
/// This table (the biome files') with its colours passed through `f`, when
|
||||
/// they differ from `current`'s: what a refresh of a table already
|
||||
/// coloured by `f` once must not compound.
|
||||
pub fn recolored_from(
|
||||
&self,
|
||||
current: &Self,
|
||||
f: impl FnMut(&str, &mut BiomeColors),
|
||||
) -> Option<Self> {
|
||||
let out = self.with_colors(f).unwrap_or_else(|| self.clone());
|
||||
let tints = |b: &BiomeInfo| (b.grass, b.foliage, b.dry_foliage, b.water);
|
||||
let same = out.list.len() == current.list.len()
|
||||
&& out
|
||||
.list
|
||||
.iter()
|
||||
.zip(¤t.list)
|
||||
.all(|(a, b)| tints(a) == tints(b));
|
||||
(!same).then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 24-bit colour as sRGB components in 0..1.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5390,11 +5390,12 @@ impl Renderer {
|
||||
&& self.native.is_none()
|
||||
}
|
||||
|
||||
/// Whether the eyes render type's layers may be posed on the GPU too
|
||||
/// (`mobs::skin::SkinModels::eyes`): only with improved transparency off,
|
||||
/// Whether the layers tagged `OIT_ENTITY` (the eyes render type's, horse
|
||||
/// markings, a slime's outer cube) may be posed on the GPU too
|
||||
/// (`mobs::skin::SkinModels::tagged`): only with improved transparency off,
|
||||
/// where they are ordinary entity quads. On, `EntityBatch` sorts them into
|
||||
/// its stages by the CPU vertices' `OIT_ENTITY` tag.
|
||||
pub fn skins_eyes(&self) -> bool {
|
||||
pub fn skins_tagged(&self) -> bool {
|
||||
self.skins_entities() && !self.improved_transparency
|
||||
}
|
||||
|
||||
@@ -8873,6 +8874,7 @@ impl Renderer {
|
||||
camera: frame.camera,
|
||||
entities: frame.entities,
|
||||
spans: frame.entity_spans,
|
||||
motion: frame.entity_motion,
|
||||
atlas: self.hud.atlas_texture.as_ref(),
|
||||
quad_indices: &self.indices,
|
||||
});
|
||||
@@ -9214,20 +9216,30 @@ impl Renderer {
|
||||
.contains(wgpu::TextureUsages::COPY_SRC)
|
||||
{
|
||||
let device = &self.device;
|
||||
self.post_effects
|
||||
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
target_texture,
|
||||
view_target,
|
||||
[self.config.width, self.config.height],
|
||||
// The scaled world pass drew its own depth buffer.
|
||||
overlay.world_depth.then_some(&self.depth),
|
||||
&frame.post_effects,
|
||||
timing.as_mut(),
|
||||
);
|
||||
let post = self
|
||||
.post_effects
|
||||
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device));
|
||||
// The chains a spectated mob applies compile in the background from the
|
||||
// first frame in a world, not in the frame that first needs one.
|
||||
post.preload(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
frame.post_effects.root,
|
||||
&crate::post_chain::SPECTATOR_CHAINS,
|
||||
target_texture.format(),
|
||||
);
|
||||
post.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
target_texture,
|
||||
view_target,
|
||||
[self.config.width, self.config.height],
|
||||
// The scaled world pass drew its own depth buffer.
|
||||
overlay.world_depth.then_some(&self.depth),
|
||||
&frame.post_effects,
|
||||
timing.as_mut(),
|
||||
);
|
||||
}
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
|
||||
@@ -70,6 +70,12 @@ pub struct WorldDrawn<'a> {
|
||||
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
|
||||
pub entities: &'a [Vertex],
|
||||
pub spans: &'a [EntitySpan],
|
||||
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
|
||||
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
|
||||
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
|
||||
/// frame to frame (API @API:world_drawn_motion@).
|
||||
pub motion: &'a [(u32, u64)],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
|
||||
|
||||
@@ -380,6 +380,9 @@ struct Pass {
|
||||
image_copies: u64,
|
||||
/// The pack's images a compute pass's loads read as they were before it: copied first.
|
||||
snapshots: u64,
|
||||
/// `scale.<program>` (scale, offset x, offset y): the pass draws only that part of its
|
||||
/// buffers and keeps the rest.
|
||||
viewport: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl Pass {
|
||||
@@ -892,6 +895,20 @@ fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The viewport of `scale.<program>` (scale, offset x, offset y) on buffers of `size`, in whole
|
||||
/// pixels, at least one and inside them. Buffer rows are GL's, so the offsets count from GL's
|
||||
/// bottom-left corner as they are.
|
||||
fn viewport(size: [u32; 2], [scale, x, y]: [f32; 3]) -> [f32; 4] {
|
||||
let [w, h] = size.map(|n| n as f32);
|
||||
let (x, y) = ((x * w).floor().min(w - 1.0), (y * h).floor().min(h - 1.0));
|
||||
[
|
||||
x,
|
||||
y,
|
||||
(scale * w).floor().clamp(1.0, w - x),
|
||||
(scale * h).floor().clamp(1.0, h - y),
|
||||
]
|
||||
}
|
||||
|
||||
fn attach(
|
||||
view: &wgpu::TextureView,
|
||||
ops: wgpu::Operations<wgpu::Color>,
|
||||
@@ -1555,6 +1572,7 @@ fn build_pass(
|
||||
compute: Vec::new(),
|
||||
sink,
|
||||
geometry: p.geometry_triangles,
|
||||
viewport: (!final_pass && p.scale != [1.0, 0.0, 0.0]).then_some(p.scale),
|
||||
image_copies,
|
||||
snapshots: 0,
|
||||
})
|
||||
@@ -1688,6 +1706,7 @@ fn build_compute(
|
||||
geometry: 0,
|
||||
image_copies,
|
||||
snapshots,
|
||||
viewport: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3990,18 +4009,27 @@ impl PackPost {
|
||||
.map(|t| &t[usize::from(self.main & 1 << i == 0)])
|
||||
};
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
// A scaled pass keeps what its buffers hold outside its viewport.
|
||||
let ops = if pass.viewport.is_some() {
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}
|
||||
} else {
|
||||
overwrite
|
||||
};
|
||||
if final_pass {
|
||||
attachments.push(attach(screen, overwrite));
|
||||
shown = true;
|
||||
} else if pass.sink {
|
||||
attachments.push(attach(&targets.sink, overwrite));
|
||||
attachments.push(attach(&targets.sink, ops));
|
||||
} else {
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
if attachments.len() <= l {
|
||||
attachments.resize(l + 1, None);
|
||||
}
|
||||
attachments[l] = view(buffer).and_then(|v| attach(v, overwrite));
|
||||
attachments[l] = view(buffer).and_then(|v| attach(v, ops));
|
||||
}
|
||||
}
|
||||
{
|
||||
@@ -4018,6 +4046,10 @@ impl PackPost {
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
if let Some(scale) = pass.viewport {
|
||||
let [x, y, w, h] = viewport(out, scale);
|
||||
rp.set_viewport(x, y, w, h, 0.0, 1.0);
|
||||
}
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
self.main ^= pass.flip;
|
||||
@@ -4273,6 +4305,27 @@ mod tests {
|
||||
assert_eq!(&read.slice(..).get_mapped_range().unwrap()[..], &[0; 4]);
|
||||
}
|
||||
|
||||
/// `scale.composite2 = 0.25 0.5 0.25` on a 1280×720 picture: Iris 1.11.7 draws x 640..960
|
||||
/// and GL y 180..360 (a probe pack of our own). Rows are GL's here, so that is rows 180..360.
|
||||
#[test]
|
||||
fn a_scaled_pass_draws_its_part_from_gl_s_corner() {
|
||||
assert_eq!(
|
||||
viewport([1280, 720], [0.25, 0.5, 0.25]),
|
||||
[640.0, 180.0, 320.0, 180.0]
|
||||
);
|
||||
assert_eq!(
|
||||
viewport([1280, 720], [0.5, 0.0, 0.0]),
|
||||
[0.0, 0.0, 640.0, 360.0]
|
||||
);
|
||||
// Whole pixels, at least one, and never past the buffer's edge.
|
||||
assert_eq!(viewport([5, 3], [0.5, 0.0, 0.0]), [0.0, 0.0, 2.0, 1.0]);
|
||||
assert_eq!(viewport([4, 4], [0.01, 0.0, 0.0]), [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(
|
||||
viewport([100, 100], [1.0, 0.5, 0.75]),
|
||||
[50.0, 75.0, 50.0, 25.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,10 @@ windows-segment-heap = []
|
||||
name = "horizon-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "datapack_biome_sync"
|
||||
path = "tests/datapack_biome_sync.rs"
|
||||
|
||||
[[test]]
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
@@ -270,13 +270,13 @@ fn main() -> Result<()> {
|
||||
let row = match recipe {
|
||||
Recipe::Shaped { cells, result, .. } => json!({
|
||||
"result": result.item.as_deref(), "count": result.count,
|
||||
"ingredients": cells.iter().flatten().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
|
||||
"ingredients": cells.iter().flatten().map(|i| sorted(&i.order)).collect::<Vec<_>>()}),
|
||||
Recipe::Shapeless {
|
||||
ingredients,
|
||||
result,
|
||||
} => json!({
|
||||
"result": result.item.as_deref(), "count": result.count,
|
||||
"ingredients": ingredients.iter().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
|
||||
"ingredients": ingredients.iter().map(|i| sorted(&i.order)).collect::<Vec<_>>()}),
|
||||
Recipe::Special(_) => json!({"special": true}),
|
||||
};
|
||||
recipe_rows.insert(name.clone(), row);
|
||||
@@ -285,14 +285,14 @@ fn main() -> Result<()> {
|
||||
recipe_rows.insert(
|
||||
c.id.clone(),
|
||||
json!({"result": &*c.result.item, "count": c.result.count,
|
||||
"ingredients": [sorted(&c.ingredient.1)]}),
|
||||
"ingredients": [sorted(&c.ingredient.order)]}),
|
||||
);
|
||||
}
|
||||
for s in stations.stonecutting.iter().filter(|s| ours(&s.id)) {
|
||||
recipe_rows.insert(
|
||||
s.id.clone(),
|
||||
json!({"result": &*s.result.item, "count": s.result.count,
|
||||
"ingredients": [sorted(&s.input.1)]}),
|
||||
"ingredients": [sorted(&s.input.order)]}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -204,6 +204,7 @@ mod native_components;
|
||||
mod native_criteria;
|
||||
mod native_data_maps;
|
||||
mod native_entities;
|
||||
mod native_entity_refs;
|
||||
mod native_explosions;
|
||||
mod native_falling;
|
||||
mod native_hooks;
|
||||
@@ -2080,6 +2081,21 @@ impl PackResources {
|
||||
light,
|
||||
))
|
||||
}
|
||||
/// The workers' context over `biomes`: this pack's own when `biomes` is
|
||||
/// the table it was built with, else the same parts with that table.
|
||||
fn context_with(&self, biomes: Arc<Biomes>) -> Arc<WorkerContext> {
|
||||
let old = &self.context;
|
||||
if Arc::ptr_eq(&old.biomes, &biomes) {
|
||||
return old.clone();
|
||||
}
|
||||
Arc::new(WorkerContext {
|
||||
assets: old.assets.clone(),
|
||||
render: old.render.clone(),
|
||||
tables: old.tables.clone(),
|
||||
biomes,
|
||||
light: old.light.clone(),
|
||||
})
|
||||
}
|
||||
/// The pack tables over their parts, which `load_resources` builds on
|
||||
/// separate workers.
|
||||
fn assemble(
|
||||
@@ -2279,6 +2295,8 @@ struct Game<'a> {
|
||||
/// The biome table the world plays with: the game's own with the world's
|
||||
/// packs over it (`Biomes::with_packs`), `None` before a world opens.
|
||||
world_biomes: Option<Arc<Biomes>>,
|
||||
/// `world_biomes` as the biome files made it, before `native_biome_colors`.
|
||||
world_biomes_base: Option<Arc<Biomes>>,
|
||||
streaming: Streaming,
|
||||
lod: Option<horizon_client::lod::Lod>,
|
||||
lod_source_attempted: bool,
|
||||
@@ -6442,7 +6460,14 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block && &*stack.item == "minecraft:bow" {
|
||||
if self.creative || self.projectile_slot("minecraft:bow").is_some() {
|
||||
// BowItem.use: ArrowNockEvent, then FAIL without ammunition.
|
||||
let found_projectile = self.get_projectile(&stack).is_some();
|
||||
let has_ammo = found_projectile || self.creative;
|
||||
let player = horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid());
|
||||
let name = self.acting_player_name().to_owned();
|
||||
let (hand, mut cancel) = (self.hand, false);
|
||||
self.native_arrow_nock(player, &name, &stack, hand, has_ammo, &mut cancel);
|
||||
if has_ammo && !cancel {
|
||||
self.start_using_item();
|
||||
}
|
||||
return Ok(true);
|
||||
@@ -7329,6 +7354,7 @@ impl Game<'_> {
|
||||
// What the blocks spawned (a dispensed minecart, a lit TNT, a
|
||||
// placed boat) joins before the entities tick.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.lan_mark_synced();
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
|
||||
@@ -7343,7 +7369,9 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
horizon_client::spans::time("tick_carts", || self.tick_carts());
|
||||
self.tick_vehicle_watch();
|
||||
let from = self.lan_synced_from();
|
||||
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
|
||||
self.lan_synced_since(from);
|
||||
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
|
||||
horizon_client::spans::time("tick_maps", || self.tick_maps());
|
||||
self.tick_lodestone_compasses();
|
||||
@@ -7439,6 +7467,9 @@ impl Game<'_> {
|
||||
self.use_with_hand()?;
|
||||
self.use_delay = 4;
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("lan_note_joined", || self.lan_note_joined());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Keep the held-key state of toggled sneak and sprint in step with their mapping.
|
||||
@@ -9022,6 +9053,10 @@ impl Game<'_> {
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
// `Gui.setScreen` clears the old screen's focus before it goes: the
|
||||
// key that closed the container (Tab bound to the inventory) has
|
||||
// already moved the focus, and it must not outlive the screen.
|
||||
self.pad_clear_focus();
|
||||
self.mod_browser_closed();
|
||||
self.reset_inventory_clicks();
|
||||
self.close_station();
|
||||
@@ -11810,6 +11845,7 @@ impl Game<'_> {
|
||||
// The world's packs over the game's biomes (`Biomes::with_packs`),
|
||||
// the table its chunks were built with.
|
||||
self.world_biomes = self.packed_biomes();
|
||||
self.world_biomes_base = self.world_biomes.clone();
|
||||
horizon_client::crash::set_detail(
|
||||
"World",
|
||||
format!("{} ({})", level.name, save_root.display()),
|
||||
@@ -13411,23 +13447,22 @@ impl Game<'_> {
|
||||
}
|
||||
/// BowItem.releaseUsing: power from charge time, arrows at power * 3.
|
||||
fn release_bow(&mut self, stack: &ItemStack, ticks: u32) {
|
||||
let arrow_slot = self.projectile_slot("minecraft:bow");
|
||||
let has_ammo = arrow_slot.is_some() || self.creative;
|
||||
let ammo = self.get_projectile(stack);
|
||||
let mut charge = i32::try_from(ticks).unwrap_or(i32::MAX);
|
||||
let mut canceled = false;
|
||||
self.native_arrow_loose(stack, has_ammo, &mut charge, &mut canceled);
|
||||
let Some(power) = item_use::bow_release_power(has_ammo, charge, canceled) else {
|
||||
self.native_arrow_loose(stack, ammo.is_some(), &mut charge, &mut canceled);
|
||||
let power = item_use::bow_release_power(ammo.is_some(), charge, canceled);
|
||||
let (Some(power), Some(ammo)) = (power, ammo) else {
|
||||
return;
|
||||
};
|
||||
let arrow_slot = ammo.slot;
|
||||
let weapon = Some(stack.clone());
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
// BowItem.shootProjectile: shootFromRotation(power * 3, inaccuracy 1).
|
||||
let p = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
|
||||
let ammo = arrow_slot
|
||||
.and_then(|i| self.stack_in(i).cloned())
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
|
||||
let weapon = self.held().cloned();
|
||||
let ammo = ammo.stack;
|
||||
let (power_bonus, infinity) = self.bow_enchantments(&ammo.item);
|
||||
let effect = dispenser::tipped_effect(&ammo).or_else(|| {
|
||||
(&*ammo.item == "minecraft:spectral_arrow").then_some(("minecraft:glowing", 200))
|
||||
@@ -13835,8 +13870,8 @@ impl Game<'_> {
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
self.streaming
|
||||
.reload_resources(&mut self.world, self.pack.context.clone());
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
@@ -15829,6 +15864,7 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.pad_clear_focus();
|
||||
self.carried = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
@@ -15941,6 +15977,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.pad_clear_focus();
|
||||
if let Some(c) = self.carried.take() {
|
||||
let _ = self.give(c);
|
||||
}
|
||||
@@ -16274,10 +16311,7 @@ impl Game<'_> {
|
||||
_ => Some(self.streaming.lod_source()),
|
||||
};
|
||||
if let Some(source) = source {
|
||||
self.lod = Some(horizon_client::lod::Lod::new(
|
||||
source,
|
||||
self.pack.context.clone(),
|
||||
));
|
||||
self.lod = Some(horizon_client::lod::Lod::new(source, self.worker_context()));
|
||||
self.lod_observe.extend(self.world.chunks.keys().copied());
|
||||
}
|
||||
}
|
||||
@@ -17369,6 +17403,25 @@ impl Game<'_> {
|
||||
self.title_action(event_loop, action)?;
|
||||
self.reopen_data_packs(event_loop)?;
|
||||
}
|
||||
// AbstractSelectionList.mouseClicked: the world list takes a
|
||||
// click of any button, so the middle one selects its row
|
||||
// without joining (only the left click's play icon or its
|
||||
// click on the selected row does that).
|
||||
WindowEvent::MouseInput {
|
||||
state: ElementState::Pressed,
|
||||
button: MouseButton::Middle,
|
||||
..
|
||||
} if self.title.is_some() && self.screen != Screen::Options => {
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let b = HudBatch::new(size);
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.select_world_at(self.cursor.0, self.cursor.1, (b.width, b.height));
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.title.is_some() => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.book.is_some() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
@@ -18250,6 +18303,7 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
Ok(Game {
|
||||
world_biomes: None,
|
||||
world_biomes_base: None,
|
||||
res: res.clone(),
|
||||
rest_loader: None,
|
||||
pending_entry: None,
|
||||
@@ -19055,6 +19109,14 @@ impl Game<'_> {
|
||||
);
|
||||
(!Arc::ptr_eq(&table, self.res.biomes())).then_some(table)
|
||||
}
|
||||
/// What the chunk and far-terrain workers mesh with: the pack's context,
|
||||
/// over the world's biomes when it has its own.
|
||||
fn worker_context(&self) -> Arc<WorkerContext> {
|
||||
match &self.world_biomes {
|
||||
Some(table) => self.pack.context_with(table.clone()),
|
||||
None => self.pack.context.clone(),
|
||||
}
|
||||
}
|
||||
fn pack_files(&self) -> horizon_world::loot::PackOverlay<'_> {
|
||||
pack_files(&self.world_tables)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -495,6 +495,30 @@ impl Host for super::Game<'_> {
|
||||
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
|
||||
self.native_block_collision(pos)
|
||||
}
|
||||
fn entity(&self, id: u32) -> Option<EntityRef> {
|
||||
self.native_entity(id)
|
||||
}
|
||||
fn level(&self, dimension: &str) -> Option<LevelRef> {
|
||||
self.native_level(dimension)
|
||||
}
|
||||
fn entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
self.native_entity_position(entity)
|
||||
}
|
||||
fn entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
|
||||
self.native_entity_block_position(entity)
|
||||
}
|
||||
fn entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
|
||||
self.native_entity_rotation(entity)
|
||||
}
|
||||
fn entity_kind(&self, entity: EntityRef) -> Option<String> {
|
||||
self.native_entity_kind(entity)
|
||||
}
|
||||
fn entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
|
||||
self.native_entity_uuid(entity)
|
||||
}
|
||||
fn entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
|
||||
self.native_entity_level(entity)
|
||||
}
|
||||
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
|
||||
self.native_item_container_slots(stack)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2500,6 +2500,25 @@ impl Game<'_> {
|
||||
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
|
||||
mod_api::dispatch_camera_shake(self, mod_api::mods(), partial, shake);
|
||||
}
|
||||
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
|
||||
pub(super) fn native_render_name_tag(
|
||||
&self,
|
||||
entity_id: u32,
|
||||
kind: &str,
|
||||
vanilla_visible: bool,
|
||||
visible: &mut bool,
|
||||
text: &mut Option<String>,
|
||||
) {
|
||||
mod_api::dispatch_render_name_tag(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
entity_id,
|
||||
kind,
|
||||
vanilla_visible,
|
||||
visible,
|
||||
text,
|
||||
);
|
||||
}
|
||||
/// Before one first-person hand and its displayed stack are drawn (RenderHandEvent).
|
||||
/// Equip progress is the inverse arm height, including the item's swap animation scale.
|
||||
/// Cancel skips that hand's default geometry, including its map and arm geometry.
|
||||
@@ -5096,6 +5115,58 @@ impl Game<'_> {
|
||||
mod_api::dispatch_entity_shoot_bow(self, mod_api::mods(), player, name, projectile, force);
|
||||
}
|
||||
|
||||
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
|
||||
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
|
||||
/// or the player is in creative. Set `cancel` to fail the use.
|
||||
/// @API:arrow_nock@.
|
||||
pub(super) fn native_arrow_nock(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
bow: &ItemStack,
|
||||
hand: Hand,
|
||||
has_ammo: bool,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_arrow_nock(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
bow,
|
||||
hand,
|
||||
has_ammo,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
|
||||
/// `LivingEntity.getProjectile(weapon)` (NeoForge's
|
||||
/// `LivingGetProjectileEvent`) for a player (`player` and `name`) or a
|
||||
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
|
||||
/// load of a pillager or piglin. `projectile` is the vanilla
|
||||
/// ammunition; replace it to choose another. A replaced stack is not taken
|
||||
/// from the inventory. @API:living_get_projectile@.
|
||||
pub(super) fn native_living_get_projectile(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
name: &str,
|
||||
entity: Option<u32>,
|
||||
kind: &str,
|
||||
weapon: &ItemStack,
|
||||
projectile: &mut Option<ItemStack>,
|
||||
) {
|
||||
mod_api::dispatch_living_get_projectile(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
entity,
|
||||
kind,
|
||||
weapon,
|
||||
projectile,
|
||||
);
|
||||
}
|
||||
|
||||
/// A guest's fall flying state changed on the server. The change has
|
||||
/// already been applied. 222.
|
||||
pub(super) fn native_entity_toggle_glide(&mut self, player: u128, name: &str, gliding: bool) {
|
||||
@@ -6073,7 +6144,7 @@ impl Game<'_> {
|
||||
/// release; `native_entity_shoot_bow` reports a spawned arrow afterward
|
||||
/// with read-only force. Fires on the Game running the bow release, including
|
||||
/// LAN guests; a remote client's own release path is not covered. Crossbows
|
||||
/// and `ArrowNockEvent` are not covered. 318.
|
||||
/// are not covered; `native_arrow_nock` is the bow's start. 318.
|
||||
pub(super) fn native_arrow_loose(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
@@ -6180,6 +6251,31 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_explosion_detonate(self, mod_api::mods(), dimension, center, blocks);
|
||||
}
|
||||
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
|
||||
/// `entity` (None for a player, whom `native_acting_player` names):
|
||||
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
|
||||
/// what is added to the entity's velocity; a player's client is sent the
|
||||
/// same. Wind bursts too, except dragons and end crystals. End crystals
|
||||
/// are also omitted from ordinary blasts: Horizon removes them without a
|
||||
/// velocity to change. Server-side. @API:explosion_knockback@.
|
||||
pub(super) fn native_explosion_knockback(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
center: [f64; 3],
|
||||
blocks: &[[i32; 3]],
|
||||
entity: Option<u32>,
|
||||
knockback_velocity: &mut [f64; 3],
|
||||
) {
|
||||
mod_api::dispatch_explosion_knockback(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
dimension,
|
||||
center,
|
||||
blocks,
|
||||
entity,
|
||||
knockback_velocity,
|
||||
);
|
||||
}
|
||||
/// A player fills (`stack` an empty bucket) or empties a bucket at `pos`:
|
||||
/// `BucketItem.use`, where it asks `Level.mayInteract`. Set `canceled`
|
||||
/// and the bucket does nothing. Server-side, for every player of the
|
||||
@@ -8468,7 +8564,7 @@ impl Game<'_> {
|
||||
};
|
||||
let items = self.items();
|
||||
let known: Vec<String> = Ingredient::parse(&json, items)?
|
||||
.1
|
||||
.order
|
||||
.into_iter()
|
||||
.filter(|item| items.get(item).is_some())
|
||||
.collect();
|
||||
|
||||
@@ -63,6 +63,12 @@ impl NativeLifecycle {
|
||||
.is_some_and(|a| a.session == OpenState::Ready && a.context() == *context)
|
||||
}
|
||||
|
||||
/// The epoch of the world open now, from the start of its open to the end
|
||||
/// of its close: what a mod's entity and level handles carry.
|
||||
pub(super) fn epoch(&self) -> Option<NativeWorldEpoch> {
|
||||
self.attempt.map(|a| NativeWorldEpoch::new(a.epoch))
|
||||
}
|
||||
|
||||
/// The open starts: a fresh process-wide epoch, never reused.
|
||||
pub(super) fn begin_open(&mut self, authority: NativeAuthority) {
|
||||
let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
@@ -55,7 +55,8 @@ impl Index {
|
||||
self.stack(&s.item, s.count, components(s))
|
||||
}
|
||||
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
|
||||
i.1.iter()
|
||||
i.order
|
||||
.iter()
|
||||
.filter_map(|id| self.stack(id, 1, String::new()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,11 @@ fn flat(ing: &Value, items: &horizon_client::items::Items) -> Option<Value> {
|
||||
.and_then(|v| horizon_client::crafting::Ingredient::parse(v, items))
|
||||
})?;
|
||||
Some(Value::Array(
|
||||
parsed.1.iter().map(|i| Value::String(i.clone())).collect(),
|
||||
parsed
|
||||
.order
|
||||
.iter()
|
||||
.map(|i| Value::String(i.clone()))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -3546,6 +3546,7 @@ pub(crate) mod tests {
|
||||
living_fall: |_, _, _, _, _| {},
|
||||
living_jump: |_, _| {},
|
||||
ingredient: |_, _, _| {},
|
||||
ingredient_test: |_, _, _, _| {},
|
||||
});
|
||||
assert!(installed.is_ok());
|
||||
let kind = Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Stacked {
|
||||
let mut max = 0;
|
||||
for (key, &count) in self.keys.iter().zip(&self.amounts) {
|
||||
if count > max {
|
||||
if ing.0.contains(&**key) {
|
||||
if ing.items.contains(&**key) {
|
||||
max = count;
|
||||
}
|
||||
if max >= min {
|
||||
@@ -146,14 +146,16 @@ impl<'a> Picker<'a> {
|
||||
let items: Vec<usize> = (0..stacked.keys.len())
|
||||
.filter(|&i| {
|
||||
stacked.amounts[i] > 0
|
||||
&& ingredients.iter().any(|g| g.0.contains(&*stacked.keys[i]))
|
||||
&& ingredients
|
||||
.iter()
|
||||
.any(|g| g.items.contains(&*stacked.keys[i]))
|
||||
})
|
||||
.collect();
|
||||
let (ni, nt) = (ingredients.len(), items.len());
|
||||
let mut connection = vec![false; ni * nt];
|
||||
for (g, ing) in ingredients.iter().enumerate() {
|
||||
for (t, &key) in items.iter().enumerate() {
|
||||
if ing.0.contains(&*stacked.keys[key]) {
|
||||
if ing.items.contains(&*stacked.keys[key]) {
|
||||
connection[t * ni + g] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2346,9 +2346,12 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
let from = [m.position.x, m.position.y, m.position.z];
|
||||
let source = Damage::mob(m.id, from);
|
||||
let first_event = cx.events.len();
|
||||
// `CaveSpider`, `Husk` and `WitherSkeleton.doHurtTarget` all test
|
||||
// `target instanceof LivingEntity`, so the effect reaches a mob the hit
|
||||
// lands on and not only the player.
|
||||
match m.species() {
|
||||
// CaveSpider.doHurtTarget: poison for 7 s on normal, 15 s on hard.
|
||||
Species::CaveSpider if who == Who::Player && cx.w.difficulty.base >= 2 => {
|
||||
Species::CaveSpider if cx.w.difficulty.base >= 2 => {
|
||||
cx.events.push(MobEvent::EffectPlayer {
|
||||
effect: "minecraft:poison",
|
||||
ticks: if cx.w.difficulty.base == 2 { 7 } else { 15 } * 20,
|
||||
@@ -2357,7 +2360,7 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
}
|
||||
// Husk.doHurtTarget: hunger for 140 ticks per whole point of the
|
||||
// local difficulty, only with an empty main hand.
|
||||
Species::Husk if who == Who::Player && m.extra.main_hand.is_none() => {
|
||||
Species::Husk if m.extra.main_hand.is_none() => {
|
||||
let ticks = 140 * cx.w.difficulty.effective as i32;
|
||||
if ticks > 0 {
|
||||
cx.events.push(MobEvent::EffectPlayer {
|
||||
@@ -2367,7 +2370,7 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
})
|
||||
}
|
||||
}
|
||||
Species::WitherSkeleton if who == Who::Player => cx.events.push(MobEvent::EffectPlayer {
|
||||
Species::WitherSkeleton => cx.events.push(MobEvent::EffectPlayer {
|
||||
effect: "minecraft:wither",
|
||||
ticks: 200,
|
||||
amplifier: 0,
|
||||
@@ -2415,33 +2418,45 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
seconds: 2,
|
||||
});
|
||||
}
|
||||
// The effect picked above goes with the hit: it applies only when the
|
||||
// target takes damage (a shield-blocked hit gives none), for a mob as for
|
||||
// the player, since those `doHurtTarget`s take any `LivingEntity`.
|
||||
let effect = cx.events[first_event..]
|
||||
.iter()
|
||||
.position(|e| matches!(e, MobEvent::EffectPlayer { .. }))
|
||||
.and_then(|k| match cx.events.remove(first_event + k) {
|
||||
MobEvent::EffectPlayer {
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
} => Some((effect, ticks, amplifier)),
|
||||
_ => None,
|
||||
});
|
||||
match who {
|
||||
// The effect picked above goes with the hit: it applies only when
|
||||
// the player takes damage (a shield-blocked hit gives none).
|
||||
Who::Player => {
|
||||
let effect = cx.events[first_event..]
|
||||
.iter()
|
||||
.position(|e| matches!(e, MobEvent::EffectPlayer { .. }))
|
||||
.map(|k| cx.events.remove(first_event + k));
|
||||
cx.events.push(match effect {
|
||||
Some(MobEvent::EffectPlayer {
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
}) => MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
from,
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
},
|
||||
_ => MobEvent::AttackPlayer(amount, from),
|
||||
});
|
||||
}
|
||||
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
|
||||
target: id,
|
||||
amount,
|
||||
source,
|
||||
Who::Player => cx.events.push(match effect {
|
||||
Some((effect, ticks, amplifier)) => MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
from,
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
},
|
||||
None => MobEvent::AttackPlayer(amount, from),
|
||||
}),
|
||||
Who::Mob(id) => cx.events.push(match effect {
|
||||
Some((effect, ticks, amplifier)) => MobEvent::AttackMobWithEffect {
|
||||
target: id,
|
||||
amount,
|
||||
source,
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
},
|
||||
None => MobEvent::AttackMob {
|
||||
target: id,
|
||||
amount,
|
||||
source,
|
||||
},
|
||||
}),
|
||||
}
|
||||
// Mob.doHurtTarget ends with playAttackSound once the hit landed.
|
||||
@@ -2978,6 +2993,126 @@ mod poison_effect_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hit_effect_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The effects of a cave spider's bite, a bare-handed husk's blow and a
|
||||
/// wither skeleton's hit go to the `LivingEntity` the hit landed on:
|
||||
/// `doHurtTarget` tests `target instanceof LivingEntity`, not `Player`.
|
||||
/// Biting a mob carries the effect with the hit — poison for 140 ticks on
|
||||
/// normal, hunger for 140 (effective difficulty 1.5), wither for 200 —
|
||||
/// where the mob is hit 2. Without the fix each of these is a plain
|
||||
/// `AttackMob` and the mob keeps no effect at all (0 ticks).
|
||||
#[test]
|
||||
fn a_melee_effect_rides_the_hit_onto_a_mob() {
|
||||
let cases = [
|
||||
("minecraft:cave_spider", "minecraft:poison", 140),
|
||||
("minecraft:husk", "minecraft:hunger", 140),
|
||||
("minecraft:wither_skeleton", "minecraft:wither", 200),
|
||||
];
|
||||
let mut carried: Vec<(&str, Option<(u32, &'static str, i32, i32)>)> = Vec::new();
|
||||
let mut strays: Vec<(&str, bool, bool)> = Vec::new();
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
for (kind, _, _) in cases {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(types.types[kind].clone()),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
let (mut lr, mut events) = (horizon_core::random::LegacyRandom::new(0), Vec::new());
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
do_hurt_target(&mut m, Who::Mob(2), &mut cx);
|
||||
carried.push((
|
||||
kind,
|
||||
events.iter().find_map(|e| match e {
|
||||
MobEvent::AttackMobWithEffect {
|
||||
target,
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
..
|
||||
} => Some((*target, *effect, *ticks, *amplifier)),
|
||||
_ => None,
|
||||
}),
|
||||
));
|
||||
strays.push((
|
||||
kind,
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::EffectPlayer { .. })),
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::AttackMob { .. })),
|
||||
));
|
||||
}
|
||||
});
|
||||
if !ran {
|
||||
return;
|
||||
}
|
||||
let expected: Vec<(&str, Option<(u32, &'static str, i32, i32)>)> = cases
|
||||
.iter()
|
||||
.map(|(kind, effect, ticks)| (*kind, Some((2, *effect, *ticks, 0))))
|
||||
.collect();
|
||||
assert_eq!(carried, expected);
|
||||
for (kind, on_player, effectless) in strays {
|
||||
assert!(!on_player, "{kind} left its effect on the player");
|
||||
assert!(!effectless, "{kind} sent a second, effectless hit");
|
||||
}
|
||||
}
|
||||
|
||||
/// On easy the cave spider's poison timer is 0, so nothing rides the hit:
|
||||
/// the mob it bites takes the damage alone (`AttackMob`, no effect).
|
||||
#[test]
|
||||
fn an_easy_cave_spiders_bite_carries_no_effect() {
|
||||
let mut out: Option<Vec<String>> = None;
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
let mut easy = w.clone();
|
||||
easy.difficulty = super::super::spawning::LocalDifficulty::new(1, 18000, 0, 0.0);
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(types.types["minecraft:cave_spider"].clone()),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
let (mut lr, mut events) = (horizon_core::random::LegacyRandom::new(0), Vec::new());
|
||||
let mut cx = Cx {
|
||||
w: &easy,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
do_hurt_target(&mut m, Who::Mob(2), &mut cx);
|
||||
out = Some(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::AttackMob { .. } | MobEvent::AttackMobWithEffect { .. }
|
||||
)
|
||||
})
|
||||
.map(|e| match e {
|
||||
MobEvent::AttackMob { target, .. } => format!("plain {target}"),
|
||||
MobEvent::AttackMobWithEffect { effect, .. } => format!("effect {effect}"),
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
});
|
||||
if !ran {
|
||||
return;
|
||||
}
|
||||
assert_eq!(out, Some(vec!["plain 2".to_owned()]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod same_vehicle_push_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -123,6 +123,40 @@ pub struct Poses {
|
||||
skipped: FxHashSet<u64>,
|
||||
/// Parts with `visible` cleared: neither they nor their children draw.
|
||||
hidden: FxHashSet<u64>,
|
||||
/// Names this content: a fresh number at each change, kept by copies
|
||||
/// ([`Poses::version`]).
|
||||
version: u64,
|
||||
}
|
||||
/// The next [`Poses::version`].
|
||||
static NEXT_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
thread_local! {
|
||||
/// Spare `Poses` for [`Poses::pooled`].
|
||||
static POOL: std::cell::RefCell<Vec<Poses>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
/// A [`Poses`] from this thread's pool, given back when dropped.
|
||||
pub struct PooledPoses(Poses);
|
||||
impl std::ops::Deref for PooledPoses {
|
||||
type Target = Poses;
|
||||
fn deref(&self) -> &Poses {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl std::ops::DerefMut for PooledPoses {
|
||||
fn deref_mut(&mut self) -> &mut Poses {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl Drop for PooledPoses {
|
||||
fn drop(&mut self) {
|
||||
let poses = std::mem::take(&mut self.0);
|
||||
// A few per thread: a mob's body and layers, a rider's.
|
||||
let _ = POOL.try_with(|pool| {
|
||||
let mut pool = pool.borrow_mut();
|
||||
if pool.len() < 8 {
|
||||
pool.push(poses);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Poses {
|
||||
pub fn new(model: &Part) -> Self {
|
||||
@@ -130,6 +164,35 @@ impl Poses {
|
||||
poses.reset(model);
|
||||
poses
|
||||
}
|
||||
/// Spare `Poses` of an earlier draw on this thread, to fill with `reset` or
|
||||
/// `copy_from` before use: a mob farm poses every mob each frame, and fresh
|
||||
/// maps grow (and rehash) part by part.
|
||||
pub fn pooled() -> PooledPoses {
|
||||
PooledPoses(
|
||||
POOL.try_with(|pool| pool.borrow_mut().pop())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
/// Names the content: two `Poses` with the same version pose every part
|
||||
/// alike (one is a copy of the other, neither changed since). A draw of
|
||||
/// the same model under the same matrix with the same version can reuse
|
||||
/// the first's part matrices (`skin::Skin::draw`).
|
||||
pub fn version(&self) -> u64 {
|
||||
self.version
|
||||
}
|
||||
fn changed(&mut self) {
|
||||
self.version = NEXT_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Makes this a copy of `other`, keeping this one's capacity.
|
||||
pub fn copy_from(&mut self, other: &Poses) {
|
||||
self.map.clone_from(&other.map);
|
||||
self.dummy = other.dummy;
|
||||
self.skipped.clone_from(&other.skipped);
|
||||
self.hidden.clone_from(&other.hidden);
|
||||
self.version = other.version;
|
||||
}
|
||||
/// Refills this for another model, keeping the map and the sets' capacity:
|
||||
/// a mob draws its body and every equipment layer each frame.
|
||||
pub fn reset(&mut self, model: &Part) {
|
||||
@@ -145,8 +208,10 @@ impl Poses {
|
||||
self.skipped.clear();
|
||||
self.hidden.clear();
|
||||
walk(model, &mut self.map);
|
||||
self.changed();
|
||||
}
|
||||
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
self.changed();
|
||||
match self.map.get_mut(&key(name)) {
|
||||
Some(p) => p,
|
||||
None => &mut self.dummy,
|
||||
@@ -156,7 +221,9 @@ impl Poses {
|
||||
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
|
||||
}
|
||||
pub fn skip_draw(&mut self, name: &str) {
|
||||
self.skipped.insert(key(name));
|
||||
if self.skipped.insert(key(name)) {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
pub fn skips(&self, part: &Part) -> bool {
|
||||
self.skips_named(&part.name)
|
||||
@@ -179,10 +246,13 @@ impl Poses {
|
||||
}
|
||||
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
|
||||
pub fn visible(&mut self, n: &str, v: bool) {
|
||||
if v {
|
||||
self.hidden.remove(&key(n));
|
||||
} else if self.map.contains_key(&key(n)) {
|
||||
self.hidden.insert(key(n));
|
||||
let changed = if v {
|
||||
self.hidden.remove(&key(n))
|
||||
} else {
|
||||
self.map.contains_key(&key(n)) && self.hidden.insert(key(n))
|
||||
};
|
||||
if changed {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
pub fn is_hidden(&self, part: &Part) -> bool {
|
||||
@@ -967,17 +1037,27 @@ pub fn setup(
|
||||
let swing = |o: f32| -((pos * 2.0 + o).mcos() * 0.4) * sp;
|
||||
let step = |o: f32| ((pos + o).msin() * 0.4).abs() * sp;
|
||||
let legs = [
|
||||
("hind", 0.0f32, 0.0f32),
|
||||
("middle_hind", PI, PI),
|
||||
("middle_front", PI / 2.0, PI / 2.0),
|
||||
("front", PI * 3.0 / 2.0, PI * 3.0 / 2.0),
|
||||
("right_hind_leg", "left_hind_leg", 0.0f32, 0.0f32),
|
||||
("right_middle_hind_leg", "left_middle_hind_leg", PI, PI),
|
||||
(
|
||||
"right_middle_front_leg",
|
||||
"left_middle_front_leg",
|
||||
PI / 2.0,
|
||||
PI / 2.0,
|
||||
),
|
||||
(
|
||||
"right_front_leg",
|
||||
"left_front_leg",
|
||||
PI * 3.0 / 2.0,
|
||||
PI * 3.0 / 2.0,
|
||||
),
|
||||
];
|
||||
for (n, so, to) in legs {
|
||||
for (right, left, so, to) in legs {
|
||||
let (sw, st) = (swing(so), step(to));
|
||||
p.get(&format!("right_{n}_leg"))[4] += sw;
|
||||
p.get(&format!("left_{n}_leg"))[4] -= sw;
|
||||
p.get(&format!("right_{n}_leg"))[5] += st;
|
||||
p.get(&format!("left_{n}_leg"))[5] -= st;
|
||||
p.get(right)[4] += sw;
|
||||
p.get(left)[4] -= sw;
|
||||
p.get(right)[5] += st;
|
||||
p.get(left)[5] -= st;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -1033,6 +1113,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
player_model_visibility(&mut p, 0, true);
|
||||
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
|
||||
@@ -1060,6 +1141,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
let arm = if right { "right_arm" } else { "left_arm" };
|
||||
p.xrot("head", pitch);
|
||||
@@ -1099,6 +1181,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
let arm = if right { "right_arm" } else { "left_arm" };
|
||||
p.xrot("head", pitch);
|
||||
@@ -1152,6 +1235,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
enderman::anim(&seated, &state, &mut poses);
|
||||
let right = *poses.get("right_leg");
|
||||
@@ -1207,6 +1291,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
let mut ground = make();
|
||||
let mut flight = make();
|
||||
@@ -1248,6 +1333,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
let same_angle = |a: f32, b: f32| ((a - b + PI).rem_euclid(PI * 2.0) - PI).abs() < 1e-5;
|
||||
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
|
||||
@@ -1354,6 +1440,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
let mut m = player();
|
||||
m.extra.main_hand = Some("minecraft:shield".into());
|
||||
@@ -1375,6 +1462,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
setup(&m, &state, &mut p, &HashMap::new());
|
||||
assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6);
|
||||
@@ -1403,6 +1491,7 @@ mod flight_pose_tests {
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
version: 0,
|
||||
};
|
||||
let mut m = player();
|
||||
m.extra.main_hand = Some("minecraft:brush".into());
|
||||
|
||||
@@ -591,7 +591,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
model,
|
||||
layers: HashMap::new(),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Camel,
|
||||
|
||||
@@ -863,6 +863,11 @@ fn age_boundary_reached(m: &mut Mob) {
|
||||
Species::Goat => set_base(m, Attr::AttackDamage, if baby { 1.0 } else { 2.0 }),
|
||||
Species::Hoglin => set_base(m, Attr::AttackDamage, if baby { 0.5 } else { 6.0 }),
|
||||
Species::SulfurCube if !baby => sulfur_size(m, 2),
|
||||
// HappyGhast.ageBoundaryReached: adultGhastSetup or babyGhastSetup,
|
||||
// then Animal's no-op. A hatched ghastling needs it before it is in
|
||||
// the level (DriedGhastBlock.spawnGhastling sets the baby age before
|
||||
// addFreshEntity), or its first tick flies the adult goals.
|
||||
Species::HappyGhast => super::happy_ghast::ensure_setup(m),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2155,9 +2155,11 @@ impl Goal for Breed {
|
||||
return false;
|
||||
}
|
||||
let species = m.species();
|
||||
// Wolf.canMate: both tame and standing.
|
||||
// Wolf.canMate and Cat.canMate: both tame. The sitting pose sits on the
|
||||
// partner side only, and only for a wolf (`wolf.isInSittingPose()`), so
|
||||
// a tamed mob ordered to sit still walks off to breed.
|
||||
let tamable = matches!(species, Species::Wolf | Species::Cat);
|
||||
if tamable && (!m.extra.tame || m.extra.sitting) {
|
||||
if tamable && !m.extra.tame {
|
||||
return false;
|
||||
}
|
||||
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
|
||||
|
||||
@@ -667,4 +667,40 @@ mod tests {
|
||||
fn travel_speed_is_five_thirds_of_the_flying_speed() {
|
||||
assert!((f64::from(AIR_SPEED) - FLYING_SPEED * 5.0 / 3.0).abs() < 1e-7);
|
||||
}
|
||||
|
||||
/// `HappyGhast.ageBoundaryReached` runs the setup at the age crossing, not
|
||||
/// at the next tick: `DriedGhastBlock.spawnGhastling` makes its ghastling a
|
||||
/// baby before `addFreshEntity`, and `AgeableMob.setAge` calls
|
||||
/// `ageBoundaryReached` there. Without it the hatched ghastling joins the
|
||||
/// level with the adult's three goals, its ground navigation and its brain
|
||||
/// stopped, and only its first `aiStep` (`ensure_setup`) fixes that.
|
||||
#[test]
|
||||
fn a_hatched_ghastling_is_set_up_as_one_at_the_age_crossing() {
|
||||
use crate::mobs::Species;
|
||||
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
t.species = Species::HappyGhast;
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(t),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
// `Mob::new` registers the adult's goals (HappyGhastFloat, its tempt
|
||||
// goal and FloatAround) for the age-zero ghast it makes.
|
||||
assert_eq!(m.extra.scratch[0], 2, "an adult's setup at construction");
|
||||
assert!(m.ai.goals.has("HappyGhastFloatGoal"));
|
||||
|
||||
crate::mobs::finalize::set_age(&mut m, -24000);
|
||||
|
||||
assert!(m.is_baby());
|
||||
assert_eq!(m.extra.scratch[0], 1, "babyGhastSetup ran");
|
||||
assert!(
|
||||
!m.ai.goals.has("HappyGhastFloatGoal")
|
||||
&& !m.ai.goals.has("RandomFloatAroundGoal")
|
||||
&& !m.ai.goals.has("ForNonPathfinders"),
|
||||
"a ghastling has no goals: its brain drives it"
|
||||
);
|
||||
assert_eq!(m.ai.nav.kind, crate::mobs::nav::NavKind::Flying);
|
||||
assert!(m.ai.nav.can_float);
|
||||
}
|
||||
}
|
||||
@@ -331,9 +331,9 @@ pub fn creeper_client_tick(m: &mut Mob) {
|
||||
if m.extra.flag {
|
||||
m.extra.counter = 1;
|
||||
}
|
||||
m.swell = (m.swell + m.extra.counter).clamp(0, 30);
|
||||
m.swell = (m.swell + m.extra.counter).max(0).min(m.max_swell);
|
||||
}
|
||||
/// Creeper.tick: advance the fuse and explode at 30.
|
||||
/// Creeper.tick: advance the fuse and explode at maxSwell.
|
||||
pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if !m.alive() {
|
||||
return;
|
||||
@@ -352,8 +352,8 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
));
|
||||
}
|
||||
m.swell = (m.swell + dir).max(0);
|
||||
if m.swell >= 30 {
|
||||
m.swell = 30;
|
||||
if m.swell >= m.max_swell {
|
||||
m.swell = m.max_swell;
|
||||
let radius = if m.extra.charged { 6.0 } else { 3.0 };
|
||||
let name = match &m.custom_name {
|
||||
Some(name) => crate::text::Text::literal(name.clone()),
|
||||
|
||||
@@ -564,14 +564,21 @@ impl Goal for CrossbowAttack {
|
||||
m.extra.using_item = 0;
|
||||
m.extra.flag = false;
|
||||
self.state = Crossbow::Charged;
|
||||
let weapon = m
|
||||
.extra
|
||||
.main_hand_stack()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:crossbow", 1));
|
||||
set_crossbow_charged(m, true);
|
||||
// The attack delay is drawn before the sound's pitch, as before.
|
||||
self.attack_delay = 20 + m.random.next_int_bound(20).unwrap_or(0);
|
||||
cx.events.push(MobEvent::Sound(
|
||||
"item.crossbow.loading_end",
|
||||
m.center(),
|
||||
1.0,
|
||||
1.0 / (m.random.next_float() * 0.5 + 1.0) + 0.2,
|
||||
));
|
||||
let pitch = 1.0 / (m.random.next_float() * 0.5 + 1.0) + 0.2;
|
||||
cx.events.push(MobEvent::CrossbowLoad {
|
||||
mob: m.id,
|
||||
at: m.center(),
|
||||
pitch,
|
||||
weapon,
|
||||
});
|
||||
}
|
||||
}
|
||||
Crossbow::Charged => {
|
||||
@@ -596,6 +603,15 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
let Some(t) = cx.w.target_info(who) else {
|
||||
return;
|
||||
};
|
||||
// CrossbowItem.shootAll: only the charged projectile flies.
|
||||
let Some(ammo) = m
|
||||
.extra
|
||||
.main_hand_stack()
|
||||
.and_then(|s| s.data())
|
||||
.and_then(|d| d.charged.first().cloned())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let start = Vec3::new(m.position.x, m.eye_y() - 0.1, m.position.z);
|
||||
let dx = t.position.x - m.position.x;
|
||||
let dz = t.position.z - m.position.z;
|
||||
@@ -620,7 +636,7 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
1.6,
|
||||
(14 - i32::from(cx.w.difficulty.id) * 4) as f32,
|
||||
);
|
||||
cx.events.push(MobEvent::Projectile(p));
|
||||
cx.events.push(MobEvent::CrossbowShot { ammo, arrow: p });
|
||||
let pitch = 1.0 / (m.random.next_float() * 0.5 + 1.8) + 0.53;
|
||||
cx.events.push(MobEvent::Sound(
|
||||
"item.crossbow.shoot",
|
||||
|
||||
@@ -1011,7 +1011,7 @@ impl Mob {
|
||||
// Creeper.causeFallDamage: the fall swells the fuse, up to five ticks
|
||||
// short of exploding.
|
||||
if self.species() == super::Species::Creeper {
|
||||
self.swell = (self.swell + (distance * 1.5) as i32).min(30 - 5);
|
||||
self.swell = (self.swell + (distance * 1.5) as i32).min(self.max_swell - 5);
|
||||
}
|
||||
// AbstractHorse.causeFallDamage (llamas override it): a landing of more
|
||||
// than a block thuds, and the horse's fall makes no generic fall sound.
|
||||
|
||||
@@ -503,7 +503,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
model,
|
||||
layers: HashMap::new(),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Rabbit,
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{
|
||||
};
|
||||
use crate::mesher::Vertex;
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use std::borrow::Cow;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// A texture lookup into the shared atlas (name without namespace -> uv rect).
|
||||
@@ -84,7 +85,7 @@ fn translucent_layer(m: &Mob, layer: &Layer) -> bool {
|
||||
/// normal, at `EYES_TIER`) on a mob whose other draws are plain: spiders',
|
||||
/// endermen's, phantoms', the creaking's and the copper golem's. The GPU path
|
||||
/// poses them as [`super::skin::Skin`] `FLAT` draws when improved transparency
|
||||
/// is off ([`super::skin::SkinModels::eyes`]).
|
||||
/// is off ([`super::skin::SkinModels::tagged`]).
|
||||
fn eyes_layer(m: &Mob, layer: &Layer) -> bool {
|
||||
layer.full_bright
|
||||
&& layer.scroll == 0.0
|
||||
@@ -101,8 +102,8 @@ fn eyes_layer(m: &Mob, layer: &Layer) -> bool {
|
||||
|
||||
/// One draw of a model with a texture.
|
||||
pub struct Layer {
|
||||
pub model: String,
|
||||
pub texture: String,
|
||||
pub model: Cow<'static, str>,
|
||||
pub texture: Cow<'static, str>,
|
||||
pub color: u32,
|
||||
pub full_bright: bool,
|
||||
/// `RenderTypes.breezeWind`: the texture scrolls with the entity's age.
|
||||
@@ -197,13 +198,44 @@ pub const PROFESSIONS: [&str; 15] = [
|
||||
"toolsmith",
|
||||
"weaponsmith",
|
||||
];
|
||||
fn baby_or(m: &Mob, adult: &str, baby: &str) -> String {
|
||||
if m.is_baby() && m.kind.layer(baby).is_some() {
|
||||
baby.to_owned()
|
||||
fn baby_or<'a>(
|
||||
m: &Mob,
|
||||
adult: impl Into<Cow<'a, str>>,
|
||||
baby: impl Into<Cow<'a, str>>,
|
||||
) -> Cow<'a, str> {
|
||||
let baby = baby.into();
|
||||
if m.is_baby() && m.kind.layer(&baby).is_some() {
|
||||
baby
|
||||
} else {
|
||||
adult.to_owned()
|
||||
adult.into()
|
||||
}
|
||||
}
|
||||
/// A name built from parts (`entity/villager/profession/farmer`), kept for the
|
||||
/// process so that drawing a mob allocates no names each frame: they come from
|
||||
/// a few tables (types, professions, levels, materials, variants). Past 4096
|
||||
/// names on a thread (an unknown item's material from a server) a new one comes
|
||||
/// back owned.
|
||||
fn interned(args: std::fmt::Arguments) -> Cow<'static, str> {
|
||||
use std::fmt::Write;
|
||||
thread_local! {
|
||||
static NAMES: std::cell::RefCell<(String, crate::fast_hash::FxHashSet<&'static str>)> =
|
||||
std::cell::RefCell::default();
|
||||
}
|
||||
NAMES.with(|names| {
|
||||
let (buffer, set) = &mut *names.borrow_mut();
|
||||
buffer.clear();
|
||||
let _ = buffer.write_fmt(args);
|
||||
if let Some(name) = set.get(buffer.as_str()) {
|
||||
return Cow::Borrowed(*name);
|
||||
}
|
||||
if set.len() >= 4096 {
|
||||
return Cow::Owned(buffer.clone());
|
||||
}
|
||||
let name: &'static str = Box::leak(buffer.as_str().into());
|
||||
set.insert(name);
|
||||
Cow::Borrowed(name)
|
||||
})
|
||||
}
|
||||
fn climate(m: &Mob) -> &'static str {
|
||||
match m.extra.variant {
|
||||
1 => "warm",
|
||||
@@ -223,21 +255,21 @@ pub fn layers_shown(m: &Mob) -> bool {
|
||||
!(avatar(m) && m.extra.spectator)
|
||||
}
|
||||
/// The model layer key and texture for the mob's body.
|
||||
pub fn body(m: &Mob) -> (String, String) {
|
||||
pub fn body(m: &Mob) -> (Cow<'static, str>, Cow<'_, str>) {
|
||||
use Species as S;
|
||||
let baby = m.is_baby();
|
||||
let b = |s: &str| {
|
||||
let b = |s: &str| -> Cow<'static, str> {
|
||||
if baby {
|
||||
format!("{s}_baby")
|
||||
interned(format_args!("{s}_baby"))
|
||||
} else {
|
||||
s.to_owned()
|
||||
interned(format_args!("{s}"))
|
||||
}
|
||||
};
|
||||
if avatar(m) {
|
||||
// AvatarRenderer: PlayerModel's slim layer for a slim skin.
|
||||
return (
|
||||
if m.slim { "player_slim#main" } else { "main" }.into(),
|
||||
m.skin.clone().unwrap_or_else(|| m.kind.texture.clone()),
|
||||
m.skin.as_deref().unwrap_or(&m.kind.texture).into(),
|
||||
);
|
||||
}
|
||||
match m.species() {
|
||||
@@ -248,24 +280,32 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
"trader_llama"
|
||||
};
|
||||
(
|
||||
baby_or(m, &format!("{name}#main"), &format!("{name}_baby#main")),
|
||||
super::llama::texture(m),
|
||||
baby_or(
|
||||
m,
|
||||
interned(format_args!("{name}#main")),
|
||||
interned(format_args!("{name}_baby#main")),
|
||||
),
|
||||
super::llama::texture(m).into(),
|
||||
)
|
||||
}
|
||||
s if super::equine::is_equine(s) => {
|
||||
let name = super::equine::model_name(s);
|
||||
(
|
||||
baby_or(m, &format!("{name}#main"), &format!("{name}_baby#main")),
|
||||
super::equine::texture(m),
|
||||
baby_or(
|
||||
m,
|
||||
interned(format_args!("{name}#main")),
|
||||
interned(format_args!("{name}_baby#main")),
|
||||
),
|
||||
super::equine::texture(m).into(),
|
||||
)
|
||||
}
|
||||
S::Wolf => (
|
||||
baby_or(m, "wolf#main", "wolf_baby#main"),
|
||||
super::tame::texture(m),
|
||||
super::tame::texture(m).into(),
|
||||
),
|
||||
S::Panda => (
|
||||
baby_or(m, "panda#main", "panda_baby#main"),
|
||||
super::panda::texture(m),
|
||||
super::panda::texture(m).into(),
|
||||
),
|
||||
S::HappyGhast => (
|
||||
baby_or(m, "happy_ghast#main", "happy_ghast_baby#main"),
|
||||
@@ -277,9 +317,12 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
),
|
||||
S::Nautilus | S::ZombieNautilus => (
|
||||
super::nautilus::model(m).into(),
|
||||
super::nautilus::texture(m),
|
||||
super::nautilus::texture(m).into(),
|
||||
),
|
||||
S::SulfurCube => super::sulfur_cube::body(m),
|
||||
S::SulfurCube => {
|
||||
let (model, texture) = super::sulfur_cube::body(m);
|
||||
(model.into(), texture.into())
|
||||
}
|
||||
S::Sniffer => (
|
||||
baby_or(m, "sniffer#main", "sniffer_baby#main"),
|
||||
if baby {
|
||||
@@ -288,7 +331,7 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
"entity/sniffer/sniffer".into()
|
||||
},
|
||||
),
|
||||
S::Shulker => ("shulker#main".into(), super::shulker::texture(m)),
|
||||
S::Shulker => ("shulker#main".into(), super::shulker::texture(m).into()),
|
||||
S::Vex => ("vex#main".into(), super::vex::texture(m).into()),
|
||||
S::Wither => ("wither#main".into(), super::wither::texture(m).into()),
|
||||
S::Ghast => (
|
||||
@@ -301,11 +344,11 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
),
|
||||
S::Cat => (
|
||||
baby_or(m, "cat#main", "cat_baby#main"),
|
||||
super::tame::cat_texture(m),
|
||||
super::tame::cat_texture(m).into(),
|
||||
),
|
||||
S::Frog => (
|
||||
"frog#main".into(),
|
||||
format!("entity/frog/frog_{}", climate(m)),
|
||||
interned(format_args!("entity/frog/frog_{}", climate(m))),
|
||||
),
|
||||
// RabbitRenderer.getTextureLocation: the Toast name tag, else the
|
||||
// variant (the killer bunny's id is 99).
|
||||
@@ -325,14 +368,17 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
};
|
||||
(
|
||||
baby_or(m, "rabbit#main", "rabbit_baby#main"),
|
||||
b(&format!("entity/rabbit/rabbit_{name}")),
|
||||
b(&interned(format_args!("entity/rabbit/rabbit_{name}"))),
|
||||
)
|
||||
}
|
||||
// ParrotRenderer.getVariantTexture.
|
||||
S::Parrot => {
|
||||
let name = ["red_blue", "blue", "green", "yellow_blue", "grey"]
|
||||
[usize::from(m.extra.variant).min(4)];
|
||||
(m.kind.model_key(), format!("entity/parrot/parrot_{name}"))
|
||||
(
|
||||
m.kind.model_name().into(),
|
||||
interned(format_args!("entity/parrot/parrot_{name}")),
|
||||
)
|
||||
}
|
||||
// AxolotlRenderer.getTextureLocation.
|
||||
S::Axolotl => {
|
||||
@@ -340,7 +386,7 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
["lucy", "wild", "gold", "cyan", "blue"][usize::from(m.extra.variant).min(4)];
|
||||
(
|
||||
baby_or(m, "axolotl#main", "axolotl_baby#main"),
|
||||
b(&format!("entity/axolotl/axolotl_{name}")),
|
||||
b(&interned(format_args!("entity/axolotl/axolotl_{name}"))),
|
||||
)
|
||||
}
|
||||
// SalmonRenderer.submit: the model of the salmon's size.
|
||||
@@ -377,7 +423,7 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
// FoxRenderer.getTextureLocation: snow variant, sleeping, baby.
|
||||
S::Fox => (
|
||||
baby_or(m, "fox#main", "fox_baby#main"),
|
||||
format!(
|
||||
interned(format_args!(
|
||||
"entity/fox/fox{}{}{}",
|
||||
if m.extra.variant == 0 { "" } else { "_snow" },
|
||||
if super::fox::flag(m, super::fox::SLEEPING) {
|
||||
@@ -386,7 +432,7 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
""
|
||||
},
|
||||
if baby { "_baby" } else { "" },
|
||||
),
|
||||
)),
|
||||
),
|
||||
S::Pig => {
|
||||
let model = if baby {
|
||||
@@ -396,7 +442,10 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
} else {
|
||||
"pig#main".into()
|
||||
};
|
||||
(model, b(&format!("entity/pig/pig_{}", climate(m))))
|
||||
(
|
||||
model,
|
||||
b(&interned(format_args!("entity/pig/pig_{}", climate(m)))),
|
||||
)
|
||||
}
|
||||
S::Cow => {
|
||||
let model = match (m.extra.variant, baby) {
|
||||
@@ -407,7 +456,10 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
(_, true) => "cow_baby#main",
|
||||
_ => "cow#main",
|
||||
};
|
||||
(model.into(), b(&format!("entity/cow/cow_{}", climate(m))))
|
||||
(
|
||||
model.into(),
|
||||
b(&interned(format_args!("entity/cow/cow_{}", climate(m)))),
|
||||
)
|
||||
}
|
||||
S::Mooshroom => (
|
||||
baby_or(m, "mooshroom#main", "mooshroom_baby#main"),
|
||||
@@ -427,7 +479,10 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
};
|
||||
(
|
||||
model.into(),
|
||||
b(&format!("entity/chicken/chicken_{}", climate(m))),
|
||||
b(&interned(format_args!(
|
||||
"entity/chicken/chicken_{}",
|
||||
climate(m)
|
||||
))),
|
||||
)
|
||||
}
|
||||
S::Sheep => (
|
||||
@@ -492,7 +547,7 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
let texture = if layer.starts_with("bee_baby") {
|
||||
b(texture)
|
||||
} else {
|
||||
texture.to_owned()
|
||||
texture.into()
|
||||
};
|
||||
(layer, texture)
|
||||
}
|
||||
@@ -530,8 +585,11 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
}
|
||||
}
|
||||
// CopperGolemOxidationLevels: the texture of the weathering.
|
||||
S::CopperGolem => (m.kind.model_key(), super::copper_golem::texture(m)),
|
||||
_ => (m.kind.model_key(), m.kind.texture.clone()),
|
||||
S::CopperGolem => (
|
||||
m.kind.model_name().into(),
|
||||
super::copper_golem::texture(m).into(),
|
||||
),
|
||||
_ => (m.kind.model_name().into(), m.kind.texture.as_str().into()),
|
||||
}
|
||||
}
|
||||
/// LivingEntityRenderer.getModelTint (tropical fish base colour).
|
||||
@@ -555,7 +613,13 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
return v;
|
||||
}
|
||||
let white = 0xFFFF_FFFF;
|
||||
let push = |v: &mut Vec<Layer>, model: &str, texture: &str, color: u32, full_bright: bool| {
|
||||
fn push(
|
||||
v: &mut Vec<Layer>,
|
||||
model: impl Into<Cow<'static, str>>,
|
||||
texture: impl Into<Cow<'static, str>>,
|
||||
color: u32,
|
||||
full_bright: bool,
|
||||
) {
|
||||
v.push(Layer {
|
||||
model: model.into(),
|
||||
texture: texture.into(),
|
||||
@@ -563,7 +627,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
full_bright,
|
||||
scroll: 0.0,
|
||||
})
|
||||
};
|
||||
}
|
||||
match m.species() {
|
||||
// WolfArmorLayer, then WolfCollarLayer.
|
||||
S::Wolf if m.extra.tame => {
|
||||
@@ -590,7 +654,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
push(
|
||||
&mut v,
|
||||
"wolf_armor#main",
|
||||
&format!("entity/wolf/wolf_armor_crackiness_{level}"),
|
||||
interned(format_args!("entity/wolf/wolf_armor_crackiness_{level}")),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
@@ -614,7 +678,13 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
if !m.is_baby()
|
||||
&& let Some(texture) = &harness
|
||||
{
|
||||
push(&mut v, "happy_ghast_harness#main", texture, white, false);
|
||||
push(
|
||||
&mut v,
|
||||
"happy_ghast_harness#main",
|
||||
texture.clone(),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
}
|
||||
// RopesLayer: the ropes over a harnessed body while the ghast
|
||||
// holds a quad lead (`isLeashHolder`), the baby's model for a baby.
|
||||
@@ -655,24 +725,24 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
// LlamaDecorLayer: the carpet, or the trader's blanket.
|
||||
S::Llama | S::TraderLlama => {
|
||||
if let Some((model, texture)) = super::llama::decor(m) {
|
||||
push(&mut v, &model, &texture, white, false);
|
||||
push(&mut v, model, texture, white, false);
|
||||
}
|
||||
}
|
||||
// HorseMarkingLayer, the saddle and the horse armour.
|
||||
s if super::equine::is_equine(s) => {
|
||||
let name = super::equine::model_name(s);
|
||||
let body = if m.is_baby() {
|
||||
format!("{name}_baby#main")
|
||||
interned(format_args!("{name}_baby#main"))
|
||||
} else {
|
||||
format!("{name}#main")
|
||||
interned(format_args!("{name}#main"))
|
||||
};
|
||||
if let Some(tex) = super::equine::markings(m) {
|
||||
push(&mut v, &body, &tex, white, false);
|
||||
push(&mut v, body, tex, white, false);
|
||||
}
|
||||
if m.extra.saddled && !m.is_baby() {
|
||||
push(
|
||||
&mut v,
|
||||
&format!("{name}#saddle"),
|
||||
interned(format_args!("{name}#saddle")),
|
||||
super::equine::saddle_texture(s),
|
||||
white,
|
||||
false,
|
||||
@@ -683,7 +753,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
.strip_prefix("minecraft:")
|
||||
.and_then(|a| a.strip_suffix("_horse_armor"))
|
||||
{
|
||||
let tex = format!("entity/equipment/horse_body/{material}");
|
||||
let tex = interned(format_args!("entity/equipment/horse_body/{material}"));
|
||||
let color = if material == "leather" {
|
||||
abgr(0xA06540)
|
||||
} else {
|
||||
@@ -695,7 +765,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
} else {
|
||||
"horse_armor#main"
|
||||
};
|
||||
push(&mut v, layer, &tex, color, false);
|
||||
push(&mut v, layer, tex, color, false);
|
||||
}
|
||||
}
|
||||
// SimpleEquipmentLayer: pig and strider saddles.
|
||||
@@ -742,7 +812,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
.as_deref()
|
||||
.and_then(super::nautilus::armor_texture)
|
||||
{
|
||||
push(&mut v, "nautilus_armor#main", &texture, white, false);
|
||||
push(&mut v, "nautilus_armor#main", texture, white, false);
|
||||
}
|
||||
if m.extra.saddled {
|
||||
push(
|
||||
@@ -803,11 +873,11 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
// from ModelLayers.COPPER_GOLEM, the body's, not COPPER_GOLEM_EYES.
|
||||
S::CopperGolem => {
|
||||
let eyes = super::copper_golem::eye_texture(m);
|
||||
push(&mut v, &m.kind.model_key(), &eyes, white, true);
|
||||
push(&mut v, m.kind.model_name(), eyes, white, true);
|
||||
}
|
||||
S::Spider | S::CaveSpider => push(
|
||||
&mut v,
|
||||
&m.kind.model_key(),
|
||||
m.kind.model_name(),
|
||||
"entity/spider/spider_eyes",
|
||||
white,
|
||||
true,
|
||||
@@ -869,7 +939,7 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
push(
|
||||
&mut v,
|
||||
model,
|
||||
&format!("entity/fish/tropical_{letter}_pattern_{n}"),
|
||||
interned(format_args!("entity/fish/tropical_{letter}_pattern_{n}")),
|
||||
abgr(DYE[m.extra.variant3 as usize & 15]),
|
||||
false,
|
||||
);
|
||||
@@ -881,9 +951,9 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
"zombie_villager"
|
||||
};
|
||||
let model = if m.is_baby() {
|
||||
format!("{base}_baby#main")
|
||||
interned(format_args!("{base}_baby#main"))
|
||||
} else {
|
||||
format!("{base}#main")
|
||||
interned(format_args!("{base}#main"))
|
||||
};
|
||||
let t = VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()];
|
||||
let folder = if m.species() == S::Villager {
|
||||
@@ -895,8 +965,8 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
let group = if m.is_baby() { "baby" } else { "type" };
|
||||
push(
|
||||
&mut v,
|
||||
&model,
|
||||
&format!("{folder}/{group}/{t}"),
|
||||
model.clone(),
|
||||
interned(format_args!("{folder}/{group}/{t}")),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
@@ -904,8 +974,8 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
if !m.is_baby() && prof != "none" {
|
||||
push(
|
||||
&mut v,
|
||||
&model,
|
||||
&format!("{folder}/profession/{prof}"),
|
||||
model.clone(),
|
||||
interned(format_args!("{folder}/profession/{prof}")),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
@@ -915,8 +985,8 @@ pub fn layers(m: &Mob, age: f32) -> Vec<Layer> {
|
||||
[(m.extra.variant3.max(1) as usize - 1).min(4)];
|
||||
push(
|
||||
&mut v,
|
||||
&model,
|
||||
&format!("{folder}/profession_level/{level}"),
|
||||
model,
|
||||
interned(format_args!("{folder}/profession_level/{level}")),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
@@ -935,6 +1005,10 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
if super::equine::is_equine(m.species()) || avatar(m) {
|
||||
return;
|
||||
}
|
||||
let e = &m.extra;
|
||||
if e.head.is_none() && e.chest.is_none() && e.legs.is_none() && e.feet.is_none() {
|
||||
return;
|
||||
}
|
||||
let (model, _) = body(m);
|
||||
let Some(base) = model.strip_suffix("#main") else {
|
||||
return;
|
||||
@@ -949,7 +1023,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
let Some(name) = item.as_deref().and_then(|i| i.strip_prefix("minecraft:")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(material) = name.strip_suffix(&format!("_{layer}")) else {
|
||||
let Some(material) = name.strip_suffix(layer).and_then(|n| n.strip_suffix('_')) else {
|
||||
continue;
|
||||
};
|
||||
let asset = match material {
|
||||
@@ -962,7 +1036,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
let model = format!("{base}#{layer}");
|
||||
let model = interned(format_args!("{base}#{layer}"));
|
||||
// EquipmentLayerRenderer.getColorForLayer: the leather layer takes
|
||||
// the stack's dye colour, the colour it has when undyed otherwise.
|
||||
let tint = if asset == "leather" {
|
||||
@@ -972,7 +1046,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
};
|
||||
v.push(Layer {
|
||||
model: model.clone(),
|
||||
texture: format!("entity/equipment/{folder}/{asset}"),
|
||||
texture: interned(format_args!("entity/equipment/{folder}/{asset}")),
|
||||
color: tint,
|
||||
full_bright: false,
|
||||
scroll: 0.0,
|
||||
@@ -980,7 +1054,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
if asset == "leather" {
|
||||
v.push(Layer {
|
||||
model,
|
||||
texture: format!("entity/equipment/{folder}/leather_overlay"),
|
||||
texture: interned(format_args!("entity/equipment/{folder}/leather_overlay")),
|
||||
color: 0xFFFF_FFFF,
|
||||
full_bright: false,
|
||||
scroll: 0.0,
|
||||
@@ -991,7 +1065,11 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
impl super::MobType {
|
||||
/// The main model layer key without the namespace.
|
||||
pub fn model_key(&self) -> String {
|
||||
super::species::def(self.species).model.to_owned()
|
||||
self.model_name().to_owned()
|
||||
}
|
||||
/// `model_key` without the copy.
|
||||
pub fn model_name(&self) -> &'static str {
|
||||
super::species::def(self.species).model
|
||||
}
|
||||
}
|
||||
/// The mob's root transform (LivingEntityRenderer.submit + setupRotations).
|
||||
@@ -1193,7 +1271,8 @@ fn species_rotations(m: &Mob, alpha: f32) -> glam::Mat4 {
|
||||
}
|
||||
/// Creeper.getSwelling: the fuse's progress, past 1 in its last two ticks.
|
||||
fn creeper_swelling(m: &Mob, alpha: f32) -> f32 {
|
||||
(m.previous_swell as f32 + alpha * (m.swell - m.previous_swell) as f32) / 28.0
|
||||
(m.previous_swell as f32 + alpha * (m.swell - m.previous_swell) as f32)
|
||||
/ (m.max_swell - 2) as f32
|
||||
}
|
||||
/// LivingEntityRenderer.getOverlayCoords for the body: the hurt red, or a
|
||||
/// swelling creeper's white flash. Layers take the red only (progress 0).
|
||||
@@ -1765,7 +1844,7 @@ pub fn drawn_textures(m: &Mob, data: &MobData) -> Vec<String> {
|
||||
let (model_key, texture) = body(m);
|
||||
let cem_key = cem_key(&model_key);
|
||||
let mut names = Vec::new();
|
||||
push(&mut names, cem.layer(cem_key), texture);
|
||||
push(&mut names, cem.layer(cem_key), texture.into_owned());
|
||||
let mut all_layers = layers(m, m.age as f32);
|
||||
if m.species() == Species::Warden {
|
||||
for (model, texture, _) in super::warden::emissive_layers(m, 0.0) {
|
||||
@@ -1778,12 +1857,12 @@ pub fn drawn_textures(m: &Mob, data: &MobData) -> Vec<String> {
|
||||
});
|
||||
}
|
||||
}
|
||||
let body_keys = [model_key.as_str(), cem_key];
|
||||
let body_keys = [&*model_key, cem_key];
|
||||
for (i, layer) in all_layers.iter().enumerate() {
|
||||
let custom = cem
|
||||
.layer(&layer.model)
|
||||
.filter(|_| paints_with_jem(&all_layers, i, body_keys));
|
||||
push(&mut names, custom, layer.texture.clone());
|
||||
push(&mut names, custom, layer.texture.to_string());
|
||||
}
|
||||
names
|
||||
}
|
||||
@@ -1805,7 +1884,7 @@ fn cem_texture<'a>(
|
||||
/// (`body`: eyes, collars, a villager's type and profession), and keep their own textures
|
||||
/// over the new geometry.
|
||||
fn paints_with_jem(layers: &[Layer], i: usize, body: [&str; 2]) -> bool {
|
||||
let key = layers[i].model.as_str();
|
||||
let key = &*layers[i].model;
|
||||
!body.contains(&key) && layers[..i].iter().all(|l| l.model != key)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1886,7 +1965,8 @@ fn render_visible(
|
||||
}
|
||||
atlas(emissive.overlay(drawn)?)
|
||||
};
|
||||
let mut poses = Poses::new(&model);
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(&model);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
if let Some(animation) = body_animation {
|
||||
animation.run(m, &s, &mut poses);
|
||||
@@ -1907,9 +1987,9 @@ fn render_visible(
|
||||
}
|
||||
}
|
||||
// The GPU path takes the whole mob or none of it, so its draws keep their
|
||||
// order: only plain opaque and cutout draws of models it can pose (no
|
||||
// custom models, translucency, eyes or energy swirl, which the CPU path
|
||||
// changes after drawing).
|
||||
// order: only plain draws of models it can pose (no custom models,
|
||||
// translucent body or energy swirl, which the CPU path changes after
|
||||
// drawing), and with improved transparency off the tagged layers too.
|
||||
let mut skin = skin;
|
||||
let posable = match skin.as_deref_mut() {
|
||||
Some(skin) => {
|
||||
@@ -1920,7 +2000,9 @@ fn render_visible(
|
||||
&& energy_swirl(m, s.age).is_none()
|
||||
&& skin.supports(&model)
|
||||
&& all_layers.iter().all(|layer| {
|
||||
(skin.models.eyes && eyes_layer(m, layer)
|
||||
// With improved transparency off, a translucent layer's tag
|
||||
// is the only difference from a plain draw.
|
||||
(skin.models.tagged && (eyes_layer(m, layer) || !layer.full_bright)
|
||||
|| !layer.full_bright && !translucent_layer(m, layer))
|
||||
&& cem.layer(&layer.model).is_none()
|
||||
&& m.kind
|
||||
@@ -1989,8 +2071,8 @@ fn render_visible(
|
||||
}
|
||||
// One Poses refilled for every layer (armour draws up to seven): it keeps
|
||||
// its maps' capacity.
|
||||
let mut layer_poses = Poses::default();
|
||||
let body_keys = [model_key.as_str(), cem_key];
|
||||
let mut layer_poses = Poses::pooled();
|
||||
let body_keys = [&*model_key, cem_key];
|
||||
for (i, layer) in all_layers.iter().enumerate() {
|
||||
let custom = cem.layer(&layer.model);
|
||||
// `layer.texture` stays the vanilla name (the villager hats below look for its
|
||||
@@ -2010,10 +2092,16 @@ fn render_visible(
|
||||
let Some((drawn, uv)) = sprite(texture).or_else(|| Some((texture, atlas(texture)?))) else {
|
||||
continue;
|
||||
};
|
||||
layer_poses.reset(&part);
|
||||
anim::setup(m, &s, &mut layer_poses, &data.animations);
|
||||
if let Some(animation) = custom.and_then(|c| c.animation.as_ref()) {
|
||||
animation.run(m, &s, &mut layer_poses);
|
||||
if custom.is_none() && custom_body.is_none() && std::sync::Arc::ptr_eq(&part, &model) {
|
||||
// A layer over the body's own model (a villager's type, profession
|
||||
// and level, a spider's eyes): `setupAnim` gives the body's poses.
|
||||
layer_poses.copy_from(&poses);
|
||||
} else {
|
||||
layer_poses.reset(&part);
|
||||
anim::setup(m, &s, &mut layer_poses, &data.animations);
|
||||
if let Some(animation) = custom.and_then(|c| c.animation.as_ref()) {
|
||||
animation.run(m, &s, &mut layer_poses);
|
||||
}
|
||||
}
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
|
||||
&& let Some((folder, variant)) = layer
|
||||
@@ -2513,6 +2601,26 @@ pub fn held_item_stack(extra: &super::Extra, off_hand: bool) -> Option<crate::it
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn creeper_swelling_uses_local_fuse_and_keeps_java_zero_division() {
|
||||
let mut mob = Mob::new(
|
||||
1,
|
||||
crate::mobs::sensor_scan_tests::kind(),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
mob.previous_swell = 19;
|
||||
mob.swell = 20;
|
||||
assert_eq!(creeper_swelling(&mob, 0.5), 19.5 / 28.0);
|
||||
mob.max_swell = 40;
|
||||
assert_eq!(creeper_swelling(&mob, 0.5), 19.5 / 38.0);
|
||||
mob.max_swell = 2;
|
||||
assert!(creeper_swelling(&mob, 0.5).is_infinite());
|
||||
mob.previous_swell = 0;
|
||||
mob.swell = 0;
|
||||
assert!(creeper_swelling(&mob, 0.5).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offhand_does_not_inherit_mainhand_components() {
|
||||
use crate::items::ItemStack;
|
||||
@@ -2592,7 +2700,7 @@ mod tests {
|
||||
ambient_interval: 80,
|
||||
flags: Flags::default(),
|
||||
model,
|
||||
layers: std::collections::HashMap::new(),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Camel,
|
||||
@@ -3200,7 +3308,7 @@ mod scroll_tests {
|
||||
layers(m, 0.0)
|
||||
.into_iter()
|
||||
.filter(|l| l.model.contains("#wool"))
|
||||
.map(|l| (l.model, l.texture, l.color))
|
||||
.map(|l| (l.model.into_owned(), l.texture.into_owned(), l.color))
|
||||
.collect()
|
||||
};
|
||||
let dyed = abgr(sheep_wool_rgb(1));
|
||||
|
||||
@@ -44,10 +44,11 @@ pub struct SkinModels {
|
||||
/// Item models, the same way (`item_render::ItemRenderState::submit_skinned`).
|
||||
items: FxHashMap<usize, (Arc<BakedItemModel>, Option<StoredItem>)>,
|
||||
vertices: u32,
|
||||
/// Whether the eyes render type's layers (spiders', endermen's...) are posed
|
||||
/// here too, as [`SkinJob::FLAT`] draws (`gfx::Renderer::skins_eyes`); set
|
||||
/// each frame.
|
||||
pub eyes: bool,
|
||||
/// Whether the layers improved transparency would draw in its stages are
|
||||
/// posed here too (`gfx::Renderer::skins_tagged`; set each frame): the eyes
|
||||
/// render type's (spiders', endermen's...) as [`SkinJob::FLAT`] draws, and
|
||||
/// `entityTranslucent` layers (a horse's markings, a slime's outer cube).
|
||||
pub tagged: bool,
|
||||
}
|
||||
|
||||
/// A stored item model: its quads' vertices in order, by runs of quads that
|
||||
@@ -288,9 +289,29 @@ pub struct Skin<'a> {
|
||||
pub frame: &'a mut SkinFrame,
|
||||
/// Camera-relative bounds of what this mob drew here (for culling).
|
||||
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
/// The last walk, for a draw that repeats it.
|
||||
last: Option<Walked>,
|
||||
}
|
||||
|
||||
impl Skin<'_> {
|
||||
/// One walk of [`Skin::draw`]: what it drew, and the jobs it pushed.
|
||||
struct Walked {
|
||||
model: usize,
|
||||
base: glam::Mat4,
|
||||
poses: u64,
|
||||
jobs: std::ops::Range<usize>,
|
||||
}
|
||||
|
||||
impl<'a> Skin<'a> {
|
||||
/// One entity's draws into `frame`.
|
||||
pub fn new(models: &'a mut SkinModels, frame: &'a mut SkinFrame) -> Self {
|
||||
Self {
|
||||
models,
|
||||
frame,
|
||||
bounds: None,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Grows the bounds of what this entity drew here by `lo..hi` posed by `m`.
|
||||
pub fn grow_bounds(&mut self, m: glam::Mat4, (lo, hi): (glam::Vec3, glam::Vec3)) {
|
||||
let center = m.transform_point3((lo + hi) * 0.5);
|
||||
@@ -318,9 +339,30 @@ impl Skin<'_> {
|
||||
poses: &Poses,
|
||||
paint: Paint,
|
||||
) -> bool {
|
||||
let key = Arc::as_ptr(model) as usize;
|
||||
// A draw that repeats the last walk (a villager's type, profession and
|
||||
// level over its body, a spider's eyes): the same model, matrix and
|
||||
// poses give the same part matrices and parts left out, so its jobs
|
||||
// are the last ones' over the same parts, with this paint.
|
||||
if let Some(last) = &self.last
|
||||
&& last.model == key
|
||||
&& last.base == base
|
||||
&& last.poses == poses.version()
|
||||
{
|
||||
for i in last.jobs.clone() {
|
||||
let job = self.frame.jobs[i];
|
||||
self.frame.jobs.push(SkinJob {
|
||||
dst: self.frame.vertices,
|
||||
..paint.job(job.first, job.count, job.part, job.cull)
|
||||
});
|
||||
self.frame.vertices += job.count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let Some(stored) = self.models.entry(model, self.frame) else {
|
||||
return false;
|
||||
};
|
||||
let jobs = self.frame.jobs.len();
|
||||
let first_part = self.frame.parts.len() as u32;
|
||||
self.frame.parts.resize(
|
||||
self.frame.parts.len() + stored.parts.len(),
|
||||
@@ -338,10 +380,40 @@ impl Skin<'_> {
|
||||
index: 0,
|
||||
};
|
||||
walk.part(model, base, ModelNormals::new(base));
|
||||
// The walk's first job never extends an earlier draw's: those have
|
||||
// other parts.
|
||||
self.last = Some(Walked {
|
||||
model: key,
|
||||
base,
|
||||
poses: poses.version(),
|
||||
jobs: jobs..self.frame.jobs.len(),
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
/// A job drawing `count` stored vertices from `first`, posed by the parts
|
||||
/// from `part`, in this paint (`dst` left 0).
|
||||
fn job(self, first: u32, count: u32, part: u32, cull: u32) -> SkinJob {
|
||||
SkinJob {
|
||||
first,
|
||||
count,
|
||||
part,
|
||||
dst: 0,
|
||||
rect: self.uv,
|
||||
scroll: self.scroll,
|
||||
color: self.color,
|
||||
light: self.light,
|
||||
overlay: self.overlay,
|
||||
glint: u32::from(self.overlay == super::render::ARMOR_GLINT),
|
||||
mode: SkinJob::MODEL,
|
||||
cull,
|
||||
spare: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
stored: &'a Stored,
|
||||
poses: &'a Poses,
|
||||
@@ -416,19 +488,13 @@ impl Walk<'_> {
|
||||
job.count += stored.count;
|
||||
} else {
|
||||
self.jobs.push(SkinJob {
|
||||
first,
|
||||
count: stored.count,
|
||||
part: self.first_part,
|
||||
dst: *self.vertices,
|
||||
rect: paint.uv,
|
||||
scroll: paint.scroll,
|
||||
color: paint.color,
|
||||
light: paint.light,
|
||||
overlay: paint.overlay,
|
||||
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
|
||||
mode: SkinJob::MODEL,
|
||||
cull: u32::from(self.stored.cull),
|
||||
spare: 0,
|
||||
..paint.job(
|
||||
first,
|
||||
stored.count,
|
||||
self.first_part,
|
||||
u32::from(self.stored.cull),
|
||||
)
|
||||
});
|
||||
}
|
||||
*self.vertices += stored.count;
|
||||
@@ -446,7 +512,8 @@ mod tests {
|
||||
/// the bit and in the same order, for each mob it takes; the mobs it
|
||||
/// leaves to the CPU draw exactly as before. Twice: with the eyes render
|
||||
/// type's layers left to the CPU (improved transparency on), then posed
|
||||
/// too (`SkinModels::eyes`). Needs the prepared resources.
|
||||
/// too with a horse's markings and a slime's outer cube (`SkinModels::tagged`).
|
||||
/// Needs the prepared resources.
|
||||
#[test]
|
||||
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -487,7 +554,7 @@ mod tests {
|
||||
let (mut posed, mut kept) = (0, 0);
|
||||
let mut ids: Vec<_> = types.types.keys().cloned().collect();
|
||||
ids.sort();
|
||||
// The mobs posed at least once, without and with the eyes layers.
|
||||
// The mobs posed at least once, without and with the tagged layers.
|
||||
let mut taken = [std::collections::BTreeSet::new(), Default::default()];
|
||||
for eyes in [false, true] {
|
||||
for (n, id) in ids.iter().enumerate() {
|
||||
@@ -499,16 +566,25 @@ mod tests {
|
||||
m.age = age;
|
||||
m.limb_speed = 0.6;
|
||||
m.limb_position = age as f32 * 0.37;
|
||||
// A farmer: its type, profession and level layers repeat
|
||||
// the body's draw (`Skin::draw` reuses its walk).
|
||||
if matches!(
|
||||
id.as_str(),
|
||||
"minecraft:villager" | "minecraft:zombie_villager"
|
||||
) {
|
||||
m.extra.variant2 = 5;
|
||||
m.extra.variant3 = 3;
|
||||
}
|
||||
// White field markings (`entityTranslucent`).
|
||||
if id == "minecraft:horse" {
|
||||
m.extra.variant2 = 2;
|
||||
}
|
||||
let mut cpu = Vec::new();
|
||||
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
|
||||
models.begin(&mut frame, store.len() as u32);
|
||||
models.eyes = eyes;
|
||||
models.tagged = eyes;
|
||||
let mut gpu = Vec::new();
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let mut skin = Skin::new(&mut models, &mut frame);
|
||||
render::render_skinned(
|
||||
&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin,
|
||||
);
|
||||
@@ -555,15 +631,17 @@ mod tests {
|
||||
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
|
||||
assert!(posed > 2 * 3 * 40, "only {posed} draws took the GPU path");
|
||||
let added: Vec<_> = taken[1].difference(&taken[0]).copied().collect();
|
||||
eprintln!("posed only with the eyes layers: {added:?}");
|
||||
eprintln!("posed only with the tagged layers: {added:?}");
|
||||
for id in [
|
||||
"minecraft:spider",
|
||||
"minecraft:cave_spider",
|
||||
"minecraft:enderman",
|
||||
"minecraft:phantom",
|
||||
"minecraft:copper_golem",
|
||||
"minecraft:horse",
|
||||
"minecraft:slime",
|
||||
] {
|
||||
assert!(added.contains(&id), "{id} not posed with its eyes");
|
||||
assert!(added.contains(&id), "{id} not posed with its tagged layers");
|
||||
}
|
||||
assert!(taken[0].is_subset(&taken[1]));
|
||||
}
|
||||
@@ -603,11 +681,7 @@ mod tests {
|
||||
let mut frame = SkinFrame::default();
|
||||
for (id, cull) in [("minecraft:bat", 1), ("minecraft:cow", 0)] {
|
||||
models.begin(&mut frame, 0);
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let mut skin = Skin::new(&mut models, &mut frame);
|
||||
assert!(skin.draw(
|
||||
&types.types[id].model,
|
||||
glam::Mat4::IDENTITY,
|
||||
@@ -680,11 +754,7 @@ mod tests {
|
||||
);
|
||||
models.begin(&mut frame, store.len() as u32);
|
||||
let mut gpu = Vec::new();
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let mut skin = Skin::new(&mut models, &mut frame);
|
||||
let paint = (uv, 0.0, 0xff80_c0ff, 0x70a0, overlay);
|
||||
render::put(
|
||||
&mut Some(&mut skin),
|
||||
|
||||
@@ -1874,6 +1874,51 @@ mod tests {
|
||||
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 1.0])));
|
||||
assert!(m.extra.sitting);
|
||||
}
|
||||
|
||||
/// `Cat.canMate` and `Wolf.canMate`: the seeker only has to be tamed, so a
|
||||
/// mob ordered to sit still goes looking for a mate. Only a wolf's
|
||||
/// *partner* is refused while it sits (`wolf.isInSittingPose()`), which is
|
||||
/// the one sitting check vanilla makes.
|
||||
#[test]
|
||||
fn a_sitting_tame_mob_still_looks_for_a_mate() {
|
||||
use crate::mobs::goals::{Breed, Cx, Goal};
|
||||
use crate::mobs::world::with_test_world;
|
||||
let named = |species, name: &str, id: u32, sat: bool| {
|
||||
let mut m = sitting(species, name);
|
||||
m.id = id;
|
||||
m.extra.in_love = 600;
|
||||
m.extra.sitting = sat;
|
||||
m
|
||||
};
|
||||
for (species, name, seeker_sat, partner_sat, breeds) in [
|
||||
// A tamed cat pairs with a tamed cat, sitting or not.
|
||||
(Species::Cat, "minecraft:cat", true, true, true),
|
||||
(Species::Cat, "minecraft:cat", true, false, true),
|
||||
// A wolf pairs unless the wolf it would take is sitting.
|
||||
(Species::Wolf, "minecraft:wolf", true, false, true),
|
||||
(Species::Wolf, "minecraft:wolf", true, true, false),
|
||||
(Species::Wolf, "minecraft:wolf", false, true, false),
|
||||
] {
|
||||
let mut seeker = named(species, name, 1, seeker_sat);
|
||||
let partner = named(species, name, 2, partner_sat);
|
||||
let seen = [partner.seen(None)];
|
||||
with_test_world(&seen, |w, _| {
|
||||
let mut events = Vec::new();
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goal = Breed::new(1.0);
|
||||
assert_eq!(
|
||||
goal.can_use(&mut seeker, &mut cx),
|
||||
breeds,
|
||||
"{name}: seeker sits {seeker_sat}, partner sits {partner_sat}"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
|
||||
|
||||
@@ -905,7 +905,20 @@ pub fn perform_ranged_attack(m: &mut Mob, who: Who, cx: &mut Cx, power: f32) {
|
||||
1.6,
|
||||
(14 - id * 4) as f32,
|
||||
);
|
||||
cx.events.push(MobEvent::Projectile(p));
|
||||
cx.events.push(MobEvent::BowShot {
|
||||
mob: m.id,
|
||||
// ProjectileUtil.getWeaponHoldingHand(this, Items.BOW).
|
||||
weapon: if m.extra.using_off_hand {
|
||||
m.extra
|
||||
.off_hand
|
||||
.as_ref()
|
||||
.map(|item| crate::items::ItemStack::new(item, 1))
|
||||
} else {
|
||||
m.extra.main_hand_stack().cloned()
|
||||
}
|
||||
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:bow", 1)),
|
||||
arrow: p,
|
||||
});
|
||||
let pitch = 1.0 / (m.random.next_float() * 0.4 + 0.8);
|
||||
cx.events.push(MobEvent::Sound(
|
||||
"entity.skeleton.shoot",
|
||||
|
||||
@@ -877,7 +877,7 @@ mod tests {
|
||||
|
||||
fn villager() -> Mob {
|
||||
use crate::mobs::{Anim, Brain, Flags, MobType, Part, Species};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
let kind = Arc::new(MobType {
|
||||
id: "minecraft:villager".into(),
|
||||
species: Species::Villager,
|
||||
@@ -910,7 +910,7 @@ mod tests {
|
||||
ambient_interval: 80,
|
||||
flags: Flags::default(),
|
||||
model: Arc::new(Part::parse("root", &serde_json::json!({}))),
|
||||
layers: HashMap::new(),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Villager,
|
||||
|
||||
@@ -236,11 +236,29 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let _ = powered;
|
||||
}
|
||||
if m.extra.counter > 0 {
|
||||
for _ in 0..3 {
|
||||
m.random.next_gaussian();
|
||||
m.random.next_float();
|
||||
m.random.next_gaussian();
|
||||
}
|
||||
charging_particles(m, events);
|
||||
}
|
||||
}
|
||||
/// `WitherBoss.aiStep`'s entity-effect particles while the wither is still
|
||||
/// charging (`getInvulnerableTicks() > 0`): three a tick in the
|
||||
/// (0.7, 0.7, 0.9) colour, a gaussian away from the body on x and z and up to
|
||||
/// `3.3 * getScale()` above its feet on y, all from the wither's own random.
|
||||
pub fn charging_particles(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if m.extra.counter <= 0 {
|
||||
return;
|
||||
}
|
||||
let spread = 3.3 * super::riders::scale(m);
|
||||
for _ in 0..3 {
|
||||
// `this.random`: a gaussian for x, a float for y, a gaussian for z.
|
||||
let at = [
|
||||
m.position.x + m.random.next_gaussian(),
|
||||
m.position.y + f64::from(m.random.next_float()) * spread,
|
||||
m.position.z + m.random.next_gaussian(),
|
||||
];
|
||||
events.push(MobEvent::ParticleColored {
|
||||
at,
|
||||
color: [0.7, 0.7, 0.9],
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `WitherBoss.customServerAiStep`: charging, then the side heads' targets
|
||||
@@ -474,4 +492,63 @@ mod tests {
|
||||
assert!(!shielded_from(Species::Wither, Species::Creeper));
|
||||
assert!(!shielded_from(Species::Zombie, Species::Wither));
|
||||
}
|
||||
/// A spawning wither rolled the three particles of `aiStep`'s
|
||||
/// `getInvulnerableTicks() > 0` block and dropped them on the floor: the
|
||||
/// potion-coloured (`0.7, 0.7, 0.9`) cloud around it never appeared (#7486).
|
||||
#[test]
|
||||
fn a_charging_wither_sheds_three_potion_coloured_particles_a_tick() {
|
||||
use crate::mobs::Species;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use std::sync::Arc;
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:wither".into();
|
||||
k.species = Species::Wither;
|
||||
let wither = |model_scale: f32| {
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(4.0, 64.0, -2.0), 0.0);
|
||||
m.extra.counter = INVULNERABLE;
|
||||
m.extra.model_scale = model_scale;
|
||||
m
|
||||
};
|
||||
for (model_scale, spread) in [(1.0_f32, 3.3_f64), (2.0, 6.6)] {
|
||||
let mut m = wither(model_scale);
|
||||
// The wither's own random, advanced the way vanilla's `aiStep`
|
||||
// does it, for the expected offsets.
|
||||
let mut probe = m.random.clone();
|
||||
let mut events = Vec::new();
|
||||
charging_particles(&mut m, &mut events);
|
||||
let mut want = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (dx, dy, dz) = (
|
||||
probe.next_gaussian(),
|
||||
f64::from(probe.next_float()) * spread,
|
||||
probe.next_gaussian(),
|
||||
);
|
||||
want.push([4.0 + dx, 64.0 + dy, -2.0 + dz]);
|
||||
}
|
||||
let got: Vec<([f64; 3], [f32; 3])> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
MobEvent::ParticleColored { at, color } => (*at, *color),
|
||||
other => panic!("not an entity-effect particle: {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got.len(), 3, "three a tick, as many as the heads' smoke");
|
||||
for (at, color) in &got {
|
||||
assert_eq!(*color, [0.7, 0.7, 0.9]);
|
||||
// Measured against the body, gaussians on x and z and a float
|
||||
// over [0, 3.3 * getScale()) on y.
|
||||
assert!((0.0..spread).contains(&(at[1] - 64.0)), "{at:?}");
|
||||
}
|
||||
let flat: Vec<[f64; 3]> = got.iter().map(|(at, _)| *at).collect();
|
||||
assert_eq!(flat, want, "scale {model_scale}");
|
||||
}
|
||||
// Once the charge ends (`getInvulnerableTicks` back to 0) the cloud
|
||||
// stops: the block is guarded, not unconditional.
|
||||
let mut m = wither(1.0);
|
||||
m.extra.counter = 0;
|
||||
let mut events = Vec::new();
|
||||
charging_particles(&mut m, &mut events);
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ pub enum NativeAuthority {
|
||||
}
|
||||
|
||||
/// One open attempt of one world; it rejects a previous open's commands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct NativeWorldEpoch(u64);
|
||||
|
||||
impl NativeWorldEpoch {
|
||||
@@ -360,6 +360,58 @@ impl NativeWorldEpoch {
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity of the open world, for the `Host` methods that read or change one
|
||||
/// (`entity.position(host)`). It holds the world's epoch and the entity's id: ids
|
||||
/// are shared by every level and never reused while the world is open, and an
|
||||
/// entity that changes dimension gets a new one, as in vanilla. A handle from an
|
||||
/// earlier open, of an entity that is gone, or used on a client reads as `None`
|
||||
/// and changes nothing. `Host::entity` makes one from an event's entity id.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct EntityRef {
|
||||
epoch: NativeWorldEpoch,
|
||||
id: u32,
|
||||
}
|
||||
|
||||
impl EntityRef {
|
||||
/// Create a handle for the game.
|
||||
#[doc(hidden)]
|
||||
pub fn new(epoch: NativeWorldEpoch, id: u32) -> Self {
|
||||
Self { epoch, id }
|
||||
}
|
||||
/// The entity's id (the network id events carry).
|
||||
pub fn id(self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// The open of the world the handle belongs to.
|
||||
pub fn epoch(self) -> NativeWorldEpoch {
|
||||
self.epoch
|
||||
}
|
||||
}
|
||||
|
||||
/// A level of the open world, by its dimension (`minecraft:the_nether`).
|
||||
/// `Host::level` makes one, and `entity.level(host)` gives an entity's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct LevelRef {
|
||||
epoch: NativeWorldEpoch,
|
||||
dimension: &'static str,
|
||||
}
|
||||
|
||||
impl LevelRef {
|
||||
/// Create a handle for the game.
|
||||
#[doc(hidden)]
|
||||
pub fn new(epoch: NativeWorldEpoch, dimension: &'static str) -> Self {
|
||||
Self { epoch, dimension }
|
||||
}
|
||||
/// The level's dimension (`minecraft:overworld`).
|
||||
pub fn dimension(self) -> &'static str {
|
||||
self.dimension
|
||||
}
|
||||
/// The open of the world the handle belongs to.
|
||||
pub fn epoch(self) -> NativeWorldEpoch {
|
||||
self.epoch
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-paused authoritative server ticks since open (not game or frame time).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct NativeTickId(u64);
|
||||
|
||||
+204
-1
@@ -2477,7 +2477,7 @@ pub trait Hooks: Send + Sync {
|
||||
/// release; `native_entity_shoot_bow` reports a spawned arrow afterward
|
||||
/// with read-only force. Fires on the Game running the bow release, including
|
||||
/// LAN guests; a remote client's own release path is not covered. Crossbows
|
||||
/// and `ArrowNockEvent` are not covered. 318.
|
||||
/// are not covered; `native_arrow_nock` is the bow's start. 318.
|
||||
///
|
||||
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
|
||||
fn arrow_loose(&self, host: &mut dyn Host, event: &mut ArrowLooseEvent<'_>) -> Flow {
|
||||
@@ -4512,12 +4512,45 @@ pub trait Hooks: Send + Sync {
|
||||
fn living_jump(&self, event: &mut LivingJumpEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
|
||||
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
|
||||
/// or the player is in creative. Set `cancel` to fail the use.
|
||||
/// @API:arrow_nock@.
|
||||
///
|
||||
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
|
||||
fn arrow_nock(&self, host: &mut dyn Host, event: &mut ArrowNockEvent<'_>) -> Flow {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
Flow::Continue
|
||||
}
|
||||
/// Player FOV scale, after vanilla effects and before zoom and easing.
|
||||
/// Invalid values or values outside 0.01..=10 revert to base. API @API:compute_fov_modifier@.
|
||||
fn compute_fov_modifier(&self, host: &dyn Host, event: &mut ComputeFovModifierEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
|
||||
/// `entity` (None for a player, whom `native_acting_player` names):
|
||||
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
|
||||
/// what is added to the entity's velocity; a player's client is sent the
|
||||
/// same. Wind bursts too, except dragons and end crystals. End crystals
|
||||
/// are also omitted from ordinary blasts: Horizon removes them without a
|
||||
/// velocity to change. Server-side. @API:explosion_knockback@.
|
||||
///
|
||||
/// Every mod gets `event.knockback_velocity` in order and may add, change or remove knockback_velocity.
|
||||
fn explosion_knockback(&self, host: &mut dyn Host, event: &mut ExplosionKnockbackEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// A mod's own ingredient kind, matching a concrete stack at craft time:
|
||||
/// the ingredient's `test` calls here with the kind, its JSON and the
|
||||
/// stack being matched, `accept` already true (the load-time list named
|
||||
/// the item). Set `accept` to false to refuse the stack — its components
|
||||
/// are the usual reason. The hook may be called many times a craft; the
|
||||
/// answer must be the same for the same stack. API @API:ingredient_test@.
|
||||
fn ingredient_test(&self, event: &mut IngredientTestEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
|
||||
/// `slots` when the item is yours, for a backpack, a storage cell or a
|
||||
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
|
||||
@@ -4534,6 +4567,18 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = event;
|
||||
None
|
||||
}
|
||||
/// `LivingEntity.getProjectile(weapon)` (NeoForge's
|
||||
/// `LivingGetProjectileEvent`) for a player (`player` and `name`) or a
|
||||
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
|
||||
/// load of a pillager or piglin. `projectile` is the vanilla
|
||||
/// ammunition; replace it to choose another. A replaced stack is not taken
|
||||
/// from the inventory. @API:living_get_projectile@.
|
||||
///
|
||||
/// Every mod gets `event.projectile` in order and may add, change or remove projectile.
|
||||
fn living_get_projectile(&self, host: &mut dyn Host, event: &mut LivingGetProjectileEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// Local player movement input, after reading it and before applying it.
|
||||
fn movement_input(&self, host: &dyn Host, event: &mut MovementInputEvent) {
|
||||
let _ = host;
|
||||
@@ -4566,6 +4611,11 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = event;
|
||||
Flow::Continue
|
||||
}
|
||||
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
|
||||
fn render_name_tag(&self, host: &dyn Host, event: &mut RenderNameTagEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// A screen was observed closing, at most once per frame.
|
||||
fn screen_closing(&self, host: &dyn Host, event: &mut ScreenClosingEvent<'_>) {
|
||||
let _ = host;
|
||||
@@ -13874,6 +13924,42 @@ pub fn dispatch_living_jump(mods: &[Box<dyn Hooks>], entity: u32, kind: &str) {
|
||||
mod_hook.living_jump(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::arrow_nock`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ArrowNockEvent<'a> {
|
||||
pub player: u128,
|
||||
pub name: &'a str,
|
||||
pub bow: &'a ItemStack,
|
||||
pub hand: Hand,
|
||||
pub has_ammo: bool,
|
||||
}
|
||||
pub fn dispatch_arrow_nock(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
player: u128,
|
||||
name: &str,
|
||||
bow: &ItemStack,
|
||||
hand: Hand,
|
||||
has_ammo: bool,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
if *cancel {
|
||||
return;
|
||||
}
|
||||
let mut event = ArrowNockEvent {
|
||||
player,
|
||||
name,
|
||||
bow,
|
||||
hand,
|
||||
has_ammo,
|
||||
};
|
||||
for mod_hook in super::entered(mods, "arrow_nock") {
|
||||
if matches!(mod_hook.arrow_nock(host, &mut event), Flow::Cancel) {
|
||||
*cancel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::compute_fov_modifier`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ComputeFovModifierEvent<'a> {
|
||||
@@ -13898,6 +13984,61 @@ pub fn dispatch_compute_fov_modifier(
|
||||
}
|
||||
*modifier = event.modifier;
|
||||
}
|
||||
/// What [`Hooks::explosion_knockback`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ExplosionKnockbackEvent<'a> {
|
||||
pub dimension: &'a str,
|
||||
pub center: [f64; 3],
|
||||
pub blocks: &'a [[i32; 3]],
|
||||
pub entity: Option<u32>,
|
||||
pub knockback_velocity: &'a mut [f64; 3],
|
||||
}
|
||||
pub fn dispatch_explosion_knockback(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
dimension: &str,
|
||||
center: [f64; 3],
|
||||
blocks: &[[i32; 3]],
|
||||
entity: Option<u32>,
|
||||
knockback_velocity: &mut [f64; 3],
|
||||
) {
|
||||
let mut event = ExplosionKnockbackEvent {
|
||||
dimension,
|
||||
center,
|
||||
blocks,
|
||||
entity,
|
||||
knockback_velocity,
|
||||
};
|
||||
for mod_hook in super::entered(mods, "explosion_knockback") {
|
||||
mod_hook.explosion_knockback(host, &mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::ingredient_test`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct IngredientTestEvent<'a> {
|
||||
pub kind: &'a str,
|
||||
pub data: &'a serde_json::Map<String, Value>,
|
||||
pub stack: &'a ItemStack,
|
||||
pub accept: bool,
|
||||
}
|
||||
pub fn dispatch_ingredient_test(
|
||||
mods: &[Box<dyn Hooks>],
|
||||
kind: &str,
|
||||
data: &serde_json::Map<String, Value>,
|
||||
stack: &ItemStack,
|
||||
accept: &mut bool,
|
||||
) {
|
||||
let mut event = IngredientTestEvent {
|
||||
kind,
|
||||
data,
|
||||
stack,
|
||||
accept: *accept,
|
||||
};
|
||||
for mod_hook in super::entered(mods, "ingredient_test") {
|
||||
mod_hook.ingredient_test(&mut event);
|
||||
}
|
||||
*accept = event.accept;
|
||||
}
|
||||
/// What [`Hooks::item_container`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ItemContainerEvent<'a> {
|
||||
@@ -13920,6 +14061,38 @@ pub fn dispatch_item_container(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::living_get_projectile`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct LivingGetProjectileEvent<'a> {
|
||||
pub player: Option<u128>,
|
||||
pub name: &'a str,
|
||||
pub entity: Option<u32>,
|
||||
pub kind: &'a str,
|
||||
pub weapon: &'a ItemStack,
|
||||
pub projectile: &'a mut Option<ItemStack>,
|
||||
}
|
||||
pub fn dispatch_living_get_projectile(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
player: Option<u128>,
|
||||
name: &str,
|
||||
entity: Option<u32>,
|
||||
kind: &str,
|
||||
weapon: &ItemStack,
|
||||
projectile: &mut Option<ItemStack>,
|
||||
) {
|
||||
let mut event = LivingGetProjectileEvent {
|
||||
player,
|
||||
name,
|
||||
entity,
|
||||
kind,
|
||||
weapon,
|
||||
projectile,
|
||||
};
|
||||
for mod_hook in super::entered(mods, "living_get_projectile") {
|
||||
mod_hook.living_get_projectile(host, &mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::movement_input`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct MovementInputEvent {
|
||||
@@ -14055,6 +14228,36 @@ pub fn dispatch_render_item_in_frame(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::render_name_tag`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct RenderNameTagEvent<'a> {
|
||||
pub entity_id: u32,
|
||||
pub kind: &'a str,
|
||||
pub vanilla_visible: bool,
|
||||
pub visible: bool,
|
||||
pub text: &'a mut Option<String>,
|
||||
}
|
||||
pub fn dispatch_render_name_tag(
|
||||
host: &dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
entity_id: u32,
|
||||
kind: &str,
|
||||
vanilla_visible: bool,
|
||||
visible: &mut bool,
|
||||
text: &mut Option<String>,
|
||||
) {
|
||||
let mut event = RenderNameTagEvent {
|
||||
entity_id,
|
||||
kind,
|
||||
vanilla_visible,
|
||||
visible: *visible,
|
||||
text,
|
||||
};
|
||||
for mod_hook in super::entered(mods, "render_name_tag") {
|
||||
mod_hook.render_name_tag(host, &mut event);
|
||||
}
|
||||
*visible = event.visible;
|
||||
}
|
||||
/// What [`Hooks::screen_closing`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ScreenClosingEvent<'a> {
|
||||
|
||||
+83
@@ -1212,6 +1212,57 @@ pub trait Host {
|
||||
let _ = pos;
|
||||
unimplemented!("Host::block_collision")
|
||||
}
|
||||
/// The entity `id` (the id events carry) as a handle for the `entity_*`
|
||||
/// methods: None on a client, with no world open, or for no such entity
|
||||
/// in the level in place or among the server's players. API
|
||||
/// @API:entity_handles@.
|
||||
fn entity(&self, id: u32) -> Option<EntityRef> {
|
||||
let _ = id;
|
||||
unimplemented!("Host::entity")
|
||||
}
|
||||
/// The level `dimension` (`minecraft:the_nether`) as a handle: None on a
|
||||
/// client, with no world open, or for a dimension the server hasn't
|
||||
/// loaded. API @API:entity_handles@.
|
||||
fn level(&self, dimension: &str) -> Option<LevelRef> {
|
||||
let _ = dimension;
|
||||
unimplemented!("Host::level")
|
||||
}
|
||||
/// The entity's position (`Entity.position`): its feet, the middle of
|
||||
/// its box for a painting or an item frame. API @API:entity_handles@.
|
||||
fn entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
let _ = entity;
|
||||
unimplemented!("Host::entity_position")
|
||||
}
|
||||
/// The block the entity's position is in (`Entity.blockPosition`). API
|
||||
/// @API:entity_handles@.
|
||||
fn entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
|
||||
let _ = entity;
|
||||
unimplemented!("Host::entity_block_position")
|
||||
}
|
||||
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
|
||||
/// for the kinds that don't turn. API @API:entity_handles@.
|
||||
fn entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
|
||||
let _ = entity;
|
||||
unimplemented!("Host::entity_rotation")
|
||||
}
|
||||
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
|
||||
/// @API:entity_handles@.
|
||||
fn entity_kind(&self, entity: EntityRef) -> Option<String> {
|
||||
let _ = entity;
|
||||
unimplemented!("Host::entity_kind")
|
||||
}
|
||||
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
|
||||
/// entity that has none yet gets it now, as a new vanilla entity does.
|
||||
/// API @API:entity_handles@.
|
||||
fn entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
|
||||
let _ = entity;
|
||||
unimplemented!("Host::entity_uuid")
|
||||
}
|
||||
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
|
||||
fn entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
|
||||
let _ = entity;
|
||||
unimplemented!("Host::entity_level")
|
||||
}
|
||||
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
|
||||
/// empty ones included, kept in its `minecraft:container`. None for an
|
||||
/// item without slots, and for an empty stack.
|
||||
@@ -1260,3 +1311,35 @@ pub trait Host {
|
||||
unimplemented!("Host::recipe_book_category")
|
||||
}
|
||||
}
|
||||
impl EntityRef {
|
||||
/// The entity's position (`Entity.position`): its feet, the middle of
|
||||
/// its box for a painting or an item frame. API @API:entity_handles@.
|
||||
pub fn position(self, host: &dyn Host) -> Option<[f64; 3]> {
|
||||
host.entity_position(self)
|
||||
}
|
||||
/// The block the entity's position is in (`Entity.blockPosition`). API
|
||||
/// @API:entity_handles@.
|
||||
pub fn block_position(self, host: &dyn Host) -> Option<[i32; 3]> {
|
||||
host.entity_block_position(self)
|
||||
}
|
||||
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
|
||||
/// for the kinds that don't turn. API @API:entity_handles@.
|
||||
pub fn rotation(self, host: &dyn Host) -> Option<[f32; 2]> {
|
||||
host.entity_rotation(self)
|
||||
}
|
||||
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
|
||||
/// @API:entity_handles@.
|
||||
pub fn kind(self, host: &dyn Host) -> Option<String> {
|
||||
host.entity_kind(self)
|
||||
}
|
||||
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
|
||||
/// entity that has none yet gets it now, as a new vanilla entity does.
|
||||
/// API @API:entity_handles@.
|
||||
pub fn uuid(self, host: &mut dyn Host) -> Option<u128> {
|
||||
host.entity_uuid(self)
|
||||
}
|
||||
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
|
||||
pub fn level(self, host: &dyn Host) -> Option<LevelRef> {
|
||||
host.entity_level(self)
|
||||
}
|
||||
}
|
||||
@@ -2059,6 +2059,15 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Entity.move(MoverType.SHULKER_BOX, movement)`: a shulker box lid pushed
|
||||
/// the player. An ordinary move — the collision scan steps and reads the
|
||||
/// ground flag — whose end writes the ground state
|
||||
/// (`Entity.setOnGroundWithMovement`): a lid lifting the body off the
|
||||
/// ground leaves it airborne, and the next tick's gravity lands it on the
|
||||
/// lid again instead of the client holding it on a stale ground.
|
||||
pub fn shulker_box_push(&mut self, movement: Vec3) -> Result<()> {
|
||||
entity_move::move_entity(self, MoverType::ShulkerBox, movement)
|
||||
}
|
||||
/// `LocalPlayer.move`: the move, then `updateAutoJump` with the float
|
||||
/// horizontal displacement.
|
||||
pub fn move_self(&mut self, movement: Vec3) -> Result<()> {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct NativeClientHooks {
|
||||
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
|
||||
pub living_jump: fn(u32, &str),
|
||||
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
|
||||
pub ingredient_test: fn(&str, &Map<String, Value>, &ItemStack, &mut bool),
|
||||
}
|
||||
|
||||
static HOOKS: OnceLock<NativeClientHooks> = OnceLock::new();
|
||||
@@ -94,3 +95,14 @@ pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<
|
||||
(hooks.ingredient)(kind, data, items);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ingredient_test(
|
||||
kind: &str,
|
||||
data: &Map<String, Value>,
|
||||
stack: &ItemStack,
|
||||
accept: &mut bool,
|
||||
) {
|
||||
if let Some(hooks) = HOOKS.get() {
|
||||
(hooks.ingredient_test)(kind, data, stack, accept);
|
||||
}
|
||||
}
|
||||
@@ -475,7 +475,7 @@ impl Book {
|
||||
/// `ingredient`'s items as its display resolves them (the cycling order
|
||||
/// of ghost slots and the overlay).
|
||||
pub fn items_of(&self, ingredient: &Ingredient) -> Vec<String> {
|
||||
ingredient.1.clone()
|
||||
ingredient.order.clone()
|
||||
}
|
||||
/// `RecipeBookComponent.fillGhostRecipe` of a book type: the result and the
|
||||
/// ingredients faded into the menu's slots, in the order the component
|
||||
|
||||
@@ -708,7 +708,7 @@ impl StationRecipes {
|
||||
addition,
|
||||
pattern,
|
||||
..
|
||||
} if i == id => Some((&base.1, &addition.1, pattern)),
|
||||
} if i == id => Some((&base.order, &addition.order, pattern)),
|
||||
_ => None,
|
||||
})?;
|
||||
if base.is_empty() || addition.is_empty() {
|
||||
@@ -1041,13 +1041,13 @@ mod tests {
|
||||
fn smithing_ingredient_parses_holders() {
|
||||
let items = sample_items();
|
||||
let a = ingredient(&serde_json::json!("minecraft:diamond_sword"), &items);
|
||||
assert_eq!(a.1, vec!["minecraft:diamond_sword"]);
|
||||
assert_eq!(a.order, vec!["minecraft:diamond_sword"]);
|
||||
let b = ingredient(
|
||||
&serde_json::json!(["minecraft:diamond_sword", "minecraft:diamond_axe"]),
|
||||
&items,
|
||||
);
|
||||
assert_eq!(
|
||||
b.1,
|
||||
b.order,
|
||||
vec!["minecraft:diamond_sword", "minecraft:diamond_axe"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3275,6 +3275,30 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
let next = create_world::next_tab_index(options.tab, key, shift)?;
|
||||
Some(self.select_create_tab(next.min(create_world::tab_count() - 1)))
|
||||
}
|
||||
/// `AbstractSelectionList.mouseClicked`: the row under the pointer takes
|
||||
/// a click of any button (`ObjectSelectionList.Entry.mouseClicked` accepts
|
||||
/// them all), so a middle click selects a world and its Play, Edit and
|
||||
/// Delete buttons hear of it (`WorldSelectionList.setSelected`), without
|
||||
/// opening it. Only the left button joins, through [`Self::click`].
|
||||
pub fn select_world_at(&mut self, x: f32, y: f32, size: (f32, f32)) -> bool {
|
||||
let index = {
|
||||
let Mode::Select { worlds, .. } = &self.mode else {
|
||||
return false;
|
||||
};
|
||||
let Some((row, _)) = self.world_row_at(worlds, (x, y), size) else {
|
||||
return false;
|
||||
};
|
||||
let Some((index, _)) = self.visible_worlds(worlds).nth(row) else {
|
||||
return false;
|
||||
};
|
||||
index
|
||||
};
|
||||
if let Mode::Select { selected, .. } = &mut self.mode {
|
||||
*selected = Some(index);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The action and whether to play `AbstractWidget.playButtonClickSound`.
|
||||
/// Navigation buttons can return `Action::None` while still sounding.
|
||||
pub fn click(&mut self, x: f32, y: f32, size: (f32, f32)) -> (Action, bool) {
|
||||
|
||||
@@ -719,6 +719,40 @@ fn classic_world_selection_is_silent_until_the_entry_launches() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_clicking_a_world_selects_it_without_joining() {
|
||||
let mut f = Fixture::new();
|
||||
f.world("World", "World");
|
||||
f.title.enter_singleplayer();
|
||||
let size = (480.0, 262.0);
|
||||
let enabled = |f: &Fixture, label: &str| {
|
||||
f.title
|
||||
.buttons(size.0, size.1)
|
||||
.into_iter()
|
||||
.find(|b| b.label == label)
|
||||
.unwrap()
|
||||
.enabled
|
||||
};
|
||||
assert!(!enabled(&f, "Edit"), "nothing is selected yet");
|
||||
assert!(f.title.select_world_at(240.0, 60.0, size));
|
||||
assert!(matches!(
|
||||
f.title.mode,
|
||||
Mode::Select {
|
||||
selected: Some(0),
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
enabled(&f, "Edit"),
|
||||
"the selection callback reaches the footer"
|
||||
);
|
||||
// Again on the selected row: still only a selection, not a join.
|
||||
assert!(f.title.select_world_at(240.0, 60.0, size));
|
||||
assert!(matches!(f.title.mode, Mode::Select { .. }));
|
||||
// Outside the rows (the footer) nothing is selected.
|
||||
assert!(!f.title.select_world_at(240.0, 250.0, size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_open_elsewhere_stays_closed_behind_a_toast() {
|
||||
let mut f = Fixture::new();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! A data-pack biome is in the registry a server sends, with its data, at the
|
||||
//! place the chunk encoder numbers it by, and a client rebuilds it from that
|
||||
//! data.
|
||||
use horizon_client::{
|
||||
biome::{BiomeColors, Biomes},
|
||||
chunk::LevelShapes,
|
||||
lan::data::{BIOME_REGISTRY, SyncData, client_biomes},
|
||||
scoreboard::json_to_tag,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Removes the folder when the test ends, passed or not.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource folder with two biomes and 1x1 colormaps, loaded.
|
||||
fn write_base(root: &std::path::Path) -> Biomes {
|
||||
let maps = root.join("assets/minecraft/textures/colormap");
|
||||
let biome_dir = root.join("data/minecraft/worldgen/biome");
|
||||
std::fs::create_dir_all(&maps).unwrap();
|
||||
std::fs::create_dir_all(&biome_dir).unwrap();
|
||||
std::fs::write(root.join("registries.json"), "{}").unwrap();
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
let file = std::fs::File::create(maps.join(format!("{name}.png"))).unwrap();
|
||||
let mut png = png::Encoder::new(file, 1, 1);
|
||||
png.set_color(png::ColorType::Rgb);
|
||||
png.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = png.write_header().unwrap();
|
||||
writer.write_image_data(&[1, 2, 3]).unwrap();
|
||||
}
|
||||
for name in ["plains", "river"] {
|
||||
std::fs::write(biome_dir.join(format!("{name}.json")), "{}").unwrap();
|
||||
}
|
||||
Biomes::load(root).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_biome_is_sent_with_its_data_and_read_back_by_a_client() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sync-biomes-{}", std::process::id()));
|
||||
let _scratch = Scratch(root.clone());
|
||||
let base = write_base(&root);
|
||||
let pack: Value = serde_json::from_str(r##"{"effects":{"grass_color":"#123456"}}"##).unwrap();
|
||||
let world = base.with_packs([("minecraft:tundra_x", &pack)]);
|
||||
let sync =
|
||||
SyncData::load_with(&root, &horizon_world::loot::PackOverlay::NONE, Some(&world)).unwrap();
|
||||
|
||||
// The encoder numbers a biome by its place in this list: none may be
|
||||
// missing, and the pack's is not the first.
|
||||
let registry = &sync.dynamic.registries[BIOME_REGISTRY];
|
||||
let names: Vec<_> = registry.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["minecraft:plains", "minecraft:river", "minecraft:tundra_x"]
|
||||
);
|
||||
assert!(world.list.iter().all(|b| names.contains(&b.name.as_str())));
|
||||
|
||||
// A client that knows the vanilla pack still gets the pack biome's data.
|
||||
let sent = sync.registry_packets(true, &LevelShapes::VANILLA);
|
||||
let biomes = sent.iter().find(|r| r.registry == BIOME_REGISTRY).unwrap();
|
||||
assert!(biomes.entries[0].data.is_none(), "a known one is not sent");
|
||||
assert!(biomes.entries[2].data.is_some(), "the pack's biome is sent");
|
||||
|
||||
// The client builds its table from what arrived: the server's id for
|
||||
// the name, and the pack's colour.
|
||||
let received: Vec<_> = biomes
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| (e.id.clone(), e.data.clone()))
|
||||
.collect();
|
||||
let client = client_biomes(&base, &received).expect("the pack's biome is new to the client");
|
||||
let id = client.id("minecraft:tundra_x");
|
||||
assert_eq!(id, world.id("minecraft:tundra_x"));
|
||||
assert_eq!(client.get(id).grass, 0x123456);
|
||||
assert_ne!(
|
||||
client.get(id).grass,
|
||||
client.get(client.id("minecraft:plains")).grass
|
||||
);
|
||||
|
||||
// Nothing new to a client that knows them all: it keeps its own table.
|
||||
// even when the server sends data for them (another grass colour here).
|
||||
let other: Value = serde_json::from_str(r##"{"effects":{"grass_color":"#654321"}}"##).unwrap();
|
||||
let other = json_to_tag(&other);
|
||||
let known: Vec<_> = received
|
||||
.iter()
|
||||
.filter(|(n, _)| base.contains(n))
|
||||
.map(|(n, _)| (n.clone(), Some(other.clone())))
|
||||
.collect();
|
||||
assert!(client_biomes(&base, &known).is_none());
|
||||
|
||||
// Beside a new biome, a known one keeps the game's colour.
|
||||
let mut mixed = known.clone();
|
||||
mixed.extend(received.iter().filter(|(n, _)| !base.contains(n)).cloned());
|
||||
let client = client_biomes(&base, &mixed).unwrap();
|
||||
let plains = base.get(base.id("minecraft:plains")).grass;
|
||||
assert_ne!(plains, 0x654321);
|
||||
assert_eq!(client.get(client.id("minecraft:plains")).grass, plains);
|
||||
assert_eq!(client.get(client.id("minecraft:tundra_x")).grass, 0x123456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recolouring_twice_starts_from_the_biome_files_each_time() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-recolor-biomes-{}", std::process::id()));
|
||||
let _scratch = Scratch(root.clone());
|
||||
let base = write_base(&root);
|
||||
let grass = |b: &Biomes| b.get(b.id("minecraft:plains")).grass;
|
||||
let darker = |_: &str, c: &mut BiomeColors| c.grass -= 0x10;
|
||||
let once = base.recolored_from(&base, darker).expect("colours changed");
|
||||
assert_eq!(grass(&once), grass(&base) - 0x10);
|
||||
// The same colours again, asked from the base: nothing to rebuild.
|
||||
assert!(base.recolored_from(&once, darker).is_none());
|
||||
// Other colours replace the first ones rather than add to them.
|
||||
let lighter = |_: &str, c: &mut BiomeColors| c.grass += 0x10;
|
||||
let next = base.recolored_from(&once, lighter).unwrap();
|
||||
assert_eq!(grass(&next), grass(&base) + 0x10);
|
||||
}
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user