mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb7be3765 | ||
|
|
4af2c671c7 | ||
|
|
e92c9ea743 | ||
|
|
9635fbb0a9 | ||
|
|
b409d5f624 | ||
|
|
9502716a07 | ||
|
|
723ab2aa50 | ||
|
|
4d5ee970d7 | ||
|
|
b4db58ab8e | ||
|
|
3833886df3 | ||
|
|
104ef68ee6 | ||
|
|
31ebe6db6c | ||
|
|
9e12caaabc | ||
|
|
23aa18322c | ||
|
|
617665a176 | ||
|
|
2036f0f517 | ||
|
|
f770f15616 | ||
|
|
0b95d39d86 | ||
|
|
3de1a56f1f | ||
|
|
9dc425d357 | ||
|
|
b16ab9e8c4 | ||
|
|
b2edfb2c5b | ||
|
|
644cca2a10 | ||
|
|
07dd22212e | ||
|
|
2d3b53a785 | ||
|
|
1e3c9c9ef7 | ||
|
|
d930105423 | ||
|
|
a6b61e9a3c | ||
|
|
80751276f3 | ||
|
|
23743968e1 | ||
|
|
6af60956ec | ||
|
|
8c1fa65f35 | ||
|
|
bc1549a438 | ||
|
|
b6f18dc8f3 | ||
|
|
504c9daf20 | ||
|
|
5a6bf9705c | ||
|
|
8f0797f258 | ||
|
|
8ecf5d6e47 | ||
|
|
c1ebf5d1b0 | ||
|
|
9ad735b9d7 | ||
|
|
6f19f06634 | ||
|
|
83ee5c303b | ||
|
|
c79b5be2e4 | ||
|
|
89225ec081 | ||
|
|
7b11005688 | ||
|
|
40f8b9600d | ||
|
|
6044c6ff88 | ||
|
|
ab099b2701 | ||
|
|
6cb5e4954e | ||
|
|
d74d39cb05 | ||
|
|
2ba33bd146 | ||
|
|
4f6e4e72b1 | ||
|
|
73aef0ce8e | ||
|
|
39c8a2aa0e | ||
|
|
21fd3a6b06 | ||
|
|
8beaa57331 | ||
|
|
b3709b9766 | ||
|
|
b2199f5a0e | ||
|
|
dfb28e42d0 | ||
|
|
f1f34edb80 | ||
|
|
9bc8e12ad1 | ||
|
|
7115c708e1 | ||
|
|
82c8398c6b | ||
|
|
cde33a8e42 | ||
|
|
0ccf3e110b | ||
|
|
d601715126 | ||
|
|
e70ead5bfe | ||
|
|
9ae37fcb50 | ||
|
|
b39c4b8dd4 | ||
|
|
bad95e4f6a | ||
|
|
8866e618b5 | ||
|
|
7732ce986f | ||
|
|
b03d1411d1 | ||
|
|
42ef865c34 | ||
|
|
e8891849a4 | ||
|
|
ee0af20e16 | ||
|
|
b1450f1ef2 | ||
|
|
64cd55cf74 | ||
|
|
5b5ebfcce3 | ||
|
|
a24a6fa26c | ||
|
|
faeff96cf9 | ||
|
|
a55c825467 | ||
|
|
6c716f457b | ||
|
|
6d9bb375c5 | ||
|
|
ae3737b1c2 | ||
|
|
766bfbcdfa | ||
|
|
0c127ec1d9 | ||
|
|
10c9d8440c | ||
|
|
7f5e3384b3 | ||
|
|
76d748caf8 | ||
|
|
2c8b933be4 | ||
|
|
f661443fa9 | ||
|
|
442d52a038 | ||
|
|
004ad993d9 | ||
|
|
42a57b8286 | ||
|
|
de141bb7cb | ||
|
|
8526305faa | ||
|
|
b37528f5d6 | ||
|
|
2a98f584df | ||
|
|
7f6d556e68 | ||
|
|
d33f547bf7 | ||
|
|
cfd2dc8392 | ||
|
|
6f4c2cfbbc | ||
|
|
2e7283d01c | ||
|
|
9e71837df9 | ||
|
|
9a7f955a77 | ||
|
|
6b7617c277 | ||
|
|
7c5efff307 | ||
|
|
1f6e498238 | ||
|
|
32f6079134 | ||
|
|
ce1db1b0c3 | ||
|
|
ad3ddeb43f | ||
|
|
34f4d3b451 | ||
|
|
27de895fdb |
No files matched your search
@@ -72,6 +72,9 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
|
||||
@@ -11,7 +11,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
@@ -66,6 +66,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
|
||||
Generated
+122
-2
@@ -477,6 +477,13 @@ dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coords-hud-lua"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-lua",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -930,6 +937,13 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "far-view"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1093,6 +1107,30 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-arena"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"gc-arena-derive",
|
||||
"hashbrown 0.14.5",
|
||||
"sptr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-arena-derive"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -1257,6 +1295,16 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1461,6 +1509,16 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-lua"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-mod-sdk",
|
||||
"piccolo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
@@ -2737,6 +2795,21 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "piccolo"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
"anyhow",
|
||||
"gc-arena",
|
||||
"hashbrown 0.14.5",
|
||||
"rand",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2864,6 +2937,15 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predator-missile"
|
||||
version = "0.1.0"
|
||||
@@ -2964,6 +3046,27 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3443,6 +3546,12 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sptr"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3560,6 +3669,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -5221,7 +5341,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5262,7 +5382,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -30,15 +30,18 @@ members = [
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud-lua",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
"crates/horizon-mod-lua",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
@@ -26,6 +26,8 @@ use std::{
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
|
||||
/// The Lua runtime from the same repository, with the template's capability.
|
||||
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
@@ -42,7 +44,7 @@ fn main() -> ExitCode {
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
let (sdk, lua) = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
|
||||
(
|
||||
format!("{{ path = {:?} }}", p.display().to_string()),
|
||||
format!(
|
||||
"{{ path = {:?}, features = [\"chat\"] }}",
|
||||
lua.display().to_string()
|
||||
),
|
||||
)
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
.replace("{lua}", &lua)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
// The code template's mod, written in Lua.
|
||||
let lua_files = [
|
||||
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
|
||||
("main.lua", include_str!("../template/lua/main.lua")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
"lua" => &lua_files,
|
||||
other => return Err(format!("no template {other}: code, content or lua")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
let file = fill(file);
|
||||
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
|
||||
// takes a custom source only with this cfg.
|
||||
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
|
||||
cmd.env(
|
||||
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
|
||||
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
|
||||
);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
@@ -386,6 +412,26 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lua_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
|
||||
let path = dir.join("hello-lua");
|
||||
new(&[
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into(),
|
||||
])
|
||||
.unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
|
||||
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
|
||||
assert!(script.contains("horizon.command(\"hello\")"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
@@ -424,7 +470,7 @@ mod tests {
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into()
|
||||
"python".into()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# The Lua interpreter, built into the mod with main.lua. Its features are the
|
||||
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
|
||||
# the two lists the same.
|
||||
[dependencies]
|
||||
horizon-mod-lua = {lua}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -0,0 +1,12 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the Lua runtime targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -0,0 +1,12 @@
|
||||
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
|
||||
-- Commands come from horizon.command; hooks are global functions:
|
||||
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
|
||||
|
||||
local greeted = 0
|
||||
|
||||
horizon.command("hello")
|
||||
|
||||
function on_command(name, args)
|
||||
greeted = greeted + 1
|
||||
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
// The mod is main.lua: this builds it into the mod with its interpreter.
|
||||
horizon_mod_lua::lua_mod!("main.lua");
|
||||
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
|
||||
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
|
||||
use Species as S;
|
||||
let s = m.species();
|
||||
let named = |reg: &finalize::VariantRegistry, i: u8| {
|
||||
format!(
|
||||
"minecraft:{}",
|
||||
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
|
||||
let name = i.map_or("classic", |i| {
|
||||
reg.names.get(i as usize).map_or("?", |n| n.as_str())
|
||||
)
|
||||
});
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
|
||||
match s {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//!
|
||||
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::recipes::RecipeBookChangeSettings,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
_ => BookType::Smoker,
|
||||
}
|
||||
.index();
|
||||
book.open[i] = op["open"].as_bool().unwrap_or(false);
|
||||
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
|
||||
let packet = RecipeBookChangeSettings {
|
||||
book_type: i as i32,
|
||||
open: op["open"].as_bool().unwrap_or(false),
|
||||
filtering: op["filtering"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let mut wire = Writer::new();
|
||||
RecipeBookChangeSettings::write(&packet, &mut wire);
|
||||
extra = format!(
|
||||
" packet={}",
|
||||
wire.data
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
|
||||
book.open[decoded.book_type as usize] = decoded.open;
|
||||
book.filtering[decoded.book_type as usize] = decoded.filtering;
|
||||
}
|
||||
"save" => {
|
||||
let tag = book.write();
|
||||
|
||||
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
|
||||
}
|
||||
}
|
||||
impl crate::light::LightWorld for ChunkMap {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
self.columns.state(x, y, z)
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
|
||||
self.columns.section(cx, s, cz)
|
||||
}
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
self.columns.light(x, y, z)
|
||||
}
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
|
||||
self.columns.set_light(x, y, z, packed);
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +103,13 @@ mod tests {
|
||||
for column in map.values_mut() {
|
||||
Arc::make_mut(column).light_loaded = true;
|
||||
}
|
||||
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
|
||||
crate::light::LightWorld::set_section_light(
|
||||
&mut map,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
crate::chunk::Light::Uniform(0),
|
||||
);
|
||||
assert_eq!(inserted, map.membership_revision());
|
||||
map.remove(&(0, 0));
|
||||
map.insert((1, 0), chunk);
|
||||
|
||||
@@ -219,9 +219,22 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
return color;
|
||||
return celestial_color(in);
|
||||
}
|
||||
|
||||
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
|
||||
// premultiplied for an additive (one, one) blend, and as alpha how much of it
|
||||
// the texel adds, which the blend takes off the frame's alpha. HDR output
|
||||
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
|
||||
// the dark rest of their quads or the dim End sky.
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
|
||||
@@ -244,7 +244,51 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
let species = m.species();
|
||||
if let Some((_, variant)) =
|
||||
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
|
||||
{
|
||||
map.insert("variant".into(), string(&variant));
|
||||
}
|
||||
if species == crate::mobs::Species::Chicken {
|
||||
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
let sounds = crate::mobs::finalize::sound_variants(species);
|
||||
if !sounds.is_empty() {
|
||||
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
|
||||
let sound = sound.copied().unwrap_or("classic");
|
||||
map.insert(
|
||||
"sound_variant".into(),
|
||||
string(&format!("minecraft:{sound}")),
|
||||
);
|
||||
}
|
||||
match id {
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type.
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
for (key, value) in [
|
||||
("Trusted", Tag::List(Vec::new())),
|
||||
("Sleeping", Tag::Byte(0)),
|
||||
("Sitting", Tag::Byte(0)),
|
||||
("Crouching", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
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);
|
||||
@@ -260,10 +304,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:wolf" => {
|
||||
if let Some(owner) = m.extra.owner {
|
||||
@@ -273,10 +313,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
@@ -311,6 +347,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
| "minecraft:trader_llama" => {
|
||||
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
|
||||
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
|
||||
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
|
||||
for key in ["EatingHaystack", "Bred"] {
|
||||
map.entry(key.into()).or_insert(Tag::Byte(0));
|
||||
}
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
@@ -800,6 +840,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
|
||||
// with the type's and saved before it drops to the one it rolled. (Only
|
||||
// the base is read here, so the clamp would cut a leader zombie's bonus.)
|
||||
if crate::mobs::equine::is_equine(m.species()) {
|
||||
m.health = m.health.min(m.attrs.max_health);
|
||||
}
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -935,20 +981,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.vector[0] = 0.7;
|
||||
}
|
||||
}
|
||||
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
|
||||
&& let Some(v) = text(tag.get("variant"))
|
||||
{
|
||||
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Chicken
|
||||
&& let Some(t) = number(tag.get("EggLayTime"))
|
||||
{
|
||||
m.extra.egg_time = t as i32;
|
||||
}
|
||||
if let Some(v) = text(tag.get("sound_variant")) {
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
let sounds = crate::mobs::finalize::sound_variants(m.species());
|
||||
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
|
||||
}
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
|
||||
v @ (0..=5 | 99) => v as u8,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
crate::mobs::Species::Goat => {
|
||||
m.extra.screaming = flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Cat {
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
|
||||
.iter()
|
||||
.position(|n| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wolf {
|
||||
let health = m.health;
|
||||
@@ -959,14 +1030,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
|
||||
.iter()
|
||||
.position(|(n, _)| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -2567,6 +2630,123 @@ mod sheep_nbt_tests {
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn farm_animals_keep_their_variant_and_sound_variant() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
types["minecraft:pig"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(
|
||||
text(tag.get("variant")).as_deref(),
|
||||
Some("minecraft:temperate")
|
||||
);
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:classic")
|
||||
);
|
||||
// Spawned in a warm biome with the mini sounds.
|
||||
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:mini")
|
||||
);
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
|
||||
let mut hen = Mob::new(
|
||||
3,
|
||||
types["minecraft:chicken"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
|
||||
let back = mob_from_nbt(&tag, &types, 4).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
|
||||
// The sound variants are indices into the registries finalizeSpawn draws from.
|
||||
use crate::mobs::{Species, finalize};
|
||||
let registries = finalize::SpawnRegistries::load(&root);
|
||||
for (species, registry) in [
|
||||
(Species::Pig, ®istries.pig_sound),
|
||||
(Species::Cow, ®istries.cow_sound),
|
||||
(Species::Chicken, ®istries.chicken_sound),
|
||||
(Species::Cat, ®istries.cat_sound),
|
||||
(Species::Wolf, ®istries.wolf_sound),
|
||||
] {
|
||||
assert_eq!(
|
||||
finalize::sound_variants(species),
|
||||
registry.names,
|
||||
"{species:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
let round_trip = |m: &Mob| {
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
|
||||
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
|
||||
};
|
||||
let mut fox = new("minecraft:fox");
|
||||
fox.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&fox);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
assert_eq!(number(tag.get("RabbitType")), Some(4));
|
||||
assert_eq!(back.extra.variant, 4);
|
||||
let mut goat = new("minecraft:goat");
|
||||
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
|
||||
let (back, tag) = round_trip(&goat);
|
||||
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
|
||||
assert_eq!(
|
||||
(back.extra.screaming, back.extra.lost_horns),
|
||||
(true, [false, true])
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:goat")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -157,6 +157,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_links;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
@@ -508,6 +509,12 @@ enum Screen {
|
||||
Friends,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
|
||||
ServerLinks,
|
||||
/// ConfirmLinkScreen for a server link (game/server_links.rs).
|
||||
ConfirmLink,
|
||||
/// DialogScreen.WarningScreen: the note about custom screens.
|
||||
DialogWarning,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
@@ -1675,6 +1682,7 @@ struct Game<'a> {
|
||||
guest_use: Option<GuestUse>,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
|
||||
/// Block events (position, b0, b1, state) and entity events (id, event,
|
||||
/// position) for the guests.
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
@@ -1698,6 +1706,9 @@ struct Game<'a> {
|
||||
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
|
||||
/// hub's item registry, which the guest's turn does not hold.
|
||||
guest_cooldowns: Vec<(Arc<str>, i32)>,
|
||||
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
|
||||
/// sent with their entity id, which the guest's turn does not hold.
|
||||
guest_entity_events: Vec<i8>,
|
||||
/// `broadcastToAdmins` lines a guest's command queued, sent once it
|
||||
/// returns: the line, whether operators get it and whether the console
|
||||
/// logs it.
|
||||
@@ -1732,6 +1743,8 @@ struct Game<'a> {
|
||||
restore_player_state: bool,
|
||||
stats_ui: stats_screen::StatsUi,
|
||||
adv_ui: advancements_screen::AdvUi,
|
||||
/// The Server Links screens' state (game/server_links.rs).
|
||||
links_ui: server_links::LinksUi,
|
||||
camera_type: perspective::CameraType,
|
||||
fangs: fangs::FangState,
|
||||
/// The local player as a model (third person, the inventory).
|
||||
@@ -2990,6 +3003,16 @@ impl Game<'_> {
|
||||
data,
|
||||
} => self.sculk_charge_event(pos, data),
|
||||
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
|
||||
// LevelEventHandler 2019 and 2020: the crack on a face of the
|
||||
// block being mined, with its hit sound for 2020.
|
||||
LevelEvent::LevelEvent {
|
||||
id: id @ (2019 | 2020),
|
||||
pos,
|
||||
data,
|
||||
} => {
|
||||
let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize];
|
||||
self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020);
|
||||
}
|
||||
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
|
||||
self.spawner_burst([pos.x, pos.y, pos.z])
|
||||
}
|
||||
@@ -3009,6 +3032,11 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.dragon_egg_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2016,
|
||||
pos,
|
||||
data,
|
||||
} => self.shulker_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2017,
|
||||
pos,
|
||||
@@ -4202,6 +4230,7 @@ impl Game<'_> {
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
// SpyglassItem.USE_DURATION, then finishUsingItem.
|
||||
if ticks + 1 >= 1200 {
|
||||
self.send_use_item_complete();
|
||||
self.stop_spyglass();
|
||||
self.using = None;
|
||||
} else {
|
||||
@@ -4223,6 +4252,7 @@ impl Game<'_> {
|
||||
}
|
||||
if ticks >= duration {
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
@@ -4241,6 +4271,17 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
|
||||
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
|
||||
/// for everyone around, the digger included (`levelEvent(null, ...)`).
|
||||
fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
|
||||
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
|
||||
let direction = horizon_core::geometry::Direction::ALL
|
||||
.iter()
|
||||
.position(|d| d.normal() == face)
|
||||
.unwrap_or(1);
|
||||
self.level_event_at(id, pos, direction as i32);
|
||||
}
|
||||
fn tick_mining(&mut self) -> Result<()> {
|
||||
if self.destroy_delay > 0 {
|
||||
self.destroy_delay -= 1;
|
||||
@@ -4284,6 +4325,11 @@ impl Game<'_> {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let facts = self.res.facts().get(state);
|
||||
if facts.hardness < 0.0 {
|
||||
// The server still counts its destroy ticks on an unbreakable block.
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.ticks += 1;
|
||||
let (pos, ticks) = (m.pos, m.ticks);
|
||||
self.destroy_progress_event(pos, hit.face, ticks);
|
||||
return Ok(());
|
||||
}
|
||||
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
|
||||
@@ -4314,26 +4360,15 @@ impl Game<'_> {
|
||||
} else {
|
||||
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
|
||||
};
|
||||
let hit_sound = facts.sounds.clone();
|
||||
self.swing_arm();
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.progress += progress;
|
||||
m.ticks += 1;
|
||||
let crack = (m.pos, hit.face);
|
||||
if m.ticks % 4 == 1 {
|
||||
let c = [
|
||||
f64::from(m.pos[0]) + 0.5,
|
||||
f64::from(m.pos[1]) + 0.5,
|
||||
f64::from(m.pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&hit_sound.hit,
|
||||
Some(c),
|
||||
(hit_sound.volume + 1.0) / 8.0,
|
||||
hit_sound.pitch * 0.5,
|
||||
);
|
||||
// An instant break at START sends no destroy events.
|
||||
if m.progress < 1.0 || m.ticks > 1 {
|
||||
let (pos, ticks) = (m.pos, m.ticks);
|
||||
self.destroy_progress_event(pos, hit.face, ticks);
|
||||
}
|
||||
self.crack_particle(crack.0, crack.1, state);
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
|
||||
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
|
||||
@@ -5098,6 +5133,9 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
@@ -5581,6 +5619,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => self.draw_advancements(&mut batch),
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
Screen::CommandBlock => self.draw_command_block(&mut batch),
|
||||
Screen::Inventory => {
|
||||
self.draw_inventory(&mut batch);
|
||||
@@ -11375,6 +11416,10 @@ impl Game<'_> {
|
||||
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
|
||||
Screen::Dead => {}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
|
||||
Screen::Advancements => self.close_advancements(),
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
@@ -11443,8 +11488,10 @@ impl Game<'_> {
|
||||
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
|
||||
// (the server swaps the hands and sends both slots back; spectators can't).
|
||||
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
|
||||
if !self.spectator() {
|
||||
self.remote_swap_hands();
|
||||
if !self.spectator()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
@@ -11541,11 +11588,28 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`Graphics::lod_distance_on`).
|
||||
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
|
||||
/// Distance when it is on, else a mod's far terrain
|
||||
/// (`render.set-far-terrain`).
|
||||
fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
if g.lod_distance > 0 {
|
||||
return (g.lod_distance, reduced);
|
||||
}
|
||||
match self.mod_far_terrain() {
|
||||
Some(f) => (f.distance, reduced || f.reduced),
|
||||
None => (0, reduced),
|
||||
}
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
|
||||
fn lod_distance(&self) -> u32 {
|
||||
let (distance, _) = self.far_terrain_choice();
|
||||
match &self.renderer {
|
||||
Some(renderer) => self.settings.horizon.lod_distance_on(&renderer.info),
|
||||
None => self.settings.horizon.lod_distance.min(1024),
|
||||
Some(renderer) => {
|
||||
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
|
||||
}
|
||||
None => distance.min(1024),
|
||||
}
|
||||
}
|
||||
/// Only enabled LOD worlds extend the renderer's range, never simulation.
|
||||
@@ -11566,7 +11630,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn prepare_lod(&mut self) {
|
||||
let enabled = self.settings.horizon.lod_distance > 0
|
||||
let enabled = self.far_terrain_choice().0 > 0
|
||||
&& self.renderer.is_some()
|
||||
&& self.player.is_some()
|
||||
&& self.title.is_none()
|
||||
@@ -11663,6 +11727,7 @@ impl Game<'_> {
|
||||
{
|
||||
log::warn!("Horizon: failed to save unloaded chunks: {e}");
|
||||
}
|
||||
let (far_distance, far_reduced) = self.far_terrain_choice();
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
let g = &self.settings.horizon;
|
||||
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
|
||||
@@ -11716,12 +11781,10 @@ impl Game<'_> {
|
||||
}
|
||||
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
|
||||
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
|
||||
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
|
||||
let lod_distance =
|
||||
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
|
||||
lod.set_near_loading(self.streaming.busy());
|
||||
lod.set_reduced_detail(
|
||||
self.settings.horizon.far_detail
|
||||
== horizon_client::graphics::FarDetail::Reduced,
|
||||
);
|
||||
lod.set_reduced_detail(far_reduced);
|
||||
// The covered columns change only with the complete ones and
|
||||
// the render area: rebuilt then, not every frame.
|
||||
let at = (
|
||||
@@ -12055,6 +12118,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::WorldOptions && d != 0.0 {
|
||||
self.world_options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::ServerLinks && d != 0.0 {
|
||||
self.server_links_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
@@ -12246,6 +12312,15 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::WorldOptions if pressed && button == MouseButton::Left => {
|
||||
self.world_options_click()
|
||||
}
|
||||
Screen::ServerLinks if pressed && button == MouseButton::Left => {
|
||||
self.server_links_click()?
|
||||
}
|
||||
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
|
||||
self.confirm_link_click()
|
||||
}
|
||||
Screen::DialogWarning if pressed && button == MouseButton::Left => {
|
||||
self.dialog_warning_click()?
|
||||
}
|
||||
Screen::WorldOptions if button == MouseButton::Left => {
|
||||
self.world_options_release()
|
||||
}
|
||||
@@ -13093,6 +13168,7 @@ impl<'a> Game<'a> {
|
||||
forced_hit: None,
|
||||
guest_use: None,
|
||||
lan_events: Vec::new(),
|
||||
lan_cushion_breaks: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
@@ -13101,6 +13177,7 @@ impl<'a> Game<'a> {
|
||||
lan_acting: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
guest_entity_events: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
chat_capture: None,
|
||||
@@ -13127,6 +13204,7 @@ impl<'a> Game<'a> {
|
||||
restore_player_state: true,
|
||||
stats_ui: Default::default(),
|
||||
adv_ui: Default::default(),
|
||||
links_ui: Default::default(),
|
||||
camera_type: Default::default(),
|
||||
fangs: Default::default(),
|
||||
player_model: None,
|
||||
|
||||
@@ -419,6 +419,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
@@ -707,9 +710,12 @@ impl Game<'_> {
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
if !book.is_empty() {
|
||||
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
|
||||
v["recipe_book"] = json!({
|
||||
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
|
||||
"known": self.recipe_book_known(),
|
||||
"open": open,
|
||||
"filtering": filtering,
|
||||
});
|
||||
}
|
||||
v["slot_ids"] = json!(
|
||||
|
||||
@@ -862,7 +862,7 @@ impl Game<'_> {
|
||||
let a = alpha as f32;
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for b in &self.boats {
|
||||
let entity_start = out.len();
|
||||
|
||||
@@ -1009,9 +1009,12 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
|
||||
let text = tr("book.pageIndicator", "Page %s of %s")
|
||||
.replacen("%s", &(page + 1).to_string(), 1)
|
||||
.replacen("%s", &count.max(1).to_string(), 1);
|
||||
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
|
||||
// positional ("Page %1$s of %2$s").
|
||||
let text = horizon_protocol::text::format_java(
|
||||
&tr("book.pageIndicator", "Page %1$s of %2$s"),
|
||||
&[(page + 1).to_string(), count.max(1).to_string()],
|
||||
);
|
||||
let tw = HudBatch::text_width(atlas, &text);
|
||||
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
|
||||
};
|
||||
|
||||
@@ -907,10 +907,12 @@ impl Game<'_> {
|
||||
.coords(c, "pos")
|
||||
.map(|co| co.block(self.source_position(), self.source_rotation()));
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
return Ok(flag(
|
||||
dimension == self.dim
|
||||
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
|
||||
));
|
||||
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
|
||||
let loaded = |p: [i32; 3]| {
|
||||
let key = (p[0] >> 4, p[2] >> 4);
|
||||
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
|
||||
};
|
||||
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
|
||||
}
|
||||
if c.has("dimension") {
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
|
||||
@@ -19,7 +19,17 @@ use std::convert::Infallible;
|
||||
|
||||
type ItemConsumable = Consumable<String, ConsumeEffect>;
|
||||
|
||||
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
|
||||
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.completeUsingItem`: the player's own client is told
|
||||
/// first, and completes the use itself (eating the food there too).
|
||||
pub(super) fn send_use_item_complete(&mut self) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
|
||||
}
|
||||
}
|
||||
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
|
||||
pub(super) fn emit_consume(
|
||||
&mut self,
|
||||
|
||||
@@ -878,6 +878,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
|
||||
@@ -980,6 +980,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Shift { target, delta } => {
|
||||
let d = Vec3::new(delta[0], delta[1], delta[2]);
|
||||
match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = p.position.add_vector(d);
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.position = m.position.add_vector(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Wololo { mob } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
// DyeColor.RED.
|
||||
@@ -3406,7 +3421,7 @@ impl Game<'_> {
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = f64::from(self.settings.entity_distance);
|
||||
let scale = self.view_scale();
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
|
||||
@@ -3415,9 +3430,16 @@ impl Game<'_> {
|
||||
}
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the entity distance option.
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let range = (w + h + w) / 3.0 * 64.0 * scale;
|
||||
let edge = (w + h + w) / 3.0
|
||||
* if &*mob.kind.id == "minecraft:player" {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -348,11 +348,15 @@ impl Game<'_> {
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
let p = c.position;
|
||||
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
|
||||
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
|
||||
// Guest actions temporarily take self.lan; flush once the hub is back.
|
||||
// Queue before resolving a sprite: a dedicated server has no renderer.
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_cushion_breaks.push((state, particle_at));
|
||||
}
|
||||
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
@@ -361,14 +365,8 @@ impl Game<'_> {
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
let p = c.position;
|
||||
self.particles.send_particles(
|
||||
&options,
|
||||
[p.x, p.y + 0.666_666_7, p.z],
|
||||
[0.25, 0.0625, 0.25],
|
||||
0.05,
|
||||
10,
|
||||
);
|
||||
self.particles
|
||||
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
@@ -455,8 +453,7 @@ impl Game<'_> {
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use horizon_client::{
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, OnceLock, mpsc},
|
||||
time::{Duration, Instant},
|
||||
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
|
||||
}
|
||||
});
|
||||
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
|
||||
println!(
|
||||
// The launcher can close its stdout pipe while the server keeps running.
|
||||
let _ = writeln!(
|
||||
std::io::stdout().lock(),
|
||||
"[{}] [{thread}/{level}]: {message}",
|
||||
chrono::Local::now().format("%H:%M:%S")
|
||||
);
|
||||
@@ -618,6 +621,22 @@ impl Game<'_> {
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
// DedicatedServer.serverLinks: `bug-report-link` as the only link
|
||||
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
|
||||
server_links: if props.bug_report_link.is_empty() {
|
||||
Vec::new()
|
||||
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
|
||||
vec![horizon_protocol::packets::configuration::ServerLink {
|
||||
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
|
||||
link: props.bug_report_link.clone(),
|
||||
}]
|
||||
} else {
|
||||
log(
|
||||
"WARN",
|
||||
&format!("Failed to parse bug link {}", props.bug_report_link),
|
||||
);
|
||||
Vec::new()
|
||||
},
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
@@ -1311,3 +1330,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
process::{Command, Stdio},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
|
||||
const WARNING: &str = "Authentication warning";
|
||||
const READY: &str = "horizon-console-log-ready\n";
|
||||
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
|
||||
|
||||
fn assert_log_lines(text: &str) {
|
||||
let expected = [
|
||||
format!("] [Server thread/INFO]: {LOGIN}"),
|
||||
format!("] [User Authenticator #1/WARN]: {WARNING}"),
|
||||
];
|
||||
assert_eq!(text.lines().count(), expected.len());
|
||||
for (line, suffix) in text.lines().zip(expected) {
|
||||
assert!(line.starts_with('['));
|
||||
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
|
||||
assert_eq!(&line[9..], suffix);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_logging_survives_closed_stdout() {
|
||||
if let Some(home) = std::env::var_os(CHILD_HOME) {
|
||||
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
|
||||
// Let the parent close the real stdout pipe only after libtest's startup output.
|
||||
io::stdout().write_all(READY.as_bytes()).unwrap();
|
||||
io::stdout().flush().unwrap();
|
||||
io::stdin().read_exact(&mut [0]).unwrap();
|
||||
std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(|| {
|
||||
let captured = capture_log(|| {
|
||||
log("INFO", LOGIN);
|
||||
log_from("User Authenticator #1", "WARN", WARNING);
|
||||
});
|
||||
assert_eq!(captured, [LOGIN, WARNING]);
|
||||
horizon_client::logging::flush();
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
// Libtest's final status also writes to stdout; only the game logger is under test.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
for closed_stdout in [false, true] {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
"horizon-console-log-test-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&home).unwrap();
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"game::dedicated::tests::console_logging_survives_closed_stdout",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_HOME, &home)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(
|
||||
stdout.read_line(&mut line).unwrap() > 0,
|
||||
"child was not ready"
|
||||
);
|
||||
if line.ends_with(READY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed_stdout {
|
||||
drop(stdout); // No reader remains: the next console write gets EPIPE.
|
||||
} else {
|
||||
child.stdout = Some(stdout.into_inner());
|
||||
}
|
||||
child.stdin.take().unwrap().write_all(&[1]).unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"closed_stdout={closed_stdout}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !closed_stdout {
|
||||
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
|
||||
}
|
||||
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use horizon_core::{
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
const GATEWAY_RADIUS: f64 = 96.0;
|
||||
const GATEWAY_Y: i32 = 75;
|
||||
|
||||
@@ -60,12 +60,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
|
||||
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
|
||||
/// at 20 or more. The render distance is the client setting, capped by
|
||||
/// the server's view distance when connected
|
||||
/// (Options.getEffectiveRenderDistance).
|
||||
pub(super) fn view_scale(&self) -> f64 {
|
||||
let distance = match &self.remote {
|
||||
Some(r) => self.settings.render_distance.min(r.view_distance),
|
||||
None => self.settings.render_distance,
|
||||
};
|
||||
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
|
||||
/// box's mean edge, scaled by the entity distance option.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
|
||||
/// box's mean edge, scaled by the view scale.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
|
||||
let edge = (size.x + size.y + size.z) / 3.0;
|
||||
let edge = if edge.is_nan() { 1.0 } else { edge };
|
||||
edge * 64.0 * f64::from(view_scale)
|
||||
edge * 64.0 * view_scale
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -311,8 +311,9 @@ impl Game<'_> {
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
|
||||
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
|
||||
// of 1.
|
||||
let range = 16.0 * 64.0 * self.view_scale();
|
||||
for f in &self.item_frames {
|
||||
let entity_start = out.len();
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
|
||||
@@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
crypto::offline_uuid,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -715,6 +716,7 @@ impl Game<'_> {
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.lan_cushion_breaks.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
self.system_message(Text::translate(
|
||||
@@ -1267,8 +1269,9 @@ impl Game<'_> {
|
||||
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
// (these levelEvent calls name no excluded player), nor
|
||||
// the digger's own crack and hit sound (2019, 2020).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -1330,6 +1333,33 @@ impl Game<'_> {
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
|
||||
if let Some(id) = host
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", "minecraft:block")
|
||||
{
|
||||
host.hub.broadcast_near(
|
||||
at,
|
||||
32.0,
|
||||
&C::LevelParticles(play::LevelParticles {
|
||||
particle: horizon_protocol::entity_data::Particle {
|
||||
id,
|
||||
data: Some(horizon_protocol::entity_data::ParticleData::State(
|
||||
state.0 as i32,
|
||||
)),
|
||||
},
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.25, 0.0625, 0.25],
|
||||
max_speed: [0.05; 3],
|
||||
count: 10,
|
||||
randomization: 0,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
|
||||
@@ -165,6 +165,8 @@ pub(super) struct GuestSwap {
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
/// The packets queued for that guest.
|
||||
packets: Vec<play::Clientbound>,
|
||||
/// The entity events queued for that guest.
|
||||
entity_events: Vec<i8>,
|
||||
/// The host's count of swings started, kept while the guest acts.
|
||||
swings: u32,
|
||||
/// How many projectiles there were: those added since are the guest's.
|
||||
@@ -389,6 +391,7 @@ impl Game<'_> {
|
||||
self.lan_acting_uuid.replace(g.uuid),
|
||||
);
|
||||
let packets = std::mem::take(&mut self.guest_packets);
|
||||
let entity_events = std::mem::take(&mut self.guest_entity_events);
|
||||
let swings = self.swings_started;
|
||||
let projectiles = self.creatures.projectiles.len();
|
||||
let modifiers = std::mem::replace(
|
||||
@@ -449,6 +452,7 @@ impl Game<'_> {
|
||||
death_cause,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -508,6 +512,7 @@ impl Game<'_> {
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -529,6 +534,17 @@ impl Game<'_> {
|
||||
for packet in std::mem::replace(&mut self.guest_packets, packets) {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
// ServerPlayer.connection.send: the guest's own entity events.
|
||||
let entity_id = host.hub.guests[i].entity_id;
|
||||
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::EntityEvent(play::EntityEvent {
|
||||
id: entity_id,
|
||||
event,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
|
||||
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
|
||||
host.hub.send_to(
|
||||
@@ -1916,21 +1932,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1967,6 +1970,24 @@ impl Game<'_> {
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl), after
|
||||
// LivingEntity.tick's use: an entry a finished use starts has 20
|
||||
// ticks left when the next tick's packets are handled, as the
|
||||
// guest's own client counts.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
let g = &host.hub.guests[gi];
|
||||
|
||||
@@ -13,6 +13,12 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
|
||||
}
|
||||
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
|
||||
/// shell's old and new cells.
|
||||
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
|
||||
}
|
||||
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
|
||||
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
@@ -135,6 +141,30 @@ impl Game<'_> {
|
||||
// Each guest plays it from the global packet.
|
||||
self.audio.play_unshared(sound, at, volume, 1.0);
|
||||
}
|
||||
/// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for
|
||||
/// `play_sound`) and one crumb on the face being mined; nothing for air.
|
||||
pub(super) fn breaking_block_effects(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
play_sound: bool,
|
||||
) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if self.res.facts().is_air(state) {
|
||||
return;
|
||||
}
|
||||
if play_sound {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Each guest plays it from the level event itself.
|
||||
self.audio.play_unshared(
|
||||
&sounds.hit,
|
||||
Some(pos.map(|v| f64::from(v) + 0.5)),
|
||||
(sounds.volume + 1.0) / 8.0,
|
||||
sounds.pitch * 0.5,
|
||||
);
|
||||
}
|
||||
self.crack_particle(pos, face, state);
|
||||
}
|
||||
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
|
||||
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
|
||||
/// spell particles in the potion's colour `data`, flung out in a disc.
|
||||
|
||||
@@ -90,6 +90,8 @@ pub(super) struct Minecart {
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
/// again while the items are still that roll.
|
||||
pub loot: Option<horizon_client::save::RolledLoot>,
|
||||
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
|
||||
pub first_tick: bool,
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
@@ -186,6 +188,7 @@ impl Minecart {
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -536,6 +539,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
|
||||
c.first_tick = false;
|
||||
c.previous = c.position;
|
||||
c.previous_yaw = c.yaw;
|
||||
c.hurt_time = (c.hurt_time - 1).max(0);
|
||||
@@ -1340,9 +1344,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
|
||||
// edge, scaled by the entity distance option.
|
||||
let range =
|
||||
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// edge, scaled by the view scale.
|
||||
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
|
||||
for c in &self.carts {
|
||||
let entity_start = out.len();
|
||||
let Some(model) = self.cart_models.models.get(&c.kind) else {
|
||||
@@ -1509,6 +1512,10 @@ impl Minecart {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
@@ -1558,6 +1565,7 @@ impl Minecart {
|
||||
_ => 0.0,
|
||||
};
|
||||
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
|
||||
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
@@ -1582,4 +1590,30 @@ mod tests {
|
||||
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
|
||||
assert_eq!(display_offset(CartKind::Rideable), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cart_saves_as_never_ticked_until_it_ticks() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let generated = Tag::compound([
|
||||
("id", Tag::String("minecraft:chest_minecart".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(
|
||||
[0.5f64, 40.5, 0.5]
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec(),
|
||||
),
|
||||
),
|
||||
("HasTicked", Tag::Byte(1)),
|
||||
("Fire", Tag::Short(0)),
|
||||
]);
|
||||
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
cart.first_tick = false;
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ impl Game<'_> {
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
None
|
||||
}
|
||||
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
|
||||
@@ -51,6 +54,13 @@ pub(super) struct ModMinimap {
|
||||
pub(super) round: bool,
|
||||
}
|
||||
|
||||
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModFarTerrain {
|
||||
pub(super) distance: u32,
|
||||
pub(super) reduced: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack as `input` shows it; none when it is empty.
|
||||
@@ -190,4 +200,12 @@ impl Game<'_> {
|
||||
round: m.round,
|
||||
})
|
||||
}
|
||||
/// The far terrain the first mod drawing one asks for.
|
||||
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
|
||||
let f = self.mods.mods.far_terrain()?;
|
||||
Some(ModFarTerrain {
|
||||
distance: f.distance,
|
||||
reduced: f.reduced,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,19 @@ impl Game<'_> {
|
||||
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
|
||||
return false;
|
||||
}
|
||||
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
|
||||
// seat, inventory or feeding to give. Camels seat two and check their
|
||||
// passengers themselves.
|
||||
let id = m.id;
|
||||
if !matches!(species, Species::Camel | Species::CamelHusk)
|
||||
&& self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let m = &self.mobs[index];
|
||||
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
|
||||
if !m.is_baby()
|
||||
&& m.extra.tame
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`,
|
||||
//! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination`
|
||||
//! (`PortalForcer` through `horizon_world::level::portal_travel`), and
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`.
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through
|
||||
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
|
||||
//! the player's End portals are game/end.rs's.
|
||||
//!
|
||||
//! Only one dimension is loaded at a time. The player's destination is found
|
||||
//! once the other dimension has loaded around it. An item or mob that goes
|
||||
@@ -12,7 +14,7 @@ use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
use horizon_core::{
|
||||
geometry::{Axis, Vec3},
|
||||
geometry::{Aabb, Axis, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
@@ -40,6 +42,32 @@ pub(super) struct Plan {
|
||||
offset: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
/// Through an End portal: `approximate` is the End's spawn point, or the
|
||||
/// Overworld's shared spawn.
|
||||
end: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// `EndPortalBlock.getPortalDestination` for an entity other than a
|
||||
/// player in `from`: the End's spawn point, or out of the End the
|
||||
/// Overworld's shared spawn `world_spawn`.
|
||||
fn end_portal(from: Dim, world_spawn: BlockPos, width: f32, height: f32) -> Self {
|
||||
let (dim, approximate) = if from == Dim::End {
|
||||
(Dim::Overworld, world_spawn)
|
||||
} else {
|
||||
let [x, y, z] = super::end::END_SPAWN;
|
||||
(Dim::End, BlockPos::new(x, y, z))
|
||||
};
|
||||
Self {
|
||||
dim,
|
||||
approximate,
|
||||
entry_x_axis: true,
|
||||
offset: Vec3::ZERO,
|
||||
width,
|
||||
height,
|
||||
end: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity on its way to a dimension that isn't loaded.
|
||||
@@ -81,6 +109,89 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
/// Whether a `width` by `height` box moving from `from` to `to` meets the End
|
||||
/// portal at `pos`: `Entity.collidedWithShapeMovingFrom` against its
|
||||
/// entity-inside shape, y 6/16..12/16 of the block.
|
||||
fn touches_end_portal(from: Vec3, to: Vec3, width: f32, height: f32, pos: BlockPos) -> bool {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let start = Aabb::new(
|
||||
Vec3::new(from.x - half, from.y, from.z - half),
|
||||
Vec3::new(from.x + half, from.y + f64::from(height), from.z + half),
|
||||
);
|
||||
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
|
||||
let shape = Aabb::new(
|
||||
Vec3::new(x, y + 0.375, z),
|
||||
Vec3::new(x + 1.0, y + 0.75, z + 1.0),
|
||||
);
|
||||
start.collided_along_vector(to.subtract(from), &[shape])
|
||||
}
|
||||
/// `PositionMoveRotation.calculateAbsolute` for an End portal's transition
|
||||
/// of an entity facing `[yaw, pitch]`: into the End it faces west and keeps
|
||||
/// its pitch (`Relative.X_ROT`); out of it both turn by the world spawn's
|
||||
/// angles (`Relative.ROTATION`). The pitch stays within ±90.
|
||||
fn end_portal_rotation(into_end: bool, [yaw, pitch]: [f32; 2], spawn: [f32; 2]) -> [f32; 2] {
|
||||
let (yaw, pitch) = if into_end {
|
||||
// Direction.WEST.toYRot().
|
||||
(90.0, pitch)
|
||||
} else {
|
||||
(yaw + spawn[0], pitch + spawn[1])
|
||||
};
|
||||
[yaw, pitch.clamp(-90.0, 90.0)]
|
||||
}
|
||||
/// A waiting entity as `horizon_portal_arrivals.dat` keeps it.
|
||||
fn arrival_tag(a: &Arrival) -> Tag {
|
||||
let p = &a.plan;
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(p.dim.id().into())),
|
||||
("x", Tag::Int(p.approximate.x)),
|
||||
("y", Tag::Int(p.approximate.y)),
|
||||
("z", Tag::Int(p.approximate.z)),
|
||||
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
|
||||
("right", Tag::Double(p.offset.x.to_bits())),
|
||||
("up", Tag::Double(p.offset.y.to_bits())),
|
||||
("forward", Tag::Double(p.offset.z.to_bits())),
|
||||
("width", Tag::Float(p.width.to_bits())),
|
||||
("height", Tag::Float(p.height.to_bits())),
|
||||
("end", Tag::Int(i32::from(p.end))),
|
||||
("entity", a.tag.clone()),
|
||||
])
|
||||
}
|
||||
/// The waiting entity `arrival_tag` wrote.
|
||||
fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
|
||||
let int = |k: &str| match a.get(k) {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let float = |k: &str| match a.get(k) {
|
||||
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let double = |k: &str| match a.get(k) {
|
||||
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let dim = Dim::from_id(&horizon_client::save::text(a.get("dimension"))?);
|
||||
let (Some(x), Some(y), Some(z), Some(tag)) = (int("x"), int("y"), int("z"), a.get("entity"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(Arrival {
|
||||
plan: Plan {
|
||||
dim,
|
||||
approximate: BlockPos::new(x, y, z),
|
||||
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
|
||||
offset: Vec3::new(
|
||||
double("right").unwrap_or(0.5),
|
||||
double("up").unwrap_or(0.0),
|
||||
double("forward").unwrap_or(0.0),
|
||||
),
|
||||
width: float("width").unwrap_or(0.25),
|
||||
height: float("height").unwrap_or(0.25),
|
||||
end: int("end").unwrap_or(0) != 0,
|
||||
},
|
||||
tag: tag.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The world border's extent.
|
||||
@@ -146,8 +257,54 @@ impl Game<'_> {
|
||||
offset,
|
||||
width,
|
||||
height,
|
||||
end: false,
|
||||
}
|
||||
}
|
||||
/// The plan of an item or mob going through the portal block at `entry`:
|
||||
/// an End portal's fixed destination, else the Nether portal's.
|
||||
fn entity_plan(
|
||||
&self,
|
||||
entry: BlockPos,
|
||||
at: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
reset_forward: bool,
|
||||
) -> Plan {
|
||||
if !self.is_end_portal(self.world.block(entry.x, entry.y, entry.z)) {
|
||||
return self.portal_plan(entry, at, width, height, reset_forward);
|
||||
}
|
||||
let spawn = self.level.world_spawn.unwrap_or_else(|| {
|
||||
self.spawn.map_or([0, 64, 0], |s| {
|
||||
[s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32]
|
||||
})
|
||||
});
|
||||
Plan::end_portal(
|
||||
self.dim,
|
||||
BlockPos::new(spawn[0], spawn[1], spawn[2]),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
fn is_end_portal(&self, state: horizon_world::registry::BlockStateId) -> bool {
|
||||
&*self.res.facts().get(state).name == "minecraft:end_portal"
|
||||
}
|
||||
/// Where an item or mob through an End portal lands once `plan`'s level
|
||||
/// has loaded around it: the bottom centre of the End's spawn point,
|
||||
/// above the platform built below it (`EndPlatformFeature.createEndPlatform`);
|
||||
/// out of the End, the top of the shared spawn's column
|
||||
/// (`Entity.adjustSpawnLocation`).
|
||||
fn end_portal_exit(&mut self, plan: &Plan) -> Vec3 {
|
||||
let p = plan.approximate;
|
||||
let centre = Vec3::new(f64::from(p.x) + 0.5, f64::from(p.y), f64::from(p.z) + 0.5);
|
||||
if plan.dim == Dim::End {
|
||||
if let Err(e) = self.end_arrival(centre) {
|
||||
log::error!("End platform: {e}");
|
||||
}
|
||||
return centre;
|
||||
}
|
||||
let y = self.motion_top(p.x, p.z);
|
||||
Vec3::new(centre.x, f64::from(y), centre.z)
|
||||
}
|
||||
/// The nether portal blocks of the chunks within `radius` of `center`
|
||||
/// (the points of interest `PortalForcer` searches): loaded chunks as
|
||||
/// they are, the others as saved; chunks never generated have none.
|
||||
@@ -520,9 +677,10 @@ impl Game<'_> {
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
|
||||
/// and minecarts that moved this tick: the portal blocks their box swept
|
||||
/// through.
|
||||
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
|
||||
/// for the items, mobs and empty boats and minecarts that moved this
|
||||
/// tick: the portal blocks their box swept through (an End portal's
|
||||
/// transition time is 0 too).
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -570,7 +728,7 @@ impl Game<'_> {
|
||||
.set_inside(pos, ENTITY_DELAY);
|
||||
}
|
||||
}
|
||||
/// The first nether portal block an entity box swept through
|
||||
/// The first nether or End portal block an entity box swept through
|
||||
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
|
||||
fn swept_portal(
|
||||
&self,
|
||||
@@ -604,11 +762,9 @@ impl Game<'_> {
|
||||
bounds,
|
||||
1_000_000,
|
||||
|p, _| {
|
||||
if self
|
||||
.res
|
||||
.behaviours()
|
||||
.portal_axis(self.world.block(p.x, p.y, p.z))
|
||||
.is_some()
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.behaviours().portal_axis(state).is_some()
|
||||
|| (self.is_end_portal(state) && touches_end_portal(from, to, width, height, p))
|
||||
{
|
||||
found = Some(p);
|
||||
return false;
|
||||
@@ -618,8 +774,8 @@ impl Game<'_> {
|
||||
);
|
||||
found
|
||||
}
|
||||
/// Whether a section `reach` touches has a nether portal state in its
|
||||
/// palette: without one, no block there is a portal. Palette answers are
|
||||
/// Whether a section `reach` touches has a nether or End portal state in
|
||||
/// its palette: without one, no block there is a portal. Palette answers are
|
||||
/// kept per chunk across ticks (`portal_sections`); a box over many
|
||||
/// sections is not checked.
|
||||
fn may_hold_portal(
|
||||
@@ -656,9 +812,8 @@ impl Game<'_> {
|
||||
(z0..=z1).any(|cz| {
|
||||
chunks.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
sections.get((cx, cz), chunk, |v| {
|
||||
behaviours
|
||||
.portal_axis(horizon_world::registry::BlockStateId(v))
|
||||
.is_some()
|
||||
let state = horizon_world::registry::BlockStateId(v);
|
||||
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
|
||||
}) & y_mask
|
||||
!= 0
|
||||
})
|
||||
@@ -670,7 +825,7 @@ impl Game<'_> {
|
||||
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
|
||||
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let e = &self.dropped[i];
|
||||
let plan = self.portal_plan(entry, e.position, 0.25, 0.25, false);
|
||||
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -686,7 +841,7 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let m = &self.mobs[i];
|
||||
let plan = self.portal_plan(entry, m.position, m.width(), m.height(), true);
|
||||
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -716,7 +871,7 @@ impl Game<'_> {
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.portal_plan(entry, b.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -741,7 +896,7 @@ impl Game<'_> {
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.portal_plan(entry, c.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -754,8 +909,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built), position, turned yaw and motion,
|
||||
/// and portal cooldown; they join the world with the chunk entities.
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
pub(super) fn resolve_arrivals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -772,21 +927,28 @@ impl Game<'_> {
|
||||
}
|
||||
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
|
||||
self.portals.dirty = true;
|
||||
let Some(exit) = self.nether_exit_portal(&plan) else {
|
||||
continue;
|
||||
};
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
let motion = rotate_y(horizon_client::entity_nbt::motion(&tag), -rotation);
|
||||
let motion = horizon_client::entity_nbt::motion(&tag);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
let (at, motion, [yaw, pitch]) = if plan.end {
|
||||
// Relative.DELTA: the motion is kept.
|
||||
let at = self.end_portal_exit(&plan);
|
||||
let into_end = plan.dim == Dim::End;
|
||||
let turn =
|
||||
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
|
||||
(at, motion, turn)
|
||||
} else {
|
||||
let Some(exit) = self.nether_exit_portal(&plan) else {
|
||||
continue;
|
||||
};
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
|
||||
};
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
|
||||
c.insert(
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![
|
||||
Tag::Float((yaw + rotation).to_bits()),
|
||||
Tag::Float(pitch.to_bits()),
|
||||
]),
|
||||
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
|
||||
);
|
||||
// setPortalCooldown before the teleport, copied by restoreFrom.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
@@ -823,75 +985,117 @@ impl Game<'_> {
|
||||
let Some(Tag::List(list)) = root.get("arrivals") else {
|
||||
return;
|
||||
};
|
||||
for a in list {
|
||||
let int = |k: &str| match a.get(k) {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let float = |k: &str| match a.get(k) {
|
||||
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let double = |k: &str| match a.get(k) {
|
||||
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let dim = match horizon_client::save::text(a.get("dimension")).as_deref() {
|
||||
Some("minecraft:the_nether") => Dim::Nether,
|
||||
Some(_) => Dim::Overworld,
|
||||
None => continue,
|
||||
};
|
||||
let (Some(x), Some(y), Some(z), Some(tag)) =
|
||||
(int("x"), int("y"), int("z"), a.get("entity"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.portals.arrivals.push(Arrival {
|
||||
plan: Plan {
|
||||
dim,
|
||||
approximate: BlockPos::new(x, y, z),
|
||||
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
|
||||
offset: Vec3::new(
|
||||
double("right").unwrap_or(0.5),
|
||||
double("up").unwrap_or(0.0),
|
||||
double("forward").unwrap_or(0.0),
|
||||
),
|
||||
width: float("width").unwrap_or(0.25),
|
||||
height: float("height").unwrap_or(0.25),
|
||||
},
|
||||
tag: tag.clone(),
|
||||
});
|
||||
}
|
||||
self.portals
|
||||
.arrivals
|
||||
.extend(list.iter().filter_map(arrival_from_tag));
|
||||
}
|
||||
/// Write the waiting entities when they changed.
|
||||
pub(super) fn save_portal_arrivals(&mut self) {
|
||||
if !self.portals.dirty || self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let list = self
|
||||
.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let p = &a.plan;
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(p.dim.id().into())),
|
||||
("x", Tag::Int(p.approximate.x)),
|
||||
("y", Tag::Int(p.approximate.y)),
|
||||
("z", Tag::Int(p.approximate.z)),
|
||||
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
|
||||
("right", Tag::Double(p.offset.x.to_bits())),
|
||||
("up", Tag::Double(p.offset.y.to_bits())),
|
||||
("forward", Tag::Double(p.offset.z.to_bits())),
|
||||
("width", Tag::Float(p.width.to_bits())),
|
||||
("height", Tag::Float(p.height.to_bits())),
|
||||
("entity", a.tag.clone()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let list = self.portals.arrivals.iter().map(arrival_tag).collect();
|
||||
let root = Tag::compound([("arrivals", Tag::List(list))]);
|
||||
if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() {
|
||||
self.portals.dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
|
||||
let spawn = BlockPos::new(-7, 70, 12);
|
||||
for from in [Dim::Overworld, Dim::Nether] {
|
||||
let plan = Plan::end_portal(from, spawn, 0.9, 1.4);
|
||||
assert_eq!(plan.dim, Dim::End);
|
||||
assert_eq!(plan.approximate, BlockPos::new(100, 50, 0));
|
||||
assert!(plan.end);
|
||||
}
|
||||
let plan = Plan::end_portal(Dim::End, spawn, 0.25, 0.25);
|
||||
assert_eq!(plan.dim, Dim::Overworld);
|
||||
assert_eq!(plan.approximate, spawn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_portal_shape_takes_falling_items_and_standing_mobs() {
|
||||
let portal = BlockPos::new(0, 100, 3);
|
||||
let at = |y| Vec3::new(0.5, y, 3.5);
|
||||
// A dropped item falling through the slab, or in it.
|
||||
assert!(touches_end_portal(
|
||||
at(100.6),
|
||||
at(100.56),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
// One tick's fall across the whole slab, in it at neither end.
|
||||
assert!(touches_end_portal(at(101.0), at(99.9), 0.25, 0.25, portal));
|
||||
// Resting below the slab (on the floor) or above it.
|
||||
assert!(!touches_end_portal(
|
||||
at(100.0),
|
||||
at(100.0),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
assert!(!touches_end_portal(
|
||||
at(100.8),
|
||||
at(100.8),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
// A cow standing in the portal block.
|
||||
assert!(touches_end_portal(at(100.0), at(100.0), 0.9, 1.4, portal));
|
||||
// Beside it.
|
||||
let beside = Vec3::new(2.0, 100.0, 3.5);
|
||||
assert!(!touches_end_portal(beside, beside, 0.9, 1.4, portal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_portals_turn_entities_like_their_teleport_transition() {
|
||||
// Into the End: facing west, pitch kept.
|
||||
assert_eq!(
|
||||
end_portal_rotation(true, [-30.0, 20.0], [45.0, 10.0]),
|
||||
[90.0, 20.0]
|
||||
);
|
||||
// Out of it: turned by the world spawn's yaw and pitch, pitch clamped.
|
||||
assert_eq!(
|
||||
end_portal_rotation(false, [-30.0, 20.0], [45.0, 10.0]),
|
||||
[15.0, 30.0]
|
||||
);
|
||||
assert_eq!(
|
||||
end_portal_rotation(false, [0.0, 80.0], [0.0, 30.0]),
|
||||
[0.0, 90.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_arrivals_keep_their_end_destination_when_saved() {
|
||||
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
|
||||
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
|
||||
let arrival = Arrival {
|
||||
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
|
||||
tag: tag.clone(),
|
||||
};
|
||||
let back = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
|
||||
assert_eq!(back.plan.dim, dim);
|
||||
assert_eq!(back.plan.approximate, arrival.plan.approximate);
|
||||
assert!(back.plan.end);
|
||||
assert_eq!(back.tag, tag);
|
||||
}
|
||||
// Arrivals saved before End portals were Nether portal plans.
|
||||
let mut old = arrival_tag(&Arrival {
|
||||
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
|
||||
tag,
|
||||
});
|
||||
if let Tag::Compound(c) = &mut old {
|
||||
c.remove(&"end".into());
|
||||
}
|
||||
assert!(!arrival_from_tag(&old).unwrap().plan.end);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ pub fn open_folder(dir: &Path) {
|
||||
log::warn!("Horizon: could not open {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
/// `Util.getPlatform().openUri`: the system browser on the link.
|
||||
pub fn open_uri(uri: &str) {
|
||||
open_folder(Path::new(uri));
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
|
||||
pub fn startup_root(
|
||||
|
||||
@@ -267,8 +267,7 @@ impl Game<'_> {
|
||||
// measured from the box's centre (HangingEntity's position).
|
||||
let (lo, hi) = p.bounds();
|
||||
let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi));
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -313,6 +313,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player);
|
||||
if ominous {
|
||||
self.drink_ominous_bottle(slot, stack);
|
||||
|
||||
@@ -609,6 +609,7 @@ impl Game<'_> {
|
||||
if inside(cursor, bx, by, 20.0, 18.0) {
|
||||
let open = !self.book_open(book_type);
|
||||
self.level.recipe_book.open[book_type.index()] = open;
|
||||
self.send_book_settings(book_type);
|
||||
if !open {
|
||||
self.recipe_ui.overlay = None;
|
||||
}
|
||||
@@ -694,6 +695,7 @@ impl Game<'_> {
|
||||
if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) {
|
||||
let filtering = !self.book_filtering(book_type);
|
||||
self.level.recipe_book.filtering[book_type.index()] = filtering;
|
||||
self.send_book_settings(book_type);
|
||||
self.recipe_ui.page = 0;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
@@ -714,6 +716,22 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book.
|
||||
fn send_book_settings(&self, book_type: BookType) {
|
||||
self.send(
|
||||
horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings(
|
||||
horizon_protocol::packets::recipes::RecipeBookChangeSettings {
|
||||
book_type: book_type.index() as i32,
|
||||
open: self.book_open(book_type),
|
||||
filtering: self.book_filtering(book_type),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> {
|
||||
let book = self.active_book()?;
|
||||
Some((self.book_open(book), self.book_filtering(book)))
|
||||
}
|
||||
/// How many displays the client's book holds.
|
||||
pub(super) fn recipe_book_known(&self) -> usize {
|
||||
self.recipe_ui.client.displays().len()
|
||||
|
||||
@@ -117,13 +117,17 @@ pub(super) struct Remote {
|
||||
joined: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
pub(super) use_item: Option<Arc<str>>,
|
||||
dimension: DimensionType,
|
||||
/// What chunk packets decode with (the dimension's height and sky, the
|
||||
/// biome registry); replaced whenever one of them changes.
|
||||
pub(super) chunk_format: Arc<ChunkFormat>,
|
||||
/// Decodes chunk packets as they arrive, off the frame.
|
||||
decoder: ChunkDecoder,
|
||||
view_distance: i32,
|
||||
/// Options.serverRenderDistance: the server's view distance from the
|
||||
/// login or the last chunk-cache-radius packet.
|
||||
pub(super) view_distance: i32,
|
||||
sequence: i32,
|
||||
position_sender: horizon_client::player_sync::PositionSender,
|
||||
last_input: u8,
|
||||
@@ -189,6 +193,10 @@ pub(super) struct Remote {
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
/// Keys whose presses send a packet (swap hands, drop, a mount's
|
||||
/// inventory): like the click queue, they wait for the tick's
|
||||
/// `handleKeybinds` phase rather than going out between window events.
|
||||
pub(super) pending_keys: Vec<KeyCode>,
|
||||
stop_mining: bool,
|
||||
placing: Option<([i32; 3], u64)>,
|
||||
death_message: Option<String>,
|
||||
@@ -212,6 +220,9 @@ pub(super) struct Remote {
|
||||
/// `ClientboundTransferPacket`'s target: when the connection ends the
|
||||
/// game connects here, the stored cookies carried over (`TransferState`).
|
||||
transfer: Option<String>,
|
||||
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
|
||||
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
|
||||
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
}
|
||||
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
@@ -394,6 +405,9 @@ impl Game<'_> {
|
||||
self.carried = None;
|
||||
self.using = None;
|
||||
self.clicks.miss_time = 0;
|
||||
// A new LocalPlayer has no effects: the server sends what it has
|
||||
// (ClientboundUpdateMobEffectPacket on placement).
|
||||
self.potions = Default::default();
|
||||
self.set_mode_id(0);
|
||||
self.experience = Experience {
|
||||
level: 0,
|
||||
@@ -428,6 +442,7 @@ impl Game<'_> {
|
||||
joined: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
use_item: None,
|
||||
dimension: DimensionType::vanilla("minecraft:overworld"),
|
||||
chunk_format: Arc::new(ChunkFormat::new(
|
||||
&DimensionType::vanilla("minecraft:overworld"),
|
||||
@@ -473,6 +488,7 @@ impl Game<'_> {
|
||||
destroying: None,
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
pending_keys: Vec::new(),
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
death_message: None,
|
||||
@@ -486,6 +502,7 @@ impl Game<'_> {
|
||||
chunk_center: (0, 0),
|
||||
backlog: VecDeque::new(),
|
||||
backlog_since: None,
|
||||
server_links: Vec::new(),
|
||||
transfer: None,
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
@@ -862,6 +879,11 @@ impl Game<'_> {
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ServerLinks(links) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.server_links = super::server_links::trusted(links);
|
||||
}
|
||||
}
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::DecodeError(e) => {
|
||||
@@ -1621,7 +1643,11 @@ impl Game<'_> {
|
||||
}
|
||||
C::Login(p) => {
|
||||
self.level.hardcore = p.hardcore;
|
||||
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
|
||||
// new LocalPlayer (slot 0).
|
||||
self.selected = 0;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.view_distance = p.chunk_radius;
|
||||
@@ -1639,8 +1665,10 @@ impl Game<'_> {
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
// A new player, without effects: the server sends them again.
|
||||
// A new player, without effects (the server sends them
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -2174,6 +2202,11 @@ impl Game<'_> {
|
||||
self.honey_slide_particles(at);
|
||||
}
|
||||
}
|
||||
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
{
|
||||
self.remote_complete_using();
|
||||
}
|
||||
}
|
||||
C::Animate(p) => self.remote_animate(p.id, p.action),
|
||||
// ClientPacketListener.handleSwingAnimation: the entity swings
|
||||
@@ -2336,10 +2369,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
|
||||
// carried index stays, so the next tick answers with SetCarriedItem.
|
||||
C::SetHeldSlot(p) => {
|
||||
self.selected = p.slot.clamp(0, 8) as usize;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(self.selected);
|
||||
if (0..9).contains(&p.slot) {
|
||||
self.selected = p.slot as usize;
|
||||
}
|
||||
}
|
||||
C::ContainerSetContent(p) => {
|
||||
@@ -3171,12 +3205,6 @@ impl Game<'_> {
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
|
||||
// for a server's client (multiplayer and the default single player),
|
||||
// so the entries advance here or the slot stays shaded until the
|
||||
// server says the entry ended. `onCooldownEnded` is the plain
|
||||
// ItemCooldowns' no-op on a client.
|
||||
self.cooldowns.tick();
|
||||
// Leaving a slot clears its bundle's selection here and on the server.
|
||||
self.tick_bundle_hover();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
@@ -3212,6 +3240,14 @@ impl Game<'_> {
|
||||
// Window callbacks only queue clicks, so an interaction cannot leak
|
||||
// between CLIENT_TICK_END and a later pong.
|
||||
self.remote_handle_input();
|
||||
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
|
||||
// handleKeybinds: this path replaces `tick_world`'s for a server's
|
||||
// client (multiplayer and the default single player), so the entries
|
||||
// advance here or the slot stays shaded until the server says the
|
||||
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
|
||||
// a client. Ticking it after the keybinds keeps a held use from
|
||||
// trying again a tick before the server's entry ends.
|
||||
self.cooldowns.tick();
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
@@ -3333,6 +3369,11 @@ impl Game<'_> {
|
||||
resources: self.motion,
|
||||
}
|
||||
.controlled_simulation_tick(keys)?;
|
||||
} else {
|
||||
// LivingEntity.baseTick counts hurtTime down for a dead or riding
|
||||
// player too (controlled_simulation_tick does it otherwise): left
|
||||
// at 10, the hurt tilt restarted every frame and shook the camera.
|
||||
player.health.advance_clock();
|
||||
}
|
||||
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
|
||||
// jump, a spectator, landing) is sent to the server.
|
||||
@@ -3930,6 +3971,9 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.destroying = Some((hit.pos, face, 0.0));
|
||||
}
|
||||
// startDestroyBlock plays the first hit and crack itself; the
|
||||
// server's level events 2019 and 2020 do the rest.
|
||||
self.breaking_block_effects(hit.pos, hit.face, true);
|
||||
self.mining = Some(Mining {
|
||||
pos: hit.pos,
|
||||
progress: 0.0,
|
||||
@@ -3942,20 +3986,6 @@ impl Game<'_> {
|
||||
};
|
||||
let done = done + progress;
|
||||
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
|
||||
if ticks % 4 == 1 {
|
||||
let c = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&facts.sounds.hit,
|
||||
Some(c),
|
||||
(facts.sounds.volume + 1.0) / 8.0,
|
||||
facts.sounds.pitch * 0.5,
|
||||
);
|
||||
}
|
||||
self.crack_particle(pos, hit.face, state);
|
||||
if done >= 1.0 {
|
||||
let seq = self.next_sequence();
|
||||
self.predict_break(pos, state, seq);
|
||||
@@ -4354,13 +4384,27 @@ impl Game<'_> {
|
||||
let Some(info) = self.res.items().get(&held.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
// MultiPlayerGameMode.useItem runs the use here too (inside
|
||||
// startPrediction): the `use_cooldown` component starts its entry, and
|
||||
// the server's ClientboundCooldownPacket confirms it.
|
||||
self.apply_use_cooldown(&held);
|
||||
// Food starts being eaten (Consumable.startConsuming), a book and
|
||||
// quill opens.
|
||||
self.with_hand(hand, |g| g.start_eating(&info));
|
||||
// MultiPlayerGameMode.useItem's prediction: an item cooling down
|
||||
// passes (its packet still goes).
|
||||
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return false;
|
||||
}
|
||||
// Food starts being eaten (Consumable.startConsuming). A food that
|
||||
// can't be eaten (full and not always-edible) fails the use
|
||||
// (InteractionResult.FAIL): startUseItem tries the other hand, and
|
||||
// no cooldown or use animation starts.
|
||||
let eating = self.with_hand(hand, |g| g.start_eating(&info));
|
||||
if info.food.is_some() && !eating {
|
||||
return false;
|
||||
}
|
||||
// It runs the use here too: ItemStack.use starts the `use_cooldown`
|
||||
// of an item with no use time, and the server's
|
||||
// ClientboundCooldownPacket confirms it; one held in use starts it at
|
||||
// finishUsingItem (remote_complete_using).
|
||||
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
|
||||
self.apply_use_cooldown(&held);
|
||||
}
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
}
|
||||
@@ -4432,14 +4476,25 @@ impl Game<'_> {
|
||||
self.remote_use_item(Hand::Off);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a
|
||||
/// consumable's eating animation and its crumbs. The server eats it and
|
||||
/// sends the stack, the hunger and the sounds.
|
||||
/// use (`held_until_released`), and a consumable's eating animation and
|
||||
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
|
||||
/// stack, its hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
let Some((slot, ticks)) = self.using else {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// The use stops when the hand no longer holds the item it began with
|
||||
// (ItemStack.isSameItem).
|
||||
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if ticks == 0 {
|
||||
r.use_item = item;
|
||||
} else if r.use_item != item {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
@@ -4464,20 +4519,51 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item.
|
||||
let duration = consumable.ticks().max(0) as u32;
|
||||
let remaining = duration - ticks.min(duration);
|
||||
if consumable.particles && consumable.should_emit(remaining as i32) {
|
||||
// Consumable.startConsuming eats one with no use time at once, without
|
||||
// a use (nor a USE_ITEM_COMPLETE).
|
||||
if consumable.ticks() <= 0 {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
|
||||
// countdown goes on past zero (LivingEntity.updateUsingItem completes
|
||||
// only on the server) until the server's USE_ITEM_COMPLETE.
|
||||
let remaining = consumable.ticks() - ticks as i32;
|
||||
if consumable.particles && consumable.should_emit(remaining) {
|
||||
self.eat_particles(&stack, 5);
|
||||
}
|
||||
if ticks + 1 >= duration {
|
||||
self.using = None;
|
||||
if consumable.particles {
|
||||
self.eat_particles(&stack, 16);
|
||||
}
|
||||
} else {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
|
||||
/// which runs on the client too (Consumable.onConsume calls its
|
||||
/// ConsumableListeners on both sides): the client's FoodData eats at once,
|
||||
/// so a held right click finds `Player.canEat` false instead of starting
|
||||
/// another bite before the server's ClientboundSetHealth arrives.
|
||||
fn remote_complete_using(&mut self) {
|
||||
let Some((slot, _)) = self.using.take() else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = self.stack_in(slot).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return;
|
||||
};
|
||||
if info.consumable.as_ref().is_some_and(|c| c.particles) {
|
||||
self.eat_particles(&stack, 16);
|
||||
}
|
||||
if let Some((nutrition, saturation)) = info.food
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
// FoodData.eat(nutrition, saturation), as finish_consuming does;
|
||||
// the next ClientboundSetHealth replaces it.
|
||||
let (_, food, sat) = &mut r.health;
|
||||
*food = (*food + nutrition as i32).clamp(0, 20);
|
||||
*sat = (*sat + saturation).clamp(0.0, *food as f32);
|
||||
self.apply_remote_health();
|
||||
}
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
}
|
||||
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
|
||||
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
@@ -4542,6 +4628,31 @@ impl Game<'_> {
|
||||
}
|
||||
let (attack, use_item) = (self.left, self.right);
|
||||
input.tick(&mut RemoteInputWorld(self), attack, use_item);
|
||||
// handleKeybinds' keybind polls: the queued presses act now, in tick
|
||||
// order, so no packet goes out between CLIENT_TICK_END and a pong.
|
||||
let pending = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.pending_keys))
|
||||
.unwrap_or_default();
|
||||
for key in pending {
|
||||
match key {
|
||||
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
|
||||
KeyCode::KeyQ if !self.spectator() => {
|
||||
let all = self.keys.contains(&KeyCode::ControlLeft);
|
||||
self.remote_drop(all);
|
||||
}
|
||||
KeyCode::KeyE if self.riding_mount_with_menu() => {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 5,
|
||||
data: 0,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.clicks.miss_time > 0 {
|
||||
self.clicks.miss_time -= 1;
|
||||
}
|
||||
@@ -4653,22 +4764,8 @@ impl Game<'_> {
|
||||
if self.spectator() {
|
||||
return Ok(true);
|
||||
}
|
||||
let all = self.keys.contains(&KeyCode::ControlLeft);
|
||||
// MultiPlayerGameMode.dropItem: predict removal before sending
|
||||
// the action, then swing only if a nonempty stack was removed.
|
||||
let dropped = horizon_client::items::take_held_drop(
|
||||
&mut self.inventory[self.selected],
|
||||
all,
|
||||
false,
|
||||
);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: if all { 4 } else { 5 },
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
if dropped.is_some() {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -4676,12 +4773,9 @@ impl Game<'_> {
|
||||
// horse or llama, the server opens its inventory
|
||||
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
|
||||
KeyCode::KeyE if self.riding_mount_with_menu() => {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 5,
|
||||
data: 0,
|
||||
}));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
KeyCode::KeyE => {
|
||||
@@ -4728,6 +4822,21 @@ impl Game<'_> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
|
||||
/// the action; swing only if a nonempty stack was removed.
|
||||
fn remote_drop(&mut self, all: bool) {
|
||||
let dropped =
|
||||
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: if all { 4 } else { 5 },
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
if dropped.is_some() {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
}
|
||||
}
|
||||
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
|
||||
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
|
||||
/// sends both slots back; nothing is predicted.
|
||||
|
||||
@@ -19,6 +19,9 @@ impl Screen {
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning
|
||||
| Self::Mod => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
@@ -35,20 +38,35 @@ impl Screen {
|
||||
}
|
||||
|
||||
/// PauseScreen buttons: (x, y, width, label key, action id, active).
|
||||
/// `links`: the `minecraft:server_links` dialog's button, which the
|
||||
/// `pause_screen_additions` tag (empty in vanilla) falls back to when the
|
||||
/// server sent links — a full-width row that pushes the last two rows down.
|
||||
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
|
||||
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
fn pause_buttons(
|
||||
w: f32,
|
||||
h: f32,
|
||||
remote: bool,
|
||||
links: bool,
|
||||
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
|
||||
let (grid_w, grid_h) = (212.0, 166.0);
|
||||
let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 });
|
||||
let x0 = ((w - grid_w) * 0.5).floor();
|
||||
let y0 = ((h - grid_h) * 0.25).floor();
|
||||
vec![
|
||||
let down = if links { 24.0 } else { 0.0 };
|
||||
let mut v = vec![
|
||||
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
|
||||
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
|
||||
(x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true),
|
||||
(x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true),
|
||||
];
|
||||
if links {
|
||||
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
|
||||
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
|
||||
}
|
||||
v.extend([
|
||||
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
|
||||
(
|
||||
x0 + 110.0,
|
||||
y0 + 122.0,
|
||||
y0 + 122.0 + down,
|
||||
98.0,
|
||||
"options.worldOptions.button",
|
||||
9,
|
||||
@@ -56,7 +74,7 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
|
||||
),
|
||||
(
|
||||
x0 + 4.0,
|
||||
y0 + 146.0,
|
||||
y0 + 146.0 + down,
|
||||
204.0,
|
||||
if remote {
|
||||
"menu.disconnect"
|
||||
@@ -66,16 +84,17 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
|
||||
2,
|
||||
true,
|
||||
),
|
||||
]
|
||||
]);
|
||||
v
|
||||
}
|
||||
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
|
||||
/// Play with Friends; the others stay inactive.
|
||||
const FRIENDS_ICON: usize = 2;
|
||||
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
|
||||
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
|
||||
let y = ((h - 166.0) * 0.25).floor() + 98.0;
|
||||
let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0;
|
||||
[
|
||||
(x0, y, "gui/sprites/pause_menu/bug"),
|
||||
(x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"),
|
||||
@@ -120,7 +139,9 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
|
||||
let links = !self.server_links().is_empty();
|
||||
let mut tooltip = None;
|
||||
for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -133,9 +154,12 @@ impl Game<'_> {
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
// PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP.
|
||||
if id == 11 && hover {
|
||||
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
let mut tooltip = None;
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() {
|
||||
let active = i == FRIENDS_ICON && friends;
|
||||
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
|
||||
// The icon has no label: name it for controller focus and the
|
||||
@@ -168,7 +192,13 @@ impl Game<'_> {
|
||||
}
|
||||
// SpriteIconButton.withTootip: the label as a tooltip.
|
||||
if let Some(tip) = tooltip {
|
||||
ui::tooltip(batch, atlas, &[tip], self.cursor);
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&tip, batch.width)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, self.cursor);
|
||||
}
|
||||
}
|
||||
pub(super) fn pause_click(&mut self) -> Result<()> {
|
||||
@@ -180,8 +210,9 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
(b.width, b.height)
|
||||
};
|
||||
let friends = pause_icons(w, h)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server())
|
||||
let links = !self.server_links().is_empty();
|
||||
let friends = pause_icons(w, h, links)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server(), links)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
@@ -200,6 +231,11 @@ impl Game<'_> {
|
||||
Some(4) => self.open_advancements(true),
|
||||
Some(9) => self.open_world_options(),
|
||||
Some(10) => self.screen = Screen::Friends,
|
||||
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
|
||||
Some(11) => {
|
||||
self.links_ui.scroll = 0.0;
|
||||
self.screen = Screen::ServerLinks;
|
||||
}
|
||||
Some(2) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
@@ -357,7 +393,7 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Screen, pause_buttons};
|
||||
use super::{Screen, pause_buttons, pause_icons};
|
||||
|
||||
#[test]
|
||||
fn menu_blur_matches_vanilla_screen_backgrounds() {
|
||||
@@ -390,11 +426,32 @@ mod tests {
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
|
||||
let buttons = pause_buttons(427.0, 240.0, remote);
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false);
|
||||
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
|
||||
assert_eq!(quit.3, label);
|
||||
assert!(quit.5);
|
||||
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_links_adds_a_full_width_row_above_options() {
|
||||
let with = pause_buttons(427.0, 240.0, true, true);
|
||||
let without = pause_buttons(427.0, 240.0, true, false);
|
||||
let links = with.iter().find(|b| b.4 == 11).unwrap();
|
||||
assert_eq!(links.2, 204.0);
|
||||
assert_eq!(links.3, "menu.server_links");
|
||||
assert!(links.5);
|
||||
// The icon row sits above it; Options and Disconnect move down 24.
|
||||
let icons = pause_icons(427.0, 240.0, true);
|
||||
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
|
||||
assert_eq!(icons[0].1, y0_icons);
|
||||
assert!(links.1 > icons[0].1);
|
||||
for b in &with {
|
||||
if b.4 == 1 || b.4 == 9 || b.4 == 2 {
|
||||
let same = without.iter().find(|o| o.4 == b.4).unwrap();
|
||||
assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens
|
||||
//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's
|
||||
//! fallback when a server sent ClientboundServerLinksPacket): a scrollable
|
||||
//! list of the server's links, each opened through `ConfirmLinkScreen`
|
||||
//! (Screen.clickUrlAction), and the `dialog/warning_button`'s
|
||||
//! DialogScreen.WarningScreen ("Note about custom screens").
|
||||
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen
|
||||
use super::{Game, Screen, packs::open_uri};
|
||||
use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui};
|
||||
use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind};
|
||||
|
||||
/// HeaderAndFooterLayout's header and footer heights; the content wants to
|
||||
/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`).
|
||||
const HEADER: f32 = 33.0;
|
||||
const FOOTER: f32 = 33.0;
|
||||
const CONTENT_TOP: f32 = HEADER + 30.0;
|
||||
/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200.
|
||||
const LINK_WIDTH: f32 = 310.0;
|
||||
/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons.
|
||||
const ROW: f32 = 22.0;
|
||||
/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6).
|
||||
const SCROLL_RESERVE: f32 = 10.0;
|
||||
/// DialogScreen's `dialog/warning_button` ImageButton.
|
||||
const WARNING_BUTTON: f32 = 20.0;
|
||||
|
||||
/// The ServerLinks screen's state: ScrollableLayout's scroll and the link
|
||||
/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct LinksUi {
|
||||
pub scroll: f32,
|
||||
pub confirm: Option<String>,
|
||||
}
|
||||
|
||||
/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI
|
||||
/// (java.net.URI's scheme then the allowlist; whitespace-free).
|
||||
pub(super) fn valid_untrusted_uri(uri: &str) -> bool {
|
||||
let Some(colon) = uri.find(':') else {
|
||||
return false;
|
||||
};
|
||||
let scheme = &uri[..colon];
|
||||
let mut chars = scheme.chars();
|
||||
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
!uri.chars().any(|c| c.is_control() || c == ' ')
|
||||
}
|
||||
/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links
|
||||
/// that validate, warning about the dropped ones.
|
||||
pub(super) fn trusted(links: Vec<ServerLink>) -> Vec<ServerLink> {
|
||||
links
|
||||
.into_iter()
|
||||
.filter(|l| {
|
||||
let ok = valid_untrusted_uri(&l.link);
|
||||
if !ok {
|
||||
log::warn!("Horizon: server sent an invalid link: {}", l.link);
|
||||
}
|
||||
ok
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerLinks.Entry.displayName`: `known_server_link.<name>` for a known
|
||||
/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else.
|
||||
fn link_label(link: &ServerLink) -> Text {
|
||||
const KNOWN: [&str; 10] = [
|
||||
"report_bug",
|
||||
"community_guidelines",
|
||||
"support",
|
||||
"status",
|
||||
"feedback",
|
||||
"community",
|
||||
"website",
|
||||
"forums",
|
||||
"news",
|
||||
"announcements",
|
||||
];
|
||||
match &link.kind {
|
||||
ServerLinkKind::Known(id) => Text::translate(
|
||||
&format!(
|
||||
"known_server_link.{}",
|
||||
KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0])
|
||||
),
|
||||
&[],
|
||||
),
|
||||
ServerLinkKind::Custom(tag) => {
|
||||
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrolled column of link buttons: content height and, for a list that
|
||||
/// fits, the `HeaderAndFooterLayout` contents frame's top.
|
||||
fn list_height(n: usize) -> f32 {
|
||||
n as f32 * ROW - 2.0
|
||||
}
|
||||
fn list_top(h: f32, n: usize) -> f32 {
|
||||
CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER))
|
||||
}
|
||||
fn scroll_limit(h: f32, n: usize) -> f32 {
|
||||
(list_height(n) - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
|
||||
/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the
|
||||
/// footer `Back` button's corner. `top` includes the scroll.
|
||||
fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> {
|
||||
let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor();
|
||||
(0..n).map(|i| (x, top + i as f32 * ROW)).collect()
|
||||
}
|
||||
/// The warning button beside the title (DialogScreen's title row: 10 pixels
|
||||
/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds).
|
||||
fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) {
|
||||
let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0;
|
||||
let y = ((HEADER - WARNING_BUTTON) / 2.0).floor();
|
||||
if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON {
|
||||
(0.0f32.max(w - 40.0), 5.0f32.min(h))
|
||||
} else {
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
fn back_button(w: f32, h: f32) -> (f32, f32) {
|
||||
(
|
||||
((w - 200.0) / 2.0).floor(),
|
||||
h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `connection.serverLinks()`: the links the open server sent (empty for
|
||||
/// a world without a connection or one that sent none).
|
||||
pub(super) fn server_links(&self) -> &[ServerLink] {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.map_or(&[], |r| r.server_links.as_slice())
|
||||
}
|
||||
/// DialogScreen over the pause menu: `menu.server_links.title`, the
|
||||
/// scrollable link buttons and `gui.back` in the footer.
|
||||
pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let links = self.server_links().to_vec();
|
||||
self.links_ui.scroll = self
|
||||
.links_ui
|
||||
.scroll
|
||||
.clamp(0.0, scroll_limit(h, links.len()));
|
||||
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
|
||||
// The title row: the title, then 10 pixels on the warning button.
|
||||
let title = Text::translate("menu.server_links.title", &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
let tx = ((w - tw - 30.0) / 2.0).floor();
|
||||
batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true);
|
||||
let (wx, wy) = warning_button(w, h, tw);
|
||||
let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON);
|
||||
horizon_client::widgets::record(
|
||||
"menu.custom_screen_info.tooltip",
|
||||
[wx, wy, WARNING_BUTTON, WARNING_BUTTON],
|
||||
true,
|
||||
);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
if wh {
|
||||
"gui/sprites/dialog/warning_button_highlighted"
|
||||
} else {
|
||||
"gui/sprites/dialog/warning_button"
|
||||
},
|
||||
wx,
|
||||
wy,
|
||||
WARNING_BUTTON,
|
||||
WARNING_BUTTON,
|
||||
u32::MAX,
|
||||
);
|
||||
let first = batch.vertices.len();
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) {
|
||||
let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
LINK_WIDTH,
|
||||
20.0,
|
||||
&link_label(link),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
(w / 2.0).floor() + 155.0 + 4.0,
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
list_height(links.len()),
|
||||
self.links_ui.scroll.trunc(),
|
||||
);
|
||||
let (bx, by) = back_button(w, h);
|
||||
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
bx,
|
||||
by,
|
||||
200.0,
|
||||
20.0,
|
||||
&Text::translate("gui.back", &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
if wh {
|
||||
ui::tooltip(
|
||||
batch,
|
||||
atlas,
|
||||
&[Text::translate("menu.custom_screen_info.tooltip", &[])],
|
||||
self.cursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A click on `Screen::ServerLinks`: a link, the warning button or Back.
|
||||
pub(super) fn server_links_click(&mut self) -> Result<()> {
|
||||
let (w, h) = self.gui_size();
|
||||
let links = self.server_links().to_vec();
|
||||
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
if inside
|
||||
&& let Some(i) = link_buttons(w, top, links.len())
|
||||
.iter()
|
||||
.position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0))
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
self.click_link(&links[i].link);
|
||||
return Ok(());
|
||||
}
|
||||
let title = Text::translate("menu.server_links.title", &[]);
|
||||
let tw = self.gui.atlas.font.width_text(&title);
|
||||
let (wx, wy) = warning_button(w, h, tw);
|
||||
if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) {
|
||||
self.audio.play_button_click();
|
||||
self.screen = Screen::DialogWarning;
|
||||
return Ok(());
|
||||
}
|
||||
let (bx, by) = back_button(w, h);
|
||||
if ui::hovered(self.cursor, bx, by, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on
|
||||
/// Links" asks with ConfirmLinkScreen, else the browser opens directly.
|
||||
fn click_link(&mut self, url: &str) {
|
||||
if !self.settings.chat_links {
|
||||
return;
|
||||
}
|
||||
if self.settings.chat_links_prompt {
|
||||
self.links_ui.confirm = Some(url.to_owned());
|
||||
self.screen = Screen::ConfirmLink;
|
||||
} else {
|
||||
open_uri(url);
|
||||
}
|
||||
}
|
||||
/// `ScrollableLayout`'s mouse wheel over the links.
|
||||
pub(super) fn server_links_scroll(&mut self, delta: f32) {
|
||||
let (_, h) = self.gui_size();
|
||||
self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0)
|
||||
.clamp(0.0, scroll_limit(h, self.server_links().len()));
|
||||
}
|
||||
/// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the
|
||||
/// following website?", the link, the red warning, and Yes / Copy Link to
|
||||
/// Clipboard / No.
|
||||
pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let url = self.links_ui.confirm.clone().unwrap_or_default();
|
||||
let layout = confirm_link_layout(atlas, (w, h), &url);
|
||||
for (line, x, y) in &layout.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"];
|
||||
for ((x, y), key) in layout.buttons.iter().zip(labels) {
|
||||
let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
*x,
|
||||
*y,
|
||||
100.0,
|
||||
20.0,
|
||||
&Text::translate(key, &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// ConfirmLinkScreen's answer: yes opens the browser, the middle button
|
||||
/// copies the link, no closes; all return to Server Links.
|
||||
pub(super) fn confirm_link_click(&mut self) {
|
||||
let (w, h) = self.gui_size();
|
||||
let url = self.links_ui.confirm.clone().unwrap_or_default();
|
||||
let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url);
|
||||
for (i, (x, y)) in layout.buttons.iter().enumerate() {
|
||||
if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) {
|
||||
continue;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
match i {
|
||||
0 => open_uri(&url),
|
||||
1 => {
|
||||
self.clipboard.set(&url);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.screen = Screen::ServerLinks;
|
||||
return;
|
||||
}
|
||||
}
|
||||
/// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect
|
||||
/// button ("Custom screen rejected" is the shown reason) and Back.
|
||||
pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let layout = dialog_warning_layout(atlas, (w, h));
|
||||
for (line, x, y) in &layout.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
let quit = if self.external_server() {
|
||||
"menu.disconnect"
|
||||
} else {
|
||||
"menu.returnToMenu"
|
||||
};
|
||||
for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) {
|
||||
let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
150.0,
|
||||
20.0,
|
||||
&Text::translate(key, &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// WarningScreen's answer: disconnect, or back to Server Links.
|
||||
pub(super) fn dialog_warning_click(&mut self) -> Result<()> {
|
||||
let (w, h) = self.gui_size();
|
||||
let layout = dialog_warning_layout(&self.gui.atlas, (w, h));
|
||||
for (i, (x, y)) in layout.buttons.iter().enumerate() {
|
||||
if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) {
|
||||
continue;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
if i == 0 {
|
||||
// DialogConnectionAccess.disconnect: leave with the custom
|
||||
// screens' reason.
|
||||
let reason = self.translate(
|
||||
"menu.custom_screen_info.disconnect",
|
||||
"Custom screen rejected",
|
||||
);
|
||||
self.leave_server(Some(reason))?;
|
||||
} else {
|
||||
self.screen = Screen::ServerLinks;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A confirm screen's laid-out text (title, message, extra warning lines) and
|
||||
/// button corners; the whole column centred (ConfirmScreen.init).
|
||||
struct ConfirmLayout {
|
||||
lines: Vec<(Text, f32, f32)>,
|
||||
buttons: Vec<(f32, f32)>,
|
||||
}
|
||||
/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at
|
||||
/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16.
|
||||
fn confirm_layout(
|
||||
font: &horizon_client::font::Font,
|
||||
(w, h): (f32, f32),
|
||||
title: &Text,
|
||||
message: &Text,
|
||||
extra: &[Text],
|
||||
button_widths: &[f32],
|
||||
) -> ConfirmLayout {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
// LinearLayout vertical spacing 8; the button row's children pad 16 on top.
|
||||
let extra_h = 17.0 * extra.len() as f32;
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0;
|
||||
let mut y = ((h - total) / 2.0).trunc();
|
||||
let mut lines = Vec::new();
|
||||
let mut push = |line: &Text, at: f32| {
|
||||
let lw = font.width_text(line);
|
||||
lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at));
|
||||
};
|
||||
push(title, y);
|
||||
y += 17.0;
|
||||
for line in &message {
|
||||
push(line, y);
|
||||
y += 9.0;
|
||||
}
|
||||
for line in extra {
|
||||
y += 8.0;
|
||||
push(line, y);
|
||||
y += 9.0;
|
||||
}
|
||||
let row_w: f32 = button_widths.iter().sum::<f32>() + 4.0 * (button_widths.len() - 1) as f32;
|
||||
let by = y + 8.0 + 16.0;
|
||||
let mut bx = ((w - row_w) / 2.0).trunc();
|
||||
let buttons = button_widths
|
||||
.iter()
|
||||
.map(|bw| {
|
||||
let p = (bx, by);
|
||||
bx += bw + 4.0;
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
ConfirmLayout { lines, buttons }
|
||||
}
|
||||
fn confirm_link_layout(
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
size: (f32, f32),
|
||||
url: &str,
|
||||
) -> ConfirmLayout {
|
||||
// ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the
|
||||
// message, `chat.link.warning` in its red (0xFFCCCC).
|
||||
confirm_layout(
|
||||
&atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&Text::literal(url.to_owned()),
|
||||
&[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)],
|
||||
&[100.0; 3],
|
||||
)
|
||||
}
|
||||
fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout {
|
||||
confirm_layout(
|
||||
&atlas.font,
|
||||
size,
|
||||
&Text::translate("menu.custom_screen_info.title", &[]),
|
||||
&Text::translate("menu.custom_screen_info.contents", &[]),
|
||||
&[],
|
||||
&[150.0; 2],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn untrusted_uris_are_http_or_https_with_a_scheme() {
|
||||
for ok in [
|
||||
"https://example.net/x",
|
||||
"http://example.net",
|
||||
"HTTPS://EXAMPLE.NET",
|
||||
] {
|
||||
assert!(valid_untrusted_uri(ok), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"example.net/no-scheme",
|
||||
"ftp://example.net",
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://exa mple.net",
|
||||
"://example.net",
|
||||
"",
|
||||
] {
|
||||
assert!(!valid_untrusted_uri(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,11 +164,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
[self.yaw, self.pitch]
|
||||
};
|
||||
let scale = self.view_scale();
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ impl Game<'_> {
|
||||
}
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::splat(PrimedTnt::SIZE),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for t in &self.tnts {
|
||||
// EntityRenderer.shouldRender.
|
||||
|
||||
@@ -495,7 +495,7 @@ impl Game<'_> {
|
||||
let range = 10.0
|
||||
* super::entity_culling::default_range(
|
||||
glam::DVec3::splat(2.0 * HALF),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for t in &self.tridents {
|
||||
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
|
||||
|
||||
@@ -511,10 +511,10 @@ pub struct Renderer {
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// Whether the window's surface can present scRGB.
|
||||
hdr_capable: bool,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -1071,7 +1071,7 @@ impl Renderer {
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
|
||||
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
@@ -1099,7 +1099,7 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::supported(&caps))
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -1113,7 +1113,7 @@ impl Renderer {
|
||||
view_formats: vec![format],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
(config, Vec::new(), false)
|
||||
(config, Vec::new(), None)
|
||||
};
|
||||
let offscreen = surface
|
||||
.is_none()
|
||||
@@ -1953,22 +1953,23 @@ impl Renderer {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity_shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
entry_point: Some("fs_sky_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
|
||||
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
|
||||
// moon shine; nothing else reads the frame's alpha.
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
|
||||
// the colour premultiplied in fs_sky_celestial. Its alpha is
|
||||
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
|
||||
// and moon's discs shine; nothing else reads the frame's alpha.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
@@ -2456,7 +2457,7 @@ impl Renderer {
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_capable,
|
||||
hdr_output,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -2990,24 +2991,24 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// scRGB while HDR output is on.
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
let mut config = self.config.clone();
|
||||
if self.hdr.is_some() {
|
||||
config.format = hdr::FORMAT;
|
||||
config.view_formats = vec![hdr::FORMAT];
|
||||
config.color_space = hdr::COLOR_SPACE;
|
||||
if let Some(hdr) = &self.hdr {
|
||||
config.format = hdr.output.format();
|
||||
config.view_formats = vec![hdr.output.format()];
|
||||
config.color_space = hdr.output.color_space();
|
||||
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
}
|
||||
config
|
||||
}
|
||||
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
|
||||
let on = on && self.hdr_capable && self.surface.is_some();
|
||||
if on != self.hdr.is_some() {
|
||||
self.hdr =
|
||||
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
//! HDR output (Horizon setting, off by default): the frame renders exactly as
|
||||
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
|
||||
//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white,
|
||||
//! expanding the world's highlights towards their peak brightness. The world
|
||||
//! is copied aside before the hand and HUD so only it is expanded.
|
||||
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
|
||||
//! white, expanding the world's highlights towards their peak brightness. The
|
||||
//! world is copied aside before the hand and HUD so only it is expanded.
|
||||
|
||||
/// The swapchain format and colour space HDR output presents in.
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear;
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Output {
|
||||
/// HDR10: 10-bit BT.2020 with the PQ curve, the display's own signal.
|
||||
Pq,
|
||||
/// scRGB: linear 16-bit float, BT.709 primaries, 1.0 = 80 nits.
|
||||
ScRgb,
|
||||
}
|
||||
|
||||
/// Whether `caps` can present scRGB.
|
||||
pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool {
|
||||
caps.color_spaces(FORMAT)
|
||||
.contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR)
|
||||
impl Output {
|
||||
pub fn format(self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
Self::ScRgb => wgpu::TextureFormat::Rgba16Float,
|
||||
}
|
||||
}
|
||||
pub fn color_space(self) -> wgpu::SurfaceColorSpace {
|
||||
match self {
|
||||
Self::Pq => wgpu::SurfaceColorSpace::Bt2100Pq,
|
||||
Self::ScRgb => wgpu::SurfaceColorSpace::ExtendedSrgbLinear,
|
||||
}
|
||||
}
|
||||
fn space(self) -> wgpu::SurfaceColorSpaces {
|
||||
match self {
|
||||
Self::Pq => wgpu::SurfaceColorSpaces::BT2100_PQ,
|
||||
Self::ScRgb => wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
|
||||
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
|
||||
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
|
||||
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
|
||||
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
|
||||
.into_iter()
|
||||
.filter(|(_, name)| only.is_none_or(|o| o == *name))
|
||||
.map(|(output, _)| output)
|
||||
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
@@ -25,6 +58,7 @@ pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
@@ -36,7 +70,7 @@ pub struct Hdr {
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
@@ -90,7 +124,7 @@ impl Hdr {
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: FORMAT,
|
||||
format: output.format(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -100,7 +134,7 @@ impl Hdr {
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 16,
|
||||
size: 32,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -108,6 +142,7 @@ impl Hdr {
|
||||
layout,
|
||||
pipeline,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
@@ -198,6 +233,10 @@ impl Hdr {
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
let view = target.create_view(&Default::default());
|
||||
@@ -220,3 +259,37 @@ impl Hdr {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
|
||||
wgpu::SurfaceCapabilities {
|
||||
format_capabilities: offered
|
||||
.iter()
|
||||
.map(|&(format, color_spaces)| wgpu::SurfaceFormatCapabilities {
|
||||
format,
|
||||
color_spaces,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr10_first_then_scrgb() {
|
||||
let both = caps(&[
|
||||
(F::Bgra8UnormSrgb, S::SRGB),
|
||||
(F::Rgb10a2Unorm, S::BT2100_PQ | S::BT2100_HLG),
|
||||
(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR),
|
||||
]);
|
||||
assert_eq!(pick(&both, None), Some(Output::Pq));
|
||||
assert_eq!(pick(&both, Some("scrgb")), Some(Output::ScRgb));
|
||||
let scrgb = caps(&[(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR)]);
|
||||
assert_eq!(pick(&scrgb, None), Some(Output::ScRgb));
|
||||
assert_eq!(pick(&scrgb, Some("pq")), None);
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
}
|
||||
@@ -216,15 +216,19 @@ pub const INTEGRATED_LOD_DISTANCE: u32 = 128;
|
||||
pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.device_type == wgpu::DeviceType::IntegratedGpu && !info.name.starts_with("Apple")
|
||||
}
|
||||
/// A LOD distance as far terrain uses it on this adapter.
|
||||
pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 {
|
||||
let distance = distance.min(1024);
|
||||
if lod_capped(info) {
|
||||
distance.min(INTEGRATED_LOD_DISTANCE)
|
||||
} else {
|
||||
distance
|
||||
}
|
||||
}
|
||||
impl Graphics {
|
||||
/// `lod_distance` as far terrain uses it on this adapter.
|
||||
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
|
||||
let distance = self.lod_distance.min(1024);
|
||||
if lod_capped(info) {
|
||||
distance.min(INTEGRATED_LOD_DISTANCE)
|
||||
} else {
|
||||
distance
|
||||
}
|
||||
lod_distance_capped(self.lod_distance, info)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -255,7 +259,7 @@ pub struct Graphics {
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// HDR output (scRGB), where the display path offers it.
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
pub hdr_paper_white: u32,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma
|
||||
// space, decoded to linear light at the paper white and written to the scRGB
|
||||
// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the
|
||||
// space, decoded to linear light at the paper white and written to the swapchain:
|
||||
// HDR10 (BT.2020, PQ) or scRGB (1.0 = 80 nits). Bright pixels of light sources that still show the
|
||||
// world as it was before the hand and HUD are expanded towards the peak.
|
||||
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world: texture_2d<f32>;
|
||||
@@ -14,6 +14,8 @@ struct Params {
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
@@ -40,6 +42,19 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
const BT709_TO_BT2020 = mat3x3<f32>(
|
||||
vec3(0.6274, 0.0691, 0.0164),
|
||||
vec3(0.3293, 0.9195, 0.0880),
|
||||
vec3(0.0433, 0.0114, 0.8956),
|
||||
);
|
||||
|
||||
// SMPTE ST 2084: nits (up to 10000) to the PQ signal.
|
||||
fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
let y = pow(clamp(nits / 10000.0, vec3(0.0), vec3(1.0)), vec3(0.1593017578125));
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
@@ -65,5 +80,9 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec4(light * params.paper, 1.0);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -321,7 +321,9 @@ fn read_text(t: &Tag) -> Option<String> {
|
||||
// A component's root `text` may be empty with the visible words in
|
||||
// `extra` (every legacy display name ViaVersion produces looks like
|
||||
// this), so flatten the whole component instead of reading one key.
|
||||
Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()),
|
||||
// ComponentSerialization.CODEC also takes a list (its first element
|
||||
// styling the rest), as servers behind ViaVersion send book pages.
|
||||
Tag::Compound(_) | Tag::List(_) => Some(crate::entity_nbt::name_text(t).plain()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -916,6 +918,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_book_pages_may_be_component_lists() {
|
||||
// A page as Hypixel sends it through ViaVersion: a list, not a compound.
|
||||
let page = Tag::List(vec![
|
||||
Tag::compound([("text", Tag::String("".into()))]),
|
||||
Tag::compound([("text", Tag::String("Please read ".into()))]),
|
||||
Tag::compound([("text", Tag::String("the rules".into()))]),
|
||||
]);
|
||||
let book = Tag::compound([
|
||||
(
|
||||
"title",
|
||||
Tag::compound([("raw", Tag::String("Rules".into()))]),
|
||||
),
|
||||
("author", Tag::String("Server".into())),
|
||||
(
|
||||
"pages",
|
||||
Tag::List(vec![
|
||||
page,
|
||||
Tag::compound([("raw", Tag::String("Two".into()))]),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let components = Tag::compound([("minecraft:written_book_content", book)]);
|
||||
let (_, extra) = decode_components(Some(&components));
|
||||
let pages = extra.and_then(|d| d.written.clone()).map(|w| w.pages);
|
||||
assert_eq!(
|
||||
pages,
|
||||
Some(vec!["Please read the rules".to_owned(), "Two".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_layers_and_shield_base_round_trip() {
|
||||
let mut stack = ItemStack::new("minecraft:shield", 1);
|
||||
|
||||
@@ -791,6 +791,16 @@ fn login_and_configure(
|
||||
},
|
||||
}),
|
||||
)?;
|
||||
// ServerConfigurationPacketListenerImpl.startConfiguration: links, only
|
||||
// when there are any (MinecraftServer.serverLinks is empty elsewhere).
|
||||
if !shared.config.server_links.is_empty() {
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::ServerLinks(cfg::ServerLinks {
|
||||
links: shared.config.server_links.clone(),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::UpdateEnabledFeatures(cfg::UpdateEnabledFeatures {
|
||||
|
||||
@@ -2068,6 +2068,7 @@ impl Hub {
|
||||
| S::PlayerLoaded(_)
|
||||
| S::ChunkBatchReceived(_)
|
||||
| S::ClientInformation(_)
|
||||
| S::RecipeBookChangeSettings(_)
|
||||
| S::ClientTickEnd(_)
|
||||
| S::KeepAlive(_) => false,
|
||||
S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]),
|
||||
@@ -2134,6 +2135,15 @@ impl Hub {
|
||||
g.max_unacknowledged = 10;
|
||||
}
|
||||
S::ClientTickEnd(_) => self.guests[i].position_this_tick = false,
|
||||
// ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket.
|
||||
S::RecipeBookChangeSettings(p) => {
|
||||
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
|
||||
{
|
||||
let settings = &mut self.guests[i].recipe_book;
|
||||
settings.open[book.index()] = p.open;
|
||||
settings.filtering[book.index()] = p.filtering;
|
||||
}
|
||||
}
|
||||
S::ClientInformation(info) => {
|
||||
let g = &mut self.guests[i];
|
||||
g.view_distance =
|
||||
@@ -2269,11 +2279,16 @@ impl Hub {
|
||||
packets = 1;
|
||||
}
|
||||
// shouldCheckPlayerMovement (never the single-player owner).
|
||||
// The server holds no velocity for a guest (getDeltaMovement),
|
||||
// so nothing is expected.
|
||||
if !owner && check && (!g.fall_flying || elytra_check) {
|
||||
let limit = if g.fall_flying { 300.0 } else { 100.0 };
|
||||
if moved > limit * f64::from(packets) {
|
||||
// `movedDist - expectedDist` (getDeltaMovement): the velocity
|
||||
// the server last simulated for the guest is expected
|
||||
// movement, not a violation — a landing glide keeps its speed.
|
||||
let expected = g.motion.as_ref().map_or(0.0, |m| {
|
||||
let v = m.velocity;
|
||||
v.x * v.x + v.y * v.y + v.z * v.z
|
||||
});
|
||||
if moved - expected > limit * f64::from(packets) {
|
||||
events.push(HostEvent::Warn(format!(
|
||||
"{} moved too quickly! {},{},{}",
|
||||
g.name,
|
||||
|
||||
@@ -142,6 +142,10 @@ pub struct NetConfig {
|
||||
/// server.properties `resource-pack`: offered to every player during
|
||||
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
|
||||
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
|
||||
/// MinecraftServer.serverLinks (a dedicated server's `bug-report-link`):
|
||||
/// sent in configuration when non-empty
|
||||
/// (ServerConfigurationPacketListenerImpl.startConfiguration).
|
||||
pub server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
}
|
||||
impl NetConfig {
|
||||
/// MinecraftServer.enforceSecureProfile.
|
||||
@@ -168,6 +172,7 @@ impl NetConfig {
|
||||
online: None,
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
server_links: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,10 +324,13 @@ impl BlockLandingWorld for MotionWorld<'_, '_, '_> {
|
||||
type DamageSource = LandingDamage;
|
||||
type Sound = SceneSound;
|
||||
fn is_server_level(&self) -> bool {
|
||||
true
|
||||
// A remote client's world is a client level: it never tramples,
|
||||
// the server's block change and teleport do it (FarmlandBlock.fallOn
|
||||
// and TurtleEggBlock.fallOn check `level instanceof ServerLevel`).
|
||||
!self.player.client_side
|
||||
}
|
||||
fn is_client_side(&mut self) -> Result<bool> {
|
||||
Ok(false)
|
||||
Ok(self.player.client_side)
|
||||
}
|
||||
fn random_float(&mut self) -> Result<f32> {
|
||||
Ok(self.scene.random.next_float())
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, index},
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
|
||||
};
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
@@ -565,43 +565,130 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
|
||||
|
||||
/// Resident chunks the incremental relighter reads and writes.
|
||||
pub trait LightWorld {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32>;
|
||||
/// Packed light (sky << 4 | block), None outside loaded chunks.
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8>;
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8);
|
||||
/// Section `s` of chunk (cx, cz), None unless the chunk is loaded and lit.
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
|
||||
/// Replace the light of a section `section` returned.
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
|
||||
}
|
||||
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
|
||||
self.get(&(cx, cz))
|
||||
.filter(|c| c.lit)
|
||||
.map(|c| &c.sections[s])
|
||||
}
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light) {
|
||||
if let Some(chunk) = self.get_mut(&(cx, cz)) {
|
||||
Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]).light = light;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The sections one relight touches, held for the whole flood fill so each
|
||||
/// cell access is an array index instead of a chunk lookup. Light a section
|
||||
/// gets written is copied out on the first write and stored back by `finish`.
|
||||
struct Region<'w> {
|
||||
world: &'w mut dyn LightWorld,
|
||||
slots: Vec<Slot>,
|
||||
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
|
||||
index: FxHashMap<Pos, u32>,
|
||||
last: (Pos, u32),
|
||||
}
|
||||
struct Slot {
|
||||
key: Pos,
|
||||
/// The world's section; its light is the value before this relight.
|
||||
section: Arc<Section>,
|
||||
light: Option<Box<[u8; 4096]>>,
|
||||
}
|
||||
const NONE: u32 = u32::MAX;
|
||||
impl<'w> Region<'w> {
|
||||
fn new(world: &'w mut dyn LightWorld) -> Self {
|
||||
Self {
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
index: FxHashMap::default(),
|
||||
last: ((i32::MIN, 0, 0), NONE),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
self.get(&(x >> 4, z >> 4))
|
||||
.filter(|c| c.lit)
|
||||
.map(|c| c.get(x & 15, y, z & 15))
|
||||
}
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
if self.last.0 != key {
|
||||
self.last = (key, self.load(key));
|
||||
}
|
||||
self.get(&(x >> 4, z >> 4))
|
||||
.filter(|c| c.lit)
|
||||
.map(|c| c.light(x & 15, y, z & 15))
|
||||
(self.last.1 != NONE).then_some(self.last.1 as usize)
|
||||
}
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
|
||||
let Some(chunk) = self.get_mut(&(x >> 4, z >> 4)) else {
|
||||
fn load(&mut self, key: Pos) -> u32 {
|
||||
if let Some(&slot) = self.index.get(&key) {
|
||||
return slot;
|
||||
}
|
||||
let slot = match self.world.section(key.0, key.1 as usize, key.2) {
|
||||
Some(section) => {
|
||||
self.slots.push(Slot {
|
||||
key,
|
||||
section: section.clone(),
|
||||
light: None,
|
||||
});
|
||||
(self.slots.len() - 1) as u32
|
||||
}
|
||||
None => NONE,
|
||||
};
|
||||
self.index.insert(key, slot);
|
||||
slot
|
||||
}
|
||||
fn state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
Some(self.slots[slot].section.get(index(x, y, z)))
|
||||
}
|
||||
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
|
||||
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
let slot = &self.slots[slot];
|
||||
let i = index(x, y, z);
|
||||
Some(match &slot.light {
|
||||
Some(light) => light[i],
|
||||
None => slot.section.light(i),
|
||||
})
|
||||
}
|
||||
/// State and packed light of a cell, with a single section lookup.
|
||||
fn cell(&mut self, x: i32, y: i32, z: i32) -> Option<(u32, u8)> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
let slot = &self.slots[slot];
|
||||
let i = index(x, y, z);
|
||||
let light = match &slot.light {
|
||||
Some(light) => light[i],
|
||||
None => slot.section.light(i),
|
||||
};
|
||||
Some((slot.section.get(i), light))
|
||||
}
|
||||
/// Rewrite a cell's packed light; nothing outside loaded, lit chunks.
|
||||
fn update_light(&mut self, x: i32, y: i32, z: i32, f: impl FnOnce(u8) -> u8) {
|
||||
let Some(slot) = self.slot(x, y, z) else {
|
||||
return;
|
||||
};
|
||||
let s = ((y - MIN_Y) >> 4) as usize;
|
||||
let slot = &mut self.slots[slot];
|
||||
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
|
||||
Light::Uniform(v) => Box::new([*v; 4096]),
|
||||
Light::Full(a) => a.clone(),
|
||||
});
|
||||
let i = index(x, y, z);
|
||||
if chunk.sections[s].light(i) == packed {
|
||||
return;
|
||||
}
|
||||
let section = Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]);
|
||||
if let Light::Uniform(v) = section.light {
|
||||
section.light = Light::Full(Box::new([v; 4096]));
|
||||
}
|
||||
if let Light::Full(a) = &mut section.light {
|
||||
a[i] = packed;
|
||||
light[i] = f(light[i]);
|
||||
}
|
||||
/// Store back the light of every section whose values changed.
|
||||
fn finish(self) {
|
||||
for slot in self.slots {
|
||||
let Some(light) = slot.light else {
|
||||
continue;
|
||||
};
|
||||
if (0..4096).all(|i| slot.section.light(i) == light[i]) {
|
||||
continue;
|
||||
}
|
||||
// Drop the clone first so the world's section isn't copied.
|
||||
drop(slot.section);
|
||||
let (cx, s, cz) = slot.key;
|
||||
self.world
|
||||
.set_section_light(cx, s as usize, cz, Light::Full(light));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,25 +777,23 @@ impl LightChanges {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn level(world: &impl LightWorld, sky: bool, p: Pos) -> Option<u8> {
|
||||
fn level(world: &mut Region, sky: bool, p: Pos) -> Option<u8> {
|
||||
world
|
||||
.light(p.0, p.1, p.2)
|
||||
.map(|v| if sky { v >> 4 } else { v & 15 })
|
||||
}
|
||||
fn set_level(world: &mut impl LightWorld, sky: bool, p: Pos, value: u8) {
|
||||
let Some(old) = world.light(p.0, p.1, p.2) else {
|
||||
return;
|
||||
};
|
||||
let packed = if sky {
|
||||
(old & 0x0F) | value << 4
|
||||
} else {
|
||||
(old & 0xF0) | value
|
||||
};
|
||||
world.set_light(p.0, p.1, p.2, packed);
|
||||
fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
|
||||
world.update_light(p.0, p.1, p.2, |old| {
|
||||
if sky {
|
||||
(old & 0x0F) | value << 4
|
||||
} else {
|
||||
(old & 0xF0) | value
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
|
||||
/// edge in the column (every block at or above it is a sky source).
|
||||
pub fn lowest_sky_source(world: &impl LightWorld, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
let Some(mut top) = world.state(x, y, z) else {
|
||||
return MIN_Y;
|
||||
@@ -739,34 +824,52 @@ pub fn relight(
|
||||
force: bool,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed);
|
||||
let mut region = Region::new(world);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
fn relight_region(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
positions: &[Pos],
|
||||
force: bool,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
crate::spans::time("lt_block", || {
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let mut seeds = positions.to_vec();
|
||||
let mut columns: HashMap<(i32, i32), i32> = HashMap::new();
|
||||
for &(x, _, z) in positions {
|
||||
if columns.contains_key(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
let source = lowest_sky_source(world, tables, x, z);
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_columns", || {
|
||||
for &(x, _, z) in positions {
|
||||
if columns.contains_key(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
let source = lowest_sky_source(world, tables, x, z);
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
seeds.push((x, y, z));
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
relight_layer(world, tables, true, seeds, force, changed);
|
||||
});
|
||||
crate::spans::time("lt_sky", || {
|
||||
relight_layer(world, tables, true, seeds, force, changed)
|
||||
});
|
||||
}
|
||||
}
|
||||
fn relight_layer(
|
||||
world: &mut impl LightWorld,
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
sky: bool,
|
||||
seeds: Vec<Pos>,
|
||||
@@ -809,42 +912,40 @@ fn relight_layer(
|
||||
}
|
||||
}
|
||||
// Sources inside the cleared area light up again.
|
||||
let mut sources: HashMap<(i32, i32), i32> = HashMap::new();
|
||||
for p in cleared {
|
||||
let seed = if sky {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
if p.1 >= lowest { 15 } else { 0 }
|
||||
} else {
|
||||
world
|
||||
.state(p.0, p.1, p.2)
|
||||
.map_or(0, |s| tables.cells[s as usize].emission)
|
||||
};
|
||||
if seed > level(world, sky, p).unwrap_or(15) {
|
||||
set_level(world, sky, p, seed);
|
||||
changed.push(p);
|
||||
increase.push_back(p);
|
||||
let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_sources", || {
|
||||
for p in cleared {
|
||||
let seed = if sky {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
if p.1 >= lowest { 15 } else { 0 }
|
||||
} else {
|
||||
world
|
||||
.state(p.0, p.1, p.2)
|
||||
.map_or(0, |s| tables.cells[s as usize].emission)
|
||||
};
|
||||
if seed > level(world, sky, p).unwrap_or(15) {
|
||||
set_level(world, sky, p, seed);
|
||||
changed.push(p);
|
||||
increase.push_back(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
while let Some(p) = increase.pop_front() {
|
||||
let Some(l) = level(world, sky, p) else {
|
||||
let Some((from, packed)) = world.cell(p.0, p.1, p.2) else {
|
||||
continue;
|
||||
};
|
||||
let l = if sky { packed >> 4 } else { packed & 15 };
|
||||
if l <= 1 {
|
||||
continue;
|
||||
}
|
||||
let Some(from) = world.state(p.0, p.1, p.2) else {
|
||||
continue;
|
||||
};
|
||||
for (d, (ox, oy, oz)) in OFFSETS.iter().enumerate() {
|
||||
let q = (p.0 + ox, p.1 + oy, p.2 + oz);
|
||||
let Some(to) = world.state(q.0, q.1, q.2) else {
|
||||
continue;
|
||||
};
|
||||
let Some(ql) = level(world, sky, q) else {
|
||||
let Some((to, packed)) = world.cell(q.0, q.1, q.2) else {
|
||||
continue;
|
||||
};
|
||||
let ql = if sky { packed >> 4 } else { packed & 15 };
|
||||
let next = l.saturating_sub(tables.cells[to as usize].dampening.max(1));
|
||||
if next > ql && !tables.blocked(from as usize, to as usize, d) {
|
||||
set_level(world, sky, q, next);
|
||||
@@ -915,10 +1016,10 @@ mod tests {
|
||||
lit.into_iter().collect()
|
||||
}
|
||||
fn sky(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
|
||||
world.light(x, y, z).unwrap() >> 4
|
||||
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) >> 4
|
||||
}
|
||||
fn block(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
|
||||
world.light(x, y, z).unwrap() & 15
|
||||
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) & 15
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -954,6 +1055,108 @@ mod tests {
|
||||
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
|
||||
}
|
||||
#[test]
|
||||
fn relight_keeps_unchanged_and_unlit_sections() {
|
||||
let mut w = world(|_| {});
|
||||
Arc::make_mut(w.get_mut(&(1, 0)).unwrap()).lit = false;
|
||||
let before = w.clone();
|
||||
let same = |w: &HashMap<(i32, i32), Arc<Chunk>>, key| {
|
||||
w[&key]
|
||||
.sections
|
||||
.iter()
|
||||
.zip(&before[&key].sections)
|
||||
.all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
};
|
||||
// Cleared and lit back to the same values: nothing is stored.
|
||||
relight(
|
||||
&mut w,
|
||||
&tables(),
|
||||
true,
|
||||
&[(8, 5, 8)],
|
||||
false,
|
||||
&mut LightChanges::default(),
|
||||
);
|
||||
assert!(w.keys().all(|&key| same(&w, key)));
|
||||
// Light reaching an unlit chunk isn't written into it.
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(15, 0, 8, TORCH);
|
||||
relight(
|
||||
&mut w,
|
||||
&tables(),
|
||||
true,
|
||||
&[(15, 0, 8)],
|
||||
false,
|
||||
&mut LightChanges::default(),
|
||||
);
|
||||
assert!(same(&w, (1, 0)));
|
||||
assert_eq!(block(&w, 14, 0, 8), 13);
|
||||
}
|
||||
#[test]
|
||||
fn random_border_relights_match_full_relight() {
|
||||
// Edits around every border of chunk (0, 0), at the top and bottom of
|
||||
// the world, in batches; a held clone of the chunk must not change.
|
||||
let mut w = world(|_| {});
|
||||
let held = w[&(0, 0)].clone();
|
||||
let mut rng = 0x9e37_79b9_u32;
|
||||
let mut next = |n: usize| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
rng as usize % n
|
||||
};
|
||||
let xz = [-2, -1, 0, 1, 7, 14, 15, 16, 17];
|
||||
let ys = [
|
||||
MIN_Y,
|
||||
MIN_Y + 1,
|
||||
-2,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
15,
|
||||
16,
|
||||
MIN_Y + HEIGHT - 2,
|
||||
MIN_Y + HEIGHT - 1,
|
||||
];
|
||||
for _ in 0..60 {
|
||||
let positions: Vec<Pos> = (0..1 + next(6))
|
||||
.map(|_| (xz[next(xz.len())], ys[next(ys.len())], xz[next(xz.len())]))
|
||||
.collect();
|
||||
for &(x, y, z) in &positions {
|
||||
let state = [TORCH, SLAB, AIR, STONE][next(4)];
|
||||
Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state);
|
||||
}
|
||||
relight(
|
||||
&mut w,
|
||||
&tables(),
|
||||
true,
|
||||
&positions,
|
||||
false,
|
||||
&mut LightChanges::default(),
|
||||
);
|
||||
}
|
||||
// Every chunk, the outer ring lit as `world` lit it (no neighbours
|
||||
// past the 3x3).
|
||||
for (&(cx, cz), chunk) in &w {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
w.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
for (si, s) in light_chunk(®ion, &tables(), true).iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
chunk.sections[si].light(i),
|
||||
"chunk {cx},{cz} section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fresh = world(|_| {});
|
||||
for (a, b) in held.sections.iter().zip(&fresh[&(0, 0)].sections) {
|
||||
assert!((0..4096).all(|i| a.light(i) == b.light(i)));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn incremental_relight_matches_full_relight() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
@@ -1319,4 +1522,37 @@ mod tests {
|
||||
"fixture heap payload {payload} bytes; old worker clones it plus 24 section Arcs; old application clones current payload and 98304 returned light bytes. New worker clones zero section payload; new application clones current payload only when shared."
|
||||
);
|
||||
}
|
||||
/// Relight cost for torches placed and removed near chunk borders with
|
||||
/// slabs flickering around them (fluids next to lava, in the game).
|
||||
/// `cargo test --profile fast -p horizon-client relight_bench -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn relight_bench() {
|
||||
let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect();
|
||||
let t = tables();
|
||||
let mut rng = 0x2545_f491_u32;
|
||||
let mut next = |n: i32| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
(rng % n as u32) as i32
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
let mut changed = LightChanges::default();
|
||||
for round in 0..400 {
|
||||
let state = [TORCH, SLAB, AIR][round % 3];
|
||||
let positions: Vec<Pos> = (0..32)
|
||||
.map(|_| (next(32) - 8, next(12), next(32) - 8))
|
||||
.collect();
|
||||
for &(x, y, z) in &positions {
|
||||
Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state);
|
||||
}
|
||||
relight(&mut w, &t, true, &positions, false, &mut changed);
|
||||
}
|
||||
println!(
|
||||
"relight_bench: {:?}, {} sections",
|
||||
start.elapsed(),
|
||||
changed.sections().count()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -781,8 +781,9 @@ pub struct Extra {
|
||||
pub drop_chances: Option<Box<[f32; 8]>>,
|
||||
/// `Mob.leftHanded`.
|
||||
pub left_handed: bool,
|
||||
/// The sound variant: index in the species' `*_sound_variant` registry.
|
||||
pub sound_variant: u8,
|
||||
/// The sound variant: index in the species' `*_sound_variant` registry
|
||||
/// (`finalize::sound_variants`); None is the default, `classic`.
|
||||
pub sound_variant: Option<u8>,
|
||||
/// `Zombie.canBreakDoors`.
|
||||
pub can_break_doors: bool,
|
||||
/// A goat's `IsScreamingGoat` and the horns it lost (left, right).
|
||||
@@ -983,6 +984,12 @@ pub enum MobEvent {
|
||||
target: Who,
|
||||
velocity: [f64; 3],
|
||||
},
|
||||
/// `Entity.move(MoverType.SHULKER, delta)`: shift a target's position
|
||||
/// (an opening shulker lifts what stands on its shell).
|
||||
Shift {
|
||||
target: Who,
|
||||
delta: [f64; 3],
|
||||
},
|
||||
/// `EvokerWololoSpellGoal`: a blue sheep turns red.
|
||||
Wololo {
|
||||
mob: u32,
|
||||
|
||||
@@ -6,7 +6,7 @@ use horizon_core::geometry::trig;
|
||||
/// `2.5000003E-7F` widened: the squared distance controls treat as arrived.
|
||||
const ARRIVED: f64 = 2.500_000_3e-7_f32 as f64;
|
||||
/// `1.0E-5F` widened.
|
||||
const EPSILON: f64 = 1.0e-5_f32 as f64;
|
||||
pub(super) const EPSILON: f64 = 1.0e-5_f32 as f64;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Operation {
|
||||
@@ -90,6 +90,9 @@ pub enum LookKind {
|
||||
Fixed,
|
||||
/// `HappyGhast.HappyGhastLookControl`.
|
||||
HappyGhast,
|
||||
/// `Shulker.ShulkerLookControl`: the yaw turns in the attach face's
|
||||
/// plane and the head is never clamped to the body.
|
||||
Shulker,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LookControl {
|
||||
@@ -135,6 +138,9 @@ impl Mob {
|
||||
if self.species() == super::Species::Rabbit {
|
||||
return super::rabbit::move_control(self, w);
|
||||
}
|
||||
if self.species() == super::Species::Turtle {
|
||||
return super::turtle::move_control(self);
|
||||
}
|
||||
let attr_speed = self.movement_speed();
|
||||
match self.ai.mov.kind {
|
||||
MoveKind::Custom => super::ai::custom_move_control(self, w, events),
|
||||
@@ -345,6 +351,20 @@ impl Mob {
|
||||
self.body_yaw += 4.0;
|
||||
}
|
||||
}
|
||||
LookKind::Shulker => {
|
||||
// ShulkerLookControl: resetXRotOnTick, getXRotD 0, and the
|
||||
// yaw worked out in the attach face's plane (getYRotD).
|
||||
self.head_pitch = 0.0;
|
||||
if look.cooldown > 0 {
|
||||
self.ai.look.cooldown -= 1;
|
||||
if let Some(y) = super::shulker::look_yaw(self, look.wanted) {
|
||||
self.head_yaw = rotate_towards(self.head_yaw, y, look.y_max_rot_speed);
|
||||
}
|
||||
} else {
|
||||
self.head_yaw = rotate_towards(self.head_yaw, self.body_yaw, 10.0);
|
||||
}
|
||||
// clampHeadRotationToBody does nothing for a shulker.
|
||||
}
|
||||
LookKind::Default | LookKind::Fixed => {
|
||||
if look.kind == LookKind::Default {
|
||||
self.head_pitch = 0.0;
|
||||
|
||||
@@ -1508,7 +1508,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
|
||||
}
|
||||
fn sound(&mut self, m: &mut Mob, registry: fn(&SpawnRegistries) -> &VariantRegistry) {
|
||||
let r = registry(self.data.registries);
|
||||
m.extra.sound_variant = r.random(self.random) as u8;
|
||||
m.extra.sound_variant = Some(r.random(self.random) as u8);
|
||||
}
|
||||
|
||||
/// `finalizeSpawn` of the mob's class.
|
||||
@@ -2014,6 +2014,17 @@ pub fn species_variant_component(s: Species, variant: u8) -> Option<(&'static st
|
||||
};
|
||||
Some((component, format!("minecraft:{}", variant_name(s, variant))))
|
||||
}
|
||||
/// The `*_sound_variant` registry of a species, in registry order.
|
||||
pub fn sound_variants(s: Species) -> &'static [&'static str] {
|
||||
match s {
|
||||
Species::Pig => &["big", "classic", "mini"],
|
||||
Species::Cow => &["classic", "moody"],
|
||||
Species::Chicken => &["classic", "picky"],
|
||||
Species::Cat => &["classic", "royal"],
|
||||
Species::Wolf => &["angry", "big", "classic", "cute", "grumpy", "puglin", "sad"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// The registry name of the game's variant index.
|
||||
pub fn variant_name(s: Species, index: u8) -> &'static str {
|
||||
let i = index as usize;
|
||||
|
||||
@@ -235,6 +235,26 @@ impl MobWorld<'_> {
|
||||
};
|
||||
scene.unobstructed(b).unwrap_or(false)
|
||||
}
|
||||
/// `LevelReader.containsAnyLiquid`: the blocks from the box's minimum
|
||||
/// corner to the block holding its maximum corner.
|
||||
pub fn contains_any_liquid(&self, b: Aabb) -> bool {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let (x0, y0, z0) = (
|
||||
lo.x.floor() as i32,
|
||||
lo.y.floor() as i32,
|
||||
lo.z.floor() as i32,
|
||||
);
|
||||
let (x1, y1, z1) = (
|
||||
hi.x.floor() as i32,
|
||||
hi.y.floor() as i32,
|
||||
hi.z.floor() as i32,
|
||||
);
|
||||
(x0..=x1).any(|x| {
|
||||
(y0..=y1).any(|y| {
|
||||
(z0..=z1).any(|z| self.facts.get(self.world.block(x, y, z)).fluid != Fluid::None)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Mob {
|
||||
/// `Entity.refreshDimensions` after growing from `old_w` by `old_h`:
|
||||
@@ -813,14 +833,24 @@ impl Mob {
|
||||
self.velocity.y -= gravity / 4.0;
|
||||
}
|
||||
}
|
||||
// jumpOutOfFluid
|
||||
// jumpOutOfFluid: Entity.isFree wants the box dry and clear of the
|
||||
// entities a mob collides with (canBeCollidedWith: living shulkers).
|
||||
let m = self.velocity;
|
||||
let ahead = self.bounds().move_by(Vec3::new(
|
||||
m.x,
|
||||
m.y + f64::from(0.6_f32) - self.position.y + old_y,
|
||||
m.z,
|
||||
));
|
||||
let wide = ahead.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
if self.horizontal_collision
|
||||
&& w.no_collision(self.bounds().move_by(Vec3::new(
|
||||
m.x,
|
||||
m.y + f64::from(0.6_f32) - self.position.y + old_y,
|
||||
m.z,
|
||||
)))
|
||||
&& w.no_collision(ahead)
|
||||
&& !w.contains_any_liquid(ahead)
|
||||
&& !w.others_near(wide).any(|s| {
|
||||
s.id != self.id
|
||||
&& s.alive
|
||||
&& s.species == super::Species::Shulker
|
||||
&& s.bounds.intersects(wide)
|
||||
})
|
||||
{
|
||||
self.velocity.y = f64::from(0.3_f32);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{
|
||||
Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who,
|
||||
anim::{Poses, State},
|
||||
control::LookKind,
|
||||
goals::{
|
||||
Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, NearestTarget, RandomLookAround,
|
||||
goal_clone, reduced,
|
||||
@@ -37,7 +38,13 @@ pub fn register(m: &mut Mob) {
|
||||
g.add(4, ShulkerAttack { attack_time: 0 });
|
||||
g.add(7, Peek { time: 0 });
|
||||
g.add(8, RandomLookAround::default());
|
||||
m.ai.targets.add(1, HurtByTarget::new().alert());
|
||||
// `ShulkerLookControl`: the head turns in the attach face's plane.
|
||||
m.ai.look.kind = LookKind::Shulker;
|
||||
// `HurtByTargetGoal(this, Shulker.class).setAlertOthers()`: damage from
|
||||
// another shulker draws no retaliation (`toIgnoreDamage`), but a hurt
|
||||
// shulker alerts every targetless shulker around.
|
||||
m.ai.targets
|
||||
.add(1, HurtByTarget::new().alert().ignoring(&[Species::Shulker]));
|
||||
// ShulkerNearestAttackGoal: the search box is only 4 deep along the
|
||||
// attach axis.
|
||||
m.ai.targets.add(
|
||||
@@ -57,6 +64,10 @@ pub fn register(m: &mut Mob) {
|
||||
fn face_step(face: u8) -> [i32; 3] {
|
||||
STEPS[face.min(5) as usize]
|
||||
}
|
||||
/// `Direction.getOpposite` for a 3D data value.
|
||||
fn opposite(face: u8) -> u8 {
|
||||
face.min(5) ^ 1
|
||||
}
|
||||
fn block_pos(m: &Mob) -> [i32; 3] {
|
||||
[
|
||||
m.position.x.floor() as i32,
|
||||
@@ -64,19 +75,83 @@ fn block_pos(m: &Mob) -> [i32; 3] {
|
||||
m.position.z.floor() as i32,
|
||||
]
|
||||
}
|
||||
/// `Shulker.canStayAt`: an empty spot against a sturdy face.
|
||||
fn can_stay_at(w: &MobWorld, p: [i32; 3], face: u8) -> bool {
|
||||
if !w.facts.is_air(w.world.block(p[0], p[1], p[2])) {
|
||||
/// `Shulker.getProgressDeltaAabb` at scale 1: the shell's box from `pos`
|
||||
/// (the bottom centre), grown `from`..`to` along `direction`.
|
||||
fn progress_aabb(direction: u8, from: f64, to: f64, pos: Vec3) -> Aabb {
|
||||
let s = face_step(direction).map(f64::from);
|
||||
let (max, min) = (from.max(to), from.min(to));
|
||||
Aabb::new(Vec3::new(-0.5, 0.0, -0.5), Vec3::new(0.5, 1.0, 0.5))
|
||||
.expand_towards(Vec3::new(s[0] * max, s[1] * max, s[2] * max))
|
||||
.contract(Vec3::new(
|
||||
-s[0] * (1.0 + min),
|
||||
-s[1] * (1.0 + min),
|
||||
-s[2] * (1.0 + min),
|
||||
))
|
||||
.move_by(pos)
|
||||
}
|
||||
/// `Level.noCollision(shulker, aabb)`: no block collision under the empty
|
||||
/// context, and none of the entities a shulker collides with overlaps
|
||||
/// (`canBeCollidedWith`: living shulkers and adult happy ghasts; players
|
||||
/// and other mobs never block it).
|
||||
fn no_collision(m: &Mob, w: &MobWorld, bounds: Aabb) -> bool {
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: w.world,
|
||||
binding: w.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !crate::collisions::no_collision_empty(&mut scene, bounds) {
|
||||
return false;
|
||||
}
|
||||
let grown = bounds.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!w.others_near(grown).any(|s| {
|
||||
s.id != m.id
|
||||
&& s.alive
|
||||
&& (s.species == Species::Shulker || (s.species == Species::HappyGhast && !s.baby))
|
||||
&& s.bounds.intersects(grown)
|
||||
})
|
||||
}
|
||||
/// `Shulker.canStayAt`: an empty spot against a sturdy face, with room
|
||||
/// for the fully opened shell (`canStayAt`).
|
||||
fn can_stay_at(m: &Mob, w: &MobWorld, p: [i32; 3], face: u8) -> bool {
|
||||
// isPositionBlocked: anything but air blocks, except a moving piston
|
||||
// being driven through the shulker's own cell.
|
||||
if !w.facts.is_air(w.world.block(p[0], p[1], p[2]))
|
||||
&& (p != block_pos(m) || w.block_name(p[0], p[1], p[2]) != "minecraft:moving_piston")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// loadedAndEntityCanStandOnFace: the supporting block's face toward
|
||||
// the shell is sturdy.
|
||||
let s = face_step(face);
|
||||
let n = w.world.block(p[0] + s[0], p[1] + s[1], p[2] + s[2]);
|
||||
w.facts.get(n).full_collision
|
||||
let support = w.world.block(p[0] + s[0], p[1] + s[1], p[2] + s[2]);
|
||||
let open = opposite(face);
|
||||
if w.facts.get(support).sturdy_full & (1 << open) == 0 {
|
||||
return false;
|
||||
}
|
||||
// The fully opened shell's box, shrunk by the skin vanilla uses.
|
||||
let opened = progress_aabb(
|
||||
open,
|
||||
-1.0,
|
||||
1.0,
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6));
|
||||
no_collision(m, w, opened)
|
||||
}
|
||||
/// `findAttachableSurface`: the first face (down, up, north, south, west,
|
||||
/// east) to cling to.
|
||||
fn attachable(w: &MobWorld, p: [i32; 3]) -> Option<u8> {
|
||||
(0..6).find(|&f| can_stay_at(w, p, f))
|
||||
fn attachable(m: &Mob, w: &MobWorld, p: [i32; 3]) -> Option<u8> {
|
||||
(0..6).find(|&f| can_stay_at(m, w, p, f))
|
||||
}
|
||||
/// `BlockUtil.packDifferenceInPosition` within 8 blocks of `pos`.
|
||||
fn pack_diff(pos: [i32; 3], to: [i32; 3]) -> i32 {
|
||||
((to[0] - pos[0] + 8) & 0xFF) << 16
|
||||
| ((to[1] - pos[1] + 8) & 0xFF) << 8
|
||||
| (to[2] - pos[2] + 8) & 0xFF
|
||||
}
|
||||
/// `Shulker.teleportSomewhere`: up to five tries within 8 blocks.
|
||||
fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
@@ -87,12 +162,31 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
for _ in 0..5 {
|
||||
let r = |m: &mut Mob| m.random.next_int_bound(17).unwrap_or(8) - 8;
|
||||
let t = [old[0] + r(m), old[1] + r(m), old[2] + r(m)];
|
||||
if t[1] <= crate::chunk::MIN_Y || !w.facts.is_air(w.world.block(t[0], t[1], t[2])) {
|
||||
let unit = Aabb::new(
|
||||
Vec3::new(f64::from(t[0]), f64::from(t[1]), f64::from(t[2])),
|
||||
Vec3::new(
|
||||
f64::from(t[0]) + 1.0,
|
||||
f64::from(t[1]) + 1.0,
|
||||
f64::from(t[2]) + 1.0,
|
||||
),
|
||||
);
|
||||
if t[1] <= crate::chunk::MIN_Y
|
||||
|| !w.facts.is_air(w.world.block(t[0], t[1], t[2]))
|
||||
|| !no_collision(m, w, unit.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(face) = attachable(w, t) else {
|
||||
let Some(face) = attachable(m, w, t) else {
|
||||
continue;
|
||||
};
|
||||
// `#minecraft:shulker_does_not_teleport_to` (bedrock) is never clung to.
|
||||
let s = face_step(face);
|
||||
if w.has_tag(
|
||||
"minecraft:shulker_does_not_teleport_to",
|
||||
w.block_name(t[0] + s[0], t[1] + s[1], t[2] + s[2]),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
m.extra.variant3 = face;
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.shulker.teleport",
|
||||
@@ -106,6 +200,14 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
f64::from(t[2]) + 0.5,
|
||||
);
|
||||
m.previous = m.position;
|
||||
// Level.levelEvent(2016, oldPos, packedDiff): the teleport's
|
||||
// particles at both ends, seen by the players around.
|
||||
events.push(MobEvent::LevelEvent {
|
||||
id: 2016,
|
||||
pos: old,
|
||||
data: pack_diff(old, t),
|
||||
});
|
||||
m.needs_sync = true;
|
||||
m.extra.counter = 0;
|
||||
m.ai.target = None;
|
||||
return true;
|
||||
@@ -127,31 +229,102 @@ fn set_peek(m: &mut Mob, amount: i32, events: &mut Vec<MobEvent>) {
|
||||
));
|
||||
m.extra.counter = amount;
|
||||
}
|
||||
/// `Shulker.tick` additions: keep a face (or teleport), ease the peek,
|
||||
/// stay still and square on its block.
|
||||
/// `Shulker.updatePeekAmount`/`updatePeekAmountO`: the eased peek.
|
||||
fn ease_peek(m: &mut Mob) -> (f32, f32) {
|
||||
let old = m.anim.v[PEEK];
|
||||
let target = m.extra.counter as f32 * 0.01;
|
||||
let new = if old > target {
|
||||
(old - 0.05).clamp(target, 1.0)
|
||||
} else {
|
||||
(old + 0.05).clamp(0.0, target)
|
||||
};
|
||||
m.anim.v[PEEK] = new;
|
||||
(old, new)
|
||||
}
|
||||
/// `Shulker.tick` additions: keep a face (or teleport), ease the peek
|
||||
/// (pushing whatever it opens into), stay still and square on its block.
|
||||
pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.body_yaw = 0.0;
|
||||
m.yaw = 0.0;
|
||||
m.head_pitch = 0.0;
|
||||
let p = block_pos(m);
|
||||
if !can_stay_at(w, p, m.extra.variant3) {
|
||||
match attachable(w, p) {
|
||||
if !can_stay_at(m, w, p, m.extra.variant3) {
|
||||
match attachable(m, w, p) {
|
||||
Some(f) => m.extra.variant3 = f,
|
||||
None => {
|
||||
teleport(m, w, events);
|
||||
}
|
||||
}
|
||||
}
|
||||
let a = &mut m.anim;
|
||||
let target = m.extra.counter as f32 * 0.01;
|
||||
let cur = a.v[PEEK];
|
||||
if cur > target {
|
||||
a.v[PEEK] = (cur - 0.05).clamp(target, 1.0);
|
||||
} else if cur < target {
|
||||
a.v[PEEK] = (cur + 0.05).clamp(0.0, target);
|
||||
let (old, new) = ease_peek(m);
|
||||
if new != old {
|
||||
on_peek_change(m, w, old, new, events);
|
||||
}
|
||||
}
|
||||
/// `Shulker.getPhysicalPeek`: the lid's lift for a peek amount.
|
||||
fn physical_peek(amount: f32) -> f64 {
|
||||
f64::from(0.5 - ((0.5 + amount) * PI).msin() * 0.5)
|
||||
}
|
||||
/// `Shulker.onPeekAmountChange`: an opening shell pushes out of its way
|
||||
/// every entity that can be collided with (except other shulkers).
|
||||
fn on_peek_change(m: &Mob, w: &MobWorld, old: f32, new: f32, events: &mut Vec<MobEvent>) {
|
||||
let dir = opposite(m.extra.variant3);
|
||||
let s = face_step(dir).map(f64::from);
|
||||
let (old_p, new_p) = (physical_peek(old), physical_peek(new));
|
||||
let push = new_p - old_p;
|
||||
if push <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let delta = [push * s[0], push * s[1], push * s[2]];
|
||||
let area = progress_aabb(dir, old_p, new_p, m.position);
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& !p.spectator
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
events.push(MobEvent::Shift {
|
||||
target: Who::Player,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
for seen in w.others_near(area) {
|
||||
if seen.id == m.id
|
||||
|| !seen.alive
|
||||
|| seen.species == Species::Shulker
|
||||
|| !seen.bounds.intersects(area)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
events.push(MobEvent::Shift {
|
||||
target: Who::Mob(seen.id),
|
||||
delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `ShulkerLookControl.getYRotD`: the look yaw in the open direction's
|
||||
/// plane (`None`: the target is on the head's own axis).
|
||||
pub fn look_yaw(m: &Mob, at: [f64; 3]) -> Option<f32> {
|
||||
let open = opposite(m.extra.variant3);
|
||||
// `attachFace.getRotation().transform(FORWARD)`: the model's forward
|
||||
// rotated as the renderer does (`rotations` uses the same quats).
|
||||
let forward = face_quat(m.extra.variant3) * glam::Vec3::Z;
|
||||
let s = face_step(open).map(|v| v as f32);
|
||||
let right = glam::Vec3::from_array(s).cross(forward);
|
||||
let out = glam::Vec3::new(
|
||||
(at[0] - m.position.x) as f32,
|
||||
(at[1] - m.eye_y()) as f32,
|
||||
(at[2] - m.position.z) as f32,
|
||||
);
|
||||
let (delta_right, delta_forward) = (right.dot(out), forward.dot(out));
|
||||
if delta_right.abs() <= 1.0e-5 && delta_forward.abs() <= 1.0e-5 {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_core::geometry::trig::atan2_degrees(
|
||||
f64::from(-delta_right),
|
||||
f64::from(delta_forward),
|
||||
))
|
||||
}
|
||||
/// `Shulker.hurtServer` before the damage: arrows bounce off a closed shell.
|
||||
pub fn accepts(m: &Mob, source: &Damage) -> bool {
|
||||
!(m.extra.counter == 0 && source.kind == DamageKind::Arrow)
|
||||
@@ -319,7 +492,7 @@ impl Goal for Peek {
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
m.ai.target.is_none()
|
||||
&& m.random.next_int_bound(reduced(40)).unwrap_or(1) == 0
|
||||
&& can_stay_at(cx.w, block_pos(m), m.extra.variant3)
|
||||
&& can_stay_at(m, cx.w, block_pos(m), m.extra.variant3)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
|
||||
m.ai.target.is_none() && self.time > 0
|
||||
@@ -362,24 +535,27 @@ pub fn texture(m: &Mob) -> String {
|
||||
None => "entity/shulker/shulker".into(),
|
||||
}
|
||||
}
|
||||
/// `attachFace.getOpposite().getRotation()` by attach face: the open
|
||||
/// direction's rotation (`Direction.getRotation` of `face ^ 1`).
|
||||
fn face_quat(face: u8) -> glam::Quat {
|
||||
match face.min(5) {
|
||||
// Attached below: upright.
|
||||
0 => glam::Quat::IDENTITY,
|
||||
1 => glam::Quat::from_rotation_x(PI),
|
||||
2 => glam::Quat::from_rotation_x(PI / 2.0),
|
||||
3 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI),
|
||||
4 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, -PI / 2.0),
|
||||
_ => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI / 2.0),
|
||||
}
|
||||
}
|
||||
/// `ShulkerRenderer.setupRotations`: turned onto its attach face around the
|
||||
/// block's centre (after a half turn of the body).
|
||||
pub fn rotations(m: &Mob) -> glam::Mat4 {
|
||||
{
|
||||
let q = match m.extra.variant3 {
|
||||
// Attached below: upright.
|
||||
0 => glam::Quat::IDENTITY,
|
||||
1 => glam::Quat::from_rotation_x(PI),
|
||||
2 => glam::Quat::from_rotation_x(PI / 2.0),
|
||||
3 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI),
|
||||
4 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, -PI / 2.0),
|
||||
_ => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI / 2.0),
|
||||
};
|
||||
glam::Mat4::from_rotation_y(PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.5, 0.0))
|
||||
* glam::Mat4::from_quat(q)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.5, 0.0))
|
||||
}
|
||||
let q = face_quat(m.extra.variant3);
|
||||
glam::Mat4::from_rotation_y(PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.5, 0.0))
|
||||
* glam::Mat4::from_quat(q)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.5, 0.0))
|
||||
}
|
||||
/// `ShulkerModel.setupAnim`: the lid rises and twists with the peek, the
|
||||
/// head turns.
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
//! Turtles (`Turtle`): the egg a bred mother carries (`TurtleBreedGoal`), her
|
||||
//! walk home (`TurtleGoHomeGoal`) and the egg she digs into the sand
|
||||
//! (`TurtleLayEggGoal`). `extra.flag` is `hasEgg`, `extra.flag2` `isLayingEgg`,
|
||||
//! `extra.counter` `layEggCounter` and `extra.home` `homePos`.
|
||||
//! `extra.counter` `layEggCounter` and `extra.home` `homePos`. Its own
|
||||
//! `TurtleMoveControl` sets the speed it walks and swims at. Not ported yet:
|
||||
//! `Turtle.travelInWater` (swimming still uses the generic fluid travel),
|
||||
//! `TurtlePathNavigation` and the rest of the turtle goal set, so movement in
|
||||
//! water still differs from vanilla.
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld,
|
||||
control::{EPSILON, Operation, rotlerp},
|
||||
goals::{Breed, Cx, Goal, JUMP, MOVE, MoveToBlock, goal_clone, reduced},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_core::{geometry::trig, random::RandomSource};
|
||||
|
||||
pub fn register(m: &mut Mob) {
|
||||
let g = &mut m.ai.goals;
|
||||
@@ -49,6 +54,45 @@ fn block_of(m: &Mob) -> [i32; 3] {
|
||||
fn is_sand(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
w.has_tag("minecraft:sand", w.block_name(p[0], p[1], p[2]))
|
||||
}
|
||||
/// `Turtle.TurtleMoveControl.updateSpeed`: slow in water away from home
|
||||
/// (slower still as a baby), and halved every tick on land, never under 0.06.
|
||||
fn update_speed(m: &mut Mob) {
|
||||
if m.fluids.in_water {
|
||||
m.velocity.y += 0.005;
|
||||
if !close_to_home(m, 16.0) {
|
||||
m.set_speed((m.ai.speed / 2.0).max(0.08));
|
||||
}
|
||||
if m.is_baby() {
|
||||
m.set_speed((m.ai.speed / 3.0).max(0.06));
|
||||
}
|
||||
} else if m.on_ground {
|
||||
m.set_speed((m.ai.speed / 2.0).max(0.06));
|
||||
}
|
||||
}
|
||||
/// `Turtle.TurtleMoveControl.tick`: ease the speed an eighth of the way to
|
||||
/// the wanted one each tick (after `updateSpeed` cut it), face the wanted
|
||||
/// point and lift towards it; no jumping, and the operation is never reset.
|
||||
pub fn move_control(m: &mut Mob) {
|
||||
update_speed(m);
|
||||
if m.ai.mov.operation != Operation::MoveTo || m.ai.nav.is_done() {
|
||||
m.set_speed(0.0);
|
||||
return;
|
||||
}
|
||||
let [wx, wy, wz] = m.ai.mov.wanted;
|
||||
let (xd, yd, zd) = (wx - m.position.x, wy - m.position.y, wz - m.position.z);
|
||||
let dist = (xd * xd + yd * yd + zd * zd).sqrt();
|
||||
if dist < EPSILON {
|
||||
m.set_speed(0.0);
|
||||
return;
|
||||
}
|
||||
let y_rot = trig::atan2_degrees(zd, xd) - 90.0;
|
||||
m.yaw = rotlerp(m.yaw, y_rot, 90.0);
|
||||
m.body_yaw = m.yaw;
|
||||
let wanted = (m.ai.mov.speed_modifier * m.movement_speed()) as f32;
|
||||
// Mth.lerp(0.125F, getSpeed(), wanted).
|
||||
m.set_speed(m.ai.speed + 0.125 * (wanted - m.ai.speed));
|
||||
m.velocity.y += f64::from(m.ai.speed) * (yd / dist) * 0.1;
|
||||
}
|
||||
/// `Turtle.aiStep`: while digging, sand crumbs fly every fifth tick.
|
||||
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.alive() && m.extra.flag2 && m.extra.counter >= 1 && m.extra.counter % 5 == 0 {
|
||||
@@ -207,3 +251,149 @@ impl Goal for GoHome {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::{
|
||||
Species,
|
||||
path::{Path, PathNode, PathType},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A turtle (movement speed 0.25) strolling (`speedModifier` 1.0) to a
|
||||
/// point ten blocks ahead along a path it has not finished.
|
||||
fn strolling_turtle() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:turtle".into();
|
||||
k.species = Species::Turtle;
|
||||
k.speed = 0.25;
|
||||
k.step_height = 1.0;
|
||||
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
m.on_ground = true;
|
||||
let node = PathNode {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 10,
|
||||
kind: PathType::Walkable,
|
||||
};
|
||||
m.ai.nav.path = Some(Path {
|
||||
nodes: vec![node],
|
||||
next: 0,
|
||||
target: [0, 0, 10],
|
||||
dist_to_target: 0.0,
|
||||
reached: true,
|
||||
});
|
||||
m.ai.mov.set_wanted(0.0, 0.0, 10.0, 1.0);
|
||||
m
|
||||
}
|
||||
fn speeds(m: &mut Mob, ticks: usize) -> Vec<f32> {
|
||||
(0..ticks)
|
||||
.map(|_| {
|
||||
move_control(m);
|
||||
assert_eq!(m.ai.zza, m.ai.speed, "setSpeed sets the forward input");
|
||||
m.ai.speed
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_on_land_at_a_third_of_its_movement_speed() {
|
||||
// TurtleMoveControl.tick on the ground: updateSpeed makes it
|
||||
// max(speed / 2, 0.06) = 0.06, then Mth.lerp(0.125F, 0.06, 0.25),
|
||||
// every tick. The default MoveControl walked it at the full 0.25, so
|
||||
// the walk animation (and TurtleModel's 8 * walkSpeed leg swing) ran
|
||||
// about nine times too fast (#1776).
|
||||
let mut m = strolling_turtle();
|
||||
let land = 0.06_f32 + 0.125 * (0.25 - 0.06_f32);
|
||||
assert_eq!(speeds(&mut m, 40), vec![land; 40]);
|
||||
// Facing the wanted point, the body with it; it never jumps.
|
||||
assert_eq!(m.body_yaw, m.yaw);
|
||||
assert!(crate::mobs::living::wrap(m.yaw).abs() < 1.0e-3);
|
||||
assert!(!m.ai.jump);
|
||||
assert_eq!(m.ai.mov.operation, Operation::MoveTo);
|
||||
assert_eq!(m.velocity.y, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_speed_is_cut_away_from_home_and_for_babies() {
|
||||
// updateSpeed in water near home: no cut, the lerp climbs towards
|
||||
// 0.25, and it lifts the turtle 0.005 a tick. (Only the control's
|
||||
// speed: Turtle.travelInWater is not ported.)
|
||||
let mut m = strolling_turtle();
|
||||
m.on_ground = false;
|
||||
m.fluids.in_water = true;
|
||||
let s = speeds(&mut m, 60);
|
||||
assert_eq!(s[0], 0.125 * 0.25);
|
||||
assert!(s.windows(2).all(|w| w[1] > w[0]) && s[59] > 0.24 && s[59] < 0.25);
|
||||
assert!((m.velocity.y - 60.0 * 0.005).abs() < 1.0e-9);
|
||||
// More than 16 blocks from home: max(speed / 2, 0.08) first.
|
||||
let mut m = strolling_turtle();
|
||||
m.on_ground = false;
|
||||
m.fluids.in_water = true;
|
||||
m.extra.home = Some([0, 0, 40]);
|
||||
let far = 0.08_f32 + 0.125 * (0.25 - 0.08_f32);
|
||||
assert_eq!(speeds(&mut m, 20), vec![far; 20]);
|
||||
// A baby: max(speed / 3, 0.06).
|
||||
let mut m = strolling_turtle();
|
||||
m.on_ground = false;
|
||||
m.fluids.in_water = true;
|
||||
m.extra.age = -24000;
|
||||
let young = 0.06_f32 + 0.125 * (0.25 - 0.06_f32);
|
||||
assert_eq!(speeds(&mut m, 20), vec![young; 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_when_the_path_is_done_or_the_point_reached() {
|
||||
let mut m = strolling_turtle();
|
||||
m.ai.nav.stop();
|
||||
assert_eq!(speeds(&mut m, 1), vec![0.0]);
|
||||
let mut m = strolling_turtle();
|
||||
m.ai.mov.set_wanted(0.0, 0.0, 0.0, 1.0);
|
||||
assert_eq!(speeds(&mut m, 1), vec![0.0]);
|
||||
// updateSpeed runs before the early return: a stopped turtle in
|
||||
// water is still lifted 0.005, path done or not moving at all.
|
||||
let mut m = strolling_turtle();
|
||||
m.fluids.in_water = true;
|
||||
m.ai.nav.stop();
|
||||
assert_eq!(speeds(&mut m, 1), vec![0.0]);
|
||||
assert_eq!(m.velocity.y, 0.005);
|
||||
let mut m = strolling_turtle();
|
||||
m.fluids.in_water = true;
|
||||
m.ai.mov.operation = Operation::Wait;
|
||||
assert_eq!(speeds(&mut m, 1), vec![0.0]);
|
||||
assert_eq!(m.velocity.y, 0.005);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifts_towards_a_point_above_by_the_slope_of_the_way_there() {
|
||||
// setDeltaMovement(... + getSpeed() * (dy / dist) * 0.1): two up,
|
||||
// three across and ten ahead.
|
||||
let mut m = strolling_turtle();
|
||||
m.ai.mov.set_wanted(3.0, 2.0, 10.0, 1.0);
|
||||
let speed = speeds(&mut m, 1)[0];
|
||||
let dist = (3.0_f64 * 3.0 + 2.0 * 2.0 + 10.0 * 10.0).sqrt();
|
||||
assert_eq!(m.velocity.y, f64::from(speed) * (2.0 / dist) * 0.1);
|
||||
assert!(m.velocity.y > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_at_most_ninety_degrees_a_tick_and_wraps_into_a_turn() {
|
||||
// rotlerp(yRot, target, 90): from 150 towards about -16.7 it turns
|
||||
// the full 90 to 60, then reaches the target, kept in [0, 360] as
|
||||
// about 343.3; the body follows the yaw each tick.
|
||||
let mut m = strolling_turtle();
|
||||
m.ai.mov.set_wanted(3.0, 0.0, 10.0, 1.0);
|
||||
m.yaw = 150.0;
|
||||
m.body_yaw = 150.0;
|
||||
let target = trig::atan2_degrees(10.0, 3.0) - 90.0;
|
||||
assert!((-17.0..-16.0).contains(&target));
|
||||
move_control(&mut m);
|
||||
assert_eq!((m.yaw, m.body_yaw), (60.0, 60.0));
|
||||
move_control(&mut m);
|
||||
assert_eq!(m.yaw, 60.0 + (target - 60.0) + 360.0);
|
||||
assert_eq!(m.body_yaw, m.yaw);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ fn ruby_mod(dir: &Path) -> ContentMod {
|
||||
/// new blocks behave like their templates with the overrides, and vanilla's
|
||||
/// states are where they were.
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn content_root_loads_with_every_table() {
|
||||
let Some(base) = prepared() else {
|
||||
return;
|
||||
@@ -352,6 +353,7 @@ fn no_content_keeps_the_base_root() {
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn shared_behaviour_document_matches_independent_loaders() {
|
||||
let Some(root) = prepared() else { return };
|
||||
let assets = Assets::load(&root).expect("assets");
|
||||
@@ -414,6 +416,7 @@ fn shared_behaviour_document_matches_independent_loaders() {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn independent_reloads_read_changed_behaviour_json_and_report_invalid_files() {
|
||||
let Some(base) = prepared() else { return };
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-json-reloads-{}", std::process::id()));
|
||||
@@ -535,6 +538,7 @@ fn independent_reloads_read_changed_behaviour_json_and_report_invalid_files() {
|
||||
/// A built root is kept for later starts, and built again when a mod's
|
||||
/// files change.
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn content_root_is_reused_until_a_mod_changes() {
|
||||
let Some(base) = prepared() else {
|
||||
return;
|
||||
@@ -571,6 +575,7 @@ fn content_root_is_reused_until_a_mod_changes() {
|
||||
/// set, pool and templates join the bundles, and its biome tag keeps only
|
||||
/// what the loader knows.
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn content_root_carries_a_structure_mod() {
|
||||
let Some(base) = prepared() else {
|
||||
return;
|
||||
|
||||
@@ -68,6 +68,9 @@ pub enum Event {
|
||||
host: String,
|
||||
port: i32,
|
||||
},
|
||||
/// `ClientboundServerLinksPacket`: the links the pause menu's "Server
|
||||
/// Links..." lists (ClientCommonPacketListenerImpl.handleServerLinks).
|
||||
ServerLinks(Vec<cfg::ServerLink>),
|
||||
/// The connection ended. `from_server`: the server gave the reason (or
|
||||
/// the connection was refused for good), rather than the connection
|
||||
/// failing (auto-reconnect retries only failures and shutdowns).
|
||||
@@ -565,6 +568,9 @@ fn receive_play(
|
||||
play::Clientbound::StoreCookie(p) => {
|
||||
cookies.lock().unwrap().insert(p.key, p.payload);
|
||||
}
|
||||
play::Clientbound::ServerLinks(p) => {
|
||||
let _ = send.send(Event::ServerLinks(p.links));
|
||||
}
|
||||
// ClientCommonPacketListenerImpl.handleTransfer: the connection
|
||||
// ends and the game connects to the other address.
|
||||
play::Clientbound::Transfer(p) => {
|
||||
@@ -644,6 +650,9 @@ fn configure(
|
||||
cfg::Clientbound::StoreCookie(p) => {
|
||||
cookies.lock().unwrap().insert(p.key, p.payload);
|
||||
}
|
||||
cfg::Clientbound::ServerLinks(p) => {
|
||||
let _ = send.send(Event::ServerLinks(p.links));
|
||||
}
|
||||
cfg::Clientbound::Transfer(p) => {
|
||||
let _ = send.send(Event::Transfer {
|
||||
host: p.host,
|
||||
|
||||
@@ -56,6 +56,7 @@ pub enum Opt {
|
||||
ChatVisibility,
|
||||
ChatColors,
|
||||
ChatLinks,
|
||||
ChatLinksPrompt,
|
||||
ChatOpacity,
|
||||
TextBackgroundOpacity,
|
||||
ChatScale,
|
||||
@@ -243,6 +244,7 @@ impl Opt {
|
||||
Opt::ChatVisibility => "options.chat.visibility",
|
||||
Opt::ChatColors => "options.chat.color",
|
||||
Opt::ChatLinks => "options.chat.links",
|
||||
Opt::ChatLinksPrompt => "options.chat.links.prompt",
|
||||
Opt::ChatOpacity => "options.chat.opacity",
|
||||
Opt::TextBackgroundOpacity => "options.accessibility.text_background_opacity",
|
||||
Opt::ChatScale => "options.chat.scale",
|
||||
@@ -581,6 +583,7 @@ impl Opt {
|
||||
][usize::from(s.chat_visibility.min(2))]),
|
||||
Opt::ChatColors => on_off(s.chat_colors),
|
||||
Opt::ChatLinks => on_off(s.chat_links),
|
||||
Opt::ChatLinksPrompt => on_off(s.chat_links_prompt),
|
||||
Opt::AutoSuggestions => on_off(s.auto_suggestions),
|
||||
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
|
||||
Opt::HighContrast => on_off(s.high_contrast),
|
||||
@@ -755,6 +758,7 @@ impl Opt {
|
||||
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
|
||||
Opt::ChatColors => s.chat_colors = !s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links = !s.chat_links,
|
||||
Opt::ChatLinksPrompt => s.chat_links_prompt = !s.chat_links_prompt,
|
||||
Opt::AutoSuggestions => s.auto_suggestions = !s.auto_suggestions,
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast = !s.high_contrast,
|
||||
@@ -1507,6 +1511,7 @@ impl OptionsUi {
|
||||
Opt::ChatVisibility,
|
||||
Opt::ChatColors,
|
||||
Opt::ChatLinks,
|
||||
Opt::ChatLinksPrompt,
|
||||
Opt::ChatOpacity,
|
||||
Opt::TextBackgroundOpacity,
|
||||
Opt::ChatScale,
|
||||
|
||||
@@ -3820,6 +3820,9 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
|
||||
fn is_decorated(&self, proto: &crate::streaming::Proto) -> bool {
|
||||
self.inner.is_decorated(proto)
|
||||
}
|
||||
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
|
||||
self.inner.light_view(proto)
|
||||
}
|
||||
fn save_protos(&self, protos: &[&crate::streaming::Proto]) {
|
||||
self.save
|
||||
.tables
|
||||
|
||||
@@ -145,6 +145,7 @@ pub struct Settings {
|
||||
pub chat_visibility: u8,
|
||||
pub chat_colors: bool,
|
||||
pub chat_links: bool,
|
||||
pub chat_links_prompt: bool,
|
||||
pub chat_opacity: f32,
|
||||
pub text_background_opacity: f32,
|
||||
pub chat_scale: f32,
|
||||
@@ -230,6 +231,7 @@ impl Default for Settings {
|
||||
chat_visibility: 0,
|
||||
chat_colors: true,
|
||||
chat_links: true,
|
||||
chat_links_prompt: true,
|
||||
chat_opacity: 1.0,
|
||||
text_background_opacity: 0.5,
|
||||
chat_scale: 1.0,
|
||||
@@ -423,6 +425,7 @@ impl Settings {
|
||||
chat_visibility: f.int("chatVisibility") as u8,
|
||||
chat_colors: f.bool("chatColors"),
|
||||
chat_links: f.bool("chatLinks"),
|
||||
chat_links_prompt: f.bool("chatLinksPrompt"),
|
||||
chat_opacity: f.double("chatOpacity") as f32,
|
||||
text_background_opacity: f.double("textBackgroundOpacity") as f32,
|
||||
chat_scale: f.double("chatScale") as f32,
|
||||
@@ -506,6 +509,7 @@ impl Settings {
|
||||
put_i(&mut f, "chatVisibility", i32::from(self.chat_visibility));
|
||||
put_b(&mut f, "chatColors", self.chat_colors);
|
||||
put_b(&mut f, "chatLinks", self.chat_links);
|
||||
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
|
||||
put_f(&mut f, "chatOpacity", self.chat_opacity);
|
||||
put_f(
|
||||
&mut f,
|
||||
|
||||
@@ -52,8 +52,9 @@ pub fn thread_cpu() -> Option<Duration> {
|
||||
Some(Duration::new(t.tv_sec as u64, t.tv_nsec as u32))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
None
|
||||
horizon_threads::thread_cpu()
|
||||
}
|
||||
|
||||
/// The frame's spans, summed by name, slowest first; clears them.
|
||||
pub fn take() -> Vec<Span> {
|
||||
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
|
||||
@@ -135,7 +136,7 @@ mod tests {
|
||||
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
|
||||
#[test]
|
||||
fn thread_cpu_counts_work_not_sleep() {
|
||||
let start = super::thread_cpu().unwrap();
|
||||
|
||||
@@ -70,6 +70,11 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
false
|
||||
}
|
||||
/// What to save of a proto chunk dropped before it became final.
|
||||
/// A decorated proto chunk's blocks as a column, to light a finished
|
||||
/// neighbour against (vanilla lights with neighbours at FEATURES).
|
||||
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
|
||||
None
|
||||
}
|
||||
fn saved_proto(&self, _proto: &Proto) -> Option<crate::save::SavedProto> {
|
||||
None
|
||||
}
|
||||
@@ -212,6 +217,7 @@ struct Shared {
|
||||
}
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
results: mpsc::Receiver<Done>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
@@ -539,8 +545,13 @@ impl Streaming {
|
||||
.expect("spawn worker")
|
||||
})
|
||||
.collect();
|
||||
let light_view = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.light_view(p))
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
@@ -779,6 +790,26 @@ impl Streaming {
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.ranges().priority(x, z, self.loading)
|
||||
}
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
/// vanilla lights its last FULL ring against neighbours at FEATURES.
|
||||
fn light_region(&self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
|
||||
region[dz][dx] = Some(match world.chunks.get(&n) {
|
||||
Some(chunk) => chunk.clone(),
|
||||
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
|
||||
let proto = self.protos.get(&n)?.as_ref()?;
|
||||
Arc::new((self.light_view)(proto)?)
|
||||
}
|
||||
None => return None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Box::new(region))
|
||||
}
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
@@ -900,6 +931,11 @@ impl Streaming {
|
||||
} else {
|
||||
apply_relighting(chunk, sections, &mut changed);
|
||||
}
|
||||
// Saved unlit (at the edge of what was loaded), or its light
|
||||
// changed: saved again.
|
||||
if (first && !chunk.light_loaded) || !changed.is_empty() {
|
||||
world.modified.insert((x, z));
|
||||
}
|
||||
chunk.lit = true;
|
||||
if first {
|
||||
for dz in -1..=1 {
|
||||
@@ -1136,7 +1172,7 @@ impl Streaming {
|
||||
world.dirty_light.remove(&(x, z));
|
||||
continue;
|
||||
}
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
world.dirty_light.remove(&(x, z));
|
||||
@@ -1468,7 +1504,7 @@ impl Streaming {
|
||||
counts[1] += 1;
|
||||
} else {
|
||||
counts[2] += 1;
|
||||
if Self::region(world, key.0, key.1).is_some() {
|
||||
if self.light_region(world, key.0, key.1).is_some() {
|
||||
world.dirty_light.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +505,12 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
fn is_decorated(&self, proto: &Proto) -> bool {
|
||||
matches!(proto.downcast_ref::<VanillaProto>(), Some(VanillaProto::Ready(c)) if c.is_decorated())
|
||||
}
|
||||
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
|
||||
match proto.downcast_ref::<VanillaProto>() {
|
||||
Some(VanillaProto::Ready(chunk)) => Some(self.column(chunk)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn saved_proto(&self, proto: &Proto) -> Option<SavedProto> {
|
||||
let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::<VanillaProto>() else {
|
||||
return None;
|
||||
|
||||
@@ -279,6 +279,75 @@ fn run<T: Send + 'static>(
|
||||
handle.join().unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_settings_packets_update_only_the_players_book_and_survive_save() {
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let _bot = run(&mut s, move || connect(port, "RecipeSettings", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
for book in 0..4 {
|
||||
for flags in 0..4 {
|
||||
let open = flags & 1 != 0;
|
||||
let filtering = flags & 2 != 0;
|
||||
let before = s.hub.guests[0].recipe_book.clone();
|
||||
// Vanilla's wire payload: RecipeBookType VarInt, open, filtering.
|
||||
let packet = S::decode(
|
||||
horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS,
|
||||
&[book, u8::from(open), u8::from(filtering)],
|
||||
)
|
||||
.unwrap();
|
||||
s.hub.guests[0].inbox.push_back(packet);
|
||||
s.tick(&[]);
|
||||
let settings = &s.hub.guests[0].recipe_book;
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
settings.open[i],
|
||||
if i == usize::from(book) {
|
||||
open
|
||||
} else {
|
||||
before.open[i]
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
settings.filtering[i],
|
||||
if i == usize::from(book) {
|
||||
filtering
|
||||
} else {
|
||||
before.filtering[i]
|
||||
}
|
||||
);
|
||||
}
|
||||
let mut saved = horizon_client::save::Level::default();
|
||||
horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved);
|
||||
let mut reopened = horizon_client::save::Level::default();
|
||||
reopened.read_player(&saved.player_tag());
|
||||
assert_eq!(reopened.recipe_book.open, settings.open);
|
||||
assert_eq!(reopened.recipe_book.filtering, settings.filtering);
|
||||
}
|
||||
}
|
||||
for book_type in [-1, 4] {
|
||||
let mut wire = horizon_protocol::buf::Writer::new();
|
||||
wire.varint(book_type);
|
||||
wire.bool(true);
|
||||
wire.bool(true);
|
||||
assert!(
|
||||
S::decode(
|
||||
horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS,
|
||||
&wire.data,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_commands_survive_keyboard_input_changes() {
|
||||
let Some(resources) = resources() else {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "horizon-mod-lua"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Write Horizon mods in Lua: a Lua interpreter (piccolo) running inside the mod's WebAssembly sandbox"
|
||||
|
||||
# One feature per capability, as the mod's horizon-mod.toml lists them: the
|
||||
# game refuses a mod whose component imports an interface it didn't declare,
|
||||
# so only the `horizon.<interface>` tables a feature names are compiled in.
|
||||
[features]
|
||||
game-read = []
|
||||
chat = []
|
||||
hud = []
|
||||
storage = []
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../horizon-mod-sdk" }
|
||||
# Pure Rust, so it builds for wasm32-unknown-unknown (C Lua needs setjmp). MIT.
|
||||
piccolo = "0.3.3"
|
||||
|
||||
# piccolo's hash tables (ahash) and math.random (rand) ask getrandom for
|
||||
# seeds; a mod has no entropy source, so they get a fixed sequence (below).
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom02 = { package = "getrandom", version = "0.2", features = ["custom"] }
|
||||
getrandom03 = { package = "getrandom", version = "0.3" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Horizon mods in Lua (`docs/mods.md`, "Lua"): a Lua 5.4 interpreter
|
||||
//! ([piccolo](https://github.com/kyren/piccolo), pure Rust) compiled into the
|
||||
//! mod's WebAssembly component with the mod's script, so a Lua mod runs in
|
||||
//! the same sandbox, with the same capabilities and time budgets, as a Rust
|
||||
//! one. The mod's crate is one line:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! horizon_mod_lua::lua_mod!("main.lua");
|
||||
//! ```
|
||||
//!
|
||||
//! The script registers its commands with `horizon.command(name)` and
|
||||
//! defines the hooks it wants as globals: `on_tick(tick)`,
|
||||
//! `on_frame(seconds)`, `on_command(name, args)` (a string returned is the
|
||||
//! feedback, `error(text)` the red error) and `on_chat(text)`. The
|
||||
//! interfaces are tables of `horizon`: `log` always, and `game`, `chat`,
|
||||
//! `hud`, `storage` with this crate's feature of the same capability
|
||||
//! (`game-read` for `game`), as the mod's `horizon-mod.toml` lists them.
|
||||
#[cfg(feature = "chat")]
|
||||
use horizon_mod_sdk::chat;
|
||||
#[cfg(feature = "game-read")]
|
||||
use horizon_mod_sdk::game;
|
||||
#[cfg(feature = "hud")]
|
||||
use horizon_mod_sdk::hud;
|
||||
#[cfg(feature = "storage")]
|
||||
use horizon_mod_sdk::storage;
|
||||
use horizon_mod_sdk::{Registration, log};
|
||||
use piccolo::{
|
||||
Callback, CallbackReturn, Closure, Context, Error, Executor, Lua, StashedExecutor, StaticError,
|
||||
Table, Value, Variadic,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
|
||||
mod stdlib;
|
||||
|
||||
/// Seeds for piccolo's hash tables and `math.random`: a mod's sandbox has
|
||||
/// no entropy source, so they come from a fixed sequence (SplitMix64).
|
||||
/// `getrandom` 0.3 takes it with `--cfg getrandom_backend="custom"`, which
|
||||
/// `cargo horizon-mod build` passes.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod entropy {
|
||||
use std::cell::Cell;
|
||||
|
||||
std::thread_local! {
|
||||
static STATE: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
|
||||
}
|
||||
|
||||
fn fill(dest: &mut [u8]) {
|
||||
for chunk in dest.chunks_mut(8) {
|
||||
let z = STATE.with(|s| {
|
||||
let z = s.get().wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
s.set(z);
|
||||
z
|
||||
});
|
||||
let mut z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^= z >> 31;
|
||||
chunk.copy_from_slice(&z.to_le_bytes()[..chunk.len()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn fill02(dest: &mut [u8]) -> Result<(), getrandom02::Error> {
|
||||
fill(dest);
|
||||
Ok(())
|
||||
}
|
||||
getrandom02::register_custom_getrandom!(fill02);
|
||||
|
||||
/// getrandom 0.3's custom backend.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dest` points to `len` writable bytes (getrandom's contract).
|
||||
#[unsafe(no_mangle)]
|
||||
unsafe extern "Rust" fn __getrandom_v03_custom(
|
||||
dest: *mut u8,
|
||||
len: usize,
|
||||
) -> Result<(), getrandom03::Error> {
|
||||
// SAFETY: getrandom passes a buffer of `len` bytes it owns.
|
||||
fill(unsafe { std::slice::from_raw_parts_mut(dest, len) });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub use horizon_mod_sdk as __sdk;
|
||||
|
||||
std::thread_local! {
|
||||
/// What `horizon.command` registered while the script's chunk ran.
|
||||
static COMMANDS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// A hook's argument.
|
||||
pub enum Arg {
|
||||
Integer(i64),
|
||||
Number(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// A running Lua mod: the interpreter with the script loaded.
|
||||
pub struct LuaMod {
|
||||
lua: Lua,
|
||||
/// One executor, restarted for each call (a new one per call costs a
|
||||
/// thread's allocation).
|
||||
executor: StashedExecutor,
|
||||
}
|
||||
|
||||
/// A Lua error as the player reads it: the message alone for `error(text)`.
|
||||
fn message(e: StaticError) -> String {
|
||||
match e {
|
||||
StaticError::Lua(e) => e.to_string(),
|
||||
StaticError::Runtime(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl LuaMod {
|
||||
/// Runs the script's chunk; the hooks it defined and the commands it
|
||||
/// registered make the [`Registration`].
|
||||
pub fn start(name: &str, source: &str) -> Result<(Self, Registration), String> {
|
||||
let mut lua = Lua::core();
|
||||
lua.try_enter(|ctx| {
|
||||
stdlib::install(ctx)?;
|
||||
install(ctx)
|
||||
})
|
||||
.map_err(message)?;
|
||||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||||
let chunk = lua
|
||||
.try_enter(|ctx| {
|
||||
let closure = Closure::load(ctx, Some(name), source.as_bytes())?;
|
||||
Ok(ctx.stash(Executor::start(ctx, closure.into(), ())))
|
||||
})
|
||||
.map_err(message)?;
|
||||
lua.execute::<()>(&chunk).map_err(message)?;
|
||||
let mut registration = Registration::new();
|
||||
lua.enter(|ctx| {
|
||||
let defined = |hook: &'static str| matches!(ctx.get_global(hook), Value::Function(_));
|
||||
registration.tick = defined("on_tick");
|
||||
registration.frame = defined("on_frame");
|
||||
registration.chat = defined("on_chat");
|
||||
});
|
||||
registration.commands = COMMANDS.with(|c| std::mem::take(&mut *c.borrow_mut()));
|
||||
let executor = lua.enter(|ctx| ctx.stash(Executor::new(ctx)));
|
||||
Ok((LuaMod { lua, executor }, registration))
|
||||
}
|
||||
|
||||
/// Calls the global function `hook` with `args`: what it returned, as
|
||||
/// text (`None` for nothing or nil), or the error it raised. A hook the
|
||||
/// script didn't define returns `None`.
|
||||
pub fn call(&mut self, hook: &'static str, args: Vec<Arg>) -> Result<Option<String>, String> {
|
||||
let executor = &self.executor;
|
||||
let found = self
|
||||
.lua
|
||||
.try_enter(|ctx| {
|
||||
let Value::Function(f) = ctx.get_global(hook) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let args: Vec<Value> = args
|
||||
.into_iter()
|
||||
.map(|a| match a {
|
||||
Arg::Integer(i) => Value::Integer(i),
|
||||
Arg::Number(n) => Value::Number(n),
|
||||
Arg::Text(s) => ctx.intern(s.as_bytes()).into(),
|
||||
})
|
||||
.collect();
|
||||
ctx.fetch(executor).restart(ctx, f, Variadic(args));
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(message)?;
|
||||
if !found {
|
||||
return Ok(None);
|
||||
}
|
||||
self.lua.finish(executor);
|
||||
self.lua
|
||||
.try_enter(|ctx| {
|
||||
let value = ctx.fetch(executor).take_result::<Value>(ctx)??;
|
||||
Ok(match value {
|
||||
Value::Nil => None,
|
||||
Value::String(s) => Some(s.to_str_lossy().into_owned()),
|
||||
other => Some(other.to_string()),
|
||||
})
|
||||
})
|
||||
.map_err(message)
|
||||
}
|
||||
|
||||
/// A hook that returns nothing: an error goes to the mod's log.
|
||||
pub fn run(&mut self, hook: &'static str, args: Vec<Arg>) {
|
||||
if let Err(e) = self.call(hook, args) {
|
||||
log::write(log::Level::Error, &format!("{hook}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fn(args) -> results` as a Lua function.
|
||||
fn function<'gc>(
|
||||
ctx: Context<'gc>,
|
||||
f: impl Fn(Context<'gc>, Vec<Value<'gc>>) -> Result<Vec<Value<'gc>>, Error<'gc>> + 'static,
|
||||
) -> Callback<'gc> {
|
||||
Callback::from_fn(&ctx, move |ctx, _, mut stack| {
|
||||
let args: Vec<Value> = stack.drain(..).collect();
|
||||
let out = f(ctx, args)?;
|
||||
stack.extend(out);
|
||||
Ok(CallbackReturn::Return)
|
||||
})
|
||||
}
|
||||
|
||||
fn text<'gc>(ctx: Context<'gc>, s: &str) -> Value<'gc> {
|
||||
ctx.intern(s.as_bytes()).into()
|
||||
}
|
||||
|
||||
/// The argument at `i` as text (numbers convert, as Lua's `tostring`).
|
||||
fn arg_text(args: &[Value], i: usize, what: &str) -> Result<String, String> {
|
||||
match args.get(i).copied().unwrap_or(Value::Nil) {
|
||||
Value::String(s) => Ok(s.to_str_lossy().into_owned()),
|
||||
Value::Integer(n) => Ok(n.to_string()),
|
||||
Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(format!(
|
||||
"{what} must be a string, not {}",
|
||||
other.type_name()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn lua_error<'gc>(ctx: Context<'gc>, message: String) -> Error<'gc> {
|
||||
text(ctx, &message).into()
|
||||
}
|
||||
|
||||
#[cfg(feature = "game-read")]
|
||||
fn table<'gc>(
|
||||
ctx: Context<'gc>,
|
||||
fields: &[(&'static str, Value<'gc>)],
|
||||
) -> Result<Table<'gc>, Error<'gc>> {
|
||||
let t = Table::new(&ctx);
|
||||
for &(k, v) in fields {
|
||||
t.set(ctx, k, v)?;
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The `horizon` table and `print` (to the mod's log).
|
||||
fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
|
||||
let horizon = Table::new(&ctx);
|
||||
horizon.set(
|
||||
ctx,
|
||||
"command",
|
||||
function(ctx, |ctx, args| {
|
||||
let name = arg_text(&args, 0, "the command's name").map_err(|e| lua_error(ctx, e))?;
|
||||
COMMANDS.with(|c| c.borrow_mut().push(name));
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
let log_table = Table::new(&ctx);
|
||||
for (name, level) in [
|
||||
("debug", log::Level::Debug),
|
||||
("info", log::Level::Info),
|
||||
("warn", log::Level::Warn),
|
||||
("error", log::Level::Error),
|
||||
] {
|
||||
log_table.set(
|
||||
ctx,
|
||||
name,
|
||||
function(ctx, move |ctx, args| {
|
||||
let message = arg_text(&args, 0, "the message").map_err(|e| lua_error(ctx, e))?;
|
||||
log::write(level, &message);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
horizon.set(ctx, "log", log_table)?;
|
||||
ctx.set_global(
|
||||
"print",
|
||||
function(ctx, |_, args| {
|
||||
let line: Vec<String> = args
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::String(s) => s.to_str_lossy().into_owned(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
log::write(log::Level::Info, &line.join("\t"));
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
|
||||
#[cfg(feature = "game-read")]
|
||||
{
|
||||
let game_table = Table::new(&ctx);
|
||||
game_table.set(
|
||||
ctx,
|
||||
"player",
|
||||
function(ctx, |ctx, _| {
|
||||
let Some(p) = game::get_player() else {
|
||||
return Ok(vec![Value::Nil]);
|
||||
};
|
||||
let t = table(
|
||||
ctx,
|
||||
&[
|
||||
("x", Value::Number(p.x)),
|
||||
("y", Value::Number(p.y)),
|
||||
("z", Value::Number(p.z)),
|
||||
("yaw", Value::Number(p.yaw.into())),
|
||||
("pitch", Value::Number(p.pitch.into())),
|
||||
("health", Value::Number(p.health.into())),
|
||||
("food", Value::Integer(p.food.into())),
|
||||
("dimension", text(ctx, &p.dimension)),
|
||||
],
|
||||
)?;
|
||||
Ok(vec![t.into()])
|
||||
}),
|
||||
)?;
|
||||
game_table.set(
|
||||
ctx,
|
||||
"clock",
|
||||
function(ctx, |ctx, _| {
|
||||
let Some(c) = game::get_clock() else {
|
||||
return Ok(vec![Value::Nil]);
|
||||
};
|
||||
let t = table(
|
||||
ctx,
|
||||
&[
|
||||
("game_time", Value::Integer(c.game_time as i64)),
|
||||
("day_time", Value::Integer(c.day_time as i64)),
|
||||
],
|
||||
)?;
|
||||
Ok(vec![t.into()])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "game", game_table)?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "chat")]
|
||||
{
|
||||
let chat_table = Table::new(&ctx);
|
||||
chat_table.set(
|
||||
ctx,
|
||||
"show",
|
||||
function(ctx, |ctx, args| {
|
||||
let line = arg_text(&args, 0, "the line").map_err(|e| lua_error(ctx, e))?;
|
||||
chat::show(&line);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "chat", chat_table)?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "hud")]
|
||||
{
|
||||
let hud_table = Table::new(&ctx);
|
||||
hud_table.set(
|
||||
ctx,
|
||||
"set_panel",
|
||||
function(ctx, |ctx, args| {
|
||||
let corner = match arg_text(&args, 0, "the corner").map_err(|e| lua_error(ctx, e))?.as_str() {
|
||||
"top-left" => hud::Corner::TopLeft,
|
||||
"top-right" => hud::Corner::TopRight,
|
||||
"bottom-left" => hud::Corner::BottomLeft,
|
||||
"bottom-right" => hud::Corner::BottomRight,
|
||||
other => {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
format!("no corner {other:?}: top-left, top-right, bottom-left or bottom-right"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(Value::Table(rows)) = args.get(1).copied() else {
|
||||
return Err(lua_error(ctx, "the lines must be a table".into()));
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
for i in 1..=rows.length() {
|
||||
let line = match rows.get(ctx, i) {
|
||||
Value::Table(row) => hud::Line {
|
||||
text: arg_text(&[row.get(ctx, "text")], 0, "a line's text")
|
||||
.map_err(|e| lua_error(ctx, e))?,
|
||||
color: row.get(ctx, "color").to_integer().unwrap_or(0xFFFFFF) as u32,
|
||||
},
|
||||
v => hud::Line {
|
||||
text: arg_text(&[v], 0, "a line").map_err(|e| lua_error(ctx, e))?,
|
||||
color: 0xFFFFFF,
|
||||
},
|
||||
};
|
||||
lines.push(line);
|
||||
}
|
||||
hud::set_panel(corner, &lines);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
hud_table.set(
|
||||
ctx,
|
||||
"clear",
|
||||
function(ctx, |_, _| {
|
||||
hud::clear();
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "hud", hud_table)?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "storage")]
|
||||
{
|
||||
let storage_table = Table::new(&ctx);
|
||||
storage_table.set(
|
||||
ctx,
|
||||
"get",
|
||||
function(ctx, |ctx, args| {
|
||||
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
|
||||
Ok(vec![match storage::get(&key) {
|
||||
Some(bytes) => ctx.intern(&bytes).into(),
|
||||
None => Value::Nil,
|
||||
}])
|
||||
}),
|
||||
)?;
|
||||
storage_table.set(
|
||||
ctx,
|
||||
"set",
|
||||
function(ctx, |ctx, args| {
|
||||
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
|
||||
let value = match args.get(1).copied().unwrap_or(Value::Nil) {
|
||||
Value::String(s) => s.as_bytes().to_vec(),
|
||||
v => arg_text(&[v], 0, "the value")
|
||||
.map_err(|e| lua_error(ctx, e))?
|
||||
.into_bytes(),
|
||||
};
|
||||
// Lua's way: true, or nil and why.
|
||||
Ok(match storage::set(&key, &value) {
|
||||
Ok(()) => vec![Value::Boolean(true)],
|
||||
Err(e) => vec![Value::Nil, text(ctx, &e)],
|
||||
})
|
||||
}),
|
||||
)?;
|
||||
storage_table.set(
|
||||
ctx,
|
||||
"delete",
|
||||
function(ctx, |ctx, args| {
|
||||
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
|
||||
storage::delete(&key);
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
horizon.set(ctx, "storage", storage_table)?;
|
||||
}
|
||||
ctx.set_global("horizon", horizon)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Lua mod: the crate's `src/lib.rs` is `horizon_mod_lua::lua_mod!("main.lua");`,
|
||||
/// the script's path from the crate's folder.
|
||||
#[macro_export]
|
||||
macro_rules! lua_mod {
|
||||
($script:literal) => {
|
||||
const _: () = {
|
||||
// The SDK's export macro names the SDK's crate.
|
||||
use $crate::__sdk as horizon_mod_sdk;
|
||||
struct LuaMod($crate::LuaMod);
|
||||
impl horizon_mod_sdk::Mod for LuaMod {
|
||||
fn init() -> Result<(Self, horizon_mod_sdk::Registration), String> {
|
||||
let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $script));
|
||||
let (m, registration) = $crate::LuaMod::start($script, source)?;
|
||||
Ok((LuaMod(m), registration))
|
||||
}
|
||||
fn tick(&mut self, tick: u64) {
|
||||
self.0
|
||||
.run("on_tick", vec![$crate::Arg::Integer(tick as i64)]);
|
||||
}
|
||||
fn frame(&mut self, seconds: f32) {
|
||||
self.0
|
||||
.run("on_frame", vec![$crate::Arg::Number(seconds.into())]);
|
||||
}
|
||||
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
self.0.call(
|
||||
"on_command",
|
||||
vec![
|
||||
$crate::Arg::Text(name.into()),
|
||||
$crate::Arg::Text(args.into()),
|
||||
],
|
||||
)
|
||||
}
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.0.run("on_chat", vec![$crate::Arg::Text(text.into())]);
|
||||
}
|
||||
}
|
||||
horizon_mod_sdk::export_mod!(LuaMod);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Loads `source` and calls its global `f`.
|
||||
fn run(source: &str) -> Result<Option<String>, String> {
|
||||
let (mut m, _) = LuaMod::start("test.lua", source)?;
|
||||
m.call("on_command", vec![])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_makes_the_registration() {
|
||||
let (_, r) = LuaMod::start(
|
||||
"test.lua",
|
||||
r#"horizon.command("where") horizon.command("coords")
|
||||
function on_tick(t) end
|
||||
function on_command(name, args) end"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.commands, ["where", "coords"]);
|
||||
assert!(r.tick && !r.frame && !r.chat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_answers_or_errs() {
|
||||
assert_eq!(
|
||||
run("function on_command() return 'done' end"),
|
||||
Ok(Some("done".into()))
|
||||
);
|
||||
assert_eq!(run("function on_command() end"), Ok(None));
|
||||
assert_eq!(
|
||||
run("function on_command() error('Not in a world') end"),
|
||||
Err("Not in a world".into())
|
||||
);
|
||||
assert!(run("function on_command() return nil + 1 end").is_err());
|
||||
assert!(LuaMod::start("test.lua", "this is not lua").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_format_as_c_prints() {
|
||||
let f = |args: &str| {
|
||||
run(&format!(
|
||||
"function on_command() return string.format({args}) end"
|
||||
))
|
||||
};
|
||||
assert_eq!(
|
||||
f("'XYZ %.1f / %.1f', 12.5, -3.25"),
|
||||
Ok(Some("XYZ 12.5 / -3.2".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
f("'Day %d, %02d:%02d', 2, 6, 5"),
|
||||
Ok(Some("Day 2, 06:05".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
f("'[%5s|%-4d|%x|%X|%+d]', 'ab', 7, 255, 255, 3"),
|
||||
Ok(Some("[ ab|7 |ff|FF|+3]".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
f("'%g %g %e', 0.0001, 100000, 1234.5"),
|
||||
Ok(Some("0.0001 100000 1.234500e+03".into()))
|
||||
);
|
||||
assert_eq!(f("'%.0f%%', 99.6"), Ok(Some("100%".into())));
|
||||
assert_eq!(f("'%s %s', 1.0, nil"), Ok(Some("1.0 nil".into())));
|
||||
assert!(
|
||||
f("'%d', 1.5")
|
||||
.unwrap_err()
|
||||
.contains("no integer representation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_insert_remove_concat() {
|
||||
let script = r#"function on_command()
|
||||
local t = {}
|
||||
table.insert(t, "b")
|
||||
table.insert(t, "c")
|
||||
table.insert(t, 1, "a")
|
||||
local last = table.remove(t)
|
||||
return table.concat(t, ",") .. "|" .. last .. "|" .. string.rep("ab", 3, "-")
|
||||
end"#;
|
||||
assert_eq!(run(script), Ok(Some("a,b|c|ab-ab-ab".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rep_and_concat_keep_bytes() {
|
||||
let script = r#"function on_command()
|
||||
local r = string.rep("\255", 2, "\0")
|
||||
local c = table.concat({"\254", 7, "\128"}, "\255")
|
||||
return tostring(r == "\255\0\255" and #r == 3) .. " " .. tostring(c == "\254\255" .. "7\255\128" and #c == 5)
|
||||
end"#;
|
||||
assert_eq!(run(script), Ok(Some("true true".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! What piccolo's standard library leaves out that mods reach for:
|
||||
//! `string.format`, `string.rep`, `table.insert`, `table.remove` and
|
||||
//! `table.concat`, as Lua 5.4 defines them (the subset of `format` below).
|
||||
use crate::{function, lua_error, text};
|
||||
use piccolo::{Context, Error, Table, Value};
|
||||
|
||||
/// Adds them to the `string` and `table` tables.
|
||||
pub fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
|
||||
let string = match ctx.get_global("string") {
|
||||
Value::Table(t) => t,
|
||||
_ => {
|
||||
let t = Table::new(&ctx);
|
||||
ctx.set_global("string", t)?;
|
||||
t
|
||||
}
|
||||
};
|
||||
string.set(
|
||||
ctx,
|
||||
"format",
|
||||
function(ctx, |ctx, args| {
|
||||
let out = format(&args)
|
||||
.map_err(|e| lua_error(ctx, format!("bad argument to 'format' ({e})")))?;
|
||||
Ok(vec![text(ctx, &out)])
|
||||
}),
|
||||
)?;
|
||||
string.set(
|
||||
ctx,
|
||||
"rep",
|
||||
function(ctx, |ctx, args| {
|
||||
let s = bytes_arg(&args, 0).ok_or_else(|| {
|
||||
lua_error(ctx, "bad argument #1 to 'rep' (string expected)".into())
|
||||
})?;
|
||||
let n = args.get(1).and_then(|v| v.to_integer()).unwrap_or(0).max(0) as usize;
|
||||
let sep = bytes_arg(&args, 2).unwrap_or_default();
|
||||
if (s.len() + sep.len()).saturating_mul(n) > 1 << 20 {
|
||||
return Err(lua_error(ctx, "resulting string too large".into()));
|
||||
}
|
||||
Ok(vec![bytes(ctx, &vec![s; n].join(&sep[..]))])
|
||||
}),
|
||||
)?;
|
||||
let table = match ctx.get_global("table") {
|
||||
Value::Table(t) => t,
|
||||
_ => {
|
||||
let t = Table::new(&ctx);
|
||||
ctx.set_global("table", t)?;
|
||||
t
|
||||
}
|
||||
};
|
||||
table.set(
|
||||
ctx,
|
||||
"insert",
|
||||
function(ctx, |ctx, args| {
|
||||
let Some(Value::Table(t)) = args.first().copied() else {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #1 to 'insert' (table expected)".into(),
|
||||
));
|
||||
};
|
||||
let n = t.length();
|
||||
match args.len() {
|
||||
2 => {
|
||||
t.set(ctx, n + 1, args[1])?;
|
||||
}
|
||||
3 => {
|
||||
let at = args[1]
|
||||
.to_integer()
|
||||
.filter(|&i| (1..=n + 1).contains(&i))
|
||||
.ok_or_else(|| {
|
||||
lua_error(
|
||||
ctx,
|
||||
"bad argument #2 to 'insert' (position out of bounds)".into(),
|
||||
)
|
||||
})?;
|
||||
for i in (at..=n).rev() {
|
||||
t.set(ctx, i + 1, t.get(ctx, i))?;
|
||||
}
|
||||
t.set(ctx, at, args[2])?;
|
||||
}
|
||||
_ => {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"wrong number of arguments to 'insert'".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}),
|
||||
)?;
|
||||
table.set(
|
||||
ctx,
|
||||
"remove",
|
||||
function(ctx, |ctx, args| {
|
||||
let Some(Value::Table(t)) = args.first().copied() else {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #1 to 'remove' (table expected)".into(),
|
||||
));
|
||||
};
|
||||
let n = t.length();
|
||||
let at = args.get(1).and_then(|v| v.to_integer()).unwrap_or(n);
|
||||
if n == 0 && args.len() < 2 {
|
||||
return Ok(vec![Value::Nil]);
|
||||
}
|
||||
if !(1..=n + 1).contains(&at) {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #2 to 'remove' (position out of bounds)".into(),
|
||||
));
|
||||
}
|
||||
let removed = t.get(ctx, at);
|
||||
for i in at..n {
|
||||
t.set(ctx, i, t.get(ctx, i + 1))?;
|
||||
}
|
||||
if at <= n {
|
||||
t.set(ctx, n, Value::Nil)?;
|
||||
}
|
||||
Ok(vec![removed])
|
||||
}),
|
||||
)?;
|
||||
table.set(
|
||||
ctx,
|
||||
"concat",
|
||||
function(ctx, |ctx, args| {
|
||||
let Some(Value::Table(t)) = args.first().copied() else {
|
||||
return Err(lua_error(
|
||||
ctx,
|
||||
"bad argument #1 to 'concat' (table expected)".into(),
|
||||
));
|
||||
};
|
||||
let sep = bytes_arg(&args, 1).unwrap_or_default();
|
||||
let first = args.get(2).and_then(|v| v.to_integer()).unwrap_or(1);
|
||||
let last = args
|
||||
.get(3)
|
||||
.and_then(|v| v.to_integer())
|
||||
.unwrap_or_else(|| t.length());
|
||||
let mut parts = Vec::new();
|
||||
for i in first..=last {
|
||||
let v = t.get(ctx, i);
|
||||
parts.push(bytes_arg(&[v], 0).ok_or_else(|| {
|
||||
lua_error(
|
||||
ctx,
|
||||
format!("invalid value (at index {i}) in table for 'concat'"),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
Ok(vec![bytes(ctx, &parts.join(&sep[..]))])
|
||||
}),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A string or number argument as bytes: Lua strings are byte strings, so `rep` and `concat`
|
||||
/// keep bytes that are not UTF-8 (binary values from `horizon.storage`, for one) as they are.
|
||||
fn bytes_arg(args: &[Value], i: usize) -> Option<Vec<u8>> {
|
||||
match args.get(i).copied()? {
|
||||
Value::String(s) => Some(s.as_bytes().to_vec()),
|
||||
_ => string_arg(args, i).map(String::into_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes<'gc>(ctx: Context<'gc>, b: &[u8]) -> Value<'gc> {
|
||||
Value::String(piccolo::String::from_slice(&ctx, b))
|
||||
}
|
||||
|
||||
/// A string or number argument as text, as Lua coerces them.
|
||||
fn string_arg(args: &[Value], i: usize) -> Option<String> {
|
||||
match args.get(i).copied()? {
|
||||
Value::String(s) => Some(s.to_str_lossy().into_owned()),
|
||||
Value::Integer(n) => Some(n.to_string()),
|
||||
Value::Number(n) => Some(number_text(n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `tostring` of a float: `1.0`, `0.5`, `1e+100`.
|
||||
fn number_text(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
format!("{n:.1}")
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `string.format` for `%d %i %u %c %x %X %o %f %F %e %E %g %G %s %q %%` with
|
||||
/// flags `-0+ #`, a width and a precision.
|
||||
fn format(args: &[Value]) -> Result<String, String> {
|
||||
let template = string_arg(args, 0).ok_or("#1: string expected")?;
|
||||
let mut out = String::new();
|
||||
let mut next = 1;
|
||||
let mut chars = template.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '%' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
if chars.peek() == Some(&'%') {
|
||||
chars.next();
|
||||
out.push('%');
|
||||
continue;
|
||||
}
|
||||
let mut flags = String::new();
|
||||
while let Some(&f) = chars.peek().filter(|f| "-0+ #".contains(**f)) {
|
||||
flags.push(f);
|
||||
chars.next();
|
||||
}
|
||||
let mut width = String::new();
|
||||
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
|
||||
width.push(d);
|
||||
chars.next();
|
||||
}
|
||||
let mut precision = None;
|
||||
if chars.peek() == Some(&'.') {
|
||||
chars.next();
|
||||
let mut p = String::new();
|
||||
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
|
||||
p.push(d);
|
||||
chars.next();
|
||||
}
|
||||
precision = Some(p.parse::<usize>().unwrap_or(0));
|
||||
}
|
||||
let conversion = chars.next().ok_or("invalid conversion '%' to 'format'")?;
|
||||
let arg = args.get(next).copied().unwrap_or(Value::Nil);
|
||||
let what = format!("#{}", next + 1);
|
||||
next += 1;
|
||||
let integer = || -> Result<i64, String> {
|
||||
match arg {
|
||||
Value::Integer(i) => Ok(i),
|
||||
Value::Number(n) if n.fract() == 0.0 => Ok(n as i64),
|
||||
Value::Number(_) => Err(format!("{what}: number has no integer representation")),
|
||||
_ => Err(format!("{what}: number expected, got {}", arg.type_name())),
|
||||
}
|
||||
};
|
||||
let float = || -> Result<f64, String> {
|
||||
arg.to_number()
|
||||
.ok_or_else(|| format!("{what}: number expected, got {}", arg.type_name()))
|
||||
};
|
||||
let plus = flags.contains('+');
|
||||
let body = match conversion {
|
||||
'd' | 'i' | 'u' => {
|
||||
let n = integer()?;
|
||||
if plus && n >= 0 {
|
||||
format!("+{n}")
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
'c' => char::from_u32(integer()? as u32).unwrap_or('?').to_string(),
|
||||
'x' => format!("{:x}", integer()?),
|
||||
'X' => format!("{:X}", integer()?),
|
||||
'o' => format!("{:o}", integer()?),
|
||||
'f' | 'F' => {
|
||||
let n = float()?;
|
||||
let s = format!("{n:.*}", precision.unwrap_or(6));
|
||||
if plus && n >= 0.0 { format!("+{s}") } else { s }
|
||||
}
|
||||
'e' | 'E' => {
|
||||
let s = c_exponent(float()?, precision.unwrap_or(6));
|
||||
if conversion == 'E' {
|
||||
s.to_uppercase()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
'g' | 'G' => {
|
||||
let n = float()?;
|
||||
let p = precision.unwrap_or(6).max(1);
|
||||
let exp = if n == 0.0 {
|
||||
0
|
||||
} else {
|
||||
n.abs().log10().floor() as i32
|
||||
};
|
||||
let s = if exp < -4 || exp >= p as i32 {
|
||||
c_exponent(n, p - 1)
|
||||
} else {
|
||||
let s = format!("{n:.*}", (p as i32 - 1 - exp).max(0) as usize);
|
||||
if s.contains('.') && !flags.contains('#') {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
};
|
||||
if conversion == 'G' {
|
||||
s.to_uppercase()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
's' => {
|
||||
let s = match arg {
|
||||
Value::String(s) => s.to_str_lossy().into_owned(),
|
||||
Value::Number(n) => number_text(n),
|
||||
Value::Nil => "nil".into(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
match precision {
|
||||
Some(p) => s.chars().take(p).collect(),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
'q' => format!(
|
||||
"{:?}",
|
||||
string_arg(&[arg], 0).ok_or(format!("{what}: string expected"))?
|
||||
),
|
||||
other => return Err(format!("invalid conversion '%{other}' to 'format'")),
|
||||
};
|
||||
let width: usize = width.parse().unwrap_or(0);
|
||||
let pad = width.saturating_sub(body.chars().count());
|
||||
if flags.contains('-') {
|
||||
out.push_str(&body);
|
||||
out.extend(std::iter::repeat_n(' ', pad));
|
||||
} else if flags.contains('0') && !matches!(conversion, 's' | 'q' | 'c') {
|
||||
let (sign, digits) = match body.strip_prefix(['-', '+']) {
|
||||
Some(rest) => (&body[..1], rest),
|
||||
None => ("", body.as_str()),
|
||||
};
|
||||
out.push_str(sign);
|
||||
out.extend(std::iter::repeat_n('0', pad));
|
||||
out.push_str(digits);
|
||||
} else {
|
||||
out.extend(std::iter::repeat_n(' ', pad));
|
||||
out.push_str(&body);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// C's `%e`: `1.500000e+02`.
|
||||
fn c_exponent(n: f64, precision: usize) -> String {
|
||||
let s = format!("{n:.*e}", precision);
|
||||
let (mantissa, exp) = s.split_once('e').unwrap_or((&s, "0"));
|
||||
let exp: i32 = exp.parse().unwrap_or(0);
|
||||
format!(
|
||||
"{mantissa}e{}{:02}",
|
||||
if exp < 0 { '-' } else { '+' },
|
||||
exp.abs()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "coords-hud-lua"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon mod in Lua: the coords-hud example, line for line"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-lua = { path = "../../../horizon-mod-lua", features = ["game-read", "hud", "chat"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,8 @@
|
||||
id = "coords_hud_lua"
|
||||
name = "Coordinates HUD (Lua)"
|
||||
version = "0.1.0"
|
||||
description = "The Coordinates HUD example written in Lua: your coordinates, facing and the time of day in a corner of the screen. /where puts them in chat, /coords hides or shows the panel."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["game-read", "hud", "chat"]
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Example Horizon mod in Lua (docs/mods.md, "Lua"): coordinates, facing and
|
||||
-- the time of day in the top-left corner, /where to put them in chat and
|
||||
-- /coords to hide or show the panel. The same mod as examples/coords-hud
|
||||
-- in Rust, and the same test checks both.
|
||||
|
||||
local shown = true
|
||||
-- The panel last sent, so an unchanged one costs no call.
|
||||
local last = ""
|
||||
|
||||
horizon.command("where")
|
||||
horizon.command("coords")
|
||||
|
||||
-- Direction.fromYRot: yaw 0 faces south, 90 west.
|
||||
local FACING = { "South (+Z)", "West (-X)", "North (-Z)", "East (+X)" }
|
||||
local function facing(yaw)
|
||||
return FACING[(math.floor(yaw / 90 + 0.5) & 3) + 1]
|
||||
end
|
||||
|
||||
-- "Day 3, 14:05": day time 0 is 6:00 on day 1.
|
||||
local function time_of_day(clock)
|
||||
local day = clock.day_time // 24000 + 1
|
||||
local ticks = clock.day_time % 24000
|
||||
local hours = (ticks // 1000 + 6) % 24
|
||||
local minutes = ticks % 1000 * 60 // 1000
|
||||
return string.format("Day %d, %02d:%02d", day, hours, minutes)
|
||||
end
|
||||
|
||||
local function lines(p, clock)
|
||||
local out = {
|
||||
string.format("XYZ %.1f / %.1f / %.1f", p.x, p.y, p.z),
|
||||
"Facing " .. facing(p.yaw),
|
||||
}
|
||||
if clock then
|
||||
table.insert(out, time_of_day(clock))
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function on_tick(tick)
|
||||
local p = shown and horizon.game.player()
|
||||
if not p then
|
||||
if last ~= "" then
|
||||
horizon.hud.clear()
|
||||
last = ""
|
||||
end
|
||||
return
|
||||
end
|
||||
local now = lines(p, horizon.game.clock())
|
||||
local key = table.concat(now, "\n")
|
||||
if key ~= last then
|
||||
horizon.hud.set_panel("top-left", now)
|
||||
last = key
|
||||
end
|
||||
end
|
||||
|
||||
function on_command(name, args)
|
||||
if name == "coords" then
|
||||
shown = not shown
|
||||
return "Coordinates panel " .. (shown and "shown" or "hidden")
|
||||
end
|
||||
local p = horizon.game.player()
|
||||
if not p then
|
||||
error("Not in a world")
|
||||
end
|
||||
horizon.chat.show(string.format("%.0f, %.0f, %.0f in %s",
|
||||
math.floor(p.x), math.floor(p.y), math.floor(p.z), p.dimension))
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
//! The coords-hud example in Lua: the mod is `main.lua`.
|
||||
horizon_mod_lua::lua_mod!("main.lua");
|
||||
@@ -412,6 +412,23 @@ interface render {
|
||||
/// own frame budget; hidden, they are dropped. One map shows at a time:
|
||||
/// the first loaded mod's.
|
||||
set-minimap: func(minimap: option<minimap>);
|
||||
record far-terrain {
|
||||
/// How far the distant terrain reaches, in chunks: 64 to 1024. On
|
||||
/// integrated graphics other than Apple's the game caps it lower.
|
||||
distance: u32,
|
||||
/// Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms
|
||||
/// less GPU time per frame on integrated graphics.
|
||||
reduced: bool,
|
||||
}
|
||||
/// Draws the world beyond the render distance as distant terrain, out to
|
||||
/// `distance` chunks, until the next call (none ends it). The game builds
|
||||
/// it on its own workers, from the chunks already loaded or saved and,
|
||||
/// where nobody has been, from the world's generator; the simulation
|
||||
/// distance doesn't change. Only worlds whose generator the game has
|
||||
/// (single player and a LAN world you host) show it. The player's own LOD
|
||||
/// Distance setting, when on, wins. One far terrain shows at a time: the
|
||||
/// first loaded mod's.
|
||||
set-far-terrain: func(far-terrain: option<far-terrain>);
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
pub const LEVEL: u32 = 6;
|
||||
pub const LEVEL: u32 = 7;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
pub const FUNCTIONS: [(&str, u32); 45] = [
|
||||
pub const FUNCTIONS: [(&str, u32); 46] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -59,6 +59,7 @@ pub const FUNCTIONS: [(&str, u32); 45] = [
|
||||
("browser.set-recipes", 6),
|
||||
("recipes.place", 6),
|
||||
("recipes.give", 6),
|
||||
("render.set-far-terrain", 7),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
|
||||
@@ -78,9 +78,9 @@ use render_bindings::horizon::mods::input;
|
||||
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
|
||||
/// What capability `render` asks the game to draw (WIT `interface render`).
|
||||
pub use render_bindings::horizon::mods::render::{
|
||||
Corner as RenderCorner, FillShape, FrameRate, ItemShape, Minimap as ModMinimap,
|
||||
Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape, TextShape,
|
||||
Zoom as ModZoom,
|
||||
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
|
||||
Minimap as ModMinimap, Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape,
|
||||
TextShape, Zoom as ModZoom,
|
||||
};
|
||||
use render_bindings::{RenderHost, horizon::mods::render};
|
||||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||||
@@ -414,6 +414,8 @@ struct State {
|
||||
zoom: Option<ModZoom>,
|
||||
/// Capability `render`'s minimap, when the mod shows one.
|
||||
minimap: Option<ModMinimap>,
|
||||
/// Capability `render`'s far terrain, when the mod draws one.
|
||||
far_terrain: Option<ModFarTerrain>,
|
||||
input: InputState,
|
||||
browser: BrowserState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
@@ -616,6 +618,7 @@ impl Mods {
|
||||
corner: None,
|
||||
zoom: None,
|
||||
minimap: None,
|
||||
far_terrain: None,
|
||||
input: InputState::default(),
|
||||
browser: BrowserState::default(),
|
||||
data_dir,
|
||||
@@ -1170,6 +1173,12 @@ impl Mods {
|
||||
.iter()
|
||||
.find_map(|e| e.run.as_ref()?.store.data().minimap)
|
||||
}
|
||||
/// The far terrain the first running mod draws (capability `render`).
|
||||
pub fn far_terrain(&self) -> Option<ModFarTerrain> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find_map(|e| e.run.as_ref()?.store.data().far_terrain)
|
||||
}
|
||||
fn inputs(&self) -> impl Iterator<Item = &InputState> {
|
||||
self.entries
|
||||
.iter()
|
||||
@@ -1723,6 +1732,8 @@ const TURN_MIN: f32 = 0.05;
|
||||
/// `set-minimap`'s side in GUI pixels and its blocks per map pixel.
|
||||
const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0);
|
||||
const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0);
|
||||
/// `set-far-terrain`'s distance in chunks, as the LOD Distance setting's.
|
||||
const FAR_TERRAIN_DISTANCE: (u32, u32) = (64, 1024);
|
||||
|
||||
/// A mod's corner panel: shapes in a `width` × `height` box the game
|
||||
/// places in `corner`, stacked with the other overlays.
|
||||
@@ -1812,6 +1823,14 @@ impl render::Host for State {
|
||||
..m
|
||||
});
|
||||
}
|
||||
fn set_far_terrain(&mut self, far_terrain: Option<ModFarTerrain>) {
|
||||
self.far_terrain = far_terrain.map(|f| ModFarTerrain {
|
||||
distance: f
|
||||
.distance
|
||||
.clamp(FAR_TERRAIN_DISTANCE.0, FAR_TERRAIN_DISTANCE.1),
|
||||
..f
|
||||
});
|
||||
}
|
||||
fn frame_stats(&mut self) -> FrameRate {
|
||||
let (fps, min, low) = self.snapshot.frame;
|
||||
FrameRate { fps, min, low }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Loads real mods built with the SDK: the coords-hud and predator-missile
|
||||
//! examples and a guest
|
||||
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
//! Loads real mods built with the SDK: the coords-hud example (in Rust and
|
||||
//! in Lua), the predator-missile example and a guest that misbehaves on
|
||||
//! command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
use horizon_mods::{
|
||||
BlockPos, Clock, Corner, GameMode, HeldItem, Hit, Mods, Output, Package, Player, Runs,
|
||||
ServerPlayer, Shape, Snapshot, Status,
|
||||
@@ -39,10 +39,17 @@ fn mods_dir() -> &'static Path {
|
||||
let status = Command::new(env!("CARGO"))
|
||||
.current_dir(&root)
|
||||
.args(["build", "--release", "--target", "wasm32-unknown-unknown"])
|
||||
// As `cargo horizon-mod build` does, for the Lua runtime's seeds.
|
||||
.env(
|
||||
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
|
||||
"--cfg getrandom_backend=\"custom\"",
|
||||
)
|
||||
.args([
|
||||
"-p",
|
||||
"coords-hud",
|
||||
"-p",
|
||||
"coords-hud-lua",
|
||||
"-p",
|
||||
"predator-missile",
|
||||
"-p",
|
||||
"welcome",
|
||||
@@ -70,6 +77,7 @@ fn mods_dir() -> &'static Path {
|
||||
};
|
||||
let mods = [
|
||||
("coords_hud", example("coords-hud")),
|
||||
("coords_hud_lua", example("coords-hud-lua")),
|
||||
("predator_missile", example("predator-missile")),
|
||||
("welcome", example("welcome")),
|
||||
("beacons", example("beacons")),
|
||||
@@ -140,9 +148,19 @@ fn predator_missile_runs_commands() {
|
||||
assert!(blast.iter().all(|l| l.contains("summon tnt")));
|
||||
}
|
||||
|
||||
/// The same example in Rust and in Lua: the same checks pass for both.
|
||||
#[test]
|
||||
fn coords_hud_draws_and_answers() {
|
||||
let mut mods = load("coords_hud.hmod");
|
||||
for (hmod, name) in [
|
||||
("coords_hud.hmod", "Coordinates HUD"),
|
||||
("coords_hud_lua.hmod", "Coordinates HUD (Lua)"),
|
||||
] {
|
||||
coords_hud_checks(hmod, name);
|
||||
}
|
||||
}
|
||||
|
||||
fn coords_hud_checks(hmod: &str, name: &str) {
|
||||
let mut mods = load(hmod);
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
assert_eq!(mods.commands().collect::<Vec<_>>(), ["where", "coords"]);
|
||||
mods.set_snapshot(player());
|
||||
@@ -161,7 +179,7 @@ fn coords_hud_draws_and_answers() {
|
||||
|
||||
assert!(mods.command("where"));
|
||||
let chat = Output::Chat {
|
||||
name: "Coordinates HUD".into(),
|
||||
name: name.into(),
|
||||
text: "12, 64, -4 in minecraft:overworld".into(),
|
||||
};
|
||||
assert_eq!(mods.drain(), [chat]);
|
||||
@@ -174,6 +192,44 @@ fn coords_hud_draws_and_answers() {
|
||||
};
|
||||
assert_eq!(mods.drain(), [hidden]);
|
||||
assert!(!mods.command("gamemode creative"));
|
||||
// On the title screen /where is an error, `error()` in Lua.
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
assert!(mods.command("where"));
|
||||
let error = Output::Feedback {
|
||||
text: "Not in a world".into(),
|
||||
error: true,
|
||||
};
|
||||
assert_eq!(mods.drain(), [error], "{hmod}");
|
||||
}
|
||||
|
||||
/// What a tick of the HUD example costs, in Rust and in Lua: the player
|
||||
/// moves every tick, so each one formats and sends a new panel.
|
||||
#[test]
|
||||
fn coords_hud_tick_cost_in_rust_and_lua() {
|
||||
for hmod in ["coords_hud.hmod", "coords_hud_lua.hmod"] {
|
||||
let mut mods = load(hmod);
|
||||
let mut snapshot = player();
|
||||
// Warm up: the first calls compile and fill caches.
|
||||
for t in 0..100 {
|
||||
snapshot.player.as_mut().unwrap().x += 0.1;
|
||||
mods.set_snapshot(snapshot.clone());
|
||||
mods.tick(t);
|
||||
}
|
||||
let ticks = 2000;
|
||||
let started = Instant::now();
|
||||
for t in 0..ticks {
|
||||
snapshot.player.as_mut().unwrap().x += 0.1;
|
||||
mods.set_snapshot(snapshot.clone());
|
||||
mods.tick(t);
|
||||
}
|
||||
let per_tick = started.elapsed() / ticks as u32;
|
||||
assert_eq!(mods.entries()[0].status, Status::Running, "{hmod}");
|
||||
println!("{hmod}: {per_tick:?} per tick (a new panel each tick)");
|
||||
assert!(
|
||||
per_tick.as_micros() < 1000,
|
||||
"{hmod} took {per_tick:?} per tick"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -205,7 +261,7 @@ fn undeclared_capabilities_are_refused() {
|
||||
/// from the functions they import.
|
||||
#[test]
|
||||
fn a_mod_for_a_newer_horizon_is_refused() {
|
||||
use horizon_mods::api_level::{imports, needed};
|
||||
use horizon_mods::api_level::{LEVEL, imports, needed};
|
||||
use wit_parser::{ManglingAndAbi, Resolve};
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let wit = std::fs::read_to_string(root.join("crates/horizon-mod-sdk/wit/horizon.wit"))
|
||||
@@ -255,8 +311,8 @@ experimental = ["ui", "render", "input", "recipes"]
|
||||
needed(imports(&wasm).unwrap().iter().map(String::as_str)).0
|
||||
};
|
||||
assert_eq!(level("coords_hud.hmod"), 0);
|
||||
// The UI guest fills a browser panel (`browser`, level 6).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 6);
|
||||
// The UI guest draws far terrain (`render.set-far-terrain`).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -725,6 +781,21 @@ fn render_minimap_is_clamped_and_hidden() {
|
||||
assert_eq!(map(&mut mods, "off"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_far_terrain_is_clamped_and_ended() {
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
let far = |mods: &mut Mods, args: &str| {
|
||||
assert!(mods.command(&format!("far_terrain {args}")));
|
||||
mods.far_terrain().map(|f| (f.distance, f.reduced))
|
||||
};
|
||||
assert!(mods.far_terrain().is_none());
|
||||
assert_eq!(far(&mut mods, "256 1"), Some((256, true)));
|
||||
// The distance is clamped to LOD Distance's 64 to 1024 chunks.
|
||||
assert_eq!(far(&mut mods, "8 0"), Some((64, false)));
|
||||
assert_eq!(far(&mut mods, "99999 0"), Some((1024, false)));
|
||||
assert_eq!(far(&mut mods, "off"), None);
|
||||
}
|
||||
|
||||
/// `game.raycast`: the view's hit the game worked out, up to the distance
|
||||
/// asked for; the game works it out only while a mod imports the call.
|
||||
#[test]
|
||||
|
||||
@@ -112,6 +112,7 @@ impl Mod for Guest {
|
||||
"input_wheel",
|
||||
"zoom",
|
||||
"minimap",
|
||||
"far_terrain",
|
||||
"raycast",
|
||||
"quiet",
|
||||
"quiet_many",
|
||||
@@ -242,6 +243,15 @@ impl Mod for Guest {
|
||||
});
|
||||
render::set_minimap(map);
|
||||
}
|
||||
// `far_terrain off`, or `far_terrain DISTANCE REDUCED`.
|
||||
"far_terrain" => {
|
||||
let words: Vec<&str> = args.split(' ').collect();
|
||||
let far = (args != "off").then(|| render::FarTerrain {
|
||||
distance: words.first().and_then(|w| w.parse().ok()).unwrap_or(0),
|
||||
reduced: words.get(1) == Some(&"1"),
|
||||
});
|
||||
render::set_far_terrain(far);
|
||||
}
|
||||
"quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::<Vec<_>>()),
|
||||
"quiet_many" => {
|
||||
let n: usize = args.parse().unwrap_or(0);
|
||||
|
||||
@@ -45,6 +45,54 @@ packet!(
|
||||
packet!(KnownPack { namespace: String => Str, id: String => Str, version: String => Str });
|
||||
packet!(SelectKnownPacks { packs: Vec<KnownPack> => List<KnownPack> });
|
||||
packet!(CodeOfConduct { text: String => Str });
|
||||
|
||||
/// ServerLinks.UntrustedEntry's `type` (Either<KnownLinkType, Component>): a
|
||||
/// known link's id, or a custom label component.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ServerLinkKind {
|
||||
/// ServerLinks.KnownLinkType by id (ByIdMap: unknown ids read as 0).
|
||||
Known(i32),
|
||||
/// A custom label (TRUSTED_CONTEXT_FREE_STREAM_CODEC: a component in NBT).
|
||||
Custom(Tag),
|
||||
}
|
||||
/// ServerLinks.UntrustedEntry: the label and the unchecked link string (the
|
||||
/// client keeps only `http`/`https` URIs, Util.parseAndValidateUntrustedUri).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ServerLink {
|
||||
pub kind: ServerLinkKind,
|
||||
pub link: String,
|
||||
}
|
||||
impl Codec<ServerLink> for ServerLink {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let kind = if Bool::read(r)? {
|
||||
ServerLinkKind::Known(VarInt::read(r)?)
|
||||
} else {
|
||||
ServerLinkKind::Custom(Nbt::read(r)?)
|
||||
};
|
||||
Ok(Self {
|
||||
kind,
|
||||
link: Str::read(r)?,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
match &v.kind {
|
||||
ServerLinkKind::Known(id) => {
|
||||
Bool::write(&true, w);
|
||||
VarInt::write(id, w);
|
||||
}
|
||||
ServerLinkKind::Custom(tag) => {
|
||||
Bool::write(&false, w);
|
||||
Nbt::write(tag, w);
|
||||
}
|
||||
}
|
||||
Str::write(&v.link, w);
|
||||
}
|
||||
}
|
||||
packet!(
|
||||
/// ClientboundServerLinksPacket (a common packet: configuration and play).
|
||||
/// Links the pause menu's "Server Links..." screen lists.
|
||||
ServerLinks { links: Vec<ServerLink> => List<ServerLink> }
|
||||
);
|
||||
packet!(
|
||||
/// ClientboundShowDialogPacket.CONTEXT_FREE_STREAM_CODEC: the dialog
|
||||
/// itself (`Dialog.DIRECT_CODEC` as NBT).
|
||||
@@ -89,6 +137,7 @@ packet_enum!(Clientbound, crate::ids::configuration::clientbound {
|
||||
UpdateTags(UpdateTags) = UPDATE_TAGS,
|
||||
SelectKnownPacks(SelectKnownPacks) = SELECT_KNOWN_PACKS,
|
||||
ClearDialog(Empty) = CLEAR_DIALOG,
|
||||
ServerLinks(ServerLinks) = SERVER_LINKS,
|
||||
ShowDialog(ShowDialog) = SHOW_DIALOG,
|
||||
CodeOfConduct(CodeOfConduct) = CODE_OF_CONDUCT,
|
||||
});
|
||||
@@ -236,6 +285,18 @@ mod tests {
|
||||
Clientbound::CodeOfConduct(CodeOfConduct {
|
||||
text: "Be nice".into(),
|
||||
}),
|
||||
Clientbound::ServerLinks(ServerLinks {
|
||||
links: vec![
|
||||
ServerLink {
|
||||
kind: ServerLinkKind::Known(0),
|
||||
link: "https://bugs.example.net".into(),
|
||||
},
|
||||
ServerLink {
|
||||
kind: ServerLinkKind::Custom(crate::text::literal("Custom link")),
|
||||
link: "https://example.net".into(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
Clientbound::ShowDialog(ShowDialog {
|
||||
dialog: Tag::compound([("type", Tag::String("minecraft:notice".into()))]),
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
configuration::{
|
||||
ClientInformation, CookieRequest, CookieResponse, CustomClickAction, CustomPayload,
|
||||
Disconnect, KeepAlive, Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse,
|
||||
StoreCookie, Transfer, UpdateTags,
|
||||
ServerLinks, StoreCookie, Transfer, UpdateTags,
|
||||
},
|
||||
login::Property,
|
||||
},
|
||||
@@ -1903,6 +1903,9 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
StopSound(StopSound) = STOP_SOUND,
|
||||
StartConfiguration(Empty) = START_CONFIGURATION,
|
||||
StoreCookie(StoreCookie) = STORE_COOKIE,
|
||||
// A common packet: the same `configuration::ServerLinks` the
|
||||
// configuration phase sends.
|
||||
ServerLinks(ServerLinks) = SERVER_LINKS,
|
||||
SwingAnimation(SwingAnimation) = SWING_ANIMATION,
|
||||
SystemChat(SystemChat) = SYSTEM_CHAT,
|
||||
TabList(TabList) = TAB_LIST,
|
||||
@@ -2290,6 +2293,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
ChatCommandSigned(ChatCommandSigned) = CHAT_COMMAND_SIGNED,
|
||||
ChatSessionUpdate(ChatSessionUpdate) = CHAT_SESSION_UPDATE,
|
||||
PlaceRecipe(super::recipes::PlaceRecipe) = PLACE_RECIPE,
|
||||
RecipeBookChangeSettings(super::recipes::RecipeBookChangeSettings) = RECIPE_BOOK_CHANGE_SETTINGS,
|
||||
ChunkBatchReceived(ChunkBatchReceived) = CHUNK_BATCH_RECEIVED,
|
||||
ClientCommand(ClientCommand) = CLIENT_COMMAND,
|
||||
ClientInformation(ClientInformation) = CLIENT_INFORMATION,
|
||||
|
||||
@@ -500,6 +500,33 @@ impl Codec<RecipeBookSettings> for RecipeBookSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundRecipeBookChangeSettingsPacket: the active book's open and
|
||||
/// filtering flags, with its `RecipeBookType` ordinal as a VarInt.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RecipeBookChangeSettings {
|
||||
pub book_type: i32,
|
||||
pub open: bool,
|
||||
pub filtering: bool,
|
||||
}
|
||||
impl Codec<RecipeBookChangeSettings> for RecipeBookChangeSettings {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let book_type = r.varint()?;
|
||||
if !(0..4).contains(&book_type) {
|
||||
return Err(crate::Error::new("invalid recipe book type"));
|
||||
}
|
||||
Ok(Self {
|
||||
book_type,
|
||||
open: r.bool()?,
|
||||
filtering: r.bool()?,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
w.varint(v.book_type);
|
||||
w.bool(v.open);
|
||||
w.bool(v.filtering);
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientboundPlaceGhostRecipePacket: the recipe to show faded in a menu
|
||||
/// whose items were missing.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Scheduling hints for the game's threads: the OS calls the crates
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_WindowsProgramming"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -41,6 +41,43 @@ fn favour() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The CPU time the calling thread has used, where only an OS call gives it
|
||||
/// precisely. Windows counts it exactly only in cycles of the time-stamp
|
||||
/// counter (`QueryThreadCycleTime`; `GetThreadTimes` moves in scheduler ticks
|
||||
/// of ~15.6 ms), so the counter's rate is measured once against the wall
|
||||
/// clock and cycles convert to time. `None` elsewhere: unix callers read
|
||||
/// `CLOCK_THREAD_CPUTIME_ID` themselves.
|
||||
#[cfg(all(windows, target_arch = "x86_64"))]
|
||||
pub fn thread_cpu() -> Option<std::time::Duration> {
|
||||
use std::{
|
||||
sync::OnceLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use windows_sys::Win32::System::{
|
||||
Threading::GetCurrentThread, WindowsProgramming::QueryThreadCycleTime,
|
||||
};
|
||||
// Time-stamp counter cycles per nanosecond.
|
||||
static RATE: OnceLock<f64> = OnceLock::new();
|
||||
let rate = *RATE.get_or_init(|| {
|
||||
// SAFETY: RDTSC has no preconditions on x86_64.
|
||||
let tsc = || unsafe { core::arch::x86_64::_rdtsc() };
|
||||
let (wall, start) = (Instant::now(), tsc());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
(tsc() - start) as f64 / wall.elapsed().as_nanos() as f64
|
||||
});
|
||||
let mut cycles = 0;
|
||||
// SAFETY: GetCurrentThread's pseudo handle needs no closing; a valid out pointer.
|
||||
if unsafe { QueryThreadCycleTime(GetCurrentThread(), &mut cycles) } == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Duration::from_nanos((cycles as f64 / rate) as u64))
|
||||
}
|
||||
|
||||
#[cfg(not(all(windows, target_arch = "x86_64")))]
|
||||
pub fn thread_cpu() -> Option<std::time::Duration> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Set once as the game starts, before its helper threads do: they then go
|
||||
/// behind the server thread and the render thread (`behind_server`).
|
||||
static HELPERS_BEHIND: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -616,6 +616,9 @@ impl MineshaftPiece {
|
||||
// A chest minecart with the mineshaft loot table.
|
||||
let seed = c.random.long();
|
||||
let mut nbt = super::legacy::spawn_nbt("minecraft:chest_minecart", pos, false, false);
|
||||
// As the proto chunk saves the new cart: not ticked, not burning.
|
||||
nbt.insert("HasTicked".into(), Tag::Byte(1));
|
||||
nbt.insert("Fire".into(), Tag::Short(0));
|
||||
let p = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
|
||||
@@ -435,6 +435,13 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `rotate` | `bool` | The map turns with the player, who faces up, and an N on its rim marks north. Otherwise north is up and the player's arrow turns. |
|
||||
| `round` | `bool` | Round, the corners left clear; otherwise square. |
|
||||
|
||||
### record `far-terrain`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `distance` | `u32` | How far the distant terrain reaches, in chunks: 64 to 1024. On integrated graphics other than Apple's the game caps it lower. |
|
||||
| `reduced` | `bool` | Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms less GPU time per frame on integrated graphics. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
|
||||
@@ -446,6 +453,7 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
|
||||
| `frame-stats` | `func() -> frame-rate` | The game's frame counter, recomputed twice a second. |
|
||||
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
|
||||
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
|
||||
| `set-far-terrain` | `func(far-terrain: option<far-terrain>)` | Draws the world beyond the render distance as distant terrain, out to `distance` chunks, until the next call (none ends it). The game builds it on its own workers, from the chunks already loaded or saved and, where nobody has been, from the world's generator; the simulation distance doesn't change. Only worlds whose generator the game has (single player and a LAN world you host) show it. The player's own LOD Distance setting, when on, wins. One far terrain shows at a time: the first loaded mod's. |
|
||||
|
||||
## `events`
|
||||
|
||||
|
||||
@@ -121,5 +121,7 @@ What real mods need next, in the order modpacks and servers need it:
|
||||
use-stop, key-down, key-up).
|
||||
- Experimental `render` (`experimental = ["render"]`): HUD shapes (fill, quad, sprite, item, text), `gui-size`, camera
|
||||
`shake`, drawn by the game's own renderer.
|
||||
- SDK: `horizon-mod-lua`, mods written in Lua 5.4 (the interpreter built into the mod, same sandbox and API);
|
||||
`cargo horizon-mod new --template lua`; example `coords-hud-lua`.
|
||||
- API 0.2 (content): data-driven blocks and items, tags, `horizon:add_to`, terrain adaptation.
|
||||
- API 0.1.0: `log`, `game`, `chat`, `hud`, `storage`; `init`, `tick`, `frame`, `command`, `chat`.
|
||||
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
|
||||
|
||||
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
|
||||
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`. A mod needs the level of
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`, level 7 `render.set-far-terrain`. A mod needs the level of
|
||||
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
|
||||
never declared:
|
||||
|
||||
@@ -109,7 +109,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
|
||||
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
|
||||
| Server mods: world `horizon-server-mod`, `players`, `server-events`, `world`, typed `server-command`s | experimental | manifest `api = "0.3"` |
|
||||
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
|
||||
| Script mods: Lua (`horizon-mod-lua`, an interpreter built into the mod; `cargo horizon-mod new --template lua`) | experimental | `horizon-mod-lua` 0.1.0 |
|
||||
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
|
||||
| Shader API | not started | |
|
||||
| `horizon-ctl` commands and MCP tools | preview | none yet (unversioned) |
|
||||
@@ -134,3 +134,8 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
for a map image costs too much through hook calls, so the game keeps the tiles (vanilla's map colours) and the mod
|
||||
picks the corner, side, scale and shape. One map shows at a time, the first loaded mod's. It is a new function and a
|
||||
new type, so mods built before it keep loading.
|
||||
- **`render.set-far-terrain`** turns on the game's own distant terrain, not one a mod builds: sampling the generator
|
||||
and meshing thousands of far tiles runs on the game's workers within its frame and upload budgets, which no hook
|
||||
call could match. The mod picks the distance (64 to 1024 chunks, capped lower on integrated graphics as LOD Distance
|
||||
is) and Far Detail's Reduced. The player's own LOD Distance, when on, wins; one far terrain shows at a time, the
|
||||
first loaded mod's.
|
||||
+2
-2
@@ -29,7 +29,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
|
||||
|
||||
**Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are.
|
||||
|
||||
**HDR** (Display, off by default) presents to an scRGB swapchain (`Rgba16Float`, extended linear sRGB) on displays and compositors that offer it. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
|
||||
**HDR** (Display, off by default) presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
|
||||
|
||||
Which settings each screen shows, their descriptions and costs are in `crates/horizon-client/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them.
|
||||
|
||||
@@ -47,7 +47,7 @@ Coverage and detail improve progressively; a large selected radius does not mean
|
||||
|
||||
Ordinary Overworld atmospheric fog extends with the selected LOD distance. Fog Off disables distance haze at large ranges too; fluid, darkness and boss fog retain their limits.
|
||||
|
||||
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
|
||||
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. The official Far View mod (`mods/official/far-view`, [mods.md](mods.md)) turns the same distant terrain on from the mod browser, with its own distance and detail; the setting, when on, wins. Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
|
||||
|
||||
Not implemented: anisotropic filtering (wgpu needs linear magnification for it, which blurs close blocks), MSAA (FXAA instead) and exclusive fullscreen (fullscreen is borderless).
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ MCP: `account_sign_out`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher account select`
|
||||
|
||||
Play as this account.
|
||||
Play as this account, or as one of your offline names.
|
||||
|
||||
horizon-launcher account select <WHO>
|
||||
|
||||
@@ -126,19 +126,46 @@ MCP: `account_select`.
|
||||
|
||||
## `horizon-launcher account offline`
|
||||
|
||||
Play offline, without an account.
|
||||
Play offline, without an account: as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game made up.
|
||||
|
||||
horizon-launcher account offline
|
||||
horizon-launcher account offline [NAME]
|
||||
|
||||
- `NAME`
|
||||
|
||||
MCP: `account_offline`.
|
||||
|
||||
## `horizon-launcher account forget-offline`
|
||||
|
||||
Forget one of your offline names.
|
||||
|
||||
horizon-launcher account forget-offline <NAME>
|
||||
|
||||
- `NAME`
|
||||
|
||||
MCP: `account_forget_offline`.
|
||||
|
||||
## `horizon-launcher account skin`
|
||||
|
||||
Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape PNG, the model. Kept by the launcher and given to the game when that name plays.
|
||||
|
||||
horizon-launcher account skin <NAME> [FILE]
|
||||
|
||||
- `NAME`
|
||||
- `FILE`: The skin's PNG
|
||||
- `--model`: classic or slim arms
|
||||
- `--cape`: The cape's PNG
|
||||
- `--no-skin`: Back to the default skin
|
||||
- `--no-cape`: Take the cape off
|
||||
|
||||
MCP: `account_skin`.
|
||||
|
||||
## `horizon-launcher settings`
|
||||
|
||||
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it).
|
||||
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink, background PATH (a picture behind Home; "default" shows Horizon's landscape).
|
||||
|
||||
horizon-launcher settings [KEY] [VALUE]
|
||||
|
||||
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy)
|
||||
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy, while-playing, language, accent, background)
|
||||
- `VALUE`: The new value (with KEY)
|
||||
|
||||
MCP: `settings`.
|
||||
@@ -153,6 +180,26 @@ Show or set the extra arguments the game starts with (for example --server-threa
|
||||
|
||||
MCP: `game_args`.
|
||||
|
||||
## `horizon-launcher share-log`
|
||||
|
||||
Upload an instance's latest.log to mclo.gs (a paste service for Minecraft logs) and print its link, for a bug report: only when the player asks. The home folder, the user name and the account names are taken out first; the link expires after 90 days unread.
|
||||
|
||||
horizon-launcher share-log
|
||||
|
||||
- `--instance`: The instance (default: the selected one)
|
||||
|
||||
MCP: `share_log`, only when the player asks.
|
||||
|
||||
## `horizon-launcher unshare-log`
|
||||
|
||||
Delete a log shared with share-log: the "<id>:<token>" it printed.
|
||||
|
||||
horizon-launcher unshare-log <SHARED>
|
||||
|
||||
- `SHARED`
|
||||
|
||||
MCP: `unshare_log`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher logs`
|
||||
|
||||
Print the end of the game's log (logs/latest.log in the game folder), or its newest crash report.
|
||||
@@ -344,6 +391,33 @@ Export an instance (or a port) to share: its settings, packs, Horizon mods and t
|
||||
|
||||
MCP: `instance_export`.
|
||||
|
||||
## `horizon-launcher instance launch`
|
||||
|
||||
Show or set what runs around an instance's game: a shell command before it (the game starts only if it succeeds), a wrapper it runs through (`nice -n 5`), and environment variables ("NAME=value NAME2=value"). An empty value clears one. On Linux, also GameMode, MangoHud and the dedicated graphics card, when the computer has them.
|
||||
|
||||
horizon-launcher instance launch <INSTANCE>
|
||||
|
||||
- `INSTANCE`
|
||||
- `--pre-launch`
|
||||
- `--wrapper`
|
||||
- `--env`
|
||||
- `--gamemode`: Linux: run it through GameMode (gamemoderun), on or off
|
||||
- `--mangohud`: Linux: MangoHud's overlay, on or off
|
||||
- `--dedicated-gpu`: Linux: the dedicated graphics card (DRI_PRIME, or NVIDIA's offload), on or off
|
||||
|
||||
MCP: `instance_launch`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher instance shortcut`
|
||||
|
||||
Put a shortcut on the desktop that plays an instance (`play --instance`): a .desktop file (also in the applications menu) on Linux, an app on macOS, a .lnk on Windows.
|
||||
|
||||
horizon-launcher instance shortcut [INSTANCE]
|
||||
|
||||
- `INSTANCE`: The instance (default: the selected one)
|
||||
- `--remove`: Move the instance's shortcuts to the trash instead
|
||||
|
||||
MCP: `instance_shortcut`.
|
||||
|
||||
## `horizon-launcher instance import`
|
||||
|
||||
Import what a file is, as dropping it on the window does: a shared instance (a .mrpack without mod loaders, such as `instance export` writes, or a game folder zip) becomes a new instance; a Java modpack goes to the Modpacks import; a resource or shader pack into the instance (default: the selected one).
|
||||
|
||||
+65
-3
@@ -15,7 +15,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands |
|
||||
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; and Recipes, a recipe viewer |
|
||||
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; Far View (`far-view`), distant terrain; and Recipes, a recipe viewer |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) |
|
||||
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
|
||||
@@ -89,7 +89,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
|
||||
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
|
||||
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's); `set-far-terrain(far-terrain)`: the world past the render distance as distant terrain, out to 64 to 1024 chunks, built by the game's own workers (the player's LOD Distance, when on, wins; the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
|
||||
| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key.<mod>.<name>:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` |
|
||||
| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds; `place(book-id, all)`: the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); `give(stack)`: in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` |
|
||||
|
||||
@@ -132,7 +132,8 @@ There is no file, socket, process or raw-input interface, on purpose.
|
||||
| **wasmtime + component model** | yes: memory, fuel, no imports but ours | yes | WIT → Rust on both sides | 85 ns per empty hook call; 50 ms to compile a 64 KB mod on load | **chosen** |
|
||||
| wasmtime, core modules with a hand ABI | yes | yes | no: pointers and lengths by hand | a few ns less per call | not worth the fragile ABI |
|
||||
| wasmi (interpreter) | yes | yes | no components | no compile time, execution ~10-50x slower than cranelift code | kept in mind for a platform without JIT |
|
||||
| Lua (mlua) or another script | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
|
||||
| Lua (mlua) or another script, in the game | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
|
||||
| Lua *inside* the WebAssembly sandbox ([`horizon-mod-lua`](#lua-mods)) | yes: the same as any mod | yes | the same API, as Lua tables | the interpreter in the mod (~240 KB); a HUD tick ~60 µs | **offered** as a way in for Lua writers |
|
||||
| native `cdylib` plugins | none | no: per OS and arch, unstable Rust ABI | yes | none | unsafe for players |
|
||||
| an embedded JVM | none per mod | yes | Java | 100+ MB, seconds of startup | see the research |
|
||||
|
||||
@@ -246,6 +247,66 @@ and each of its commands: it must start and never trap or overrun. It checks a
|
||||
`.hpack` and a resource pack `.zip` too, and a signature with `--key <public
|
||||
key> --signature <file>.minisig`.
|
||||
|
||||
### Lua mods
|
||||
|
||||
A mod can be written in Lua 5.4 instead of Rust:
|
||||
`cargo horizon-mod new my-mod --template lua` makes one. The interpreter,
|
||||
[piccolo](https://github.com/kyren/piccolo) (pure Rust, MIT), is built into
|
||||
the mod's WebAssembly component with the script by
|
||||
[`horizon-mod-lua`](../crates/horizon-mod-lua/src/lib.rs). So a Lua mod is an
|
||||
`.hmod` like any other: the same sandbox, capabilities, time budgets and
|
||||
checks. Only the toolchain is shared with Rust mods (`cargo horizon-mod build`
|
||||
compiles the interpreter once, in about 30 s).
|
||||
|
||||
```lua
|
||||
local greeted = 0
|
||||
horizon.command("hello")
|
||||
|
||||
function on_command(name, args)
|
||||
greeted = greeted + 1
|
||||
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
|
||||
end
|
||||
```
|
||||
|
||||
- **Hooks are global functions the script defines:** `on_tick(tick)`,
|
||||
`on_frame(seconds)`, `on_command(name, args)` and `on_chat(text)`. The game
|
||||
calls only those that exist. A command returns its feedback as a string, or
|
||||
raises `error(text)` for the red error.
|
||||
- **Commands** are registered with `horizon.command(name)` while the script
|
||||
loads.
|
||||
- **The API is a table per interface:**
|
||||
- `horizon.game.player()` (a table with `x y z yaw pitch health food
|
||||
dimension`, or nil) and `horizon.game.clock()` (`game_time day_time`).
|
||||
- `horizon.chat.show(text)`.
|
||||
- `horizon.hud.set_panel(corner, lines)`, where the corner is
|
||||
`"top-left"`, ... and the lines are strings or `{text =, color =}`; and
|
||||
`horizon.hud.clear()`.
|
||||
- `horizon.storage.get(key)`, `set(key, value)` (`true`, or `nil` and why)
|
||||
and `delete(key)`.
|
||||
- `horizon.log.info(text)`, ...; `print` writes to the mod's log.
|
||||
- **Capabilities are the crate's features too.** The Cargo.toml's
|
||||
`horizon-mod-lua` features list the same capabilities as
|
||||
`horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`): only those
|
||||
tables are built in, and the game refuses a mod whose component uses one
|
||||
it didn't declare. A capability in the manifest but not in the features
|
||||
leaves its table out: the script fails with `attempt to index a nil value`.
|
||||
- **The standard library** is piccolo's: base, `math`, `string` (`len sub
|
||||
lower upper reverse`), `table` (`pack unpack`) and `coroutine`. The mod
|
||||
runtime adds `string.format` (`%d %s %f %x %e %g %q`, with flags, width and
|
||||
precision), `string.rep`, `table.insert`, `table.remove` and `table.concat`.
|
||||
There is no `io` or `os`, and patterns (`string.find`, `gsub`) aren't there
|
||||
yet.
|
||||
- **`math.random`** is seeded from a fixed sequence: the sandbox has no
|
||||
entropy.
|
||||
|
||||
The example [`coords-hud-lua`](../crates/horizon-mod-sdk/examples/coords-hud-lua/main.lua)
|
||||
is [`coords-hud`](../crates/horizon-mod-sdk/examples/coords-hud/src/lib.rs)
|
||||
line for line, and the sandbox test runs the same checks on both. With the
|
||||
player moving every tick (a new panel each one), a tick costs 54–68 µs in Lua
|
||||
against 22–63 µs in Rust on a busy Mac mini M4 Pro
|
||||
(`coords_hud_tick_cost_in_rust_and_lua`), under the 0.1 ms the API asks of a
|
||||
HUD script.
|
||||
|
||||
### Testing a mod
|
||||
|
||||
`cargo horizon-mod test` builds the mod, then runs each `tests/<name>.toml` in
|
||||
@@ -611,6 +672,7 @@ so the same rustc and `Cargo.lock` give the same file.
|
||||
| Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` |
|
||||
| Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` |
|
||||
| Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) |
|
||||
| Far View | `far_view` | Graphics > LOD Distance and Far Detail | `distance` (chunks): `64`, `128`, `256` (default), `512`, `1024`; `detail`: `full` (default), `reduced`. The game builds and draws the distant terrain (`render.set-far-terrain`); the player's LOD Distance, when on, wins |
|
||||
| Recipes | `recipes` | nothing (new) | `give` (a click on the item panel gives the item in creative mode: one, a stack with a right click): `false` (default), `true`. The keys are the game's: `key.recipes.recipe` (default `key.keyboard.r`) and `key.recipes.uses` (default `key.keyboard.u`) in `horizon-mods.txt`. Every item sits in the item panel (`browser`) with a search box (words in the name or id, `@namespace`, `#tag`); R or a click shows an item's recipes, U or a right click its uses, a tab per station, and + fills the grid with a recipe the player knows (`recipes.place`). On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book |
|
||||
|
||||
## Versions and compatibility
|
||||
|
||||
@@ -44,6 +44,8 @@ Harnesses can also run on the Mac mini, the desktop and playhorizon (`tools/pari
|
||||
|
||||
Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip` runs the game on a virtual screen) and each host's `exclude`. The Mac excludes nothing: vanilla's `Math.sin`, `pow`, `log` and the like don't round the same on an Apple silicon Mac as on Linux (HotSpot uses fdlibm there, Intel's stubs on x86-64), and Rust's float functions round like neither (they call the platform libm), so code that mirrors a vanilla `Math` call uses `horizon_core::jmath`, which computes what HotSpot computes on the host architecture. The ThinkPad no longer runs harnesses (2026-10-26): playhorizon is the Linux host that is always on.
|
||||
|
||||
The file's `solo` list names harnesses that drive game windows (`remote_fireworks`, `server_player_sculk`, `world_reopen`, ...). On a machine where `parity.py` has 4 CPUs or fewer, such as playhorizon's CI runner, each of them runs alone, after the others: there frames are drawn on the CPU, and beside other harnesses a screenshot or a control request took more ticks than the check allows. `PARITY_SOLO=1` or `0` forces this on or off.
|
||||
|
||||
### Any command on playhorizon: `tools/remote.sh`
|
||||
|
||||
For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh <worktree> <command...>` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/<name>` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 0-3 (`--cpus`): the CI runner has 4-7, so these runs never slow a pull request's parity job. A benchmark asks for its CPUs with `--cpus 0-3 --exclusive` (and says so in its pull request): it starts only when no other run uses them, and later runs wait for it to end. The command gets scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Whatever the command leaves running (a game, a daemon) is stopped when it ends, and stopping `remote.sh` (or losing the connection) stops the command on the host. Logs stay in `~/remote-work/<name>.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones.
|
||||
|
||||
Generated
+1
@@ -2763,6 +2763,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
"sys-locale",
|
||||
"tar",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
"trash",
|
||||
|
||||
@@ -22,6 +22,8 @@ tar = { version = "0.4", default-features = false }
|
||||
toml = "1.1"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
dirs = "5"
|
||||
# The system's preferred languages, for the window's language (i18n.rs).
|
||||
sys-locale = "0.3"
|
||||
self-replace = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
horizon-theme = { path = "../crates/horizon-theme" }
|
||||
|
||||
+60
-2
@@ -100,6 +100,62 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
pack into the selected instance. A data pack is pointed at the world rows, and the Packs section
|
||||
and the world rows keep taking their own drops. A note in the corner says what happened.
|
||||
`horizon-launcher instance import <file>`.
|
||||
- **Launch options**: Home's Instance row has **Launch…**: per instance, a shell command run in its
|
||||
folder before the game (the game starts only if it succeeds), a wrapper the game runs through
|
||||
(`nice -n 5`), and environment variables (`NAME=value OTHER="two words"`), kept in
|
||||
`instances.json` (`horizon_launcher::launch`). The game stays one process the launcher follows:
|
||||
on Unix `/bin/sh` runs the command and then `exec`s the wrapper and the game, whose words pass
|
||||
through `"$@"` untouched; on Windows `cmd /C` runs `<command> && <game>`. Both the window's Play
|
||||
and `play` use them. `horizon-launcher instance launch <instance> [--pre-launch …]
|
||||
[--wrapper …] [--env …]` (an MCP agent needs `confirm: true`: it sets commands Play runs).
|
||||
On Linux the panel also has switches for what the computer has: **GameMode** (`gamemoderun`
|
||||
on the PATH), **MangoHud** (`mangohud`), and, with two graphics cards under
|
||||
`/sys/class/drm`, the **dedicated graphics card** (`DRI_PRIME=1`, or NVIDIA's
|
||||
`__NV_PRIME_RENDER_OFFLOAD`, `__VK_LAYER_NV_optimus` and `__GLX_VENDOR_LIBRARY_NAME` when its
|
||||
driver is loaded). A switch whose tool is gone does nothing. `instance launch --gamemode
|
||||
on|off --mangohud on|off --dedicated-gpu on|off`; `HORIZON_LAUNCHER_LINUX_TOOLS` makes any
|
||||
system show them, for tests.
|
||||
- **Desktop shortcut**: Home's Instance row has **Shortcut**: a shortcut on the desktop that plays
|
||||
the selected instance without opening the launcher (`horizon-launcher play --instance <id>`).
|
||||
A `.desktop` file on Linux (also put in the applications menu, and marked trusted with `gio`
|
||||
where it exists), a small app with Horizon's icon on macOS, a `.lnk` on Windows
|
||||
(`horizon_launcher::shortcut`). **Remove shortcut** moves it to the trash; it is found by the
|
||||
instance id inside it, so a rename doesn't lose it. `horizon-launcher instance shortcut
|
||||
[instance] [--remove]`; `HORIZON_LAUNCHER_DESKTOP` points it at another folder.
|
||||
- **Accounts and quick switch**: the top bar's account chip opens a menu of everyone who can play:
|
||||
each signed-in Microsoft account, the offline names the player keeps, and the name the game
|
||||
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Settings > Account
|
||||
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them;
|
||||
the Console's Share log takes them out of the log too. `horizon-launcher account offline
|
||||
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
|
||||
<name>`.
|
||||
- **Accent and Home background**: on top of Dark, Light or System, Settings > Launcher picks an
|
||||
accent (Horizon's violet, blue, teal, green, amber, red or pink) and a picture of the player's
|
||||
own behind Home (PNG, JPEG or WebP, cropped to fill; **Landscape** brings Horizon's back). An
|
||||
accent turns the violet tokens to its hue, then darkens or lightens them just enough to keep
|
||||
white on the Play button at 4.5:1 and the accent at 3:1 on every background (a test holds every
|
||||
accent in both looks to it). The picture is copied into the data folder.
|
||||
`horizon-launcher settings accent NAME`, `settings background PATH|default`.
|
||||
- **Language**: English, French and Russian (the languages Horizon's own game menus have).
|
||||
The window follows the system's language unless the player picks one in Settings > Launcher
|
||||
(each named in itself); a string a table lacks shows in English. The tables are
|
||||
`launcher/lang/<code>.json`, English text to its translation, Horizon's own; a test fails when
|
||||
the code asks `tr("…")` for a string a table lacks, or a table keeps one the code dropped.
|
||||
`horizon-launcher settings language system|en|fr|ru`.
|
||||
- **Skin and cape**: each offline name's **Skin…** in Settings > Account shows the front of its
|
||||
skin and cape, takes a skin PNG (64×64, or the older 64×32) and a cape PNG (64×32), and picks
|
||||
classic or slim arms. The launcher keeps copies in its `skins/` folder and gives the game
|
||||
`HORIZON_OFFLINE_SKIN`, `HORIZON_OFFLINE_SKIN_MODEL` and `HORIZON_OFFLINE_CAPE` when that name
|
||||
plays; the game draws the default skins until it reads them. A Microsoft account's skin and
|
||||
cape: coming soon, with sign-in. `horizon-launcher account skin <name> [FILE] [--model
|
||||
classic|slim] [--cape FILE] [--no-skin] [--no-cape]`.
|
||||
- **Console**: Home's Console shows the game's output of this run as it comes: a search (every
|
||||
word, any case), Follow to keep the newest line in view, Copy, and Open logs folder. Warnings
|
||||
and errors stand out. **Share log…** uploads the log to [mclo.gs](https://mclo.gs), a paste
|
||||
service for Minecraft logs, only after the player confirms; the home folder, the user name and
|
||||
the account names are taken out first (`horizon_launcher::game_log`), and **Delete it** removes
|
||||
the paste. `horizon-launcher share-log [--instance]` and `unshare-log <id:token>`;
|
||||
`HORIZON_LAUNCHER_PASTE_URL` points it at a test server.
|
||||
- **Worlds**: Home lists the selected instance's worlds (picture, mode, version, last played,
|
||||
size). **Back up** zips one into the instance's `backups/` as the game's Make Backup does
|
||||
(`<date>_<time>_<folder>.zip`); **Copy** puts "<name> (1)" beside it; **Delete** moves it to the
|
||||
@@ -140,8 +196,10 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
|
||||
|
||||
- **Settings**: install new Horizon versions automatically (on unless you turn it off; the
|
||||
selected version only moves to the new one if you were on the newest), the opening animation,
|
||||
the game's extra command-line arguments, an HTTP proxy for downloads, and the launcher's own
|
||||
version and update state.
|
||||
what the window does while you play (stays by default; Minimise, or Hide on macOS, puts it away
|
||||
when the game starts and brings it back to the front when the game closes; `settings
|
||||
while-playing stay|minimize|hide`), the game's extra command-line arguments, an HTTP proxy for
|
||||
downloads, and the launcher's own version and update state.
|
||||
- **Updates**: there is one channel. Every release is published as the release, GitHub's latest,
|
||||
once its smoke test passed (docs/release-flow.md), and the launcher installs it. The game
|
||||
pre-releases of the old Alpha channel (v0.2.1 on) stay on the Versions page to install
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"Home": "Accueil",
|
||||
"Mods": "Mods",
|
||||
"Servers": "Serveurs",
|
||||
"Versions": "Versions",
|
||||
"Patch notes": "Notes de version",
|
||||
"Settings": "Paramètres",
|
||||
"Accounts, updates, and the launcher itself.": "Comptes, mises à jour et le launcher lui-même.",
|
||||
"Account": "Compte",
|
||||
"Updates": "Mises à jour",
|
||||
"Launcher": "Launcher",
|
||||
"Network": "Réseau",
|
||||
"Game": "Jeu",
|
||||
"AI": "IA",
|
||||
"About": "À propos",
|
||||
"Install new versions automatically": "Installer les nouvelles versions automatiquement",
|
||||
"Your selected version only changes if you were playing the newest one.": "Votre version choisie ne change que si vous jouiez à la plus récente.",
|
||||
"Appearance": "Apparence",
|
||||
"Dark": "Sombre",
|
||||
"Light": "Clair",
|
||||
"System": "Système",
|
||||
"Dark by default. System follows your computer's light or dark.": "Sombre par défaut. Système suit le mode clair ou sombre de votre ordinateur.",
|
||||
"Language": "Langue",
|
||||
"System follows your computer's language: {language} here. A string not translated yet shows in English.": "Système suit la langue de votre ordinateur : ici {language}. Un texte pas encore traduit s'affiche en anglais.",
|
||||
"While playing": "Pendant le jeu",
|
||||
"Stay": "Rester",
|
||||
"Minimise": "Réduire",
|
||||
"Hide": "Masquer",
|
||||
"The launcher stays open while you play.": "Le launcher reste ouvert pendant que vous jouez.",
|
||||
"The launcher goes down when the game starts, and comes back when it closes.": "Le launcher se réduit quand le jeu démarre et revient quand il se ferme.",
|
||||
"The launcher hides when the game starts, and comes back when it closes.": "Le launcher se masque quand le jeu démarre et revient quand il se ferme.",
|
||||
"Show the opening": "Afficher l'ouverture",
|
||||
"The wordmark, while the release list loads.": "Le logo, pendant que la liste des versions se charge.",
|
||||
"{version} available": "{version} disponible",
|
||||
"Up to date": "À jour",
|
||||
"up to date": "à jour",
|
||||
"Checking…": "Vérification…",
|
||||
"downloading {version}, {percent}%": "téléchargement de {version}, {percent} %",
|
||||
"Updated to {version}. Restart the launcher to use it.": "Mis à jour vers {version}. Redémarrez le launcher pour l'utiliser.",
|
||||
"the update failed: {error}": "la mise à jour a échoué : {error}",
|
||||
"Install": "Installer",
|
||||
"Restart": "Redémarrer",
|
||||
"Check now": "Vérifier",
|
||||
"Open": "Ouvrir",
|
||||
"Data folder": "Dossier des données",
|
||||
"Logs": "Journaux",
|
||||
"crash reports beside it": "rapports de plantage à côté",
|
||||
"Applications menu": "Menu des applications",
|
||||
"Horizon and its icon are in your desktop's menu.": "Horizon et son icône sont dans le menu de votre bureau.",
|
||||
"Not in your desktop's menu.": "Pas dans le menu de votre bureau.",
|
||||
"Microsoft account (coming soon)": "Compte Microsoft (bientôt)",
|
||||
"Microsoft account": "Compte Microsoft",
|
||||
"Another account": "Un autre compte",
|
||||
"Microsoft sign-in is coming soon. Until then the game plays offline: single player, LAN and servers in offline mode work.": "La connexion Microsoft arrive bientôt. D'ici là, le jeu se joue hors ligne : solo, LAN et serveurs en mode hors ligne fonctionnent.",
|
||||
"Coming soon": "Bientôt",
|
||||
"Microsoft account · plays": "Compte Microsoft · joue",
|
||||
"Microsoft account · signed in": "Compte Microsoft · connecté",
|
||||
"Asking Microsoft…": "Demande à Microsoft…",
|
||||
"Add another account.": "Ajoutez un autre compte.",
|
||||
"Copy code": "Copier le code",
|
||||
"Open link": "Ouvrir le lien",
|
||||
"Play as": "Jouer en tant que",
|
||||
"Play offline": "Jouer hors ligne",
|
||||
"Sign out": "Se déconnecter",
|
||||
"Offline": "Hors ligne",
|
||||
"Microsoft": "Microsoft",
|
||||
"Offline, the game's name": "Hors ligne, le nom du jeu",
|
||||
"Manage accounts": "Gérer les comptes",
|
||||
"Offline name": "Nom hors ligne",
|
||||
"Offline name · plays": "Nom hors ligne · joue",
|
||||
"A name, 3 to 16 letters": "Un nom, de 3 à 16 lettres",
|
||||
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Jouez hors ligne sous un autre nom, et changez depuis la barre du haut. Les serveurs en ligne n'acceptent pas les noms hors ligne.",
|
||||
"Add": "Ajouter",
|
||||
"Skin…": "Skin…",
|
||||
"Forget": "Oublier",
|
||||
"{name}'s skin": "Skin de {name}",
|
||||
"{name}'s skin is kept.": "Le skin de {name} est enregistré.",
|
||||
"{name}'s cape is kept.": "La cape de {name} est enregistrée.",
|
||||
"Choose a skin…": "Choisir un skin…",
|
||||
"Default skin": "Skin par défaut",
|
||||
"Classic arms": "Bras classiques",
|
||||
"Slim arms": "Bras fins",
|
||||
"Choose a cape…": "Choisir une cape…",
|
||||
"No cape": "Pas de cape",
|
||||
"Default": "Par défaut",
|
||||
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "Un PNG de skin 64×64 (ou 64×32) et un PNG de cape 64×32, gardés par le launcher et donnés au jeu quand ce nom joue. Le jeu dessine encore les skins par défaut ; il utilisera ceux-ci dès qu'il le pourra. Skin et cape d'un compte Microsoft : bientôt.",
|
||||
"Accent colour": "Couleur d'accent",
|
||||
"Horizon's violet by default. Every colour keeps text as easy to read.": "Le violet d'Horizon par défaut. Chaque couleur garde le texte aussi lisible.",
|
||||
"Choose": "Choisir",
|
||||
"Choose a picture…": "Choisir une image…",
|
||||
"Landscape": "Paysage",
|
||||
"Your picture, behind Home, cropped to fill it.": "Votre image, derrière l'accueil, recadrée pour le remplir.",
|
||||
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Le paysage d'Horizon. Choisissez votre propre image PNG, JPEG ou WebP.",
|
||||
"Home background": "Fond de l'accueil",
|
||||
"Play": "Jouer",
|
||||
"Running": "En cours",
|
||||
"Stopped": "Arrêté",
|
||||
"Start the game, or run a server for others to join.": "Lancez le jeu, ou ouvrez un serveur que d'autres peuvent rejoindre.",
|
||||
"No version installed yet.": "Aucune version installée pour l'instant.",
|
||||
"Choose a version": "Choisir une version",
|
||||
"Host a server": "Héberger un serveur",
|
||||
"Preparing…": "Préparation…",
|
||||
"Prepare": "Préparer",
|
||||
"Prepare again": "Préparer à nouveau",
|
||||
"Not found. Choose the folder where your launcher keeps Minecraft.": "Introuvable. Choisissez le dossier où votre launcher garde Minecraft.",
|
||||
"Ready to play": "Prêt à jouer",
|
||||
"Resources": "Ressources",
|
||||
"Minecraft folder": "Dossier Minecraft",
|
||||
"Choose…": "Choisir…",
|
||||
"Playing offline": "Jeu hors ligne",
|
||||
"Online servers need a Microsoft account.": "Les serveurs en ligne demandent un compte Microsoft.",
|
||||
"Accounts…": "Comptes…",
|
||||
"The first preparation takes a few minutes.": "La première préparation prend quelques minutes.",
|
||||
"Horizon crashed": "Horizon a planté",
|
||||
"When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Pour le signaler sur Discord, joignez le rapport de plantage (ou collez-le avec Copier le rapport) et dites ce que vous faisiez.",
|
||||
"The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Le jeu s'est arrêté sans rapport de plantage. Pour le signaler sur Discord, joignez latest.log du dossier des journaux et dites ce que vous faisiez.",
|
||||
"Open report": "Ouvrir le rapport",
|
||||
"Copied": "Copié",
|
||||
"Copy report": "Copier le rapport",
|
||||
"Open logs folder": "Ouvrir le dossier des journaux",
|
||||
"Report on Discord": "Signaler sur Discord",
|
||||
"Dismiss": "Fermer",
|
||||
"INSTANCES": "INSTANCES",
|
||||
"SERVERS": "SERVEURS",
|
||||
"HIDE": "MASQUER",
|
||||
"Website": "Site web",
|
||||
"Ready": "Prêt",
|
||||
"Needs preparing": "À préparer",
|
||||
"{version} is not installed": "{version} n'est pas installée",
|
||||
"No version installed": "Aucune version installée",
|
||||
"Modpack": "Modpack",
|
||||
"From Horizon's registry": "Du registre d'Horizon",
|
||||
"Instance": "Instance",
|
||||
"Edit…": "Modifier…",
|
||||
"Copy…": "Copier…",
|
||||
"Export…": "Exporter…",
|
||||
"Close export": "Fermer l'export",
|
||||
"Launch…": "Lancement…",
|
||||
"Close launch": "Fermer le lancement",
|
||||
"Open folder": "Ouvrir le dossier",
|
||||
"Join a friend": "Rejoindre un ami",
|
||||
"Paste an invite code": "Collez un code d'invitation",
|
||||
"Paste the code a friend shows when they invite you.": "Collez le code qu'un ami affiche quand il vous invite.",
|
||||
"Join": "Rejoindre",
|
||||
"Shortcut": "Raccourci",
|
||||
"Remove shortcut": "Retirer le raccourci",
|
||||
"today": "aujourd'hui",
|
||||
"yesterday": "hier",
|
||||
"{days} days ago": "il y a {days} jours",
|
||||
"{time} in total · last played {when}": "{time} au total · joué pour la dernière fois {when}",
|
||||
"Not played yet": "Pas encore joué",
|
||||
"under a minute": "moins d'une minute",
|
||||
"PARTNER": "PARTENAIRE",
|
||||
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Pas préparé. Horizon a besoin des fichiers de votre propre Minecraft {version}.",
|
||||
"Prepared for Minecraft {version}": "Préparé pour Minecraft {version}",
|
||||
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Préparé pour Minecraft {version}, sans ses sons : le jeu sera muet. Préparez à nouveau pour les copier depuis votre installation de Minecraft."
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"Home": "Главная",
|
||||
"Mods": "Моды",
|
||||
"Servers": "Серверы",
|
||||
"Versions": "Версии",
|
||||
"Patch notes": "Список изменений",
|
||||
"Settings": "Настройки",
|
||||
"Accounts, updates, and the launcher itself.": "Аккаунты, обновления и сам лаунчер.",
|
||||
"Account": "Аккаунт",
|
||||
"Updates": "Обновления",
|
||||
"Launcher": "Лаунчер",
|
||||
"Network": "Сеть",
|
||||
"Game": "Игра",
|
||||
"AI": "ИИ",
|
||||
"About": "О программе",
|
||||
"Install new versions automatically": "Устанавливать новые версии автоматически",
|
||||
"Your selected version only changes if you were playing the newest one.": "Выбранная версия меняется, только если вы играли в самую новую.",
|
||||
"Appearance": "Оформление",
|
||||
"Dark": "Тёмное",
|
||||
"Light": "Светлое",
|
||||
"System": "Системное",
|
||||
"Dark by default. System follows your computer's light or dark.": "По умолчанию тёмное. «Системное» следует светлой или тёмной теме компьютера.",
|
||||
"Language": "Язык",
|
||||
"System follows your computer's language: {language} here. A string not translated yet shows in English.": "«Системный» следует языку компьютера: здесь {language}. Ещё не переведённый текст показывается по-английски.",
|
||||
"While playing": "Во время игры",
|
||||
"Stay": "Оставаться",
|
||||
"Minimise": "Свернуть",
|
||||
"Hide": "Скрыть",
|
||||
"The launcher stays open while you play.": "Лаунчер остаётся открытым, пока вы играете.",
|
||||
"The launcher goes down when the game starts, and comes back when it closes.": "Лаунчер сворачивается при запуске игры и возвращается, когда она закрывается.",
|
||||
"The launcher hides when the game starts, and comes back when it closes.": "Лаунчер скрывается при запуске игры и возвращается, когда она закрывается.",
|
||||
"Show the opening": "Показывать заставку",
|
||||
"The wordmark, while the release list loads.": "Логотип, пока загружается список версий.",
|
||||
"{version} available": "Доступна {version}",
|
||||
"Up to date": "Обновлено",
|
||||
"up to date": "обновлено",
|
||||
"Checking…": "Проверка…",
|
||||
"downloading {version}, {percent}%": "загрузка {version}, {percent}%",
|
||||
"Updated to {version}. Restart the launcher to use it.": "Обновлено до {version}. Перезапустите лаунчер, чтобы применить.",
|
||||
"the update failed: {error}": "обновление не удалось: {error}",
|
||||
"Install": "Установить",
|
||||
"Restart": "Перезапустить",
|
||||
"Check now": "Проверить",
|
||||
"Open": "Открыть",
|
||||
"Data folder": "Папка данных",
|
||||
"Logs": "Журналы",
|
||||
"crash reports beside it": "отчёты о сбоях рядом",
|
||||
"Applications menu": "Меню приложений",
|
||||
"Horizon and its icon are in your desktop's menu.": "Horizon и его значок есть в меню рабочего стола.",
|
||||
"Not in your desktop's menu.": "Нет в меню рабочего стола.",
|
||||
"Microsoft account (coming soon)": "Аккаунт Microsoft (скоро)",
|
||||
"Microsoft account": "Аккаунт Microsoft",
|
||||
"Another account": "Другой аккаунт",
|
||||
"Microsoft sign-in is coming soon. Until then the game plays offline: single player, LAN and servers in offline mode work.": "Вход через Microsoft скоро появится. А пока игра работает офлайн: одиночная игра, LAN и серверы в офлайн-режиме.",
|
||||
"Coming soon": "Скоро",
|
||||
"Microsoft account · plays": "Аккаунт Microsoft · играет",
|
||||
"Microsoft account · signed in": "Аккаунт Microsoft · вход выполнен",
|
||||
"Asking Microsoft…": "Запрос к Microsoft…",
|
||||
"Add another account.": "Добавьте ещё один аккаунт.",
|
||||
"Copy code": "Скопировать код",
|
||||
"Open link": "Открыть ссылку",
|
||||
"Play as": "Играть за",
|
||||
"Play offline": "Играть офлайн",
|
||||
"Sign out": "Выйти",
|
||||
"Offline": "Офлайн",
|
||||
"Microsoft": "Microsoft",
|
||||
"Offline, the game's name": "Офлайн, имя от игры",
|
||||
"Manage accounts": "Управление аккаунтами",
|
||||
"Offline name": "Офлайн-имя",
|
||||
"Offline name · plays": "Офлайн-имя · играет",
|
||||
"A name, 3 to 16 letters": "Имя, от 3 до 16 букв",
|
||||
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Играйте офлайн под другим именем и переключайтесь в верхней панели. Онлайн-серверы не принимают офлайн-имена.",
|
||||
"Add": "Добавить",
|
||||
"Skin…": "Скин…",
|
||||
"Forget": "Забыть",
|
||||
"{name}'s skin": "Скин {name}",
|
||||
"{name}'s skin is kept.": "Скин {name} сохранён.",
|
||||
"{name}'s cape is kept.": "Плащ {name} сохранён.",
|
||||
"Choose a skin…": "Выбрать скин…",
|
||||
"Default skin": "Скин по умолчанию",
|
||||
"Classic arms": "Обычные руки",
|
||||
"Slim arms": "Тонкие руки",
|
||||
"Choose a cape…": "Выбрать плащ…",
|
||||
"No cape": "Без плаща",
|
||||
"Default": "По умолчанию",
|
||||
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "PNG скина 64×64 (или 64×32) и PNG плаща 64×32 хранятся в лаунчере и передаются игре, когда играет это имя. Пока игра рисует стандартные скины; она начнёт использовать эти, как только сможет. Скин и плащ аккаунта Microsoft: скоро.",
|
||||
"Accent colour": "Цвет акцента",
|
||||
"Horizon's violet by default. Every colour keeps text as easy to read.": "По умолчанию фиолетовый Horizon. С любым цветом текст читается так же легко.",
|
||||
"Choose": "Выбрать",
|
||||
"Choose a picture…": "Выбрать картинку…",
|
||||
"Landscape": "Пейзаж",
|
||||
"Your picture, behind Home, cropped to fill it.": "Ваша картинка за главной страницей, обрезанная по размеру.",
|
||||
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Пейзаж Horizon. Выберите свою картинку PNG, JPEG или WebP.",
|
||||
"Home background": "Фон главной",
|
||||
"Play": "Играть",
|
||||
"Running": "Запущено",
|
||||
"Stopped": "Остановлен",
|
||||
"Start the game, or run a server for others to join.": "Запустите игру или сервер, к которому смогут подключиться другие.",
|
||||
"No version installed yet.": "Пока не установлено ни одной версии.",
|
||||
"Choose a version": "Выбрать версию",
|
||||
"Host a server": "Открыть сервер",
|
||||
"Preparing…": "Подготовка…",
|
||||
"Prepare": "Подготовить",
|
||||
"Prepare again": "Подготовить заново",
|
||||
"Not found. Choose the folder where your launcher keeps Minecraft.": "Не найдено. Выберите папку, где ваш лаунчер хранит Minecraft.",
|
||||
"Ready to play": "Готово к игре",
|
||||
"Resources": "Ресурсы",
|
||||
"Minecraft folder": "Папка Minecraft",
|
||||
"Choose…": "Выбрать…",
|
||||
"Playing offline": "Игра офлайн",
|
||||
"Online servers need a Microsoft account.": "Для онлайн-серверов нужен аккаунт Microsoft.",
|
||||
"Accounts…": "Аккаунты…",
|
||||
"The first preparation takes a few minutes.": "Первая подготовка занимает несколько минут.",
|
||||
"Horizon crashed": "Horizon вылетел",
|
||||
"When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Сообщая об этом в Discord, приложите файл отчёта о сбое (или вставьте его через «Скопировать отчёт») и расскажите, что вы делали.",
|
||||
"The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Игра закрылась без отчёта о сбое. Сообщая об этом в Discord, приложите latest.log из папки журналов и расскажите, что вы делали.",
|
||||
"Open report": "Открыть отчёт",
|
||||
"Copied": "Скопировано",
|
||||
"Copy report": "Скопировать отчёт",
|
||||
"Open logs folder": "Открыть папку журналов",
|
||||
"Report on Discord": "Сообщить в Discord",
|
||||
"Dismiss": "Закрыть",
|
||||
"INSTANCES": "СБОРКИ",
|
||||
"SERVERS": "СЕРВЕРЫ",
|
||||
"HIDE": "СКРЫТЬ",
|
||||
"Website": "Сайт",
|
||||
"Ready": "Готово",
|
||||
"Needs preparing": "Нужна подготовка",
|
||||
"{version} is not installed": "{version} не установлена",
|
||||
"No version installed": "Версия не установлена",
|
||||
"Modpack": "Сборка модов",
|
||||
"From Horizon's registry": "Из каталога Horizon",
|
||||
"Instance": "Сборка",
|
||||
"Edit…": "Изменить…",
|
||||
"Copy…": "Копировать…",
|
||||
"Export…": "Экспорт…",
|
||||
"Close export": "Закрыть экспорт",
|
||||
"Launch…": "Запуск…",
|
||||
"Close launch": "Закрыть запуск",
|
||||
"Open folder": "Открыть папку",
|
||||
"Join a friend": "Присоединиться к другу",
|
||||
"Paste an invite code": "Вставьте код приглашения",
|
||||
"Paste the code a friend shows when they invite you.": "Вставьте код, который друг показывает, когда приглашает вас.",
|
||||
"Join": "Войти",
|
||||
"Shortcut": "Ярлык",
|
||||
"Remove shortcut": "Убрать ярлык",
|
||||
"today": "сегодня",
|
||||
"yesterday": "вчера",
|
||||
"{days} days ago": "{days} дн. назад",
|
||||
"{time} in total · last played {when}": "всего {time} · последняя игра: {when}",
|
||||
"Not played yet": "Ещё не запускалась",
|
||||
"under a minute": "меньше минуты",
|
||||
"PARTNER": "ПАРТНЁР",
|
||||
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Не подготовлено. Horizon нужны файлы вашего собственного Minecraft {version}.",
|
||||
"Prepared for Minecraft {version}": "Подготовлено для Minecraft {version}",
|
||||
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Подготовлено для Minecraft {version}, но без звуков: игра будет беззвучной. Подготовьте заново, чтобы скопировать их из вашей установки Minecraft."
|
||||
}
|
||||
@@ -638,6 +638,33 @@ pub struct Accounts {
|
||||
pub accounts: Vec<Account>,
|
||||
/// The UUID of the account the game starts with; none plays offline.
|
||||
pub selected: Option<String>,
|
||||
/// Offline names the player keeps, to switch between (`--name`): no account, no skin.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub offline: Vec<String>,
|
||||
/// The offline name that plays when no account is selected; none: the name the game made up
|
||||
/// (`horizon-player.txt`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offline_selected: Option<String>,
|
||||
/// Each offline name's skin and cape (`skins`), by the name in lowercase.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub looks: std::collections::BTreeMap<String, crate::skins::Look>,
|
||||
}
|
||||
|
||||
/// Who plays, as the quick switch lists them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Player {
|
||||
/// A Microsoft account, by UUID.
|
||||
Microsoft(String),
|
||||
/// An offline name.
|
||||
Offline(String),
|
||||
/// Offline as the name the game made up.
|
||||
GameName,
|
||||
}
|
||||
|
||||
/// Whether `name` can be an offline name: 3 to 16 letters, digits or `_`, as account names are.
|
||||
pub fn valid_offline_name(name: &str) -> bool {
|
||||
(3..=16).contains(&name.chars().count())
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
impl Accounts {
|
||||
@@ -687,6 +714,94 @@ impl Accounts {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Who plays now.
|
||||
pub fn player(&self) -> Player {
|
||||
match (self.selected(), &self.offline_selected) {
|
||||
(Some(a), _) => Player::Microsoft(a.uuid.clone()),
|
||||
(None, Some(name)) => Player::Offline(name.clone()),
|
||||
(None, None) => Player::GameName,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays as `who`: an account, an offline name (kept), or the game's own name.
|
||||
pub fn play_as(&mut self, who: Player) -> Result<(), String> {
|
||||
match who {
|
||||
Player::Microsoft(uuid) => {
|
||||
if !self.accounts.iter().any(|a| a.uuid == uuid) {
|
||||
return Err("That account isn't signed in.".into());
|
||||
}
|
||||
self.selected = Some(uuid);
|
||||
}
|
||||
Player::Offline(name) => {
|
||||
let name = self.add_offline(&name)?;
|
||||
self.selected = None;
|
||||
self.offline_selected = Some(name);
|
||||
}
|
||||
Player::GameName => {
|
||||
self.selected = None;
|
||||
self.offline_selected = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keeps an offline name (once, in any case); returns it as kept.
|
||||
pub fn add_offline(&mut self, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if !valid_offline_name(name) {
|
||||
return Err(format!(
|
||||
"{name}: an offline name is 3 to 16 letters, digits or _"
|
||||
));
|
||||
}
|
||||
if let Some(kept) = self.offline.iter().find(|n| n.eq_ignore_ascii_case(name)) {
|
||||
return Ok(kept.clone());
|
||||
}
|
||||
self.offline.push(name.to_string());
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Forgets an offline name; if it was playing, the game's own name plays.
|
||||
pub fn remove_offline(&mut self, name: &str) -> Result<(), String> {
|
||||
let before = self.offline.len();
|
||||
self.offline.retain(|n| !n.eq_ignore_ascii_case(name));
|
||||
self.looks.remove(&name.to_ascii_lowercase());
|
||||
if self.offline.len() == before {
|
||||
return Err(format!("No offline name {name}."));
|
||||
}
|
||||
if self
|
||||
.offline_selected
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(name))
|
||||
{
|
||||
self.offline_selected = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An offline name's look (the default: no skin of its own).
|
||||
pub fn look(&self, name: &str) -> crate::skins::Look {
|
||||
self.looks
|
||||
.get(&name.to_ascii_lowercase())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The game's variables for the offline name that plays: its skin and cape.
|
||||
pub fn look_env(&self) -> Vec<(&'static str, String)> {
|
||||
match self.name_arg() {
|
||||
Some(name) => crate::skins::env(&crate::skins::dir(), name, &self.look(name)),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's `--name` for this run: the offline name, when one plays.
|
||||
pub fn name_arg(&self) -> Option<&str> {
|
||||
match self.selected() {
|
||||
Some(_) => None,
|
||||
None => self.offline_selected.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out: the secrets leave the keychain, the account the list.
|
||||
pub fn remove(&mut self, store: &dyn SecretStore, uuid: &str) -> Result<(), String> {
|
||||
store.delete(uuid)?;
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user