Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5bb7be3765 Merge main into feat/lua-mods (Cargo.toml members one per line; stability.md server-mods row from main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:24 +01:00
Ludovic MarieandClaude Opus 5.5 bad95e4f6a horizon-mod-lua: string.rep and table.concat keep bytes that are not UTF-8
Lua strings are byte strings. Both converted their arguments with
to_str_lossy, so a byte such as \255 (binary values a mod keeps in
horizon.storage, for one) came back as U+FFFD's three bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:52 +01:00
Ludovic Marie b03d1411d1 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-27 02:24:19 +01:00
Ludovic Marie a24a6fa26c Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 23:40:35 +01:00
Ludovic Marie 6d9bb375c5 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 21:43:18 +01:00
Ludovic Marie 10c9d8440c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 19:13:17 +01:00
Ludovic MarieandClaude Opus 5.5 7f6d556e68 docs: Lua mods' page goes to horizon-site with the rest of website/
docs/mods.md keeps the reference, plus what a capability missing from the features does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:09:10 +01:00
Ludovic MarieandClaude Opus 5.5 d33f547bf7 Merge origin/main into feat/mods-lua
website/ moved to its own repo (#2286): this branch's changes to make-a-mod.md and nav.json go there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:55 +01:00
Ludovic Marie 2e7283d01c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 16:10:40 +01:00
Ludovic Marie 9a7f955a77 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
#	docs/api/stability.md
2026-10-26 11:54:53 +01:00
Ludovic Marie 6b7617c277 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 09:44:13 +01:00
Ludovic MarieandClaude Opus 5.5 7c5efff307 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:31:10 +01:00
Ludovic MarieandClaude Opus 5.5 1f6e498238 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:48:21 +01:00
Ludovic MarieandClaude Opus 5.5 32f6079134 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 ce1db1b0c3 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:16 +01:00
Ludovic Marie ad3ddeb43f Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/horizon-mods/tests/sandbox.rs
2026-10-26 01:43:14 +01:00
Ludovic Marie 34f4d3b451 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 00:25:27 +01:00
Ludovic MarieandClaude Opus 5.5 27de895fdb Mods in Lua: horizon-mod-lua, a Lua 5.4 interpreter built into the mod
A mod's code can be a Lua script. The crate horizon-mod-lua embeds
piccolo (pure Rust Lua, MIT) in the wasm guest with the script, so a Lua
mod is an .hmod like any other: same sandbox, capabilities, fuel and
checks. `horizon_mod_lua::lua_mod!("main.lua")` is the whole Rust side.

- The script runs once at load and registers commands with
  horizon.command; hooks are globals (on_tick, on_frame, on_command,
  on_chat). Capabilities are tables of `horizon` (game, chat, hud,
  storage), each behind a cargo feature so unused imports aren't linked.
- piccolo's stdlib lacks them: string.format (C flags, width,
  precision), string.rep, table.insert/remove/concat.
- The sandbox has no entropy: getrandom 0.2 and 0.3 get a custom
  SplitMix64 backend (cfg getrandom_backend="custom", set by
  cargo horizon-mod build).
- Example coords-hud-lua: coords-hud line for line. The sandbox tests
  run the same checks on both, and measure a tick of each.
- cargo horizon-mod new --template lua.
- Docs: docs/mods.md, website "Write it in Lua", changelog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:32:50 +01:00
150 changed files with 2157 additions and 4831 deletions

No files matched your search

Generated
+115 -9
View File
@@ -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"
@@ -1100,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"
@@ -1264,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"
@@ -1405,7 +1446,6 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -1469,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"
@@ -2745,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"
@@ -2872,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"
@@ -2972,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"
@@ -3451,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"
@@ -3568,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"
@@ -3775,12 +3887,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
@@ -5235,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5276,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+2
View File
@@ -30,6 +30,7 @@ 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",
@@ -40,6 +41,7 @@ members = [
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
+54 -8
View File
@@ -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");
+1 -18
View File
@@ -4,10 +4,7 @@
//! language used by every screen.
use std::{
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
sync::{Arc, RwLock},
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -26,16 +23,11 @@ impl LanguageInfo {
#[derive(Default)]
pub struct Language {
pub code: String,
/// `LanguageInfo.bidirectional`: text without a strong character reads
/// right to left (`ClientLanguage.defaultRightToLeft`).
bidirectional: bool,
map: HashMap<String, String>,
}
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -142,12 +134,8 @@ impl Language {
{
apply_deprecated(&mut map, &bytes);
}
let bidirectional = available(sources)
.iter()
.any(|info| info.code == code && info.bidirectional);
Self {
code: code.to_owned(),
bidirectional,
map,
}
}
@@ -209,16 +197,11 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
}
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
}
pub fn code() -> String {
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
}
-1
View File
@@ -62,7 +62,6 @@ gilrs = { version = "0.11", optional = true }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
@@ -1061,7 +1061,6 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -353,7 +353,6 @@ pub fn main() -> Result<()> {
horizon_world::food::Difficulty::Hard,
][op["difficulty"].as_u64().unwrap() as usize],
natural_regeneration: op["regenerate"].as_bool().unwrap(),
..Default::default()
};
}
"food" => {
-36
View File
@@ -73,8 +73,6 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// `WetSpongeBlock.animateTick`.
WetSponge,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -169,10 +167,6 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3),
"suspicious_gravel" => Animate::Falling(0x707070),
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -969,36 +963,6 @@ fn block_animate_tick(
}
}
}
Animate::WetSponge => {
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
const DIRECTIONS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
// The sponge occludes, so only a sturdy neighbour face hides the drip.
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
let r = engine.random();
let p = match d {
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
[dx, _, 0] => {
let y = fy + r.next_double() * 0.8;
let z = fz + r.next_double();
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
}
[_, _, dz] => {
let y = fy + r.next_double() * 0.8;
let x = fx + r.next_double();
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
}
};
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
Animate::Mycelium => {
let r = engine.random();
if next_int(r, 10) == 0 {
+5
View File
@@ -719,6 +719,11 @@ impl Audio {
self.play(event, at, volume, pitch);
self.capture = capture;
}
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
/// client (the entity's own random there, never the server's world random).
pub fn hurt_pitch(&mut self) -> f32 {
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
}
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
pub fn play_button_click(&mut self) {
self.play_instance(
-48
View File
@@ -450,54 +450,6 @@ mod tests {
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
/// The status a fake control answers with: the player is where it should
/// be, nothing is queued, and the world holds `chunks` resident chunks.
fn status(chunks: u64) -> String {
serde_json::json!({
"player": {"position": [0.0, 64.0, 0.0]},
"pending_chunks": 0,
"screen": "playing",
"render_distance": 12,
"chunks": chunks,
"generated_chunks": chunks,
"meshed_sections": 0,
"streaming": {},
})
.to_string()
}
#[test]
fn the_load_waits_for_the_chunks_a_teleport_starts() {
// Right after a teleport the game reports steady counts and nothing
// queued for over a second, before the integrated server has sent the
// first chunk: the quiet rule alone would call that loaded, and the
// scene would be measured over an empty view (#1749).
const LATE: usize = 30;
let (tx, rx) = mpsc::channel::<Request>();
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = std::sync::Arc::clone(&polls);
let server = thread::spawn(move || {
let mut seen = 0;
while let Ok(request) = rx.recv() {
if request.line == "status" {
let chunks = if seen < LATE { 0 } else { 400 };
seen += 1;
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
let _ = request.reply.send(status(chunks));
} else {
let _ = request.reply.send("{}".to_owned());
}
}
});
let control = Control(tx);
wait_loaded(&control, |_| true).expect("the scene loads");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
"the load finished before the chunks arrived"
);
drop(control);
let _ = server.join();
}
#[test]
fn yaw_matches_vanilla() {
-302
View File
@@ -1,302 +0,0 @@
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
//! `Language.getVisualOrder`): the logical text a player types becomes the
//! glyphs a font draws left to right. Arabic letters are shaped into their
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
//! text is reordered by the Unicode bidirectional algorithm, and characters in
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
use crate::text::{Span, Text};
use std::borrow::Cow;
use unicode_bidi::{
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
};
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
/// (diacritics) and `None` never joins.
#[derive(Clone, Copy, PartialEq)]
enum Join {
None,
Right,
Dual,
Mark,
}
use Join::{Dual, Mark, Right};
/// ICU's joining table (`araLink`): code point ranges and their class, with the
/// lam-alef ligatures at the end. Anything else is `Join::None`.
const JOINS: &[(u32, u32, Join)] = &[
(0x0622, 0x0625, Right),
(0x0626, 0x0626, Dual),
(0x0627, 0x0627, Right),
(0x0628, 0x0628, Dual),
(0x0629, 0x0629, Right),
(0x062A, 0x062E, Dual),
(0x062F, 0x0632, Right),
(0x0633, 0x063A, Dual),
(0x0640, 0x0647, Dual),
(0x0648, 0x0649, Right),
(0x064A, 0x064A, Dual),
(0x064B, 0x0656, Mark),
(0x0670, 0x0670, Mark),
(0x0671, 0x0673, Right),
(0x0675, 0x0677, Right),
(0x0678, 0x0687, Dual),
(0x0688, 0x0699, Right),
(0x069A, 0x06B9, Dual),
(0x06BA, 0x06BA, Right),
(0x06BB, 0x06BF, Dual),
(0x06C0, 0x06C0, Right),
(0x06C1, 0x06C1, Dual),
(0x06C2, 0x06CB, Right),
(0x06CC, 0x06CC, Dual),
(0x06CD, 0x06CD, Right),
(0x06CE, 0x06D1, Dual),
(0x06D2, 0x06D3, Right),
(0x200D, 0x200D, Dual),
(0x206D, 0x206F, Mark),
(0xFEF5, 0xFEFC, Right),
];
/// Letters that have joined forms and the code point of the isolated one; the
/// final, initial and medial forms follow it (a right-joining letter has the
/// isolated and final only). The lam-alef ligatures are their own letters.
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
/// U+06D3) unchanged, so they are in `JOINS` but not here.
#[rustfmt::skip]
const FORMS: &[(u32, u32)] = &[
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
];
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
/// neighbouring characters that mirror each other.
const MIRRORED: &str = "\
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
fn join(c: char) -> Join {
let c = c as u32;
JOINS
.iter()
.find(|&&(from, to, _)| (from..=to).contains(&c))
.map_or(Join::None, |&(_, _, join)| join)
}
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
fn letter_of(c: char) -> char {
let c32 = c as u32;
if !matches!(c32, 0xFE70..=0xFEFC) {
return c;
}
if (0xFE70..0xFE80).contains(&c32) {
// The diacritics' forms pair up from fathatan on.
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
}
FORMS
.iter()
.find(|&&(letter, iso)| {
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
4
} else {
2
};
(iso..iso + forms).contains(&c32)
})
.and_then(|&(letter, _)| char::from_u32(letter))
.unwrap_or(c)
}
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
/// may sit between) becomes one ligature, then every letter takes the isolated,
/// final, initial or medial form its neighbours call for. Diacritics take the
/// tatweel form between two joined letters. The text can only get shorter.
fn shape(text: &[char]) -> Vec<char> {
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
let mut chars = Vec::with_capacity(letters.len());
let mut i = 0;
while i < letters.len() {
if letters[i] == '\u{644}' {
let mut at = i + 1;
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
at += 1;
}
let alef = letters.get(at).and_then(|&c| {
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
.iter()
.position(|&a| a == c)
});
if let Some(alef) = alef {
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
chars.extend_from_slice(&letters[i + 1..at]);
i = at + 1;
continue;
}
}
chars.push(letters[i]);
i += 1;
}
// Whether the nearest letter before joins the next one, and the joins of
// the nearest letter after.
let mut joins_next = false;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let next_joins = chars[i + 1..]
.iter()
.map(|&n| join(n))
.find(|&j| j != Mark)
.is_some_and(|j| j != Join::None);
let kind = join(c);
let shaped = match kind {
Join::None => {
joins_next = false;
c
}
Mark => match c as u32 {
m @ 0x064B..=0x0652 => {
let iso = 0xFE70 + 2 * (m - 0x064B);
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
}
_ => c,
},
Right | Dual => {
let to_next = kind == Dual && next_joins;
let form = match (joins_next, to_next) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
joins_next = kind == Dual;
FORMS
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
.ok()
.and_then(|at| char::from_u32(FORMS[at].1 + form))
.unwrap_or(c)
}
};
out.push(shaped);
}
out
}
/// `UCharacter.getMirror`.
fn mirror(c: char) -> char {
if bidi_class(c) != BidiClass::ON {
return c;
}
let mut pairs = MIRRORED.chars();
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
if c == a {
return b;
}
if c == b {
return a;
}
}
c
}
/// Whether a character can change anything: right-to-left letters and numbers,
/// the explicit direction controls and the diacritics that shaping turns into
/// letters. Without any, left-to-right text keeps its order.
fn directional(c: char) -> bool {
c as u32 >= 0x0590
&& (matches!(c as u32, 0x064B..=0x0652)
|| matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::LRE
| BidiClass::RLE
| BidiClass::LRO
| BidiClass::RLO
| BidiClass::PDF
| BidiClass::LRI
| BidiClass::RLI
| BidiClass::FSI
| BidiClass::PDI
))
}
/// Whether `chars` keep their order and shapes: left-to-right text without
/// anything directional, in a language that reads left to right by default.
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
!default_rtl && !chars.any(directional)
}
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
/// character's position in the shaped text. A character's style is the one at
/// its position in the shaped text, as `SubStringSource` takes it (the text
/// shrinks at a lam-alef ligature).
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
let shaped = shape(chars);
let plain: String = shaped.iter().collect();
// DEFAULT_RTL only changes text without a strong character.
let level =
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
let info = ParagraphBidiInfo::new(&plain, level);
let levels = info.reordered_levels_per_char(0..plain.len());
ParagraphBidiInfo::reorder_visual(&levels)
.into_iter()
.map(|i| {
let c = if levels[i].is_rtl() {
mirror(shaped[i])
} else {
shaped[i]
};
(c, i)
})
.collect()
}
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
/// the current language's direction.
pub fn visual(text: &Text) -> Cow<'_, Text> {
let default_rtl = crate::lang::bidirectional();
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
return Cow::Borrowed(text);
}
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
let mut out = Text::default();
for (c, at) in reorder(&chars, default_rtl) {
match out.spans.last_mut() {
Some(span) if span.style == *styles[at] => span.text.push(c),
_ => out.spans.push(Span {
text: c.to_string(),
style: styles[at].clone(),
}),
}
}
Cow::Owned(out)
}
/// The same for a plain string.
pub fn visual_str(s: &str) -> Cow<'_, str> {
let default_rtl = crate::lang::bidirectional();
if unchanged(s.chars(), default_rtl) {
return Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
Cow::Owned(
reorder(&chars, default_rtl)
.into_iter()
.map(|(c, _)| c)
.collect(),
)
}
@@ -610,9 +610,7 @@ fn sign_text_calls(
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = crate::bidi::visual_str(&shown).into_owned();
TextCall {
matrix: m,
x: -((width(&shown) as i32) / 2) as f32,
+1 -24
View File
@@ -7,12 +7,7 @@ use crate::{
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
sync::{Arc, OnceLock},
};
use std::{collections::HashSet, fs, path::Path, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
@@ -99,24 +94,6 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
+8 -23
View File
@@ -40,8 +40,6 @@ pub struct Interaction {
/// The cauldron's new state.
pub block: Option<BlockState>,
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -59,7 +57,6 @@ fn pass() -> Interaction {
outcome: Outcome::Pass,
block: None,
sound: None,
stat: None,
}
}
@@ -120,12 +117,11 @@ fn lowered(cauldron: &BlockState) -> BlockState {
}
}
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
Interaction {
outcome: Outcome::Success,
block: Some(block),
sound,
stat: Some(stat),
}
}
@@ -183,22 +179,18 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None, "clean_armor");
return changed(lowered(cauldron), None);
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
return pass();
}
filled(player, ItemStack::new(new, 1), true);
changed(
state("minecraft:cauldron", None),
Some(sound),
"use_cauldron",
)
changed(state("minecraft:cauldron", None), Some(sound))
};
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
filled(player, ItemStack::new("minecraft:bucket", 1), true);
changed(block, Some(sound), "fill_cauldron")
changed(block, Some(sound))
};
match item {
"minecraft:lava_bucket" => {
@@ -207,7 +199,6 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -230,7 +221,6 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -251,7 +241,6 @@ pub fn interact(
changed(
state("minecraft:water_cauldron", Some(1)),
Some("minecraft:item.bottle.empty"),
"use_cauldron",
)
}
("water", "minecraft:bucket") => fill_bucket(
@@ -266,11 +255,7 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -281,7 +266,7 @@ pub fn interact(
// BlockState.cycle on a 1..=3 property
next.properties
.insert("level".into(), (level % 3 + 1).to_string());
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
changed(next, Some("minecraft:item.bottle.empty"))
}
("water", name)
if name
@@ -298,7 +283,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None, "clean_banner")
changed(lowered(cauldron), None)
}
("water", name)
if name
@@ -307,7 +292,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None, "clean_shulker_box")
changed(lowered(cauldron), None)
}
("lava", "minecraft:bucket") => fill_bucket(
player,
@@ -35,23 +35,6 @@ impl Mode {
Self::Redstone => "minecraft:command_block",
}
}
/// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes.
pub fn ordinal(self) -> i32 {
match self {
Self::Sequence => 0,
Self::Auto => 1,
Self::Redstone => 2,
}
}
/// The mode of a packet's ordinal; `handleSetCommandBlock` treats any
/// other value as a plain command block (its `default` branch).
pub fn from_ordinal(ordinal: i32) -> Self {
match ordinal {
0 => Self::Sequence,
1 => Self::Auto,
_ => Self::Redstone,
}
}
/// `CycleButton`: the next value, wrapping round.
pub fn next(self) -> Self {
match self {
@@ -124,18 +124,6 @@ impl Clicks {
self.skip_next_release = true;
}
/// The screen that takes the pointer over starts with its own state:
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
/// belongs to on the screen, so a screen that opens (or closes) drops
/// them. A press whose release never arrived -- the pointer left the
/// window -- would otherwise keep the drag running, and `press` answers
/// `None` for every later press, so the quick move and the clicks go
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
self.skip_next_release = false;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
/// `target`, if any. A press holding a stack starts a quick craft drag
/// instead. `cloning` is `keyPickItem` with infinite materials.
@@ -313,35 +301,4 @@ mod tests {
Some(Input::Pickup)
);
}
/// #1737: a press whose release never arrives (the pointer left the
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
/// every later press, so the Shift quick move and the clicks on the
/// inventory go dead. The drag belongs to the screen that has it
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
/// that takes the pointer over drops it and the quick move works again.
#[test]
fn a_lost_release_does_not_wedge_the_quick_move() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release never arrives.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
// The stuck drag swallows the next press: the Shift quick move stops
// working and the inventory stops answering.
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
// The screen that takes the pointer over starts with its own state.
clicks.cancel_drag();
assert!(clicks.quick_crafting().is_none());
// The quick move works again.
assert_eq!(
clicks.press(&slot, Button::Right, true, true, false),
Some(Input::QuickMove)
);
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
-12
View File
@@ -728,18 +728,6 @@ impl Furnace {
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
+8 -12
View File
@@ -179,14 +179,13 @@ impl PlayerEffects {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, particles visible).
/// duration, amplifier, ambient, visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, particles) in saved {
for (id, duration, amplifier, ambient, _) in saved {
if let Some(def) = brewing::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
}
}
@@ -214,9 +213,8 @@ impl PlayerEffects {
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -226,7 +224,6 @@ impl PlayerEffects {
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
let mut actions = Vec::new();
@@ -249,7 +246,6 @@ impl PlayerEffects {
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
match answer {
@@ -469,13 +465,13 @@ mod tests {
};
// The first tick adds Raid Omen and stops there (the iterator's
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
let (_, added) = fx.tick_with(&mut convert, false);
let (_, added) = fx.tick_with(&mut convert);
assert!(added);
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
assert_eq!(fx.get("speed").unwrap().duration, 50);
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
// The second refreshes Raid Omen and drops Bad Omen.
let (_, added) = fx.tick_with(&mut convert, false);
let (_, added) = fx.tick_with(&mut convert);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
@@ -490,9 +486,9 @@ mod tests {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch, false);
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch, false);
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
+4 -8
View File
@@ -582,21 +582,17 @@ impl Experience {
7 + level * 2
}
}
/// Returns the first multiple of 5 the player reached, where each level
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
pub fn give(&mut self, points: i32) -> Option<i32> {
let mut sound_level = None;
/// Returns true when a level was gained.
pub fn give(&mut self, points: i32) -> bool {
let start = self.level;
self.progress += points as f32 / Self::needed(self.level) as f32;
self.total = self.total.saturating_add(points).max(0);
while self.progress >= 1.0 {
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
self.level += 1;
if self.level % 5 == 0 {
sound_level.get_or_insert(self.level);
}
self.progress /= Self::needed(self.level) as f32;
}
sound_level
self.level > start
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
+1 -1
View File
@@ -203,7 +203,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
color *= in.light_color;
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
if in.overlay == 7u || in.overlay == 8u {
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
// enchanted_glint sheen drifting diagonally over the item's shape.
let px = in.uv * vec2<f32>(textureDimensions(atlas));
+1 -106
View File
@@ -187,31 +187,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
// given with `hideParticles` shows neither particles nor its icon).
if m.extra.effects.is_empty() {
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
} else {
let effects = m
.extra
.effects
.iter()
.map(|&(effect, duration, amplifier)| {
let shown = !m.extra.hidden_effects.contains(&effect);
let mut e = vec![
("id", string(crate::brewing::namespaced(effect))),
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
("duration", Tag::Int(duration)),
];
if !shown {
e.push(("show_particles", Tag::Byte(0)));
}
e.push(("show_icon", Tag::Byte(i8::from(shown))));
Tag::compound(e)
});
map.insert("active_effects".into(), Tag::List(effects.collect()));
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
@@ -491,32 +466,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:parrot" => {
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
map.insert("type".into(), string(size));
}
// TropicalFish.addAdditionalSaveData: the packed variant.
"minecraft:tropical_fish" => {
let pattern = m.extra.variant.min(11);
let packed = i32::from(pattern >= 6)
| i32::from(pattern % 6) << 8
| i32::from(m.extra.variant2 & 15) << 16
| i32::from(m.extra.variant3 & 15) << 24;
map.insert("Variant".into(), Tag::Int(packed));
}
"minecraft:axolotl" => write_axolotl_memories(m, map),
"minecraft:allay" => {
// InventoryCarrier: the fetched stack; the liked player memory.
let mut inv = Vec::new();
@@ -950,25 +900,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.no_gravity = flag(tag, "NoGravity");
m.glowing = flag(tag, "Glowing");
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
// LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last
// entry of an effect winning.
if let Some(Tag::List(effects)) = tag.get("active_effects") {
for e in effects {
let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else {
continue;
};
let effect = crate::brewing::namespaced(def.id);
let duration = number(e.get("duration")).unwrap_or(0) as i32;
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
m.extra.effects.retain(|x| x.0 != effect);
m.extra.effects.push((effect, duration, amplifier));
// Details.CODEC: `show_particles` defaults to true.
m.extra.hidden_effects.retain(|h| *h != effect);
if number(e.get("show_particles")) == Some(0) {
m.extra.hidden_effects.push(effect);
}
}
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
if m.species() == crate::mobs::Species::Bat {
@@ -1117,7 +1048,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::Axolotl {
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
if m.species() == crate::mobs::Species::Allay {
@@ -2299,41 +2229,6 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
}
}
}
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
/// `TropicalFish` read; a missing tag is the default variant (a medium
/// salmon, otherwise index 0).
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
v @ (0..=5 | 99) => v as u8,
_ => 0,
}
}
S::Salmon => {
m.extra.variant = text(tag.get("type"))
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
.unwrap_or(1) as u8;
}
S::TropicalFish => {
let packed = int("Variant");
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
// TropicalFish.Pattern.byId: an unknown id is a kob.
m.extra.variant = if size <= 1 && index < 6 {
(size * 6 + index) as u8
} else {
0
};
m.extra.variant2 = ((packed >> 16) & 15) as u8;
m.extra.variant3 = ((packed >> 24) & 15) as u8;
}
_ => {}
}
}
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
let ttl = memories
+3 -8
View File
@@ -889,17 +889,12 @@ impl Font {
if s.contains('§') {
return self.width_text(&Text::legacy(s));
}
crate::bidi::visual_str(s)
.chars()
.map(|c| self.glyph(c).advance)
.sum::<f32>()
.ceil()
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
/// not glyphs.
pub fn width_text(&self, t: &Text) -> f32 {
let t = t.resolved();
crate::bidi::visual(&t)
t.resolved()
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
+66 -310
View File
@@ -63,6 +63,7 @@ mod frames;
mod friends;
mod functions;
mod game_mode;
mod game_rules_screen;
mod gamemode_switcher;
mod ghasts;
mod golems;
@@ -570,8 +571,6 @@ struct GameLevel<'w> {
player_collision: Option<PlayerCollision>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
cart_signals: Vec<(minecarts::CartKind, i32)>,
/// Arrow and thrown trident boxes (wooden buttons).
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
/// The other entities that keep blocks from being placed into them.
@@ -650,37 +649,6 @@ impl EntityObstructionWorld for BuildingObstruction {
}
}
impl GameLevel<'_> {
/// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the
/// minecart on it, a command block cart first (its success count, which
/// is 0 here), else the first container cart's fullness.
fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 {
if !powered {
return 0;
}
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let area = Aabb::new(
Vec3::new(x + 0.2, y, z + 0.2),
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
);
let mut container = None;
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
let cart = Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
);
if !area.intersects(cart) {
continue;
}
match kind {
minecarts::CartKind::CommandBlock => return 0,
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
container = container.or(Some(signal));
}
_ => {}
}
}
container.unwrap_or(0)
}
/// Every entity box that blocks building: the players who aren't
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
/// armor stand, whose box is empty, doesn't).
@@ -741,14 +709,6 @@ impl LevelAccess for GameLevel<'_> {
state: BlockStateId,
_direction: Direction,
) -> i32 {
if let Some(s) = self.registry.state(state)
&& s.name == "minecraft:detector_rail"
{
return self.detector_rail_signal(
pos,
s.properties.get("powered").is_some_and(|v| v == "true"),
);
}
if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) {
return signal;
}
@@ -2090,11 +2050,9 @@ impl Game<'_> {
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
/// (decreaseAirSupply), the wings, leather boots on powder snow
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping
/// pose.
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
pub(crate) fn sync_player_body(&mut self) {
let glider = self.glider_worn();
let sleeping = self.sleeping.is_some();
let item_use_speed = self
.using
.and_then(|(slot, _)| {
@@ -2122,7 +2080,6 @@ impl Game<'_> {
player.item_use_speed = item_use_speed;
player.respiration = respiration;
player.glider = glider;
let _ = player.set_sleeping(sleeping);
player.leather_boots = leather_boots;
player.freeze_immune = freeze_immune;
player.fluids.fast_lava = self.dim == Dim::Nether;
@@ -2618,14 +2575,8 @@ impl Game<'_> {
pickups::Taken::Item(Box::new(item.clone())),
pickups::Collector::Player,
);
// handleTakeItemEntity: the random pitch of a full pickup.
let pitch = ((self.world.random.next_float()
- self.world.random.next_float())
* 0.7
+ 1.0)
* 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch);
.play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0);
}
item.stack = rest;
}
@@ -2682,7 +2633,6 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2725,7 +2675,6 @@ impl Game<'_> {
};
let mut level = GameLevel {
cart_boxes,
cart_signals,
arrow_boxes,
blockers,
world: &mut self.world,
@@ -3122,34 +3071,6 @@ impl Game<'_> {
pos,
data,
} => self.composter_fill_event(pos, data == 1),
// LevelEventHandler 1504: the stalactite's drip particle.
LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos),
// LevelEventHandler 2006: the dragon fireball's burst.
LevelEvent::LevelEvent {
id: 2006,
pos,
data,
} => self.dragon_fireball_burst(pos, data),
// LevelEventHandler 2009: a wet sponge drying.
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
// LevelEventHandler 2014: a straw bed destroyed, with no sound.
LevelEvent::LevelEvent {
id: 2014,
pos,
data,
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
// LevelEventHandler 3009: a sniffer egg cracking.
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
self.sniffer_egg_crack_particles(pos)
}
LevelEvent::LevelEvent {
id: id @ 3011..=3021,
pos,
@@ -3165,16 +3086,6 @@ impl Game<'_> {
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
1046 => Some((
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
2.0,
)),
1047 => Some((
"minecraft:block.pointed_dripstone.drip_water_into_cauldron",
2.0,
)),
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
@@ -3196,13 +3107,6 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
// LevelEventHandler draws these from the client
// level's random, before 1501's and 1502's smoke.
1501 | 1502 => {
let r = self.particles.level_random();
2.6 + (r.next_float() - r.next_float()) * 0.8
}
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
@@ -3221,15 +3125,6 @@ impl Game<'_> {
self.audio.play(name, c, volume, pitch);
}
}
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
match id {
1501 => self.puffs_above_block(
pos,
horizon_client::particles::ParticleType::LargeSmoke,
),
1502 => self.torch_burnout_smoke(pos),
_ => {}
}
}
LevelEvent::Sound {
pos,
@@ -3296,8 +3191,6 @@ impl Game<'_> {
// Popped as the behaviour emits it (GameLevel::emit).
PlayerEffect::DropItem(_) => {}
PlayerEffect::EatCake => {
// CakeBlock.eat
self.stats.custom("eat_cake_slice", 1);
if let Some(p) = self.player.as_mut() {
let food = &mut p.health.food;
food.food = (food.food + 2).min(20);
@@ -3431,18 +3324,11 @@ impl Game<'_> {
self.open_command_block(key);
return;
}
// SignBlock.useWithoutItem: the text editor, not a container.
if horizon_client::signs::sign_kind(&name).is_some() {
self.use_sign_empty_hand(pos);
return;
}
if &*name == "minecraft:crafting_table" {
self.craft_size = 3;
self.craft_table = Some(pos);
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
self.container_opener_event(key, true);
let at = [
f64::from(key.0) + 0.5,
@@ -3834,21 +3720,6 @@ impl Game<'_> {
{
self.hive_broken_by_player(pos, hive, honey, facing);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
if facts.name.starts_with("minecraft:infested_")
&& !silk
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
// SpawnerBlock.spawnAfterBreak pops its experience whatever the tool.
let xp = if silk && &*facts.name != "minecraft:spawner" {
0
@@ -3970,10 +3841,6 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_sign_applicator(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
}
if self.use_jukebox(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
@@ -4033,20 +3900,6 @@ impl Game<'_> {
)
});
self.process_level_events()?;
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
if result == horizon_world::level::InteractionResult::Success {
let name = self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name
.clone();
match &*name {
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
"minecraft:bell" => self.stats.custom("bell_ring", 1),
_ => {}
}
}
if result.consumes_action() {
self.swing_arm();
return Ok(true);
@@ -4835,6 +4688,7 @@ impl Game<'_> {
player.dimensions.eye_height
};
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
// glide's wear): both draw from the level random.
if let Some((at, velocity)) = self.tick_soul_speed() {
@@ -4870,12 +4724,10 @@ impl Game<'_> {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
MotionEvent::Emission {
state, in_water, ..
} => {
} if !crouching => {
let sounds = self.res.facts().get(state).sounds.clone();
// Entity.playStepSound: the block's step sound at 0.15 volume,
// muffled under the swim sound in water. Whether it plays is
// Player.getMovementEmission's call (shift on the ground),
// not the pose: a forced crouch still steps.
// muffled under the swim sound in water.
let (volume, pitch) = if in_water {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
@@ -4887,21 +4739,26 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
MotionEvent::Drown => {
// The guests see the entity event 67 too.
if let Some(host) = &self.lan {
host.hub
.entity_event(host.host_entity, combat::DROWN_EVENT, feet);
// LivingEntity.makeDrownParticles: eight bubbles moving
// with the player.
use horizon_client::particles::{ParticleOptions, ParticleType};
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [
feet[0] + triangle(),
feet[1] + triangle(),
feet[2] + triangle(),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::Bubble),
p,
player_velocity,
);
}
self.drown_particles(feet, player_velocity);
}
MotionEvent::BubbleColumnParticle { bubble, position } => {
// ServerLevel.sendParticles: SPLASH (speed 1) or BUBBLE
@@ -4937,10 +4794,6 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
// Player.causeFallDamage: a fall of 2 blocks or more counts.
MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => {
self.stats.custom("fall_one_cm", cm)
}
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
@@ -5008,7 +4861,6 @@ impl Game<'_> {
}
}
self.powder_snow_fire()?;
self.rain_extinguish_in_frame();
self.tick_totem();
let Some(player) = self.player.as_mut() else {
return Ok(());
@@ -5305,13 +5157,10 @@ impl Game<'_> {
if capture && !self.focused {
return;
}
// The container screen's press state belongs to the screen: a screen
// that takes the pointer over starts with its own state, as
// `AbstractContainerScreen` does, so a press whose release never
// arrived (the pointer left the window) cannot wedge the next
// screen's quick move and clicks.
self.reset_inventory_clicks();
if !capture {
if capture {
// The inventory's screens are behind us: no drag survives them.
self.reset_inventory_clicks();
} else {
self.stop_mining();
}
// The real pointer takes over from an agent's.
@@ -6160,14 +6009,12 @@ impl Game<'_> {
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// belongs to on the screen; Horizon keeps them on the game, so they are
/// dropped when a screen closes. A press whose release never reaches the
/// screen (the pointer left the window, or the screen closed first) would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_clicks = Default::default();
self.inv_drag = None;
self.last_inv_click = None;
}
@@ -8083,15 +7930,13 @@ impl Game<'_> {
}
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
let sweep = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
self.particles.add(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
),
at,
[0.0; 3],
);
self.particles.add(&sweep, at, [0.0; 3]);
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
// the offset is the one from the player to the particle.
let origin = from.unwrap_or(at);
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
}
@@ -8246,6 +8091,11 @@ impl Game<'_> {
let sweep = self.sweep_attack_position(scale, crit);
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
let player = self.player.as_ref();
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
if knockback_attack {
@@ -8325,14 +8175,6 @@ impl Game<'_> {
0.2,
(actual * 0.5) as u32,
);
// The players a server serves are sent them too.
self.lan_send_particles(
&indicator,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32,
);
}
self.last_hurt_by_player
.insert(self.mobs[index].id, self.lan_acting);
@@ -8363,13 +8205,6 @@ impl Game<'_> {
}
}
self.weapon_hit(index);
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
// (inside `if (wasHurt)`), after damageStatsAndHearts.
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
} else {
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
@@ -8933,10 +8768,6 @@ impl Game<'_> {
}
self.lan_close(false);
self.save_game()?;
if let Some(mut remote) = self.remote.take() {
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
remote.session.disconnect();
}
self.saved_for_exit = true;
Ok(())
}
@@ -9218,9 +9049,6 @@ impl Game<'_> {
if result.outcome == Outcome::Pass {
return Ok(false);
}
if let Some(stat) = result.stat {
self.stats.custom(stat, 1);
}
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
{
@@ -9923,13 +9751,16 @@ impl Game<'_> {
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let at = self.eye_position();
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = self.experience.give(gained as i32)
if self.experience.give(gained as i32)
&& self.experience.level % 5 == 0
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
let volume = if self.experience.level > 30 {
1.0
} else {
self.experience.level as f32 / 30.0
};
self.audio
.play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0);
}
@@ -9994,7 +9825,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.add_projectile(arrow);
self.creatures.projectiles.push(arrow);
self.player_projectile_shot();
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
self.audio
@@ -10299,14 +10130,6 @@ impl Game<'_> {
};
Some(t < 0.15)
}
/// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops
/// motion is above, and the biome's precipitation there is rain.
fn raining_at(&self, p: BlockPos) -> bool {
self.world.weather.levels(1.0).0 > 0.2
&& self.world.light(p.x, p.y, p.z) >> 4 == 15
&& self.rain_height(p.x, p.z) <= p.y
&& self.precipitation(p.x, p.y, p.z) == Some(false)
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
fn rain_height(&self, x: i32, z: i32) -> i32 {
@@ -10343,15 +10166,12 @@ impl Game<'_> {
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
// The Horizon Weather option off silences the rain too.
if self.settings.horizon.weather {
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
}
}
@@ -10894,9 +10714,6 @@ impl Game<'_> {
let level = &mut self.level;
level.creative = self.creative;
level.game_mode = mode;
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
level.difficulty =
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
level.time = self.world.game_time;
level.weather = self.world.weather;
level.day_time = self.world.day_time;
@@ -11052,21 +10869,6 @@ impl Game<'_> {
}
return Ok(false);
}
// A server pack's reload covers the frame with its progress
// (`LoadingOverlay`) until `tick_server_packs` applies it.
if let Some(reload) = &self.server_packs.reload {
if let Some(renderer) = &mut self.renderer {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
horizon_client::level_loading::draw_progress(
&mut hud,
&self.gui.atlas,
reload.progress.line(),
reload.progress.fraction(),
);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
}
let Some(size) = self
.renderer
.as_ref()
@@ -11187,11 +10989,7 @@ impl Game<'_> {
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it.
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
let outline = if self.screen == Screen::Playing && !self.spectator() {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
@@ -11387,7 +11185,6 @@ impl Game<'_> {
dark_disc: self.dark_disc(camera),
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -11447,7 +11244,7 @@ impl Game<'_> {
}),
ticks: self.ticks,
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.fog.block_light_flicker,
boss_darken: self
.boss_overlay
@@ -12154,19 +11951,6 @@ impl ApplicationHandler for Game<'_> {
{
return;
}
// The pack reload's progress overlay takes no input
// (`LoadingOverlay`): `start_pack_reload` released what was held.
if self.server_packs.reload.is_some()
&& !matches!(
&event,
WindowEvent::CloseRequested
| WindowEvent::Focused(_)
| WindowEvent::Resized(_)
| WindowEvent::RedrawRequested
)
{
return;
}
if self.pending_save.is_some() {
match event {
WindowEvent::CloseRequested => {
@@ -12390,8 +12174,7 @@ impl ApplicationHandler for Game<'_> {
let (action, sound) = self.title.as_mut().map_or((Action::None, false), |t| {
let clicked = t.click(self.cursor.0, self.cursor.1, (b.width, b.height));
t.press_field(atlas, self.cursor, (b.width, b.height), shift);
let rules = t.press_rules(atlas, self.cursor, (b.width, b.height));
(clicked.0, clicked.1 || rules)
clicked
});
self.save_title_options();
if sound {
@@ -12969,16 +12752,6 @@ impl ApplicationHandler for Game<'_> {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
{
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
if let Err(error) = self.begin_closing_save(true) {
self.error = Some(error);
event_loop.exit();
return;
}
}
if self.pending_save.is_some() {
match self.poll_closing_save() {
Ok(true) => event_loop.exit(),
@@ -13674,6 +13447,7 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
KeyCode::Numpad9,
];
const STAGES: u32 = 10;
const STAGE_LINES: [&str; 10] = [
"Loading menu",
"Loading assets",
@@ -13689,28 +13463,19 @@ const STAGE_LINES: [&str; 10] = [
/// The background loader's checkpoint, read by the loading screen each frame.
#[derive(Clone)]
struct Progress {
value: Arc<AtomicU32>,
lines: &'static [&'static str],
}
struct Progress(Arc<AtomicU32>);
impl Progress {
fn new() -> Self {
Self::with(&STAGE_LINES)
}
fn with(lines: &'static [&'static str]) -> Self {
Self {
value: Arc::new(AtomicU32::new(0)),
lines,
}
Self(Arc::new(AtomicU32::new(0)))
}
fn set(&self, stage: u32) {
self.value.store(stage, Ordering::Relaxed);
self.0.store(stage, Ordering::Relaxed);
}
fn fraction(&self) -> f32 {
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
}
fn line(&self) -> &'static str {
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
}
}
@@ -14164,7 +13929,6 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -14477,11 +14241,6 @@ impl Loading {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0 {
// No world is open yet: nothing to save.
event_loop.exit();
return;
}
if let Err(error) = self.poll_menu_resources() {
self.error = Some(error);
event_loop.exit();
@@ -14759,9 +14518,6 @@ pub fn run() -> Result<()> {
{
horizon_client::heartbeat::start();
}
if options.execution_role.is_client() {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
@@ -708,27 +708,6 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&villager),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["villager"], &vctx, w)
&& adv::item(&c["item"], &it, w)
});
}
/// `EnterBlockTrigger`: the block, its state properties and the player.
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
+1 -2
View File
@@ -131,8 +131,7 @@ impl Game<'_> {
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
const PITCHES: [f32; 16] = [
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
@@ -547,7 +547,7 @@ impl Game<'_> {
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
+35 -13
View File
@@ -789,11 +789,6 @@ impl Game<'_> {
pub(super) fn sync_rules(&mut self) {
self.world.health_rules.natural_regeneration =
self.ui.rules.bool("natural_health_regeneration");
// Player.isInvulnerableTo: the damage-type rules.
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
@@ -806,11 +801,7 @@ impl Game<'_> {
pub(super) fn load_world_state(&mut self) {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.clock = WorldClock::default();
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = horizon_client::title::DIFFICULTIES
.iter()
.position(|d| *d == self.level.difficulty)
.map_or(2, |i| i as u8);
self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2);
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
&& let Ok((root, _)) =
@@ -835,9 +826,9 @@ impl Game<'_> {
self.build_registries();
self.sync_rules();
}
/// Save the clock beside level.dat (Level.write saves the game rules: a
/// second writer of game_rules.dat could leave it with the rules loaded
/// when the world opened).
/// Save the clock and the difficulty beside level.dat (Level.write saves
/// the game rules: a second writer of game_rules.dat could leave it with
/// the rules loaded when the world opened).
pub(super) fn save_world_state(&mut self) {
if self.world_root.as_os_str().is_empty() {
return;
@@ -885,6 +876,9 @@ impl Game<'_> {
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -994,6 +988,34 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
n += 1;
}
}
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
let bytes = std::fs::read(world.join("level.dat")).ok()?;
let raw = horizon_world::compression::Compression::Gzip
.decode(&bytes, 64 << 20)
.ok()?;
let (root, _) =
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
match root.tag.get("Data")?.get("Difficulty")? {
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
_ => None,
}
}
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
use horizon_world::{compression::Compression, nbt};
let path = world.join("level.dat");
let bytes = std::fs::read(&path)?;
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
if let Some(data) = root.tag.get_mut("Data")
&& let Some(d) = data.get_mut("Difficulty")
{
*d = nbt::Tag::Byte(difficulty as i8);
}
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
Ok(())
}
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
/// commands are allowed, else `ALL` (0).
+3 -44
View File
@@ -1,16 +1,7 @@
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
//! where the player fell from, turned into the vanilla death message.
use super::{Game, chat::PLAYER_NAME};
use horizon_client::{
health::HealthEvent,
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::random::RandomSource;
/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage.
pub(super) const DROWN_EVENT: i8 = 67;
use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text};
struct Entry {
damage_type: String,
@@ -54,38 +45,6 @@ pub(super) struct CombatTracker {
fall_before: f64,
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()];
self.particles.add(&bubble, p, velocity);
}
}
/// Entity event 67 from a server, for this client's own player or an
/// entity it draws.
pub(super) fn remote_drown(&mut self, id: i32) {
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if own {
self.player.as_ref().map(|p| {
let (at, v) = (p.position, p.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
let (at, v) = (m.position, m.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
};
if let Some((at, v)) = body {
self.drown_particles(at, v);
}
}
/// The display name of a mob: its custom name or its type's name.
pub(super) fn mob_display_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
@@ -123,8 +82,8 @@ impl Game<'_> {
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
// Player.getVoicePitch: always 1.0.
self.audio.play(sound, Some(at), 1.0, 1.0);
let pitch = self.audio.hurt_pitch();
self.audio.play(sound, Some(at), 1.0, pitch);
}
/// `CombatTracker.recordDamage` for the motion events of this tick.
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
@@ -2,7 +2,7 @@
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
//! through the command system with the block's output, and
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use super::{Game, Screen, commands::execute::Source};
use horizon_client::{
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
@@ -12,7 +12,6 @@ use horizon_client::{
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
@@ -36,16 +35,6 @@ pub(super) struct CommandBlockScreen {
auto: bool,
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Button {
Output,
@@ -197,18 +186,6 @@ impl Game<'_> {
pub(super) fn can_use_game_master_blocks(&self) -> bool {
self.creative && self.ui.registries.permission >= 2
}
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
/// screen itself (`Player.openCommandBlock`) while the server only
/// answers the click: the block at `pos`, when it is a command block the
/// player may edit.
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
let is_command_block = self
.block_state_at(pos)
.is_some_and(|state| Mode::of_block(&state.name).is_some());
if is_command_block && self.can_use_game_master_blocks() {
self.open_command_block(pos);
}
}
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
match self.world.block_entities.get(&pos)? {
BlockEntity::CommandBlock(c) => Some(c),
@@ -595,66 +572,12 @@ impl Game<'_> {
s.command.insert_text(&allowed);
}
}
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
fn set_command_block(&mut self, s: &CommandBlockScreen) {
if !self.can_use_game_master_blocks() {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
auto: s.auto,
};
if self.remote.is_some() {
self.send(S::SetCommandBlock(play::SetCommandBlock {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
command: edit.command,
mode: edit.mode.ordinal(),
flags: i8::from(edit.track_output)
| (i8::from(edit.conditional) << 1)
| (i8::from(edit.auto) << 2),
}));
} else {
self.apply_command_block(&edit);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
/// block is edited only by a creative player with game master commands.
pub(super) fn lan_set_command_block(
&mut self,
host: &mut LanHost,
conn: u32,
p: play::SetCommandBlock,
) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let allowed = g.creative() && g.permission >= 2;
let edit = CommandBlockEdit {
pos: (p.pos[0], p.pos[1], p.pos[2]),
command: p.command,
track_output: p.flags & 1 != 0,
mode: Mode::from_ordinal(p.mode),
conditional: p.flags & 2 != 0,
auto: p.flags & 4 != 0,
};
self.as_guest(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
});
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -686,7 +609,7 @@ impl Game<'_> {
}
let _ = self.process_level_events();
}
let command = s.command.clone();
let command = s.command.value.clone();
let Some(c) = command_block_mut(&mut self.world, pos) else {
return;
};
+77 -231
View File
@@ -42,10 +42,9 @@ pub(super) enum Target {
Guest(u32),
Mob(u32),
Item(u32),
/// An experience orb, by its entity id.
Orb(u32),
/// A projectile, by its entity id.
Projectile(u32),
Orb(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -54,10 +53,6 @@ pub(super) enum Target {
/// An entity of a type Horizon does not simulate (markers, displays,
/// interactions...): kept as its saved data at a position.
Other(u32),
/// An End crystal, by its entity id (`minecraft:end_crystal`).
Crystal(u32),
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
Dragon(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -345,16 +340,14 @@ impl Game<'_> {
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
Target::Orb(i) => self
.orbs
.iter()
.find(|o| o.id == id)
.get(i)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
Target::Projectile(i) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.get(i)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
@@ -392,16 +385,6 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -416,11 +399,10 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Projectile(id) => self
Target::Projectile(i) => self
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.get(i)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
@@ -446,8 +428,6 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map_or_else(String::new, |o| o.kind.clone()),
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -532,72 +512,50 @@ impl Game<'_> {
s.pos[2].unwrap_or(p[2]),
]
};
let mut source_candidates: Vec<Target> = Vec::new();
let mut candidates: Vec<Target> = Vec::new();
match (s.kind, self.command_source) {
// @s: the executing entity (none for a server source).
('s', Some(source)) => source_candidates.extend(source.entity),
('s', None) => source_candidates.extend(self.source_entity()),
('s', Some(source)) => candidates.extend(source.entity),
('s', None) => candidates.extend(self.source_entity()),
// A guest in place is among the players, in join order.
_ if self.player.is_some() && self.lan_acting.is_none() => {
source_candidates.push(Target::Player);
candidates.push(Target::Player);
}
_ => {}
}
if s.kind != 's' {
source_candidates.extend(self.player_candidates(s));
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend((0..self.orbs.len()).map(Target::Orb));
candidates.extend(
self.creatures
.projectiles
.iter()
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
}
let source_dim = self.dim;
// A selector with a spatial constraint reaches the level the command
// runs in only; an unbounded one reaches every loaded level
// (EntitySelector.findEntities).
let dims = if s.world_limited() {
vec![source_dim]
} else {
self.loaded_dims()
};
let mut out: Vec<(Target, f64)> = Vec::new();
for dim in dims {
let back = self.enter_command_level(dim);
let mut candidates = Vec::new();
if dim == source_dim {
candidates.append(&mut source_candidates);
}
if s.kind != 's' {
candidates.extend(self.level_candidates());
}
out.extend(self.select_in_level(candidates, s, origin));
if let Some(back) = back {
self.enter_command_level(back);
}
}
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The candidates of the level in place that pass the selector
/// (EntitySelector.getPredicate).
fn select_in_level(
&mut self,
candidates: Vec<Target>,
s: &Selector,
origin: [f64; 3],
) -> Vec<(Target, f64)> {
let level = self.experience.level;
let creative = self.creative;
let passing = self.selector_extra_filter(&candidates, s);
candidates
let mut out: Vec<(Target, f64)> = candidates
.into_iter()
.filter_map(|t| {
let p = self.target_position(t)?;
@@ -669,101 +627,22 @@ impl Game<'_> {
}
Some((t, d2))
})
.collect()
}
/// The entities of the level in place a selector can find.
fn level_candidates(&self) -> Vec<Target> {
use horizon_client::projectiles::ProjectileKind as K;
let mut candidates = Vec::new();
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
candidates.extend(
self.creatures
.projectiles
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|p| Target::Projectile(p.id)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
// The End crystals and the dragon (EndCrystal and EnderDragon
// are entities of their own).
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
if let Some(d) = &self.dragon {
candidates.push(Target::Dragon(d.id));
}
candidates
}
/// The loaded levels, in the order the server holds them: the
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
fn loaded_dims(&self) -> Vec<Dim> {
let mut dims = vec![self.dim];
dims.extend(self.levels.parked_dims());
dims.sort_by_key(|d| match d {
Dim::Overworld => 0,
Dim::Nether => 1,
Dim::End => 2,
});
dims
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
// A player is in no level of its own.
Target::Player | Target::Guest(_) => true,
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
Target::Other(id) => self.others.iter().any(|o| o.id == id),
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
/// (the entity ids are unique across the loaded levels) and put the
/// level back afterwards. `f` does not run when no level holds the
/// entity.
pub(in crate::game) fn in_target_level<R>(
&mut self,
t: Target,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
if self.level_holds(t) {
return Some(f(self));
}
for dim in self.levels.parked_dims() {
let back = self.enter_command_level(dim);
if self.level_holds(t) {
let r = f(self);
if let Some(back) = back {
self.enter_command_level(back);
.collect();
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
return Some(r);
}
if let Some(back) = back {
self.enter_command_level(back);
}
Sort::Arbitrary => {}
}
None
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
let path = self.options.resources.join(format!(
@@ -1224,10 +1103,6 @@ impl Game<'_> {
Ok(targets.len() as i32)
}
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
// An entity in another level is teleported in the level that holds it.
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
let v = Vec3::new(pos[0], pos[1], pos[2]);
match t {
Target::Guest(_) => {
@@ -1276,14 +1151,14 @@ impl Game<'_> {
i.previous = v;
}
}
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
Target::Orb(i) => {
if let Some(o) = self.orbs.get_mut(i) {
o.position = v;
o.previous = v;
}
}
Target::Projectile(id) => {
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
a.position = v;
a.previous = v;
}
@@ -1323,17 +1198,6 @@ impl Game<'_> {
}
}
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
c.position = v;
}
}
Target::Dragon(id) => {
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
d.position = v;
d.previous = v;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -1693,10 +1557,6 @@ impl Game<'_> {
Ok(duration.unwrap_or(-1))
}
fn kill_target(&mut self, t: Target) {
// An entity in another level is killed in the level that holds it.
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
}
fn kill_target_in_level(&mut self, t: Target) {
match t {
Target::Guest(_) => {
self.for_player(t, |g| g.kill_target(Target::Player));
@@ -1716,13 +1576,16 @@ impl Game<'_> {
}
}
Target::Item(id) => self.dropped.retain(|i| i.id != id),
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.age = u32::MAX / 2;
Target::Orb(i) => {
if i < self.orbs.len() {
self.orbs[i].age = u32::MAX / 2;
}
}
Target::Projectile(id) => {
self.creatures.projectiles.retain(|p| p.id != id);
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
@@ -1730,11 +1593,7 @@ impl Game<'_> {
Target::Boat(id) => self.boats.retain(|b| b.id != id),
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -1742,18 +1601,19 @@ impl Game<'_> {
} else {
vec![self.implied_player()]
};
let mut names: Vec<Text> = Vec::new();
for &t in &targets {
// The name of an entity in another level is read there.
let name = match self.in_target_level(t, |g| g.target_name(t)) {
Some(name) => name,
None => self.target_name(t),
};
names.push(name);
}
for &t in &targets {
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
// Remove indexed entities from the back so earlier indices stay valid.
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
for t in ordered {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -1845,20 +1705,6 @@ impl Game<'_> {
self.spawn_bolt(pos, false);
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
}
"end_crystal" => {
// EndCrystal's constructor takes an id from the entity
// counter, and the crystal is shown with its base.
self.next_entity += 1;
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: Vec3::new(pos[0], pos[1], pos[2]),
show_bottom: true,
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
beam: None,
invulnerable: false,
});
name = Text::translate("entity.minecraft.end_crystal", &[]);
}
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
@@ -31,13 +31,8 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
impl Game<'_> {
// ---- entity data ---------------------------------------------------
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
/// in another level is read in the level that holds it.
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
.flatten()
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
let player_uuid = self.level.player_uuid;
Some(match t {
Target::Guest(_) => {
@@ -118,11 +113,9 @@ impl Game<'_> {
let o = self.others.iter().find(|o| o.id == id)?.clone();
self.other_to_nbt(&o, uuid)
}
Target::Orb(id) => {
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
}
Target::Projectile(id) => {
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
} else {
@@ -136,16 +129,6 @@ impl Game<'_> {
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
Target::Dragon(id) => {
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
@@ -414,19 +397,6 @@ impl Game<'_> {
}
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
let target = self.data_ref(ctx, 0)?;
// An entity in another level is read and written in the level that
// holds it.
if let DataRef::Entity(t) = &target {
let t = *t;
let target = target.clone();
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
Some(r) => r,
None => Err(err("argument.entity.notfound.entity", &[])),
};
}
self.cmd_data_in(id, ctx, target)
}
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
match id {
"data.merge" => {
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
@@ -1,10 +1,11 @@
//! `/effect` (EffectCommands) and `/enchant` (EnchantCommand).
use super::{Arg, Context, Game, R, Target, err, texts};
use horizon_client::{
brewing::{self, namespaced},
brewing,
effects::EffectInstance,
text::{self, Style, Text},
};
use std::{collections::HashMap, sync::OnceLock};
/// `CommandResponseTracker` over targets: elements with a non-zero result.
#[derive(Default)]
@@ -20,6 +21,24 @@ impl Tracker {
}
}
}
/// Namespaced effect ids for mob effect storage.
fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
brewing::EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
fn effect_name(id: &str) -> Text {
Text::translate(&format!("effect.minecraft.{id}"), &[])
}
@@ -663,11 +663,10 @@ impl Game<'_> {
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
Target::Projectile(id) => self
Target::Projectile(i) => self
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.get(i)
.and_then(|p| match (p.owner?, p.guest) {
// A guest's shot: `Who::Player` is them.
(horizon_client::mobs::Who::Player, Some(conn))
@@ -119,7 +119,6 @@ impl Game<'_> {
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
_ => DamageKind::Generic,
-11
View File
@@ -67,17 +67,6 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
}
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
if let Some(data) = stack.extra.as_deref() {
for (id, duration) in &data.stew_effects {
if let Some(def) = horizon_client::brewing::effect(id) {
self.add_player_effect(horizon_client::effects::EffectInstance::new(
def.id, *duration, 0,
));
}
}
}
for effect in &consumable.effects {
self.apply_consume_effect(effect);
}
@@ -44,13 +44,13 @@ impl Game<'_> {
slot: usize,
stack: Option<ItemStack>,
) {
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
&& let Some(s) = e.slots_mut().get_mut(slot)
{
*s = stack;
// TransportItemsBetweenContainers calls Container.setChanged.
self.container_changed(key);
}
}
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
+7 -174
View File
@@ -46,22 +46,10 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
horizon_client::particles::ParticleOptions,
[f64; 3],
[f64; 3],
f64,
u32,
)>,
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
@@ -210,7 +198,6 @@ impl Game<'_> {
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
passenger: self.riding.is_some(),
})
}
/// Biome facts at a position for finalizeSpawn and variant rules.
@@ -692,7 +679,6 @@ impl Game<'_> {
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_pickups();
@@ -942,29 +928,11 @@ impl Game<'_> {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
}
}
/// Adds a projectile, giving it an id from the entity counter
/// (`Entity.id`, the same counter as the rest) when it has none.
pub(super) fn add_projectile(&mut self, mut p: horizon_client::projectiles::Projectile) {
if p.id == 0 {
self.next_entity += 1;
p.id = self.next_entity;
}
self.creatures.projectiles.push(p);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Drown { mob, at } => {
self.lan_entity_event(mob as i32, super::combat::DROWN_EVENT, at);
let v = self
.mobs
.iter()
.find(|m| m.id == mob)
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
self.drown_particles(at, v);
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -1166,7 +1134,7 @@ impl Game<'_> {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
self.add_projectile(p);
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
MobEvent::Spawn(mut m) => {
@@ -1967,7 +1935,6 @@ impl Game<'_> {
let mut hits = Vec::new();
let mut deflections: Vec<[f64; 3]> = Vec::new();
let mut arrow_events = Vec::new();
let mut trails = Vec::new();
{
let mut targets: Vec<(Who, Aabb)> = self
.mobs
@@ -2059,10 +2026,6 @@ impl Game<'_> {
if shooter.is_some() {
p.owner = Some(Who::Player);
}
// AbstractHurtingProjectile.tick ends with createParticleTrail.
if super::remote_projectiles::has_smoke_trail(&p.kind) {
trails.push(p.position);
}
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
@@ -2105,7 +2068,6 @@ impl Game<'_> {
keep
});
}
self.smoke_trails(trails);
for at in deflections {
self.audio
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
@@ -2138,7 +2100,6 @@ impl Game<'_> {
_ => self.projectile_hit(&p, hit)?,
}
}
self.flush_lan_explosions();
self.flush_wind_bursts()
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
@@ -2271,9 +2232,11 @@ impl Game<'_> {
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
}
}
/// `Level.isRainingAt` for arrows.
/// `Level.isRainingAt` for arrows: raining, open to the sky (the biome's
/// precipitation is not checked here).
fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
move |p: BlockPos| self.raining_at(p)
let raining = self.world.weather.levels(1.0).0 > 0.2;
move |p: BlockPos| raining && self.world.light(p.x, p.y, p.z) >> 4 == 15
}
/// The level as arrows see it.
fn arrow_scene<'s>(
@@ -2311,7 +2274,7 @@ impl Game<'_> {
}
}
if keep {
self.add_projectile(arrow);
self.creatures.projectiles.push(arrow);
}
}
/// `TargetBlock.onProjectileHit`: a target block takes its power from
@@ -2905,14 +2868,6 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.interact_armadillo(index, &item) {
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
self.swing_arm();
return Ok(true);
}
if self.interact_copper_golem(index, &item) {
self.swing_arm();
return Ok(true);
@@ -2968,16 +2923,6 @@ impl Game<'_> {
consume = true;
sound = Some("entity.cow.milk");
}
// Goat.mobInteract: a bucket on an adult gives milk.
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
sound = Some(if m.extra.screaming {
"entity.goat.screaming.milk"
} else {
"entity.goat.milk"
});
}
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
give = Some("minecraft:mushroom_stew");
consume = true;
@@ -3238,19 +3183,6 @@ impl Game<'_> {
self.trigger_entity("minecraft:tame_animal", "entity", &view);
}
}
// Tadpole.feed: frog food ages it by a tenth of the time left to
// grow into a frog (it grows on its next tick once past 24000).
Species::Tadpole if is_food && !m.extra.age_locked => {
consume = true;
let left = 24_000 - m.extra.age;
m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20;
// One happy villager particle at rest about the body.
self.particles_around_mob(
self.mobs[index].id,
"happy_villager",
horizon_client::particles::Burst::HORSE_EATING,
);
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -3338,50 +3270,6 @@ impl Game<'_> {
}
Ok(true)
}
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
/// (`brushOffScute`) and loses 16 durability.
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if item != "minecraft:brush"
|| m.species() != Species::Armadillo
|| !m.alive()
|| m.is_baby()
{
return false;
}
let (id, at) = (m.id, m.position);
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
self.spawn_at_location(at, 0.0, d);
}
self.audio
.play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0);
let ctx = self.who_context(Some(Who::Mob(id)), None);
self.post_game_event("minecraft:entity_interact", feet, ctx);
// ItemStack.hurtAndBreak(16).
if !self.creative
&& let Some(stack) = tool
{
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
if max > 0 {
let loss = self.durability_loss(&stack, 16);
let slot = self.held_slot();
if let Some(held) = self.stack_in_mut(slot).as_mut() {
held.damage += loss;
if held.damage >= max {
*self.stack_in_mut(slot) = None;
}
}
}
}
true
}
pub(super) fn damage_held_tool(&mut self) {
if self.creative {
return;
@@ -3436,7 +3324,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.add_projectile(proj);
self.creatures.projectiles.push(proj);
self.player_projectile_shot();
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
// second is the `use_cooldown` component, as for any thrown item.
@@ -3529,60 +3417,6 @@ impl Game<'_> {
}
}
}
/// `Guardian.aiStep` on a client: two bubbles a tick trailing a guardian
/// that swims along, and bubbles up the beam to its target. The server
/// never draws them, so the mob's own random stays untouched.
pub(super) fn guardian_bubbles(&mut self) {
use horizon_client::mobs::guardian;
use horizon_client::particles::{ParticleOptions, ParticleType};
let mut swimming = Vec::new();
let mut beams = Vec::new();
for m in &self.mobs {
if !m.alive() || !matches!(m.species(), Species::Guardian | Species::ElderGuardian) {
continue;
}
let at = [m.position.x, m.position.y, m.position.z];
if m.extra.flag2 && m.fluids.in_water {
let (width, height, _) = m.dims();
swimming.push((at, width, height, m.view_vector()));
}
if let Some((_, scale)) = guardian::beam(m, 0.0)
&& let Some(t) = self.beam_target(m.ai.target, 1.0)
{
beams.push(([at[0], m.eye_y(), at[2]], [t.x, t.y, t.z], f64::from(scale)));
}
}
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for (at, width, height, view) in swimming {
for _ in 0..2 {
let (x, y, z) = {
let r = self.particles.random();
(r.next_double(), r.next_double(), r.next_double())
};
let p = [
at[0] + width * (2.0 * x - 1.0) * 0.5 - view[0] * 1.5,
at[1] + height * y - view[1] * 1.5,
at[2] + width * (2.0 * z - 1.0) * 0.5 - view[2] * 1.5,
];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
for (eye, target, scale) in beams {
let d = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if length <= 0.0 {
continue;
}
let mut dist = self.particles.random().next_double();
while dist < length {
let r = self.particles.random();
dist += 1.8 - scale + r.next_double() * (1.7 - scale);
let f = dist / length;
let p = [eye[0] + d[0] * f, eye[1] + d[1] * f, eye[2] + d[2] * f];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
}
/// Mob models and projectiles for the entity pass.
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
@@ -3891,7 +3725,6 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::InWall => "minecraft:in_wall",
K::Thorns => "minecraft:thorns",
K::SonicBoom => "minecraft:sonic_boom",
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
}
}
+1 -1
View File
@@ -222,7 +222,7 @@ impl Game<'_> {
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.add_projectile(arrow);
self.creatures.projectiles.push(arrow);
}
self.player_projectile_shot();
let pitch = if index == 0 {
+2 -165
View File
@@ -13,7 +13,7 @@ use horizon_client::{
save::VERSION_NAME,
text::Text,
};
use horizon_core::geometry::{Axis, Vec3};
use horizon_core::geometry::Axis;
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -766,8 +766,7 @@ impl Game<'_> {
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
let guests = self.lan.iter().flat_map(|h| h.models.values());
for m in self.mobs.iter().chain(guests) {
for m in &self.mobs {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
@@ -797,159 +796,6 @@ impl Game<'_> {
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
// unless the type sets one). `at` is the interpolated position.
let at = |previous: Vec3, now: Vec3| {
[
lerp(previous.x, now.x),
lerp(previous.y, now.y),
lerp(previous.z, now.z),
]
};
for p in &self.paintings {
let (min, max) = p.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
}
for f in self.item_frames.iter().filter(|f| !f.invisible) {
let (min, max) = f.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
}
for c in &self.cushions {
let (min, max) = c.bounds();
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
let (size, eye) = match (a.marker, a.small) {
(true, _) => ((0.0, 0.0), 0.0),
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
g.hitbox(
at(b.previous, b.position),
(super::boats::WIDTH as f32, h),
h,
false,
(0.0, b.yaw),
);
}
for c in &self.carts {
let (w, h) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
g.hitbox(
at(c.previous, c.position),
(w, h),
h * 0.85,
false,
(0.0, c.yaw),
);
}
for o in &self.orbs {
g.hitbox(
at(o.previous, o.position),
(0.5, 0.5),
0.425,
false,
(0.0, 0.0),
);
}
for f in &self.falling {
g.hitbox(
at(f.previous, f.position),
(0.98, 0.98),
0.833,
false,
(0.0, 0.0),
);
}
for t in &self.tnts {
g.hitbox(
at(t.previous, t.position),
(0.98, 0.98),
0.15,
false,
(0.0, 0.0),
);
}
for c in &self.crystals {
let p = c.position;
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
}
for p in &self.creatures.projectiles {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
K::LargeFireball { .. } => (1.0, 0.85),
};
g.hitbox(
at(p.previous, p.position),
(size, size),
eye,
false,
(p.pitch, p.yaw),
);
}
for t in &self.tridents {
g.hitbox(
at(t.previous, t.position),
(0.5, 0.5),
0.13,
false,
(t.pitch, t.yaw),
);
}
for r in &self.rockets {
// Projectile.updateRotation: the look follows the motion.
let v = r.velocity;
let yaw = v.x.atan2(v.z).to_degrees() as f32;
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
g.hitbox(
at(r.previous, r.position),
(0.25, 0.25),
0.2125,
false,
(pitch, yaw),
);
}
for h in self
.hook
.iter()
.map(|h| (h.previous, h.position))
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
{
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
at(d.previous, d.position),
(16.0, 8.0),
6.8,
true,
(0.0, d.yaw),
);
}
// The dragon's parts.
for (id, b) in self.special_targets() {
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
@@ -982,12 +828,3 @@ impl Game<'_> {
g
}
}
/// The (x, y) rotations whose view vector is the step of a direction.
fn step_view(step: [i32; 3]) -> (f32, f32) {
let [x, y, z] = step.map(f64::from);
(
(-y).asin().to_degrees() as f32,
(-x.atan2(z)).to_degrees() as f32,
)
}
@@ -120,11 +120,6 @@ impl Game<'_> {
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
+1 -1
View File
@@ -1034,7 +1034,7 @@ impl Game<'_> {
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot(&mut own, dir, power, uncertainty);
self.add_projectile(shot);
self.creatures.projectiles.push(shot);
}
}
}
+16 -61
View File
@@ -291,11 +291,11 @@ pub(super) enum FightWho {
}
/// A player in the End as the dragon sees it.
#[derive(Clone, Copy, Debug)]
pub(super) struct FightPlayer {
pub(super) who: FightWho,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
struct FightPlayer {
who: FightWho,
position: Vec3,
bounds: Aabb,
alive: bool,
creative: bool,
spectator: bool,
}
@@ -693,7 +693,7 @@ impl Game<'_> {
}
/// The players in the level the dragon sees (`level.players()`): the
/// window's own, then every guest in the level.
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
fn fight_players(&self) -> Vec<FightPlayer> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref() {
out.push(FightPlayer {
@@ -722,7 +722,7 @@ impl Game<'_> {
}
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
/// sent to its client).
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
match who {
FightWho::Host => f(self),
FightWho::Guest(conn) => {
@@ -828,10 +828,8 @@ impl Game<'_> {
continue;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals.push(Crystal {
id: self.next_entity,
id: 0,
position: at,
show_bottom: true,
time,
@@ -1568,7 +1566,6 @@ impl Game<'_> {
age: 0,
duration: 200,
from_dragon: true,
potion: None,
});
}
}
@@ -2081,18 +2078,12 @@ impl Game<'_> {
age: 0,
duration: 600,
from_dragon: false,
potion: None,
});
// DragonFireball.onHit: level event 2006 (the burst of dragon
// breath and the explosion sound) at its block position.
self.level_event_at(
2006,
[
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
],
1,
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some([next.x, next.y, next.z]),
1.0,
1.0,
);
return false;
}
@@ -2119,10 +2110,6 @@ impl Game<'_> {
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -2270,22 +2257,8 @@ pub(super) struct Breath {
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
pub(super) duration: i32,
pub(super) from_dragon: bool,
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
pub(super) potion: Option<super::potions::PotionCloud>,
}
impl Breath {
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
/// lingering potion's).
pub(super) fn waiting(&self) -> bool {
self.age
< if self.potion.is_some() {
super::potions::LINGERING_WAIT
} else {
20
}
}
duration: i32,
from_dragon: bool,
}
impl Game<'_> {
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
@@ -2862,11 +2835,8 @@ impl Game<'_> {
return false;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals.push(Crystal {
id: self.next_entity,
id: 0,
position: Vec3::new(x + 0.5, y, z + 0.5),
show_bottom: false,
time,
@@ -2883,21 +2853,6 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
/// (`Entity.kill` does not hurt it).
pub(super) fn kill_crystal(&mut self, id: u32) {
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
self.destroy_crystal(i, None, CrystalDamage::Explosion);
}
}
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
/// does not hurt it (the dragon flies its death flight when its health
/// runs out).
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
self.dragon = None;
}
}
/// Any entity touching `room` (`Level.getEntities(null, box)`).
fn entity_in(&self, room: Aabb) -> bool {
let touches = |lo: Vec3, hi: Vec3| {
+3 -10
View File
@@ -253,17 +253,10 @@ impl Game<'_> {
out
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
let intensity = self.end.flash.intensity(partial as f32);
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
intensity / 3.0
} else {
intensity
};
if self.dim == Dim::End {
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
previous: Vec3,
pub(super) velocity: Vec3,
life: i32,
lifetime: i32,
+4 -6
View File
@@ -30,8 +30,8 @@ enum State {
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
position: Vec3,
previous: Vec3,
velocity: Vec3,
state: State,
life: i32,
@@ -433,8 +433,6 @@ impl Game<'_> {
let options = ParticleOptions::simple(kind);
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
@@ -772,8 +770,8 @@ impl Game<'_> {
pub(super) struct RemoteHook {
id: u32,
owner: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
position: Vec3,
previous: Vec3,
}
impl Hook {
+1 -1
View File
@@ -457,7 +457,7 @@ impl Game<'_> {
}
for (id, m, light) in maps {
let entity_start = out.len();
self.map_vertices(id, true, m, light, out);
self.map_vertices(id, m, light, out);
self.cull_entity_vertices(out, entity_start);
}
}
@@ -1,9 +1,8 @@
//! AbstractGameRulesScreen: the rules by category under a search box, a
//! switch or a number box each. InWorldGameRulesScreen, opened from World
//! Options, sends the changed ones on Done (ServerboundSetGameRulePacket) and
//! asks before leaving with changes (ConfirmScreen); WorldCreationGameRulesScreen,
//! opened from Create New World, hands them back and leaves without asking.
use crate::{
//! InWorldGameRulesScreen (AbstractGameRulesScreen), opened from World
//! Options: the rules by category under a search box, a switch or a number
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
@@ -33,14 +32,14 @@ enum Row {
/// What a click or Escape asks of World Options.
#[derive(Debug, PartialEq)]
pub enum Exit {
pub(super) enum Exit {
/// Done: the rules that changed.
Apply(Vec<(&'static str, Value)>),
/// Cancel, or discarding the changes.
Discard,
}
pub struct RulesScreen {
pub(super) struct RulesScreen {
search: EditBox,
values: GameRules,
initial: GameRules,
@@ -50,8 +49,6 @@ pub struct RulesScreen {
scroll: ui::Scroll,
/// ConfirmScreen "Game Rule Changes" in its place.
confirm: bool,
/// InWorldGameRulesScreen rather than WorldCreationGameRulesScreen.
in_world: bool,
}
fn name_key(r: &Rule) -> String {
@@ -105,15 +102,7 @@ fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
}
impl RulesScreen {
/// InWorldGameRulesScreen over the world's rules.
pub fn new(rules: &GameRules) -> Self {
Self::build(rules, true)
}
/// WorldCreationGameRulesScreen over the new world's rules.
pub fn for_creation(rules: &GameRules) -> Self {
Self::build(rules, false)
}
fn build(rules: &GameRules, in_world: bool) -> Self {
pub(super) fn new(rules: &GameRules) -> Self {
// setInitialFocus: the search box.
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
let numbers = RULES
@@ -134,7 +123,6 @@ impl RulesScreen {
numbers,
scroll: ui::Scroll::default(),
confirm: false,
in_world,
}
}
@@ -209,10 +197,9 @@ impl RulesScreen {
})
.collect()
}
/// InWorldGameRulesScreen.onClose: pending changes ask first;
/// WorldCreationGameRulesScreen.onClose discards them.
/// InWorldGameRulesScreen.onClose: pending changes ask first.
fn close(&mut self) -> Option<Exit> {
if !self.in_world || self.changes().is_empty() {
if self.changes().is_empty() {
Some(Exit::Discard)
} else {
self.confirm = true;
@@ -220,7 +207,7 @@ impl RulesScreen {
}
}
/// Escape: ConfirmScreen answers no; the rules close.
pub fn escape(&mut self) -> Option<Exit> {
pub(super) fn escape(&mut self) -> Option<Exit> {
if self.confirm {
self.confirm = false;
None
@@ -229,7 +216,7 @@ impl RulesScreen {
}
}
/// The mouse wheel (AbstractSelectionList: half an entry a step).
pub fn wheel(&mut self, delta: f32, h: f32) {
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
if !self.confirm {
let content = self.content();
self.scroll
@@ -237,20 +224,20 @@ impl RulesScreen {
}
}
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
pub fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
let content = self.content();
self.scroll
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
}
/// The pointer moved while the scrollbar is held.
pub fn drag(&mut self, y: f32, h: f32) {
pub(super) fn drag(&mut self, y: f32, h: f32) {
if self.scroll.held() {
let content = self.content();
self.scroll.drag(y, HEADER, h - FOOTER, content);
}
}
/// The button came up: the scrollbar lets go.
pub fn release(&mut self) {
pub(super) fn release(&mut self) {
self.scroll.release();
}
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
@@ -285,7 +272,7 @@ impl RulesScreen {
}
}
/// Run an edit on the focused box, then its responder.
pub fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
pub(super) fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
let Some((rule, b)) = self.focused() else {
return;
};
@@ -295,12 +282,12 @@ impl RulesScreen {
self.edited(rule);
}
}
pub fn typing(&self) -> bool {
pub(super) fn typing(&self) -> bool {
!self.confirm && (self.search.focused || self.numbers.iter().any(|(_, b)| b.focused))
}
/// A left click; Some when the screen closes.
pub fn click(
pub(super) fn click(
&mut self,
atlas: &HudAtlas,
cursor: (f32, f32),
@@ -368,7 +355,12 @@ impl RulesScreen {
}
}
/// Whether a click lands on a button (for its sound).
pub fn clicks_button(&self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool {
pub(super) fn clicks_button(
&self,
atlas: &HudAtlas,
cursor: (f32, f32),
size: (f32, f32),
) -> bool {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
@@ -385,9 +377,9 @@ impl RulesScreen {
})
}
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
pub(super) fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
if self.confirm {
let labels = [
Text::translate("gui.yes", &[]),
@@ -401,7 +393,7 @@ impl RulesScreen {
self.scroll.clamp(HEADER, h - FOOTER, content);
// RuleList: the in-world list background, its entries clipped to it,
// then the separators and scrollbar.
ui::list_background(batch, atlas, HEADER, h - FOOTER, self.in_world);
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
let first = batch.vertices.len();
let hovered = self.hovered(rows.len(), cursor, (w, h));
let (left, x) = (row_left(w), control_x(w));
@@ -458,7 +450,7 @@ impl RulesScreen {
}
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::separators(batch, atlas, HEADER, h - FOOTER, self.in_world);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
ui::scrollbar(
batch,
atlas,
@@ -534,8 +526,8 @@ mod tests {
#[test]
fn box_colours_are_vanillas() {
assert_eq!(VALID, crate::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, crate::hud::rgba(255, 0, 0, 255));
assert_eq!(VALID, horizon_client::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, horizon_client::hud::rgba(255, 0, 0, 255));
}
#[test]
@@ -79,7 +79,6 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
+2 -33
View File
@@ -412,26 +412,6 @@ fn mob_meta(
}
}
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
/// AgeableMob's two and DATA_BOOST_TIME).
fn mob_freeze_meta(
m: &Mob,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
if m.ticks_frozen > 0 {
meta.push((7, serializer::INT, Value::Int(m.ticks_frozen)));
}
defaults.push((7, serializer::INT, Value::Int(0)));
if m.kind.id == "minecraft:strider" {
if m.extra.flag {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
}
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
/// off hand, feet, legs, chest, head, body, saddle).
fn mob_equipment(
@@ -440,16 +420,13 @@ fn mob_equipment(
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
let e = &m.extra;
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
// slot's item (6) in `head`.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
(2, &e.feet),
(3, &e.legs),
(4, &e.chest),
(if body { 6 } else { 5 }, &e.head),
(5, &e.head),
];
for (slot, item) in slots {
if let Some(item) = item {
@@ -953,7 +930,6 @@ impl Game<'_> {
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
e.needs_sync = m.needs_sync;
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
@@ -1030,9 +1006,6 @@ impl Game<'_> {
for m in &mut self.mobs {
m.needs_sync = false;
}
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
}
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
@@ -1193,11 +1166,8 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
// A mace's wind burst from a guest's attack.
let _ = self.flush_wind_bursts();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
}
fn lan_guest_joined(
@@ -1551,7 +1521,6 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
+10 -37
View File
@@ -267,10 +267,8 @@ impl Game<'_> {
}
let mut play = host.play.remove(&conn).unwrap_or_default();
if fresh {
// A new body (joined, respawned, changed level): nothing on it yet,
// its effects' modifiers and armor protection included.
// A new body (joined, respawned, changed level): nothing on it yet.
play.equipped = None;
play.potions = Default::default();
play.sculk = Default::default();
}
let g = &mut host.hub.guests[i];
@@ -407,12 +405,6 @@ impl Game<'_> {
self.apply_equipment();
play.equipped = Some(equipped);
}
// The player's effects are on the new body before anything it does
// (the movement that brought it, a landing): vanilla keeps the same
// entity through a level change, with its attributes and effects.
if fresh {
self.apply_effect_status();
}
self.update_totem_ready();
Some(GuestSwap {
conn,
@@ -1224,7 +1216,6 @@ impl Game<'_> {
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
S::SetCommandBlock(p) => self.lan_set_command_block(host, conn, p),
S::EditBook(p) => self.lan_edit_book(host, conn, p),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
@@ -1549,13 +1540,9 @@ impl Game<'_> {
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
&& strike.stops_sprint()
{
self.as_guest(host, conn, |g| {
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
}
return;
}
@@ -1860,6 +1847,10 @@ impl Game<'_> {
};
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
// A new body: nothing given to the old one carries over.
if let Some(play) = host.play.get_mut(&conn) {
play.potions = Default::default();
}
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
@@ -2218,7 +2209,7 @@ impl Game<'_> {
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let sneaking = g.sneaking;
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
@@ -2287,7 +2278,6 @@ impl Game<'_> {
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
let _ = w.player.set_sleeping(sleeping);
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
@@ -2304,8 +2294,6 @@ impl Game<'_> {
game.cauldron_extinguish(*pos);
}
}
// Entity.baseTick: rain puts out a burning player.
game.rain_extinguish_player();
// WitherRoseBlock.entityInside along those moves.
for event in &events {
if matches!(event, MotionEvent::InsideWitherRose) {
@@ -2344,13 +2332,6 @@ impl Game<'_> {
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let velocity = marked.then_some(player.velocity);
// Player.causeFallDamage: a fall of 2 blocks or more counts.
for event in &events {
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
{
game.stats.custom("fall_one_cm", *cm);
}
}
game.record_combat(&events);
game.wear_armor(&events);
(events, velocity)
@@ -2373,15 +2354,6 @@ impl Game<'_> {
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// LivingEntity.baseTick's entity event 67, to every client
// around, the guest's own included; this window draws it too.
MotionEvent::Drown => {
if let Some(g) = host.hub.guest(conn) {
host.hub
.entity_event(g.entity_id, super::combat::DROWN_EVENT, at);
}
self.drown_particles(at, [0.0; 3]);
}
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
@@ -2717,7 +2689,8 @@ impl Game<'_> {
/// with Mending and gives the rest as points. Everyone near hears it.
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
let g = &host.hub.guests[gi];
let height = f64::from(g.dimensions().height);
// Player's standing and crouching boxes: 1.8 and 1.5 high.
let height = if g.sneaking { 1.5 } else { 1.8 };
let (conn, id, at) = (g.conn, g.entity_id, g.position);
let unable = g.spectator() || g.dead || g.health <= 0.0;
let play = host.play.entry(conn).or_default();
@@ -342,7 +342,6 @@ impl Game<'_> {
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
e.needs_sync = p.needs_sync;
// Projectile.getAddEntityPacket: the owner's id.
e.data = match p.guest.and_then(|conn| hub.guest(conn)) {
Some(g) => g.entity_id,
@@ -467,7 +466,7 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
@@ -485,14 +484,10 @@ impl Game<'_> {
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.waiting() {
if b.age < 20 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
let particle = match &b.potion {
Some(p) => self.network_particle(&p.particle(), None),
None => breath.clone(),
};
if let Some(p) = &particle {
if let Some(p) = &breath {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
+11 -7
View File
@@ -175,9 +175,9 @@ impl Game<'_> {
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| {
let b = g.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3])
})
.collect()
}
@@ -319,7 +319,7 @@ impl Game<'_> {
uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid),
position: g.position,
eye: [x, y + g.eye_height(), z],
height: g.dimensions().height,
height: if g.sneaking { 1.5 } else { 1.8 },
creative: g.creative(),
spectator: g.spectator(),
alive: !g.dead,
@@ -336,9 +336,14 @@ impl Game<'_> {
}
}
/// A guest's bounding box, by its pose (`Avatar.POSES`).
/// A guest's bounding box (0.6 wide, 1.8 tall, 1.5 crouching).
pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
g.bounds()
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
)
}
#[cfg(test)]
@@ -369,7 +374,6 @@ mod tests {
uuid: [0; 4],
allies: Default::default(),
remote: false,
passenger: false,
}
}
@@ -318,86 +318,16 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` for the players a server serves: one
/// `ClientboundLevelParticlesPacket` to each player in the level whose
/// block is within 32 blocks of `at`. The window's own player is not
/// sent it (the caller draws it in this window). While the host holds
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
pub(super) fn lan_send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
return;
};
let Some(host) = self.lan.as_ref() else {
return;
};
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if d < 32.0 * 32.0 {
g.sender.send(body.clone());
}
}
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(
&mut self,
&self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
// to end so that it reaches every player, the guest too.
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
return;
}
let emitter = if small {
"minecraft:explosion"
} else {
@@ -411,15 +341,6 @@ impl Game<'_> {
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
+8 -15
View File
@@ -86,11 +86,9 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.stops_sprint()
{
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
self.stop_sprint_after_extra_knockback();
}
self.lan = Some(host);
true
@@ -108,18 +106,8 @@ impl Game<'_> {
.map(|g| Victim::Guest(g.conn))
}
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
/// (the host, or a guest swapped in) only once the hit hurt the victim.
pub(super) fn pvp_exhaustion(&mut self) {
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
}
/// The attacker's side of `Player.attack` (the host, or a guest swapped
/// in): the damage, the charge reset and the swing.
/// in): the damage, the charge reset, the exhaustion and the swing.
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
@@ -128,6 +116,11 @@ impl Game<'_> {
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
+6 -2
View File
@@ -5,10 +5,14 @@
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
//! Restrictions opens RestrictionsScreen.
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
use super::{Game, Screen, integrated::ServerOptions, lan::LanSettings};
use super::{
Game, Screen,
game_rules_screen::{Exit, RulesScreen},
integrated::ServerOptions,
lan::LanSettings,
};
use horizon_client::{
font::Font,
game_rules_screen::{Exit, RulesScreen},
hud::{HudBatch, rgba},
text::{Text, named_color},
ui::{self, EditBox},
@@ -277,12 +277,7 @@ impl Game<'_> {
g.carried = menu.carried.take();
}
self.register_exploration_maps(made);
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
gm.merchant.session = session;
if !traded.is_empty() {
// The guest's own statistic and advancements.
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
}
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
self.drop_guest_items(host, gi, dropped);
self.lan_send_offers(host, conn, &mut gm, false);
self.lan_send_merchant_content(host, conn, &mut gm);
+15 -190
View File
@@ -1,14 +1,12 @@
//! Level events whose client handler (`LevelEventHandler`) spawns particles
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
@@ -74,42 +72,24 @@ impl Game<'_> {
} else {
// ParticleUtils.spawnParticleInBlock: up to the
// block's shape (a full block when air).
(at, count, 0.5, self.block_shape_top(at), true)
let inside = self.world.block(at[0], at[1], at[2]);
let height = if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
};
(at, count, 0.5, height, true)
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
None => return,
};
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
}
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
let facts = self.res.facts();
let inside = self.world.block(at[0], at[1], at[2]);
if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
}
}
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
/// `xz` blocks around `at` and `height` up, only above solid ground
/// unless floating ones are allowed.
fn spawn_particles(
&mut self,
at: [i32; 3],
count: i32,
xz: f64,
height: f64,
floating: bool,
ty: ParticleType,
) {
let options = ParticleOptions::simple(ty);
let options = ParticleOptions::simple(ParticleType::HappyVillager);
for _ in 0..count.max(0) {
let r = self.particles.level_random();
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
@@ -128,161 +108,6 @@ impl Game<'_> {
}
}
}
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
let at = [pos.x, pos.y, pos.z];
let height = self.block_shape_top(at);
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
}
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
let mut spawned = Vec::new();
particles::on_block_faces_between(
self.particles.level_random(),
[pos.x, pos.y, pos.z],
3,
6,
|p, v| spawned.push((p, v)),
);
let options = ParticleOptions::simple(ParticleType::EggCrack);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
/// eight puffs just above the block, drawn after the sound.
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
let options = ParticleOptions::simple(ty);
for _ in 0..8 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double();
let z = f64::from(pos.z) + r.next_double();
self.particles
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
/// puffs inside the block, drawn after the sound.
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..5 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
/// particles thrown outwards, then the explosion sound when `data` is 1.
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
for _ in 0..200 {
let r = self.particles.level_random();
let dist = r.next_float() * 4.0;
let angle = r.next_float() * std::f32::consts::TAU;
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
let vy = 0.01 + r.next_double() * 0.5;
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
let options = ParticleOptions {
scale: dist,
..ParticleOptions::simple(ParticleType::DragonBreath)
};
self.particles.add(
&options,
[
f64::from(pos.x) + vx * 0.1,
f64::from(pos.y) + 0.3,
f64::from(pos.z) + vz * 0.1,
],
[vx, vy, vz],
);
}
if data == 1 {
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some(at),
1.0,
pitch,
);
}
}
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
fn is_stalactite(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).has_class("SpeleothemBlock")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("vertical_direction"))
.is_some_and(|d| d == "down")
}
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
/// with none, the dimension's default (lava in the Nether).
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
let tip_state = self.world.block(pos.x, pos.y, pos.z);
if !self.is_stalactite(tip_state) {
return;
}
// findRootBlock: up through the same stalactite, at most 11 steps.
let tip_name = self.res.facts().get(tip_state).name.clone();
let mut root = None;
for step in 1..11 {
let y = pos.y + step;
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&y)
{
break;
}
let state = self.world.block(pos.x, y, pos.z);
if self.res.facts().get(state).name != tip_name {
root = Some(y);
break;
}
if !self.is_stalactite(state) {
break;
}
}
let Some(root_y) = root else {
return;
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
};
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
.pack
.context
.tables
.offset(tip_state, pos.x, pos.z)
.unwrap_or([0.0; 3]);
// The tip shape starts 5/16 up; the particle sits a pixel below that.
self.particles.add(
&ParticleOptions::simple(ty),
[
f64::from(pos.x) + 0.5 + offset[0],
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
f64::from(pos.z) + 0.5 + offset[2],
],
[0.0; 3],
);
}
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
-4
View File
@@ -120,10 +120,6 @@ impl Levels {
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
}
}
impl Game<'_> {
+3 -18
View File
@@ -324,13 +324,6 @@ const DECORATION_TYPES: [(&str, &str); 24] = [
("mineshaft", "map/decorations/mineshaft"),
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
];
/// `MapDecorationType.showOnItemFrame` is false for the player arrows and the two markers.
fn shown_on_frame(sprite: &str) -> bool {
!matches!(
decoration_type(sprite),
Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits")
)
}
/// The sprite of a decoration type that a `minecraft:map_decorations`
/// entry or a server's packet names.
fn decoration_sprite(id: &str) -> Option<&'static str> {
@@ -1121,12 +1114,9 @@ impl Game<'_> {
}
/// `MapRenderer.render`: the map texture and its decorations under `m`
/// (map pixels 0..128, y down), drawn at depth offsets toward the viewer.
/// `show_only_frame` (a map in an item frame) leaves out the decorations that
/// do not show on frames.
pub(super) fn map_vertices(
&mut self,
id: i32,
show_only_frame: bool,
m: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
@@ -1168,18 +1158,14 @@ impl Game<'_> {
.get(&id)
.map(|m| m.decorations.clone())
.unwrap_or_default();
let mut count = 0;
for d in &decorations {
if show_only_frame && !shown_on_frame(d.sprite) {
continue;
}
for (i, d) in decorations.iter().enumerate() {
let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else {
continue;
};
let dm = glam::Mat4::from_translation(glam::Vec3::new(
f32::from(d.x) / 2.0 + 64.0,
f32::from(d.y) / 2.0 + 64.0,
-0.02 - count as f32 * 0.001,
-0.02 - i as f32 * 0.001,
)) * glam::Mat4::from_rotation_z(
(f32::from(d.rot) * 360.0 / 16.0).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(4.0))
@@ -1201,7 +1187,6 @@ impl Game<'_> {
[p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)],
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
);
count += 1;
}
}
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
@@ -1270,7 +1255,7 @@ impl Game<'_> {
}
}
if has_data {
self.map_vertices(id, false, m, light, out);
self.map_vertices(id, m, light, out);
}
}
}
+21 -49
View File
@@ -9,7 +9,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
containers::BlockEntity,
items::{ItemStack, Items},
items::ItemStack,
mesher::Vertex,
mobs::{Part, anim::Poses},
particles::{ParticleOptions, ParticleType},
@@ -201,17 +201,6 @@ impl Minecart {
fn horizontal(&self) -> f64 {
self.velocity.x.hypot(self.velocity.z)
}
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
let mut total = 0.0f32;
for s in slots.iter().flatten() {
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
total += s.count as f32 / max as f32;
}
total /= slots.len().max(1) as f32;
// Mth.lerpDiscrete(total, 0, 15)
(total * 14.0).floor() as i32 + i32::from(total > 0.0)
}
}
pub(super) struct CartModels {
pub models: HashMap<CartKind, Part>,
@@ -728,7 +717,12 @@ impl Game<'_> {
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
}) {
let [x, y, z] = g.position;
pushers.push((Vec3::new(x, y, z), g.bounds()));
let height = if g.sneaking { 1.5 } else { 1.8 };
let bb = Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
);
pushers.push((Vec3::new(x, y, z), bb));
}
}
for (feet, bb) in pushers {
@@ -956,7 +950,7 @@ impl Game<'_> {
}
true
}
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
/// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top.
pub(super) fn dismount_cart(&mut self, i: usize) {
self.riding = None;
let c = &self.carts[i];
@@ -966,25 +960,17 @@ impl Game<'_> {
c.position.z.floor() as i32,
);
let mut target = None;
// DismountHelper.offsetsForDirection of the cart's facing
// (Direction.fromYRot of its yaw): right, left, back right, back left,
// forward right, forward left, back, forward.
let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize];
let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32;
let (fx, fz) = step(facing);
let (rx, rz) = (-fz, fx);
let offsets = [
(rx, rz),
(-rx, -rz),
(rx - fx, rz - fz),
(-rx - fx, -rz - fz),
(fx + rx, fz + rz),
(fx - rx, fz - rz),
(-fx, -fz),
(fx, fz),
];
'search: for dy in [0, 1, -1] {
for (dx, dz) in offsets {
for (dx, dz) in [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(-1, -1),
(1, -1),
(-1, 1),
] {
let (x, y, z) = (bx + dx, by + dy, bz + dz);
let below = self.res.facts().get(self.world.block(x, y - 1, z));
let here = self.res.facts().get(self.world.block(x, y, z));
@@ -1068,22 +1054,6 @@ impl Game<'_> {
}
best
}
/// Each cart's kind and comparator signal, for the detector rail
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
/// counts the items in that menu.
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
self.carts
.iter()
.map(|c| {
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &c.items,
};
(c.kind, Minecart::container_signal(slots, self.res.items()))
})
.collect()
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -1101,8 +1071,10 @@ impl Game<'_> {
}
self.riding = Some(self.carts[i].id);
}
// AbstractMinecartContainer.interact: no sneak check.
CartKind::Chest | CartKind::Hopper => {
if sneaking {
return false;
}
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
+1 -3
View File
@@ -338,9 +338,7 @@ impl Game<'_> {
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
// Text space: x right, y down (scale 0.025, -0.025).
let at = |x: f32, y: f32| o + r * x - u * y;
// Component.getVisualOrderText.
let shown = horizon_client::bidi::visual(text);
let chars: Vec<(char, u32)> = shown
let chars: Vec<(char, u32)> = text
.spans
.iter()
.flat_map(|s| {
+35 -117
View File
@@ -1,23 +1,14 @@
//! Resource pack selection bound to the game: the pack screen lists, and a
//! full resource reload after a change, while playing.
use super::{
Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures,
};
use super::Game;
use horizon_client::{
assets::Assets,
gfx::PreparedTextures,
hud::HudAtlas,
lang,
pack_screen::{self, Compat, PackInfo, Source as PackSource},
packs::{self, Pack, Position},
streaming::WorkerContext,
text::{Style, Text},
};
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
sync::Arc,
};
use std::path::{Path, PathBuf};
/// Where overlay roots are built (beside options.txt).
pub fn cache_dir() -> PathBuf {
@@ -107,7 +98,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
infos
}
impl Game<'_> {
pub(super) fn vanilla_root(&self) -> PathBuf {
fn vanilla_root(&self) -> PathBuf {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.options.resources.clone()
} else {
@@ -147,36 +138,35 @@ impl Game<'_> {
self.ui.options.resource_error = Some(e.to_string());
}
}
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
/// `apply_staged_pack` finishes the GPU half and the swap.
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
/// Reload from the selected packs and the server's.
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
stage_pack(
&vanilla,
&self.settings.resource_packs,
&self.server_packs.active,
&self.settings.mods.enabled,
&self.options.dev_mods,
&self.pack.assets,
&self.res,
&self.pack.context,
self.settings.force_unicode,
self.settings.japanese_glyphs,
&super::Progress::new(),
)
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
let root =
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
self.reload_resources(&root)
}
/// The render-thread half: the renderer on the new pack's textures, then
/// the swap (`ReloadInstance`'s application listeners).
pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> {
let StagedPack {
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
atlas.font.uniform = self.settings.force_unicode;
atlas.font.japanese = self.settings.japanese_glyphs;
let gui = super::UiResources {
atlas,
panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
};
let pack = super::PackResources::load(
root,
gui,
pack,
textures,
} = staged;
self.pack.assets.clone(),
render,
self.res.facts(),
self.pack.context.biomes.clone(),
self.pack.context.light.clone(),
)?;
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
renderer.release_surface();
@@ -184,17 +174,7 @@ impl Game<'_> {
let renderer = match self
.window
.clone()
.map(|window| {
build_renderer(
window,
self.options.offscreen,
&pack,
&gui,
self.renderer.as_ref(),
Some(textures),
self.settings.horizon.api,
)
})
.map(|window| self.renderer_for(window, &pack, &gui))
.transpose()
{
Ok(renderer) => renderer,
@@ -210,18 +190,18 @@ impl Game<'_> {
}
};
// Everything loaded: switch over.
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root);
self.armor_stand_assets.reload_trims(&root);
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
self.armor_stand_assets.reload_trims(root);
self.random_tick_sections.clear();
self.portals.sections.clear();
self.options.resources = root.clone();
self.options.resources = root.to_path_buf();
let context = pack.context.clone();
self.pack = pack;
self.gui = gui;
// The atlas's sprites moved: the particle engine re-reads every rect
// and the packs' sprite sets, or every particle draws a stale region.
self.particles
.reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
horizon_client::ui_theme::reset_atlas();
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
*self
@@ -230,11 +210,11 @@ impl Game<'_> {
.villager_hats
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
horizon_client::mobs::villager_hats::VillagerHats::load(&root);
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
horizon_client::mobs::villager_hats::VillagerHats::load(root);
horizon_client::ui::load_metadata(root);
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
self.refresh_remote_language();
self.audio.reload(&root);
self.audio.reload(root);
self.clear_lod();
self.streaming.reload_resources(&mut self.world, context);
self.pending_uploads.clear();
@@ -249,66 +229,4 @@ impl Game<'_> {
self.creative_screen.forget_items();
Ok(())
}
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
/// applied (`PackReload` runs the same two phases across frames).
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let staged = self.stage_selected_packs()?;
self.apply_staged_pack(staged)
}
}
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
/// root, the models and sprites, the menu bundle, the pack's tables and its
/// CPU mip chains.
pub(super) fn stage_pack(
vanilla_root: &Path,
selected: &[String],
server: &[Pack],
mods_enabled: &BTreeSet<String>,
dev_mods: &[PathBuf],
assets: &Arc<Assets>,
res: &Resources,
context: &WorkerContext,
force_unicode: bool,
japanese: bool,
progress: &super::Progress,
) -> horizon_client::Result<StagedPack> {
let root = startup_root(vanilla_root, selected, server)?;
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
progress.set(1);
let render = Arc::new(assets.reload(&root)?);
let mut atlas = HudAtlas::build_over(&root, vanilla_root)?;
atlas.font.uniform = force_unicode;
atlas.font.japanese = japanese;
let gui = UiResources {
atlas,
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)),
};
progress.set(2);
let pack = PackResources::load(
&root,
assets.clone(),
render,
res.facts(),
context.biomes.clone(),
context.light.clone(),
)?;
let textures = prepare_renderer_textures(&pack);
progress.set(3);
Ok(StagedPack {
root,
gui,
pack,
textures,
})
}
/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes
/// it on the render thread).
pub(super) struct StagedPack {
pub root: PathBuf,
pub gui: UiResources,
pub pack: PackResources,
pub textures: PreparedTextures,
}
+4 -20
View File
@@ -193,12 +193,12 @@ impl Game<'_> {
if let Some(a) = p.arrow.as_mut() {
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
}
self.add_projectile(p);
self.creatures.projectiles.push(p);
}
}
"minecraft:fireball" | "minecraft:small_fireball" => {
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.add_projectile(p);
self.creatures.projectiles.push(p);
}
}
"minecraft:falling_block" => {
@@ -290,22 +290,14 @@ impl Game<'_> {
"minecraft:ender_dragon" => {
let seed = self.world.random.next_long();
if self.dragon.is_none()
&& let Some(mut d) = super::dragon::Dragon::from_nbt(&tag, seed)
&& let Some(d) = super::dragon::Dragon::from_nbt(&tag, seed)
{
// EnderDragon's constructor takes its id and its
// parts' from the entity counter.
d.id = self.next_entity + 1;
self.next_entity += 9;
self.dragon = Some(d);
}
}
"minecraft:end_crystal" => {
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
if let Some(mut c) = super::dragon::Crystal::from_nbt(&tag, time) {
// EndCrystal's constructor takes an id from the
// entity counter.
self.next_entity += 1;
c.id = self.next_entity;
if let Some(c) = super::dragon::Crystal::from_nbt(&tag, time) {
self.crystals.push(c);
}
}
@@ -541,14 +533,6 @@ impl Game<'_> {
self.persist.uuids.remove(&id);
self.persist.raw.remove(&id);
}
/// The compound an entity without a Horizon entity ID was loaded with
/// (the crystals and the dragon), found again by type and position.
pub(super) fn persist_anon(&self, kind: &str, p: Vec3) -> (Option<[i32; 4]>, Option<&Tag>) {
self.persist
.anon
.get(&anon_key(kind, p))
.map_or((None, None), |(_, uuid, raw)| (*uuid, Some(raw)))
}
/// The compound an entity was loaded with (and commands have edited).
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
self.persist.raw.get(&id)
+2 -11
View File
@@ -218,20 +218,11 @@ impl Game<'_> {
_ => None,
})
});
// Camera.setup: the living mount's scale and camera distance
// attributes (the happy ghast's is 8, the default 4).
// A living mount at the default scale and camera distance.
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| {
let attribute = |name: &str, default: f64| {
m.kind.attributes.get(name).copied().unwrap_or(default)
};
(
attribute("minecraft:scale", 1.0) as f32,
attribute("minecraft:camera_distance", 4.0),
)
});
.map(|_| (1.0, 4.0));
// AbstractBoat.positionRider turns a passenger by the boat's
// deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot
// interpolates a passenger's yaw: the boat's turn is spread over the
+47 -286
View File
@@ -2,12 +2,7 @@
//! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`),
//! filling glass bottles (`BottleItem`), the per-tick effect application,
//! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`).
use super::{
Game, Screen,
dragon::{Breath, FightPlayer, FightWho},
item_draw::owner_at,
remote_projectiles::cloud_client_tick,
};
use super::{Game, Screen, item_draw::owner_at};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
@@ -17,13 +12,9 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
@@ -40,35 +31,6 @@ pub(super) struct ThrownPotion {
/// for a dispenser's).
owner: Option<Text>,
}
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
pub(super) const LINGERING_WAIT: i32 = 10;
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
const REAPPLICATION_DELAY: i32 = 20;
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
#[derive(Clone, Copy, PartialEq)]
enum Victim {
Mob(u32),
Player(FightWho),
}
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
/// (at the lingering item's 0.25 duration scale) and who it has affected.
pub(super) struct PotionCloud {
effects: &'static [(&'static str, i32, i32)],
/// The potion's colour (`PotionContents.getColor`).
color: u32,
/// Each victim and the tick it can be affected again.
victims: Vec<(Victim, i32)>,
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
/// thrower, else the cloud.
source: Text,
}
impl PotionCloud {
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
pub(super) fn particle(&self) -> ParticleOptions {
let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0;
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
}
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
use std::{collections::HashMap, sync::OnceLock};
@@ -86,39 +48,6 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
.get(id)
.copied()
}
/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
/// decide first, then the tag of mobs that ignore poison and regeneration.
fn mob_can_be_affected(
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
kind: &str,
effect: &str,
) -> bool {
let effect = effect.trim_start_matches("minecraft:");
let kind = kind.trim_start_matches("minecraft:");
let overridden = match kind {
"wither" | "wither_skeleton" => effect == "wither",
"spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison",
"parched" => effect == "weakness",
_ => false,
};
if overridden {
return false;
}
let in_tag = |tag: &str| {
tags.get(tag)
.is_some_and(|t| t.contains(&format!("minecraft:{kind}")))
};
if in_tag("minecraft:immune_to_infested") {
effect != "infested"
} else if in_tag("minecraft:immune_to_oozing") {
effect != "oozing"
} else {
!in_tag("minecraft:ignores_poison_and_regen")
|| (effect != "regeneration" && effect != "poison")
}
}
impl Game<'_> {
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
@@ -462,7 +391,6 @@ impl Game<'_> {
self.tick_mob_effects();
if running {
self.tick_thrown_potions();
self.tick_potion_clouds();
}
}
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
@@ -478,10 +406,8 @@ impl Game<'_> {
}
// The omens act from inside the effect tick (BadOmenMobEffect,
// RaidOmenMobEffect).
let absorption_empty = player.health.absorption() <= 0.0;
let mut effects = std::mem::take(&mut self.potions.effects);
let (actions, raid_omen_added) =
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e));
self.potions.effects = effects;
if raid_omen_added {
self.raid_omen_added();
@@ -622,176 +548,29 @@ impl Game<'_> {
let state =
self.world
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let mob = self.mobs.iter().find(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
});
if self.res.facts().get(state).full_collision || mob.is_some() {
hit = Some((p, mob.map(|m| m.position)));
if self.res.facts().get(state).full_collision
|| self.mobs.iter().any(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
})
{
hit = Some(p);
break;
}
}
if let Some((p, mob)) = hit {
landed.push((t.stack.clone(), p, mob, t.owner.clone()));
if let Some(p) = hit {
landed.push((t.stack.clone(), p, t.owner.clone()));
return false;
}
t.position = next;
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at, mob, owner) in landed {
self.splash(&stack, at, mob, owner);
}
}
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
/// tick's particles, then the server tick.
fn tick_potion_clouds(&mut self) {
if self.breath_clouds.iter().all(|c| c.potion.is_none()) {
return;
}
let players: Vec<FightPlayer> = self
.fight_players()
.into_iter()
.filter(|p| p.alive)
.collect();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players));
self.breath_clouds.append(&mut clouds);
}
/// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its
/// wait it shrinks, and every 5 ticks it affects the living entities in
/// its box and radius that were not affected in the last 20 ticks, each
/// taking some of its radius. False once it is gone.
fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool {
c.age += 1;
let Some(potion) = c.potion.as_mut() else {
return true;
};
if c.age - LINGERING_WAIT >= c.duration {
return false;
}
let waiting = c.age < LINGERING_WAIT;
cloud_client_tick(
&mut self.particles,
c.at,
c.radius,
waiting,
&potion.particle(),
);
if waiting {
return true;
}
// setRadiusPerTick(-radius / duration).
c.radius += -3.0 / 600.0;
if c.radius < 0.5 {
return false;
}
if c.age % 5 != 0 {
return true;
}
potion.victims.retain(|(_, until)| c.age < *until);
let r = f64::from(c.radius);
let area = Aabb::new(
Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r),
Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r),
);
let mut inside = Vec::new();
for p in players.iter().filter(|p| p.bounds.intersects(area)) {
inside.push((Victim::Player(p.who), p.position.x, p.position.z));
}
let effects = potion.effects;
let tags = &self.res.stations().enchantments.entity_tags;
// Only mobs that at least one of the cloud's effects can affect are
// taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so
// the others neither become victims nor use up its radius.
for m in self.mobs.iter().filter(|m| {
m.alive()
&& m.bounds().intersects(area)
&& effects
.iter()
.any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id))
}) {
inside.push((Victim::Mob(m.id), m.position.x, m.position.z));
}
for (victim, x, z) in inside {
let (dx, dz) = (x - c.at[0], z - c.at[2]);
if potion.victims.iter().any(|(v, _)| *v == victim)
|| dx * dx + dz * dz > f64::from(c.radius * c.radius)
{
continue;
}
potion.victims.push((victim, c.age + REAPPLICATION_DELAY));
self.cloud_affect(victim, effects, c.at, &potion.source);
// setRadiusOnUse(-0.5).
c.radius -= 0.5;
if c.radius < 0.5 {
return false;
}
}
true
}
/// What a cloud puts on one entity: instant effects at half strength
/// (`applyInstantaneousEffect` with 0.5), the others for their duration
/// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`).
fn cloud_affect(
&mut self,
victim: Victim,
effects: &'static [(&'static str, i32, i32)],
at: [f64; 3],
source: &Text,
) {
let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1);
match victim {
Victim::Player(who) => self.as_fight_player(who, |g| {
for (id, duration, amp) in effects {
let Some(e) = brewing::effect(id) else {
continue;
};
if e.instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
g.apply_indirect_effect_action(a, source);
}
} else {
g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp));
}
}
}),
Victim::Mob(mob) => {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
// Undead invert healing and harming.
let tags = &self.res.stations().enchantments.entity_tags;
let inverted = tags
.get("minecraft:inverted_healing_and_harm")
.is_some_and(|t| t.contains(&self.mobs[i].kind.id));
for (id, duration, amp) in effects {
let m = &mut self.mobs[i];
match *id {
"instant_damage" | "instant_health" => {
let amount =
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
if (*id == "instant_damage") != inverted {
m.hurt(amount(6), Some(at), &mut self.world.random);
} else {
m.health = (m.health + amount(4)).min(m.kind.max_health);
}
}
_ => {
// `LivingEntity.addEffect` refuses what the mob
// cannot be affected by.
if mob_can_be_affected(tags, &m.kind.id, id)
&& let Some(name) = mob_effect_id(id)
{
m.add_effect(name, scaled(*duration), *amp);
}
}
}
}
}
for (stack, at, owner) in landed {
self.splash(&stack, at, owner);
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
@@ -893,23 +672,12 @@ impl Game<'_> {
self.submit_item_world(&state, pose, vertex_light(light), out);
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
/// potion or a cloud): indirectMagic, not plain magic.
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity("minecraft:indirect_magic", source.clone());
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
}
/// A splash potion's effects on the player in place, by their distance.
fn splash_on_player(
&mut self,
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
scale: f64,
source: &Text,
) {
if let Some(p) = self.player.as_ref() {
@@ -926,10 +694,22 @@ impl Game<'_> {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
self.apply_indirect_effect_action(a, source);
// HealOrHarmMobEffect.applyInstantaneousEffect with
// a source: indirectMagic, not plain magic.
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity(
"minecraft:indirect_magic",
source.clone(),
);
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
}
} else {
let d = (intensity * f64::from(*duration) + 0.5) as i32;
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
@@ -940,9 +720,8 @@ impl Game<'_> {
}
}
}
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
/// for a lingering potion; `mob` is where it hit a mob.
fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option<Vec3>, owner: Option<Text>) {
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
// ThrownExperienceBottle.onHit: the water-coloured splash (level
@@ -966,41 +745,23 @@ impl Game<'_> {
let color = brewing::potion_color(stack.potion());
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
let scale = if lingering { 0.25 } else { 1.0 };
let Some(def) = potion else {
return;
};
if lingering {
// The cloud sits at the mob it hit (its feet), else the impact;
// radius 3, wait 10, 600 ticks, shrinking to nothing, and a
// potion with no effects leaves none.
if !def.effects.is_empty() {
let at = mob.unwrap_or(at);
self.breath_clouds.push(Breath {
id: 0,
at: [at.x, at.y, at.z],
radius: 3.0,
age: 0,
duration: 600,
from_dragon: false,
potion: Some(PotionCloud {
effects: def.effects,
color,
victims: Vec::new(),
source: owner.unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
}),
});
}
return;
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
// Instant damage is indirectMagic(potion, owner), which the death
// message names by the owner, else by the potion.
let source =
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
self.for_each_player(|g| g.splash_on_player(def.effects, at, &source));
// Instant damage is indirectMagic(potion or cloud, owner), which the
// death message names by the owner, else by the potion or cloud.
let source = owner.unwrap_or_else(|| {
let entity = if lingering {
"entity.minecraft.area_effect_cloud"
} else {
"entity.minecraft.splash_potion"
};
Text::translate(entity, &[])
});
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations().enchantments.entity_tags;
let mut hits = Vec::new();
@@ -1023,7 +784,7 @@ impl Game<'_> {
_ => {
// `Potion.applySplash`: timed effects last in
// proportion to how close the mob was.
let d = (intensity * f64::from(*duration) + 0.5) as i32;
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
if d > 20
&& let Some(name) = mob_effect_id(id)
{
+2 -28
View File
@@ -53,11 +53,7 @@ impl Game<'_> {
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
self.extinguish_sound(at);
Ok(())
}
/// Entity.playEntityOnFireExtinguishedSound
fn extinguish_sound(&mut self, at: [f64; 3]) {
// Entity.playEntityOnFireExtinguishedSound
let r = self.particles.random();
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
self.audio.play(
@@ -66,29 +62,7 @@ impl Game<'_> {
0.7,
pitch,
);
}
/// `Entity.applyEffectsFromBlocks`: a burning player in the rain
/// (`isInRain`) has the fire put out (`clearFire`), with the extinguish
/// sound that everyone near hears.
pub(super) fn rain_extinguish_player(&mut self) {
let Some(player) = self.player.as_ref() else {
return;
};
let (at, top) = (player.position, player.bounds().max_position().y);
if player.fire_ticks <= 0 || !self.in_rain(at, top) {
return;
}
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
self.extinguish_sound([at.x, at.y, at.z]);
}
/// The in-window player's rain extinguish (a server's player has its
/// fire put out by the server).
pub(super) fn rain_extinguish_in_frame(&mut self) {
if self.remote.is_none() {
self.rain_extinguish_player();
}
Ok(())
}
/// Whether the block at `pos` is powder snow.
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
-21
View File
@@ -1036,7 +1036,6 @@ impl Game<'_> {
/// read it).
fn sync_villager_raids(&mut self, infos: &HashMap<i32, Vec<RaiderInfo>>) {
let raids = &self.raiding.raids;
let mut ongoing = Vec::new();
for m in &mut self.mobs {
if m.species() != Species::Villager {
continue;
@@ -1062,27 +1061,7 @@ impl Game<'_> {
let ext = mobs_villager::ext_mut(m);
ext.raid = status;
ext.raid_wave = wave;
if !m.no_ai && matches!(status, mobs_villager::PRE_RAID | mobs_villager::RAID) {
ongoing.push(m.id);
}
}
// Villager.customServerAiStep: a 1 in 100 chance each tick, in an
// ongoing raid, of entity event 42 (`handleEntityEvent` splashes).
for id in ongoing {
if self.particles.random().next_int_bound(100) == Ok(0) {
self.mob_raid_splash(id);
}
}
}
/// Entity event 42 for villager `id`: the guests get the byte, and this
/// game draws `AbstractVillager.addParticlesAroundSelf(SPLASH)` itself.
fn mob_raid_splash(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
self.lan_entity_event(id as i32, 42, at);
self.particles_around_mob(id, "splash", horizon_client::particles::Burst::VILLAGER);
}
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
+7 -63
View File
@@ -1954,22 +1954,6 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
(7, Value::Int(ticks)) => m.ticks_frozen = *ticks,
// Strider.DATA_SUFFOCATING (after AgeableMob's two
// and DATA_BOOST_TIME): a cold strider shakes.
(19, Value::Bool(cold))
if m.species() == horizon_client::mobs::Species::Strider =>
{
m.extra.flag = *cold;
}
_ => {}
}
}
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
// too, remote_mob_data's apply reads index 8 above.
let mannequin = self
@@ -2068,22 +2052,13 @@ impl Game<'_> {
// player holds and wears (EquipmentSlot ordinals).
C::SetEquipment(p) => {
self.remote_object_equipment(p.id, &p.slots);
let items: Vec<(u8, Option<Arc<str>>, Option<ItemStack>)> = p
let items: Vec<(u8, Option<Arc<str>>)> = p
.slots
.iter()
.map(|(slot, stack)| {
(
*slot,
stack.as_ref().map(|s| self.item_name(s.item)),
self.to_stack(stack),
)
})
.map(|(slot, stack)| (*slot, stack.as_ref().map(|s| self.item_name(s.item))))
.collect();
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
for (slot, item, stack) in items {
for (slot, item) in items {
let e = &mut m.extra;
match slot {
0 => e.main_hand = item,
@@ -2091,9 +2066,7 @@ impl Game<'_> {
2 => e.feet = item,
3 => e.legs = item,
4 => e.chest = item,
5 if !body => e.head = item,
6 if body => e.head = item,
7 => m.set_saddle(stack),
5 => e.head = item,
_ => {}
}
}
@@ -2212,9 +2185,6 @@ impl Game<'_> {
if p.event == super::totem::TOTEM_EVENT {
self.remote_totem(p.id);
}
if p.event == super::combat::DROWN_EVENT {
self.remote_drown(p.id);
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
@@ -3414,12 +3384,13 @@ impl Game<'_> {
let events = std::mem::take(&mut player.events);
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission {
state, in_water, ..
} => {
} if !crouching => {
let sounds = self.res.facts().get(state).sounds.clone();
// Player.playStepSound: muffled under the swim sound in water.
let (volume, pitch) = if in_water {
@@ -3433,14 +3404,6 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
horizon_client::motion::MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
@@ -3615,15 +3578,11 @@ impl Game<'_> {
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
let mut wither_heads = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
continue;
}
if m.species() == horizon_client::mobs::Species::Wither {
wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i)));
}
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
@@ -3696,7 +3655,7 @@ impl Game<'_> {
m.extra.main_hand.clone()
};
if let Some(item) = item {
let eye = m.eye_height();
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
let at = [m.position.x, m.position.y, m.position.z];
using.push((
item,
@@ -3754,24 +3713,10 @@ impl Game<'_> {
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
// WitherBoss.aiStep runs on a client too: smoke around each head.
let smoke = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Smoke,
);
for h in wither_heads {
let r = self.particles.random();
let at = [
h[0] + r.next_gaussian() * 0.3,
h[1] + r.next_gaussian() * 0.3,
h[2] + r.next_gaussian() * 0.3,
];
self.particles.add(&smoke, at, [0.0; 3]);
}
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
@@ -4208,7 +4153,6 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
@@ -17,12 +17,6 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
match (*index, value) {
// Entity.DATA_POSE: a player's or mannequin's box follows it.
(6, Value::Id(pose))
if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") =>
{
m.extra.avatar_pose = horizon_client::pose::Pose::from_id(*pose);
}
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
@@ -66,11 +66,8 @@ impl Game<'_> {
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Axolotl => item == "minecraft:water_bucket",
// Tadpole.mobInteract: frog food, else the bucket.
Species::Tadpole => {
item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item)
}
| Species::Axolotl
| Species::Tadpole => item == "minecraft:water_bucket",
Species::Armadillo => item == "minecraft:brush",
// ZombieVillager.mobInteract: CONSUME for a golden apple.
Species::ZombieVillager => item == "minecraft:golden_apple",
@@ -23,58 +23,6 @@ fn default_cloud_particle() -> ParticleOptions {
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles
/// near the middle; then about one per square block of the disc, drifting
/// (an entity effect stays still).
pub(super) fn cloud_client_tick(
particles: &mut horizon_client::particles::ParticleEngine,
at: [f64; 3],
radius: f32,
waiting: bool,
particle: &ParticleOptions,
) {
if waiting && particles.random().next_boolean() {
return;
}
let (count, spread) = if waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * radius * radius).ceil() as i32,
radius,
)
};
for _ in 0..count {
let r = particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
at[0] + f64::from(angle.cos() * distance),
at[1],
at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if particle.ty == ParticleType::EntityEffect {
let default = waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
particle.clone()
};
(options, [0.0; 3])
} else if waiting {
(particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(particle.clone(), v)
};
particles.add(&options, at, velocity);
}
}
/// The projectile a type id names, as Horizon models it.
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
Some(match kind {
@@ -129,36 +77,8 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
}
p.velocity.y -= 0.05;
}
/// A server's client ticks a projectile itself: it flies on between the
/// tracker's updates (10 to 20 ticks apart) with its own gravity and drag,
/// and a block stops it there; only the server decides what it hits.
pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
if !matches!(p.tick(w).1, Hit::None) {
p.velocity = Vec3::ZERO;
}
}
/// `AbstractHurtingProjectile.getTrailParticle`: smoke for every one of them
/// but the wind charges (no trail) and the dragon's fireball (its own).
pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. }
)
}
impl Game<'_> {
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
/// block above each position.
pub(super) fn smoke_trails(&mut self, at: impl IntoIterator<Item = Vec3>) {
let smoke = ParticleOptions::simple(ParticleType::Smoke);
for p in at {
self.particles.add(&smoke, [p.x, p.y + 0.5, p.z], [0.0; 3]);
}
}
/// `handleAddEntity` for these types; false when `kind` isn't one.
pub(super) fn spawn_remote_projectile(
&mut self,
@@ -251,7 +171,7 @@ impl Game<'_> {
p.velocity = velocity;
p.yaw = yaw;
p.pitch = pitch;
self.add_projectile(p);
self.creatures.projectiles.push(p);
}
}
true
@@ -427,19 +347,15 @@ impl Game<'_> {
p.age += 1;
p.position = p.position.add_vector(p.velocity);
}
_ => remote_projectile_tick(p, &world),
_ => {
if !matches!(p.tick(&world).1, Hit::None) {
p.velocity = Vec3::ZERO;
}
}
}
}
}
self.creatures.projectiles = projectiles;
let trails: Vec<Vec3> = self
.creatures
.projectiles
.iter()
.filter(|p| has_smoke_trail(&p.kind))
.map(|p| p.position)
.collect();
self.smoke_trails(trails);
for t in &mut self.tridents {
let id = t.id;
t.remote_step(|at| step(id, at));
@@ -498,10 +414,52 @@ impl Game<'_> {
}
}
/// `AreaEffectCloud.clientTick` for each cloud a server tracks.
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
/// particles near the middle; then about one per square block of the
/// disc, drifting (an entity effect stays still).
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle);
let r = self.particles.random();
if c.waiting && r.next_boolean() {
continue;
}
let (count, spread) = if c.waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
c.radius,
)
};
for _ in 0..count {
let r = self.particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
c.at[0] + f64::from(angle.cos() * distance),
c.at[1],
c.at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
let default = c.waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
c.particle.clone()
};
(options, [0.0; 3])
} else if c.waiting {
(c.particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
}
}
}
@@ -575,68 +533,3 @@ impl Game<'_> {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::mobs::Who;
use horizon_core::geometry::Aabb;
/// A world with nothing in it: the client tick of a projectile only
/// needs the blocks around it.
struct Empty;
impl ProjectileWorld for Empty {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Vec3) -> bool {
false
}
fn targets(&self) -> &[(Who, Aabb)] {
&[]
}
}
/// A thrown item, as a server's client tracks it: the spawn position and
/// the motion the server gave it.
fn thrown(kind: ProjectileKind) -> Projectile {
let mut p = Projectile::new(kind, Vec3::new(0.0, 100.0, 0.0), None);
p.velocity = Vec3::new(1.5, 0.0, 0.0);
p
}
#[test]
fn a_thrown_item_flies_on_between_the_server_updates() {
// A thrown egg, an ender pearl and a wind charge (#1762): the server
// sends their position every 10 ticks, and the client ticks them
// itself in between, as vanilla's no-op interpolation handler does.
for kind in [
ProjectileKind::Egg,
ProjectileKind::EnderPearl,
ProjectileKind::WindCharge,
] {
let mut p = thrown(kind);
remote_projectile_tick(&mut p, &Empty);
assert!(p.position.x > 1.0, "the projectile did not fly on: {p:?}");
// A second tick moves it further: it keeps flying, not a hop.
let first = p.position.x;
remote_projectile_tick(&mut p, &Empty);
assert!(p.position.x > first, "the projectile stopped: {p:?}");
}
}
#[test]
fn a_thrown_item_falls_and_drags_like_a_throwable_projectile() {
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99.
let mut p = thrown(ProjectileKind::Egg);
remote_projectile_tick(&mut p, &Empty);
assert!((p.velocity.x - 1.5 * 0.99).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, "{p:?}");
// A wind charge keeps its speed: no gravity and no drag
// (AbstractWindCharge: acceleration 0, inertia 1).
let mut p = thrown(ProjectileKind::WindCharge);
remote_projectile_tick(&mut p, &Empty);
assert!((p.velocity.x - 1.5).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - 0.0).abs() < 1e-9, "{p:?}");
}
}
@@ -288,11 +288,10 @@ impl Game<'_> {
e[2].floor() as i32,
];
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
// The Horizon Weather option off means no rain visuals at all.
if rain > 0.0 && self.settings.horizon.weather {
// ClientLevel.tickWeatherEffects: rain splashes within the weather radius.
if rain > 0.0 {
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
let radius = self.settings.weather_radius;
let radius = 10;
let diameter = 2 * radius + 1;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
for _ in 0..count {
+2 -8
View File
@@ -139,10 +139,8 @@ impl Game<'_> {
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
// Orbs and projectiles have no UUID here.
// Orbs and projectiles are addressed by index and have no UUID here.
Target::Orb(_) | Target::Projectile(_) => None,
}
}
@@ -190,11 +188,7 @@ impl Game<'_> {
| Target::Cart(id)
| Target::Other(id) => self.persist.raw.get(&id),
Target::Player => self.level.player_extra.as_ref(),
Target::Guest(_)
| Target::Orb(_)
| Target::Projectile(_)
| Target::Crystal(_)
| Target::Dragon(_) => None,
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
};
match raw.and_then(|r| r.get("Tags")) {
Some(Tag::List(l)) => l
+2 -2
View File
@@ -1194,14 +1194,14 @@ impl Game<'_> {
/// `LightmapRenderStateExtractor`: the night vision effect's intensity
/// (`GameRenderer.nightVisionScale`), or conduit power's water vision.
pub(super) fn night_vision_intensity(&self, partial: f32) -> f32 {
pub(super) fn night_vision_intensity(&self) -> f32 {
use horizon_client::fog::FogType;
if let Some(e) = self.potions.effects.get("night_vision") {
// MobEffectInstance.endsWithin(200); -1 is infinite.
return if e.duration < 0 || e.duration > 200 {
1.0
} else {
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
0.7 + (e.duration as f32 * std::f32::consts::PI * 0.2).sin() * 0.3
};
}
if self.potions.effects.get("conduit_power").is_some()
+16 -96
View File
@@ -3,7 +3,7 @@
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
//! server list's setting, downloads into `downloads/`, and a resource reload
//! with the server's packs on top of the player's own.
use super::{Game, Progress};
use super::Game;
use horizon_client::{
Result,
confirm_screen::Confirm,
@@ -82,9 +82,6 @@ pub(super) struct ServerPackState {
pub(super) active: Vec<packs::Pack>,
/// The download batches' notifiers, oldest first.
notifiers: Vec<PackNotifier>,
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
/// frame draws its progress until `tick_server_packs` applies it.
pub(super) reload: Option<PackReload>,
}
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
/// own `SystemToast`.
@@ -194,21 +191,6 @@ fn saves() -> PathBuf {
std::env::current_dir().unwrap_or_default().join("saves")
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
const RELOAD_LINES: [&str; 4] = [
"Preparing resources",
"Loading assets",
"Loading pack tables",
"Applying resources",
];
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
/// worker while `progress` feeds the loading screen, then the apply lands
/// on the render thread.
pub(super) struct PackReload {
pub(super) progress: Progress,
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
}
impl Game<'_> {
/// `ConnectScreen`: the server's setting decides what its packs do
/// (`configureForServerControl`); single player's own server takes them
@@ -332,7 +314,6 @@ impl Game<'_> {
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
}
self.drive_pack_reload();
let finished = self
.server_packs
.queue
@@ -347,8 +328,13 @@ impl Game<'_> {
if !tick.download.is_empty() {
self.start_pack_downloads(tick.download);
}
if let Some(load) = tick.reload {
self.start_pack_reload(&load);
if let Some(mut load) = tick.reload {
while let Some(recovery) = {
let ok = self.reload_server_packs(&load);
self.server_packs.packs.reloaded(ok)
} {
load = recovery;
}
}
}
for (id, status) in self.server_packs.packs.take_replies() {
@@ -393,90 +379,24 @@ impl Game<'_> {
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
/// own, staged on a worker (`Minecraft.reloadResourcePacks` under a
/// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no
/// readable `pack.mcmeta` fails the reload at once, as `onFailure`.
fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) {
let server: Option<Vec<_>> = load
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
/// readable `pack.mcmeta` or the reload failed.
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
let packs: Option<Vec<_>> = load
.iter()
.map(|(id, path)| packs::server_pack(*id, path))
.collect();
let Some(server) = server else {
let Some(packs) = packs else {
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
self.finish_pack_reload(false);
return;
return false;
};
self.server_packs.active = server.clone();
// The overlay swallows input until the apply (`LoadingOverlay`):
// release what is held so nothing keeps acting while it covers the
// frame (`KeyMapping.releaseAll` when a screen opens).
self.keys.clear();
self.physical_keys.clear();
self.left = false;
self.right = false;
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let (selected, mods, dev_mods) = (
self.settings.resource_packs.clone(),
self.settings.mods.enabled.clone(),
self.options.dev_mods.clone(),
);
let (assets, res, context) = (
self.pack.assets.clone(),
self.res.clone(),
self.pack.context.clone(),
);
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
let progress = Progress::with(&RELOAD_LINES);
let worker = progress.clone();
let handle = std::thread::Builder::new()
.name("server-pack-reload".into())
.spawn(move || {
super::packs::stage_pack(
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
unicode, japanese, &worker,
)
.map_err(|e| e.to_string())
});
match handle {
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
Err(e) => {
log::error!("Horizon: could not start the pack reload worker: {e}");
self.finish_pack_reload(false);
}
}
}
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
/// worker is done; on failure the packs active before reload again.
fn drive_pack_reload(&mut self) {
let Some(reload) = &self.server_packs.reload else {
return;
};
if !reload.handle.is_finished() {
return;
}
let reload = self.server_packs.reload.take().unwrap();
let staged = reload
.handle
.join()
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
let ok = match staged
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
{
self.server_packs.active = packs;
match self.reload_selected_packs() {
Ok(()) => true,
Err(e) => {
log::warn!("Horizon: server resource pack reload failed: {e}");
false
}
};
self.finish_pack_reload(ok);
}
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
fn finish_pack_reload(&mut self, ok: bool) {
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
self.start_pack_reload(&recovery);
}
}
+1 -153
View File
@@ -5,8 +5,7 @@ use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
items::ItemStack,
signs::{DYE_TEXT, wood_type},
signs::wood_type,
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -32,157 +31,6 @@ fn sign_box() -> EditBox {
}
impl Game<'_> {
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
/// front of the sign's text (within 90 degrees of its facing).
fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool {
let Some(player) = self.player.as_ref() else {
return true;
};
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(state) = self.pack.assets.registry.state(state) else {
return true;
};
let prop = |key: &str| state.properties.get(key).map(String::as_str);
// getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or
// ceiling sign's rotation segment.
let y_rot = if state.name.contains("_wall_") {
match prop("facing") {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
}
} else {
prop("rotation")
.and_then(|r| r.parse::<f32>().ok())
.unwrap_or(0.0)
* 22.5
};
// getSignHitboxCenterPosition: only a wall sign's board is off-centre,
// against the wall it hangs on.
let centre = if state.name.ends_with("_wall_sign") {
match y_rot as i32 {
90 => (0.9375, 0.5),
180 => (0.5, 0.9375),
270 => (0.0625, 0.5),
_ => (0.5, 0.0625),
}
} else {
(0.5, 0.5)
};
let xd = player.position.x - (f64::from(pos.0) + centre.0);
let zd = player.position.z - (f64::from(pos.2) + centre.1);
let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32;
// Mth.degreesDifferenceAbs.
let diff = (player_y_rot - y_rot).rem_euclid(360.0);
let diff = if diff >= 180.0 { 360.0 - diff } else { diff };
diff <= 90.0
}
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed
/// sign plays the failed-interaction sound; otherwise a player who may
/// build edits the side they face.
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return;
};
if sign.waxed {
// `getSignInteractionFailedSoundEvent`: a hanging sign has its own.
let sound = if sign.hanging {
"minecraft:block.hanging_sign.waxed_interact_fail"
} else {
"minecraft:block.sign.waxed_interact_fail"
};
self.audio
.play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0);
} else if self.may_build() {
let front = self.facing_sign_front(key);
self.open_sign_editor(key, front);
}
}
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
/// honeycomb): the item changes the side the player faces and is used up.
/// Whether it did; a sign it does not apply to goes on to the empty hand.
pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let Some(stack) = held else {
return false;
};
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return false;
};
if sign.waxed || !self.may_build() {
return false;
}
let item = &*stack.item;
let dye = if item.ends_with("_dye") {
self.res
.items()
.get(&stack.item)
.and_then(|i| i.dye.clone())
} else {
None
};
let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d));
if !matches!(
item,
"minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac"
) && colour.is_none()
{
return false;
}
let front = self.facing_sign_front(key);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
return false;
};
let centre = pos.map(|v| f64::from(v) + 0.5);
let sound = if item == "minecraft:honeycomb" {
// HoneycombItem.tryApplyToSign: waxes any sign, empty or not.
sign.waxed = true;
"minecraft:item.honeycomb.wax_on"
} else {
let side = if front {
&mut sign.front
} else {
&mut sign.back
};
// SignApplicator.canApplyToSign: the side must have text.
if side.is_empty() {
return false;
}
match colour {
Some(c) if side.color != c as u8 => {
side.color = c as u8;
"minecraft:item.dye.use"
}
None if item == "minecraft:glow_ink_sac" && !side.glowing => {
side.glowing = true;
"minecraft:item.glow_ink_sac.use"
}
None if item == "minecraft:ink_sac" && side.glowing => {
side.glowing = false;
"minecraft:item.ink_sac.use"
}
_ => return false,
}
};
if item == "minecraft:honeycomb" {
self.level_event_at(3003, pos, 0);
}
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.mark_block_entity_updated(key);
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event(
"minecraft:block_change",
pos,
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.consume_held();
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `SignBlock.openTextEdit` for the front side after placement.
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
self.sign_edit = Some(SignEditor {
+1 -1
View File
@@ -253,7 +253,7 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// `LivingEntity.makePoofParticles` (entity event 20) for a mob.
pub(super) fn mob_poof(&mut self, id: u32) {
fn mob_poof(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
@@ -546,11 +546,6 @@ impl Loading {
(batch.width, batch.height),
self.menu.modifiers.shift_key(),
);
title.press_rules(
&gui.atlas,
self.menu.cursor,
(batch.width, batch.height),
);
self.menu_action(event_loop, action);
}
}
+10 -22
View File
@@ -256,8 +256,9 @@ impl Game<'_> {
.fluid
== horizon_client::blocks::Fluid::Water;
let on_ground = p.ground.on_ground;
let (sprinting, gliding, swimming) = (
let (sprinting, flying, gliding, swimming) = (
p.sprinting,
p.flying,
p.fall_flying,
p.pose == horizon_client::pose::Pose::Swimming,
);
@@ -271,27 +272,15 @@ impl Game<'_> {
});
let s = &mut self.stats;
match riding {
Some(Ok(sp)) => {
use horizon_client::mobs::Species;
// `checkRidingStatistics`: only these mounts count, the horse
// family by the full 3D distance; any other mount nothing.
match sp {
Species::Pig => s.custom("pig_one_cm", total),
Species::Strider => s.custom("strider_one_cm", total),
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
Species::Nautilus | Species::ZombieNautilus => {
s.custom("nautilus_one_cm", total)
}
sp if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", total)
}
_ => {}
}
Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total),
Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total),
Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", horizontal)
}
Some(Err(())) => {
Some(_) => {
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
s.custom("boat_one_cm", total)
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
} else {
s.custom("minecart_one_cm", total)
}
}
@@ -311,9 +300,8 @@ impl Game<'_> {
}
}
None if gliding => s.custom("aviate_one_cm", total),
// Airborne and not gliding: more than 25 cm sideways per move
// (creative flight, or a long jump), not only flying.
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
None if flying => s.custom("fly_one_cm", horizontal),
None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32),
None => {}
}
}
+6 -24
View File
@@ -245,7 +245,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talked_to_villager", 1);
self.stats.custom("talk_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -398,21 +398,18 @@ impl Game<'_> {
.as_ref()
.and_then(|s| s.merchant.as_ref())
.and_then(|m| m.mob);
let (session, traded) = self.merchant_apply(session, mob);
self.merchant_traded(mob, &traded);
let session = self.merchant_apply(session, mob);
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
m.session = session;
}
}
/// What a menu click's session did, applied to the world and its mob;
/// with the result of each trade made, for `merchant_traded`.
/// What a menu click's session did, applied to the world and its mob.
pub(super) fn merchant_apply(
&mut self,
session: Box<merchant::Session>,
mob: Option<u32>,
) -> (Box<merchant::Session>, Vec<ItemStack>) {
) -> Box<merchant::Session> {
let mut session = session;
let mut traded = Vec::new();
let events = std::mem::take(&mut session.events);
let (villager, origin) = (session.villager, session.origin);
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
@@ -441,26 +438,11 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(index) => {
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
}
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
merchant::Event::TradeSound => {}
}
}
(session, traded)
}
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
/// for each trade made: the statistic and the `villager_trade` trigger.
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
for result in results {
self.stats.custom("traded_with_villager", 1);
let villager = mob
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
if let Some(villager) = villager {
self.trigger_trade(&villager, result);
}
}
session
}
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
/// trade, a tick later; the prices follow the new reputation.
+4 -4
View File
@@ -21,7 +21,7 @@ pub(super) struct ThrownTrident {
pub id: u32,
pub(super) stack: ItemStack,
pub(super) position: Vec3,
pub(super) previous: Vec3,
previous: Vec3,
pub(super) velocity: Vec3,
pub(super) yaw: f32,
pub(super) pitch: f32,
@@ -112,7 +112,7 @@ impl Game<'_> {
}
true
}
fn in_water_or_rain(&mut self) -> bool {
fn in_water_or_rain(&self) -> bool {
let Some(p) = &self.player else { return false };
let (x, y, z) = (
p.position.x.floor() as i32,
@@ -121,8 +121,8 @@ impl Game<'_> {
);
let water = self.res.facts().get(self.world.block(x, y, z)).fluid
== horizon_client::blocks::Fluid::Water;
let (at, top) = (p.position, p.bounds().max_position().y);
water || self.in_rain(at, top)
let sky = self.world.light(x, y + 1, z) >> 4 == 15;
water || (self.world.weather.raining && sky)
}
/// The living entities a spinning player can touch this tick, for
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
+1 -1
View File
@@ -68,7 +68,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.add_projectile(proj);
self.creatures.projectiles.push(proj);
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
self.audio
.play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch);
@@ -94,8 +94,6 @@ impl WorldRequest {
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
level.allow_commands = Some(o.commands(creative));
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
// WorldCreationUiState.getGameRules, written with the level.
level.game_rules = o.rules.by_name();
}
// Worlds made without the Create New World screen (--world, the
// control socket) take its default: commands on for Creative only.
+40 -103
View File
@@ -37,10 +37,7 @@ mod crumbling_tests;
mod entity_batch;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{
SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
};
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -250,8 +247,6 @@ pub struct Frame<'a> {
pub outline: Vec<[f64; 6]>,
/// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain.
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -382,7 +377,6 @@ pub struct Renderer {
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
lines_secondary: wgpu::RenderPipeline,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// always-on-top ones.
debug_lines: wgpu::RenderPipeline,
@@ -397,7 +391,6 @@ pub struct Renderer {
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
shadow_blob_pipeline: wgpu::RenderPipeline,
translucent_item_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
water_mask_pipeline: wgpu::RenderPipeline,
cloud_pipeline: wgpu::RenderPipeline,
@@ -1220,6 +1213,12 @@ impl Renderer {
attributes: &instance_attributes,
}),
];
let line_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3];
let line_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &line_attributes,
})];
let alpha_blend = wgpu::BlendState::ALPHA_BLENDING;
let debug_line_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
@@ -1352,25 +1351,13 @@ impl Renderer {
"Block outline",
"vs_line",
"fs_line",
&debug_line_buffers,
&line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
true,
true,
Some(alpha_blend),
);
// RenderTypes.secondaryBlockOutline: the black stroke under a high contrast outline.
let lines_secondary = pipeline(
"Block outline secondary",
"vs_line",
"fs_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
true,
Some(alpha_blend),
);
let debug_lines = pipeline(
"Debug lines",
"vs_debug_line",
@@ -1544,7 +1531,7 @@ impl Renderer {
});
let outline = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Outline"),
size: 32 * 2 * MAX_OUTLINE_EDGES,
size: 24 * MAX_OUTLINE_EDGES,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -1831,40 +1818,25 @@ impl Renderer {
false,
);
// RenderPipelines.ENTITY_SHADOW: translucent, depth test without
// writes. The quads lie exactly on the block tops like vanilla's, but
// terrain and shadows are separate draw paths, so their fp32 depth
// interpolations disagree by a few dozen ulps of the fragment depth
// and the shadow's rim z-fights with the block top (speckle that
// re-rolls as the camera drifts a subpixel). A constant bias of 128
// ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto
// the feet.
// writes. The quads lie on the block tops like vanilla's, but the
// terrain under them snaps to the subpixel grid at other vertices, so
// at a slant their depths differ by up to 1/256 of a pixel's depth
// slope and whole shadows failed the test. The bias covers that and
// the rounding, and stays below the 0.001 a model's feet sit higher
// (1/27 of a pixel's slope at the 16-block shadow range in 720p).
let shadow_blob_pipeline = entity_pipeline(
"Entity shadows",
None,
false,
wgpu::CompareFunction::GreaterEqual,
wgpu::DepthBiasState {
constant: 128,
constant: 16,
slope_scale: 0.015,
clamp: 0.0,
},
alpha_blend,
false,
);
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
// translucent terrain, it cannot write depth: the water behind a
// glass item would fail the test and vanish (vanilla sorts these in
// its order independent transparency pass instead).
let translucent_item_pipeline = entity_pipeline(
"Translucent items",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
alpha_blend,
false,
);
let cloud_pipeline = entity_pipeline(
"Clouds",
Some(wgpu::Face::Back),
@@ -2390,7 +2362,6 @@ impl Renderer {
sky,
dark_disc,
lines,
lines_secondary,
debug_lines,
debug_lines_on_top,
debug_line_buffer,
@@ -2401,7 +2372,6 @@ impl Renderer {
weather,
crumbling_pipeline,
shadow_blob_pipeline,
translucent_item_pipeline,
water_mask_pipeline,
cloud_pipeline,
cloud_mesh: Default::default(),
@@ -2513,18 +2483,6 @@ impl Renderer {
present_modes,
})
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
pass.set_vertex_buffer(0, self.outline.slice(..));
if high_contrast {
pass.set_pipeline(&self.lines_secondary);
pass.draw(0..4, 0..edges);
}
pass.set_pipeline(&self.lines);
let first = if high_contrast { edges } else { 0 };
pass.draw(0..4, first..first + edges);
}
fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
@@ -3237,7 +3195,7 @@ impl Renderer {
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
let levels = crate::sprites::sprite_mipmap_limit(
let levels = sprite_mipmap_limit(
levels,
self.sprite_textures[0].width(),
self.sprite_minimum_size,
@@ -3997,26 +3955,7 @@ impl Renderer {
);
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
// The same per-line colour and pixel width layout as the gizmo lines. A high
// contrast outline is two strokes: the black one first, the bright one after it.
let edges = outline_vertices(&frame.outline[..outline_edges], camera);
let width = outline_width(self.config.width);
let stroke = |color: u32, width: f32| {
edges.iter().map(move |e| {
let mut line = [0.0; 8];
line[..6].copy_from_slice(e);
line[6] = f32::from_bits(color);
line[7] = width;
line
})
};
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0)
.chain(stroke(0xff57_ffe1, width))
.collect()
} else {
stroke(0x6600_0000, width).collect()
};
let lines = outline_vertices(&frame.outline[..outline_edges], camera);
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
}
@@ -4043,7 +3982,6 @@ impl Renderer {
water_mask_quads,
shadow_quads,
translucent_particle_quads,
translucent_item_quads,
] = self
.entity_batch
.update(entities, frame.additive, frame.hand, frame.see_through);
@@ -4053,8 +3991,7 @@ impl Renderer {
+ translucent_crumbling_quads
+ water_mask_quads
+ shadow_quads
+ translucent_particle_quads
+ translucent_item_quads;
+ translucent_particle_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -4577,17 +4514,6 @@ impl Renderer {
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_item_quads * 6,
((world_quads - translucent_item_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if !pack_draws_weather {
self.weather.draw(&mut pass, &self.entity_group);
}
@@ -4605,11 +4531,7 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads)
* 4) as i32,
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4641,7 +4563,9 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 && !frame.outline_after_terrain {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
}
if debug_depth > 0 {
pass.set_pipeline(&self.debug_lines);
@@ -4688,8 +4612,7 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
as i32,
((world_quads - translucent_particle_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4697,7 +4620,9 @@ impl Renderer {
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
@@ -5316,7 +5241,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0, 1, 1, 0]);
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -5552,6 +5477,11 @@ mod tests {
}
}
pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, available: u32) -> u32 {
// Only the atlas native minimum determines how many resampling-only mips to skip.
let upscale = (size / minimum.max(1)).max(1).ilog2();
requested.saturating_add(upscale).min(available - 1)
}
#[cfg(test)]
mod sprite_upload_tests {
use super::*;
@@ -5612,6 +5542,13 @@ mod sprite_upload_tests {
}
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels);
}
}
#[test]
fn prepared_texture_uploads_preserve_layer_order_and_settings() {
let size = 4;
let layers = 5;
@@ -5,20 +5,14 @@ use super::Vertex;
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
const MATERIALS: usize = 7;
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
const MATERIALS: usize = 6;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
pub const SHADOW_OVERLAY: u32 = 14;
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
/// The overlay of item quads with translucent pixels (`Sheets.translucentItemSheet`,
/// `RenderPipelines.ITEM_TRANSLUCENT`); past the creeper swell range (16 to 31).
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
/// A translucent item with an enchantment glint.
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
#[derive(Default)]
pub(super) struct EntityBatch {
@@ -51,7 +45,6 @@ impl EntityBatch {
WATER_MASK_OVERLAY => 3,
SHADOW_OVERLAY => 4,
TRANSLUCENT_PARTICLE_OVERLAY => 5,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 6,
_ => 0,
};
self.buckets[material][counts[material]] = index;
-13
View File
@@ -204,19 +204,6 @@ impl Gizmos {
position[1] + f64::from(size.1),
position[2] + half,
];
self.entity_box(min, max, position, eye_height, living, view);
}
/// The same for an entity whose box is not centred on its position
/// (a painting or an item frame): `min` and `max` are the box.
pub fn entity_box(
&mut self,
min: [f64; 3],
max: [f64; 3],
position: [f64; 3],
eye_height: f32,
living: bool,
view: (f32, f32),
) {
self.cuboid(min, max, WHITE, STROKE_WIDTH);
if living {
let eye = min[1] + f64::from(eye_height);
+5 -9
View File
@@ -32,15 +32,14 @@ const STRENGTH: f32 = 0.7;
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
// across its channels, as the vignette and other screen-wide tints darken it.
// The hand, HUD and text replace pixels instead, so they never pass. The
// channels are held to one factor, the ratio of their sums, so a channel at 0
// stays 0; a step of 8-bit rounding is allowed on each.
// The hand, HUD and text replace pixels instead, so they never pass.
fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
if all(c == w) {
return true;
}
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
let k = c / max(w, vec3(1.0 / 255.0));
let mean = (k.r + k.g + k.b) / 3.0;
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.
@@ -70,10 +69,7 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine.
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b)));
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
+2 -42
View File
@@ -29,22 +29,12 @@ pub(crate) type State = HurtState<DamageTypeId>;
pub struct HealthRules {
pub difficulty: Difficulty,
pub natural_regeneration: bool,
/// The `drowning_damage`, `fall_damage`, `fire_damage` and
/// `freeze_damage` rules `Player.isInvulnerableTo` reads.
pub drowning_damage: bool,
pub fall_damage: bool,
pub fire_damage: bool,
pub freeze_damage: bool,
}
impl Default for HealthRules {
fn default() -> Self {
Self {
difficulty: Difficulty::Normal,
natural_regeneration: true,
drowning_damage: true,
fall_damage: true,
fire_damage: true,
freeze_damage: true,
}
}
}
@@ -643,13 +633,8 @@ impl ImmunityWorld for Environment<'_> {
fn enchantment_immunity(&mut self) -> io::Result<bool> {
Ok(false)
}
fn damage_rule(&mut self, rule: DamageRule) -> io::Result<bool> {
Ok(match rule {
DamageRule::Drowning => self.rules.drowning_damage,
DamageRule::Fall => self.rules.fall_damage,
DamageRule::Fire => self.rules.fire_damage,
DamageRule::Freeze => self.rules.freeze_damage,
})
fn damage_rule(&mut self, _: DamageRule) -> io::Result<bool> {
Ok(true)
}
fn changing_dimension(&mut self) -> io::Result<bool> {
Ok(false)
@@ -875,31 +860,6 @@ mod tests {
assert!(!third.hurt && !third.knockback);
}
/// #2141: an arrow that takes the last of the player's health must kill
/// them: `LivingEntity.hurt` leaves the health at zero and `Player.die`
/// follows.
#[test]
fn an_arrow_kills_a_player_at_one_health() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
let mut player =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.unwrap();
player.set_health(1.0, false).unwrap();
let (health, rules) = (&resources.health, super::HealthRules::default());
let id = health.damage.id("minecraft:arrow").unwrap();
let hurt = player.hurt(health, rules, id, 5.0).unwrap();
assert!(hurt, "the arrow must hurt");
assert_eq!(player.health.health(), 0.0, "the health must be spent");
assert!(player.health.dead_or_dying(), "the player must be dead");
}
#[test]
fn hurt_sounds_follow_damage_effects() {
// DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING.
-2
View File
@@ -912,8 +912,6 @@ impl HudBatch {
// StringDecomposer.iterateFormatted inside Font.drawInternal:
// `§` codes inside a span restyle what follows and are not drawn.
let text = text.resolved();
// Language.getVisualOrder: shaped and reordered for right-to-left text.
let text = crate::bidi::visual(&text);
let font = &atlas.font;
let alpha = (color >> 24) as u8;
let mut glyphs = Vec::new();
+2 -39
View File
@@ -46,9 +46,6 @@ pub struct ItemQuad {
pub foil: Foil,
/// `MaterialInfo.lightEmission` (block light floor).
pub light_emission: u8,
/// The quad's sprite has translucent pixels (`MaterialInfo.itemRenderType`
/// is a translucent sheet): drawn with the translucent entities.
pub translucent: bool,
}
/// Which glint render type a quad uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -162,7 +159,7 @@ impl ItemRenderState {
let m = pose * layer.matrix;
match &layer.geometry {
LayerGeometry::Quads(baked) => {
submit_quads(renderer, baked, layer, self.display, m, sprites, sink);
submit_quads(baked, layer, self.display, m, sprites, sink);
}
LayerGeometry::Special { model, argument } => special::submit(
renderer,
@@ -206,7 +203,6 @@ pub fn argb(color: i32) -> u32 {
/// `ItemFeatureRenderer.prepareMainSubmit` for one cuboid layer.
fn submit_quads(
renderer: &ItemRenderer,
baked: &BakedItemModel,
layer: &LayerRender,
display: DisplayContext,
@@ -220,8 +216,6 @@ fn submit_quads(
FoilType::Special => Foil::Special,
};
let _ = display;
// Quads of one layer mostly share a sprite: look its translucency up once.
let mut last: Option<(&str, bool)> = None;
for q in &baked.quads {
let Some(r) = sprites
.get(sprite_name(&q.material.sprite))
@@ -245,16 +239,6 @@ fn submit_quads(
let pos = q.positions.map(|p| m.transform_point3(Vec3::from(p)));
let uv =
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v]);
let translucent = match last {
Some((sprite, translucent)) if sprite == q.material.sprite => translucent,
_ => {
let translucent = renderer
.sprites
.translucent(sprite_name(&q.material.sprite));
last = Some((&q.material.sprite, translucent));
translucent
}
};
sink.quad(ItemQuad {
pos,
uv,
@@ -262,7 +246,6 @@ fn submit_quads(
color: argb(color),
foil,
light_emission: q.light_emission,
translucent: translucent || q.material.force_translucent,
});
}
}
@@ -277,9 +260,6 @@ struct FileSprites {
root: PathBuf,
permutations: Vec<PalettedPermutations>,
cache: Mutex<HashMap<String, Option<SpriteMask>>>,
/// Sprites with a pixel that is neither fully transparent nor opaque
/// (`Transparency.hasTranslucent`), filled in as sprites load.
translucent: Mutex<HashMap<String, bool>>,
}
impl SpriteLookup for FileSprites {
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
@@ -303,13 +283,6 @@ fn argb_pixels(rgba: &[u8]) -> Vec<u32> {
.collect()
}
impl FileSprites {
fn translucent(&self, sprite: &str) -> bool {
let known = |this: &Self| this.translucent.lock().ok()?.get(sprite).copied();
known(self).unwrap_or_else(|| {
self.mask(sprite);
known(self).unwrap_or(false)
})
}
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
crate::atlas_sources::texture_path(&self.root, sprite, directory)
}
@@ -330,10 +303,6 @@ impl FileSprites {
(w, h, pixels)
}
};
let translucent = argb.iter().any(|p| matches!(p >> 24, 1..=254));
if let Ok(mut cache) = self.translucent.lock() {
cache.insert(sprite.to_owned(), translucent);
}
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
.ok()
@@ -483,7 +452,6 @@ impl ItemRenderer {
root: root.to_path_buf(),
permutations,
cache: Mutex::default(),
translucent: Mutex::default(),
},
baked: Mutex::default(),
extents: Mutex::default(),
@@ -726,12 +694,7 @@ impl ItemSink for WorldSink<'_> {
} else {
self.light
};
let overlay = match (q.foil, q.translucent) {
(Foil::None, true) => crate::gfx::TRANSLUCENT_ITEM_OVERLAY,
(Foil::None, false) => 0,
(_, true) => crate::gfx::TRANSLUCENT_GLINT_ITEM_OVERLAY,
(_, false) => 7,
};
let overlay = if q.foil == Foil::None { 0 } else { 7 };
for (p, uv) in q.pos.iter().zip(q.uv) {
self.out.push(Vertex {
normal: [0; 4],
@@ -358,7 +358,6 @@ mod tests {
color,
foil: Foil::None,
light_emission: 0,
translucent: false,
}
}
@@ -119,7 +119,6 @@ fn draw(
color,
foil,
light_emission: 0,
translucent: false,
});
}
for (child_name, child) in &part.children {
+2 -40
View File
@@ -2,12 +2,7 @@
//! player state (inventory, health, game mode) that the host's gameplay code
//! operates on when it acts for them.
use super::{Joined, PlayerSender, entity::Tracker};
use crate::{
items::ItemStack,
motion::PlayerMotion,
pose::{Dimensions, Pose},
};
use horizon_core::geometry::{Aabb, Vec3};
use crate::{items::ItemStack, motion::PlayerMotion};
use horizon_protocol::{
item::Stack,
packets::{configuration::ClientInformation, play},
@@ -412,41 +407,8 @@ impl Guest {
}
f
}
/// `Player.getDesiredPose`: sleeping, gliding, then the body's own pose
/// (swimming, crawling, crouching) once its server tick has run.
pub fn pose(&self) -> Pose {
if self.sleeping {
Pose::Sleeping
} else if self.fall_flying {
Pose::FallFlying
} else if let Some(m) = &self.motion {
m.pose
} else if self.sneaking && !self.flying {
Pose::Crouching
} else {
Pose::Standing
}
}
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
pub fn dimensions(&self) -> Dimensions {
let pose = self.pose();
match &self.motion {
Some(m) if m.pose == pose => m.dimensions,
_ => pose.dimensions(1.0),
}
}
/// `Entity.getBoundingBox`.
pub fn bounds(&self) -> Aabb {
let [x, y, z] = self.position;
let d = self.dimensions();
let half = f64::from(d.width) / 2.0;
Aabb::new(
Vec3::new(x - half, y, z - half),
Vec3::new(x + half, y + f64::from(d.height), z + half),
)
}
pub fn eye_height(&self) -> f64 {
f64::from(self.dimensions().eye_height)
if self.sneaking { 1.27 } else { 1.62 }
}
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
/// its block interaction range is 4.5, 5 in creative.
+12 -2
View File
@@ -2344,8 +2344,18 @@ impl Hub {
}
// getBoundingBox: the box of the body's pose (swimming and crawling
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
let d = g.dimensions();
let (half, height) = (f64::from(d.width) / 2.0, f64::from(d.height));
let (half, height) = if g.fall_flying {
(0.3, 0.6)
} else if let Some(m) = &g.motion {
(
f64::from(m.dimensions.width) / 2.0,
f64::from(m.dimensions.height),
)
} else if g.sneaking {
(0.3, 1.5)
} else {
(0.3, 1.8)
};
let at = |p: [f64; 3]| {
Aabb::new(
Vec3::new(p[0] - half, p[1], p[2] - half),
-2
View File
@@ -15,7 +15,6 @@ pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod bidi;
pub mod biome;
pub mod block_entities;
pub mod block_entity_anim;
@@ -75,7 +74,6 @@ pub mod fog;
pub mod font;
pub mod friends_screen;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gamepad;
pub mod gfx;
pub mod gizmos;
-60
View File
@@ -3211,64 +3211,4 @@ pub(crate) mod tests {
assert_eq!(menu.slot(0), stack("dirt", 1).as_ref());
});
}
/// #1737: a Shift+Right-Click quick move in a chest (a double chest's
/// 54 slots) moves the stack and leaves the menu's state clean, so the
/// next quick move still moves its stack and the inventory still
/// answers.
#[test]
fn a_chest_quick_move_keeps_the_next_one_working() {
with_menu(Kind::Chest(6), |env, menu| {
menu.creative = false;
// The hotbar holds two stacks; the chest is empty.
menu.set_slot(env, 81, stack("cobblestone", 64));
menu.set_slot(env, 82, stack("dirt", 32));
// Shift+Right-Click on the hotbar: the stack goes into the chest.
menu.click(env, 81, 1, Input::QuickMove).unwrap();
assert_eq!(menu.slot(81), None);
assert_eq!(menu.slot(0).map(|s| s.count), Some(64));
// Nothing is left on the cursor and no quick craft is running.
assert_eq!(menu.carried, None);
assert_eq!(menu.quick_status, 0);
// The next quick move still works.
menu.click(env, 82, 1, Input::QuickMove).unwrap();
assert_eq!(menu.slot(82), None);
assert_eq!(menu.slot(1).map(|s| s.count), Some(32));
assert_eq!(menu.carried, None);
assert_eq!(menu.quick_status, 0);
});
}
/// #1832: a potion taken from the creative inventory and put in the
/// hotbar keeps its `PotionContents` (the brew), as vanilla's
/// `PotionContents.createItemStack` builds it: the item tab's stack is
/// the real one, and the hotbar slot holds it as it is.
#[test]
fn a_creative_potion_keeps_its_contents_in_the_hotbar() {
let snbt = r#"{components:{"minecraft:potion_contents":{potion:"minecraft:swiftness"}},count:1,id:"minecraft:potion"}"#;
let potion = crate::item_data::parse_stack(snbt).unwrap().unwrap();
assert_eq!(potion.potion(), Some("minecraft:swiftness"));
// The creative screen hands the stack over...
let mut carried = None;
crate::creative::click_display(
&mut carried,
Some(&potion),
|_: &ItemStack| 1,
0,
Input::Pickup,
);
assert_eq!(
carried.as_ref().and_then(|s| s.potion()),
Some("minecraft:swiftness")
);
// ... and the hotbar slot keeps it: it is not the uncraftable
// potion, which is a `minecraft:potion` with no contents at all.
with_menu(Kind::ItemPicker, |env, menu| {
menu.carried = carried;
menu.click(env, 45, 0, Input::Pickup).unwrap();
let in_hand = menu.slot(45).expect("the hotbar slot");
assert_eq!(in_hand.potion(), Some("minecraft:swiftness"));
assert_eq!(&*in_hand.item, "minecraft:potion");
});
}
}
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more