Merge origin/main into fix/server-player-sculk-events
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
|
||||
Generated
+90
@@ -1116,12 +1116,44 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
|
||||
dependencies = [
|
||||
"khronos_api",
|
||||
"log",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"slotmap",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_wgl_sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -1352,6 +1384,13 @@ dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods-test-ui-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1805,6 +1844,23 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -3142,6 +3198,15 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "slotmap"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
@@ -4267,6 +4332,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu-core-deps-apple",
|
||||
"wgpu-core-deps-emscripten",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-naga-bridge",
|
||||
@@ -4282,6 +4348,15 @@ dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-emscripten"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "30.0.1"
|
||||
@@ -4304,13 +4379,18 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"glow",
|
||||
"glutin_wgl_sys",
|
||||
"gpu-allocator",
|
||||
"hashbrown 0.17.1",
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"naga",
|
||||
"naga-types",
|
||||
"ndk-sys",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -4330,10 +4410,14 @@ dependencies = [
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen",
|
||||
"wayland-sys",
|
||||
"web-sys",
|
||||
"wgpu-naga-bridge",
|
||||
"wgpu-types",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-result 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5074,6 +5158,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+32
-13
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included.
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -374,6 +374,16 @@ More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
@@ -790,18 +800,6 @@ Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds
|
||||
|
||||
</details>
|
||||
|
||||
#### OpenGL for old computers
|
||||
|
||||
A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships.
|
||||
|
||||
More: [docs/graphics.md](docs/graphics.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
@@ -812,6 +810,16 @@ No official build. Android was first closed as not planned, with touch controls,
|
||||
|
||||
</details>
|
||||
|
||||
#### AppImage and a Nix flake
|
||||
|
||||
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### A complete video studio inside the game
|
||||
@@ -824,6 +832,16 @@ Three stages. Recording: capture gameplay in a keypress with an encoder thread a
|
||||
|
||||
</details>
|
||||
|
||||
#### Game menus in the launcher's style
|
||||
|
||||
An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### An optional AI teammate
|
||||
@@ -936,6 +954,7 @@ Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
|
||||
|
||||
@@ -225,6 +225,8 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
|
||||
@@ -68,6 +68,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Lore tooltip companion for tools/test_lore_tooltip_parity.py: the spans
|
||||
//! `item_tooltip::lore_texts` gives each stack (SNBT, one per line).
|
||||
use horizon_client::{
|
||||
item_data::parse_stack,
|
||||
item_tooltip::{TooltipDisplay, lore_texts},
|
||||
};
|
||||
use std::{error::Error, fmt::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1])?;
|
||||
let mut output = String::new();
|
||||
for line in input
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
{
|
||||
let stack = parse_stack(line)?.ok_or("empty stack")?;
|
||||
let display = TooltipDisplay::for_stack(&stack);
|
||||
if !display.shows("minecraft:lore") {
|
||||
writeln!(output, "-")?;
|
||||
continue;
|
||||
}
|
||||
let lines = lore_texts(&stack);
|
||||
if lines.is_empty() {
|
||||
writeln!(output, "-")?;
|
||||
continue;
|
||||
}
|
||||
let mut rendered = Vec::new();
|
||||
for text in lines {
|
||||
let mut spans = Vec::new();
|
||||
for span in &text.spans {
|
||||
if span.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = &span.style;
|
||||
spans.push(format!(
|
||||
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
|
||||
span.text,
|
||||
style.color.map(|c| c as i32).unwrap_or(-1),
|
||||
style.bold == Some(true),
|
||||
style.italic == Some(true),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
style.obfuscated == Some(true),
|
||||
));
|
||||
}
|
||||
rendered.push(spans.join(" | "));
|
||||
}
|
||||
writeln!(output, "{}", rendered.join(" || "))?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -49,6 +49,7 @@ mod input_probe;
|
||||
mod interaction_input_fixture;
|
||||
mod lan_probe;
|
||||
mod light_parity;
|
||||
mod lore_tooltip_fixture;
|
||||
mod marker_particles_probe;
|
||||
mod menu_fixture;
|
||||
mod mipmap_probe;
|
||||
@@ -133,6 +134,7 @@ probes! {
|
||||
interaction_input_fixture,
|
||||
lan_probe,
|
||||
light_parity,
|
||||
lore_tooltip_fixture,
|
||||
marker_particles_probe,
|
||||
menu_fixture,
|
||||
mipmap_probe,
|
||||
|
||||
@@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
cooking_timer: menu.data[2],
|
||||
cooking_total: menu.data[3],
|
||||
speed: 1.0,
|
||||
recipes_used: Default::default(),
|
||||
});
|
||||
menu.drops.clear();
|
||||
menu.equipped.clear();
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct Furnace {
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
@@ -190,6 +194,7 @@ impl BlockEntity {
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
@@ -483,6 +488,14 @@ impl Furnace {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
@@ -564,6 +577,7 @@ impl Furnace {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
@@ -594,3 +608,41 @@ fn total_time(time: u32, speed: f32) -> i32 {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
pub fn create_experience(
|
||||
count: u32,
|
||||
experience: f32,
|
||||
random: &mut impl horizon_core::random::RandomSource,
|
||||
) -> u32 {
|
||||
let total = count as f32 * experience;
|
||||
let whole = total.floor();
|
||||
let fraction = total - whole;
|
||||
let mut xp = whole.max(0.0) as u32;
|
||||
if fraction != 0.0 && random.next_float() < fraction {
|
||||
xp += 1;
|
||||
}
|
||||
xp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut untouched = LegacyRandom::new(7);
|
||||
assert_eq!(create_experience(4, 0.5, &mut random), 2);
|
||||
assert_eq!(create_experience(3, 0.0, &mut random), 0);
|
||||
assert_eq!(random.next_long(), untouched.next_long());
|
||||
let paid: u32 = (0..1000)
|
||||
.map(|_| create_experience(1, 0.35, &mut random))
|
||||
.sum();
|
||||
assert!((250..450).contains(&paid), "{paid}");
|
||||
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1610,6 +1610,14 @@ impl Recipes {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
|
||||
/// pack does not have (a save from another pack can name it).
|
||||
pub fn cooking_experience(&self, id: &str) -> f32 {
|
||||
self.cooking
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map_or(0.0, |c| c.experience)
|
||||
}
|
||||
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
|
||||
let index = self.cook_index.get_or_init(|| {
|
||||
let mut index = CookIndex::new();
|
||||
|
||||
@@ -71,10 +71,52 @@ impl Names {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| path.replace('_', " "))
|
||||
}
|
||||
/// An item's display name in the selected language: `item.*` then
|
||||
/// `block.*`, then the pinned English map (`Names::item`).
|
||||
pub fn display_item(&self, id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
if let Some(lang) = crate::lang::current() {
|
||||
for prefix in ["item", "block"] {
|
||||
if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) {
|
||||
return name.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.item(id)
|
||||
}
|
||||
/// A translation key's text in the selected language, else the pinned
|
||||
/// English map (`HoverName` and tooltip keys such as potion names).
|
||||
pub fn display_get(&self, key: &str) -> Option<String> {
|
||||
crate::lang::current()
|
||||
.and_then(|lang| lang.get(key).map(str::to_owned))
|
||||
.or_else(|| self.get(key).map(str::to_owned))
|
||||
}
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the
|
||||
/// export's `title` is the English fallback.
|
||||
pub fn item_group_key(id: &str) -> Option<&'static str> {
|
||||
Some(match id.trim_start_matches("minecraft:") {
|
||||
"building_blocks" => "itemGroup.buildingBlocks",
|
||||
"colored_blocks" => "itemGroup.coloredBlocks",
|
||||
"natural_blocks" => "itemGroup.natural",
|
||||
"functional_blocks" => "itemGroup.functional",
|
||||
"redstone_blocks" => "itemGroup.redstone",
|
||||
"hotbar" => "itemGroup.hotbar",
|
||||
"search" => "itemGroup.search",
|
||||
"tools_and_utilities" => "itemGroup.tools",
|
||||
"combat" => "itemGroup.combat",
|
||||
"food_and_drinks" => "itemGroup.foodAndDrink",
|
||||
"ingredients" => "itemGroup.ingredients",
|
||||
"spawn_eggs" => "itemGroup.spawnEggs",
|
||||
"op_blocks" => "itemGroup.op",
|
||||
"inventory" => "itemGroup.inventory",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
impl Creative {
|
||||
pub const WIDTH: f32 = 195.0;
|
||||
pub const HEIGHT: f32 = 136.0;
|
||||
@@ -303,7 +345,17 @@ impl Creative {
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
// The export's lines are English (they come from the vanilla jars);
|
||||
// the hover name in the selected language is matched too, so a
|
||||
// localized search finds the item. It is the whole name (a tipped
|
||||
// arrow's potion, the ominous banner), as the first line is.
|
||||
let text = java_lower(term);
|
||||
let localized = |i: usize| {
|
||||
java_lower(&crate::item_tooltip::name_line(
|
||||
&self.search_items[i],
|
||||
&|k| names.display_get(k),
|
||||
))
|
||||
};
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
@@ -314,11 +366,15 @@ impl Creative {
|
||||
&e.lines
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => any(lines.iter().map(String::as_str), &text),
|
||||
None => {
|
||||
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
|
||||
}
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
|
||||
&& (e.path.contains(path)
|
||||
|| any(lines.iter().map(String::as_str), path)
|
||||
|| localized(i).contains(path))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -759,6 +815,24 @@ mod tests {
|
||||
assert_eq!(q("#craft"), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_the_display_name_in_the_selected_language() {
|
||||
let c = creative(0);
|
||||
let names = Names {
|
||||
map: HashMap::from([
|
||||
("item.minecraft.anvil".to_owned(), "铁砧".to_owned()),
|
||||
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
|
||||
]),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
assert_eq!(q("铁砧"), [0]);
|
||||
assert_eq!(q("木锄"), [2]);
|
||||
// The English tooltip lines the export indexed still match.
|
||||
assert_eq!(q("anvil"), [0]);
|
||||
// A colon's path searches the display name too.
|
||||
assert_eq!(q("minecraft:铁砧"), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
@@ -794,6 +868,20 @@ mod tests {
|
||||
assert!(!c.scrolling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_tab_titles_have_vanilla_keys() {
|
||||
assert_eq!(
|
||||
item_group_key("minecraft:building_blocks"),
|
||||
Some("itemGroup.buildingBlocks")
|
||||
);
|
||||
assert_eq!(
|
||||
item_group_key("minecraft:food_and_drinks"),
|
||||
Some("itemGroup.foodAndDrink")
|
||||
);
|
||||
assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar"));
|
||||
assert_eq!(item_group_key("minecraft:nonsense"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_moves_one_row_per_notch() {
|
||||
let mut c = creative(90);
|
||||
|
||||
@@ -617,9 +617,23 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.head_pitch = pitch;
|
||||
m.on_ground = flag(tag, "OnGround");
|
||||
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
if let Some(h) = float(tag.get("Health")) {
|
||||
m.health = h as f32;
|
||||
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
|
||||
// the health, the max health when there is none.
|
||||
if let Some(Tag::List(attrs)) = tag.get("attributes") {
|
||||
for a in attrs {
|
||||
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -735,12 +749,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:jump_strength" => m.extra.vector[0] = v,
|
||||
_ => {}
|
||||
if id == "minecraft:jump_strength" {
|
||||
m.extra.vector[0] = f64::from_bits(*bits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,8 +891,11 @@ impl Font {
|
||||
}
|
||||
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
|
||||
}
|
||||
/// Font.width of a component: `§` codes in the spans are formatting,
|
||||
/// not glyphs.
|
||||
pub fn width_text(&self, t: &Text) -> f32 {
|
||||
t.chars()
|
||||
t.resolved()
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
|
||||
@@ -482,6 +482,9 @@ enum Screen {
|
||||
CommandBlock,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
/// (game/mods.rs).
|
||||
Mod,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -537,6 +540,8 @@ struct GameLevel<'w> {
|
||||
seed: i64,
|
||||
behaviours: &'w Behaviours,
|
||||
items: &'w Items,
|
||||
/// Cooking recipes' experience, which a removed furnace pays out.
|
||||
recipes: &'w horizon_client::crafting::Recipes,
|
||||
/// Container loot tables (`unpackLootTable`).
|
||||
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
@@ -1570,6 +1575,8 @@ struct Game<'a> {
|
||||
cmds: commands::extra::CommandExtras,
|
||||
ui: chat::UiState,
|
||||
combat: combat::CombatTracker,
|
||||
/// Each guest's own `CombatTracker`, out of `combat` between turns.
|
||||
guest_combat: std::collections::HashMap<u32, combat::CombatTracker>,
|
||||
mount: mounts::MountState,
|
||||
stats: stats::Stats,
|
||||
score: scoreboard::ScoreState,
|
||||
@@ -1839,6 +1846,19 @@ impl Game<'_> {
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
})
|
||||
}
|
||||
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
|
||||
/// `freeze_immune_wearables`.
|
||||
pub(crate) fn wears_freeze_immune(&self) -> bool {
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
self.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
|
||||
}
|
||||
/// What the window decides for its player's body every tick, in frame and
|
||||
/// on a server alike: the look (Player.travel steers sprint-swimming by
|
||||
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
|
||||
@@ -1865,16 +1885,7 @@ impl Game<'_> {
|
||||
let leather_boots = self.armor[3]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
let freeze_immune = self
|
||||
.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
let freeze_immune = self.wears_freeze_immune();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -2175,6 +2186,11 @@ impl Game<'_> {
|
||||
// Player.drop: the drop statistics.
|
||||
self.stats.add("dropped", &thrown.item, thrown.count as i32);
|
||||
self.stats.custom("drop", 1);
|
||||
self.throw_from_eyes(thrown, eye);
|
||||
}
|
||||
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
|
||||
/// along the view direction.
|
||||
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) {
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
@@ -2422,6 +2438,7 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
behaviours: self.res.behaviours(),
|
||||
items: self.res.items(),
|
||||
recipes: self.res.recipes(),
|
||||
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
|
||||
registry: self.pack.assets.registry,
|
||||
rules,
|
||||
@@ -4292,6 +4309,7 @@ impl Game<'_> {
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
|
||||
MotionEvent::SmashLanding {
|
||||
state,
|
||||
position,
|
||||
@@ -4338,7 +4356,8 @@ impl Game<'_> {
|
||||
// Out of the world.
|
||||
let _ = player.set_health(0.0, false);
|
||||
}
|
||||
if player.health.dead_or_dying() && self.screen != Screen::Dead {
|
||||
let dead = player.health.dead_or_dying();
|
||||
if dead && self.screen != Screen::Dead {
|
||||
// Inventory.dropAll: every stack thrown in a random direction.
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
let at = [feet[0], feet[1] + 1.32, feet[2]];
|
||||
@@ -4372,6 +4391,18 @@ impl Game<'_> {
|
||||
}
|
||||
self.screen = Screen::Dead;
|
||||
self.capture(false);
|
||||
self.ui.death_time = 0;
|
||||
} else if dead {
|
||||
// LivingEntity.tickDeath: 20 ticks after dying the player is
|
||||
// removed, and Player.remove's inventoryMenu.removed drops what
|
||||
// the cursor held.
|
||||
self.ui.death_time += 1;
|
||||
if self.ui.death_time >= 20
|
||||
&& let Some(stack) = self.carried.take()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.throw_from_eyes(stack, eye);
|
||||
}
|
||||
}
|
||||
self.tick_world(running)
|
||||
}
|
||||
@@ -5050,6 +5081,7 @@ impl Game<'_> {
|
||||
self.draw_creative(&mut batch);
|
||||
}
|
||||
Screen::Chat => self.draw_chat_screen(&mut batch),
|
||||
Screen::Mod => self.draw_mod_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
@@ -5307,22 +5339,32 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Chest(_)) if self.draw_machine_screen(batch, ox, oy) => {}
|
||||
Some(BlockEntity::Chest(_)) => self.draw_chest_screen(batch, ox, oy),
|
||||
Some(BlockEntity::Furnace(f)) => {
|
||||
let (tex, title) = match f.kind {
|
||||
let (tex, key, fallback) = match f.kind {
|
||||
horizon_client::containers::FurnaceKind::Furnace => {
|
||||
("gui/container/furnace", "Furnace")
|
||||
}
|
||||
horizon_client::containers::FurnaceKind::BlastFurnace => {
|
||||
("gui/container/blast_furnace", "Blast Furnace")
|
||||
("gui/container/furnace", "container.furnace", "Furnace")
|
||||
}
|
||||
horizon_client::containers::FurnaceKind::BlastFurnace => (
|
||||
"gui/container/blast_furnace",
|
||||
"container.blast_furnace",
|
||||
"Blast Furnace",
|
||||
),
|
||||
horizon_client::containers::FurnaceKind::Smoker => {
|
||||
("gui/container/smoker", "Smoker")
|
||||
("gui/container/smoker", "container.smoker", "Smoker")
|
||||
}
|
||||
};
|
||||
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let title = &self.container_title(title.to_owned());
|
||||
let tw = HudBatch::text_width(atlas, title);
|
||||
batch.text(atlas, title, ox + (176.0 - tw) / 2.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let title = self.container_title(self.translate(key, fallback));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
ox + (176.0 - tw) / 2.0,
|
||||
oy + 6.0,
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
if f.lit_remaining > 0 {
|
||||
// AbstractFurnaceScreen: flame height ceil(lit * 13) + 1.
|
||||
let total = if f.lit_total == 0 { 200 } else { f.lit_total };
|
||||
@@ -5376,14 +5418,12 @@ impl Game<'_> {
|
||||
"gui/container/inventory"
|
||||
};
|
||||
part(batch, texture, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let (title, tx) = if self.craft_size == 3 {
|
||||
("Crafting", 29.0)
|
||||
} else {
|
||||
("Crafting", 97.0)
|
||||
};
|
||||
batch.text(atlas, title, ox + tx, oy + 6.0, grey, false);
|
||||
let title = self.translate("container.crafting", "Crafting");
|
||||
let tx = if self.craft_size == 3 { 29.0 } else { 97.0 };
|
||||
batch.text(atlas, &title, ox + tx, oy + 6.0, grey, false);
|
||||
if self.craft_size == 3 {
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5940,6 +5980,12 @@ impl Game<'_> {
|
||||
// Attached after the slots were set: setting them is not a click.
|
||||
menu.merchant = session.map(|(s, _)| s);
|
||||
}
|
||||
// `FurnaceResultSlot.checkTakeAchievements` pays out on what left the output slot.
|
||||
let furnace_before = if matches!(kind, Kind::Furnace(_)) {
|
||||
menu.slot(2).cloned().map_or(0, |s| s.count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let outcome = run(&mut menu, &env, &to_menu);
|
||||
let made = maps.made.take();
|
||||
match outcome {
|
||||
@@ -5957,6 +6003,12 @@ impl Game<'_> {
|
||||
self.merchant_sync_out(s);
|
||||
}
|
||||
let after: Vec<Option<ItemStack>> = (0..total).map(|i| menu.slot(i).cloned()).collect();
|
||||
// Only a furnace has a result slot to compare: an enchanting table has two slots.
|
||||
let furnace_after = if matches!(kind, Kind::Furnace(_)) {
|
||||
after[2].as_ref().map_or(0, |s| s.count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let carried = menu.carried.take();
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let sounds = std::mem::take(&mut menu.sounds);
|
||||
@@ -5981,6 +6033,13 @@ impl Game<'_> {
|
||||
self.player_drop(stack);
|
||||
}
|
||||
self.award_crafted(&crafted);
|
||||
if furnace_before > furnace_after
|
||||
&& self.remote.is_none()
|
||||
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
|
||||
{
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Loom {
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
@@ -8776,6 +8835,27 @@ impl Game<'_> {
|
||||
self.give_orb_points(gained);
|
||||
}
|
||||
}
|
||||
/// `FurnaceResultSlot.checkTakeAchievements`: the player took from the output of the
|
||||
/// furnace at `pos`, which pops the experience of what it made at them
|
||||
/// (`awardUsedRecipesAndPopExperience`) and forgets it.
|
||||
pub(super) fn award_furnace_recipes(&mut self, pos: (i32, i32, i32), at: [f64; 3]) {
|
||||
let used = match self.world.block_entities.get_mut(&pos) {
|
||||
Some(BlockEntity::Furnace(f)) => {
|
||||
let used = f.recipe_experience(self.res.recipes());
|
||||
f.recipes_used.clear();
|
||||
used
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
for (count, experience) in used {
|
||||
let xp = horizon_client::containers::create_experience(
|
||||
count,
|
||||
experience,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.spawn_orbs(at, xp);
|
||||
}
|
||||
}
|
||||
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
|
||||
/// sound every fifth level (at most every 100 ticks).
|
||||
pub(super) fn give_orb_points(&mut self, gained: u32) {
|
||||
@@ -9419,6 +9499,11 @@ impl Game<'_> {
|
||||
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
|
||||
batch.centered_origin(Creative::WIDTH, Creative::HEIGHT)
|
||||
}
|
||||
/// A creative tab's title: `itemGroup.*`, else the export's English title.
|
||||
fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
|
||||
horizon_client::creative::item_group_key(&tab.id)
|
||||
.map_or_else(|| tab.title.clone(), |key| self.translate(key, &tab.title))
|
||||
}
|
||||
/// Vanilla tooltip: dark box with a purple gradient border.
|
||||
fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -9488,9 +9573,10 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(&tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&tab.title,
|
||||
&title,
|
||||
ox + 8.0,
|
||||
oy + 6.0,
|
||||
rgba(64, 64, 64, 255),
|
||||
@@ -9601,7 +9687,12 @@ impl Game<'_> {
|
||||
&& self.cursor.1 >= oy + ty
|
||||
&& self.cursor.1 < oy + ty + 32.0
|
||||
{
|
||||
self.tooltip(batch, &t.title, self.cursor.0, self.cursor.1);
|
||||
self.tooltip(
|
||||
batch,
|
||||
&self.creative_tab_title(t),
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_carried(batch);
|
||||
@@ -10371,6 +10462,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::Chat => self.close_chat(),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::Mod => self.close_mod_screen(true),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -11017,6 +11109,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Options && d != 0.0 {
|
||||
self.options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Mod && d != 0.0 {
|
||||
self.mod_screen_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Creative && d != 0.0 {
|
||||
self.creative_screen.wheel(f64::from(d), &self.pack.names);
|
||||
}
|
||||
@@ -11239,6 +11334,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
self.stats_click(size);
|
||||
}
|
||||
Screen::Mod if pressed && button == MouseButton::Left => {
|
||||
self.mod_screen_click()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -11280,9 +11378,10 @@ impl ApplicationHandler for Game<'_> {
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
|
||||
self.creative_search_backspace(self.has_control_down());
|
||||
} else {
|
||||
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
|
||||
self.creative_search_edit(event.text.as_deref(), backspace);
|
||||
self.creative_search_edit(event.text.as_deref());
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
@@ -12014,6 +12113,7 @@ impl<'a> Game<'a> {
|
||||
cmds: Default::default(),
|
||||
ui: Default::default(),
|
||||
combat: Default::default(),
|
||||
guest_combat: Default::default(),
|
||||
mount: Default::default(),
|
||||
stats: Default::default(),
|
||||
score: Default::default(),
|
||||
|
||||
@@ -416,6 +416,7 @@ impl Game<'_> {
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -1599,8 +1600,12 @@ impl Game<'_> {
|
||||
if self.screen != Screen::Dead {
|
||||
return Err("respawn needs the death screen".into());
|
||||
}
|
||||
let label =
|
||||
horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain();
|
||||
let key = if self.level.hardcore {
|
||||
"deathScreen.spectate"
|
||||
} else {
|
||||
"deathScreen.respawn"
|
||||
};
|
||||
let label = horizon_client::text::Text::translate(key, &[]).plain();
|
||||
let w = widgets::find(&widgets::last(), &label)?;
|
||||
if !w.enabled {
|
||||
// DeathScreen enables its buttons after 20 ticks.
|
||||
|
||||
@@ -519,7 +519,12 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
&[(horizon_client::text::Text::literal(text), u32::MAX)],
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
|
||||
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
@@ -18,7 +18,7 @@ fn chat_tree<'a>(
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
|
||||
Some(tree) => tree,
|
||||
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
|
||||
None => mods.dispatcher(),
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ pub struct UiState {
|
||||
/// Death screen cause and when the player died (buttons wait 20 ticks).
|
||||
pub death_text: Option<Text>,
|
||||
pub died_at: Option<std::time::Instant>,
|
||||
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
|
||||
pub death_time: u32,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -586,7 +588,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
let line = Text::translate(
|
||||
"chat.type.text",
|
||||
&[Text::literal(PLAYER_NAME), Text::literal(message)],
|
||||
&[self.player_display_name(), Text::literal(message)],
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
|
||||
@@ -97,15 +97,25 @@ impl Game<'_> {
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
/// Typing or a backspace in the creative search box.
|
||||
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>, backspace: bool) {
|
||||
/// A backspace in the creative search box: with Ctrl, the whole word
|
||||
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
|
||||
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
let cleared = self.text_selection.take(CREATIVE_SEARCH, search);
|
||||
if backspace {
|
||||
if !cleared {
|
||||
if !self.text_selection.take(CREATIVE_SEARCH, search) {
|
||||
if ctrl {
|
||||
horizon_client::ui::delete_words(search, -1);
|
||||
} else {
|
||||
search.pop();
|
||||
}
|
||||
} else if let Some(text) = typed {
|
||||
}
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
/// Typing in the creative search box.
|
||||
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
self.text_selection.take(CREATIVE_SEARCH, search);
|
||||
if let Some(text) = typed {
|
||||
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
|
||||
}
|
||||
self.creative_screen.scroll = 0.0;
|
||||
|
||||
@@ -191,8 +191,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage` for a player whose display name is
|
||||
/// `me`: a server's guest is not `PLAYER_NAME`.
|
||||
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
|
||||
self.recheck_combat();
|
||||
let me = Text::literal(PLAYER_NAME);
|
||||
let entries = std::mem::take(&mut self.combat.entries);
|
||||
self.combat.in_combat = false;
|
||||
let Some(killing) = entries.last() else {
|
||||
|
||||
@@ -214,7 +214,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
fn player_display_name(&self) -> Text {
|
||||
pub(super) fn player_display_name(&self) -> Text {
|
||||
let name = self.acting_player_name().to_owned();
|
||||
let Some(uuid) = self.lan_acting_uuid else {
|
||||
return self.target_name(Target::Player);
|
||||
@@ -444,7 +444,7 @@ impl Game<'_> {
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| i.stack.item.to_string())
|
||||
.unwrap_or_default();
|
||||
Text::literal(self.pack.names.item(&item))
|
||||
Text::literal(self.pack.names.display_item(&item))
|
||||
}
|
||||
Target::Mob(id)
|
||||
if self
|
||||
@@ -2809,7 +2809,7 @@ impl Game<'_> {
|
||||
let name = match self.command_source.map(|s| s.entity) {
|
||||
Some(Some(t)) => self.target_name(t),
|
||||
Some(None) => Text::literal("Server"),
|
||||
None => Text::literal(self.acting_player_name()),
|
||||
None => self.player_display_name(),
|
||||
};
|
||||
let line = match id {
|
||||
"say" => Text::translate("chat.type.announcement", &[name, message]),
|
||||
@@ -2819,14 +2819,14 @@ impl Game<'_> {
|
||||
// Whispering to yourself: the incoming and outgoing lines.
|
||||
let out = Text::translate(
|
||||
"commands.message.display.outgoing",
|
||||
&[Text::literal(self.acting_player_name()), message.clone()],
|
||||
&[self.player_display_name(), message.clone()],
|
||||
)
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(out);
|
||||
Text::translate(
|
||||
"commands.message.display.incoming",
|
||||
&[Text::literal(self.acting_player_name()), message],
|
||||
&[self.player_display_name(), message],
|
||||
)
|
||||
.with_color(text::GRAY)
|
||||
.italic()
|
||||
|
||||
@@ -606,10 +606,7 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => match entity {
|
||||
Target::Player => self.riding.map(Target::Mob).into_iter().collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
|
||||
Some("controller") | Some("passengers") => match entity {
|
||||
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
|
||||
vec![Target::Player]
|
||||
|
||||
@@ -1074,15 +1074,15 @@ impl Game<'_> {
|
||||
};
|
||||
let name = self.target_name(target);
|
||||
if id == "ride.dismount" {
|
||||
let vehicle = match target {
|
||||
Target::Player => self.riding.map(Target::Mob),
|
||||
_ => None,
|
||||
};
|
||||
let Some(v) = vehicle else {
|
||||
let Some(v) = self.ride_vehicle(target) else {
|
||||
return Err(err("commands.ride.not_riding", &[&name.plain()]));
|
||||
};
|
||||
let vehicle_name = self.target_name(v);
|
||||
self.dismount_mount();
|
||||
match target {
|
||||
Target::Player => self.dismount_mount(),
|
||||
Target::Mob(rider) => self.dismount_mob(rider),
|
||||
_ => {}
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.dismount.success",
|
||||
&[name, vehicle_name],
|
||||
@@ -1094,11 +1094,8 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let vehicle_name = self.target_name(vehicle);
|
||||
if target == Target::Player && self.riding.is_some() {
|
||||
let current = self
|
||||
.riding
|
||||
.map(Target::Mob)
|
||||
.map_or_else(Text::empty, |v| self.target_name(v));
|
||||
if let Some(current) = self.ride_vehicle(target) {
|
||||
let current = self.target_name(current);
|
||||
return Err(err(
|
||||
"commands.ride.already_riding",
|
||||
&[&name.plain(), ¤t.plain()],
|
||||
@@ -1107,22 +1104,42 @@ impl Game<'_> {
|
||||
if vehicle == Target::Player {
|
||||
return Err(err("commands.ride.mount.failure.cant_ride_players", &[]));
|
||||
}
|
||||
if target == vehicle {
|
||||
return Err(err("commands.ride.mount.failure.loop", &[]));
|
||||
// getSelfAndPassengers: the vehicle is the target or rides it.
|
||||
let mut up = Some(vehicle);
|
||||
while let Some(v) = up {
|
||||
if v == target {
|
||||
return Err(err("commands.ride.mount.failure.loop", &[]));
|
||||
}
|
||||
up = self.ride_vehicle(v);
|
||||
}
|
||||
// The player can ride mobs that carry a rider.
|
||||
// Players and mobs can ride mobs.
|
||||
let Target::Mob(mob_id) = vehicle else {
|
||||
return Err(err(
|
||||
"commands.ride.mount.failure.generic",
|
||||
&[&name.plain(), &vehicle_name.plain()],
|
||||
));
|
||||
};
|
||||
if target != Target::Player || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) {
|
||||
let rider = match target {
|
||||
Target::Mob(id) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
if !(target == Target::Player || rider.is_some())
|
||||
|| !self.mobs.iter().any(|m| m.id == mob_id && m.alive())
|
||||
{
|
||||
return Err(err(
|
||||
"commands.ride.mount.failure.generic",
|
||||
&[&name.plain(), &vehicle_name.plain()],
|
||||
));
|
||||
}
|
||||
if let Some(rider) = rider {
|
||||
// Entity.startRiding.
|
||||
self.mount_mob(rider, mob_id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) {
|
||||
m.extra.flag = false;
|
||||
m.extra.counter = 0;
|
||||
@@ -1138,6 +1155,47 @@ impl Game<'_> {
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// Entity.getVehicle: the player's mount, or a mob's.
|
||||
pub(super) fn ride_vehicle(&self, t: Target) -> Option<Target> {
|
||||
let id = match t {
|
||||
Target::Player => return self.riding.map(Target::Mob),
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(if self.boats.iter().any(|b| b.id == id) {
|
||||
Target::Boat(id)
|
||||
} else {
|
||||
Target::Mob(id)
|
||||
})
|
||||
}
|
||||
/// LivingEntity.stopRiding for a mob: off its vehicle
|
||||
/// (Entity.removePassenger), then to where the vehicle lets it out
|
||||
/// (LivingEntity.dismountVehicle).
|
||||
fn dismount_mob(&mut self, rider: u32) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
|
||||
return;
|
||||
};
|
||||
let Some(v) = self.mobs[i].extra.mount.take() else {
|
||||
return;
|
||||
};
|
||||
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
|
||||
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
|
||||
self.boat_dismount_target(b, width, yaw)
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) {
|
||||
m.extra.rider = None;
|
||||
// Entity.getDismountLocationForPassenger: on top of the vehicle.
|
||||
let p = m.position;
|
||||
horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z)
|
||||
} else {
|
||||
self.mobs[i].position
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = super::super::boat_passengers::BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
|
||||
// ---- /spreadplayers -----------------------------------------------------------------------
|
||||
|
||||
pub(super) fn cmd_spreadplayers(&mut self, ctx: &Context) -> R<i32> {
|
||||
|
||||
@@ -569,7 +569,7 @@ impl Game<'_> {
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text), false)
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
@@ -799,6 +799,7 @@ impl Game<'_> {
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1562,6 +1562,16 @@ impl Game<'_> {
|
||||
id, ticks, amplifier,
|
||||
));
|
||||
}
|
||||
/// `WitherRoseBlock.entityInside` for the player: on a server level that
|
||||
/// isn't peaceful, `getBeeInteractionEffect` (Wither, 40 ticks) unless the
|
||||
/// player is invulnerable to wither damage (creative, spectator). A
|
||||
/// server's player gets it from the server.
|
||||
pub(super) fn wither_rose_contact(&mut self) {
|
||||
if self.remote.is_some() || self.ui.difficulty == 0 {
|
||||
return;
|
||||
}
|
||||
self.give_player_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
/// Burning applied to the player by mobs.
|
||||
fn tick_player_effects(&mut self) {
|
||||
self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1);
|
||||
@@ -1698,7 +1708,26 @@ impl Game<'_> {
|
||||
targets: targets_guests,
|
||||
};
|
||||
let guest_entities = self.lan_guest_entities();
|
||||
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
|
||||
// place of its tick while ender_pearls_vanish_on_death is on.
|
||||
let vanish = self.ui.rules.bool("ender_pearls_vanish_on_death");
|
||||
let host_dead = self.player_view().is_some_and(|p| !p.alive);
|
||||
let dead_guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
projectiles.retain_mut(|p| {
|
||||
if vanish
|
||||
&& matches!(p.kind, ProjectileKind::EnderPearl)
|
||||
&& p.owner == Some(Who::Player)
|
||||
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
// guest's flies from that guest (Projectile.getOwner), not
|
||||
|
||||
@@ -336,6 +336,12 @@ enum StrafeRequest {
|
||||
/// Anything else: the player nearest the crystal.
|
||||
Nearest(Vec3),
|
||||
}
|
||||
/// Whether `EndCrystal.hurtServer` creates a blast before notifying the fight.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum CrystalDamage {
|
||||
Direct,
|
||||
Explosion,
|
||||
}
|
||||
/// What `EnderDragon.hurt` made of a hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Hurt {
|
||||
@@ -867,27 +873,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A crystal took damage: `EndCrystal.hurtServer` blows it up (power 6).
|
||||
fn destroy_crystal(&mut self, index: usize, by: Option<FightWho>) {
|
||||
/// `EndCrystal.hurtServer`: remove it, blast only for non-explosion damage,
|
||||
/// then notify the dragon fight even when no blast was created.
|
||||
fn destroy_crystal(&mut self, index: usize, by: Option<FightWho>, damage: CrystalDamage) {
|
||||
if index >= self.crystals.len() || self.crystals[index].invulnerable {
|
||||
return;
|
||||
}
|
||||
let c = self.crystals.remove(index);
|
||||
if self.fight.respawn.is_some()
|
||||
// Read before the blast, which can take other respawn crystals.
|
||||
let respawn_crystal = self.fight.respawn.is_some()
|
||||
&& self
|
||||
.fight
|
||||
.respawn_crystals
|
||||
.iter()
|
||||
.any(|p| p.distance_squared(c.position) < 0.01)
|
||||
{
|
||||
.any(|p| p.distance_squared(c.position) < 0.01);
|
||||
if damage == CrystalDamage::Direct {
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let _ =
|
||||
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
}
|
||||
if respawn_crystal {
|
||||
self.abort_respawn();
|
||||
return;
|
||||
}
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let _ = self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
// EnderDragonFight.onCrystalDestroyed.
|
||||
self.count_crystals();
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
@@ -962,7 +970,7 @@ impl Game<'_> {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
self.destroy_crystal(index, by);
|
||||
self.destroy_crystal(index, by, CrystalDamage::Direct);
|
||||
Hurt::Landed { full: true }
|
||||
} else if id >= SPECIAL {
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
|
||||
@@ -2634,8 +2642,8 @@ impl Crystal {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerExplosion.hurtEntities` on the end crystals (each in reach
|
||||
/// blows up in turn) and the dragon (explosions always hurt dragons).
|
||||
/// `ServerExplosion.hurtEntities`: crystals in reach are removed without
|
||||
/// another blast; explosions always hurt dragons.
|
||||
pub(super) fn explosion_hits_special(&mut self, center: [f64; 3], radius: f32) {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
let in_reach = |p: Vec3| {
|
||||
@@ -2651,9 +2659,9 @@ impl Game<'_> {
|
||||
.map(|c| c.position)
|
||||
.collect();
|
||||
for at in crystals {
|
||||
// One blowing up may already have taken the next.
|
||||
// Look up the current index after each removal.
|
||||
if let Some(i) = self.crystals.iter().position(|c| c.position == at) {
|
||||
self.destroy_crystal(i, None);
|
||||
self.destroy_crystal(i, None, CrystalDamage::Explosion);
|
||||
}
|
||||
}
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
|
||||
@@ -25,9 +25,15 @@ pub(super) struct Rocket {
|
||||
pub(super) velocity: Vec3,
|
||||
life: i32,
|
||||
lifetime: i32,
|
||||
explosions: Vec<FireworkExplosion>,
|
||||
pub(super) explosions: Vec<FireworkExplosion>,
|
||||
/// Boosting the player (attachedToEntity).
|
||||
pub(super) attached: bool,
|
||||
/// On a server, the player it boosts when not this game's own: a guest's
|
||||
/// connection.
|
||||
pub(super) guest: Option<u32>,
|
||||
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
|
||||
/// boosting happens when it is the local player.
|
||||
pub(super) attached_to: Option<u32>,
|
||||
/// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on
|
||||
/// the first block it hits.
|
||||
pub(super) angled: bool,
|
||||
@@ -48,6 +54,8 @@ impl Rocket {
|
||||
lifetime: i32::MAX,
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item,
|
||||
}
|
||||
@@ -63,7 +71,7 @@ impl Rocket {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
.data()
|
||||
@@ -89,6 +97,8 @@ impl Game<'_> {
|
||||
lifetime,
|
||||
explosions,
|
||||
attached,
|
||||
guest: None,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item: stack.clone(),
|
||||
}
|
||||
@@ -127,7 +137,8 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let rocket = self.new_rocket(at, stack, true);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
@@ -140,7 +151,18 @@ impl Game<'_> {
|
||||
while i < self.rockets.len() {
|
||||
let mut r = self.rockets.remove(i);
|
||||
r.previous = r.position;
|
||||
if r.attached {
|
||||
if r.attached
|
||||
&& let Some(conn) = r.guest
|
||||
{
|
||||
// A guest's rocket follows them; their own client boosts them.
|
||||
let guest = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|host| host.hub.guests.iter().find(|g| g.conn == conn));
|
||||
if let Some(g) = guest {
|
||||
r.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
}
|
||||
} else if r.attached {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.fall_flying {
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
@@ -229,8 +251,19 @@ impl Game<'_> {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
// The player it boosts takes the full damage, a guest as well.
|
||||
if r.attached {
|
||||
self.hurt_player_by("minecraft:fireworks", damage);
|
||||
match r.guest {
|
||||
None => self.hurt_player_by("minecraft:fireworks", damage),
|
||||
Some(conn) => {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.hurt_player_by("minecraft:fireworks", damage);
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = r.position;
|
||||
let hits: Vec<(u32, f32)> = {
|
||||
|
||||
@@ -162,6 +162,13 @@ fn mob_meta(
|
||||
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the off
|
||||
// hand 2 (a skeleton draws its bow, a witch raises her potion).
|
||||
let using = i8::from(m.extra.using_item > 0) | i8::from(m.extra.using_off_hand) << 1;
|
||||
if using != 0 {
|
||||
meta.push((8, serializer::BYTE, Value::Byte(using)));
|
||||
}
|
||||
defaults.push((8, serializer::BYTE, Value::Byte(0)));
|
||||
// LivingEntity.DATA_HEALTH_ID: defined as 1, then set to the max health.
|
||||
let health = m.health.max(0.0);
|
||||
if health != 1.0 {
|
||||
@@ -293,6 +300,21 @@ fn mob_meta(
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose and the held
|
||||
// item's loading animation.
|
||||
"minecraft:pillager" => {
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
"minecraft:evoker" | "minecraft:illusioner" => {
|
||||
if e.variant2 != 0 {
|
||||
meta.push((17, serializer::BYTE, Value::Byte(e.variant2 as i8)));
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
@@ -983,6 +1005,7 @@ impl Game<'_> {
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
self.guest_combat.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
|
||||
@@ -145,6 +145,8 @@ pub(super) struct GuestSwap {
|
||||
adv_loaded: bool,
|
||||
last_inventory: u64,
|
||||
death_cause: Option<String>,
|
||||
/// The guest's own damage record, in place while they act.
|
||||
combat: super::combat::CombatTracker,
|
||||
/// The guest whose turn this one nests in, if any.
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
/// The packets queued for that guest.
|
||||
@@ -260,6 +262,14 @@ impl Game<'_> {
|
||||
return None;
|
||||
};
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's own CombatTracker: damage to them is not the host's.
|
||||
// A new body starts with no record of the last one.
|
||||
let guest_combat = if fresh {
|
||||
Default::default()
|
||||
} else {
|
||||
self.guest_combat.remove(&conn).unwrap_or_default()
|
||||
};
|
||||
let host_combat = std::mem::replace(&mut self.combat, guest_combat);
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
motion.ground.on_ground = g.on_ground;
|
||||
@@ -268,6 +278,8 @@ impl Game<'_> {
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
let _ = motion.set_health(g.health, false);
|
||||
motion.health.food.food = g.food;
|
||||
@@ -424,6 +436,7 @@ impl Game<'_> {
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
combat: host_combat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -475,6 +488,7 @@ impl Game<'_> {
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
swings,
|
||||
@@ -482,6 +496,9 @@ impl Game<'_> {
|
||||
modifiers,
|
||||
} = swap;
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's damage record back into their slot, the host's in place.
|
||||
let guest_combat = std::mem::replace(&mut self.combat, combat);
|
||||
self.guest_combat.insert(conn, guest_combat);
|
||||
play.modifiers = std::mem::replace(&mut self.applied_modifiers, modifiers);
|
||||
// What the guest shot is theirs (Projectile.setOwner).
|
||||
for p in self.creatures.projectiles.iter_mut().skip(projectiles) {
|
||||
@@ -1265,31 +1282,30 @@ impl Game<'_> {
|
||||
host: &LanHost,
|
||||
stack: &Option<horizon_protocol::item::Stack>,
|
||||
) -> Option<ItemStack> {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
let s = stack.as_ref()?;
|
||||
let name = host
|
||||
.hub
|
||||
.item_name(s.item)
|
||||
.unwrap_or_else(|| "minecraft:air".into());
|
||||
let mut out = ItemStack::new(&name, s.count.max(1) as u32);
|
||||
out.damage = s.damage().max(0) as u32;
|
||||
if let Some(Component::Enchantments(list)) = s.component(component::ENCHANTMENTS) {
|
||||
for (id, level) in list {
|
||||
if let Some(e) = host.hub.enchantment_name(*id) {
|
||||
out.enchant(&e, *level as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Component::Potion {
|
||||
potion: Some(id), ..
|
||||
}) = s.component(component::POTION_CONTENTS)
|
||||
&& let Some(potion) = host.hub.registry_name("minecraft:potion", *id)
|
||||
{
|
||||
out = out.with_potion(potion);
|
||||
}
|
||||
let mut out = host.hub.stack_from_network(s);
|
||||
out.count = out.count.max(1);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `LivingEntity.tickDeath`: 20 ticks after dying the player is removed,
|
||||
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
|
||||
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
|
||||
/// sooner removes it too.
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
if g.death_time >= 20 {
|
||||
self.guest_drop_carried(host, gi);
|
||||
}
|
||||
}
|
||||
|
||||
fn guest_drop_carried(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed`: what a closing player held on the
|
||||
/// cursor goes back into their inventory (`placeItemBackInInventory`,
|
||||
/// dropped only if it doesn't fit), and their client's cursor empties
|
||||
@@ -1629,6 +1645,8 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn removes the dead player (Player.remove).
|
||||
self.guest_drop_carried(host, i);
|
||||
// PlayerList.respawn: a server's player comes back in its respawn
|
||||
// point's level: the Overworld, where the world spawn and beds are,
|
||||
// or another one's anchor or forced point, which its block resolves
|
||||
@@ -1669,6 +1687,7 @@ impl Game<'_> {
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.saturation = 5.0;
|
||||
@@ -1705,6 +1724,11 @@ impl Game<'_> {
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.level.hardcore {
|
||||
host.hub.set_game_mode(conn, SPECTATOR);
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
@@ -1752,7 +1776,11 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
self.guest_sleep_tick(host, gi);
|
||||
if host.hub.guests[gi].dead || self.on_server_trip(conn) {
|
||||
if host.hub.guests[gi].dead {
|
||||
self.guest_tick_death(host, gi);
|
||||
continue;
|
||||
}
|
||||
if self.on_server_trip(conn) {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
@@ -1850,31 +1878,34 @@ impl Game<'_> {
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
let message = match cause {
|
||||
// ServerPlayer.die: the death message, to everyone unless
|
||||
// the player's team hides it.
|
||||
Some(key) if self.ui.rules.bool("show_death_messages") => {
|
||||
let text = horizon_client::text::Text::translate(
|
||||
&key,
|
||||
&[self.player_text(&name, uuid)],
|
||||
);
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
message
|
||||
}
|
||||
Some(_) => literal(""),
|
||||
None => literal(&format!("{name} died")),
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -1890,6 +1921,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.die`'s message from a guest's own `CombatTracker`, which
|
||||
/// sits in `guest_combat` between turns.
|
||||
fn guest_death_message(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
me: horizon_client::text::Text,
|
||||
) -> horizon_client::text::Text {
|
||||
let mut tracker = self.guest_combat.remove(&conn).unwrap_or_default();
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
let text = self.combat_death_message_for(me);
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
self.guest_combat.insert(conn, tracker);
|
||||
text
|
||||
}
|
||||
|
||||
/// `Player.tick`'s sleep counter, and `LivingEntity.tick`'s wake-up of a
|
||||
/// sleeper who died or whose bed is gone.
|
||||
fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) {
|
||||
@@ -2000,6 +2046,7 @@ impl Game<'_> {
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let freeze_immune = game.wears_freeze_immune();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2063,11 +2110,18 @@ impl Game<'_> {
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
w.player.freeze_immune = freeze_immune;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// WitherRoseBlock.entityInside along those moves.
|
||||
for event in &events {
|
||||
if matches!(event, MotionEvent::InsideWitherRose) {
|
||||
game.wither_rose_contact();
|
||||
}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleMovePlayer:
|
||||
// Player.checkMovementStatistics for each move.
|
||||
for d in travelled {
|
||||
@@ -2091,10 +2145,14 @@ impl Game<'_> {
|
||||
resources: game.motion,
|
||||
}
|
||||
.server_travel();
|
||||
// Landing damage from this travel reaches the tracker too.
|
||||
events.append(&mut player.events);
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
(events, marked.then_some(player.velocity))
|
||||
let velocity = marked.then_some(player.velocity);
|
||||
game.record_combat(&events);
|
||||
(events, velocity)
|
||||
});
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
|
||||
@@ -225,6 +225,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
|
||||
_ => return false,
|
||||
};
|
||||
let furnace = matches!(kind, Kind::Furnace(_));
|
||||
// CrafterBlockEntity.containerData: the disabled slots, then triggered.
|
||||
let crafter_data = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)),
|
||||
@@ -284,6 +285,12 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// FurnaceResultSlot.checkTakeAchievements: the guest took from the output.
|
||||
let count = |s: Option<&ItemStack>| s.map_or(0, |s| s.count);
|
||||
if furnace && count(menu.slot(2)) < count(items[2].as_ref()) {
|
||||
let at = host.hub.guests[gi].position;
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
host.hub.inventory_clicked(conn, p, &self.world, &changed);
|
||||
true
|
||||
|
||||
@@ -404,11 +404,19 @@ impl Game<'_> {
|
||||
Value::Item(hub.stack(&Some(r.item.clone()))),
|
||||
));
|
||||
}
|
||||
let boosted = match r.guest {
|
||||
Some(conn) => hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.entity_id),
|
||||
None => host_entity,
|
||||
};
|
||||
if r.attached
|
||||
&& let Some(host) = host_entity
|
||||
&& let Some(player) = boosted
|
||||
{
|
||||
e.meta
|
||||
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(host + 1)));
|
||||
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(player + 1)));
|
||||
}
|
||||
if r.angled {
|
||||
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
|
||||
|
||||
@@ -218,7 +218,7 @@ impl Game<'_> {
|
||||
oy: f32,
|
||||
draws: &[ScreenDraw],
|
||||
background: bool,
|
||||
) -> Option<Vec<(String, u32)>> {
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut tooltip = None;
|
||||
let mut sheet: Option<String> = None;
|
||||
@@ -261,7 +261,10 @@ impl Game<'_> {
|
||||
..
|
||||
} => self.draw_banner_result(batch, ox + *x0 as f32, oy + *y0 as f32, base, layers),
|
||||
ScreenDraw::Tooltip { key } => {
|
||||
tooltip = Some(vec![(self.translate(key, key), u32::MAX)]);
|
||||
tooltip = Some(vec![(
|
||||
horizon_client::text::Text::literal(self.translate(key, key)),
|
||||
u32::MAX,
|
||||
)]);
|
||||
}
|
||||
ScreenDraw::SpriteOf { id } => sheet = Some(id.clone()),
|
||||
ScreenDraw::Fill {
|
||||
@@ -314,7 +317,7 @@ impl Game<'_> {
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
local: (f32, f32),
|
||||
) -> Option<Vec<(String, u32)>> {
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let s = self.station.as_ref()?;
|
||||
let l = &s.loom;
|
||||
let dye = s.inputs[1]
|
||||
|
||||
@@ -889,13 +889,31 @@ impl Game<'_> {
|
||||
let key_is_cart = self
|
||||
.container
|
||||
.is_some_and(|k| k.1 == super::minecarts::CART_KEY_Y);
|
||||
let (texture, height, title) = match screen {
|
||||
Screen::Hopper if key_is_cart => {
|
||||
("gui/container/hopper", 133.0, "Minecart with Hopper")
|
||||
}
|
||||
Screen::Hopper => ("gui/container/hopper", 133.0, "Item Hopper"),
|
||||
Screen::Dispenser => ("gui/container/dispenser", 166.0, "Dispenser"),
|
||||
Screen::Dropper => ("gui/container/dispenser", 166.0, "Dropper"),
|
||||
let (texture, height, key, fallback) = match screen {
|
||||
Screen::Hopper if key_is_cart => (
|
||||
"gui/container/hopper",
|
||||
133.0,
|
||||
"entity.minecraft.hopper_minecart",
|
||||
"Minecart with Hopper",
|
||||
),
|
||||
Screen::Hopper => (
|
||||
"gui/container/hopper",
|
||||
133.0,
|
||||
"container.hopper",
|
||||
"Item Hopper",
|
||||
),
|
||||
Screen::Dispenser => (
|
||||
"gui/container/dispenser",
|
||||
166.0,
|
||||
"container.dispenser",
|
||||
"Dispenser",
|
||||
),
|
||||
Screen::Dropper => (
|
||||
"gui/container/dispenser",
|
||||
166.0,
|
||||
"container.dropper",
|
||||
"Dropper",
|
||||
),
|
||||
};
|
||||
if let Some(mut r) = atlas.sprites.get(texture).copied() {
|
||||
let dv = (r.v1 - r.v0) / r.h as f32;
|
||||
@@ -905,23 +923,17 @@ impl Game<'_> {
|
||||
batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX);
|
||||
}
|
||||
let grey = horizon_client::hud::rgba(64, 64, 64, 255);
|
||||
let title = &self
|
||||
let title = self
|
||||
.remote_menu_title()
|
||||
.unwrap_or_else(|| self.container_title(title.to_owned()));
|
||||
.unwrap_or_else(|| self.container_title(self.translate(key, fallback)));
|
||||
let title_x = if screen == Screen::Hopper {
|
||||
8.0
|
||||
} else {
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, title)) / 2.0
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, &title)) / 2.0
|
||||
};
|
||||
batch.text(atlas, title, ox + title_x, oy + 6.0, grey, false);
|
||||
batch.text(
|
||||
atlas,
|
||||
"Inventory",
|
||||
ox + 8.0,
|
||||
oy + height - 94.0,
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, &title, ox + title_x, oy + 6.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + height - 94.0, grey, false);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,8 @@ impl Game<'_> {
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
self.paint_screen(batch, ox, oy, rest, true);
|
||||
}
|
||||
/// `gui.togglable_slot` over an empty, enabled grid slot.
|
||||
|
||||
@@ -259,7 +259,7 @@ impl Game<'_> {
|
||||
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
|
||||
for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let left = off_hand != mob.extra.main_hand_left;
|
||||
let left = off_hand != mob.main_arm_left();
|
||||
let Some(t) =
|
||||
render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left)
|
||||
else {
|
||||
@@ -305,7 +305,7 @@ impl Game<'_> {
|
||||
.player_model
|
||||
.as_ref()
|
||||
.is_some_and(|p| std::ptr::eq(p, mob));
|
||||
owner.main_arm_left = mob.extra.main_hand_left;
|
||||
owner.main_arm_left = mob.main_arm_left();
|
||||
if off_hand == mob.extra.using_off_hand && mob.extra.using_item > 0 {
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = 72_000;
|
||||
|
||||
@@ -20,10 +20,13 @@ use {
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
items::ItemStack,
|
||||
mod_list::Installed,
|
||||
options_ui::Page,
|
||||
text::{self, Text},
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
|
||||
std::time::Instant,
|
||||
};
|
||||
|
||||
@@ -39,6 +42,26 @@ pub(super) struct ModsState {
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
/// The server's command tree with the running mods' commands added.
|
||||
#[cfg(feature = "mods")]
|
||||
remote_tree: Option<Dispatcher>,
|
||||
/// A `ui` mod's screen or settings page while it is up.
|
||||
#[cfg(feature = "mods")]
|
||||
screen: Option<ModScreen>,
|
||||
}
|
||||
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods: the
|
||||
/// widgets the mod pushed, laid out and drawn with the menus' own widgets.
|
||||
#[cfg(feature = "mods")]
|
||||
struct ModScreen {
|
||||
/// The running mod's index in `mods.mods.entries()`.
|
||||
owner: usize,
|
||||
/// Opened from Options > Mods: Done and Esc return there.
|
||||
settings: bool,
|
||||
/// What the mod's screen replaced (Playing, Chat, ...), to restore.
|
||||
under: Screen,
|
||||
/// One scroll per `list` widget, in widget order.
|
||||
scrolls: Vec<ui::Scroll>,
|
||||
}
|
||||
impl ModsState {
|
||||
/// The chat's command tree: vanilla's, plus the running mods' commands.
|
||||
@@ -49,6 +72,14 @@ impl ModsState {
|
||||
}
|
||||
vanilla::game_dispatcher()
|
||||
}
|
||||
/// The server's tree plus the mods' commands, when a mod owns any.
|
||||
pub(super) fn remote_dispatcher(&self) -> Option<&Dispatcher> {
|
||||
#[cfg(feature = "mods")]
|
||||
if let Some(tree) = &self.remote_tree {
|
||||
return Some(tree);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether a mod runs: the chat keeps its lines for them.
|
||||
pub(super) fn running(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -158,6 +189,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn merge_remote_tree(&mut self) {}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
pub(super) fn tick_mods(&mut self) {}
|
||||
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
|
||||
@@ -166,6 +198,11 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn mods_chat(&mut self, _line: &str) {}
|
||||
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
|
||||
pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {}
|
||||
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
|
||||
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
|
||||
pub(super) fn mod_screen_click(&mut self) {}
|
||||
pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -187,6 +224,8 @@ impl Game<'_> {
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load(&paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
// Entries were replaced wholesale: a screen's owner index is stale.
|
||||
self.mods.screen = None;
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
self.show_mod_output();
|
||||
@@ -212,6 +251,7 @@ impl Game<'_> {
|
||||
description: String::new(),
|
||||
problem,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
@@ -227,6 +267,7 @@ impl Game<'_> {
|
||||
description: format!("{may} {}", m.description).trim().to_owned(),
|
||||
problem,
|
||||
dev: dev.contains(&e.path),
|
||||
has_settings: e.has_ui_settings(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -238,7 +279,20 @@ impl Game<'_> {
|
||||
.peek()
|
||||
.is_some()
|
||||
.then(|| vanilla::game_dispatcher().with_mod_commands(names));
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
|
||||
/// The server's command tree with the mods' commands, for the chat.
|
||||
pub(super) fn merge_remote_tree(&mut self) {
|
||||
let names: Vec<String> = self.mods.mods.commands().map(str::to_owned).collect();
|
||||
self.mods.remote_tree = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.commands.as_ref())
|
||||
.filter(|_| !names.is_empty())
|
||||
.map(|t| t.with_mod_commands(names.iter().map(String::as_str)));
|
||||
}
|
||||
|
||||
/// After the options changed: restart the mods if the list of the ones
|
||||
/// turned on did.
|
||||
pub(super) fn sync_mods(&mut self) {
|
||||
@@ -343,10 +397,22 @@ impl Game<'_> {
|
||||
Output::Feedback { text, error: false } => Text::literal(text),
|
||||
Output::Feedback { text, error: true } => Text::literal(text).with_color(text::RED),
|
||||
Output::Stopped { name, reason } => {
|
||||
// Its screen goes with it: the entry's run is gone, so
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
|
||||
}
|
||||
Output::OpenScreen { owner } => {
|
||||
self.open_mod_screen(owner, false);
|
||||
continue;
|
||||
}
|
||||
Output::CloseScreen { owner } => {
|
||||
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
|
||||
self.close_mod_screen(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -354,6 +420,12 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
}
|
||||
// A mod may set or clear its settings page in any call, not only
|
||||
// while loading: its row's Settings button follows.
|
||||
let rows = self.settings.mods.installed.iter_mut();
|
||||
for (row, e) in rows.zip(self.mods.mods.entries()) {
|
||||
row.has_settings = e.has_ui_settings();
|
||||
}
|
||||
}
|
||||
/// The mods' HUD panels, stacked with the extras in their corners.
|
||||
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
@@ -376,4 +448,297 @@ impl Game<'_> {
|
||||
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ui` screen's title and widgets as the mod last pushed them; None
|
||||
/// when the surface is gone (the mod stopped or closed it).
|
||||
fn mod_ui_view(&self) -> Option<(String, Vec<UiWidget>)> {
|
||||
let ms = self.mods.screen.as_ref()?;
|
||||
if ms.settings {
|
||||
let widgets = self.mods.mods.settings_widgets(ms.owner);
|
||||
if widgets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self
|
||||
.mods
|
||||
.mods
|
||||
.entries()
|
||||
.get(ms.owner)
|
||||
.and_then(|e| e.manifest.as_ref())
|
||||
.map_or("Mod", |m| m.name.as_str());
|
||||
Some((name.to_string(), widgets.to_vec()))
|
||||
} else {
|
||||
let s: &UiScreen = self.mods.mods.screen(ms.owner)?;
|
||||
Some((s.title.clone(), s.widgets.clone()))
|
||||
}
|
||||
}
|
||||
/// The layout the mod screen's widgets get at `h`: (widget, top,
|
||||
/// height), the column being `COLUMN` wide down the middle.
|
||||
fn mod_ui_layout(&self, h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
|
||||
let mut out = Vec::with_capacity(widgets.len());
|
||||
let mut y = MOD_UI_TOP;
|
||||
for w in widgets {
|
||||
let wh = match w {
|
||||
UiWidget::Text(_) => 12.0,
|
||||
UiWidget::Item(_) => 20.0,
|
||||
UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0,
|
||||
};
|
||||
// Nothing may cover Done; a crowded mod loses what won't fit.
|
||||
if y + wh > h - 34.0 {
|
||||
break;
|
||||
}
|
||||
out.push((y, wh));
|
||||
y += wh + 6.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
/// A mod pushes its screen (`ui.open-screen`), or Options > Mods opens
|
||||
/// its settings page (`settings`, the mod's `installed` index).
|
||||
pub(super) fn open_mod_screen(&mut self, owner: usize, settings: bool) {
|
||||
if self.title.is_some() {
|
||||
return;
|
||||
}
|
||||
if settings {
|
||||
self.mods.mods.ui_settings_opened(owner);
|
||||
if self.mods.mods.settings_widgets(owner).is_empty() {
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.mods.screen = Some(ModScreen {
|
||||
owner,
|
||||
settings,
|
||||
under: self.screen,
|
||||
scrolls: Vec::new(),
|
||||
});
|
||||
self.screen = Screen::Mod;
|
||||
if self.player.is_some() {
|
||||
self.capture(false);
|
||||
}
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Leaves the mod screen: back to Options for a settings page, to what
|
||||
/// it covered for a mod's own. `tell` is Esc/Done, heard as `closed`.
|
||||
pub(super) fn close_mod_screen(&mut self, tell: bool) {
|
||||
let Some(ms) = self.mods.screen.take() else {
|
||||
return;
|
||||
};
|
||||
if ms.settings {
|
||||
self.open_options_at(self.options_from_title, Page::Mods);
|
||||
} else {
|
||||
self.screen = ms.under;
|
||||
if self.screen == Screen::Playing && self.player.is_some() {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
if tell {
|
||||
self.mods.mods.ui_closed(ms.owner);
|
||||
self.show_mod_output();
|
||||
}
|
||||
}
|
||||
/// The mod's screen or settings page: its title over widgets stacked
|
||||
/// down the middle, a Done button, Esc to leave (vanilla's menus).
|
||||
pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) {
|
||||
if self.mods.screen.is_none() {
|
||||
self.screen = Screen::Playing;
|
||||
return;
|
||||
}
|
||||
let Some((title, widgets)) = self.mod_ui_view() else {
|
||||
self.mods.screen = None;
|
||||
self.screen = Screen::Playing;
|
||||
return;
|
||||
};
|
||||
// Item icons draw in a second pass: `draw_item` needs `&mut self`,
|
||||
// so `atlas` may not stay borrowed across it.
|
||||
let mut items: Vec<(String, f32, f32)> = Vec::new();
|
||||
let atlas = &self.gui.atlas;
|
||||
let in_world = self.player.is_some();
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, batch.width, batch.height, in_world);
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(title),
|
||||
0.0,
|
||||
15.0,
|
||||
batch.width,
|
||||
u32::MAX,
|
||||
);
|
||||
let cx = (batch.width / 2.0).floor();
|
||||
let x = cx - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(batch.height, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
match w {
|
||||
UiWidget::Text(t) => {
|
||||
let color = if t.color == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
t.color | 0xFF00_0000
|
||||
};
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(t.text.clone()),
|
||||
x,
|
||||
wy + 1.0,
|
||||
MOD_UI_COLUMN,
|
||||
color,
|
||||
);
|
||||
}
|
||||
UiWidget::Item(i) => {
|
||||
if self.res.items().get(&i.item).is_some() {
|
||||
items.push((i.item.clone(), x + 2.0, wy + 2.0));
|
||||
} else {
|
||||
batch.text(atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true);
|
||||
}
|
||||
let name = if i.name.is_empty() {
|
||||
i.item.rsplit(':').next().unwrap_or(&i.item).to_string()
|
||||
} else {
|
||||
i.name.clone()
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(name),
|
||||
x + 22.0,
|
||||
wy + 6.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
UiWidget::List(l) => {
|
||||
let scroll = {
|
||||
let ms = self.mods.screen.as_mut().expect("checked");
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
ms.scrolls[list_i]
|
||||
};
|
||||
list_i += 1;
|
||||
let rows_top = wy + 4.0;
|
||||
let rows_bottom = wy + wh - 4.0;
|
||||
batch.fill(atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000);
|
||||
for (b, yy) in [(x, wy), (x, wy + wh - 1.0)] {
|
||||
batch.fill(atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050);
|
||||
}
|
||||
for (xx, yy) in [(x, wy), (x + MOD_UI_COLUMN - 1.0, wy)] {
|
||||
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
|
||||
}
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
let first = (scroll.amount / 12.0) as usize;
|
||||
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
|
||||
.min(l.rows.len());
|
||||
for (vi, ri) in (first..last).enumerate() {
|
||||
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
|
||||
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
|
||||
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
|
||||
}
|
||||
if l.selected == Some(ri as u32) {
|
||||
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
|
||||
}
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(l.rows[ri].clone()),
|
||||
x + 6.0,
|
||||
ry + 2.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + MOD_UI_COLUMN - 5.0,
|
||||
rows_top,
|
||||
rows_bottom,
|
||||
content,
|
||||
scroll.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let hover = ui::hovered(self.cursor, cx - 100.0, batch.height - 27.0, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
cx - 100.0,
|
||||
batch.height - 27.0,
|
||||
200.0,
|
||||
20.0,
|
||||
&Text::literal("Done"),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
for (id, ix, iy) in items {
|
||||
let stack = ItemStack::new(id.as_str(), 1);
|
||||
self.draw_item(batch, &stack, ix, iy);
|
||||
}
|
||||
}
|
||||
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
|
||||
/// `ui-events::select`.
|
||||
pub(super) fn mod_screen_click(&mut self) {
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.close_mod_screen(true);
|
||||
return;
|
||||
}
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
return;
|
||||
};
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let scroll = self
|
||||
.mods
|
||||
.screen
|
||||
.as_ref()
|
||||
.and_then(|ms| ms.scrolls.get(list_i).copied())
|
||||
.unwrap_or_default();
|
||||
list_i += 1;
|
||||
let row = ((cursor.1 - (wy + 4.0) + scroll.amount) / 12.0) as i64;
|
||||
if ui::hovered(cursor, x + 1.0, wy + 1.0, MOD_UI_COLUMN - 7.0, wh - 2.0)
|
||||
&& row >= 0
|
||||
&& (row as usize) < l.rows.len()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
self.mods.mods.ui_select(
|
||||
self.mods.screen.as_ref().map_or(0, |ms| ms.owner),
|
||||
l.id,
|
||||
row as u32,
|
||||
);
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The wheel over a `list` scrolls it.
|
||||
pub(super) fn mod_screen_scroll(&mut self, delta: f32) {
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
return;
|
||||
};
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let over = ui::hovered(cursor, x, wy, MOD_UI_COLUMN, wh);
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
if over {
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
ms.scrolls[list_i].wheel(delta, 12.0, wy + 4.0, wy + wh - 4.0, content);
|
||||
return;
|
||||
}
|
||||
list_i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_TOP: f32 = 40.0;
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_COLUMN: f32 = 200.0;
|
||||
|
||||
@@ -470,7 +470,8 @@ impl Game<'_> {
|
||||
None => String::new(),
|
||||
};
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == open.mob) {
|
||||
self.draw_mob_preview(
|
||||
batch,
|
||||
|
||||
@@ -151,10 +151,13 @@ impl Game<'_> {
|
||||
.clone()
|
||||
.unwrap_or_else(|| Text::literal(&name))
|
||||
};
|
||||
// StringDecomposer.iterateFormatted: `§` codes a server leaves
|
||||
// inside the team prefix or the name itself apply at draw time.
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(member),
|
||||
None => member,
|
||||
};
|
||||
}
|
||||
.resolve_legacy();
|
||||
let mut lines = Vec::new();
|
||||
// EntityRenderer.extractNameTags: the score shows within the
|
||||
// below-name distance (10), under the name.
|
||||
@@ -162,7 +165,7 @@ impl Game<'_> {
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score, 0.0));
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
@@ -213,14 +216,15 @@ impl Game<'_> {
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(name.clone()),
|
||||
None => name.clone(),
|
||||
};
|
||||
}
|
||||
.resolve_legacy();
|
||||
let mut lines = Vec::new();
|
||||
let mut lift = 0.0;
|
||||
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score, 0.0));
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
|
||||
@@ -237,6 +237,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
@@ -246,6 +247,7 @@ impl Game<'_> {
|
||||
pub(super) fn load_language(&mut self, code: &str) {
|
||||
let root = self.options.resources.clone();
|
||||
lang::set(lang::Language::load(&[&root], code));
|
||||
self.refresh_remote_language();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
}
|
||||
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
|
||||
|
||||
@@ -203,6 +203,7 @@ impl Game<'_> {
|
||||
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.clear_lod();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
|
||||
@@ -921,8 +921,10 @@ impl Game<'_> {
|
||||
} else if self.book.is_some() {
|
||||
self.book_input(key, text);
|
||||
} else if self.screen == Screen::Creative && self.creative_screen.tab().search {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
self.creative_search_edit(text, backspace);
|
||||
if backspace {
|
||||
self.creative_search_backspace(false);
|
||||
} else if key != Some(KeyCode::Enter) {
|
||||
self.creative_search_edit(text);
|
||||
}
|
||||
} else if self.recipe_search_focused() {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
|
||||
@@ -147,8 +147,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Species::Strider if !baby => self.spawn_strider_jockey(id, at),
|
||||
// ZombieHorse.finalizeSpawn: a zombie with an iron spear.
|
||||
Species::ZombieHorse if natural => {
|
||||
// ZombieHorse.finalizeSpawn: a zombie with an iron spear. The rider
|
||||
// comes from EntityType.create, which makes no zombie in peaceful
|
||||
// (canSpawn), so a peaceful zombie horse spawns without one.
|
||||
Species::ZombieHorse
|
||||
if natural
|
||||
&& (self.ui.difficulty != 0
|
||||
|| horizon_client::mobs::spawn_rules::allowed_in_peaceful(
|
||||
Species::Zombie,
|
||||
)) =>
|
||||
{
|
||||
if let Some(zombie) = self.spawn_mob("minecraft:zombie", at, false) {
|
||||
if let Some(z) = self.mobs.iter_mut().find(|m| m.id == zombie) {
|
||||
z.extra.main_hand = Some("minecraft:iron_spear".into());
|
||||
|
||||
@@ -755,7 +755,7 @@ impl Game<'_> {
|
||||
image_width,
|
||||
&self.potions.effects.list,
|
||||
self.clock.rate(),
|
||||
&|key| self.pack.names.get(key).map(str::to_owned),
|
||||
&|key| self.pack.names.display_get(key),
|
||||
&|text| self.gui.atlas.font.width(text).ceil() as i32,
|
||||
);
|
||||
if let Some(lines) = inventory_effects::paint(
|
||||
|
||||
@@ -502,7 +502,7 @@ impl Game<'_> {
|
||||
horizon_client::item_tooltip::lines(
|
||||
stack,
|
||||
self.res.items().get(&stack.item),
|
||||
&|k| self.pack.names.get(k).map(str::to_owned),
|
||||
&|k| self.pack.names.display_get(k),
|
||||
)
|
||||
})
|
||||
{
|
||||
@@ -1163,8 +1163,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// Paints recipe book calls; returns the first tooltip set (vanilla keeps
|
||||
/// the first one a frame asks for).
|
||||
fn paint_book(&mut self, batch: &mut HudBatch, calls: &[Drawn]) -> Option<Vec<(String, u32)>> {
|
||||
let mut tooltip: Option<Vec<(String, u32)>> = None;
|
||||
fn paint_book(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
calls: &[Drawn],
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
for d in calls {
|
||||
let start = batch.vertices.len();
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -1259,7 +1263,14 @@ impl Game<'_> {
|
||||
tooltip = Some(
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| (l.iter().map(|r| r.text.as_str()).collect(), u32::MAX))
|
||||
.map(|l| {
|
||||
(
|
||||
horizon_client::text::Text::literal(
|
||||
l.iter().map(|r| r.text.as_str()).collect::<String>(),
|
||||
),
|
||||
u32::MAX,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
@@ -1269,10 +1280,10 @@ impl Game<'_> {
|
||||
let mut lines = self.stack_tooltip(stack);
|
||||
if *more {
|
||||
lines.push((
|
||||
self.translate(
|
||||
horizon_client::text::Text::literal(self.translate(
|
||||
"gui.recipebook.moreRecipes",
|
||||
"Right Click for More",
|
||||
),
|
||||
)),
|
||||
u32::MAX,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -223,30 +223,6 @@ fn face_direction(face: [i32; 3]) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `minecraft:profile` component as the item data JSON the model reads.
|
||||
fn profile_tag(p: &horizon_protocol::item::Profile) -> Tag {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(name, value, signature)| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", Tag::String(name.as_str().into())),
|
||||
("value", Tag::String(value.as_str().into())),
|
||||
];
|
||||
if let Some(signature) = signature {
|
||||
entries.push(("signature", Tag::String(signature.as_str().into())));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", Tag::String(name.as_str().into())));
|
||||
}
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
/// A protocol number format as the scoreboard's.
|
||||
fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFormat {
|
||||
use horizon_client::scoreboard::{NumberFormat, tag_to_json};
|
||||
@@ -610,188 +586,32 @@ impl Game<'_> {
|
||||
.and_then(|i| i.id(name))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// A stack from the server, decoded as the saved and command item NBT
|
||||
/// are, so tooltips and models see every field.
|
||||
pub(super) fn to_stack(&self, s: &Option<Stack>) -> Option<ItemStack> {
|
||||
let s = s.as_ref()?;
|
||||
let mut out = ItemStack {
|
||||
item: self.item_name(s.item),
|
||||
count: s.count.max(0) as u32,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
// A vanilla `components` compound, then the same decoder the saved
|
||||
// and command item NBT use, so tooltips and models see every field.
|
||||
let components = self.protocol_components(s);
|
||||
let (damage, data) = horizon_client::item_data::decode_components(components.as_ref());
|
||||
out.damage = damage;
|
||||
if let Some(data) = data {
|
||||
*out.data_mut() = *data;
|
||||
}
|
||||
out.tidy();
|
||||
Some(out)
|
||||
}
|
||||
/// The item's patched components as vanilla's `components` compound.
|
||||
fn protocol_components(&self, s: &Stack) -> Option<Tag> {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
let r = self.remote.as_ref()?;
|
||||
let name = |id: i32| r.builtin.get("minecraft:data_component_type")?.name(id);
|
||||
let mut entries: Vec<(&str, Tag)> = Vec::new();
|
||||
for (kind, c) in &s.added {
|
||||
let Some(key) = name(*kind) else {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
Component::Trim { material, pattern } => {
|
||||
let (Some(material), Some(pattern)) = (
|
||||
r.registries.name("minecraft:trim_material", *material),
|
||||
r.registries.name("minecraft:trim_pattern", *pattern),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
Tag::compound([
|
||||
("material", Tag::String(material.into())),
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
}
|
||||
Component::Int(v) => Tag::Int(*v),
|
||||
Component::Unit => Tag::Compound(Default::default()),
|
||||
Component::Bool(v) => Tag::Byte(i8::from(*v)),
|
||||
Component::Text(t) | Component::Nbt(t) => t.clone(),
|
||||
Component::Ident(v) => Tag::String(v.as_str().into()),
|
||||
Component::Lore(lines) => Tag::List(lines.clone()),
|
||||
Component::WritableBook(pages) => Tag::compound([(
|
||||
"pages",
|
||||
Tag::List(
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| Tag::String(p.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
)]),
|
||||
Component::WrittenBook(b) => Tag::compound([
|
||||
("title", Tag::String(b.title.as_str().into())),
|
||||
("author", Tag::String(b.author.as_str().into())),
|
||||
("generation", Tag::Int(b.generation)),
|
||||
("pages", Tag::List(b.pages.clone())),
|
||||
("resolved", Tag::Byte(i8::from(b.resolved))),
|
||||
]),
|
||||
Component::Enchantments(list) => {
|
||||
// Enchantments are a synced (data-driven) registry: the
|
||||
// ids index the server's RegistryData, not the builtin
|
||||
// report, so a datapack enchantment names correctly.
|
||||
let levels = list.iter().filter_map(|(id, level)| {
|
||||
let name = r.registries.name("minecraft:enchantment", *id)?;
|
||||
Some((name, Tag::Int(*level)))
|
||||
});
|
||||
Tag::compound(levels)
|
||||
}
|
||||
Component::AttackRange(v) => Tag::compound([
|
||||
("min_reach", Tag::Float(v[0].to_bits())),
|
||||
("max_reach", Tag::Float(v[1].to_bits())),
|
||||
("min_attack_momentum", Tag::Float(v[2].to_bits())),
|
||||
("max_attack_momentum", Tag::Float(v[3].to_bits())),
|
||||
("hitbox_margin", Tag::Float(v[4].to_bits())),
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// PotionContents' NBT: the potion by name and the custom colour.
|
||||
Component::Potion { potion, color, .. } => {
|
||||
let potion = potion.and_then(|id| r.builtin.get("minecraft:potion")?.name(id));
|
||||
Tag::compound(
|
||||
potion
|
||||
.map(|name| ("potion", Tag::String(name.into())))
|
||||
.into_iter()
|
||||
.chain(color.map(|c| ("custom_color", Tag::Int(c)))),
|
||||
)
|
||||
}
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut entries = vec![
|
||||
("id", Tag::String(self.item_name(t.item).as_ref().into())),
|
||||
("count", Tag::Int(t.count)),
|
||||
];
|
||||
if let Some(c) = self.protocol_components(t) {
|
||||
entries.push(("components", c));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Component::CustomModelData {
|
||||
floats,
|
||||
flags,
|
||||
strings,
|
||||
colors,
|
||||
} => Tag::compound([
|
||||
(
|
||||
"floats",
|
||||
Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()),
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()),
|
||||
),
|
||||
(
|
||||
"strings",
|
||||
Tag::List(
|
||||
strings
|
||||
.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"colors",
|
||||
Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()),
|
||||
),
|
||||
]),
|
||||
Component::TooltipDisplay { hide, hidden } => {
|
||||
let hidden = hidden
|
||||
.iter()
|
||||
.filter_map(|id| Some(Tag::String(name(*id)?.into())));
|
||||
Tag::compound([
|
||||
("hide_tooltip", Tag::Byte(i8::from(*hide))),
|
||||
("hidden_components", Tag::List(hidden.collect())),
|
||||
])
|
||||
}
|
||||
};
|
||||
entries.push((key, value));
|
||||
}
|
||||
Some(Tag::compound(entries))
|
||||
Some(horizon_client::item_sync::from_network(
|
||||
s.as_ref()?,
|
||||
&r.builtin,
|
||||
&r.registries,
|
||||
))
|
||||
}
|
||||
/// A stack to the server (`SetCreativeModeSlot`): the same component
|
||||
/// patch a Horizon server sends its clients, with the registries' ids, so
|
||||
/// the server's echo doesn't hand back a bare item.
|
||||
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(self.item_id(&s.item), s.count as i32);
|
||||
// A creative pick keeps its potion (PotionContents by its builtin id).
|
||||
if let Some(id) = s.potion().and_then(|p| {
|
||||
let potions = self.remote.as_ref()?.builtin.get("minecraft:potion")?;
|
||||
potions.id(&horizon_world::datafix::dynamic::namespaced(p))
|
||||
}) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::POTION_CONTENTS,
|
||||
horizon_protocol::item::Component::Potion {
|
||||
potion: Some(id),
|
||||
color: None,
|
||||
rest: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
if s.damage > 0 {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::DAMAGE,
|
||||
horizon_protocol::item::Component::Int(s.damage as i32),
|
||||
));
|
||||
}
|
||||
Some(stack)
|
||||
let r = self.remote.as_ref()?;
|
||||
horizon_client::lan::guest::stack_to_network(
|
||||
|name| self.item_id(name),
|
||||
// Built-in first, as a hub's `SyncData::id` does: potions are one.
|
||||
|registry, name| {
|
||||
r.builtin
|
||||
.get(registry)
|
||||
.and_then(|ids| ids.id(name))
|
||||
.or_else(|| r.registries.id(registry, name))
|
||||
},
|
||||
s,
|
||||
)
|
||||
}
|
||||
/// A line in the connection's chat, with its styles, click and hover
|
||||
/// events (ChatComponent.addMessage).
|
||||
@@ -2084,25 +1904,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS):
|
||||
// another player starts using the item in a hand (eating, a
|
||||
// bow drawn, a shield up), or stops.
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
|
||||
// too, remote_mob_data's apply reads index 8 above.
|
||||
let other = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id) && r.entity_id != p.id);
|
||||
if other && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (8, Value::Byte(flags)) = (index, value) {
|
||||
m.extra.using_off_hand = flags & 2 != 0;
|
||||
if flags & 1 == 0 {
|
||||
m.extra.using_item = 0;
|
||||
} else if m.extra.using_item == 0 {
|
||||
m.extra.using_item = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
|
||||
// player lies down in their bed (drawn lying in it), or gets up.
|
||||
if other {
|
||||
@@ -2352,12 +2159,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::PlayerCombatKill(p) => {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
let message = plain(&p.message, &lang);
|
||||
// The component resolves in the client's current language, as
|
||||
// the chat line does, not in the language the session joined
|
||||
// with (`Remote.lang` is the vanilla server-language table).
|
||||
let message = remote_text(&p.message).plain();
|
||||
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = Some(message);
|
||||
@@ -2560,6 +2365,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.commands = Some(tree);
|
||||
}
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
// The component as sent, translated in the client's own
|
||||
@@ -3587,25 +3393,28 @@ impl Game<'_> {
|
||||
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
|
||||
*steps -= 1;
|
||||
}
|
||||
// LivingEntity.updatingUsingItem for another player: the ticks the
|
||||
// item has been used before this one.
|
||||
if player && m.extra.using_item > 0 {
|
||||
let item = if m.extra.using_off_hand {
|
||||
m.extra.off_hand.clone()
|
||||
} else {
|
||||
m.extra.main_hand.clone()
|
||||
};
|
||||
if let Some(item) = item {
|
||||
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,
|
||||
m.extra.using_item as u32 - 1,
|
||||
at,
|
||||
eye,
|
||||
m.head_pitch,
|
||||
m.yaw,
|
||||
));
|
||||
// LivingEntity.updatingUsingItem: the ticks the item has been
|
||||
// used before this one. Another player's use particles; a mob's
|
||||
// drawn bow only needs the count (its pull follows it).
|
||||
if m.extra.using_item > 0 {
|
||||
if player {
|
||||
let item = if m.extra.using_off_hand {
|
||||
m.extra.off_hand.clone()
|
||||
} else {
|
||||
m.extra.main_hand.clone()
|
||||
};
|
||||
if let Some(item) = item {
|
||||
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,
|
||||
m.extra.using_item as u32 - 1,
|
||||
at,
|
||||
eye,
|
||||
m.head_pitch,
|
||||
m.yaw,
|
||||
));
|
||||
}
|
||||
}
|
||||
m.extra.using_item += 1;
|
||||
}
|
||||
@@ -5002,6 +4811,7 @@ impl Game<'_> {
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: std::collections::HashMap::new(),
|
||||
}))
|
||||
}
|
||||
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
|
||||
@@ -5086,6 +4896,15 @@ impl Game<'_> {
|
||||
pub(super) fn remote_menu_title(&self) -> Option<String> {
|
||||
self.remote.as_ref()?.menu_title.clone()
|
||||
}
|
||||
/// A language change reaches the server-sent components too: their titles
|
||||
/// are resolved when the packet arrives, with the session's language.
|
||||
pub(super) fn refresh_remote_language(&mut self) {
|
||||
if let Some(lang) = horizon_client::lang::current()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.lang = Arc::new(lang.map().clone());
|
||||
}
|
||||
}
|
||||
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
|
||||
pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
|
||||
@@ -20,6 +20,17 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
// off hand 2. The flag starts and stops the use; the client ticks
|
||||
// the count itself (a bow's pull follows it).
|
||||
(8, Value::Byte(flags)) => {
|
||||
m.extra.using_off_hand = flags & 2 != 0;
|
||||
if flags & 1 == 0 {
|
||||
m.extra.using_item = 0;
|
||||
} else if m.extra.using_item == 0 {
|
||||
m.extra.using_item = 1;
|
||||
}
|
||||
}
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
@@ -53,6 +64,13 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
|
||||
m.extra.variant2 = (*spell).max(0) as u8;
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
@@ -239,4 +257,42 @@ mod tests {
|
||||
);
|
||||
assert_eq!(name(&z), ("desert", "farmer", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mob_flags_drive_the_aggressive_poses() {
|
||||
let Some(mut m) = mob("minecraft:skeleton") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(2 | 4))]);
|
||||
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
|
||||
assert!(!m.ai.aggressive && !m.extra.left_handed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_living_flags_start_and_stop_a_mob_using_an_item() {
|
||||
let Some(mut m) = mob("minecraft:skeleton") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(1))]);
|
||||
assert_eq!(m.extra.using_item, 1);
|
||||
assert!(!m.extra.using_off_hand);
|
||||
// Using in the off hand.
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(3))]);
|
||||
assert!(m.extra.using_off_hand);
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.extra.using_item, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pillager_charges_and_a_spellcaster_casts() {
|
||||
let Some(mut p) = mob("minecraft:pillager") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut p, &[(17, s::BOOLEAN, Value::Bool(true))]);
|
||||
assert!(p.extra.flag);
|
||||
let mut e = mob("minecraft:evoker").unwrap();
|
||||
apply(&mut e, &[(17, s::BYTE, Value::Byte(2))]);
|
||||
assert_eq!(e.extra.variant2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -125,6 +125,37 @@ impl Game<'_> {
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
self.remote_hook_data(id, values);
|
||||
if self.rockets.iter().any(|r| r.id == id) {
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
// FireworkRocketEntity.DATA_ID_FIREWORKS_ITEM: its
|
||||
// explosions for `createFireworks`.
|
||||
(8, Value::Item(stack)) => {
|
||||
if let Some(stack) = self.to_stack(stack)
|
||||
&& let Some(r) = self.rockets.iter_mut().find(|r| r.id == id)
|
||||
{
|
||||
let (_, explosions) = fireworks_of(&stack);
|
||||
r.explosions = explosions;
|
||||
r.item = stack;
|
||||
}
|
||||
}
|
||||
// DATA_ATTACHED_TO_TARGET (OptionalInt: the id plus 1).
|
||||
(9, Value::Id(v)) => {
|
||||
let v = (*v > 0).then(|| (*v - 1) as u32);
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.attached_to = v;
|
||||
}
|
||||
}
|
||||
// DATA_SHOT_AT_ANGLE.
|
||||
(10, Value::Bool(a)) => {
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.angled = *a;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
@@ -213,9 +244,30 @@ impl Game<'_> {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
}
|
||||
// FireworkRocketEntity.tick: a rocket attached to the gliding local
|
||||
// player boosts it client-side too (attachedToEntity.isFallFlying).
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
for r in &mut self.rockets {
|
||||
let id = r.id;
|
||||
r.remote_step(|at| step(id, at));
|
||||
if me.is_some()
|
||||
&& r.attached_to == me
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
&& p.fall_flying
|
||||
{
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
}
|
||||
for c in &mut self.crystals {
|
||||
c.time += 1;
|
||||
|
||||
@@ -332,6 +332,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.load_scoreboard();
|
||||
host.hub.teams.clone_from(&self.score.board.teams);
|
||||
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
if g.scoreboard != view {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
command::args::GameMode,
|
||||
hud::{HudBatch, rgba},
|
||||
text::{self, Text},
|
||||
ui,
|
||||
@@ -17,7 +18,8 @@ impl Screen {
|
||||
| Self::Stats
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends => true,
|
||||
| Self::Friends
|
||||
| Self::Mod => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
| Self::Chat
|
||||
@@ -81,9 +83,15 @@ fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
(x0 + 72.0, y, "gui/sprites/pause_menu/player_reporting"),
|
||||
]
|
||||
}
|
||||
fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
|
||||
/// DeathScreen.init: a hardcore player can only spectate.
|
||||
fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] {
|
||||
let respawn = if hardcore {
|
||||
"deathScreen.spectate"
|
||||
} else {
|
||||
"deathScreen.respawn"
|
||||
};
|
||||
[
|
||||
(w / 2.0 - 100.0, h / 4.0 + 72.0, "deathScreen.respawn", 0),
|
||||
(w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0),
|
||||
(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0,
|
||||
@@ -223,7 +231,12 @@ impl Game<'_> {
|
||||
),
|
||||
);
|
||||
}
|
||||
let title = Text::translate("deathScreen.title", &[]);
|
||||
let title = if self.level.hardcore {
|
||||
"deathScreen.title.hardcore"
|
||||
} else {
|
||||
"deathScreen.title"
|
||||
};
|
||||
let title = Text::translate(title, &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
ui::text_scaled(
|
||||
batch,
|
||||
@@ -258,7 +271,7 @@ impl Game<'_> {
|
||||
.ui
|
||||
.died_at
|
||||
.is_none_or(|t| t.elapsed().as_millis() >= 1000);
|
||||
for (x, y, key, _) in death_buttons(w, h) {
|
||||
for (x, y, key, _) in death_buttons(w, h, self.level.hardcore) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -286,7 +299,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
let hit = death_buttons(b.width, b.height)
|
||||
let hit = death_buttons(b.width, b.height, self.level.hardcore)
|
||||
.into_iter()
|
||||
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
|
||||
.map(|d| d.3);
|
||||
@@ -330,6 +343,11 @@ impl Game<'_> {
|
||||
Some(1) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if hit == Some(0) && self.remote.is_none() && self.level.hardcore {
|
||||
self.set_mode(GameMode::Spectator);
|
||||
}
|
||||
if hit.is_some() {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
|
||||
@@ -248,13 +248,14 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(16, 16, 16, 160));
|
||||
let title = if sign.hanging {
|
||||
"Edit Hanging Sign Message"
|
||||
let (key, fallback) = if sign.hanging {
|
||||
("hanging_sign.edit", "Edit Hanging Sign Message")
|
||||
} else {
|
||||
"Edit Sign Message"
|
||||
("sign.edit", "Edit Sign Message")
|
||||
};
|
||||
let tw = HudBatch::text_width(atlas, title);
|
||||
batch.text(atlas, title, (w - tw) / 2.0, 40.0, u32::MAX, true);
|
||||
let title = self.translate(key, fallback);
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(atlas, &title, (w - tw) / 2.0, 40.0, u32::MAX, true);
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -373,12 +373,29 @@ impl GameLevel<'_> {
|
||||
let mut neighbour_updates = 0;
|
||||
if side_effects {
|
||||
match kind {
|
||||
PreRemove::Contents { slots, .. } => {
|
||||
PreRemove::Contents { slots, furnace } => {
|
||||
let items = entity.as_ref().map_or(&[][..], BlockEntity::slots);
|
||||
for slot in 0..slots {
|
||||
let stack = items.get(slot).cloned().flatten();
|
||||
self.drop_item_stack(pos, stack);
|
||||
}
|
||||
// AbstractFurnaceBlockEntity.preRemoveSideEffects: then the
|
||||
// experience of what it made, at the block's centre.
|
||||
if furnace && let Some(BlockEntity::Furnace(f)) = &entity {
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
for (count, experience) in f.recipe_experience(self.recipes) {
|
||||
let xp = horizon_client::containers::create_experience(
|
||||
count,
|
||||
experience,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.award_orbs(at, xp);
|
||||
}
|
||||
}
|
||||
}
|
||||
PreRemove::Campfire => {
|
||||
for _ in 0..4 {
|
||||
|
||||
@@ -383,6 +383,8 @@ impl Loading {
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -277,15 +277,10 @@ fn argb(c: i32) -> u32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn data(&self) -> &stations::StationData {
|
||||
self.res.stations()
|
||||
}
|
||||
/// `Component.translatable`: the selected language (which `lang` layers
|
||||
/// over `en_us`), the caller's English text last.
|
||||
pub(super) fn translate(&self, key: &str, fallback: &str) -> String {
|
||||
self.data()
|
||||
.enchantments
|
||||
.translate(key)
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
horizon_client::lang::get_or(key, fallback)
|
||||
}
|
||||
fn brewing_open(&self) -> bool {
|
||||
matches!(self.container_kind(), Some(BlockEntity::Brewing(_)))
|
||||
@@ -421,7 +416,8 @@ impl Game<'_> {
|
||||
pub(super) fn anvil_item_name(&self, s: &Station) -> String {
|
||||
match &s.inputs[0] {
|
||||
Some(input)
|
||||
if input.custom_name().is_none() && s.name == self.pack.names.item(&input.item) =>
|
||||
if input.custom_name().is_none()
|
||||
&& s.name == self.pack.names.display_item(&input.item) =>
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
@@ -462,7 +458,7 @@ impl Game<'_> {
|
||||
let name = self.anvil_item_name(&s);
|
||||
let hover = s.inputs[0]
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
s.anvil = stations::anvil_result(
|
||||
&data.enchantments,
|
||||
@@ -895,7 +891,7 @@ impl Game<'_> {
|
||||
s.anvil_last = s.inputs[0].clone();
|
||||
s.name = s.inputs[0]
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
self.station_changed();
|
||||
@@ -1096,7 +1092,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
let mut tooltip: Option<Vec<(String, u32)>> = None;
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
{
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
@@ -1345,16 +1341,19 @@ impl Game<'_> {
|
||||
let clue = self
|
||||
.translate("container.enchant.clue", "%s . . . ?")
|
||||
.replace("%s", &name);
|
||||
let mut lines = vec![(clue, u32::MAX)];
|
||||
let mut lines =
|
||||
vec![(horizon_client::text::Text::literal(clue), u32::MAX)];
|
||||
if !self.creative {
|
||||
lines.push((String::new(), u32::MAX));
|
||||
lines.push((horizon_client::text::Text::literal(""), u32::MAX));
|
||||
if level < cost {
|
||||
lines.push((
|
||||
self.translate(
|
||||
"container.enchant.level.requirement",
|
||||
"Level Requirement: %s",
|
||||
)
|
||||
.replace("%s", &cost.to_string()),
|
||||
horizon_client::text::Text::literal(
|
||||
self.translate(
|
||||
"container.enchant.level.requirement",
|
||||
"Level Requirement: %s",
|
||||
)
|
||||
.replace("%s", &cost.to_string()),
|
||||
),
|
||||
rgba(255, 85, 85, 255),
|
||||
));
|
||||
} else {
|
||||
@@ -1372,7 +1371,7 @@ impl Game<'_> {
|
||||
.replace("%s", &n.to_string())
|
||||
};
|
||||
lines.push((
|
||||
lapis,
|
||||
horizon_client::text::Text::literal(lapis),
|
||||
if gold >= n as i32 {
|
||||
rgba(170, 170, 170, 255)
|
||||
} else {
|
||||
@@ -1391,7 +1390,10 @@ impl Game<'_> {
|
||||
)
|
||||
.replace("%s", &n.to_string())
|
||||
};
|
||||
lines.push((levels, rgba(170, 170, 170, 255)));
|
||||
lines.push((
|
||||
horizon_client::text::Text::literal(levels),
|
||||
rgba(170, 170, 170, 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
tooltip = Some(lines);
|
||||
@@ -1500,10 +1502,10 @@ impl Game<'_> {
|
||||
);
|
||||
if inside(local, 65.0, 46.0, 28.0, 21.0) {
|
||||
tooltip = Some(vec![(
|
||||
self.translate(
|
||||
horizon_client::text::Text::literal(self.translate(
|
||||
"container.upgrade.error_tooltip",
|
||||
"Item can't be upgraded this way",
|
||||
),
|
||||
)),
|
||||
u32::MAX,
|
||||
)]);
|
||||
}
|
||||
@@ -1630,9 +1632,12 @@ impl Game<'_> {
|
||||
|
||||
// ---------------------------------------------------------------- tooltips
|
||||
|
||||
/// `ItemStack.getTooltipLines` subset: name in rarity colour, potion
|
||||
/// effects, enchantments (gray, curses red), stored enchantments, trims.
|
||||
pub(super) fn stack_tooltip(&self, stack: &ItemStack) -> Vec<(String, u32)> {
|
||||
/// `ItemStack.getTooltipLines` subset: name in rarity colour, lore,
|
||||
/// potion effects, enchantments (gray, curses red), stored enchantments, trims.
|
||||
pub(super) fn stack_tooltip(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let e: &Enchantments = &self.res.stations().enchantments;
|
||||
let props = e.props(&stack.item);
|
||||
let rarity = horizon_client::item_tooltip::rarity(
|
||||
@@ -1648,39 +1653,43 @@ impl Game<'_> {
|
||||
let name = if let Some(n) = stack.custom_name() {
|
||||
n.to_owned()
|
||||
} else if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
e.translate("block.minecraft.ominous_banner")
|
||||
.map_or_else(|| "Ominous Banner".to_owned(), str::to_owned)
|
||||
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
|
||||
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
|
||||
|| stack.item.ends_with("potion")
|
||||
|| &*stack.item == "minecraft:tipped_arrow"
|
||||
{
|
||||
let key = brewing::potion_name_key(&stack.item, stack.potion());
|
||||
e.translate(&key)
|
||||
.map_or_else(|| self.pack.names.item(&stack.item), str::to_owned)
|
||||
self.pack
|
||||
.names
|
||||
.display_get(&key)
|
||||
.unwrap_or_else(|| self.pack.names.display_item(&stack.item))
|
||||
} else {
|
||||
self.pack.names.item(&stack.item)
|
||||
self.pack.names.display_item(&stack.item)
|
||||
};
|
||||
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
|
||||
let mut lines = Vec::new();
|
||||
if display.name_visible(self.screen == Screen::Creative) {
|
||||
lines.push((name, color));
|
||||
lines.push((horizon_client::text::Text::legacy(&name), color));
|
||||
}
|
||||
let translate = |k: &str| e.translate(k).map(str::to_owned);
|
||||
let translate = |k: &str| self.pack.names.display_get(k);
|
||||
let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| {
|
||||
lines.push((horizon_client::text::Text::legacy(&s), c));
|
||||
};
|
||||
// ItemContainerContents.addToTooltip.
|
||||
let hover_name = |s: &ItemStack| {
|
||||
s.custom_name()
|
||||
.map_or_else(|| self.pack.names.item(&s.item), str::to_owned)
|
||||
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
|
||||
};
|
||||
if display.shows("minecraft:container") {
|
||||
for line in
|
||||
for text in
|
||||
horizon_client::item_tooltip::container_lines(stack, &hover_name, &translate)
|
||||
{
|
||||
lines.push((line, u32::MAX));
|
||||
push(&mut lines, text, u32::MAX);
|
||||
}
|
||||
}
|
||||
// Fireworks / FireworkExplosion.addToTooltip.
|
||||
for line in horizon_client::item_tooltip::firework_lines(stack, &translate) {
|
||||
lines.push((line, rgba(170, 170, 170, 255)));
|
||||
for text in horizon_client::item_tooltip::firework_lines(stack, &translate) {
|
||||
push(&mut lines, text, rgba(170, 170, 170, 255));
|
||||
}
|
||||
if display.shows("minecraft:potion_contents")
|
||||
&& (stack.item.ends_with("potion") || &*stack.item == "minecraft:tipped_arrow")
|
||||
@@ -1690,7 +1699,7 @@ impl Game<'_> {
|
||||
"minecraft:tipped_arrow" => 0.125,
|
||||
_ => 1.0,
|
||||
};
|
||||
for (line, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
|
||||
for (text, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
|
||||
e.translate(k).map(str::to_owned)
|
||||
}) {
|
||||
let c = match cat {
|
||||
@@ -1698,18 +1707,19 @@ impl Game<'_> {
|
||||
brewing::Category::Harmful => rgba(255, 85, 85, 255),
|
||||
brewing::Category::Neutral => rgba(170, 170, 170, 255),
|
||||
};
|
||||
lines.push((line, c));
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:trim")
|
||||
&& let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone())
|
||||
{
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
e.translate("item.minecraft.smithing_template.upgrade")
|
||||
.unwrap_or("Upgrade:")
|
||||
.to_owned(),
|
||||
rgba(170, 170, 170, 255),
|
||||
));
|
||||
);
|
||||
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
|
||||
let p = e
|
||||
.translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
|
||||
@@ -1717,38 +1727,51 @@ impl Game<'_> {
|
||||
let m = e
|
||||
.translate(&format!("trim_material.minecraft.{}", short(&material)))
|
||||
.map_or_else(|| short(&material), str::to_owned);
|
||||
lines.push((format!(" {p}"), rgba(170, 170, 170, 255)));
|
||||
lines.push((format!(" {m}"), rgba(170, 170, 170, 255)));
|
||||
push(&mut lines, format!(" {p}"), rgba(170, 170, 170, 255));
|
||||
push(&mut lines, format!(" {m}"), rgba(170, 170, 170, 255));
|
||||
}
|
||||
if display.shows("minecraft:stored_enchantments") {
|
||||
for (text, curse) in e.tooltip(stack.stored_enchantments()) {
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
text,
|
||||
if curse {
|
||||
rgba(255, 85, 85, 255)
|
||||
} else {
|
||||
rgba(170, 170, 170, 255)
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:enchantments") {
|
||||
for (text, curse) in e.tooltip(stack.enchantments()) {
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
text,
|
||||
if curse {
|
||||
rgba(255, 85, 85, 255)
|
||||
} else {
|
||||
rgba(170, 170, 170, 255)
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers.addToTooltip: up to six layer names.
|
||||
if display.shows("minecraft:banner_patterns") {
|
||||
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
|
||||
let key = self.res.stations().banners.layer_key(layer);
|
||||
lines.push((self.translate(&key, &key), rgba(170, 170, 170, 255)));
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(&key, &key),
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ItemLore.addToTooltip: after the enchantments, before the attributes
|
||||
// (ItemStack.addDetailsToTooltip).
|
||||
if display.shows("minecraft:lore") {
|
||||
for text in horizon_client::item_tooltip::lore_texts(stack) {
|
||||
lines.push((text, u32::MAX));
|
||||
}
|
||||
}
|
||||
// Spawner.appendHoverText: what a spawner or trial spawner item spawns.
|
||||
@@ -1762,15 +1785,15 @@ impl Game<'_> {
|
||||
match block_entity_item::spawner_entity(stack) {
|
||||
Some(id) => {
|
||||
let key = format!("entity.{}", id.replacen(':', ".", 1));
|
||||
lines.push((self.translate(&key, &id), gray));
|
||||
push(&mut lines, self.translate(&key, &id), gray);
|
||||
}
|
||||
None => {
|
||||
lines.push((String::new(), u32::MAX));
|
||||
push(&mut lines, String::new(), u32::MAX);
|
||||
let desc1 =
|
||||
self.translate("block.minecraft.spawner.desc1", "Interact with Spawn Egg:");
|
||||
lines.push((desc1, gray));
|
||||
push(&mut lines, desc1, gray);
|
||||
let desc2 = self.translate("block.minecraft.spawner.desc2", "Sets Mob Type");
|
||||
lines.push((format!(" {desc2}"), rgba(85, 85, 255, 255)));
|
||||
push(&mut lines, format!(" {desc2}"), rgba(85, 85, 255, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1798,11 +1821,13 @@ impl Game<'_> {
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED,
|
||||
),
|
||||
];
|
||||
lines.extend(horizon_client::item_tooltip::attribute_lines(
|
||||
for (text, c) in horizon_client::item_tooltip::attribute_lines(
|
||||
&info.modifiers,
|
||||
Some(player_base),
|
||||
&translate,
|
||||
));
|
||||
) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:damage")
|
||||
&& let Some(info) = self.res.items().get(&stack.item)
|
||||
@@ -1810,14 +1835,15 @@ impl Game<'_> {
|
||||
&& stack.damage > 0
|
||||
&& self.options_advanced_tooltips()
|
||||
{
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
format!(
|
||||
"Durability: {} / {}",
|
||||
info.max_damage - stack.damage,
|
||||
info.max_damage
|
||||
),
|
||||
u32::MAX,
|
||||
));
|
||||
);
|
||||
}
|
||||
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
|
||||
if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
|
||||
@@ -1832,7 +1858,11 @@ impl Game<'_> {
|
||||
"Do not use unless you know the exact contents!",
|
||||
),
|
||||
] {
|
||||
lines.push((self.translate(key, fallback), rgba(255, 85, 85, 255)));
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(key, fallback),
|
||||
rgba(255, 85, 85, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
lines
|
||||
@@ -1863,20 +1893,27 @@ impl Game<'_> {
|
||||
self.settings.file.bool("advancedItemTooltips")
|
||||
}
|
||||
/// `TooltipRenderUtil`: dark box with the purple border, first line spaced.
|
||||
pub(super) fn draw_tooltip_lines(
|
||||
pub(super) fn draw_tooltip_lines<T: Clone + Into<horizon_client::text::Text>>(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
lines: &[(String, u32)],
|
||||
lines: &[(T, u32)],
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lines: Vec<_> = lines
|
||||
.iter()
|
||||
.map(|(text, color)| {
|
||||
let text: horizon_client::text::Text = text.clone().into();
|
||||
(text.resolve_legacy(), *color)
|
||||
})
|
||||
.collect();
|
||||
let atlas = &self.gui.atlas;
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|(t, _)| HudBatch::text_width(atlas, t))
|
||||
.map(|(t, _)| atlas.font.width_text(t))
|
||||
.fold(0.0, f32::max);
|
||||
let h = if lines.len() == 1 {
|
||||
8.0
|
||||
@@ -1929,7 +1966,7 @@ impl Game<'_> {
|
||||
);
|
||||
let mut ty = y;
|
||||
for (i, (text, color)) in lines.iter().enumerate() {
|
||||
batch.text(atlas, text, x, ty, *color, true);
|
||||
batch.text_styled(atlas, text, x, ty, *color, true);
|
||||
ty += if i == 0 { 12.0 } else { 10.0 };
|
||||
}
|
||||
}
|
||||
@@ -1946,7 +1983,12 @@ impl Game<'_> {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(text) = self.crafter_tooltip(slot) {
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
&[(horizon_client::text::Text::literal(text), u32::MAX)],
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,8 @@ impl Game<'_> {
|
||||
);
|
||||
let title = self.translate("container.shulkerBox", "Shulker Box");
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 73.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
|
||||
return;
|
||||
}
|
||||
let rows = self.chest_rows().unwrap_or(3);
|
||||
@@ -301,9 +302,10 @@ impl Game<'_> {
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(
|
||||
atlas,
|
||||
"Inventory",
|
||||
&inventory,
|
||||
ox + 8.0,
|
||||
oy + 114.0 + rows as f32 * 18.0 - 94.0,
|
||||
grey,
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Game<'_> {
|
||||
Some(HeldName {
|
||||
item: stack.item.to_string(),
|
||||
hover_name: Text::literal(item_tooltip::name_line(stack, &|key| {
|
||||
self.pack.names.get(key).map(str::to_owned)
|
||||
self.pack.names.display_get(key)
|
||||
})),
|
||||
rarity_color: match rarity {
|
||||
"uncommon" => text::YELLOW,
|
||||
|
||||
@@ -399,7 +399,7 @@ impl Game<'_> {
|
||||
}
|
||||
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
|
||||
let clicks = {
|
||||
let names = |id: &str| self.pack.names.item(id);
|
||||
let names = |id: &str| self.pack.names.display_item(id);
|
||||
let rank = |s: &ItemStack| {
|
||||
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
|
||||
(order, hover_name(&names, s))
|
||||
|
||||
@@ -92,6 +92,8 @@ impl Game<'_> {
|
||||
match who {
|
||||
Some(Who::Player) => {
|
||||
if !self.creative {
|
||||
// WitherSkull.onHitEntity's source: the wither launched it.
|
||||
self.combat.projectile_owner = owner;
|
||||
self.mob_hits_player_kind(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
|
||||
@@ -516,6 +516,18 @@ fn sprite_split() -> u32 {
|
||||
}
|
||||
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
|
||||
const SPRITE_ARRAYS: u32 = 8;
|
||||
/// The layers to give a square texture of `count` layers viewed as a 2D
|
||||
/// array. wgpu's OpenGL backend picks a texture's target from its layer
|
||||
/// count when it is created: one layer makes a 2D texture and a multiple of
|
||||
/// six a cube map, neither of which can be viewed as an array.
|
||||
fn array_layers(count: u32, gl: bool) -> u32 {
|
||||
match count {
|
||||
_ if !gl => count,
|
||||
0 | 1 => 2,
|
||||
n if n % 6 == 0 => n + 1,
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The views of terrain.wgsl's sprite arrays, the unused ones a 1-layer
|
||||
/// stand-in.
|
||||
fn sprite_views(
|
||||
@@ -683,25 +695,31 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends
|
||||
/// itself. Only Windows has two: Auto tries Vulkan, then DirectX 12 when
|
||||
/// Vulkan has no adapter (no Vulkan driver) or no device. A native crash in
|
||||
/// the driver can't be caught in the game; the launcher switches the setting
|
||||
/// to DirectX 12 after one at the renderer's start (#746).
|
||||
fn backend_order(api: GraphicsApi, windows: bool, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
if !windows || wgpu_backend {
|
||||
return vec![wgpu::Backends::default()];
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
|
||||
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
|
||||
/// before has no surface, adapter or device. A native crash in the driver can't be
|
||||
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
|
||||
/// the renderer's start (#746). macOS has Metal only.
|
||||
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
use wgpu::Backends;
|
||||
if os == "macos" || wgpu_backend {
|
||||
return vec![Backends::default()];
|
||||
}
|
||||
let windows = os == "windows";
|
||||
match api {
|
||||
GraphicsApi::Auto => vec![wgpu::Backends::VULKAN, wgpu::Backends::DX12],
|
||||
GraphicsApi::Vulkan => vec![wgpu::Backends::VULKAN],
|
||||
GraphicsApi::Dx12 => vec![wgpu::Backends::DX12],
|
||||
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
|
||||
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
|
||||
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
|
||||
GraphicsApi::Vulkan | GraphicsApi::Dx12 => vec![Backends::VULKAN],
|
||||
GraphicsApi::OpenGl => vec![Backends::GL],
|
||||
}
|
||||
}
|
||||
fn backends_name(backends: wgpu::Backends) -> &'static str {
|
||||
match backends {
|
||||
wgpu::Backends::VULKAN => "Vulkan",
|
||||
wgpu::Backends::DX12 => "DirectX 12",
|
||||
wgpu::Backends::GL => "OpenGL",
|
||||
_ => "graphics",
|
||||
}
|
||||
}
|
||||
@@ -780,7 +798,7 @@ async fn open_device(
|
||||
) -> Result<Opened> {
|
||||
let order = backend_order(
|
||||
api.with_env(),
|
||||
cfg!(windows),
|
||||
std::env::consts::OS,
|
||||
std::env::var_os("WGPU_BACKEND").is_some(),
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
@@ -793,7 +811,13 @@ async fn open_device(
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
Some(instance.create_surface(window.clone())?)
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name} surface: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
@@ -863,14 +887,14 @@ async fn open_device(
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
#[cfg(target_os = "windows")]
|
||||
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan or DirectX 12 found: Horizon needs a GPU \
|
||||
driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
|
||||
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan, DirectX 12 or OpenGL found: Horizon needs \
|
||||
a GPU driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
|
||||
start the game again";
|
||||
#[cfg(target_os = "macos")]
|
||||
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan or OpenGL graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's), or OpenGL through EGL";
|
||||
/// Logs a step of the renderer's start, for a native crash in the graphics
|
||||
/// driver: it leaves no panic or crash report, so the log's last line is the
|
||||
/// only sign of where the game stopped. The log file is written unbuffered;
|
||||
@@ -1300,13 +1324,17 @@ impl Renderer {
|
||||
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
|
||||
});
|
||||
let mut mip_levels = 1;
|
||||
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
|
||||
let sprite_textures: Vec<wgpu::Texture> = prepared
|
||||
.sprites
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(array, (mips, levels))| {
|
||||
.map(|(array, (mut mips, levels))| {
|
||||
let first = array as u32 * split;
|
||||
let count = layers.saturating_sub(first).clamp(1, split);
|
||||
let used = layers.saturating_sub(first).clamp(1, split);
|
||||
let count = array_layers(used, gl);
|
||||
// The extra layers are empty.
|
||||
mips.resize(mips.len() / used as usize * count as usize, 0);
|
||||
mip_levels = levels;
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
@@ -1340,6 +1368,7 @@ impl Renderer {
|
||||
sprite_mips,
|
||||
&sprite_textures,
|
||||
split,
|
||||
animate::SpriteAnimator::compute(&adapter, &device),
|
||||
);
|
||||
let sprite_unused = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -1347,7 +1376,7 @@ impl Renderer {
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
depth_or_array_layers: array_layers(1, gl),
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
@@ -2057,16 +2086,15 @@ impl Renderer {
|
||||
// One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*):
|
||||
// the shader is specialised, so a pixel pays for none of the others.
|
||||
let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| {
|
||||
let (entry, blend) = if i & 4 == 0 {
|
||||
("fs_screen", alpha_blend)
|
||||
} else {
|
||||
("fs_vignette", vignette_blend)
|
||||
let blur = i as u32 & crate::screen_effects::SAMPLE_BLUR != 0;
|
||||
let (entry, blend) = match (i & 4 == 0, blur) {
|
||||
(true, false) => ("fs_screen", alpha_blend),
|
||||
(true, true) => ("fs_screen_blur", alpha_blend),
|
||||
(false, false) => ("fs_vignette", vignette_blend),
|
||||
(false, true) => ("fs_vignette_blur", vignette_blend),
|
||||
};
|
||||
let on = |bit: u32| f64::from(u8::from(i as u32 & bit != 0));
|
||||
let constants = [
|
||||
("BLUR", on(crate::screen_effects::SAMPLE_BLUR)),
|
||||
("CLAMP", on(crate::screen_effects::SAMPLE_CLAMP)),
|
||||
];
|
||||
let clamp = i as u32 & crate::screen_effects::SAMPLE_CLAMP != 0;
|
||||
let constants = [("CLAMP", f64::from(u8::from(clamp)))];
|
||||
let compilation_options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
@@ -2154,15 +2182,26 @@ impl Renderer {
|
||||
)
|
||||
});
|
||||
let sorted_indices = arena::Arena::indices(&device, 1 << 16, flat.is_some());
|
||||
let multi_draw = device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
|
||||
&& adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
// Without indirect draws (OpenGL before 4.3 without compute shaders)
|
||||
// even creating an indirect buffer fails; one draw per section reads
|
||||
// `draws` on the CPU instead.
|
||||
let indirect = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section draws"),
|
||||
size: 1024 * 20,
|
||||
usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
|
||||
usage: if multi_draw {
|
||||
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST
|
||||
} else {
|
||||
wgpu::BufferUsages::COPY_DST
|
||||
},
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let multi_draw = device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE);
|
||||
// HORIZON_GPU_CULL=0 turns occlusion culling off, `all` culls every
|
||||
// frame however few its draws and little it hides, `shader` tests in
|
||||
// the shader as on Metal (A/B checks).
|
||||
@@ -4469,25 +4508,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn windows_tries_vulkan_then_directx_12() {
|
||||
fn auto_tries_vulkan_then_directx_12_then_opengl() {
|
||||
use super::{GraphicsApi, backend_order};
|
||||
use wgpu::Backends;
|
||||
let order = |api, os| backend_order(api, os, false);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Auto, true, false),
|
||||
[Backends::VULKAN, Backends::DX12]
|
||||
order(GraphicsApi::Auto, "windows"),
|
||||
[Backends::VULKAN, Backends::DX12, Backends::GL]
|
||||
);
|
||||
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Vulkan, true, false),
|
||||
[Backends::VULKAN]
|
||||
order(GraphicsApi::Auto, "linux"),
|
||||
[Backends::VULKAN, Backends::GL]
|
||||
);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Dx12, true, false),
|
||||
[Backends::DX12]
|
||||
);
|
||||
// WGPU_BACKEND picks the backends itself; other systems have one.
|
||||
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
|
||||
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
|
||||
for api in GraphicsApi::ALL {
|
||||
assert_eq!(backend_order(api, true, true), [Backends::default()]);
|
||||
assert_eq!(backend_order(api, false, false), [Backends::default()]);
|
||||
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
|
||||
@@ -4547,6 +4588,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gl_sprite_arrays_are_neither_2d_textures_nor_cube_maps() {
|
||||
for (count, gl) in [(1, 2), (2, 2), (5, 5), (6, 7), (12, 13), (2048, 2048)] {
|
||||
assert_eq!(super::array_layers(count, true), gl);
|
||||
assert_eq!(super::array_layers(count, false), count);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn entity_shader_validates_with_its_overlay_constants() {
|
||||
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! frames. Blending on the CPU at the array's size and copying every changed
|
||||
//! sprite level by level (a copy command each, a blit each in Mesa) was about
|
||||
//! a fifth of the render thread on an integrated GPU.
|
||||
//!
|
||||
//! Without compute shaders (OpenGL before 4.3, the last fallback) the same
|
||||
//! jobs run on the CPU, the same integer mix as `animate.wgsl`, and each
|
||||
//! changed level is copied into its layer.
|
||||
use super::upload::Uploads;
|
||||
use crate::mipmap::Settings;
|
||||
use crate::sprites::{FrameBlend, SpriteAnimation};
|
||||
@@ -38,17 +42,32 @@ struct Sprite {
|
||||
struct Group {
|
||||
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
|
||||
offset: u64,
|
||||
/// One bind group per mip level of the array.
|
||||
/// One bind group per mip level of the array (none on the CPU).
|
||||
levels: Vec<wgpu::BindGroup>,
|
||||
/// This tick's jobs.
|
||||
jobs: Vec<u8>,
|
||||
/// Its sprite array and its frames, for the CPU.
|
||||
array: usize,
|
||||
buffer: usize,
|
||||
}
|
||||
|
||||
/// Where the jobs run.
|
||||
enum Writer {
|
||||
Gpu {
|
||||
jobs: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
},
|
||||
/// Every frames buffer's words, and the sprite arrays to copy into.
|
||||
Cpu {
|
||||
frames: Vec<Vec<u8>>,
|
||||
textures: Vec<wgpu::Texture>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
jobs: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
writer: Writer,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
/// The layers the last `animate` rewrote, counted over all arrays.
|
||||
@@ -57,7 +76,8 @@ pub(super) struct SpriteAnimator {
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size.
|
||||
/// wide; `animations` hold their frames at that size. `compute`: whether
|
||||
/// the device says it runs compute shaders writing storage textures.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
animations: &[SpriteAnimation],
|
||||
@@ -65,11 +85,18 @@ impl SpriteAnimator {
|
||||
settings: &[Settings],
|
||||
textures: &[wgpu::Texture],
|
||||
split: u32,
|
||||
compute: bool,
|
||||
) -> Self {
|
||||
let pipeline = if compute { pipeline(device) } else { None };
|
||||
let limits = device.limits();
|
||||
let limit = limits
|
||||
.max_storage_buffer_binding_size
|
||||
.min(limits.max_buffer_size);
|
||||
let limit = if pipeline.is_some() {
|
||||
limits
|
||||
.max_storage_buffer_binding_size
|
||||
.min(limits.max_buffer_size)
|
||||
} else {
|
||||
// Word offsets.
|
||||
u64::from(u32::MAX) * 4
|
||||
};
|
||||
let mut frames: Vec<Vec<u8>> = Vec::new();
|
||||
// Each group's sprite array and frames buffer.
|
||||
let mut keys: Vec<(u32, usize)> = Vec::new();
|
||||
@@ -119,6 +146,29 @@ impl SpriteAnimator {
|
||||
group,
|
||||
});
|
||||
}
|
||||
let Some(pipeline) = pipeline else {
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
.map(|(&(array, buffer), &count)| Group {
|
||||
offset: 0,
|
||||
levels: Vec::new(),
|
||||
jobs: Vec::with_capacity(count * JOB_BYTES),
|
||||
array: array as usize,
|
||||
buffer,
|
||||
})
|
||||
.collect();
|
||||
return Self {
|
||||
sprites,
|
||||
groups,
|
||||
writer: Writer::Cpu {
|
||||
frames,
|
||||
textures: textures.to_vec(),
|
||||
},
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
};
|
||||
};
|
||||
let frames: Vec<_> = frames
|
||||
.iter()
|
||||
.map(|data| {
|
||||
@@ -142,18 +192,6 @@ impl SpriteAnimator {
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("animate"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
@@ -198,19 +236,33 @@ impl SpriteAnimator {
|
||||
offset,
|
||||
levels,
|
||||
jobs: Vec::with_capacity(count * JOB_BYTES),
|
||||
array: array as usize,
|
||||
buffer,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sprites,
|
||||
groups,
|
||||
jobs,
|
||||
pipeline,
|
||||
writer: Writer::Gpu { jobs, pipeline },
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the jobs can run on the GPU: compute shaders writing a
|
||||
/// storage texture and reading two storage buffers (not OpenGL before
|
||||
/// 4.3).
|
||||
pub fn compute(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
let limits = device.limits();
|
||||
adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)
|
||||
&& limits.max_storage_textures_per_shader_stage >= 1
|
||||
&& limits.max_storage_buffers_per_shader_stage >= 2
|
||||
}
|
||||
|
||||
/// The layers the last `animate` rewrote (for the shader pack atlas).
|
||||
pub fn changed(&self) -> &[u32] {
|
||||
&self.changed
|
||||
@@ -227,14 +279,47 @@ impl SpriteAnimator {
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
let (jobs, pipeline) = match &self.writer {
|
||||
Writer::Gpu { jobs, pipeline } => (jobs, pipeline),
|
||||
Writer::Cpu { frames, textures } => {
|
||||
for group in &mut self.groups {
|
||||
let (frames, texture) = (&frames[group.buffer], &textures[group.array]);
|
||||
for job in group.jobs.as_chunks::<JOB_BYTES>().0 {
|
||||
let (words, _) = job.as_chunks::<4>();
|
||||
let job: [u32; 5] = std::array::from_fn(|i| u32::from_le_bytes(words[i]));
|
||||
for level in 0..texture.mip_level_count() {
|
||||
let width = self.size >> level;
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: job[0],
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
width,
|
||||
width,
|
||||
&mix_level(frames, job, width),
|
||||
);
|
||||
}
|
||||
}
|
||||
group.jobs.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
for group in &self.groups {
|
||||
uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs);
|
||||
uploads.buffer(encoder, jobs, group.offset, &group.jobs);
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_pipeline(pipeline);
|
||||
for group in &mut self.groups {
|
||||
let count = (group.jobs.len() / JOB_BYTES) as u32;
|
||||
if count == 0 {
|
||||
@@ -278,6 +363,62 @@ impl SpriteAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
|
||||
/// driver can list compute shaders among its extensions while wgpu writes
|
||||
/// the GLSL version of its core version, which has none (Mesa's
|
||||
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("animate"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => Some(pipeline),
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl` on the CPU: one `width` wide level of a job
|
||||
/// (`[layer, current, next, millis, edge]`) from `frames`' chains.
|
||||
fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u32) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity((width * width * 4) as usize);
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
let texel = if width >= edge {
|
||||
let scale = width / edge;
|
||||
y / scale * edge + x / scale
|
||||
} else {
|
||||
4 * (edge * edge - width * width) / 3 + y * width + x
|
||||
};
|
||||
let at = |chain: u32| ((chain + texel) * 4) as usize;
|
||||
let a = &frames[at(current)..at(current) + 4];
|
||||
if millis == 0 {
|
||||
out.extend_from_slice(a);
|
||||
continue;
|
||||
}
|
||||
let b = &frames[at(next)..at(next) + 4];
|
||||
out.extend(a.iter().zip(b).map(|(&a, &b)| {
|
||||
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000) as u8
|
||||
}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The mip chain a frame's layer is made of, and its first level's edge.
|
||||
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
@@ -512,6 +653,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gpu_layers_match_the_cpu_chains_every_tick() {
|
||||
layers_match_the_cpu_chains_every_tick(true);
|
||||
}
|
||||
|
||||
/// Without compute shaders (OpenGL before 4.3): the same layers.
|
||||
#[test]
|
||||
fn copied_layers_match_the_cpu_chains_every_tick() {
|
||||
layers_match_the_cpu_chains_every_tick(false);
|
||||
}
|
||||
|
||||
fn layers_match_the_cpu_chains_every_tick(compute: bool) {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
@@ -552,12 +703,15 @@ mod tests {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split);
|
||||
let mut animator =
|
||||
SpriteAnimator::new(&device, &animations, size, &mips, &textures, split, compute);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut writes = Vec::new();
|
||||
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
|
||||
|
||||
@@ -98,19 +98,27 @@ impl PresentMode {
|
||||
/// macOS). Applies at the next start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GraphicsApi {
|
||||
/// Vulkan, and DirectX 12 when Vulkan has no adapter or device.
|
||||
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
|
||||
#[default]
|
||||
Auto,
|
||||
Vulkan,
|
||||
Dx12,
|
||||
/// OpenGL, for GPUs with no Vulkan or DirectX 12 driver (Windows and Linux).
|
||||
OpenGl,
|
||||
}
|
||||
impl GraphicsApi {
|
||||
pub const ALL: [GraphicsApi; 3] = [GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::Dx12];
|
||||
pub const ALL: [GraphicsApi; 4] = [
|
||||
GraphicsApi::Auto,
|
||||
GraphicsApi::Vulkan,
|
||||
GraphicsApi::Dx12,
|
||||
GraphicsApi::OpenGl,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GraphicsApi::Auto => "auto",
|
||||
GraphicsApi::Vulkan => "vulkan",
|
||||
GraphicsApi::Dx12 => "dx12",
|
||||
GraphicsApi::OpenGl => "opengl",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -118,6 +126,7 @@ impl GraphicsApi {
|
||||
GraphicsApi::Auto => "Auto",
|
||||
GraphicsApi::Vulkan => "Vulkan",
|
||||
GraphicsApi::Dx12 => "DirectX 12",
|
||||
GraphicsApi::OpenGl => "OpenGL",
|
||||
}
|
||||
}
|
||||
/// The API a name stands for, ignoring case; `None` for anything else.
|
||||
@@ -126,7 +135,7 @@ impl GraphicsApi {
|
||||
.into_iter()
|
||||
.find(|api| api.name().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
/// `HORIZON_GRAPHICS_API` (auto, vulkan or dx12) if set, else the setting.
|
||||
/// `HORIZON_GRAPHICS_API` (auto, vulkan, dx12 or opengl) if set, else the setting.
|
||||
pub fn with_env(self) -> Self {
|
||||
std::env::var("HORIZON_GRAPHICS_API")
|
||||
.ok()
|
||||
|
||||
@@ -903,6 +903,9 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
let text = text.resolved();
|
||||
let font = &atlas.font;
|
||||
let alpha = (color >> 24) as u8;
|
||||
let mut glyphs = Vec::new();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Item stacks on the wire (`ItemStack.OPTIONAL_STREAM_CODEC`): the component
|
||||
//! patch read back as vanilla's `components` compound, so the decoder the saved
|
||||
//! and command item NBT use fills every field. Both sides use it: a client for
|
||||
//! what its server sends, a server for a creative client's
|
||||
//! `SetCreativeModeSlot`. The other direction is `lan::guest::stack_to_network`.
|
||||
use crate::items::ItemStack;
|
||||
use horizon_protocol::item::{Component, Profile, Stack, component};
|
||||
use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stack from the wire: its item by the built-in registry's id, its
|
||||
/// components by the synced registries' (enchantments, trims).
|
||||
pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack {
|
||||
let mut out = ItemStack {
|
||||
item: item_name(builtin, s.item),
|
||||
count: s.count.max(0) as u32,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let components = components_tag(s, builtin, synced);
|
||||
let (damage, data) = crate::item_data::decode_components(components.as_ref());
|
||||
out.damage = damage;
|
||||
if let Some(data) = data {
|
||||
*out.data_mut() = *data;
|
||||
}
|
||||
out.tidy();
|
||||
out
|
||||
}
|
||||
|
||||
fn item_name(builtin: &Builtin, id: i32) -> Arc<str> {
|
||||
Arc::from(
|
||||
builtin
|
||||
.get("minecraft:item")
|
||||
.and_then(|i| i.name(id))
|
||||
.unwrap_or("minecraft:air"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The item's patched components as vanilla's `components` compound.
|
||||
fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag> {
|
||||
let name = |id: i32| builtin.get("minecraft:data_component_type")?.name(id);
|
||||
let mut entries: Vec<(&str, Tag)> = Vec::new();
|
||||
for (kind, c) in &s.added {
|
||||
let Some(key) = name(*kind) else {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
Component::Trim { material, pattern } => {
|
||||
let (Some(material), Some(pattern)) = (
|
||||
synced.name("minecraft:trim_material", *material),
|
||||
synced.name("minecraft:trim_pattern", *pattern),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
Tag::compound([
|
||||
("material", Tag::String(material.into())),
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
}
|
||||
Component::Int(v) => Tag::Int(*v),
|
||||
Component::Unit => Tag::Compound(Default::default()),
|
||||
Component::Bool(v) => Tag::Byte(i8::from(*v)),
|
||||
Component::Text(t) | Component::Nbt(t) => t.clone(),
|
||||
Component::Ident(v) => Tag::String(v.as_str().into()),
|
||||
Component::Lore(lines) => Tag::List(lines.clone()),
|
||||
Component::WritableBook(pages) => Tag::compound([(
|
||||
"pages",
|
||||
Tag::List(
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| Tag::String(p.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
)]),
|
||||
Component::WrittenBook(b) => Tag::compound([
|
||||
("title", Tag::String(b.title.as_str().into())),
|
||||
("author", Tag::String(b.author.as_str().into())),
|
||||
("generation", Tag::Int(b.generation)),
|
||||
("pages", Tag::List(b.pages.clone())),
|
||||
("resolved", Tag::Byte(i8::from(b.resolved))),
|
||||
]),
|
||||
Component::Fireworks { flight, explosions } => Tag::compound([
|
||||
("flight_duration", Tag::Byte(*flight as i8)),
|
||||
(
|
||||
"explosions",
|
||||
Tag::List(explosions.iter().map(fireworks_tag).collect()),
|
||||
),
|
||||
]),
|
||||
Component::FireworkExplosion(e) => fireworks_tag(e),
|
||||
Component::Enchantments(list) => {
|
||||
// Enchantments are a synced (data-driven) registry: the
|
||||
// ids index the server's RegistryData, not the builtin
|
||||
// report, so a datapack enchantment names correctly.
|
||||
let levels = list.iter().filter_map(|(id, level)| {
|
||||
let name = synced.name("minecraft:enchantment", *id)?;
|
||||
Some((name, Tag::Int(*level)))
|
||||
});
|
||||
Tag::compound(levels)
|
||||
}
|
||||
Component::AttackRange(v) => Tag::compound([
|
||||
("min_reach", Tag::Float(v[0].to_bits())),
|
||||
("max_reach", Tag::Float(v[1].to_bits())),
|
||||
("min_attack_momentum", Tag::Float(v[2].to_bits())),
|
||||
("max_attack_momentum", Tag::Float(v[3].to_bits())),
|
||||
("hitbox_margin", Tag::Float(v[4].to_bits())),
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// PotionContents' NBT: the potion by name and the custom colour.
|
||||
Component::Potion { potion, color, .. } => {
|
||||
let potion = potion.and_then(|id| builtin.get("minecraft:potion")?.name(id));
|
||||
Tag::compound(
|
||||
potion
|
||||
.map(|name| ("potion", Tag::String(name.into())))
|
||||
.into_iter()
|
||||
.chain(color.map(|c| ("custom_color", Tag::Int(c)))),
|
||||
)
|
||||
}
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut entries = vec![
|
||||
(
|
||||
"id",
|
||||
Tag::String(item_name(builtin, t.item).as_ref().into()),
|
||||
),
|
||||
("count", Tag::Int(t.count)),
|
||||
];
|
||||
if let Some(c) = components_tag(t, builtin, synced) {
|
||||
entries.push(("components", c));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Component::CustomModelData {
|
||||
floats,
|
||||
flags,
|
||||
strings,
|
||||
colors,
|
||||
} => Tag::compound([
|
||||
(
|
||||
"floats",
|
||||
Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()),
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()),
|
||||
),
|
||||
(
|
||||
"strings",
|
||||
Tag::List(
|
||||
strings
|
||||
.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"colors",
|
||||
Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()),
|
||||
),
|
||||
]),
|
||||
Component::TooltipDisplay { hide, hidden } => {
|
||||
let hidden = hidden
|
||||
.iter()
|
||||
.filter_map(|id| Some(Tag::String(name(*id)?.into())));
|
||||
Tag::compound([
|
||||
("hide_tooltip", Tag::Byte(i8::from(*hide))),
|
||||
("hidden_components", Tag::List(hidden.collect())),
|
||||
])
|
||||
}
|
||||
};
|
||||
entries.push((key, value));
|
||||
}
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
|
||||
/// A wire `FireworkExplosion` as vanilla's NBT codec writes it
|
||||
/// (`FireworkExplosion.Shape`'s BY_ID order).
|
||||
fn fireworks_tag(e: &horizon_protocol::item::FireworkExplosion) -> Tag {
|
||||
const SHAPES: [&str; 5] = ["small_ball", "large_ball", "star", "creeper", "burst"];
|
||||
let ints = |c: &[i32]| Tag::List(c.iter().map(|c| Tag::Int(*c)).collect());
|
||||
Tag::compound([
|
||||
(
|
||||
"shape",
|
||||
Tag::String(
|
||||
SHAPES
|
||||
.get(e.shape.max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or("small_ball")
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
("colors", ints(&e.colors)),
|
||||
("fade_colors", ints(&e.fade)),
|
||||
("has_trail", Tag::Byte(i8::from(e.trail))),
|
||||
("has_twinkle", Tag::Byte(i8::from(e.twinkle))),
|
||||
])
|
||||
}
|
||||
|
||||
/// A `minecraft:profile` component as the item data JSON the model reads.
|
||||
fn profile_tag(p: &Profile) -> Tag {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(name, value, signature)| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", Tag::String(name.as_str().into())),
|
||||
("value", Tag::String(value.as_str().into())),
|
||||
];
|
||||
if let Some(signature) = signature {
|
||||
entries.push(("signature", Tag::String(signature.as_str().into())));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", Tag::String(name.as_str().into())));
|
||||
}
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::registry::Ids;
|
||||
|
||||
/// What a creative client sends (`stack_to_network`) comes back whole on
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim.
|
||||
#[test]
|
||||
fn a_creative_stack_keeps_its_components_across_the_wire() {
|
||||
let mut components = vec![String::new(); 60];
|
||||
for (id, name) in [
|
||||
(component::DAMAGE, "minecraft:damage"),
|
||||
(component::CUSTOM_NAME, "minecraft:custom_name"),
|
||||
(component::ENCHANTMENTS, "minecraft:enchantments"),
|
||||
(component::POTION_CONTENTS, "minecraft:potion_contents"),
|
||||
(component::REPAIR_COST, "minecraft:repair_cost"),
|
||||
(
|
||||
component::STORED_ENCHANTMENTS,
|
||||
"minecraft:stored_enchantments",
|
||||
),
|
||||
(component::TRIM, "minecraft:trim"),
|
||||
] {
|
||||
components[id as usize] = name.to_owned();
|
||||
}
|
||||
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".to_owned(),
|
||||
Ids::from_names(names(&[
|
||||
"minecraft:air",
|
||||
"minecraft:diamond_chestplate",
|
||||
"minecraft:splash_potion",
|
||||
])),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".to_owned(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
(
|
||||
"minecraft:potion".to_owned(),
|
||||
Ids::from_names(names(&["minecraft:water", "minecraft:swiftness"])),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let synced = |n: &[&str]| n.iter().map(|s| (s.to_string(), None)).collect::<Vec<_>>();
|
||||
let dynamic = Dynamic {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:enchantment".to_owned(),
|
||||
synced(&["minecraft:protection", "minecraft:sharpness"]),
|
||||
),
|
||||
(
|
||||
"minecraft:trim_material".to_owned(),
|
||||
synced(&["minecraft:gold"]),
|
||||
),
|
||||
(
|
||||
"minecraft:trim_pattern".to_owned(),
|
||||
synced(&["minecraft:sentry"]),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
stack.damage = 5;
|
||||
stack.enchant("minecraft:protection", 4);
|
||||
let d = stack.data_mut();
|
||||
d.stored_enchantments
|
||||
.push((Arc::from("minecraft:sharpness"), 2));
|
||||
d.repair_cost = 9;
|
||||
d.custom_name = Some("Keeper".to_owned());
|
||||
d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")));
|
||||
let id = |registry: &str, name: &str| {
|
||||
builtin
|
||||
.get(registry)
|
||||
.and_then(|ids| ids.id(name))
|
||||
.or_else(|| dynamic.id(registry, name))
|
||||
};
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| {
|
||||
builtin
|
||||
.get("minecraft:item")
|
||||
.and_then(|i| i.id(name))
|
||||
.unwrap_or(0)
|
||||
},
|
||||
id,
|
||||
&Some(stack.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let back = from_network(&wire, &builtin, &dynamic);
|
||||
assert_eq!(back.item, stack.item);
|
||||
assert_eq!(back.damage, 5);
|
||||
let d = back.data().expect("components");
|
||||
assert_eq!(d.enchantments, vec![(Arc::from("minecraft:protection"), 4)]);
|
||||
assert_eq!(
|
||||
d.stored_enchantments,
|
||||
vec![(Arc::from("minecraft:sharpness"), 2)]
|
||||
);
|
||||
assert_eq!(d.repair_cost, 9);
|
||||
assert_eq!(d.custom_name.as_deref(), Some("Keeper"));
|
||||
assert_eq!(
|
||||
d.trim,
|
||||
Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")))
|
||||
);
|
||||
// PotionContents: the potion is a built-in registry, as a hub's
|
||||
// `SyncData::id` treats it, so the id has to come from there.
|
||||
let potion = ItemStack::new("minecraft:splash_potion", 1).with_potion("swiftness");
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| id("minecraft:item", name).unwrap_or(0),
|
||||
id,
|
||||
&Some(potion),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
from_network(&wire, &builtin, &dynamic).potion(),
|
||||
Some("minecraft:swiftness")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -487,23 +487,31 @@ pub fn lines(
|
||||
) -> Vec<String> {
|
||||
let mut lines = vec![name_line(stack, translate)];
|
||||
if TooltipDisplay::for_stack(stack).shows("minecraft:lore") {
|
||||
lines.extend(lore_lines(stack));
|
||||
lines.extend(lore_texts(stack).into_iter().map(|t| t.plain()));
|
||||
}
|
||||
lines.extend(detail_lines(stack, info, translate));
|
||||
lines
|
||||
}
|
||||
|
||||
/// `LoreComponent.addToTooltip`: the saved component lines, as plain text.
|
||||
fn lore_lines(stack: &ItemStack) -> Vec<String> {
|
||||
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
|
||||
/// the component's own styles (including explicit `false`) winning on children.
|
||||
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let base = crate::text::Style {
|
||||
color: Some(crate::text::DARK_PURPLE),
|
||||
italic: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let json = crate::scoreboard::tag_to_json(entry);
|
||||
let text = crate::component::to_text(&json, &mut |s| crate::text::Text::literal(s));
|
||||
text.plain()
|
||||
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
|
||||
for span in &mut text.spans {
|
||||
span.style.inherit(&base);
|
||||
}
|
||||
text
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -512,6 +520,42 @@ fn lore_lines(stack: &ItemStack) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lore_keeps_component_colours_under_dark_purple_italic() {
|
||||
let mut stack = ItemStack::new("minecraft:stick", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:lore",
|
||||
Some(Tag::List(vec![
|
||||
Tag::compound([
|
||||
("text", Tag::String("Gold line".into())),
|
||||
("color", Tag::String("gold".into())),
|
||||
("italic", Tag::Byte(0)),
|
||||
]),
|
||||
Tag::String("Plain lore".into()),
|
||||
])),
|
||||
);
|
||||
let lines = lore_texts(&stack);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0].plain(), "Gold line");
|
||||
assert_eq!(lines[0].spans[0].style.color, Some(crate::text::GOLD));
|
||||
assert!(!lines[0].spans[0].style.is_italic());
|
||||
assert_eq!(lines[1].plain(), "Plain lore");
|
||||
assert_eq!(
|
||||
lines[1].spans[0].style.color,
|
||||
Some(crate::text::DARK_PURPLE)
|
||||
);
|
||||
assert!(lines[1].spans[0].style.is_italic());
|
||||
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:tooltip_display",
|
||||
Some(Tag::compound([(
|
||||
"hidden_components",
|
||||
Tag::List(vec![Tag::String("minecraft:lore".into())]),
|
||||
)])),
|
||||
);
|
||||
assert!(!TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_display_controls_name_and_component_visibility() {
|
||||
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
|
||||
|
||||
@@ -350,6 +350,14 @@ impl Hub {
|
||||
return Vec::new();
|
||||
}
|
||||
let source = self.guests[i].menu.as_ref().map(|m| m.slots);
|
||||
let shulker_box = self
|
||||
.sync
|
||||
.builtin
|
||||
.get("minecraft:menu")
|
||||
.and_then(|m| m.id("minecraft:shulker_box"));
|
||||
let shulker = in_menu
|
||||
&& shulker_box.is_some()
|
||||
&& self.guests[i].menu.as_ref().map(|m| m.kind) == shulker_box;
|
||||
let mut drag = if in_menu {
|
||||
self.guests[i].menu.as_mut().and_then(|m| m.drag.take())
|
||||
} else {
|
||||
@@ -363,6 +371,7 @@ impl Hub {
|
||||
let mut view = View {
|
||||
guest,
|
||||
container: items.as_deref_mut(),
|
||||
shulker,
|
||||
};
|
||||
let (drops, changed) = menu::click(
|
||||
&mut view,
|
||||
|
||||
@@ -223,6 +223,8 @@ pub struct Guest {
|
||||
/// Whether the guest was on the ground before the first of `moves`.
|
||||
pub moves_from_ground: bool,
|
||||
pub dead: bool,
|
||||
/// `LivingEntity.deathTime`: ticks since the guest died.
|
||||
pub death_time: u32,
|
||||
/// The join packets were sent (PlayerList.placeNewPlayer ran).
|
||||
pub placed: bool,
|
||||
/// The level the player is in (`ServerPlayer.level().dimension()`): the
|
||||
@@ -349,6 +351,7 @@ impl Guest {
|
||||
moves: Vec::new(),
|
||||
moves_from_ground: false,
|
||||
dead: false,
|
||||
death_time: 0,
|
||||
placed: false,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
menu: None,
|
||||
@@ -442,6 +445,43 @@ impl Guest {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FireworkExplosion.STREAM_CODEC`'s fields (`Shape` by its BY_ID order).
|
||||
fn network_explosion(
|
||||
e: &crate::particles::FireworkExplosion,
|
||||
) -> horizon_protocol::item::FireworkExplosion {
|
||||
horizon_protocol::item::FireworkExplosion {
|
||||
shape: e.shape as i32,
|
||||
colors: e.colors.iter().map(|c| *c as i32).collect(),
|
||||
fade: e.fade.iter().map(|c| *c as i32).collect(),
|
||||
trail: e.trail,
|
||||
twinkle: e.twinkle,
|
||||
}
|
||||
}
|
||||
|
||||
/// A rocket's `minecraft:fireworks` and a star's `minecraft:firework_explosion`.
|
||||
pub fn firework_components(
|
||||
d: &crate::item_data::StackData,
|
||||
) -> Vec<(i32, horizon_protocol::item::Component)> {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
let mut out = Vec::new();
|
||||
if let Some((flight, explosions)) = &d.fireworks {
|
||||
out.push((
|
||||
component::FIREWORKS,
|
||||
Component::Fireworks {
|
||||
flight: i32::from(*flight),
|
||||
explosions: explosions.iter().map(network_explosion).collect(),
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(e) = &d.firework_explosion {
|
||||
out.push((
|
||||
component::FIREWORK_EXPLOSION,
|
||||
Component::FireworkExplosion(network_explosion(e)),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
@@ -532,6 +572,7 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
stack.added.extend(firework_components(d));
|
||||
// PotionContents: the potion by its `minecraft:potion` id.
|
||||
if let Some(id) = (d.potion.as_deref()).and_then(|potion| {
|
||||
synced_id(
|
||||
|
||||
@@ -149,6 +149,9 @@ pub struct Hub {
|
||||
pub singleplayer_owner: Option<u128>,
|
||||
/// Who rides what, set by the game before each tick.
|
||||
pub riding: super::entity::Riding,
|
||||
/// The scoreboard's teams, set by the game each tick, for players'
|
||||
/// display names.
|
||||
pub teams: Vec<crate::scoreboard::Team>,
|
||||
last_clock: i64,
|
||||
last_rate: f32,
|
||||
ticking: (f32, bool),
|
||||
@@ -206,6 +209,24 @@ pub fn display_name(uuid: u128, name: &str) -> Tag {
|
||||
])
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Player.getDisplayName: the name formatted for the player's team
|
||||
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
|
||||
/// player on hover and inserting the name.
|
||||
pub fn player_display_name(&self, uuid: u128, name: &str) -> Tag {
|
||||
let Some(team) = self.teams.iter().find(|t| t.players.contains(name)) else {
|
||||
return display_name(uuid, name);
|
||||
};
|
||||
let mut c = team.formatted_name_comp(&crate::scoreboard::literal(name));
|
||||
c["click_event"] =
|
||||
serde_json::json!({"action": "suggest_command", "command": format!("/tell {name} ")});
|
||||
c["hover_event"] = serde_json::json!({"action": "show_entity", "id": "minecraft:player",
|
||||
"uuid": horizon_protocol::crypto::uuid_string(uuid), "name": name});
|
||||
c["insertion"] = name.into();
|
||||
crate::scoreboard::component_tag(&c)
|
||||
}
|
||||
}
|
||||
|
||||
/// A yellow translation of player names (`multiplayer.player.joined`).
|
||||
fn yellow_names(key: &str, names: Vec<Tag>) -> Tag {
|
||||
Tag::compound([
|
||||
@@ -315,6 +336,7 @@ impl Hub {
|
||||
entity_types: Default::default(),
|
||||
entities: Default::default(),
|
||||
entity_out: Vec::new(),
|
||||
teams: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2293,6 +2315,11 @@ impl Hub {
|
||||
.name("minecraft:enchantment", id)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
/// A stack a client sent (`SetCreativeModeSlot`), read with this
|
||||
/// server's registries.
|
||||
pub fn stack_from_network(&self, s: &horizon_protocol::item::Stack) -> crate::items::ItemStack {
|
||||
crate::item_sync::from_network(s, &self.sync.builtin, &self.sync.dynamic)
|
||||
}
|
||||
|
||||
/// ServerLevel.removePlayerImmediately, then addRespawnedPlayer: the other players
|
||||
/// lose the dead player's entity (ServerEntity.removePairing) and are sent the
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! handle it: chat sessions validated against the services' keys, signed
|
||||
//! messages checked link by link, the acknowledged window, and messages
|
||||
//! relayed with each connection's numbering and signature cache.
|
||||
use super::{HostEvent, Hub, display_name};
|
||||
use super::{HostEvent, Hub};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
secure_chat::{self, Body, Chain, ChatSession, DecodeError, ProfileKey, Signature, Unpacked},
|
||||
@@ -214,7 +214,7 @@ impl Hub {
|
||||
}
|
||||
let bound = play::ChatTypeBound {
|
||||
chat_type: self.chat_type,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
let line = format!("<{name}> {}", message.content);
|
||||
@@ -427,7 +427,7 @@ impl Hub {
|
||||
let (uuid, name) = (self.guests[i].uuid, self.guests[i].name.clone());
|
||||
let bound = play::ChatTypeBound {
|
||||
chat_type: self.chat_type_id(chat_type),
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
let line = match chat_type {
|
||||
@@ -467,15 +467,15 @@ impl Hub {
|
||||
self.chat_type_id("msg_command_incoming"),
|
||||
);
|
||||
for &t in targets {
|
||||
let target = display_name(self.guests[t].uuid, &self.guests[t].name);
|
||||
let target = self.player_display_name(self.guests[t].uuid, &self.guests[t].name);
|
||||
let out = play::ChatTypeBound {
|
||||
chat_type: outgoing,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: Some(target),
|
||||
};
|
||||
let inc = play::ChatTypeBound {
|
||||
chat_type: incoming,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
match &message {
|
||||
|
||||
@@ -124,6 +124,8 @@ pub struct View<'a> {
|
||||
pub guest: &'a mut Guest,
|
||||
/// The container's slots, or None for the inventory menu.
|
||||
pub container: Option<&'a mut [Option<ItemStack>]>,
|
||||
/// The container is a shulker box (`ShulkerBoxSlot`).
|
||||
pub shulker: bool,
|
||||
}
|
||||
|
||||
impl View<'_> {
|
||||
@@ -166,6 +168,12 @@ impl View<'_> {
|
||||
if self.container.is_none() && (5..=8).contains(&i) {
|
||||
return rules.armor_index(&stack.item) == Some(i - 5);
|
||||
}
|
||||
if self.shulker
|
||||
&& self.container.as_ref().is_some_and(|c| i < c.len())
|
||||
&& !crate::menu::fits_inside_container_items(stack)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.enabled(i)
|
||||
}
|
||||
fn slot_max(&self, i: usize, stack: &ItemStack, rules: &dyn Rules) -> u32 {
|
||||
@@ -678,6 +686,24 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// The prepared resources' items, or None (the test is skipped) without them.
|
||||
fn rules() -> Option<ItemRules> {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed menu check: {}", root.display());
|
||||
return None;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
Some(ItemRules(Items::load(&root, ®istry, &facts).unwrap()))
|
||||
}
|
||||
|
||||
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
|
||||
let inside = stack.as_ref().and_then(|s| s.data());
|
||||
inside.map_or(Vec::new(), |d| {
|
||||
@@ -693,20 +719,9 @@ mod tests {
|
||||
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
|
||||
#[test]
|
||||
fn guest_chest_clicks_fill_and_empty_bundles() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed bundle check: {}", root.display());
|
||||
let Some(rules) = rules() else {
|
||||
return;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
let rules = ItemRules(Items::load(&root, ®istry, &facts).unwrap());
|
||||
};
|
||||
let mut g = guest();
|
||||
let mut chest = vec![
|
||||
Some(ItemStack::new("minecraft:stone", 48)),
|
||||
@@ -717,6 +732,7 @@ mod tests {
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut chest),
|
||||
shulker: false,
|
||||
};
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
|
||||
// Left click with the bundle: the stone goes in.
|
||||
@@ -749,4 +765,37 @@ mod tests {
|
||||
);
|
||||
assert_eq!(contents(&view.get(2)), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guest_shulker_box_menu_refuses_shulker_boxes() {
|
||||
let Some(rules) = rules() else {
|
||||
return;
|
||||
};
|
||||
for shulker in [true, false] {
|
||||
let mut g = guest();
|
||||
let mut container = vec![None; 27];
|
||||
let mut drag = None;
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut container),
|
||||
shulker,
|
||||
};
|
||||
// Carried onto an empty slot, shift-clicked from the hotbar
|
||||
// (menu slot 27 + 27) and swapped in with the number key 1.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:red_shulker_box", 1));
|
||||
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.get(0).is_none(), shulker);
|
||||
view.guest.carried = None;
|
||||
view.guest.inventory[0] = Some(ItemStack::new("minecraft:shulker_box", 1));
|
||||
click(&mut view, &mut drag, 54, 0, input::QUICK_MOVE, &rules);
|
||||
assert_eq!(view.guest.inventory[0].is_some(), shulker);
|
||||
view.guest.inventory[0] = Some(ItemStack::new("minecraft:blue_shulker_box", 1));
|
||||
click(&mut view, &mut drag, 5, 0, input::SWAP, &rules);
|
||||
assert_eq!(view.get(5).is_none(), shulker);
|
||||
// Anything else goes in.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:diamond", 1));
|
||||
click(&mut view, &mut drag, 9, 0, input::PICKUP, &rules);
|
||||
assert!(view.get(9).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@ impl Language {
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(String::as_str)
|
||||
}
|
||||
/// The resolved map, for `horizon_protocol::text::plain`: a server's
|
||||
/// components carry keys, and the network layer resolves them with this.
|
||||
pub fn map(&self) -> &HashMap<String, String> {
|
||||
&self.map
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ pub mod inv_sort;
|
||||
pub mod inventory_effects;
|
||||
pub mod item_data;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod items;
|
||||
pub mod java_text;
|
||||
|
||||
@@ -247,6 +247,12 @@ pub fn beacon_required_levels(effect: Option<&str>) -> i32 {
|
||||
.map_or(i32::MAX, |i| i as i32 + 1)
|
||||
}
|
||||
|
||||
/// `Item.canFitInsideContainerItems` (`ShulkerBoxSlot.mayPlace`): shulker
|
||||
/// boxes cannot nest.
|
||||
pub fn fits_inside_container_items(stack: &ItemStack) -> bool {
|
||||
!stack.item.ends_with("shulker_box")
|
||||
}
|
||||
|
||||
/// The containers a menu spans.
|
||||
const PLAYER: usize = 0;
|
||||
const MAIN: usize = 1;
|
||||
@@ -995,7 +1001,7 @@ impl Menu {
|
||||
fn anvil_result(&mut self, env: &Env) {
|
||||
let hover = self.containers[MAIN][0]
|
||||
.as_ref()
|
||||
.map(|i| crate::stations::hover_name(&|id| env.names.item(id), i))
|
||||
.map(|i| crate::stations::hover_name(&|id| env.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
let out = crate::stations::anvil_result(
|
||||
env.enchantments,
|
||||
@@ -1210,11 +1216,6 @@ impl Menu {
|
||||
env.items.get(&stack.item).is_some_and(|i| i.fuel.is_some())
|
||||
}
|
||||
|
||||
/// `Item.canFitInsideContainerItems`: shulker boxes cannot nest.
|
||||
fn fits_inside(stack: &ItemStack) -> bool {
|
||||
!(stack.item.ends_with("shulker_box"))
|
||||
}
|
||||
|
||||
// ---- Slot ----
|
||||
|
||||
fn may_place(&self, env: &Env, s: usize, stack: &ItemStack) -> bool {
|
||||
@@ -1247,7 +1248,7 @@ impl Menu {
|
||||
.as_deref()
|
||||
.is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m)))
|
||||
}),
|
||||
Role::Shulker => Self::fits_inside(stack),
|
||||
Role::Shulker => fits_inside_container_items(stack),
|
||||
Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack),
|
||||
Role::Output | Role::CraftResult | Role::Inert => false,
|
||||
Role::CrafterIn => self.data.get(self.slots[s].index) != Some(&1),
|
||||
|
||||
@@ -1646,6 +1646,11 @@ impl Mob {
|
||||
pub fn species(&self) -> Species {
|
||||
self.kind.species
|
||||
}
|
||||
/// `Mob.getMainArm`: the left arm when the mob is left-handed, a player's
|
||||
/// `mainHand` option for a player mob.
|
||||
pub fn main_arm_left(&self) -> bool {
|
||||
self.extra.main_hand_left || self.extra.left_handed
|
||||
}
|
||||
/// The player hit this mob (melee): LivingEntity.hurt with knockback.
|
||||
pub fn hurt(
|
||||
&mut self,
|
||||
|
||||
@@ -642,7 +642,7 @@ pub fn setup(
|
||||
} else {
|
||||
ArmPose::Empty
|
||||
};
|
||||
let mut h = if m.extra.main_hand_left {
|
||||
let mut h = if m.main_arm_left() {
|
||||
Hum::new(ArmPose::Empty, main)
|
||||
} else {
|
||||
Hum::new(main, ArmPose::Empty)
|
||||
@@ -693,7 +693,7 @@ pub fn setup(
|
||||
} else {
|
||||
ArmPose::Empty
|
||||
};
|
||||
let mut h = if m.extra.main_hand_left {
|
||||
let mut h = if m.main_arm_left() {
|
||||
Hum::new(off, pose)
|
||||
} else {
|
||||
Hum::new(pose, off)
|
||||
|
||||
@@ -621,8 +621,10 @@ impl BowAttack {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.isHolding(Items.BOW)`: the bow can sit in either hand.
|
||||
fn holding_bow(m: &Mob) -> bool {
|
||||
m.extra.main_hand.as_deref() == Some("minecraft:bow")
|
||||
|| m.extra.off_hand.as_deref() == Some("minecraft:bow")
|
||||
}
|
||||
impl Goal for BowAttack {
|
||||
goal_clone!();
|
||||
@@ -715,7 +717,10 @@ impl Goal for BowAttack {
|
||||
} else {
|
||||
self.attack_time -= 1;
|
||||
if self.attack_time <= 0 && self.see_time >= -60 {
|
||||
// mob.startUsingItem(ProjectileUtil.getWeaponHoldingHand):
|
||||
// the off hand when the main one doesn't hold the bow.
|
||||
m.extra.using_item = 1;
|
||||
m.extra.using_off_hand = m.extra.main_hand.as_deref() != Some("minecraft:bow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Installed {
|
||||
pub problem: Option<String>,
|
||||
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
|
||||
pub dev: bool,
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
}
|
||||
|
||||
impl ModList {
|
||||
@@ -96,6 +99,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
};
|
||||
let mut list = ModList {
|
||||
installed: vec![installed("a", false), installed("b", true)],
|
||||
|
||||
@@ -167,6 +167,9 @@ pub enum MotionEvent {
|
||||
InsidePortal {
|
||||
pos: BlockPos,
|
||||
},
|
||||
/// `WitherRoseBlock.entityInside`: the player's box swept through a
|
||||
/// wither rose; the server adds its Wither unless peaceful.
|
||||
InsideWitherRose,
|
||||
/// `ServerPlayer.checkFallDamage` after a mace smash: block particles
|
||||
/// from the landing block, more the further the fall.
|
||||
SmashLanding {
|
||||
@@ -1083,11 +1086,26 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
/// What `applyEffectsFromBlocks` does to a server's player along the
|
||||
/// moves its client sent since `from` (in its history): the damage of
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
|
||||
/// moves it, so the slowing and the rest are the client's.
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes, and powder
|
||||
/// snow's FREEZE, then `LivingEntity.aiStep`'s freezing and its damage.
|
||||
/// Its client moves it, so the slowing and the rest are the client's.
|
||||
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
|
||||
self.player.old_position = from;
|
||||
self.effects_from_blocks(true)
|
||||
// Entity.baseTick
|
||||
self.player.was_in_powder_snow = self.player.in_powder_snow;
|
||||
self.player.in_powder_snow = false;
|
||||
self.effects_from_blocks(true)?;
|
||||
// LivingEntity.aiStep "freezing" (server).
|
||||
if !self.player.in_powder_snow || !self.player.can_freeze() {
|
||||
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
|
||||
}
|
||||
if self.player.health.tick_count % 40 == 0
|
||||
&& self.player.is_fully_frozen()
|
||||
&& self.player.can_freeze()
|
||||
{
|
||||
self.freeze_damage()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
|
||||
use horizon_world::entity_move::MovementRecord;
|
||||
@@ -1191,7 +1209,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
groups += 1;
|
||||
last_step = step;
|
||||
}
|
||||
if groups == 1 {
|
||||
if groups == 1 && !damage_only {
|
||||
// A later group finds the fire already out.
|
||||
self.player.powder_snow_entered.push(p);
|
||||
}
|
||||
@@ -1259,21 +1277,22 @@ impl MotionWorld<'_, '_, '_> {
|
||||
continue;
|
||||
}
|
||||
let powder_snow = name == "minecraft:powder_snow";
|
||||
let lava_cauldron = name == "minecraft:lava_cauldron";
|
||||
// getEntityInsideCollisionShape is the whole block but for powder
|
||||
// snow (its collision shape, the whole block when it is empty),
|
||||
// filled cauldrons, end portals and tripwires; Horizon doesn't
|
||||
// follow the player into those last three.
|
||||
// follow the player into the water and powder snow cauldrons, end
|
||||
// portals or tripwires yet.
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:water_cauldron"
|
||||
| "minecraft:lava_cauldron"
|
||||
| "minecraft:powder_snow_cauldron"
|
||||
| "minecraft:end_portal"
|
||||
| "minecraft:tripwire"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let inside = !powder_snow || {
|
||||
let inside = !(powder_snow || lava_cauldron) || {
|
||||
let context = self.context();
|
||||
let binding = self.collision_binding;
|
||||
let shape = binding.collision_shape_with_context(
|
||||
@@ -1282,21 +1301,33 @@ impl MotionWorld<'_, '_, '_> {
|
||||
p,
|
||||
&context,
|
||||
)?;
|
||||
shape.is_empty() || shape.boxes() == VoxelShape::block().boxes() || {
|
||||
let boxes: Vec<_> = shape
|
||||
.moved(Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z)))
|
||||
.boxes()
|
||||
.to_vec();
|
||||
self.player
|
||||
.dimensions
|
||||
.bounds(from)
|
||||
.collided_along_vector(to.subtract(from), &boxes)
|
||||
}
|
||||
powder_snow && (shape.is_empty() || shape.boxes() == VoxelShape::block().boxes())
|
||||
|| {
|
||||
let offset = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let mut boxes: Vec<_> = shape.moved(offset).boxes().to_vec();
|
||||
if lava_cauldron {
|
||||
// LavaCauldronBlock.FILLED_SHAPE: the cauldron's
|
||||
// shape or its lava, Block.column(12, 4, 15).
|
||||
boxes.push(Aabb::new(
|
||||
Vec3::new(2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0).add_vector(offset),
|
||||
Vec3::new(14.0 / 16.0, 15.0 / 16.0, 14.0 / 16.0).add_vector(offset),
|
||||
));
|
||||
}
|
||||
self.player
|
||||
.dimensions
|
||||
.bounds(from)
|
||||
.collided_along_vector(to.subtract(from), &boxes)
|
||||
}
|
||||
};
|
||||
if inside && visited.insert(p.pack()) {
|
||||
// CactusBlock and CampfireBlock.entityInside (`entity.hurt`).
|
||||
// CactusBlock, CampfireBlock and LavaCauldronBlock.entityInside
|
||||
// (`entity.hurt`), and WitherRoseBlock's server-side effect.
|
||||
match name {
|
||||
"minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?,
|
||||
"minecraft:lava_cauldron" => self.lava_cauldron_inside()?,
|
||||
"minecraft:wither_rose" => {
|
||||
self.player.events.push(MotionEvent::InsideWitherRose);
|
||||
}
|
||||
"minecraft:campfire" | "minecraft:soul_campfire" => {
|
||||
let lit = self
|
||||
.assets
|
||||
@@ -1316,6 +1347,8 @@ impl MotionWorld<'_, '_, '_> {
|
||||
"minecraft:sweet_berry_bush" if damage_only => {
|
||||
self.sweet_berry_bush_damage(state)?;
|
||||
}
|
||||
// PowderSnowBlock.entityInside's FREEZE.
|
||||
"minecraft:powder_snow" if damage_only => entered.push((step, p)),
|
||||
_ => {}
|
||||
}
|
||||
if damage_only {
|
||||
@@ -1354,6 +1387,14 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
self.player.impulse.try_reset();
|
||||
}
|
||||
/// `LavaCauldronBlock.entityInside`: CLEAR_FREEZE, LAVA_IGNITE
|
||||
/// (`Entity.lavaIgnite`, 15 seconds) and then `Entity.lavaHurt` (lava,
|
||||
/// 4), as ordinary lava does to the player.
|
||||
fn lava_cauldron_inside(&mut self) -> Result<()> {
|
||||
self.player.ticks_frozen = 0;
|
||||
self.player.ignite_for_ticks(300)?;
|
||||
self.environment_hurt("minecraft:lava", 4.0)
|
||||
}
|
||||
/// `WebBlock.entityInside`: stuck at a quarter of the speed, half with
|
||||
/// the Weaving effect.
|
||||
fn web_inside(&mut self) {
|
||||
@@ -2368,6 +2409,124 @@ mod tests {
|
||||
assert_eq!(player.position.y, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lava_cauldron_burns_the_player_inside_it() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
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 = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
let cauldron = assets
|
||||
.registry
|
||||
.default_state("minecraft:lava_cauldron")
|
||||
.unwrap();
|
||||
scene.set(20, 4, 20, cauldron).unwrap();
|
||||
let mut tick = |player: &mut PlayerMotion| {
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
};
|
||||
// Standing on the cauldron's floor, in its lava.
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.25, 20.5)).unwrap();
|
||||
player.ticks_frozen = 40;
|
||||
tick(&mut player);
|
||||
assert!(player.health.health() < 20.0, "the lava must hurt");
|
||||
assert!(player.fire_ticks > 0, "the lava must ignite");
|
||||
assert_eq!(player.ticks_frozen, 0);
|
||||
// Beside it, nothing.
|
||||
let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap();
|
||||
tick(&mut away);
|
||||
assert_eq!(away.health.health(), 20.0);
|
||||
assert!(away.fire_ticks <= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standing_in_a_wither_rose_asks_for_its_effect() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
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 = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
let rose = assets
|
||||
.registry
|
||||
.default_state("minecraft:wither_rose")
|
||||
.unwrap();
|
||||
scene.set(20, 4, 20, rose).unwrap();
|
||||
let roses = |player: &PlayerMotion| {
|
||||
player
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MotionEvent::InsideWitherRose))
|
||||
.count()
|
||||
};
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
assert_eq!(roses(&player), 1);
|
||||
// A server's player: its client moved it, the server applies the effect.
|
||||
player.events.clear();
|
||||
let from = player.position;
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.server_block_damage(from)
|
||||
.unwrap();
|
||||
assert_eq!(roses(&player), 1);
|
||||
// Two blocks away, nothing.
|
||||
let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap();
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut away,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
assert_eq!(roses(&away), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_travel_moves_only_the_velocity() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -686,8 +686,14 @@ pub fn json_text(v: &serde_json::Value, lang: &Language) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// en_us.json from the prepared resources.
|
||||
/// The language a session resolves server-sent components with: the selected
|
||||
/// one as `lang` loaded it (`en_us` under it, packs included), so a menu title
|
||||
/// the server sends as a key follows the language screen. A process with no
|
||||
/// language set (a dedicated server's log) reads en_us.json.
|
||||
pub fn load_language(resources: &std::path::Path) -> Language {
|
||||
if let Some(lang) = crate::lang::current() {
|
||||
return lang.map().clone();
|
||||
}
|
||||
std::fs::read(resources.join("assets/minecraft/lang/en_us.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
|
||||
@@ -893,6 +893,9 @@ enum Act {
|
||||
ResetPadKey(&'static str),
|
||||
ResetPadKeys,
|
||||
ToggleMod(usize),
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
ModSettings(usize),
|
||||
OpenModFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
@@ -944,6 +947,9 @@ pub enum Event {
|
||||
Packs(Vec<String>),
|
||||
OpenPackFolder,
|
||||
OpenModFolder,
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
@@ -1503,15 +1509,26 @@ impl OptionsUi {
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
}
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
|
||||
Row::Buttons(
|
||||
if installed.dev || installed.id.is_empty() {
|
||||
inactive(widget)
|
||||
} else {
|
||||
widget
|
||||
},
|
||||
None,
|
||||
)
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
if dead { inactive(toggle) } else { toggle },
|
||||
Some(settings),
|
||||
)
|
||||
} else {
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
|
||||
Row::Buttons(if dead { inactive(widget) } else { widget }, None)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -2498,6 +2515,10 @@ impl OptionsUi {
|
||||
s.mods.toggle(i);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
@@ -2988,6 +3009,7 @@ mod tests {
|
||||
description: "May show HUD panels.".into(),
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
});
|
||||
}
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
|
||||
@@ -488,7 +488,12 @@ pub fn encode_block_entity(
|
||||
entries.push(("cooking_time_spent", Tag::Int(f.cooking_timer)));
|
||||
entries.push(("cooking_total_time", Tag::Int(f.cooking_total)));
|
||||
entries.push(("speed_multiplier", Tag::Float(f.speed.to_bits())));
|
||||
entries.push(("RecipesUsed", Tag::Compound(Compound::new())));
|
||||
let used: Compound = f
|
||||
.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (id.as_str().into(), Tag::Int(*count as i32)))
|
||||
.collect();
|
||||
entries.push(("RecipesUsed", Tag::Compound(used)));
|
||||
}
|
||||
BlockEntity::Brewing(b) => {
|
||||
entries.push(("Items", encode_items(&b.items)));
|
||||
@@ -640,6 +645,17 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
if let Some(speed) = float(tag.get("speed_multiplier")) {
|
||||
f.speed = speed as f32;
|
||||
}
|
||||
// A save without it owes nothing: the field is newer than some worlds.
|
||||
if let Some(Tag::Compound(used)) = tag.get("RecipesUsed") {
|
||||
for (id, count) in used {
|
||||
let count = match count {
|
||||
Tag::Int(n) => *n,
|
||||
_ => continue,
|
||||
};
|
||||
f.recipes_used
|
||||
.insert(id.to_string_lossy(), count.max(0) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockEntity::Brewing(b) => {
|
||||
let items = decode_items(tag.get("Items"), 5);
|
||||
@@ -4276,4 +4292,21 @@ mod tests {
|
||||
.unwrap_or(0);
|
||||
assert_eq!(flying_survival, 0);
|
||||
}
|
||||
|
||||
/// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the
|
||||
/// player nothing after a reload.
|
||||
#[test]
|
||||
fn a_furnaces_recipes_used_survive_a_save() {
|
||||
use crate::containers::BlockEntity;
|
||||
let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else {
|
||||
panic!("a furnace");
|
||||
};
|
||||
f.recipes_used
|
||||
.insert("minecraft:cooked_porkchop".to_owned(), 3);
|
||||
let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace");
|
||||
let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else {
|
||||
panic!("a furnace back");
|
||||
};
|
||||
assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
|
||||
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
|
||||
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
|
||||
// costs what it did with nearest and repeat only: one sample.
|
||||
// costs what it did with nearest and repeat only: one sample. Each entry
|
||||
// point reads `atlas` through one sampler only: GLSL (wgpu's OpenGL backend)
|
||||
// combines a texture with its sampler, and an override would not remove the
|
||||
// other sampler's branch.
|
||||
@group(0) @binding(2) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var linear_sampler: sampler;
|
||||
override BLUR: bool = false;
|
||||
override CLAMP: bool = false;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@@ -32,28 +34,36 @@ struct Out {
|
||||
out.edges = vec4(rect.xy + half, rect.zw - half);
|
||||
return out;
|
||||
}
|
||||
fn sample(in: Out) -> vec4<f32> {
|
||||
// The sprite's atlas coordinates of this pixel.
|
||||
fn atlas_uv(in: Out) -> vec2<f32> {
|
||||
var f = in.uv;
|
||||
if !CLAMP {
|
||||
// Repeat: one period of the sprite.
|
||||
f = fract(f);
|
||||
}
|
||||
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
if CLAMP {
|
||||
uv = clamp(uv, in.edges.xy, in.edges.zw);
|
||||
return clamp(uv, in.edges.xy, in.edges.zw);
|
||||
}
|
||||
if BLUR {
|
||||
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
|
||||
}
|
||||
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
|
||||
return uv;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = sample(in);
|
||||
fn screen(c: vec4<f32>) -> vec4<f32> {
|
||||
if c.a <= 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
return screen(textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color);
|
||||
}
|
||||
@fragment fn fs_screen_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
return screen(textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color);
|
||||
}
|
||||
// RenderPipelines.VIGNETTE blends ZERO, ONE_MINUS_SRC_COLOR: output the
|
||||
// texture colour; the blend state darkens the frame by it.
|
||||
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
|
||||
return vec4(sample(in).rgb, 1.0);
|
||||
let c = textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
@fragment fn fs_vignette_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
|
||||
@@ -619,26 +619,7 @@ impl TextField {
|
||||
/// `getWordPosition(dir, from)`: past the next space run, or back to the
|
||||
/// start of the word.
|
||||
pub fn word_position(&self, dir: isize, from: usize) -> usize {
|
||||
let v: Vec<char> = self.value.chars().collect();
|
||||
let mut at = from;
|
||||
if dir > 0 {
|
||||
at = v
|
||||
.iter()
|
||||
.skip(at)
|
||||
.position(|c| *c == ' ')
|
||||
.map_or(v.len(), |p| p + at);
|
||||
while at < v.len() && v[at] == ' ' {
|
||||
at += 1;
|
||||
}
|
||||
} else {
|
||||
while at > 0 && v[at - 1] == ' ' {
|
||||
at -= 1;
|
||||
}
|
||||
while at > 0 && v[at - 1] != ' ' {
|
||||
at -= 1;
|
||||
}
|
||||
}
|
||||
at
|
||||
crate::ui::word_position(&self.value, dir, from)
|
||||
}
|
||||
/// `keyPressed` of a focused, editable box: `Some(changed)` when handled.
|
||||
pub fn key(
|
||||
|
||||
@@ -428,6 +428,15 @@ impl Text {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `resolve_legacy` borrowing `self` when no span holds a `§` code (the
|
||||
/// common case).
|
||||
pub fn resolved(&self) -> std::borrow::Cow<'_, Text> {
|
||||
if self.spans.iter().any(|s| s.text.contains('§')) {
|
||||
std::borrow::Cow::Owned(self.resolve_legacy())
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(self)
|
||||
}
|
||||
}
|
||||
/// The text without its legacy `§` codes (ChatFormatting.stripFormatting:
|
||||
/// a `§` and a code character, `0`-`9`, `a`-`f`, `k`-`o` or `r`).
|
||||
pub fn strip_legacy(&self) -> Text {
|
||||
@@ -695,4 +704,20 @@ mod tests {
|
||||
assert_eq!(t.spans[1].style.color, Some(GOLD));
|
||||
assert_eq!(t.slice(1, 2).plain(), "y");
|
||||
}
|
||||
#[test]
|
||||
fn resolved_borrows_clean_text_and_restyles_codes() {
|
||||
let clean = Text::colored("Steve", GOLD);
|
||||
assert!(matches!(clean.resolved(), std::borrow::Cow::Borrowed(_)));
|
||||
// A colour code inside a span clears its decorations (a bold
|
||||
// "§cSteve" draws "Steve" red, not bold).
|
||||
let styled = Style {
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
let styled = Text::styled("§cSteve", styled);
|
||||
let resolved = styled.resolved();
|
||||
assert_eq!(resolved.plain(), "Steve");
|
||||
assert_eq!(resolved.spans[0].style.color, Some(RED));
|
||||
assert_eq!(resolved.spans[0].style.bold, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,9 +279,17 @@ pub fn centered_text(
|
||||
w: f32,
|
||||
color: u32,
|
||||
) {
|
||||
let width = atlas.font.width_text(text);
|
||||
let text = text.resolved();
|
||||
let width = atlas.font.width_text(&text);
|
||||
if width <= w {
|
||||
batch.text_styled(atlas, text, x + ((w - width) / 2.0).floor(), y, color, true);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
x + ((w - width) / 2.0).floor(),
|
||||
y,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
// Clip by characters (vanilla scrolls; we show the part that fits).
|
||||
let mut shown = Text::empty();
|
||||
@@ -821,6 +829,40 @@ fn byte_index(s: &str, chars: usize) -> usize {
|
||||
pub fn char_len(s: &str) -> usize {
|
||||
s.chars().count()
|
||||
}
|
||||
/// `EditBox.getWordPosition(dir, from)`: the character index `dir` words away
|
||||
/// from `from`. Forward, past the rest of the word and its spaces; backward,
|
||||
/// over any spaces to the start of the word.
|
||||
pub fn word_position(value: &str, dir: isize, from: usize) -> usize {
|
||||
let chars: Vec<char> = value.chars().collect();
|
||||
let mut pos = from.min(chars.len());
|
||||
if dir < 0 {
|
||||
while pos > 0 && chars[pos - 1] == ' ' {
|
||||
pos -= 1;
|
||||
}
|
||||
while pos > 0 && chars[pos - 1] != ' ' {
|
||||
pos -= 1;
|
||||
}
|
||||
} else {
|
||||
while pos < chars.len() && chars[pos] != ' ' {
|
||||
pos += 1;
|
||||
}
|
||||
while pos < chars.len() && chars[pos] == ' ' {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
pos
|
||||
}
|
||||
/// `EditBox.deleteWords(dir)` for an append-only field, whose caret is at the
|
||||
/// end: removes the word in that direction. True when the value changed.
|
||||
pub fn delete_words(value: &mut String, dir: isize) -> bool {
|
||||
let len = char_len(value);
|
||||
let to = word_position(value, dir, len);
|
||||
if to == len {
|
||||
return false;
|
||||
}
|
||||
value.truncate(byte_index(value, to));
|
||||
true
|
||||
}
|
||||
impl crate::clipboard::Editable for EditBox {
|
||||
fn select_all(&mut self) {
|
||||
self.move_cursor_to_end(false);
|
||||
@@ -919,25 +961,7 @@ impl EditBox {
|
||||
}
|
||||
/// EditBox.getWordPosition(dir, from).
|
||||
fn word_position_from(&self, dir: isize, from: usize) -> usize {
|
||||
let chars: Vec<char> = self.value.chars().collect();
|
||||
let mut pos = from.min(chars.len());
|
||||
if dir < 0 {
|
||||
while pos > 0 && chars[pos - 1] == ' ' {
|
||||
pos -= 1;
|
||||
}
|
||||
while pos > 0 && chars[pos - 1] != ' ' {
|
||||
pos -= 1;
|
||||
}
|
||||
} else {
|
||||
let len = chars.len();
|
||||
while pos < len && chars[pos] != ' ' {
|
||||
pos += 1;
|
||||
}
|
||||
while pos < len && chars[pos] == ' ' {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
pos
|
||||
word_position(&self.value, dir, from)
|
||||
}
|
||||
pub fn set_cursor(&mut self, pos: usize, select: bool) {
|
||||
self.cursor = pos.min(char_len(&self.value));
|
||||
@@ -1451,4 +1475,36 @@ mod tests {
|
||||
assert_eq!(e.value, "éx worl");
|
||||
assert_eq!(e.cursor, 2);
|
||||
}
|
||||
#[test]
|
||||
fn word_position_steps_over_a_word_and_its_spaces() {
|
||||
assert_eq!(word_position("hello world", -1, 11), 6);
|
||||
assert_eq!(word_position("hello world", -1, 6), 0);
|
||||
assert_eq!(word_position("hello world", -1, 13), 8);
|
||||
assert_eq!(word_position("hello world", 1, 5), 8);
|
||||
// From inside a word: back to its start, forward past it and its spaces.
|
||||
assert_eq!(word_position("hello world", -1, 8), 6);
|
||||
assert_eq!(word_position("hello world", 1, 2), 6);
|
||||
// The ends, and a position past the end.
|
||||
assert_eq!(word_position("hello", -1, 0), 0);
|
||||
assert_eq!(word_position("hello", 1, 5), 5);
|
||||
assert_eq!(word_position("hi", -1, 99), 0);
|
||||
assert_eq!(word_position("hi", 1, 99), 2);
|
||||
}
|
||||
#[test]
|
||||
fn delete_words_removes_the_word_before_the_end() {
|
||||
let mut v = "stone bricks".to_string();
|
||||
assert!(delete_words(&mut v, -1));
|
||||
assert_eq!(v, "stone ");
|
||||
assert!(delete_words(&mut v, -1));
|
||||
assert_eq!(v, "");
|
||||
assert!(!delete_words(&mut v, -1));
|
||||
// Characters, not bytes.
|
||||
let mut v = "a é§x".to_string();
|
||||
assert!(delete_words(&mut v, -1));
|
||||
assert_eq!(v, "a ");
|
||||
// Nothing after the end to delete.
|
||||
let mut v = "stone".to_string();
|
||||
assert!(!delete_words(&mut v, 1));
|
||||
assert_eq!(v, "stone");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,15 @@ rust-version = "1.85"
|
||||
description = "The DualSense's output over HID: rumble, adaptive triggers, lightbar and player LEDs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
|
||||
# hidapi has no iOS backend and its build script stops there, so the output's two
|
||||
# dependencies go only to the targets that have one. On iOS the crate builds without
|
||||
# output: the Link never opens.
|
||||
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
||||
# MIT; bundles the C hidapi under its BSD licence option. hidraw on Linux (libudev, as gilrs),
|
||||
# IOKit on macOS (opened shared, so gilrs keeps reading the pad), the HID API on Windows.
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["linux-static-hidraw", "macos-shared-device"] }
|
||||
log = "0.4"
|
||||
# HD haptics through the pad's own audio device (USB).
|
||||
cpal = "0.16"
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
//! The pad over hidapi, and the thread that writes to it: writes block (a Bluetooth report can
|
||||
//! take milliseconds), so the game only hands over what the pad should do.
|
||||
use crate::{
|
||||
DEFAULT_LIGHTBAR, Output, PLAYER_1, TRIGGER_OFF, TriggerEffect, Vibration, is_dualsense,
|
||||
};
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use crate::{DEFAULT_LIGHTBAR, Output, PLAYER_1, Vibration, is_dualsense};
|
||||
use crate::{TRIGGER_OFF, TriggerEffect};
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use hidapi::{BusType, HidApi, HidDevice, HidResult};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
@@ -17,6 +24,7 @@ pub enum Transport {
|
||||
}
|
||||
|
||||
/// An open DualSense.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub struct DualSense {
|
||||
device: HidDevice,
|
||||
transport: Transport,
|
||||
@@ -34,6 +42,7 @@ pub fn bluetooth_allowed() -> bool {
|
||||
cfg!(target_os = "linux") || std::env::var_os("HORIZON_DUALSENSE_BT").is_some_and(|v| v == "1")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
impl DualSense {
|
||||
/// The first DualSense hidapi lists, USB before Bluetooth (as SDL); `None` when there is
|
||||
/// none, an error when it can't be opened (Linux without access to its hidraw node).
|
||||
@@ -119,13 +128,17 @@ impl Default for State {
|
||||
/// A vibration: the motors' levels (left, right), each for a while, in order.
|
||||
pub type Rumble = Vec<([u8; 2], Duration)>;
|
||||
|
||||
// On iOS no thread reads the queue (the Link never opens); the variants stay so the
|
||||
// Link's API is the same everywhere.
|
||||
#[cfg_attr(target_os = "ios", allow(dead_code))]
|
||||
enum Message {
|
||||
State(State),
|
||||
Rumble(Rumble),
|
||||
Scan,
|
||||
}
|
||||
|
||||
/// The writer thread. Dropping it frees the triggers and stops the motors before it ends.
|
||||
/// The output thread. Dropping it frees the triggers and stops the motors before it ends; on
|
||||
/// iOS there is none (no hidapi backend) and the Link stays closed.
|
||||
pub struct Link {
|
||||
tx: Option<Sender<Message>>,
|
||||
/// 0: no pad open, else 1 + the transport.
|
||||
@@ -135,6 +148,7 @@ pub struct Link {
|
||||
|
||||
impl Link {
|
||||
/// Start the thread and look for a DualSense.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub fn start() -> Link {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let open = Arc::new(AtomicU8::new(0));
|
||||
@@ -151,6 +165,16 @@ impl Link {
|
||||
}
|
||||
}
|
||||
|
||||
/// No output on iOS: hidapi has no backend there. The Link never opens.
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn start() -> Link {
|
||||
Link {
|
||||
tx: None,
|
||||
open: Arc::new(AtomicU8::new(0)),
|
||||
thread: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pad's transport while one is open and taking reports.
|
||||
pub fn open(&self) -> Option<Transport> {
|
||||
match self.open.load(Ordering::Relaxed) {
|
||||
@@ -194,8 +218,10 @@ impl Drop for Link {
|
||||
|
||||
/// Looks for the pad this many more times, a second apart, after a scan finds nothing (its
|
||||
/// hidraw node may appear after the controller does).
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
const SCAN_TRIES: u32 = 5;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
struct Writer {
|
||||
api: Option<HidApi>,
|
||||
pad: Option<DualSense>,
|
||||
@@ -213,6 +239,7 @@ struct Writer {
|
||||
next_scan: Instant,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Lights {
|
||||
/// Over Bluetooth, until the pad's clock says its animation is over (or this long after
|
||||
@@ -225,11 +252,13 @@ enum Lights {
|
||||
|
||||
/// The pad's sensor clock once its Bluetooth connection animation is over (SDL_hidapi_ps5.c's
|
||||
/// `HIDAPI_DriverPS5_CheckPendingLEDReset`).
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
const ANIMATION_OVER: u32 = 10_200_000;
|
||||
|
||||
/// The sensor clock of a Bluetooth input report 0x31: its state block starts at byte 2 and
|
||||
/// holds the clock at 27..31, little-endian (SDL's `PS5StatePacketCommon_t`, the kernel's
|
||||
/// `dualsense_input_report`).
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
fn bluetooth_clock(report: &[u8]) -> Option<u32> {
|
||||
match report {
|
||||
[0x31, _, state @ ..] if state.len() >= 31 => {
|
||||
@@ -239,6 +268,7 @@ fn bluetooth_clock(report: &[u8]) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
impl Writer {
|
||||
fn new(open: Arc<AtomicU8>) -> Self {
|
||||
Writer {
|
||||
@@ -451,7 +481,7 @@ impl Writer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_os = "ios")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! No HD haptics on iOS: they play through the pad's own four-channel audio device over USB,
|
||||
//! which needs cpal, and cpal is left out there with hidapi (see Cargo.toml).
|
||||
|
||||
/// The pad's audio output, playing the queued samples (left, right actuator).
|
||||
pub struct Haptics;
|
||||
|
||||
impl Haptics {
|
||||
/// The DualSense's audio device, if one is there with its four channels (USB). A phone
|
||||
/// has none.
|
||||
pub fn open() -> Option<Haptics> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Play a vibration (heavy and light levels, 0..1, each for a while), replacing the one
|
||||
/// playing. Nothing plays on iOS.
|
||||
pub fn play(&self, _steps: &[(f32, f32, std::time::Duration)]) {}
|
||||
|
||||
/// Stop at once. Nothing plays on iOS.
|
||||
pub fn stop(&self) {}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
//! The DualSense's output report, built by hand (docs/controllers.md "DualSense output"): the
|
||||
//! compatible-vibration motors, the adaptive triggers, the lightbar and the player LEDs. Gilrs
|
||||
//! keeps reading the pad; this crate only writes to it, over hidapi ([`device`]).
|
||||
//! On iOS the crate builds without hidapi, which has no backend there: [`Link`] never opens.
|
||||
//!
|
||||
//! The byte layout is the one documented by the Linux kernel's `hid-playstation.c` and SDL's
|
||||
//! `SDL_hidapi_ps5.c`: a 47-byte block shared by both transports, sent as report 0x02 over USB
|
||||
//! and as report 0x31 over Bluetooth, where a sequence tag leads it and a CRC-32 (seeded with
|
||||
//! the byte 0xA2) closes it.
|
||||
pub mod device;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub mod haptics;
|
||||
#[cfg(target_os = "ios")]
|
||||
#[path = "haptics_stub.rs"]
|
||||
pub mod haptics;
|
||||
pub mod trigger;
|
||||
|
||||
|
||||
@@ -21,18 +21,24 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Every interface but `log` is a capability: list the ones the mod uses in
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`).
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `ui`).
|
||||
//!
|
||||
//! Widgets (capability `ui`): a mod shows a screen it owns with
|
||||
//! [`ui::open_screen`] and a page in Options > Mods with
|
||||
//! [`ui::set_settings`]; what the player does with them comes back as
|
||||
//! [`Mod::ui_select`], [`Mod::ui_closed`] and [`Mod::ui_settings_opened`].
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "horizon-mod",
|
||||
world: "horizon-ui-mod",
|
||||
pub_export_macro: true,
|
||||
export_macro_name: "__export_horizon_mod",
|
||||
default_bindings_module: "horizon_mod_sdk",
|
||||
});
|
||||
|
||||
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
|
||||
pub use horizon::mods::{chat, game, hud, log, storage};
|
||||
pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest;
|
||||
pub use horizon::mods::{chat, game, hud, log, storage, ui};
|
||||
|
||||
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
|
||||
/// are called; the others may stay as their defaults.
|
||||
@@ -51,6 +57,14 @@ pub trait Mod: Sized + 'static {
|
||||
}
|
||||
/// A chat line the player received, when [`Registration::chat`] is set.
|
||||
fn chat(&mut self, _text: &str) {}
|
||||
/// A row of one of the mod's `list` widgets was picked (capability
|
||||
/// `ui`): the widget's `id` and the row.
|
||||
fn ui_select(&mut self, _id: u32, _row: u32) {}
|
||||
/// The player left the mod's screen (Esc or Done; capability `ui`).
|
||||
fn ui_closed(&mut self) {}
|
||||
/// The player opened the mod's settings page (capability `ui`): call
|
||||
/// [`ui::set_settings`] for fresh widgets.
|
||||
fn ui_settings_opened(&mut self) {}
|
||||
}
|
||||
|
||||
impl Registration {
|
||||
@@ -135,13 +149,27 @@ macro_rules! export_mod {
|
||||
with(|m| $crate::Mod::chat(m, &text))
|
||||
}
|
||||
}
|
||||
impl $crate::__UiEventsGuest for Export {
|
||||
fn select(id: u32, row: u32) {
|
||||
with(|m| $crate::Mod::ui_select(m, id, row))
|
||||
}
|
||||
fn closed() {
|
||||
with(|m| $crate::Mod::ui_closed(m))
|
||||
}
|
||||
fn settings_opened() {
|
||||
with(|m| $crate::Mod::ui_settings_opened(m))
|
||||
}
|
||||
}
|
||||
// Only WebAssembly has the component's exports: a host build (cargo test or
|
||||
// clippy on the workspace) checks the mod without them, which macOS
|
||||
// couldn't link (its exported-symbols list cuts names at the `#`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
$crate::__export_horizon_mod!(Export);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = <Export as $crate::__Guest>::init;
|
||||
let _ = (
|
||||
<Export as $crate::__Guest>::init,
|
||||
<Export as $crate::__UiEventsGuest>::select,
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,83 @@ interface storage {
|
||||
delete: func(key: string);
|
||||
}
|
||||
|
||||
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
|
||||
/// a screen the mod owns and a settings page in Options > Mods. Layout and
|
||||
/// drawing are the game's: a mod says what its widgets hold, never where
|
||||
/// they sit or how they look, and the game clips everything it is handed.
|
||||
interface ui {
|
||||
/// A line of text.
|
||||
record text-widget {
|
||||
/// The mod's own name for the widget; `ui-events` calls echo it.
|
||||
id: u32,
|
||||
/// Up to 256 characters.
|
||||
text: string,
|
||||
/// 0xRRGGBB; 0 is the menus' white.
|
||||
color: u32,
|
||||
}
|
||||
/// An item's icon and a name beside it, as recipe viewers show them.
|
||||
record item-widget {
|
||||
/// The mod's own name for the widget.
|
||||
id: u32,
|
||||
/// `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?".
|
||||
item: string,
|
||||
/// The name shown beside the icon (empty: the item's own).
|
||||
name: string,
|
||||
}
|
||||
/// Rows the player picks one of, as an options page lists choices: a
|
||||
/// pick is `ui-events::select`, `selected` is the row to mark now.
|
||||
record list-widget {
|
||||
/// The mod's own name for the widget; `ui-events::select` echoes it.
|
||||
id: u32,
|
||||
/// The rows' texts, at most 512, each up to 256 characters.
|
||||
rows: list<string>,
|
||||
/// The row to mark as the current choice, if any.
|
||||
selected: option<u32>,
|
||||
}
|
||||
/// A thing on a mod's screen or settings page.
|
||||
variant widget {
|
||||
/// Text the player reads.
|
||||
text(text-widget),
|
||||
/// An item's icon and name.
|
||||
item(item-widget),
|
||||
/// Rows the player picks from.
|
||||
%list(list-widget),
|
||||
}
|
||||
/// A screen the mod owns: a title over widgets stacked down the middle,
|
||||
/// the Done button the game adds, and Esc to leave it.
|
||||
record screen {
|
||||
/// Its title, up to 256 characters.
|
||||
title: string,
|
||||
/// Its widgets, in order, at most 64.
|
||||
widgets: list<widget>,
|
||||
}
|
||||
/// Show `screen` over the game; a second call while it is open replaces
|
||||
/// it. Only the last call of one hook counts.
|
||||
open-screen: func(screen: screen);
|
||||
/// Close the mod's screen when it is open (the mod hears no `closed`
|
||||
/// for this: it was its own doing).
|
||||
close-screen: func();
|
||||
/// The mod's page in Options > Mods, reached from a settings button on
|
||||
/// its row: the widgets it shows, an empty list for no page. The player
|
||||
/// opening the page is `ui-events::settings-opened`: push fresh widgets
|
||||
/// then; later calls update the page while it shows.
|
||||
set-settings: func(widgets: list<widget>);
|
||||
}
|
||||
|
||||
/// What the game calls on a mod that shows widgets (capability `ui`):
|
||||
/// what the player did with them.
|
||||
interface ui-events {
|
||||
/// A row of one of the mod's `list` widgets was picked: the widget's
|
||||
/// `id` and the row, on the screen or the settings page that showed it.
|
||||
select: func(id: u32, row: u32);
|
||||
/// The player left the mod's screen (Esc or Done); not for a screen the
|
||||
/// mod closed itself or one that went away with the mod.
|
||||
closed: func();
|
||||
/// The player opened the mod's settings page in Options > Mods: call
|
||||
/// `ui::set-settings` now to fill it with fresh widgets.
|
||||
settings-opened: func();
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
interface events {
|
||||
record registration {
|
||||
@@ -119,3 +196,18 @@ world horizon-mod {
|
||||
import storage;
|
||||
export events;
|
||||
}
|
||||
|
||||
/// A mod with widgets (capability `ui`): the SDK builds every mod against
|
||||
/// this world. Mods built for `horizon-mod` alone (no `ui-events` export)
|
||||
/// still load and simply hear nothing of their widgets.
|
||||
world horizon-ui-mod {
|
||||
include horizon-mod;
|
||||
import ui;
|
||||
export ui-events;
|
||||
}
|
||||
|
||||
/// The game's side of `horizon-ui-mod` beyond `horizon-mod`.
|
||||
world ui-host {
|
||||
import ui;
|
||||
export ui-events;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ pub struct Manifest {
|
||||
/// that are on may share one (`content::clash`).
|
||||
#[serde(default)]
|
||||
pub namespaces: Vec<String>,
|
||||
/// The experimental capabilities the mod opts into (`["ui"]`): each one
|
||||
/// it lists in `capabilities` must be named here too, since it may change
|
||||
/// in any release (docs/api/stability.md). One no longer experimental is
|
||||
/// accepted here, so the manifest keeps loading once it is promoted.
|
||||
#[serde(default)]
|
||||
pub experimental: Vec<Capability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
@@ -75,14 +81,18 @@ pub enum Capability {
|
||||
/// New blocks and items from the mod's `data/` (API 0.2): no WIT
|
||||
/// interface, the game reads them before the world loads.
|
||||
Registries,
|
||||
/// A screen the mod owns and a settings page in Options > Mods, drawn
|
||||
/// with the menus' own widgets (interface `ui`, calls `ui-events`).
|
||||
Ui,
|
||||
}
|
||||
impl Capability {
|
||||
pub const ALL: [Capability; 5] = [
|
||||
pub const ALL: [Capability; 6] = [
|
||||
Capability::GameRead,
|
||||
Capability::Chat,
|
||||
Capability::Hud,
|
||||
Capability::Storage,
|
||||
Capability::Registries,
|
||||
Capability::Ui,
|
||||
];
|
||||
/// The WIT interface it grants, in `horizon:mods`.
|
||||
pub fn interface(self) -> &'static str {
|
||||
@@ -92,8 +102,14 @@ impl Capability {
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Registries => "registries",
|
||||
Capability::Ui => "ui",
|
||||
}
|
||||
}
|
||||
/// May change in any release: a mod uses it only with
|
||||
/// `experimental = ["<name>"]` in its manifest.
|
||||
pub fn is_experimental(self) -> bool {
|
||||
matches!(self, Capability::Ui)
|
||||
}
|
||||
/// What the player reads before turning the mod on: "May <this>".
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
@@ -102,6 +118,7 @@ impl Capability {
|
||||
Capability::Hud => "draw text on your screen",
|
||||
Capability::Storage => "save its own settings to disk",
|
||||
Capability::Registries => "add blocks and items to your worlds",
|
||||
Capability::Ui => "show its own screens and a settings page",
|
||||
}
|
||||
}
|
||||
/// The capability a component import needs, `Ok(None)` for one every
|
||||
@@ -132,6 +149,7 @@ impl fmt::Display for Capability {
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Registries => "registries",
|
||||
Capability::Ui => "ui",
|
||||
};
|
||||
f.write_str(name)
|
||||
}
|
||||
@@ -162,6 +180,16 @@ impl Manifest {
|
||||
"{MANIFEST}: capability registries needs api = \"0.2\""
|
||||
));
|
||||
}
|
||||
if let Some(c) = m
|
||||
.capabilities
|
||||
.iter()
|
||||
.find(|c| c.is_experimental() && !m.experimental.contains(c))
|
||||
{
|
||||
return Err(format!(
|
||||
"{MANIFEST}: capability {c} is experimental and may change in any release: \
|
||||
add experimental = [\"{c}\"] to use it"
|
||||
));
|
||||
}
|
||||
if !m.namespaces.is_empty() && !m.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: namespaces are for content, which needs capability registries"
|
||||
@@ -414,4 +442,21 @@ capabilities = ["game-read", "hud"]
|
||||
assert_eq!(p.wasm.as_deref(), Some(&b"\0asm"[..]));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experimental_capabilities_need_the_opt_in() {
|
||||
let base = "id = \"m\"\nname = \"M\"\nversion = \"1\"\napi = \"0.1\"\n";
|
||||
let err = Manifest::parse(&format!("{base}capabilities = [\"ui\"]\n")).unwrap_err();
|
||||
assert!(err.contains("experimental = [\"ui\"]"), "{err}");
|
||||
let m = Manifest::parse(&format!(
|
||||
"{base}capabilities = [\"ui\"]\nexperimental = [\"ui\"]\n"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(m.experimental, [Capability::Ui]);
|
||||
// A capability that is (or became) stable is fine in the list.
|
||||
Manifest::parse(&format!(
|
||||
"{base}capabilities = [\"hud\"]\nexperimental = [\"hud\"]\n"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ mod bindings {
|
||||
world: "horizon-mod",
|
||||
});
|
||||
}
|
||||
/// Capability `ui`'s world beyond `horizon-mod` (docs/mods.md): a second
|
||||
/// world, so mods built before it (no `ui-events` export) still instantiate
|
||||
/// as `horizon-mod` and simply hear nothing of their widgets.
|
||||
mod ui_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../horizon-mod-sdk/wit",
|
||||
world: "ui-host",
|
||||
});
|
||||
}
|
||||
pub use bindings::horizon::mods::{
|
||||
game::{Clock, Player},
|
||||
hud::{Corner, Line},
|
||||
@@ -25,6 +34,12 @@ use bindings::{
|
||||
exports::horizon::mods::events::Registration,
|
||||
horizon::mods::{chat, game, hud, log as mod_log, storage},
|
||||
};
|
||||
/// The widget model a `ui` screen or settings page shows (WIT `interface
|
||||
/// ui`), for the game to draw with its menus' own widgets.
|
||||
pub use ui_bindings::horizon::mods::ui::{
|
||||
ItemWidget, ListWidget, Screen as UiScreen, TextWidget, Widget as UiWidget,
|
||||
};
|
||||
use ui_bindings::{UiHost, horizon::mods::ui};
|
||||
|
||||
/// A mod's linear memory cap.
|
||||
const MEMORY: usize = 64 << 20;
|
||||
@@ -66,6 +81,11 @@ pub enum Output {
|
||||
Feedback { text: String, error: bool },
|
||||
/// The mod was turned off: it trapped, overran or failed to start.
|
||||
Stopped { name: String, reason: String },
|
||||
/// `ui.open-screen`: the mod's entry in `entries()`; what it shows is
|
||||
/// [`Mods::screen`].
|
||||
OpenScreen { owner: usize },
|
||||
/// `ui.close-screen`: the mod closed its own screen (no `closed` back).
|
||||
CloseScreen { owner: usize },
|
||||
}
|
||||
|
||||
/// A mod's text panel, drawn every frame until it changes.
|
||||
@@ -95,19 +115,44 @@ pub struct Entry {
|
||||
run: Option<Running>,
|
||||
modified: Option<SystemTime>,
|
||||
}
|
||||
impl Entry {
|
||||
/// The running mod has a settings page (capability `ui` set widgets
|
||||
/// with `ui.set-settings`): its row in Options > Mods gains a button.
|
||||
pub fn has_ui_settings(&self) -> bool {
|
||||
self.run
|
||||
.as_ref()
|
||||
.is_some_and(|r| !r.store.data().ui.settings.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
struct Running {
|
||||
store: Store<State>,
|
||||
exports: HorizonMod,
|
||||
/// The mod's `ui-events` export; none for a mod built before `ui`.
|
||||
ui: Option<UiHost>,
|
||||
hooks: Registration,
|
||||
}
|
||||
|
||||
/// What capability `ui` pushed since the hooks last ran.
|
||||
#[derive(Default)]
|
||||
struct UiState {
|
||||
/// The screen `ui.open-screen` last pushed, until `ui.close-screen`.
|
||||
screen: Option<UiScreen>,
|
||||
/// The widgets `ui.set-settings` last pushed (the mod's page in
|
||||
/// Options > Mods).
|
||||
settings: Vec<UiWidget>,
|
||||
/// The calls since the last hook ended, for [`Output`].
|
||||
open: bool,
|
||||
close: bool,
|
||||
}
|
||||
|
||||
struct State {
|
||||
name: String,
|
||||
id: String,
|
||||
snapshot: Arc<Snapshot>,
|
||||
out: Vec<Output>,
|
||||
panel: Option<Panel>,
|
||||
ui: UiState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
data_dir: PathBuf,
|
||||
/// Bytes `storage::set` may still write before this hook call ends
|
||||
@@ -184,6 +229,8 @@ impl Mods {
|
||||
let mut linker = Linker::new(&engine);
|
||||
HorizonMod::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
UiHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.engine = Some((engine, linker));
|
||||
}
|
||||
Ok(self.engine.as_ref().expect("made above"))
|
||||
@@ -242,6 +289,7 @@ impl Mods {
|
||||
snapshot,
|
||||
out: Vec::new(),
|
||||
panel: None,
|
||||
ui: UiState::default(),
|
||||
data_dir,
|
||||
storage_budget: STORAGE_WRITE_BUDGET,
|
||||
limits: StoreLimitsBuilder::new()
|
||||
@@ -252,8 +300,13 @@ impl Mods {
|
||||
let mut store = Store::new(engine, state);
|
||||
store.limiter(|s| &mut s.limits);
|
||||
store.set_fuel(FUEL_INIT).map_err(|e| e.to_string())?;
|
||||
let exports =
|
||||
HorizonMod::instantiate(&mut store, &component, linker).map_err(|e| reason(&e))?;
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &component)
|
||||
.map_err(|e| reason(&e))?;
|
||||
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
|
||||
// `ui-events` is a mod built since `ui`'s; older mods have no export
|
||||
// and stay callable through `events` alone.
|
||||
let ui = UiHost::new(&mut store, &instance).ok();
|
||||
let mut hooks = exports
|
||||
.horizon_mods_events()
|
||||
.call_init(&mut store)
|
||||
@@ -269,6 +322,7 @@ impl Mods {
|
||||
Ok(Some(Running {
|
||||
store,
|
||||
exports,
|
||||
ui,
|
||||
hooks,
|
||||
}))
|
||||
}
|
||||
@@ -290,7 +344,7 @@ impl Mods {
|
||||
&mut self,
|
||||
fuel: u64,
|
||||
wants: impl Fn(&Registration) -> bool,
|
||||
mut f: impl FnMut(&HorizonMod, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
mut f: impl FnMut(&mut Running) -> wasmtime::Result<()>,
|
||||
) {
|
||||
for i in 0..self.entries.len() {
|
||||
if self.entries[i]
|
||||
@@ -308,20 +362,28 @@ impl Mods {
|
||||
&mut self,
|
||||
i: usize,
|
||||
fuel: u64,
|
||||
f: impl FnOnce(&HorizonMod, &mut Store<State>) -> wasmtime::Result<R>,
|
||||
f: impl FnOnce(&mut Running) -> wasmtime::Result<R>,
|
||||
) -> Option<R> {
|
||||
let entry = &mut self.entries[i];
|
||||
let run = entry.run.as_mut()?;
|
||||
run.store.data_mut().snapshot = self.snapshot.clone();
|
||||
run.store.data_mut().storage_budget = STORAGE_WRITE_BUDGET;
|
||||
let started = Instant::now();
|
||||
let result = run
|
||||
.store
|
||||
.set_fuel(fuel)
|
||||
.and_then(|()| f(&run.exports, &mut run.store));
|
||||
let result = run.store.set_fuel(fuel).and_then(|()| f(run));
|
||||
// An average over about the last 64 calls.
|
||||
entry.cost = (entry.cost * 63 + started.elapsed()) / 64;
|
||||
self.out.append(&mut run.store.data_mut().out);
|
||||
let data = run.store.data_mut();
|
||||
let (open, close) = (
|
||||
std::mem::take(&mut data.ui.open),
|
||||
std::mem::take(&mut data.ui.close),
|
||||
);
|
||||
self.out.append(&mut data.out);
|
||||
if open {
|
||||
self.out.push(Output::OpenScreen { owner: i });
|
||||
}
|
||||
if close {
|
||||
self.out.push(Output::CloseScreen { owner: i });
|
||||
}
|
||||
match result {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
@@ -344,7 +406,11 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_TICK,
|
||||
|h| h.tick,
|
||||
|m, s| m.horizon_mods_events().call_tick(s, tick),
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_tick(&mut run.store, tick)
|
||||
},
|
||||
);
|
||||
}
|
||||
/// A rendered frame, `seconds` after the last.
|
||||
@@ -352,7 +418,11 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_FRAME,
|
||||
|h| h.frame,
|
||||
|m, s| m.horizon_mods_events().call_frame(s, seconds),
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_frame(&mut run.store, seconds)
|
||||
},
|
||||
);
|
||||
}
|
||||
/// A chat line the player received, as plain text.
|
||||
@@ -360,7 +430,11 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_EVENT,
|
||||
|h| h.chat,
|
||||
|m, s| m.horizon_mods_events().call_chat(s, text),
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_chat(&mut run.store, text)
|
||||
},
|
||||
);
|
||||
}
|
||||
/// Whether a running mod owns `/name`.
|
||||
@@ -381,8 +455,10 @@ impl Mods {
|
||||
let Some(i) = self.command_owner(name) else {
|
||||
return false;
|
||||
};
|
||||
let result = self.call(i, FUEL_EVENT, |m, s| {
|
||||
m.horizon_mods_events().call_command(s, name, args)
|
||||
let result = self.call(i, FUEL_EVENT, |run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_command(&mut run.store, name, args)
|
||||
});
|
||||
match result {
|
||||
Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }),
|
||||
@@ -405,6 +481,60 @@ impl Mods {
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref()?.store.data().panel.as_ref())
|
||||
}
|
||||
/// The screen mod `i` last pushed with `ui.open-screen`, while it is up.
|
||||
pub fn screen(&self, i: usize) -> Option<&UiScreen> {
|
||||
self.entries
|
||||
.get(i)?
|
||||
.run
|
||||
.as_ref()?
|
||||
.store
|
||||
.data()
|
||||
.ui
|
||||
.screen
|
||||
.as_ref()
|
||||
}
|
||||
/// The widgets `ui.set-settings` pushed: mod `i`'s page in
|
||||
/// Options > Mods. Empty while it has none.
|
||||
pub fn settings_widgets(&self, i: usize) -> &[UiWidget] {
|
||||
self.entries
|
||||
.get(i)
|
||||
.and_then(|e| e.run.as_ref())
|
||||
.map_or(&[], |r| &r.store.data().ui.settings)
|
||||
}
|
||||
/// A `list` widget's row was picked on a surface of mod `i`.
|
||||
pub fn ui_select(&mut self, i: usize, id: u32, row: u32) {
|
||||
self.ui_event(i, |ui, s| {
|
||||
ui.horizon_mods_ui_events().call_select(s, id, row)
|
||||
});
|
||||
}
|
||||
/// The player left mod `i`'s screen (Esc or Done).
|
||||
pub fn ui_closed(&mut self, i: usize) {
|
||||
self.ui_event(i, |ui, s| ui.horizon_mods_ui_events().call_closed(s));
|
||||
}
|
||||
/// The player opened mod `i`'s settings page.
|
||||
pub fn ui_settings_opened(&mut self, i: usize) {
|
||||
self.ui_event(i, |ui, s| {
|
||||
ui.horizon_mods_ui_events().call_settings_opened(s)
|
||||
});
|
||||
}
|
||||
/// A `ui-events` call on mod `i`; no export, no call (mods built
|
||||
/// before `ui` have none).
|
||||
fn ui_event(
|
||||
&mut self,
|
||||
i: usize,
|
||||
f: impl FnOnce(&UiHost, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
) {
|
||||
let has_ui = self
|
||||
.entries
|
||||
.get(i)
|
||||
.and_then(|e| e.run.as_ref())
|
||||
.is_some_and(|r| r.ui.is_some());
|
||||
if has_ui {
|
||||
self.call(i, FUEL_EVENT, |run| {
|
||||
f(run.ui.as_ref().expect("checked above"), &mut run.store)
|
||||
});
|
||||
}
|
||||
}
|
||||
/// What mods asked to show since the last call.
|
||||
pub fn drain(&mut self) -> Vec<Output> {
|
||||
std::mem::take(&mut self.out)
|
||||
@@ -476,6 +606,33 @@ fn clip(text: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Widgets on a `ui` screen or settings page, and rows on one `list`.
|
||||
const WIDGETS: usize = 64;
|
||||
const LIST_ROWS: usize = 512;
|
||||
|
||||
fn clip_widgets(widgets: Vec<UiWidget>) -> Vec<UiWidget> {
|
||||
widgets
|
||||
.into_iter()
|
||||
.take(WIDGETS)
|
||||
.map(|w| match w {
|
||||
UiWidget::Text(mut t) => {
|
||||
t.text = clip(t.text);
|
||||
UiWidget::Text(t)
|
||||
}
|
||||
UiWidget::Item(mut i) => {
|
||||
i.item = clip(i.item);
|
||||
i.name = clip(i.name);
|
||||
UiWidget::Item(i)
|
||||
}
|
||||
UiWidget::List(mut l) => {
|
||||
l.rows = l.rows.into_iter().take(LIST_ROWS).map(clip).collect();
|
||||
l.selected = l.selected.filter(|&s| (s as usize) < l.rows.len());
|
||||
UiWidget::List(l)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl mod_log::Host for State {
|
||||
fn write(&mut self, level: mod_log::Level, message: String) {
|
||||
let level = match level {
|
||||
@@ -527,6 +684,21 @@ impl hud::Host for State {
|
||||
self.panel = None;
|
||||
}
|
||||
}
|
||||
impl ui::Host for State {
|
||||
fn open_screen(&mut self, mut screen: ui::Screen) {
|
||||
screen.title = clip(screen.title);
|
||||
screen.widgets = clip_widgets(screen.widgets);
|
||||
self.ui.screen = Some(screen);
|
||||
self.ui.open = true;
|
||||
}
|
||||
fn close_screen(&mut self) {
|
||||
self.ui.screen = None;
|
||||
self.ui.close = true;
|
||||
}
|
||||
fn set_settings(&mut self, widgets: Vec<ui::Widget>) {
|
||||
self.ui.settings = clip_widgets(widgets);
|
||||
}
|
||||
}
|
||||
impl storage::Host for State {
|
||||
fn get(&mut self, key: String) -> Option<Vec<u8>> {
|
||||
storage_key_ok(&key)
|
||||
|
||||
@@ -17,6 +17,14 @@ version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["chat", "storage"]
|
||||
"#;
|
||||
const UI_MANIFEST: &str = r#"
|
||||
id = "test_ui_guest"
|
||||
name = "Test UI Guest"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["chat", "ui"]
|
||||
experimental = ["ui"]
|
||||
"#;
|
||||
|
||||
/// Builds both mods once and writes their `.hmod` files; returns the folder.
|
||||
fn mods_dir() -> &'static Path {
|
||||
@@ -32,6 +40,8 @@ fn mods_dir() -> &'static Path {
|
||||
"coords-hud",
|
||||
"-p",
|
||||
"horizon-mods-test-guest",
|
||||
"-p",
|
||||
"horizon-mods-test-ui-guest",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
@@ -48,6 +58,7 @@ fn mods_dir() -> &'static Path {
|
||||
let mods = [
|
||||
("coords_hud", std::fs::read_to_string(example).unwrap()),
|
||||
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
|
||||
("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()),
|
||||
];
|
||||
for (name, manifest) in mods {
|
||||
let core = std::fs::read(built.join(format!("{name}.wasm"))).unwrap();
|
||||
@@ -366,6 +377,45 @@ fn hook_overhead() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_screens_settings_and_events() {
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
assert!(!mods.entries()[0].has_ui_settings());
|
||||
// `/ui_screen` pushes the screen; the host clips nothing of this one.
|
||||
assert!(mods.command("ui_screen"));
|
||||
assert_eq!(mods.drain(), [Output::OpenScreen { owner: 0 }]);
|
||||
let screen = mods.screen(0).expect("a pushed screen");
|
||||
assert_eq!(screen.title, "Guest's screen");
|
||||
assert_eq!(screen.widgets.len(), 3);
|
||||
// What the player did comes back through `ui-events`.
|
||||
mods.ui_select(0, 7, 2);
|
||||
mods.ui_closed(0);
|
||||
assert_eq!(
|
||||
mods.drain(),
|
||||
[
|
||||
Output::Chat {
|
||||
name: "Test UI Guest".into(),
|
||||
text: "selected 7:2".into(),
|
||||
},
|
||||
Output::Chat {
|
||||
name: "Test UI Guest".into(),
|
||||
text: "closed".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
// The settings page: empty until the mod fills it, refreshed on open.
|
||||
assert!(mods.command("ui_settings"));
|
||||
assert_eq!(mods.settings_widgets(0).len(), 3);
|
||||
assert!(mods.entries()[0].has_ui_settings());
|
||||
mods.ui_settings_opened(0);
|
||||
assert_eq!(mods.settings_widgets(0).len(), 1);
|
||||
// `/ui_close` closes the mod's own screen.
|
||||
assert!(mods.command("ui_close"));
|
||||
assert!(mods.drain().contains(&Output::CloseScreen { owner: 0 }));
|
||||
assert!(mods.screen(0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_only_mods_load_without_an_engine() {
|
||||
let manifest = r#"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "horizon-mods-test-ui-guest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "A mod that shows widgets, for horizon-mods' sandbox tests"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,66 @@
|
||||
//! A mod for the runtime's `ui` tests: `/ui_screen` opens a screen with one
|
||||
//! of each widget, `/ui_settings` sets a settings page, `/ui_close` closes
|
||||
//! the screen; the `ui-events` calls answer in chat so the host sees them.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, chat,
|
||||
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
|
||||
};
|
||||
|
||||
struct Guest;
|
||||
|
||||
fn widgets() -> Vec<Widget> {
|
||||
vec![
|
||||
Widget::Text(TextWidget {
|
||||
id: 1,
|
||||
text: "Pick a row".into(),
|
||||
color: 0,
|
||||
}),
|
||||
Widget::Item(ItemWidget {
|
||||
id: 2,
|
||||
item: "minecraft:diamond".into(),
|
||||
name: String::new(),
|
||||
}),
|
||||
Widget::List(ListWidget {
|
||||
id: 7,
|
||||
rows: vec!["first".into(), "second".into(), "third".into()],
|
||||
selected: Some(1),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
impl Mod for Guest {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let hooks = ["ui_screen", "ui_settings", "ui_close"]
|
||||
.into_iter()
|
||||
.fold(Registration::new(), Registration::command);
|
||||
Ok((Guest, hooks))
|
||||
}
|
||||
fn command(&mut self, name: &str, _args: &str) -> Result<Option<String>, String> {
|
||||
match name {
|
||||
"ui_screen" => ui::open_screen(&Screen {
|
||||
title: "Guest's screen".into(),
|
||||
widgets: widgets(),
|
||||
}),
|
||||
"ui_settings" => ui::set_settings(&widgets()),
|
||||
"ui_close" => ui::close_screen(),
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, row: u32) {
|
||||
chat::show(&format!("selected {id}:{row}"));
|
||||
}
|
||||
fn ui_closed(&mut self) {
|
||||
chat::show("closed");
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
ui::set_settings(&[Widget::Text(TextWidget {
|
||||
id: 1,
|
||||
text: "fresh".into(),
|
||||
color: 0x33CC33,
|
||||
})]);
|
||||
chat::show("settings opened");
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Guest);
|
||||
@@ -39,6 +39,8 @@ pub mod component {
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
pub const TRIM: i32 = 58;
|
||||
pub const FIREWORK_EXPLOSION: i32 = 70;
|
||||
pub const FIREWORKS: i32 = 71;
|
||||
}
|
||||
|
||||
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
|
||||
@@ -89,6 +91,13 @@ pub enum Component {
|
||||
WrittenBook(WrittenBook),
|
||||
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
|
||||
Stacks(Vec<Stack>),
|
||||
/// `Fireworks`: flight duration and its explosions.
|
||||
Fireworks {
|
||||
flight: i32,
|
||||
explosions: Vec<FireworkExplosion>,
|
||||
},
|
||||
/// `FireworkExplosion` (a firework star).
|
||||
FireworkExplosion(FireworkExplosion),
|
||||
/// `ArmorTrim` naming its material and pattern by registry id (`trim_material`,
|
||||
/// `trim_pattern`); a trim with a direct holder stays [`Component::Raw`].
|
||||
Trim {
|
||||
@@ -100,6 +109,41 @@ pub enum Component {
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
/// `FireworkExplosion.STREAM_CODEC`: shape id, colors, fade colors, trail
|
||||
/// and twinkle (FireworkExplosion.Shape's BY_ID order).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FireworkExplosion {
|
||||
pub shape: i32,
|
||||
pub colors: Vec<i32>,
|
||||
pub fade: Vec<i32>,
|
||||
pub trail: bool,
|
||||
pub twinkle: bool,
|
||||
}
|
||||
impl FireworkExplosion {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let ints = |r: &mut Reader| -> Result<Vec<i32>> {
|
||||
let n = r.count(1024)?;
|
||||
(0..n).map(|_| r.i32()).collect()
|
||||
};
|
||||
Ok(Self {
|
||||
shape: r.varint()?,
|
||||
colors: ints(r)?,
|
||||
fade: ints(r)?,
|
||||
trail: r.bool()?,
|
||||
twinkle: r.bool()?,
|
||||
})
|
||||
}
|
||||
fn write(&self, w: &mut Writer) {
|
||||
w.varint(self.shape);
|
||||
w.varint(self.colors.len() as i32);
|
||||
self.colors.iter().for_each(|c| w.i32(*c));
|
||||
w.varint(self.fade.len() as i32);
|
||||
self.fade.iter().for_each(|c| w.i32(*c));
|
||||
w.bool(self.trail);
|
||||
w.bool(self.twinkle);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
|
||||
/// (dropped).
|
||||
fn filterable<T>(r: &mut Reader, value: impl Fn(&mut Reader) -> Result<T>) -> Result<T> {
|
||||
@@ -260,6 +304,17 @@ impl Component {
|
||||
hidden: (0..n).map(|_| r.varint()).collect::<Result<_>>()?,
|
||||
}
|
||||
}
|
||||
FIREWORKS => {
|
||||
let flight = r.varint()?;
|
||||
let n = r.count(256)?;
|
||||
Self::Fireworks {
|
||||
flight,
|
||||
explosions: (0..n)
|
||||
.map(|_| FireworkExplosion::read(r))
|
||||
.collect::<Result<_>>()?,
|
||||
}
|
||||
}
|
||||
FIREWORK_EXPLOSION => Self::FireworkExplosion(FireworkExplosion::read(r)?),
|
||||
// ArmorTrim.STREAM_CODEC: two holders, each a reference's id plus
|
||||
// one. A direct material or pattern has no registry name: read past.
|
||||
TRIM => {
|
||||
@@ -375,6 +430,12 @@ impl Component {
|
||||
w.varint(stacks.len() as i32);
|
||||
stacks.iter().for_each(|s| Template::write(s, w));
|
||||
}
|
||||
Self::Fireworks { flight, explosions } => {
|
||||
w.varint(*flight);
|
||||
w.varint(explosions.len() as i32);
|
||||
explosions.iter().for_each(|e| e.write(w));
|
||||
}
|
||||
Self::FireworkExplosion(e) => e.write(w),
|
||||
Self::WritableBook(pages) => {
|
||||
w.varint(pages.len() as i32);
|
||||
for page in pages {
|
||||
@@ -670,6 +731,44 @@ mod tests {
|
||||
round::<Option<Hashed>, HashedSlot>(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireworks_components_round_trip() {
|
||||
let mut rocket = Stack::new(42, 1);
|
||||
rocket.added.push((
|
||||
component::FIREWORKS,
|
||||
Component::Fireworks {
|
||||
flight: 3,
|
||||
explosions: vec![
|
||||
FireworkExplosion {
|
||||
shape: 4,
|
||||
colors: vec![0xff0000, 0x00ff00],
|
||||
fade: vec![0x0000ff],
|
||||
trail: true,
|
||||
twinkle: false,
|
||||
},
|
||||
FireworkExplosion {
|
||||
shape: 0,
|
||||
colors: vec![],
|
||||
fade: vec![],
|
||||
trail: false,
|
||||
twinkle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
));
|
||||
rocket.added.push((
|
||||
component::FIREWORK_EXPLOSION,
|
||||
Component::FireworkExplosion(FireworkExplosion {
|
||||
shape: 2,
|
||||
colors: vec![7],
|
||||
fade: vec![],
|
||||
trail: false,
|
||||
twinkle: false,
|
||||
}),
|
||||
));
|
||||
round::<Option<Stack>, Slot>(Some(rocket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charged_projectiles_are_item_stack_templates() {
|
||||
// A crossbow (id 7) loaded with one arrow (id 9): ChargedProjectiles
|
||||
|
||||
@@ -102,6 +102,12 @@ impl Dynamic {
|
||||
.get(i)
|
||||
.map(|(n, _)| n.as_str())
|
||||
}
|
||||
/// The id of an entry by name, the reverse of `name`. A registry has one
|
||||
/// entry per id, so the first match is the only one.
|
||||
pub fn id(&self, registry: &str, name: &str) -> Option<i32> {
|
||||
let list = self.registries.get(registry)?;
|
||||
list.iter().position(|(n, _)| n == name).map(|i| i as i32)
|
||||
}
|
||||
pub fn data(&self, registry: &str, id: i32) -> Option<&Tag> {
|
||||
let i = usize::try_from(id).ok()?;
|
||||
self.registries.get(registry)?.get(i)?.1.as_ref()
|
||||
|
||||
@@ -849,7 +849,17 @@ mod tests {
|
||||
let first = lock(&dir).unwrap();
|
||||
assert!(lock(&dir).unwrap_err().contains("already running"));
|
||||
drop(first);
|
||||
lock(&dir).unwrap();
|
||||
// A process another test spawns holds a copy of the lock's descriptor between its fork and exec:
|
||||
// the lock comes back once that child has exec'd.
|
||||
let mut again = lock(&dir);
|
||||
for _ in 0..50 {
|
||||
if again.is_ok() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
again = lock(&dir);
|
||||
}
|
||||
again.unwrap();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,27 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`,
|
||||
| `hud` | [`hud`](#hud) | draw text on your screen |
|
||||
| `storage` | [`storage`](#storage) | save its own settings to disk |
|
||||
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
|
||||
| `ui` | [`ui`](#ui) | show its own screens and a settings page |
|
||||
|
||||
## World `horizon-mod`
|
||||
|
||||
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`
|
||||
- Exports (what the mod provides): `events`
|
||||
|
||||
## World `horizon-ui-mod`
|
||||
|
||||
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
|
||||
|
||||
- Imports (what the game offers): `ui`, `log`, `game`, `chat`, `hud`, `storage`
|
||||
- Exports (what the mod provides): `ui-events`, `events`
|
||||
|
||||
## World `ui-host`
|
||||
|
||||
The game's side of `horizon-ui-mod` beyond `horizon-mod`.
|
||||
|
||||
- Imports (what the game offers): `ui`
|
||||
- Exports (what the mod provides): `ui-events`
|
||||
|
||||
## `log`
|
||||
|
||||
Access: always granted.
|
||||
@@ -117,6 +132,79 @@ Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/m
|
||||
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
|
||||
| `delete` | `func(key: string)` | No error if `key` was already absent. |
|
||||
|
||||
## `ui`
|
||||
|
||||
Access: capability `ui`.
|
||||
|
||||
Capability `ui`: widgets the game draws with its own menus' widgets, for a screen the mod owns and a settings page in Options > Mods. Layout and drawing are the game's: a mod says what its widgets hold, never where they sit or how they look, and the game clips everything it is handed.
|
||||
|
||||
### record `text-widget`
|
||||
|
||||
A line of text.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget; `ui-events` calls echo it. |
|
||||
| `text` | `string` | Up to 256 characters. |
|
||||
| `color` | `u32` | 0xRRGGBB; 0 is the menus' white. |
|
||||
|
||||
### record `item-widget`
|
||||
|
||||
An item's icon and a name beside it, as recipe viewers show them.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget. |
|
||||
| `item` | `string` | `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?". |
|
||||
| `name` | `string` | The name shown beside the icon (empty: the item's own). |
|
||||
|
||||
### record `list-widget`
|
||||
|
||||
Rows the player picks one of, as an options page lists choices: a pick is `ui-events::select`, `selected` is the row to mark now.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget; `ui-events::select` echoes it. |
|
||||
| `rows` | `list<string>` | The rows' texts, at most 512, each up to 256 characters. |
|
||||
| `selected` | `option<u32>` | The row to mark as the current choice, if any. |
|
||||
|
||||
### variant `widget`
|
||||
|
||||
A thing on a mod's screen or settings page.
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `text` | `text-widget` | Text the player reads. |
|
||||
| `item` | `item-widget` | An item's icon and name. |
|
||||
| `list` | `list-widget` | Rows the player picks from. |
|
||||
|
||||
### record `screen`
|
||||
|
||||
A screen the mod owns: a title over widgets stacked down the middle, the Done button the game adds, and Esc to leave it.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `title` | `string` | Its title, up to 256 characters. |
|
||||
| `widgets` | `list<widget>` | Its widgets, in order, at most 64. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `open-screen` | `func(screen: screen)` | Show `screen` over the game; a second call while it is open replaces it. Only the last call of one hook counts. |
|
||||
| `close-screen` | `func()` | Close the mod's screen when it is open (the mod hears no `closed` for this: it was its own doing). |
|
||||
| `set-settings` | `func(widgets: list<widget>)` | The mod's page in Options > Mods, reached from a settings button on its row: the widgets it shows, an empty list for no page. The player opening the page is `ui-events::settings-opened`: push fresh widgets then; later calls update the page while it shows. |
|
||||
|
||||
## `ui-events`
|
||||
|
||||
Access: exported by the mod: the game calls it.
|
||||
|
||||
What the game calls on a mod that shows widgets (capability `ui`): what the player did with them.
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `select` | `func(id: u32, row: u32)` | A row of one of the mod's `list` widgets was picked: the widget's `id` and the row, on the screen or the settings page that showed it. |
|
||||
| `closed` | `func()` | The player left the mod's screen (Esc or Done); not for a screen the mod closed itself or one that went away with the mod. |
|
||||
| `settings-opened` | `func()` | The player opened the mod's settings page in Options > Mods: call `ui::set-settings` now to fill it with fresh widgets. |
|
||||
|
||||
## `events`
|
||||
|
||||
Access: exported by the mod: the game calls it.
|
||||
|
||||
@@ -87,6 +87,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
| Part | Level | Version |
|
||||
| --- | --- | --- |
|
||||
| Mod API: `log`, `game`, `chat`, `hud`, `storage`, `events` | preview | 0.1.0 |
|
||||
| Mod API: `ui`, `ui-events` (a mod's screens and settings page) | experimental | manifest `experimental = ["ui"]` |
|
||||
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
|
||||
| Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first |
|
||||
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user