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