mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Merge main into this branch (goat memory init, mooshroom kinds test)
Main's goat_init_memories, goat ram and frog jump code keep their brain memories under main's names; this branch saves them under vanilla's ids. Both now use vanilla's: long_jump_cooling_down and ram_cooldown_ticks. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
655b331b1e
171 files changed
+6994
-2270
No files matched your search
@@ -53,7 +53,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
@@ -73,7 +73,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -93,9 +93,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~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 predator-missile --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):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
|
||||
</p>
|
||||
@@ -48,7 +49,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -127,6 +128,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
+9
-9
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
@@ -350,7 +350,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -370,7 +370,7 @@ When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -380,7 +380,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
@@ -498,11 +498,11 @@ More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
|
||||
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -524,7 +524,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -534,7 +534,7 @@ Hear nearby players talk, as with the voice chat mods of other Minecraft version
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
|
||||
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ particle_types! {
|
||||
Rain = "rain",
|
||||
Bubble = "bubble",
|
||||
SulfurBubbles = "sulfur_bubbles",
|
||||
NoxiousGas = "noxious_gas",
|
||||
NoxiousGasCloud = "noxious_gas_cloud",
|
||||
Geyser = "geyser",
|
||||
GeyserBase = "geyser_base",
|
||||
GeyserPoof = "geyser_poof",
|
||||
GeyserPlume = "geyser_plume",
|
||||
BubblePop = "bubble_pop",
|
||||
BubbleColumnUp = "bubble_column_up",
|
||||
CurrentDown = "current_down",
|
||||
@@ -149,6 +155,10 @@ impl ParticleType {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::Geyser
|
||||
| Self::GeyserBase
|
||||
| Self::GeyserPoof
|
||||
| Self::GeyserPlume
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
@@ -191,6 +201,8 @@ impl ParticleType {
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// NoxiousGasParticle.
|
||||
| Self::NoxiousGas
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum Button {
|
||||
}
|
||||
|
||||
pub trait InteractionWorld {
|
||||
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
|
||||
/// every click of the tick reads, even after one of them changed the
|
||||
/// block it names (a creative break, then a use that replaces it).
|
||||
fn pick_hit_result(&mut self);
|
||||
/// The tick's clicks are done: later picks follow the crosshair again.
|
||||
fn release_hit_result(&mut self);
|
||||
fn using_item(&self) -> bool;
|
||||
fn release_using(&mut self);
|
||||
fn start_attack(&mut self) -> bool;
|
||||
@@ -48,6 +54,7 @@ impl InteractionInput {
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
world.pick_hit_result();
|
||||
let mut instant = false;
|
||||
if world.using_item() {
|
||||
if !use_down {
|
||||
@@ -70,6 +77,7 @@ impl InteractionInput {
|
||||
world.start_use();
|
||||
}
|
||||
world.continue_attack(!instant && attack_down);
|
||||
world.release_hit_result();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +94,8 @@ mod tests {
|
||||
events: Vec<&'static str>,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
@@ -183,4 +193,67 @@ mod tests {
|
||||
input.tick(&mut world, false, false);
|
||||
assert_eq!(world.events, ["release", "continue"]);
|
||||
}
|
||||
|
||||
/// A floating block in front of the crosshair, nothing behind it, and a
|
||||
/// block item in the hand of a creative player.
|
||||
#[derive(Default)]
|
||||
struct Column {
|
||||
block: bool,
|
||||
hit: Option<Option<()>>,
|
||||
placed: bool,
|
||||
}
|
||||
impl Column {
|
||||
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
|
||||
fn target(&self) -> Option<()> {
|
||||
self.hit.unwrap_or(self.block.then_some(()))
|
||||
}
|
||||
}
|
||||
impl InteractionWorld for Column {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.hit = Some(self.target());
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.hit = None;
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn release_using(&mut self) {}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
// MultiPlayerGameMode.startDestroyBlock in creative: the
|
||||
// predicted break leaves air.
|
||||
if self.target().is_some() {
|
||||
self.block = false;
|
||||
}
|
||||
!self.block
|
||||
}
|
||||
fn start_use(&mut self) {
|
||||
// BlockItem.place: the clicked block, now air, can be replaced.
|
||||
if self.target().is_some() && !self.block {
|
||||
self.block = true;
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
fn pick(&mut self) {}
|
||||
fn use_delay(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
fn continue_attack(&mut self, _: bool) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_and_use_in_one_tick_share_the_hit_result() {
|
||||
// #3006: both buttons at once replace the block the crosshair is on.
|
||||
let mut input = InteractionInput::default();
|
||||
let mut world = Column {
|
||||
block: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.press(Button::Attack);
|
||||
input.press(Button::Use);
|
||||
input.tick(&mut world, false, false);
|
||||
assert!(world.placed, "the use acts on the block the attack broke");
|
||||
assert!(world.block);
|
||||
assert!(world.hit.is_none(), "the next pick follows the crosshair");
|
||||
}
|
||||
}
|
||||
@@ -192,9 +192,17 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the models and
|
||||
/// the sprites' layers, without the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
}
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
@@ -218,7 +226,11 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
if pixels {
|
||||
sprites.finish();
|
||||
} else {
|
||||
sprites.finish_without_pixels();
|
||||
}
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
|
||||
@@ -2363,6 +2363,7 @@ mod tests {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
..Default::default()
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
|
||||
@@ -80,6 +80,24 @@ pub enum BlockEntity {
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
|
||||
PotentSulfur(PotentSulfur),
|
||||
}
|
||||
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
|
||||
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
|
||||
/// last eruption block event, which the client's plume counts from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PotentSulfur {
|
||||
pub waiting_countdown: i32,
|
||||
pub eruption_tick: i64,
|
||||
}
|
||||
impl Default for PotentSulfur {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
waiting_countdown: -1,
|
||||
eruption_tick: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -228,6 +246,8 @@ pub struct SignText {
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
/// The `click_event` in each line's component style, as saved.
|
||||
pub click: [Option<Tag>; 4],
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -236,6 +256,8 @@ pub struct SignData {
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
/// `allow_op_features`: the sign's click events may run commands.
|
||||
pub allow_op_features: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -306,6 +306,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:potent_sulfur" {
|
||||
return Some(Self::PotentSulfur(PotentSulfur::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -349,6 +352,7 @@ impl BlockEntity {
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -367,6 +371,7 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -391,6 +396,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -419,6 +425,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -53,7 +53,7 @@ struct Out {
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
@@ -254,8 +254,8 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
} else if in.overlay == 36u {
|
||||
// Enchanted armour (mobs::render::ARMOR_GLINT): enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
|
||||
@@ -59,6 +59,7 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod potent_sulfur;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
|
||||
@@ -161,6 +161,31 @@ impl Column {
|
||||
self.spans.push(span);
|
||||
}
|
||||
}
|
||||
/// Open water and its floor lit as open sky, as the seed's far columns
|
||||
/// are: a chunk's own light fades a level per block of water, so the
|
||||
/// chunks a player passed drew a darker band through the seed's sea.
|
||||
/// The water is one span again.
|
||||
fn light_open_water(&mut self, kinds: &[u8]) {
|
||||
// Water; lava glows.
|
||||
let water = |span: &Span| {
|
||||
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
|
||||
kind & FLUID != 0 && kind >> 4 == 0
|
||||
};
|
||||
if !self.spans.last().is_some_and(water) {
|
||||
return;
|
||||
}
|
||||
let floor = self
|
||||
.spans
|
||||
.iter()
|
||||
.rposition(|span| !water(span))
|
||||
.unwrap_or(0);
|
||||
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
|
||||
if i >= floor {
|
||||
span.light |= 0xf0;
|
||||
}
|
||||
self.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state.
|
||||
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
|
||||
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
|
||||
@@ -212,6 +237,7 @@ impl Column {
|
||||
});
|
||||
}
|
||||
}
|
||||
column.light_open_water(kinds);
|
||||
let mut water = None;
|
||||
for span in column.spans.iter().rev() {
|
||||
if kind(span.state) & FLUID != 0 {
|
||||
@@ -1772,6 +1798,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
|
||||
let span = |min, max, state, light| Span {
|
||||
min,
|
||||
max,
|
||||
state: BlockStateId(state),
|
||||
biome: 0,
|
||||
light,
|
||||
};
|
||||
// 0 air, 1 stone, 2 water, 3 lava.
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
|
||||
// A chunk's own light: a cave, the floor, water fading a level a block.
|
||||
let mut sea = Column {
|
||||
surface: None,
|
||||
spans: vec![
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0x10 | 2 << 12),
|
||||
span(48, 61, 2, 0x20),
|
||||
span(61, 62, 2, 0xe0),
|
||||
span(62, 63, 2, 0xf0),
|
||||
],
|
||||
};
|
||||
sea.light_open_water(&kinds);
|
||||
assert_eq!(
|
||||
sea.spans,
|
||||
[
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0xf0 | 2 << 12),
|
||||
span(48, 63, 2, 0xf0)
|
||||
]
|
||||
);
|
||||
// Land, and lava, keep their light.
|
||||
for top in [1, 3] {
|
||||
let mut column = Column {
|
||||
surface: None,
|
||||
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
|
||||
};
|
||||
let before = column.spans.clone();
|
||||
column.light_open_water(&kinds);
|
||||
assert_eq!(column.spans, before);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
|
||||
@@ -2797,26 +2797,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => events.extend(self.back(s)),
|
||||
Act::Cycle(opt) => {
|
||||
opt.cycle(s, right, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events.extend(self.cycle_option(s, opt, right, framebuffer));
|
||||
}
|
||||
Act::Extra(item) => {
|
||||
if let Some(name) = item.key_name() {
|
||||
@@ -2924,6 +2905,77 @@ impl OptionsUi {
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(top, bottom, content);
|
||||
}
|
||||
/// `CycleButton.cycleValue` on an option, then what the option changes:
|
||||
/// the preset's values, High Contrast's built-in pack.
|
||||
fn cycle_option(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
opt: Opt,
|
||||
back: bool,
|
||||
framebuffer: [i32; 2],
|
||||
) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
opt.cycle(s, back, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events
|
||||
}
|
||||
/// The mouse wheel at the cursor. Over a cycle button it takes the step
|
||||
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let plain = self.resource_error.is_none()
|
||||
&& Self::graphics_level(self.page()).is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let (widgets, top, bottom, _) = self.layout(s, size.0, size.1);
|
||||
let hovered = widgets.iter().find_map(|wd| match &wd.kind {
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
|
||||
{
|
||||
Some(*opt)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
match hovered {
|
||||
Some(opt) if amount != 0.0 => self.cycle_option(s, opt, amount > 0.0, framebuffer),
|
||||
Some(_) => Vec::new(),
|
||||
None => {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The mouse wheel at the cursor.
|
||||
pub fn scroll_at(&mut self, s: &Settings, size: (f32, f32), cursor: (f32, f32), amount: f32) {
|
||||
if self.resource_error.is_some() {
|
||||
@@ -2940,6 +2992,33 @@ impl OptionsUi {
|
||||
_ => self.scroll(s, size, amount),
|
||||
}
|
||||
}
|
||||
/// `VideoSettingsScreen.mouseScrolled`: with Ctrl held the wheel steps the GUI scale
|
||||
/// (auto counts as one past the maximum) and the list goes back to the top.
|
||||
pub fn video_wheel_gui_scale(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let max = i64::from(crate::window::calculate_scale(
|
||||
framebuffer,
|
||||
0,
|
||||
s.force_unicode,
|
||||
));
|
||||
let old = if s.gui_scale == 0 {
|
||||
max + 1
|
||||
} else {
|
||||
i64::from(s.gui_scale)
|
||||
};
|
||||
let step = i64::from(amount > 0.0) - i64::from(amount < 0.0);
|
||||
let new = old + step;
|
||||
if new == 0 || new > max {
|
||||
return Vec::new();
|
||||
}
|
||||
s.gui_scale = new as u32;
|
||||
self.scroll = 0.0;
|
||||
vec![Event::Changed]
|
||||
}
|
||||
/// The pack screen at this GUI size, made on first use.
|
||||
fn packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) -> &mut crate::pack_screen::PackScreen {
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
|
||||
@@ -44,6 +44,9 @@ pub struct ParticleOptions {
|
||||
/// `ItemParticleOption`: the particle sprite of each layer of the item's
|
||||
/// model as resolved for the ground.
|
||||
pub item: Option<Vec<String>>,
|
||||
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
|
||||
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
|
||||
pub geyser: Option<(i32, f32)>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -55,6 +58,21 @@ impl ParticleOptions {
|
||||
to_color: [1.0, 0.0, 0.0],
|
||||
trail: None,
|
||||
item: None,
|
||||
geyser: None,
|
||||
}
|
||||
}
|
||||
/// `GeyserParticleOptions(type, waterBlocks)`.
|
||||
pub fn geyser(ty: ParticleType, water_blocks: i32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, 0.0)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `GeyserBaseParticleOptions(type, waterBlocks, burstImpulseBase)`.
|
||||
pub fn geyser_base(ty: ParticleType, water_blocks: i32, burst_impulse_base: f32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, burst_impulse_base)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `ItemParticleOption`: the stack's layer particle sprites (`BreakingItemParticle`).
|
||||
@@ -189,6 +207,29 @@ enum Behaviour {
|
||||
size_start: f32,
|
||||
y_prev: f64,
|
||||
},
|
||||
/// `NoxiousGasParticle`: fades out from `fade_start` ticks of age.
|
||||
NoxiousGas {
|
||||
fade_start: f32,
|
||||
},
|
||||
/// `NoxiousGasCloudParticle`: every second tick, a gas particle where the
|
||||
/// gas reaches.
|
||||
NoxiousGasCloud,
|
||||
/// `GeyserEruptionParticle`: the base puffs, the plume and the poofs of
|
||||
/// one eruption; `aux` is the speed its provider was given.
|
||||
GeyserEruption {
|
||||
water_blocks: i32,
|
||||
aux: [f64; 3],
|
||||
},
|
||||
/// `GeyserPlumeParticle`: shoots up `plumeHeight` blocks, then falls back.
|
||||
GeyserPlume {
|
||||
start_y: f64,
|
||||
max_y: f64,
|
||||
propulsion: f32,
|
||||
spray: [f32; 2],
|
||||
min_size: f32,
|
||||
max_size: f32,
|
||||
done: bool,
|
||||
},
|
||||
BubblePop,
|
||||
BubbleColumnUp,
|
||||
CurrentDown {
|
||||
@@ -869,6 +910,26 @@ impl Particle {
|
||||
(age / self.lifetime as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.pickRandomParticleSpawnPoint`: a point up to three
|
||||
/// blocks from the centre of `center_block` in a random horizontal direction,
|
||||
/// a quarter block lower, from the level's random.
|
||||
fn pick_random_particle_spawn_point(random: &mut LegacyRandom, center_block: [i32; 3]) -> [f64; 3] {
|
||||
let dx = f64::from(random.next_float() - 0.5);
|
||||
let dz = f64::from(random.next_float() - 0.5);
|
||||
// Vec3.normalize.
|
||||
let length = (dx * dx + dz * dz).sqrt();
|
||||
let (dx, dz) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(dx / length, dz / length)
|
||||
};
|
||||
let distance = f64::from(random.next_float() * 3.0);
|
||||
[
|
||||
f64::from(center_block[0]) + 0.5 + dx * distance,
|
||||
f64::from(center_block[1]) + 0.5 - 0.25,
|
||||
f64::from(center_block[2]) + 0.5 + dz * distance,
|
||||
]
|
||||
}
|
||||
/// `FireflyParticle.getFadeAmount`.
|
||||
fn fade_amount(progress: f32, fade_in: f32, fade_out: f32) -> f32 {
|
||||
if progress >= 1.0 - fade_in {
|
||||
@@ -928,7 +989,7 @@ impl ParticleLevel<'_> {
|
||||
Err(_) => d,
|
||||
}
|
||||
}
|
||||
fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
pub(crate) fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
[
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
@@ -950,7 +1011,7 @@ impl ParticleLevel<'_> {
|
||||
(s.fluid, height)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
@@ -1165,6 +1226,17 @@ impl ParticleEngine {
|
||||
self.create(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.spawnNoxiousGasParticle`: the gas, as an
|
||||
/// always visible particle (`ClientLevel.addAlwaysVisibleParticle`).
|
||||
fn spawn_noxious_gas_particle(&mut self, pos: [f64; 3]) {
|
||||
self.add_with(
|
||||
&ParticleOptions::simple(ParticleType::NoxiousGas),
|
||||
pos,
|
||||
[0.0; 3],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
/// `ClientLevel.calculateParticleLevel`: minimal still shows one in ten
|
||||
/// always-visible particles as decreased, and decreased drops a third.
|
||||
fn calculate_particle_level(&mut self, always_show: bool) -> u8 {
|
||||
@@ -1562,7 +1634,15 @@ impl ParticleEngine {
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
T::Smoke | T::LargeSmoke | T::WhiteSmoke | T::Ash | T::WhiteAsh | T::DustPlume => {
|
||||
T::Smoke
|
||||
| T::LargeSmoke
|
||||
| T::WhiteSmoke
|
||||
| T::Ash
|
||||
| T::WhiteAsh
|
||||
| T::DustPlume
|
||||
| T::NoxiousGas
|
||||
| T::GeyserBase
|
||||
| T::GeyserPoof => {
|
||||
// BaseAshSmokeParticle(dir, xa, scale, colorRandom, maxLifetime, gravity, hasPhysics).
|
||||
let (dir, scale, color_random, max_lifetime, gravity, physics, vel) = match ty {
|
||||
T::Smoke | T::WhiteSmoke => ([0.1, 0.1, 0.1], 1.0, 0.3, 8, -0.1, true, v),
|
||||
@@ -1578,6 +1658,22 @@ impl ParticleEngine {
|
||||
false,
|
||||
[v[0], v[1] + f64::from(0.15f32), v[2]],
|
||||
),
|
||||
// NoxiousGasParticle.Provider: scale 3.
|
||||
T::NoxiousGas => ([0.1, 0.1, 0.1], 3.0, 0.3, 5, -0.02, true, v),
|
||||
// GeyserBaseParticle: the burst impulse scales every axis.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
let (water_blocks, burst_impulse_base) = o.geyser?;
|
||||
let burst_impulse = burst_impulse_base + 0.25 * water_blocks as f32;
|
||||
(
|
||||
[f64::from(burst_impulse); 3],
|
||||
3.0 + 0.125 * water_blocks as f32,
|
||||
0.0,
|
||||
0,
|
||||
0.0,
|
||||
true,
|
||||
v,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// WhiteAshParticle.Provider draws the motion.
|
||||
let pr = &mut self.random;
|
||||
@@ -1593,6 +1689,18 @@ impl ParticleEngine {
|
||||
([0.1, -0.1, 0.1], 1.0, 0.0, 20, 0.0125, false, [xa, ya, za])
|
||||
}
|
||||
};
|
||||
// GeyserBaseParticle.Provider: a random spot half a block
|
||||
// around the point, a fifth of a block up (the provider's
|
||||
// random, as float arithmetic added to the double position).
|
||||
let p = if matches!(ty, T::GeyserBase | T::GeyserPoof) {
|
||||
let pr = &mut self.random;
|
||||
let x = p[0] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
let y = p[1] + f64::from((pr.next_float() - 0.5) * 0.5) + f64::from(0.2f32);
|
||||
let z = p[2] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
[x, y, z]
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let mut q = Particle::moving(r, ty, p, [0.0; 3]);
|
||||
q.friction = 0.96;
|
||||
q.gravity = gravity;
|
||||
@@ -1621,6 +1729,87 @@ impl ParticleEngine {
|
||||
);
|
||||
q.behaviour = Behaviour::DustPlume;
|
||||
}
|
||||
match ty {
|
||||
// NoxiousGasParticle: white, living `6 / (0.5 to 1) * 3`
|
||||
// ticks, fading out over its second half.
|
||||
T::NoxiousGas => {
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.lifetime = (6.0 / (f64::from(r.next_float()) * 0.5 + 0.5)
|
||||
* f64::from(3.0f32)) as i32;
|
||||
q.behaviour = Behaviour::NoxiousGas {
|
||||
fade_start: q.lifetime as f32 / 2.0,
|
||||
};
|
||||
}
|
||||
// GeyserBaseParticle: white, only ever rising, 20 to 25 ticks.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
q.friction = 0.725;
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.velocity[1] = q.velocity[1].abs();
|
||||
let lifetime_factor = 0.8 + 0.2 * self.level_random.next_float();
|
||||
q.lifetime = (25.0 * lifetime_factor) as i32;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
// NoxiousGasCloudParticle: lives 20 ticks, draws nothing itself.
|
||||
T::NoxiousGasCloud => {
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::NoxiousGasCloud;
|
||||
q
|
||||
}
|
||||
// GeyserEruptionParticle: lives 20 ticks, draws nothing itself.
|
||||
T::Geyser => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::GeyserEruption {
|
||||
water_blocks,
|
||||
aux: v,
|
||||
};
|
||||
q
|
||||
}
|
||||
// GeyserPlumeParticle, after its provider's random placement.
|
||||
T::GeyserPlume => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let pr = &mut self.random;
|
||||
let at = [
|
||||
p[0] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
p[1] + f64::from(pr.next_float()),
|
||||
p[2] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
];
|
||||
let plume_height = 5 * water_blocks.max(1);
|
||||
let mut q = Particle::moving(r, ty, at, v);
|
||||
q.has_physics = true;
|
||||
q.speed_up_when_y_blocked = true;
|
||||
q.lifetime = plume_height * 5;
|
||||
q.velocity[1] = 0.0;
|
||||
let level = &mut self.level_random;
|
||||
let spray = [
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
];
|
||||
q.friction = 1.0;
|
||||
let propulsion =
|
||||
(if water_blocks == 1 { 1.5 } else { 1.0 }) * plume_height as f32 * 1.45;
|
||||
q.gravity = -propulsion;
|
||||
let initial_size = q.size * 0.75;
|
||||
let min_size = initial_size * (2.0 + plume_height as f32 / 8.0);
|
||||
let max_size = initial_size * (3.0 + plume_height as f32 / 8.0);
|
||||
q.size = min_size;
|
||||
q.behaviour = Behaviour::GeyserPlume {
|
||||
start_y: at[1],
|
||||
max_y: at[1] + f64::from(plume_height) - 1.0,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done: false,
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
@@ -3198,6 +3387,98 @@ impl ParticleEngine {
|
||||
y_prev: p.position[1],
|
||||
};
|
||||
}
|
||||
Behaviour::NoxiousGas { fade_start } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
let source = ParticleLevel::block_at(p.position);
|
||||
let spawn = pick_random_particle_spawn_point(&mut self.level_random, source);
|
||||
if crate::potent_sulfur::can_be_reached_by_noxious_gas(level, source, spawn) {
|
||||
self.spawn_noxious_gas_particle(spawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserEruption { water_blocks, aux } => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
for _ in 0..2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserBase,
|
||||
water_blocks,
|
||||
1.5,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
for _ in 0..water_blocks + 2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser(ParticleType::GeyserPlume, water_blocks),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
if p.age % 10 == 0 {
|
||||
for _ in 0..20 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserPoof,
|
||||
water_blocks,
|
||||
2.0,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
mut done,
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
if !done
|
||||
&& (p.velocity[1] < 0.0
|
||||
|| p.position[1] > max_y
|
||||
|| p.position[1] == p.previous[1])
|
||||
{
|
||||
p.lifetime = p.lifetime.min(p.age + 5);
|
||||
p.friction = 0.0;
|
||||
done = true;
|
||||
}
|
||||
let y_progress_linear =
|
||||
((p.position[1] - start_y) / (max_y - start_y)).clamp(0.0, 1.0);
|
||||
let y_progress_exponential = horizon_core::jmath::pow(y_progress_linear, 3.0);
|
||||
p.gravity = propulsion * y_progress_exponential as f32 * 0.12;
|
||||
p.velocity[0] = y_progress_linear * f64::from(spray[0]);
|
||||
p.velocity[2] = y_progress_linear * f64::from(spray[1]);
|
||||
self.sprite_from_age(p);
|
||||
p.size = min_size + (y_progress_linear * f64::from(max_size - min_size)) as f32;
|
||||
p.behaviour = Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done,
|
||||
};
|
||||
}
|
||||
Behaviour::BubbleColumnUp => {
|
||||
p.base_tick(level);
|
||||
if !p.removed && level.fluid(ParticleLevel::block_at(p.position)).0 != Fluid::Water
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! `PotentSulfurBlockEntity`'s reads of the level: where the noxious gas of a
|
||||
//! potent sulfur block comes out, how far a geyser throws, and whether the gas
|
||||
//! reaches a point. The client's particles and the server's nausea and
|
||||
//! launches read the level the same way, through a [`ParticleLevel`].
|
||||
use crate::{blocks::Fluid, collisions::SceneCollisions, particles::ParticleLevel};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
};
|
||||
|
||||
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
|
||||
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
|
||||
|
||||
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
|
||||
/// with no collision shape.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
|
||||
return true;
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
match level.collision.collision_shape(&mut scene, state, pos) {
|
||||
Ok(shape) => shape.is_empty(),
|
||||
Err(_) => !level.facts.get(state).full_collision,
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.isWater`: the block at `pos` holds a water source
|
||||
/// (`FluidState.isSourceOfType`).
|
||||
fn is_water_source(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let (fluid, source) = level.fluid_source(pos);
|
||||
fluid == Fluid::Water && source
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock`: the first block above
|
||||
/// the sulfur, within the water column's reach, that is not water-filled and
|
||||
/// lets gas through; None when something solid or a deeper column stops it.
|
||||
pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) -> Option<[i32; 3]> {
|
||||
let max_y = origin[1] + ALLOWED_WATER_BLOCKS_ABOVE + 1;
|
||||
let mut pos = [origin[0], origin[1] + 1, origin[2]];
|
||||
while pos[1] <= max_y {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
|
||||
if !is_water_source(level, pos)
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|
||||
{
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
|
||||
return Some(pos);
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos[1] += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.getUnobstructedBlockCount`: how many blocks from
|
||||
/// `pos` up, at most six per water block, a geyser's push goes through.
|
||||
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
|
||||
let geyser_force_height = 6 * water_blocks;
|
||||
for i in 0..geyser_force_height {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
geyser_force_height
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.canBeReachedByNoxiousGas`: `pos` is in a block gas
|
||||
/// can enter, within three blocks of the source block's centre, and above
|
||||
/// water the source's water can be seen from.
|
||||
pub fn can_be_reached_by_noxious_gas(
|
||||
level: &ParticleLevel,
|
||||
source_block: [i32; 3],
|
||||
pos: [f64; 3],
|
||||
) -> bool {
|
||||
let block = ParticleLevel::block_at(pos);
|
||||
if !is_geyser_passable_block(level, block) {
|
||||
return false;
|
||||
}
|
||||
let center = source_block.map(|c| f64::from(c) + 0.5);
|
||||
let distance_sqr =
|
||||
(center[0] - pos[0]).powi(2) + (center[1] - pos[1]).powi(2) + (center[2] - pos[2]).powi(2);
|
||||
if distance_sqr > 9.0 {
|
||||
return false;
|
||||
}
|
||||
let below_source = [center[0], f64::from(source_block[1] - 1) + 0.5, center[2]];
|
||||
let below_pos = [pos[0], pos[1] - 1.0, pos[2]];
|
||||
is_water_source(level, ParticleLevel::block_at(below_pos))
|
||||
&& have_line_of_sight(level, below_source, below_pos)
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.haveLineOfSight`: a clip over collider shapes and
|
||||
/// no fluids (as the mobs' line of sight does) finds no block between the
|
||||
/// points.
|
||||
fn have_line_of_sight(level: &ParticleLevel, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let (from, to) = (Vec3::new(a[0], a[1], a[2]), Vec3::new(b[0], b[1], b[2]));
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = traverse_blocks(from, to, 512, |p: BlockPos| {
|
||||
let state = level.world.block(p.x, p.y, p.z);
|
||||
if level.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = level
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(from, to, p).map(|_| ())
|
||||
});
|
||||
!matches!(hit, Ok(Some(())))
|
||||
}
|
||||
@@ -150,6 +150,8 @@ pub struct Settings {
|
||||
pub chat_links: bool,
|
||||
pub chat_links_prompt: bool,
|
||||
pub chat_opacity: f32,
|
||||
/// Chat Delay in seconds (`chatDelay`): player chat shows this far apart.
|
||||
pub chat_delay: f64,
|
||||
pub text_background_opacity: f32,
|
||||
pub chat_scale: f32,
|
||||
pub chat_width: f32,
|
||||
@@ -246,6 +248,7 @@ impl Default for Settings {
|
||||
chat_links: true,
|
||||
chat_links_prompt: true,
|
||||
chat_opacity: 1.0,
|
||||
chat_delay: 0.0,
|
||||
text_background_opacity: 0.5,
|
||||
chat_scale: 1.0,
|
||||
chat_width: 1.0,
|
||||
@@ -448,6 +451,7 @@ impl Settings {
|
||||
chat_links: f.bool("chatLinks"),
|
||||
chat_links_prompt: f.bool("chatLinksPrompt"),
|
||||
chat_opacity: f.double("chatOpacity") as f32,
|
||||
chat_delay: f.double("chatDelay"),
|
||||
text_background_opacity: f.double("textBackgroundOpacity") as f32,
|
||||
chat_scale: f.double("chatScale") as f32,
|
||||
chat_width: f.double("chatWidth") as f32,
|
||||
@@ -551,6 +555,9 @@ impl Settings {
|
||||
put_b(&mut f, "chatLinks", self.chat_links);
|
||||
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
|
||||
put_f(&mut f, "chatOpacity", self.chat_opacity);
|
||||
if self.chat_delay != f.double("chatDelay") {
|
||||
f.set("chatDelay", V::Double(self.chat_delay));
|
||||
}
|
||||
put_f(
|
||||
&mut f,
|
||||
"textBackgroundOpacity",
|
||||
|
||||
@@ -31,9 +31,45 @@ impl Default for SignText {
|
||||
lines: Default::default(),
|
||||
color: BLACK,
|
||||
glowing: false,
|
||||
click: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The click events `SignBlockEntity.executeClickCommandsIfPresent` acts on.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ClickAction<'a> {
|
||||
/// `ClickEvent.RunCommand`.
|
||||
RunCommand(String),
|
||||
/// `ClickEvent.ShowDialog`: a registry id or the dialog itself.
|
||||
ShowDialog(&'a Tag),
|
||||
/// `ClickEvent.Custom`.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<&'a Tag>,
|
||||
},
|
||||
}
|
||||
/// A saved `click_event` as its action: the three a sign acts on, read the
|
||||
/// way `ClickEvent.CODEC` does; any other action or a malformed one is None.
|
||||
pub fn click_action(event: &Tag) -> Option<ClickAction<'_>> {
|
||||
let Some(Tag::String(action)) = event.get("action") else {
|
||||
return None;
|
||||
};
|
||||
match action.to_string_lossy().as_str() {
|
||||
"run_command" => match event.get("command") {
|
||||
Some(Tag::String(command)) => Some(ClickAction::RunCommand(command.to_string_lossy())),
|
||||
_ => None,
|
||||
},
|
||||
"show_dialog" => event.get("dialog").map(ClickAction::ShowDialog),
|
||||
"custom" => match event.get("id") {
|
||||
Some(Tag::String(id)) => Some(ClickAction::Custom {
|
||||
id: id.to_string_lossy(),
|
||||
payload: event.get("payload"),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl SignText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.iter().all(String::is_empty)
|
||||
@@ -42,6 +78,14 @@ impl SignText {
|
||||
pub fn text_color(&self) -> u32 {
|
||||
DYE_TEXT[usize::from(self.color.min(15))].1
|
||||
}
|
||||
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
|
||||
/// click event.
|
||||
pub fn has_any_click_commands(&self) -> bool {
|
||||
self.click
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|event| matches!(click_action(event), Some(ClickAction::RunCommand(_))))
|
||||
}
|
||||
/// `AbstractSignRenderer.getDarkColor`.
|
||||
pub fn dark_color(&self) -> u32 {
|
||||
let c = self.text_color();
|
||||
@@ -70,6 +114,13 @@ impl SignData {
|
||||
pub fn line_height(&self) -> f32 {
|
||||
if self.hanging { 9.0 } else { 10.0 }
|
||||
}
|
||||
/// `SignBlockEntity.canExecuteClickCommands`: a waxed sign whose side
|
||||
/// has a `run_command` click event. (Horizon keeps no filtered text, so
|
||||
/// `Player.isTextFilteringEnabled` never selects it.)
|
||||
pub fn can_execute_click_commands(&self, front: bool) -> bool {
|
||||
let side = if front { &self.front } else { &self.back };
|
||||
self.waxed && side.has_any_click_commands()
|
||||
}
|
||||
/// NBT entries after the common block-entity header.
|
||||
pub fn encode(&self) -> Vec<(&'static str, Tag)> {
|
||||
let side = |t: &SignText| {
|
||||
@@ -79,7 +130,18 @@ impl SignData {
|
||||
Tag::List(
|
||||
t.lines
|
||||
.iter()
|
||||
.map(|l| Tag::String(l.as_str().into()))
|
||||
.zip(&t.click)
|
||||
.map(|(line, click)| {
|
||||
let text = Tag::String(line.as_str().into());
|
||||
match click {
|
||||
// The line's style keeps its click event.
|
||||
Some(event) => Tag::compound([
|
||||
("text", text),
|
||||
("click_event", event.clone()),
|
||||
]),
|
||||
None => text,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
@@ -90,19 +152,25 @@ impl SignData {
|
||||
("has_glowing_text", Tag::Byte(i8::from(t.glowing))),
|
||||
])
|
||||
};
|
||||
vec![
|
||||
let mut out = Vec::new();
|
||||
if self.allow_op_features {
|
||||
out.push(("allow_op_features", Tag::Byte(1)));
|
||||
}
|
||||
out.extend([
|
||||
("front_text", side(&self.front)),
|
||||
("back_text", side(&self.back)),
|
||||
("is_waxed", Tag::Byte(i8::from(self.waxed))),
|
||||
]
|
||||
]);
|
||||
out
|
||||
}
|
||||
pub fn decode(&mut self, tag: &Tag) {
|
||||
let side = |t: Option<&Tag>| -> SignText {
|
||||
let mut out = SignText::default();
|
||||
let Some(t) = t else { return out };
|
||||
if let Some(Tag::List(messages)) = t.get("messages") {
|
||||
for (line, m) in out.lines.iter_mut().zip(messages) {
|
||||
for ((line, click), m) in out.lines.iter_mut().zip(&mut out.click).zip(messages) {
|
||||
*line = component_text(m);
|
||||
*click = click_event(m);
|
||||
}
|
||||
}
|
||||
if let Some(Tag::String(c)) = t.get("color") {
|
||||
@@ -117,6 +185,21 @@ impl SignData {
|
||||
self.front = side(tag.get("front_text"));
|
||||
self.back = side(tag.get("back_text"));
|
||||
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
|
||||
self.allow_op_features =
|
||||
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
|
||||
}
|
||||
}
|
||||
/// The `click_event` in the style of a stored line: the one of a component
|
||||
/// object, or of the first component of a list (`createFromList` copies it
|
||||
/// and appends the rest as siblings).
|
||||
fn click_event(tag: &Tag) -> Option<Tag> {
|
||||
let root = match tag {
|
||||
Tag::List(list) => list.first()?,
|
||||
other => other,
|
||||
};
|
||||
match root.get("click_event") {
|
||||
Some(event @ Tag::Compound(_)) => Some(event.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
|
||||
|
||||
@@ -159,6 +159,14 @@ impl Sprites {
|
||||
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
|
||||
/// textures are scaled up by whole pixels, so UVs stay per layer.
|
||||
pub fn finish(&mut self) {
|
||||
self.lay_out(true);
|
||||
}
|
||||
/// [`Sprites::finish`] for a server, which draws nothing: the layers'
|
||||
/// size, but neither their pixels nor the animations' frames.
|
||||
pub fn finish_without_pixels(&mut self) {
|
||||
self.lay_out(false);
|
||||
}
|
||||
fn lay_out(&mut self, pixels: bool) {
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
@@ -166,6 +174,12 @@ impl Sprites {
|
||||
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
|
||||
size /= 2;
|
||||
}
|
||||
if !pixels {
|
||||
self.pixels = Vec::new();
|
||||
self.animations = Vec::new();
|
||||
self.size = size;
|
||||
return;
|
||||
}
|
||||
let mut resized = Vec::with_capacity(self.native.len());
|
||||
self.pixels.clear();
|
||||
for (from, frames, initial) in &self.native {
|
||||
|
||||
@@ -1078,6 +1078,21 @@ impl EditBox {
|
||||
self.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// `MultilineTextField.selectWordAtCursor` on a book page: the word before
|
||||
/// the cursor is selected, its end found past non-whitespace only
|
||||
/// (`getWordEndPosition`).
|
||||
pub fn select_word_at_cursor(&mut self) {
|
||||
let begin = multiline_word_position(&self.value, -1, self.cursor);
|
||||
let end = begin
|
||||
+ self
|
||||
.value
|
||||
.chars()
|
||||
.skip(begin)
|
||||
.take_while(|c| !java_whitespace(*c))
|
||||
.count();
|
||||
self.set_cursor(begin, false);
|
||||
self.set_cursor(end, true);
|
||||
}
|
||||
/// EditBox.onDrag: the selection follows the pointer.
|
||||
pub fn drag_to(&mut self, atlas: &HudAtlas, x: f32) {
|
||||
let pos = self.position_at(atlas, x);
|
||||
|
||||
@@ -15,6 +15,9 @@ struct World {
|
||||
passing: bool,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
// The fixture's targets don't change within a tick.
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
let vanilla = match name {
|
||||
"Float" => "FloatGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"ParrotWander" => "ParrotWanderGoal",
|
||||
"RandomStroll" => "RandomStrollGoal",
|
||||
// AbstractFish's private subclass of RandomSwimmingGoal.
|
||||
"RandomSwimming"
|
||||
|
||||
@@ -76,6 +76,14 @@ fn options(case: &Value) -> Option<ParticleOptions> {
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
|
||||
ParticleType::Geyser | ParticleType::GeyserPlume => {
|
||||
ParticleOptions::geyser(ty, o["water_blocks"].as_i64()? as i32)
|
||||
}
|
||||
ParticleType::GeyserBase | ParticleType::GeyserPoof => ParticleOptions::geyser_base(
|
||||
ty,
|
||||
o["water_blocks"].as_i64()? as i32,
|
||||
o["burst_impulse_base"].as_f64()? as f32,
|
||||
),
|
||||
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
|
||||
ParticleType::Trail => {
|
||||
let t = &o["target"];
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn main() -> Result<()> {
|
||||
aquatic::squid_cycle_event(&mut mob);
|
||||
}
|
||||
let velocity = mob.velocity;
|
||||
aquatic::squid_client_step(&mut mob);
|
||||
aquatic::squid_client_step(&mut mob, &mut Vec::new());
|
||||
assert_eq!(
|
||||
velocity, mob.velocity,
|
||||
"a remote animation must preserve packet velocity"
|
||||
|
||||
@@ -20,6 +20,9 @@ pub struct Tab {
|
||||
pub column: u32,
|
||||
pub search: bool,
|
||||
pub inventory: bool,
|
||||
/// `CreativeModeTab.Type.HOTBAR`: the saved toolbars, filled when the
|
||||
/// tab is selected (`set_saved_hotbars`).
|
||||
pub hotbar: bool,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
@@ -229,7 +232,7 @@ impl Creative {
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if kind == "HOTBAR" || t["visible"] == false {
|
||||
if t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
@@ -244,6 +247,7 @@ impl Creative {
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
@@ -277,6 +281,7 @@ impl Creative {
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
});
|
||||
}
|
||||
let search_index = search_items
|
||||
@@ -333,6 +338,7 @@ impl Creative {
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
hotbar: t.hotbar,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -464,6 +470,23 @@ impl Creative {
|
||||
});
|
||||
items
|
||||
}
|
||||
/// The stack in grid cell `index` (row-major from the tab's top), None
|
||||
/// for an empty cell: a saved toolbar's empty slot, or past the end.
|
||||
pub fn shown(&self, names: &Names, index: usize) -> Option<ItemStack> {
|
||||
self.items(names)
|
||||
.get(index)
|
||||
.filter(|s| s.count > 0)
|
||||
.cloned()
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.selectTab` for the HOTBAR tab: the nine
|
||||
/// saved rows of `HotbarManager` as the tab's items, an empty stack (count
|
||||
/// 0) for an empty slot.
|
||||
pub fn set_saved_hotbars(&mut self, items: Vec<ItemStack>) {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.hotbar) {
|
||||
tab.items = items;
|
||||
}
|
||||
self.forget_items();
|
||||
}
|
||||
/// Drop the shown items, so the next `items` works them out again: a
|
||||
/// reload can rename items without changing the Search label.
|
||||
pub fn forget_items(&self) {
|
||||
@@ -664,6 +687,13 @@ pub enum DisplayClick {
|
||||
Drop(ItemStack),
|
||||
}
|
||||
|
||||
/// Whether a stack has `minecraft:creative_slot_lock`.
|
||||
pub fn slot_locked(stack: &ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.is_some_and(|d| d.other("minecraft:creative_slot_lock").is_some())
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
|
||||
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
|
||||
/// the tab's end) and `max` gives an item's max stack size. The tab's items
|
||||
@@ -676,6 +706,11 @@ pub fn click_display(
|
||||
input: crate::menu::Input,
|
||||
) -> DisplayClick {
|
||||
use crate::menu::Input;
|
||||
// CustomCreativeSlot.mayPickup: a `creative_slot_lock` stack (the saved
|
||||
// toolbars' hint) can't be clicked at all.
|
||||
if shown.is_some_and(slot_locked) {
|
||||
return DisplayClick::Nothing;
|
||||
}
|
||||
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
|
||||
let quick_key = input == Input::QuickMove;
|
||||
match input {
|
||||
@@ -738,6 +773,7 @@ pub fn click_display(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
@@ -1047,6 +1083,53 @@ mod tests {
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
|
||||
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
|
||||
{"id": "minecraft:hotbar", "type": "HOTBAR", "row": "TOP", "column": 5, "title": "Saved Hotbars",
|
||||
"icon": "{count:1,id:\"minecraft:bookshelf\"}", "visible": true, "items": []},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
|
||||
]}))
|
||||
.unwrap();
|
||||
assert!(c.tab().hotbar);
|
||||
assert!(!c.tab_headers()[1].hotbar);
|
||||
let mut hint = stack("paper", 1);
|
||||
hint.data_mut().set_other(
|
||||
"minecraft:creative_slot_lock",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
// A saved sword in slot 0, an empty slot, then the hint.
|
||||
c.set_saved_hotbars(vec![stack("diamond_sword", 1), stack("air", 0), hint]);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
assert_eq!(c.shown(&names, 0), Some(stack("diamond_sword", 1)));
|
||||
assert_eq!(c.shown(&names, 1), None);
|
||||
assert_eq!(c.shown(&names, 3), None);
|
||||
let hint = c.shown(&names, 2).unwrap();
|
||||
assert!(slot_locked(&hint));
|
||||
assert!(!slot_locked(&stack("diamond_sword", 1)));
|
||||
// CustomCreativeSlot.mayPickup: the hint is never taken, and a carried
|
||||
// stack stays as it is.
|
||||
use crate::menu::Input::*;
|
||||
for (carried, button, input) in [
|
||||
(None, 0, Pickup),
|
||||
(None, 0, QuickMove),
|
||||
(None, 2, Clone),
|
||||
(None, 3, Swap),
|
||||
(None, 0, Throw),
|
||||
(Some(stack("dirt", 2)), 0, Pickup),
|
||||
(Some(stack("dirt", 2)), 1, Pickup),
|
||||
] {
|
||||
let mut carried = carried;
|
||||
let before = carried.clone();
|
||||
let effect = click_display(&mut carried, Some(&hint), |_| 64, button, input);
|
||||
assert_eq!(effect, DisplayClick::Nothing);
|
||||
assert_eq!(carried, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
@@ -1191,6 +1274,7 @@ mod tests {
|
||||
column,
|
||||
search: false,
|
||||
inventory: false,
|
||||
hotbar: false,
|
||||
};
|
||||
// Top row, second column: the button spans x 27..53, y -28..4.
|
||||
let t = tab(true, 1);
|
||||
|
||||
@@ -179,12 +179,17 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
// both on the left. The label is a plain StringWidget: it has no
|
||||
// click handler, so a click event in it does nothing.
|
||||
let mut shown = label.clone();
|
||||
for span in &mut shown.spans {
|
||||
span.style.click = None;
|
||||
}
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
parts: vec![
|
||||
Part::Line(label.clone(), 0.0, 0.0),
|
||||
Part::Line(shown, 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
|
||||
@@ -155,6 +155,123 @@ fn player_uuid() -> [i32; 4] {
|
||||
u as u32 as i32,
|
||||
]
|
||||
}
|
||||
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
|
||||
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
|
||||
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", string(name)));
|
||||
}
|
||||
if let Some(id) = p.uuid {
|
||||
entries.push((
|
||||
"id",
|
||||
Tag::IntArray(vec![
|
||||
(id >> 96) as i32,
|
||||
(id >> 64) as i32,
|
||||
(id >> 32) as i32,
|
||||
id as i32,
|
||||
]),
|
||||
));
|
||||
}
|
||||
if !p.properties.is_empty() {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|property| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", string(&property.name)),
|
||||
("value", string(&property.value)),
|
||||
];
|
||||
if let Some(signature) = &property.signature {
|
||||
entries.push(("signature", string(signature)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect();
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
}
|
||||
for (key, texture) in [
|
||||
("texture", &p.body),
|
||||
("cape", &p.cape),
|
||||
("elytra", &p.elytra),
|
||||
] {
|
||||
if let Some(texture) = texture {
|
||||
entries.push((key, string(texture)));
|
||||
}
|
||||
}
|
||||
if let Some(slim) = p.slim {
|
||||
entries.push(("model", string(if slim { "slim" } else { "wide" })));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
/// `ResolvableProfile.CODEC` read back: the compound `profile_nbt` writes, or a
|
||||
/// bare player name (`Codec.withAlternative(FULL_CODEC, PLAYER_NAME, ...)`).
|
||||
fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile> {
|
||||
if let Tag::String(name) = tag {
|
||||
return Some(horizon_protocol::entity_data::Profile {
|
||||
name: Some(name.to_string_lossy()),
|
||||
uuid: None,
|
||||
properties: Vec::new(),
|
||||
body: None,
|
||||
cape: None,
|
||||
elytra: None,
|
||||
slim: None,
|
||||
});
|
||||
}
|
||||
let property = |name: String, value: &Tag, signature: Option<String>| match value {
|
||||
Tag::String(v) => Some(horizon_protocol::packets::login::Property {
|
||||
name,
|
||||
value: v.to_string_lossy(),
|
||||
signature,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let properties: Vec<horizon_protocol::packets::login::Property> = match tag.get("properties") {
|
||||
// `ExtraCodecs.PROPERTY_MAP`: a list of properties ...
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let name = match p.get("name")? {
|
||||
Tag::String(s) => s.to_string_lossy(),
|
||||
_ => return None,
|
||||
};
|
||||
let signature = match p.get("signature") {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
property(name, p.get("value")?, signature)
|
||||
})
|
||||
.collect(),
|
||||
// ... or a map from a name to its values.
|
||||
Some(Tag::Compound(map)) => map
|
||||
.iter()
|
||||
.flat_map(|(name, values)| match values {
|
||||
Tag::List(values) => values
|
||||
.iter()
|
||||
.filter_map(|v| property(name.to_string_lossy(), v, None))
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let text = |key: &str| match tag.get(key) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
Some(horizon_protocol::entity_data::Profile {
|
||||
name: text("name"),
|
||||
uuid: uuid_from_tag(tag.get("id")).map(|a| {
|
||||
a.iter()
|
||||
.fold(0u128, |u, &x| (u << 32) | u128::from(x as u32))
|
||||
}),
|
||||
properties,
|
||||
body: text("texture"),
|
||||
cape: text("cape"),
|
||||
elytra: text("elytra"),
|
||||
slim: text("model").map(|model| model == "slim"),
|
||||
})
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -201,6 +318,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
|
||||
}
|
||||
}
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
|
||||
if m.species() == crate::mobs::Species::Mannequin
|
||||
&& let Some(profile) = &m.profile
|
||||
{
|
||||
put(&mut tag, "profile", profile_nbt(profile));
|
||||
}
|
||||
// `Entity.saveWithoutId` writes the flag only when set.
|
||||
if m.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
@@ -337,6 +460,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
|
||||
// effects are not modelled).
|
||||
"minecraft:mooshroom" => {
|
||||
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
@@ -607,11 +736,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
}
|
||||
"minecraft:camel" | "minecraft:camel_husk" => {
|
||||
// LastPoseTick: negative while sitting.
|
||||
let sitting = crate::mobs::camel::sitting(m);
|
||||
// LastPoseTick: the pose change time, negative while sitting. The
|
||||
// game time is not known here; the world's save overwrites it
|
||||
// with the real one (`camel::synced_pose_tick`).
|
||||
map.insert(
|
||||
"LastPoseTick".into(),
|
||||
Tag::Long(if sitting { -1 } else { 0 }),
|
||||
Tag::Long(crate::mobs::camel::synced_pose_tick(m, 0)),
|
||||
);
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
map.insert(
|
||||
@@ -669,6 +799,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
map.insert("Color".into(), Tag::Byte(m.extra.color as i8));
|
||||
}
|
||||
// GlowSquid.addAdditionalSaveData.
|
||||
"minecraft:glow_squid" => {
|
||||
map.insert(
|
||||
"DarkTicksRemaining".into(),
|
||||
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
|
||||
);
|
||||
}
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
|
||||
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
|
||||
@@ -1103,6 +1240,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
}
|
||||
}
|
||||
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
|
||||
if m.species() == crate::mobs::Species::Mannequin {
|
||||
m.profile = tag.get("profile").and_then(profile_from_nbt);
|
||||
}
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
@@ -1248,6 +1389,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
}
|
||||
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
|
||||
// Variant.DEFAULT, red.
|
||||
crate::mobs::Species::Mooshroom => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
|
||||
@@ -1332,11 +1478,15 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
|
||||
// The tick is a game time: `camel::finish_load` puts it on the mob's
|
||||
// clock once the world's time is known.
|
||||
let tick = match tag.get("LastPoseTick") {
|
||||
Some(Tag::Long(v)) => *v,
|
||||
_ => 0,
|
||||
};
|
||||
crate::mobs::camel::stand_up_instantly(&mut m);
|
||||
if sitting {
|
||||
m.extra.egg_time = -1 - 400;
|
||||
}
|
||||
m.extra.pose_change_tick = tick;
|
||||
m.extra.camel_sitting = tick < 0;
|
||||
// Camels are always tame (`Camel.register` in Horizon).
|
||||
m.extra.tame = true;
|
||||
}
|
||||
@@ -1423,6 +1573,13 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
|
||||
if m.species() == crate::mobs::Species::GlowSquid {
|
||||
crate::mobs::aquatic::set_dark_ticks(
|
||||
&mut m,
|
||||
number(tag.get("DarkTicksRemaining")).unwrap_or(0) as i32,
|
||||
);
|
||||
}
|
||||
// IronGolem.readAdditionalSaveData: not player created when absent.
|
||||
if m.species() == crate::mobs::Species::IronGolem {
|
||||
m.extra.player_created = flag(tag, "PlayerCreated");
|
||||
@@ -3107,7 +3264,7 @@ mod sheep_nbt_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -3131,6 +3288,17 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
// MushroomCow: a brown mooshroom stays brown, and a save without (or
|
||||
// with an unknown) Type reads as red.
|
||||
let mut mooshroom = new("minecraft:mooshroom");
|
||||
mooshroom.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("brown"));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
mooshroom.extra.variant = 0;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
|
||||
assert_eq!(back.extra.variant, 0);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! FallingBlockEntity's impact damage, anvil wear, saved impact settings and
|
||||
//! the cobwebs that hold it back.
|
||||
//! the blocks it moves through (cobwebs, bubble columns).
|
||||
use crate::motion::BubbleColumn;
|
||||
use crate::{
|
||||
mobs::{Damage as MobDamage, DamageKind, Mob},
|
||||
save::{float, number, put},
|
||||
@@ -118,30 +119,76 @@ pub fn bounds(position: Vec3) -> Aabb {
|
||||
pub fn web_multiplier() -> Vec3 {
|
||||
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
|
||||
}
|
||||
/// What a block a falling block moves through does to it in `entityInside`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Inside {
|
||||
/// `WebBlock`: `Entity.makeStuckInBlock`.
|
||||
Cobweb,
|
||||
/// `BubbleColumnBlock`: lifts or drags it.
|
||||
BubbleColumn(BubbleColumn),
|
||||
Nothing,
|
||||
}
|
||||
/// `Entity.applyEffectsFromBlocks` after a falling block's move this tick
|
||||
/// (`from` to `to`; `original` the move it asked for, when the move was
|
||||
/// recorded): whether `checkInsideBlocks` reaches a cobweb (`is_web`), whose
|
||||
/// `entityInside` makes it stuck. Each movement sweeps its axes in
|
||||
/// `Direction.axisStepOrder` within 16 iterations, then the end alone.
|
||||
pub fn passes_cobweb(
|
||||
/// recorded; `velocity` already zeroed on the axes `Entity.move` collided on):
|
||||
/// the blocks it went through (`block` says what each is) push it and reset
|
||||
/// its fall. Returns whether a cobweb made it stuck.
|
||||
pub fn apply_effects_from_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut is_web: impl FnMut(BlockPos) -> bool,
|
||||
velocity: &mut Vec3,
|
||||
fall_distance: &mut f64,
|
||||
mut block: impl FnMut(BlockPos) -> Inside,
|
||||
) -> bool {
|
||||
let mut stuck = false;
|
||||
inside_blocks(from, to, original, |p, intersects| match block(p) {
|
||||
Inside::Cobweb => {
|
||||
// makeStuckInBlock forgets the fall.
|
||||
*fall_distance = 0.0;
|
||||
stuck = true;
|
||||
}
|
||||
// BubbleColumnBlock.entityInside acts only when intersecting.
|
||||
Inside::BubbleColumn(column) if intersects => {
|
||||
if column.push(velocity) {
|
||||
*fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
Inside::BubbleColumn(_) | Inside::Nothing => {}
|
||||
});
|
||||
stuck
|
||||
}
|
||||
/// Each block `checkInsideBlocks` reaches, once, in order, with
|
||||
/// whether its `entityInside` counts the block as intersected (every block of a
|
||||
/// movement a block or longer, else only those the box ends in). Each movement
|
||||
/// sweeps its axes in `Direction.axisStepOrder` within 16 iterations, then the
|
||||
/// end alone. The blocks the caller acts on (cobwebs, bubble columns) have a
|
||||
/// whole-block `getEntityInsideCollisionShape`, so every one visited counts.
|
||||
fn inside_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut visit: impl FnMut(BlockPos, bool),
|
||||
) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// checkInsideBlocks(from, to, ..): the iterations used plus one.
|
||||
let mut check = |from: Vec3, to: Vec3, max: i32| {
|
||||
let d = f64::from(1.0e-5_f32);
|
||||
let target = bounds(to).deflate(Vec3::new(d, d, d));
|
||||
// The move spans a block or more: Mth.square(1.0 - 1.0E-5F).
|
||||
let long = from.distance_squared(to) > (1.0 - d) * (1.0 - d);
|
||||
let mut iterations = 0;
|
||||
let _ = sweep_intersected_blocks(from, to, target, 1_000_000, |p, iteration| {
|
||||
if iteration >= max {
|
||||
return false;
|
||||
}
|
||||
iterations = iteration;
|
||||
// getEntityInsideCollisionShape is the whole block for cobwebs.
|
||||
stuck |= is_web(p);
|
||||
if seen.insert(p) {
|
||||
let corner = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let inside =
|
||||
target.intersects_corners(corner, corner.add_vector(Vec3::new(1.0, 1.0, 1.0)));
|
||||
visit(p, long || inside);
|
||||
}
|
||||
true
|
||||
});
|
||||
iterations + 1
|
||||
@@ -170,7 +217,6 @@ pub fn passes_cobweb(
|
||||
if budget <= 0 {
|
||||
check(to, to, 1);
|
||||
}
|
||||
stuck
|
||||
}
|
||||
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
|
||||
@@ -249,6 +295,23 @@ mod tests {
|
||||
assert!(!wear_anvil(&mut state));
|
||||
}
|
||||
}
|
||||
fn passes_cobweb(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
is_web: impl Fn(BlockPos) -> bool,
|
||||
) -> bool {
|
||||
let (mut velocity, mut fall) = (Vec3::ZERO, 3.0);
|
||||
let stuck = apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, |p| {
|
||||
if is_web(p) {
|
||||
Inside::Cobweb
|
||||
} else {
|
||||
Inside::Nothing
|
||||
}
|
||||
});
|
||||
assert_eq!(fall == 0.0, stuck);
|
||||
stuck
|
||||
}
|
||||
#[test]
|
||||
fn a_cobweb_on_the_path_or_at_the_end_holds_a_falling_block() {
|
||||
let web = BlockPos::new(0, 5, 0);
|
||||
@@ -285,6 +348,50 @@ mod tests {
|
||||
assert!(!passes_cobweb(beside, beside, None, is_web));
|
||||
}
|
||||
#[test]
|
||||
fn bubble_columns_lift_or_drag_a_falling_block() {
|
||||
// Soul sand under x 0 lifts, magma under x 3 drags, both up to y 9.
|
||||
let block = |p: BlockPos| match (p.x, p.y, p.z) {
|
||||
(0 | 3, 0..=9, 0) => Inside::BubbleColumn(BubbleColumn {
|
||||
drag: p.x == 3,
|
||||
surface: p.y == 9,
|
||||
}),
|
||||
_ => Inside::Nothing,
|
||||
};
|
||||
let step = |x: f64, from: f64, to: f64, vy: f64| {
|
||||
let (from, to) = (Vec3::new(x, from, 0.5), Vec3::new(x, to, 0.5));
|
||||
let mut velocity = Vec3::new(0.0, vy, 0.0);
|
||||
let mut fall = 2.0;
|
||||
let original = Some(to.subtract(from));
|
||||
let stuck =
|
||||
apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, block);
|
||||
assert!(!stuck);
|
||||
(velocity.y, fall)
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1.0e-9;
|
||||
// Released in the column: its box ends in blocks 5 and 6, each lifts
|
||||
// it by 0.06 (handleOnInsideBubbleColumn), and its fall is forgotten.
|
||||
let (vy, fall) = step(0.5, 5.5, 5.46, -0.04);
|
||||
assert!(near(vy, 0.08), "{vy}");
|
||||
assert_eq!(fall, 0.0);
|
||||
// The lift is capped at 0.7.
|
||||
assert!(near(step(0.5, 5.0, 5.3, 0.68).0, 0.7));
|
||||
// Falling a block or more into the surface block: every column
|
||||
// block on the way counts, the surface throws it up by 0.1
|
||||
// (handleOnAboveBubbleColumn), which keeps its fall.
|
||||
let (vy, fall) = step(0.5, 11.0, 9.5, -1.5);
|
||||
assert!(near(vy, -1.5 + 0.1), "{vy}");
|
||||
assert_eq!(fall, 2.0);
|
||||
// Magma drags down by 0.03, at most to -0.3 inside and -0.9 at the surface.
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.1).0, -0.16));
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.29).0, -0.3));
|
||||
assert!(near(step(3.5, 9.8, 9.6, -0.89).0, -0.9));
|
||||
// A short move that leaves the column behind: the block it went
|
||||
// through no longer intersects its box, so nothing pushes it.
|
||||
assert_eq!(step(0.5, 9.95, 10.5, 0.55), (0.55, 2.0));
|
||||
// Beside the column.
|
||||
assert_eq!(step(1.5, 5.5, 5.46, -0.04), (-0.04, 2.0));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
|
||||
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
|
||||
use crate::mobs::{Mob, MobTypes};
|
||||
|
||||
@@ -44,6 +44,7 @@ mod creatures;
|
||||
mod crossbow;
|
||||
pub(crate) mod cushions;
|
||||
mod debug_keys;
|
||||
mod debug_stick;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
@@ -141,6 +142,7 @@ mod piglins;
|
||||
mod piston_push;
|
||||
mod player_equip;
|
||||
mod postprocess;
|
||||
mod potent_sulfur;
|
||||
mod potions;
|
||||
mod powder_snow;
|
||||
mod pumpkin;
|
||||
@@ -177,6 +179,7 @@ mod spawner;
|
||||
mod spear;
|
||||
mod spectator;
|
||||
mod spill;
|
||||
mod splash_water;
|
||||
mod startup_menu;
|
||||
mod station_control;
|
||||
mod stations;
|
||||
@@ -555,6 +558,7 @@ struct Mining {
|
||||
progress: f32,
|
||||
ticks: u32,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Hit {
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
@@ -1154,6 +1158,14 @@ impl LevelAccess for GameLevel<'_> {
|
||||
fn set_command_block_powered(&mut self, pos: BlockPos, powered: bool) -> Option<(bool, bool)> {
|
||||
command_blocks::set_powered(self.world, self.registry, (pos.x, pos.y, pos.z), powered)
|
||||
}
|
||||
/// `PotentSulfurBlockEntity.resetCountdown`.
|
||||
fn reset_potent_sulfur_countdown(&mut self, pos: BlockPos) {
|
||||
if let Some(BlockEntity::PotentSulfur(sulfur)) =
|
||||
self.world.block_entities.get_mut(&(pos.x, pos.y, pos.z))
|
||||
{
|
||||
sulfur.waiting_countdown = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// FallingBlockEntity carrying a block state.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -1325,6 +1337,17 @@ impl UiResources {
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
|
||||
})
|
||||
}
|
||||
/// What a server's game holds, since it draws nothing: the font, which books
|
||||
/// and chat measure with, and a one-pixel panorama.
|
||||
pub fn server(root: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::fonts(root)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
/// What a resource pack reload replaces (`Minecraft.reloadResourcePacks`):
|
||||
/// block models and sprites with their meshing tables, item models,
|
||||
@@ -1630,6 +1653,8 @@ struct Game<'a> {
|
||||
sign_edit: Option<sign_edit::SignEditor>,
|
||||
/// An open book, book and quill or lectern screen.
|
||||
book: Option<books::BookScreen>,
|
||||
/// The last click on the book screen, for double clicks.
|
||||
book_last_click: Option<std::time::Instant>,
|
||||
/// Lecterns whose page-turn pulse is running (ticks left).
|
||||
lectern_pulses: Vec<((i32, i32, i32), i32)>,
|
||||
/// LevelLoadingScreen.smoothedProgress while the player is not placed yet.
|
||||
@@ -1951,6 +1976,10 @@ struct Game<'a> {
|
||||
world_icon: Option<WorldIcon>,
|
||||
/// The block a guest is acting on (game/lan_actions.rs).
|
||||
forced_hit: Option<Hit>,
|
||||
/// `Minecraft.hitResult` while the clicks of a tick act (game/remote.rs
|
||||
/// `remote_handle_input`): picked once, so a creative break does not
|
||||
/// move the use that follows it past the broken block.
|
||||
tick_hit: Option<Option<Hit>>,
|
||||
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
|
||||
guest_use: Option<GuestUse>,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
@@ -1983,6 +2012,10 @@ struct Game<'a> {
|
||||
/// have each chest open.
|
||||
lid_openers: HashMap<(i32, i32, i32), i32>,
|
||||
lan_acting: Option<u32>,
|
||||
/// The block the acting player is breaking (`break_block`): its destroy
|
||||
/// event, and the ones before it, are the player's own (`Level.levelEvent`
|
||||
/// with the player excluded); the events after it are not.
|
||||
lan_break_pos: Option<[i32; 3]>,
|
||||
/// The name of the guest `lan_acting` acts for (`Player.getName`).
|
||||
lan_acting_name: Option<String>,
|
||||
/// Their UUID (`Player.getUUID`).
|
||||
@@ -2218,12 +2251,11 @@ impl Game<'_> {
|
||||
/// each block's outline shape (`BlockGetter.clip` with `ClipContext.Block.OUTLINE`).
|
||||
fn pick(&self) -> Option<Hit> {
|
||||
// A guest of a hosted world acts on the block their client targeted.
|
||||
if let Some(hit) = &self.forced_hit {
|
||||
return Some(Hit {
|
||||
pos: hit.pos,
|
||||
face: hit.face,
|
||||
point: hit.point,
|
||||
});
|
||||
if let Some(hit) = self.forced_hit {
|
||||
return Some(hit);
|
||||
}
|
||||
if let Some(hit) = self.tick_hit {
|
||||
return hit;
|
||||
}
|
||||
let (origin, dir) = self.aim()?;
|
||||
self.clip_ray(origin, dir, self.reach())
|
||||
@@ -2903,9 +2935,13 @@ impl Game<'_> {
|
||||
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
|
||||
let before = item.stack.count;
|
||||
let kind = item.stack.item.clone();
|
||||
let thrower = item.thrower;
|
||||
match self.give(item.stack.clone()) {
|
||||
None => {
|
||||
self.stats.add("picked_up", &kind, before as i32);
|
||||
if let Some(thrower) = thrower {
|
||||
self.trigger_thrown_picked_up_by_player(&item.stack, thrower);
|
||||
}
|
||||
self.add_pickup(
|
||||
pickups::Taken::Item(Box::new(item)),
|
||||
pickups::Collector::Player,
|
||||
@@ -2923,6 +2959,9 @@ impl Game<'_> {
|
||||
if rest.count < before {
|
||||
self.stats
|
||||
.add("picked_up", &kind, (before - rest.count) as i32);
|
||||
if let Some(thrower) = thrower {
|
||||
self.trigger_thrown_picked_up_by_player(&rest, thrower);
|
||||
}
|
||||
self.add_pickup(
|
||||
pickups::Taken::Item(Box::new(item.clone())),
|
||||
pickups::Collector::Player,
|
||||
@@ -3869,6 +3908,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if horizon_client::command_block::Mode::of_block(&name).is_some() {
|
||||
// `ServerPlayer.openCommandBlock` sends the block entity's data
|
||||
// (`ClientboundBlockEntityDataPacket`) so the opening client shows
|
||||
// the stored command, not its stale copy.
|
||||
self.mark_block_entity_updated(key);
|
||||
self.open_command_block(key);
|
||||
return;
|
||||
}
|
||||
@@ -4054,22 +4097,7 @@ impl Game<'_> {
|
||||
];
|
||||
return Ok(FallStep::Landed(pos));
|
||||
}
|
||||
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
|
||||
// (Entity.makeStuckInBlock, which forgets the fall). Entity.move
|
||||
// records the move unless it was blocked short of a tiny one.
|
||||
let moved = movement.length_squared();
|
||||
let recorded = moved > 1.0e-7 || wanted.length_squared() - moved < 1.0e-7;
|
||||
let world = &self.world;
|
||||
let facts = self.res.facts();
|
||||
if horizon_client::falling_block::passes_cobweb(
|
||||
from,
|
||||
f.position,
|
||||
recorded.then_some(wanted),
|
||||
|p| &*facts.get(world.block(p.x, p.y, p.z)).name == "minecraft:cobweb",
|
||||
) {
|
||||
f.fall_distance = 0.0;
|
||||
f.stuck = horizon_client::falling_block::web_multiplier();
|
||||
}
|
||||
// Entity.move stops it on the axes it collided on.
|
||||
if movement.x != wanted.x {
|
||||
f.velocity.x = 0.0;
|
||||
}
|
||||
@@ -4079,6 +4107,37 @@ impl Game<'_> {
|
||||
if movement.z != wanted.z {
|
||||
f.velocity.z = 0.0;
|
||||
}
|
||||
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
|
||||
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
|
||||
// drags it. Entity.move records the move unless it was blocked short
|
||||
// of a tiny one.
|
||||
let moved = movement.length_squared();
|
||||
let recorded = moved > 1.0e-7 || wanted.length_squared() - moved < 1.0e-7;
|
||||
let world = &self.world;
|
||||
let facts = self.res.facts();
|
||||
let (registry, collision) = (self.pack.assets.registry, self.collision);
|
||||
let stuck = horizon_client::falling_block::apply_effects_from_blocks(
|
||||
from,
|
||||
f.position,
|
||||
recorded.then_some(wanted),
|
||||
&mut f.velocity,
|
||||
&mut f.fall_distance,
|
||||
|p| {
|
||||
use horizon_client::falling_block::Inside;
|
||||
if &*facts.get(world.block(p.x, p.y, p.z)).name == "minecraft:cobweb" {
|
||||
Inside::Cobweb
|
||||
} else if let Some(column) =
|
||||
horizon_client::motion::bubble_column_at(world, registry, facts, collision, p)
|
||||
{
|
||||
Inside::BubbleColumn(column)
|
||||
} else {
|
||||
Inside::Nothing
|
||||
}
|
||||
},
|
||||
);
|
||||
if stuck {
|
||||
f.stuck = horizon_client::falling_block::web_multiplier();
|
||||
}
|
||||
f.velocity = Vec3::new(
|
||||
f.velocity.x * 0.98,
|
||||
f.velocity.y * 0.98,
|
||||
@@ -4170,9 +4229,17 @@ impl Game<'_> {
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
}
|
||||
fn break_block(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
self.with_player_actor(|game| game.break_block_as_player(pos))
|
||||
self.lan_break_pos = Some(pos);
|
||||
let broken = self.with_player_actor(|game| game.break_block_as_player(pos));
|
||||
self.lan_break_pos = None;
|
||||
broken
|
||||
}
|
||||
fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
// DebugStickItem.canDestroyBlock: a left click selects a property and
|
||||
// never destroys the block.
|
||||
if !self.debug_stick_can_destroy(pos)? {
|
||||
return Ok(());
|
||||
}
|
||||
if !self
|
||||
.res
|
||||
.items()
|
||||
@@ -4493,7 +4560,8 @@ impl Game<'_> {
|
||||
}
|
||||
// CeilingHangingSignBlock.useItemOn: a hanging sign aimed at the
|
||||
// underside passes, so the next sign chains below instead of
|
||||
// opening the editor (signs here carry no click commands).
|
||||
// opening the editor, unless the side has click commands to run
|
||||
// (`shouldTryToChainAnotherHangingSign`).
|
||||
let chains_sign = hit.face == [0, -1, 0]
|
||||
&& held
|
||||
.as_ref()
|
||||
@@ -4511,7 +4579,8 @@ impl Game<'_> {
|
||||
.block_entities
|
||||
.get(&(hit.pos[0], hit.pos[1], hit.pos[2])),
|
||||
Some(BlockEntity::Sign(_))
|
||||
);
|
||||
)
|
||||
&& !self.sign_can_execute_click_commands((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand's turn uses the block
|
||||
// itself (useWithoutItem).
|
||||
if !anchor_passed && !chains_sign && self.hand == Hand::Main {
|
||||
@@ -4575,6 +4644,24 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
// ItemStack.useOn: after the block's own useItemOn/useWithoutItem
|
||||
// passed. DebugStickItem.useOn cycles the selected property.
|
||||
if let Some(hit) = block_hit
|
||||
&& let Some(success) = self.debug_stick_use_on(hit.pos)?
|
||||
{
|
||||
// `Minecraft.startUseItem`: the stick's client-side `useOn` is
|
||||
// SUCCESS, so the actor's hand swings; a remote guest swings in
|
||||
// its own client, not on the server.
|
||||
if self.lan_acting.is_none() {
|
||||
self.swing_arm();
|
||||
}
|
||||
if success {
|
||||
// `ServerPlayerGameMode.useItemOn`: a consuming `useOn`
|
||||
// triggers ITEM_USED_ON_BLOCK with the stack before the use.
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, &stack);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if block_hit.is_some() && (self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)?) {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -5533,14 +5620,8 @@ impl Game<'_> {
|
||||
pitch,
|
||||
..
|
||||
}) => {
|
||||
let name = match sound {
|
||||
horizon_client::landing::SceneSound::BigFall => {
|
||||
"minecraft:entity.player.big_fall"
|
||||
}
|
||||
horizon_client::landing::SceneSound::SmallFall => {
|
||||
"minecraft:entity.player.small_fall"
|
||||
}
|
||||
_ => continue,
|
||||
let Some(name) = sound.player_fall_sound() else {
|
||||
continue;
|
||||
};
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
@@ -5649,6 +5730,12 @@ impl Game<'_> {
|
||||
self.level.last_death =
|
||||
Some((self.dim.id().to_owned(), feet.map(|v| v.floor() as i32)));
|
||||
self.death_message();
|
||||
// The mobs bound to a guest see that guest as their player.
|
||||
let bindings = match self.lan.as_ref() {
|
||||
Some(host) if !host.hub.guests.is_empty() => host.mob_players.clone(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
self.tell_neutral_mobs_player_died(feet, None, &bindings);
|
||||
// ServerPlayer.die: deaths, and the time-since counters restart.
|
||||
self.stats.custom("deaths", 1);
|
||||
self.score_player_died();
|
||||
@@ -6590,7 +6677,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
| BlockEntity::Shelf(_),
|
||||
| BlockEntity::Shelf(_)
|
||||
| BlockEntity::PotentSulfur(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -6876,7 +6964,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
| BlockEntity::Shelf(_),
|
||||
| BlockEntity::Shelf(_)
|
||||
| BlockEntity::PotentSulfur(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -7008,12 +7097,13 @@ impl Game<'_> {
|
||||
search.move_cursor_to_end(false);
|
||||
search.highlight = 0;
|
||||
}
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
let shown = items.get(first * 9 + slot - Creative::DISPLAY);
|
||||
let shown = self
|
||||
.creative_screen
|
||||
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY);
|
||||
let catalog = self.res.items();
|
||||
let max = |s: &ItemStack| catalog.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
match click_display(&mut self.carried, shown, max, button, input) {
|
||||
match click_display(&mut self.carried, shown.as_ref(), max, button, input) {
|
||||
DisplayClick::Hotbar(i, stack) if i < self.inventory.len() => {
|
||||
self.inventory[i] = Some(stack);
|
||||
}
|
||||
@@ -7612,9 +7702,11 @@ impl Game<'_> {
|
||||
None => Hover::Nothing,
|
||||
Some(slot) if slot >= Creative::DISPLAY => {
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
let shown = self.creative_screen.items(&self.pack.names).len();
|
||||
Hover::Display {
|
||||
filled: first * 9 + slot - Creative::DISPLAY < shown,
|
||||
filled: self
|
||||
.creative_screen
|
||||
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY)
|
||||
.is_some(),
|
||||
}
|
||||
}
|
||||
Some(_) => Hover::Slot,
|
||||
@@ -9263,6 +9355,39 @@ impl Game<'_> {
|
||||
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
|
||||
(out, stars)
|
||||
}
|
||||
/// What the title screen asked for, from a click or Enter.
|
||||
fn title_action(&mut self, event_loop: &ActiveEventLoop, action: Action) -> Result<()> {
|
||||
match action {
|
||||
Action::Quit => event_loop.exit(),
|
||||
Action::Options => self.open_options(true),
|
||||
Action::Accessibility => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Accessibility)
|
||||
}
|
||||
Action::Language => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Language)
|
||||
}
|
||||
Action::Play { folder } => self.open_world(&folder, None, None, false, None)?,
|
||||
Action::Create {
|
||||
folder,
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
options,
|
||||
} => self.open_world(&folder, Some(&name), seed, creative, Some(&options))?,
|
||||
Action::Connect { address } => {
|
||||
self.connect_server(&address, &self.options.player_name())?
|
||||
}
|
||||
Action::DataPacks { dir, selected } => {
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Options;
|
||||
self.options_from_title = true;
|
||||
let resources = self.vanilla_root();
|
||||
self.ui.options.open_data_packs(dir, selected, resources);
|
||||
}
|
||||
Action::None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Enter a local world from the client, selecting its simulation mode.
|
||||
fn open_world(
|
||||
&mut self,
|
||||
@@ -11652,7 +11777,7 @@ impl Game<'_> {
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
for r in 0..5 {
|
||||
for c in 0..9 {
|
||||
let Some(stack) = items.get((first + r) * 9 + c) else {
|
||||
let Some(stack) = items.get((first + r) * 9 + c).filter(|s| s.count > 0) else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
|
||||
@@ -11762,6 +11887,7 @@ impl Game<'_> {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if cx >= ox + tx && cx < ox + tx + 26.0 && cy >= oy + ty && cy < oy + ty + 32.0 {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, i);
|
||||
self.refresh_saved_hotbars();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
return;
|
||||
@@ -12673,11 +12799,11 @@ impl Game<'_> {
|
||||
&& self.creative_screen.tab().search
|
||||
&& self.search_tab_hotbar_key(key) => {}
|
||||
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat(""),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat_method(""),
|
||||
// key.advancements.
|
||||
KeyCode::KeyL if self.screen == Screen::Playing => self.open_advancements(false),
|
||||
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
|
||||
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat("/"),
|
||||
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat_method("/"),
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
@@ -12695,6 +12821,7 @@ impl Game<'_> {
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
self.refresh_saved_hotbars();
|
||||
} else {
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
@@ -13402,7 +13529,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.command_block_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Options && d != 0.0 {
|
||||
self.options_scroll(d);
|
||||
self.options_scroll(d)?;
|
||||
}
|
||||
// AbstractContainerScreen.mouseScrolled: a bundle under the
|
||||
// cursor takes the wheel before the screen's own scrolling.
|
||||
@@ -13508,38 +13635,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
if sound {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
match action {
|
||||
Action::Quit => event_loop.exit(),
|
||||
Action::Options => self.open_options(true),
|
||||
Action::Accessibility => self
|
||||
.open_options_at(true, horizon_client::options_ui::Page::Accessibility),
|
||||
Action::Language => {
|
||||
self.open_options_at(true, horizon_client::options_ui::Page::Language)
|
||||
}
|
||||
Action::Play { folder } => {
|
||||
self.open_world(&folder, None, None, false, None)?
|
||||
}
|
||||
Action::Create {
|
||||
folder,
|
||||
name,
|
||||
seed,
|
||||
creative,
|
||||
options,
|
||||
} => {
|
||||
self.open_world(&folder, Some(&name), seed, creative, Some(&options))?
|
||||
}
|
||||
Action::Connect { address } => {
|
||||
self.connect_server(&address, &self.options.player_name())?
|
||||
}
|
||||
Action::DataPacks { dir, selected } => {
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Options;
|
||||
self.options_from_title = true;
|
||||
let resources = self.vanilla_root();
|
||||
self.ui.options.open_data_packs(dir, selected, resources);
|
||||
}
|
||||
Action::None => {}
|
||||
}
|
||||
self.title_action(event_loop, action)?;
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.title.is_some() => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.book.is_some() => {
|
||||
@@ -13840,10 +13936,21 @@ impl ApplicationHandler for Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
if let Some(t) = self.title.as_mut() {
|
||||
t.confirm_edit();
|
||||
// `WorldListEntry.keyPressed`: Space joins too, with the button click sound.
|
||||
PhysicalKey::Code(
|
||||
code @ (KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space),
|
||||
) if code != KeyCode::Space
|
||||
|| self.title.as_ref().is_some_and(|t| t.list_focused()) =>
|
||||
{
|
||||
let action = self
|
||||
.title
|
||||
.as_mut()
|
||||
.map_or(Action::None, |t| t.confirm_key());
|
||||
self.save_title_options();
|
||||
if matches!(action, Action::Play { .. }) {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
self.title_action(event_loop, action)?;
|
||||
}
|
||||
PhysicalKey::Code(
|
||||
key @ (KeyCode::Backspace
|
||||
@@ -13979,6 +14086,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.tick_fov();
|
||||
}
|
||||
self.tick_win_screen()?;
|
||||
// Gui.tick runs ChatListener.tick paused or not.
|
||||
self.tick_chat_queue();
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
self.clock.record(started.elapsed());
|
||||
@@ -14043,6 +14152,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
server.set_distances(
|
||||
self.settings.render_distance,
|
||||
self.settings.simulation_distance,
|
||||
self.settings.entity_distance,
|
||||
);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
@@ -14407,6 +14517,7 @@ impl<'a> Game<'a> {
|
||||
pickups: Default::default(),
|
||||
sign_edit: None,
|
||||
book: None,
|
||||
book_last_click: None,
|
||||
lectern_pulses: Vec::new(),
|
||||
loading_progress: 0.0,
|
||||
spawn_origin: None,
|
||||
@@ -14600,6 +14711,7 @@ impl<'a> Game<'a> {
|
||||
pending_save: None,
|
||||
world_icon: None,
|
||||
forced_hit: None,
|
||||
tick_hit: None,
|
||||
guest_use: None,
|
||||
lan_events: Vec::new(),
|
||||
lan_songs: Vec::new(),
|
||||
@@ -14614,6 +14726,7 @@ impl<'a> Game<'a> {
|
||||
lan_global_events: Vec::new(),
|
||||
lid_openers: HashMap::new(),
|
||||
lan_acting: None,
|
||||
lan_break_pos: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
guest_entity_events: Vec::new(),
|
||||
@@ -14951,6 +15064,7 @@ fn load_resources(
|
||||
settings: &Settings,
|
||||
started: Instant,
|
||||
menu: bool,
|
||||
headless: bool,
|
||||
progress: &Progress,
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
@@ -14962,7 +15076,16 @@ fn load_resources(
|
||||
let mut loaded_gui = None;
|
||||
let mut ui_ready = false;
|
||||
let mut pack_error = None;
|
||||
let ui = match UiResources::load(&root, vanilla_root) {
|
||||
// A dedicated server builds only the font (UiResources::server), as the
|
||||
// integrated server's game does: the full atlas and panorama are 144 MB.
|
||||
let load_ui = |root: &std::path::Path| {
|
||||
if headless {
|
||||
UiResources::server(root)
|
||||
} else {
|
||||
UiResources::load(root, vanilla_root)
|
||||
}
|
||||
};
|
||||
let ui = match load_ui(&root) {
|
||||
Ok(ui) => Some(ui),
|
||||
// Minecraft.rollbackResourcePacks: the packs' textures don't fit, start
|
||||
// with the game's own resources.
|
||||
@@ -14988,8 +15111,16 @@ fn load_resources(
|
||||
ui_ready = true;
|
||||
}
|
||||
progress.set(0);
|
||||
// Nor the block sprites' pixels, which only the renderer reads (32 MB).
|
||||
let reload = |assets: &Assets, root: &std::path::Path| {
|
||||
if headless {
|
||||
assets.reload_for_server(root)
|
||||
} else {
|
||||
assets.reload(root)
|
||||
}
|
||||
};
|
||||
let (assets, render) = match Assets::load(&root).and_then(|assets| {
|
||||
let render = assets.reload(&root)?;
|
||||
let render = reload(&assets, &root)?;
|
||||
Ok((assets, render))
|
||||
}) {
|
||||
Ok(bundle) => bundle,
|
||||
@@ -15000,7 +15131,7 @@ fn load_resources(
|
||||
pack_error = Some(e.to_string());
|
||||
root = vanilla_root.to_path_buf();
|
||||
let assets = Assets::load(vanilla_root)?;
|
||||
let render = assets.reload(vanilla_root)?;
|
||||
let render = reload(&assets, vanilla_root)?;
|
||||
(assets, render)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -15014,7 +15145,7 @@ fn load_resources(
|
||||
let render = Arc::new(render);
|
||||
// The menu bundle must come from the same root as the world bundle.
|
||||
if !ui_ready || root != requested_root {
|
||||
let ui = UiResources::load(&root, vanilla_root)?;
|
||||
let ui = load_ui(&root)?;
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
@@ -16032,13 +16163,10 @@ pub fn run() -> Result<()> {
|
||||
horizon_threads::favour_current();
|
||||
}
|
||||
// Someone is playing: the anonymous ping behind the launcher's "N playing now", and the
|
||||
// official server in their list. Not for a server, a benchmark or a run that quits by itself.
|
||||
if options.execution_role.is_client()
|
||||
&& options.benchmark.is_none()
|
||||
&& !options.offscreen
|
||||
&& options.frames.is_none()
|
||||
&& options.world_frames.is_none()
|
||||
{
|
||||
// official server in their list (servers.dat in the game folder, the working directory). Not
|
||||
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
horizon_client::heartbeat::start();
|
||||
if let Err(e) = horizon_client::multiplayer::add_official_server(&std::env::current_dir()?)
|
||||
{
|
||||
@@ -16087,6 +16215,7 @@ pub fn run() -> Result<()> {
|
||||
&early_settings,
|
||||
started,
|
||||
false,
|
||||
options.dedicated.is_some(),
|
||||
&Progress::new(),
|
||||
&mut |_, _| {},
|
||||
)?;
|
||||
|
||||
@@ -1089,6 +1089,31 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `ServerPlayer.onItemPickup`: the player in place (the host, or the
|
||||
/// guest `as_guest` runs) picked up an item entity whose owner
|
||||
/// (`ItemEntity.getOwner`, the thrower) is `thrower`: the whole stack, or
|
||||
/// what is left after a partial take. An owner that is that player also
|
||||
/// fires `LivingEntity.onItemPickup` (the by-entity trigger); one that is
|
||||
/// gone, or another player, fires nothing here.
|
||||
pub(super) fn trigger_thrown_picked_up_by_player(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
thrower: [i32; 4],
|
||||
) {
|
||||
let target = if thrower == self.acting_uuid() {
|
||||
let me = self.player_entity();
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
|
||||
me
|
||||
} else if let Some(id) = self.mob_by_uuid(thrower) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
self.mob_entity(m)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_player", stack, &target);
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
|
||||
@@ -486,6 +486,8 @@ impl Game<'_> {
|
||||
// LivingEntity.deathTime while dead (the camera's death roll).
|
||||
"death_time": self.death_time(),
|
||||
"air": p.air,
|
||||
// LocalPlayer.portalEffectIntensity: the Nether portal overlay.
|
||||
"portal_effect": self.screen_fx.portal,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
@@ -598,7 +600,7 @@ impl Game<'_> {
|
||||
Mode::Delete { world } => {
|
||||
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::BackupBeforeUpgrade { world } => {
|
||||
Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
|
||||
@@ -181,6 +181,11 @@ impl Game<'_> {
|
||||
m.extra.ints[3] = 60;
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
|
||||
let allay = self.mob_uuid(self.mobs[i].id);
|
||||
if let Some(thrown) = self.dropped.last_mut() {
|
||||
thrown.thrower = Some(allay);
|
||||
}
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
const PITCHES: [f32; 16] = [
|
||||
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
|
||||
|
||||
@@ -137,16 +137,16 @@ impl ArmorStandAssets {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour,
|
||||
/// overlay) of each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
@@ -155,7 +155,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers(
|
||||
[
|
||||
@@ -172,7 +172,7 @@ impl Game<'_> {
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
@@ -206,13 +206,27 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, stack);
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
|
||||
// glint on the layer and its trim when `stack.hasFoil()`.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.is_some_and(|s| s.has_foil(item_glint)) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF, overlay));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -820,6 +834,7 @@ impl Game<'_> {
|
||||
horizon_client::mobs::ElytraAnimation::RESTING,
|
||||
false,
|
||||
packed,
|
||||
self.wings_overlay(a.equipment[4].as_ref()),
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
|
||||
@@ -67,6 +67,16 @@ impl Game<'_> {
|
||||
for head in left {
|
||||
self.player_beds.retain(|b| *b != (dim, head));
|
||||
self.set_bed_occupied(head, false);
|
||||
// AbstractBedBlock.onStopSleeping: the Overworld's straw_bed_rule
|
||||
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
|
||||
let bed = self.world.block(head[0], head[1], head[2]);
|
||||
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
|
||||
if dim == Dim::Overworld
|
||||
&& straw
|
||||
&& let Err(e) = self.destroy_straw_bed(head)
|
||||
{
|
||||
log::warn!("could not break the straw bed at {head:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ impl Game<'_> {
|
||||
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
let time = self.world.game_time;
|
||||
// PotentSulfurBlock.triggerEvent.
|
||||
if self.potent_sulfur_eruption_event((pos[0], pos[1], pos[2])) {
|
||||
return;
|
||||
}
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.be_anims
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `Level.tickBlockEntities` for the block entities ticked together here
|
||||
//! (hoppers, crafters, jukeboxes, beacons, creaking hearts, spawners, trial
|
||||
//! spawners, vaults, beehives and furnaces): only those whose state has a
|
||||
//! spawners, vaults, beehives, furnaces and potent sulfur): only those whose state has a
|
||||
//! ticker, in vanilla's list order (`horizon_client::block_entities`), in
|
||||
//! chunks that tick blocks. Sculk, brewing stands and pistons tick in their
|
||||
//! own passes.
|
||||
@@ -64,6 +64,14 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
Some(BlockEntity::PotentSulfur(_)) => {
|
||||
self.tick_potent_sulfur(pos)?;
|
||||
// A window that simulates the world itself is its own
|
||||
// client; a server's clients tick it on their own.
|
||||
if self.options.execution_role.is_client() {
|
||||
self.client_tick_potent_sulfur(pos);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +113,14 @@ impl Game<'_> {
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
// Every state but dry has a ticker.
|
||||
"minecraft:potent_sulfur" => {
|
||||
let dry = registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("potent_sulfur_state"))
|
||||
.is_some_and(|v| v == "dry");
|
||||
if dry { Ticking::Idle } else { Ticking::Yes }
|
||||
}
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
|
||||
@@ -601,9 +601,10 @@ impl Game<'_> {
|
||||
self.dismount(i);
|
||||
return Ok(());
|
||||
}
|
||||
let mut b = self.boats[i].clone();
|
||||
// The boat steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
let b = self.tick_boat(b, true);
|
||||
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
|
||||
@@ -622,8 +622,15 @@ impl Game<'_> {
|
||||
let pos = self.gui.atlas.font.fit(title, rel);
|
||||
title_edit.set_cursor(pos, shift);
|
||||
}
|
||||
/// A click on the open page.
|
||||
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
|
||||
/// A click on the open page (`MultiLineEditBox.onClick`): a double click
|
||||
/// selects the word before the cursor and leaves the cursor where it is.
|
||||
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32, double: bool) {
|
||||
if double {
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.select_word_at_cursor();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let text = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
|
||||
@@ -989,6 +996,12 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
let (w, left) = (b.width, ((b.width - 192.0) / 2.0).floor());
|
||||
let (mx, my) = self.cursor;
|
||||
// MouseHandler: a click within 250 ms of the last one on this screen is a double click.
|
||||
let now = std::time::Instant::now();
|
||||
let double = self
|
||||
.book_last_click
|
||||
.is_some_and(|at| now.duration_since(at).as_millis() < 250);
|
||||
self.book_last_click = Some(now);
|
||||
let inside =
|
||||
|x: f32, y: f32, bw: f32, bh: f32| mx >= x && my >= y && mx < x + bw && my < y + bh;
|
||||
let click = |game: &mut Self| {
|
||||
@@ -1029,7 +1042,7 @@ impl Game<'_> {
|
||||
click(self);
|
||||
self.save_book(false);
|
||||
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
|
||||
self.book_page_click(left + 36.0, 32.0, mx, my);
|
||||
self.book_page_click(left + 36.0, 32.0, mx, my, double);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
|
||||
@@ -77,11 +77,20 @@ impl TimeMarker {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.Draft`: the text the chat box kept, and whether it was a command.
|
||||
pub struct ChatDraft {
|
||||
pub text: String,
|
||||
pub command: bool,
|
||||
}
|
||||
/// UI and command state that lives beside the game.
|
||||
#[derive(Default)]
|
||||
pub struct UiState {
|
||||
pub chat: Chat,
|
||||
pub chat_screen: Option<ChatScreen>,
|
||||
/// `ChatComponent.latestDraft`.
|
||||
pub chat_draft: Option<ChatDraft>,
|
||||
/// `ChatScreen.isDraft`: the box opened with the draft and is unchanged since.
|
||||
pub chat_draft_shown: bool,
|
||||
/// When the chat screen opened (its input took the focus: the cursor blink).
|
||||
pub chat_opened: Option<std::time::Instant>,
|
||||
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
|
||||
@@ -466,10 +475,25 @@ impl Game<'_> {
|
||||
self.ui.registries.players = names;
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
|
||||
/// last time, when this method can restore it (a command draft only for
|
||||
/// /), else with its prefix.
|
||||
pub(super) fn open_chat_method(&mut self, prefix: &str) {
|
||||
let command = prefix == "/";
|
||||
let draft = self
|
||||
.ui
|
||||
.chat_draft
|
||||
.as_ref()
|
||||
.filter(|d| !command || d.command)
|
||||
.map(|d| d.text.clone());
|
||||
self.open_chat(draft.as_deref().unwrap_or(prefix));
|
||||
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
|
||||
}
|
||||
pub(super) fn open_chat(&mut self, initial: &str) {
|
||||
if self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
self.ui.chat_draft_shown = false;
|
||||
self.refresh_dynamic_registries();
|
||||
let size = self
|
||||
.renderer
|
||||
@@ -506,6 +530,13 @@ impl Game<'_> {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
let text = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.keep_chat_draft(&text);
|
||||
self.ui.chat_draft_shown = false;
|
||||
self.ui.chat_screen = None;
|
||||
self.ui.chat.reset_scroll();
|
||||
if self.screen == Screen::Chat {
|
||||
@@ -513,6 +544,21 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `ChatScreen.removed`: the text left in the box becomes the draft when
|
||||
/// Save Chat Drafts is on, else (and when blank) the draft is discarded. A
|
||||
/// draft still shown unchanged stays as it is.
|
||||
fn keep_chat_draft(&mut self, text: &str) {
|
||||
let unchanged =
|
||||
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
|
||||
if !self.settings.file.bool("saveChatDrafts") || text.trim().is_empty() {
|
||||
self.ui.chat_draft = None;
|
||||
} else if !unchanged {
|
||||
self.ui.chat_draft = Some(ChatDraft {
|
||||
text: text.to_owned(),
|
||||
command: text.starts_with('/'),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
|
||||
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
|
||||
/// screen if something was typed.
|
||||
@@ -648,7 +694,11 @@ impl Game<'_> {
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
ChatAction::Close => self.close_chat(),
|
||||
ChatAction::Close => {
|
||||
// Escape: the box still holds its text, which close_chat keeps as the draft.
|
||||
self.ui.chat_screen = Some(screen);
|
||||
self.close_chat();
|
||||
}
|
||||
ChatAction::Submit(message) => {
|
||||
// InBedChatScreen sends without closing (`closeOnSubmit` is
|
||||
// false): the box empties and the screen stays.
|
||||
@@ -657,6 +707,8 @@ impl Game<'_> {
|
||||
self.ui.chat.reset_scroll();
|
||||
self.ui.chat_screen = Some(screen);
|
||||
} else {
|
||||
// The screen is taken here, so close_chat sees no text: a sent
|
||||
// message discards the draft (DONE).
|
||||
self.close_chat();
|
||||
}
|
||||
if !message.is_empty() {
|
||||
|
||||
@@ -30,7 +30,7 @@ type Pos = (i32, i32, i32);
|
||||
|
||||
/// The open `CommandBlockEditScreen`.
|
||||
pub(super) struct CommandBlockScreen {
|
||||
pos: Pos,
|
||||
pub(super) pos: Pos,
|
||||
/// The command box with its `CommandSuggestions`.
|
||||
command: ChatScreen,
|
||||
/// When the command box was focused, for its cursor's blink.
|
||||
@@ -112,7 +112,6 @@ impl CommandBlockScreen {
|
||||
self.previous.set_value(&shown);
|
||||
}
|
||||
}
|
||||
|
||||
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
|
||||
registry.state(state)
|
||||
}
|
||||
@@ -241,6 +240,40 @@ impl Game<'_> {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
/// `CommandBlockEditScreen.updateGui`: the server's block-entity data
|
||||
/// arrived while the screen is open; refresh the fields from the entity.
|
||||
/// The mode and conditional live in the block state here (vanilla's
|
||||
/// `CommandBlockEntity.Mode`).
|
||||
pub(super) fn update_command_block_screen(&mut self) {
|
||||
let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else {
|
||||
return;
|
||||
};
|
||||
let state = self.block_state_at(screen_pos).map(|s| {
|
||||
(
|
||||
Mode::of_block(&s.name).unwrap_or(Mode::Redstone),
|
||||
conditional(s),
|
||||
)
|
||||
});
|
||||
let block = self.command_block(screen_pos).cloned();
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let Some(screen) = self.command_block_screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some((mode, conditional)) = state {
|
||||
screen.mode = mode;
|
||||
screen.conditional = conditional;
|
||||
}
|
||||
if let Some(block) = block {
|
||||
screen.command.suggest_command(&m, &c, &block.command);
|
||||
screen.track_output = block.track_output;
|
||||
screen.auto = block.auto;
|
||||
screen.show_previous(&block);
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandBlock.setPlacedBy`: unless the item carried block entity data
|
||||
/// (`defaults` false), output tracking follows `send_command_feedback` and
|
||||
|
||||
@@ -1053,6 +1053,11 @@ impl Game<'_> {
|
||||
&horizon_client::brewing::potion_name_key(item, s.potion()),
|
||||
&[],
|
||||
)
|
||||
} else if item == "minecraft:shield"
|
||||
&& let Some(color) = s.data().and_then(|d| d.base_color.as_deref())
|
||||
{
|
||||
// `ShieldItem.getName`: the banner's base colour names the shield.
|
||||
Text::translate(&format!("item.minecraft.shield.{color}"), &[])
|
||||
} else {
|
||||
// Item.getDescriptionId: a block item's is the block's.
|
||||
let (namespace, path) = item.split_once(':').unwrap_or(("minecraft", item));
|
||||
|
||||
@@ -1048,6 +1048,23 @@ impl Game<'_> {
|
||||
let Some(holder) = self.dialog_holder(dialog) else {
|
||||
return;
|
||||
};
|
||||
self.send_dialog(targets, &holder);
|
||||
}
|
||||
/// `ServerPlayer.openDialog` for the dialog of a `show_dialog` click
|
||||
/// event (a sign's): a registry id or the dialog itself, opened for the
|
||||
/// player the game acts for.
|
||||
pub(in crate::game) fn show_click_dialog(&mut self, dialog: &horizon_world::nbt::Tag) {
|
||||
let holder = match dialog {
|
||||
horizon_world::nbt::Tag::String(id) => self.dialog_holder(&id.to_string_lossy()),
|
||||
direct => Some(DialogHolder::Direct(direct.clone())),
|
||||
};
|
||||
if let Some(holder) = holder {
|
||||
self.send_dialog(&[Target::Player], &holder);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.openDialog` for each target: the packet to a guest, the
|
||||
/// screen to the window's own player.
|
||||
fn send_dialog(&mut self, targets: &[Target], holder: &DialogHolder) {
|
||||
let packet = C::ShowDialog(play::ShowDialog {
|
||||
dialog: holder.clone(),
|
||||
});
|
||||
@@ -1056,7 +1073,7 @@ impl Game<'_> {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.show_play_dialog(&holder),
|
||||
Target::Player => self.show_play_dialog(holder),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
|
||||
@@ -170,23 +170,25 @@ impl Game<'_> {
|
||||
let hurt = match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
Target::Player => {
|
||||
Target::Player | Target::Guest(_) => {
|
||||
// `DamageSource(type, cause, by)`: the death message names the
|
||||
// causing entity, else the direct one. A mob is noted by
|
||||
// `hurt_player_from`, a player here.
|
||||
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
|
||||
let name = self.target_name(t);
|
||||
self.note_direct_entity(&ty, name);
|
||||
}
|
||||
if self.spectator() {
|
||||
false
|
||||
} else {
|
||||
let attacker = match cause.or(by) {
|
||||
Some(Target::Mob(i)) => Some(Some(i)),
|
||||
_ => Some(None),
|
||||
};
|
||||
self.hurt_player_from(amount, from, &ty, attacker)
|
||||
}
|
||||
let named = match cause.or(by) {
|
||||
Some(t @ (Target::Player | Target::Guest(_))) => Some(self.target_name(t)),
|
||||
_ => None,
|
||||
};
|
||||
let attacker = match cause.or(by) {
|
||||
Some(Target::Mob(i)) => Some(Some(i)),
|
||||
_ => Some(None),
|
||||
};
|
||||
self.for_player(target, |g| {
|
||||
if let Some(name) = named {
|
||||
g.note_direct_entity(&ty, name);
|
||||
}
|
||||
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let attacker = cause.or(by).and_then(|t| match t {
|
||||
|
||||
@@ -66,6 +66,17 @@ impl Game<'_> {
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `ComposterBlock.useItemOn` on a client's level: whether `held` is
|
||||
/// compostable and the composter at `pos` below level 8 (SUCCESS).
|
||||
pub(super) fn composter_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
self.composter_level(pos).is_some_and(|level| level < 8)
|
||||
&& held.is_some_and(|stack| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.compostable.is_some())
|
||||
})
|
||||
}
|
||||
/// `HopperBlockEntity.addItem` into the composter's `InputContainer`
|
||||
/// (levels 0 to 6; one item, compostable, from above): the remainder.
|
||||
pub(super) fn composter_container_insert(
|
||||
|
||||
@@ -227,6 +227,8 @@ impl Game<'_> {
|
||||
self.dismount(i);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.dismount_cart(i);
|
||||
} else if let Some(c) = self.cushions.iter().find(|c| c.id == id).cloned() {
|
||||
self.get_up_from_cushion(&c);
|
||||
} else {
|
||||
self.dismount_mount();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
|
||||
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
|
||||
use super::Game;
|
||||
use horizon_client::{hotbar, keybinds, text::Text};
|
||||
use horizon_client::{hotbar, items::ItemStack, keybinds, text::Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the key bound to `mapping` is held.
|
||||
@@ -10,6 +11,58 @@ impl Game<'_> {
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
|
||||
/// row's nine stacks, or for an empty row a locked paper in that row's own
|
||||
/// slot, named after the keys that save it (`inventory.hotbarInfo`).
|
||||
pub(super) fn refresh_saved_hotbars(&mut self) {
|
||||
if !self.creative_screen.tab().hotbar {
|
||||
return;
|
||||
}
|
||||
let path = Self::options_path().with_file_name("hotbar.nbt");
|
||||
let known = |id: &str| self.res.items().get(id).is_some();
|
||||
let rows = hotbar::load(&path, &known);
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping)).plain();
|
||||
let empty = || ItemStack::new("minecraft:air", 0);
|
||||
let mut items = Vec::with_capacity(hotbar::GROUPS * hotbar::SIZE);
|
||||
for (i, row) in rows.iter().enumerate().take(hotbar::GROUPS) {
|
||||
if row.iter().any(Option::is_some) {
|
||||
items.extend(row.iter().map(|s| s.clone().unwrap_or_else(empty)));
|
||||
continue;
|
||||
}
|
||||
for j in 0..hotbar::SIZE {
|
||||
if j != i {
|
||||
items.push(empty());
|
||||
continue;
|
||||
}
|
||||
let with = [
|
||||
key("key.saveToolbarActivator"),
|
||||
key(&format!("key.hotbar.{}", i + 1)),
|
||||
];
|
||||
let mut paper = ItemStack::new("minecraft:paper", 1);
|
||||
let data = paper.data_mut();
|
||||
data.set_other(
|
||||
"minecraft:creative_slot_lock",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
data.set_other(
|
||||
"minecraft:item_name",
|
||||
Some(Tag::compound([
|
||||
("translate", Tag::String("inventory.hotbarInfo".into())),
|
||||
(
|
||||
"with",
|
||||
Tag::List(
|
||||
with.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
])),
|
||||
);
|
||||
items.push(paper);
|
||||
}
|
||||
}
|
||||
self.creative_screen.set_saved_hotbars(items);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
|
||||
/// `index`: load puts the saved row in the hotbar (the creative slot
|
||||
/// packets follow from the inventory change), save stores the hotbar and
|
||||
|
||||
@@ -127,6 +127,27 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
.full_collision
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractWindCharge` is pickable, so a thrown pearl's
|
||||
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
|
||||
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
|
||||
|
||||
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
|
||||
projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, q)| {
|
||||
*j != skip
|
||||
&& !q.stuck
|
||||
&& matches!(
|
||||
q.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
)
|
||||
})
|
||||
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `FlameFeatureRenderer`: fire quads stacked up a burning entity's box,
|
||||
/// turned to the camera around Y, at full block light. `at` is the entity's
|
||||
/// feet relative to the camera.
|
||||
@@ -257,6 +278,7 @@ impl Game<'_> {
|
||||
snowy: has("minecraft:spawns_snow_foxes")
|
||||
|| b.name.contains("snow")
|
||||
|| b.name.contains("frozen"),
|
||||
snow_fox: has("minecraft:spawns_snow_foxes"),
|
||||
swamp: b.name.contains("swamp"),
|
||||
ocean: has("minecraft:is_ocean"),
|
||||
mushroom: b.name == "minecraft:mushroom_fields",
|
||||
@@ -1918,6 +1940,34 @@ impl Game<'_> {
|
||||
self.spawn_orbs(at, amount);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.tellNeutralMobsThatIDied`: the mobs within 32 blocks
|
||||
/// across and 10 up and down get `NeutralMob.playerDied`, when the
|
||||
/// `forgive_dead_players` rule is on. A mob's `Who::Player` is the guest
|
||||
/// it is bound to in `bindings` (game/lan_mobs.rs), else the host's
|
||||
/// player, so only the mobs whose binding is `conn` (`None` for the
|
||||
/// host's player) hear it.
|
||||
pub(super) fn tell_neutral_mobs_player_died(
|
||||
&mut self,
|
||||
feet: [f64; 3],
|
||||
conn: Option<u32>,
|
||||
bindings: &std::collections::HashMap<u32, u32>,
|
||||
) {
|
||||
// A client of someone else's world has the server's mobs only to show.
|
||||
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
|
||||
return;
|
||||
}
|
||||
let block = feet.map(f64::floor);
|
||||
let area = Aabb::new(
|
||||
Vec3::new(block[0], block[1], block[2]),
|
||||
Vec3::new(block[0] + 1.0, block[1] + 1.0, block[2] + 1.0),
|
||||
)
|
||||
.inflate(Vec3::new(32.0, 10.0, 32.0));
|
||||
for m in self.mobs.iter_mut() {
|
||||
if bindings.get(&m.id).copied() == conn && m.bounds().intersects(area) {
|
||||
horizon_client::mobs::ai::forgive_dead_player(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Animal.spawnChildFromBreeding
|
||||
fn breed(
|
||||
&mut self,
|
||||
@@ -1990,6 +2040,24 @@ impl Game<'_> {
|
||||
}
|
||||
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
|
||||
// else either parent's variant. The draws come from the mother's random.
|
||||
let axolotl = if p.species() == Species::Axolotl {
|
||||
let other = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
self.mobs.iter_mut().find(|m| m.id == parent).map(|m| {
|
||||
if m.random.next_int_bound(1200).unwrap_or(1) == 0 {
|
||||
// Axolotl.Variant.getRareSpawnVariant: the one blue, still a draw.
|
||||
let _ = m.random.next_int_bound(1);
|
||||
4
|
||||
} else if m.random.next_boolean() {
|
||||
p.extra.variant
|
||||
} else {
|
||||
other
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A horse and a donkey have a mule.
|
||||
let kind = match (p.species(), q.as_ref().map(|q| q.species())) {
|
||||
(Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => {
|
||||
@@ -2004,6 +2072,24 @@ impl Game<'_> {
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
child.extra.sound_variant = None;
|
||||
// Fox.getBreedOffspring runs no finalizeSpawn: nothing in the
|
||||
// baby fox's mouth.
|
||||
if p.species() == Species::Fox {
|
||||
child.extra.main_hand = None;
|
||||
}
|
||||
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
|
||||
// screams if the parent a coin toss picks does, or on a 2% roll.
|
||||
if p.species() == Species::Goat {
|
||||
child.extra.lost_horns = [false; 2];
|
||||
horizon_client::mobs::finalize::goat_init_memories(child, &mut self.world.random);
|
||||
let pick_parent = self.world.random.next_boolean();
|
||||
let parent_screams = if pick_parent {
|
||||
p.extra.screaming
|
||||
} else {
|
||||
q.as_ref().unwrap_or(&p).extra.screaming
|
||||
};
|
||||
child.extra.screaming = parent_screams || self.world.random.next_double() < 0.02;
|
||||
}
|
||||
// A llama's baby was drawn in the parent's tick, with the
|
||||
// experience.
|
||||
if let Some(o) = llama {
|
||||
@@ -2091,6 +2177,14 @@ impl Game<'_> {
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Axolotl.getBreedOffspring: the drawn variant, and the baby is persistent.
|
||||
if let Some(variant) = axolotl {
|
||||
child.extra.variant = variant;
|
||||
child.persistent = true;
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
|
||||
if p.species() == Species::Nautilus && p.extra.tame {
|
||||
child.extra.tame = true;
|
||||
@@ -2497,13 +2591,21 @@ impl Game<'_> {
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
projectiles.retain_mut(|p| {
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
for i in 0..projectiles.len() {
|
||||
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
|
||||
wind_charge_hitboxes(&projectiles, i)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let p = &mut projectiles[i];
|
||||
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;
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
@@ -2518,11 +2620,25 @@ impl Game<'_> {
|
||||
if shooter.is_some() {
|
||||
p.owner = shooter;
|
||||
}
|
||||
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp) {
|
||||
(true, false) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
|
||||
(true, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets_guests),
|
||||
(false, false) => p.tick(&pw),
|
||||
(false, true) => p.tick(&pw_guests),
|
||||
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp, winds.is_empty()) {
|
||||
(true, false, _) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
|
||||
(true, true, _) => {
|
||||
horizon_client::arrows::tick(p, &scene, &arrow_targets_guests)
|
||||
}
|
||||
(false, false, true) => p.tick(&pw),
|
||||
(false, true, true) => p.tick(&pw_guests),
|
||||
(false, pvp_shot, false) => {
|
||||
let mut targets = if pvp_shot {
|
||||
pw_guests.targets.clone()
|
||||
} else {
|
||||
pw.targets.clone()
|
||||
};
|
||||
targets.extend(winds);
|
||||
p.tick(&ProjWorld {
|
||||
w: if pvp_shot { pw_guests.w } else { pw.w },
|
||||
targets,
|
||||
})
|
||||
}
|
||||
};
|
||||
if shooter.is_some() {
|
||||
p.owner = Some(Who::Player);
|
||||
@@ -2568,7 +2684,7 @@ impl Game<'_> {
|
||||
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
|
||||
}
|
||||
deflections.push([at.x, at.y, at.z]);
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
// AbstractWindCharge.tick: out of the world's top it bursts.
|
||||
if wind && keep && p.position.y > 350.0 {
|
||||
@@ -2580,12 +2696,19 @@ impl Game<'_> {
|
||||
face: [0; 3],
|
||||
},
|
||||
));
|
||||
return false;
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
if hit != Hit::None {
|
||||
hits.push((p.clone(), hit));
|
||||
}
|
||||
keep
|
||||
keep_flags[i] = keep;
|
||||
}
|
||||
let mut idx = 0;
|
||||
projectiles.retain(|_| {
|
||||
let k = keep_flags[idx];
|
||||
idx += 1;
|
||||
k
|
||||
});
|
||||
}
|
||||
self.smoke_trails(trails);
|
||||
@@ -4143,6 +4266,12 @@ impl Game<'_> {
|
||||
Some(sound) => {
|
||||
consume = true;
|
||||
eat = sound;
|
||||
// Axolotl.usePlayerItem: a tropical fish bucket is
|
||||
// emptied into a water bucket in its place.
|
||||
if species == Species::Axolotl && &*item == "minecraft:tropical_fish_bucket"
|
||||
{
|
||||
give = Some("minecraft:water_bucket");
|
||||
}
|
||||
// Animal.setInLove: entity event 18 (a baby that
|
||||
// only grew is not in love).
|
||||
if self.mobs[index].extra.in_love > 0 {
|
||||
@@ -4585,6 +4714,13 @@ impl Game<'_> {
|
||||
if mob.fire_ticks > 0 || matches!(mob.species(), Species::Blaze | Species::MagmaCube) {
|
||||
packed = (15 * 16) | ((15 * 16) << 8);
|
||||
}
|
||||
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
|
||||
// dark ticks run out (the block light is the second byte).
|
||||
if mob.species() == Species::GlowSquid {
|
||||
let lit = (packed >> 12) & 15;
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
@@ -4940,6 +5076,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::Drown => "minecraft:drown",
|
||||
K::DryOut => "minecraft:dry_out",
|
||||
K::Magic => "minecraft:magic",
|
||||
K::IndirectMagic => "minecraft:indirect_magic",
|
||||
K::Sting => "minecraft:sting",
|
||||
K::Cactus => "minecraft:cactus",
|
||||
K::SweetBerryBush => "minecraft:sweet_berry_bush",
|
||||
|
||||
@@ -134,23 +134,11 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `CushionItem.useOn`: on the top face only, spawning at the click's
|
||||
/// height above the clicked block (or the replaceable block itself).
|
||||
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(default_color) = info.cushion_color.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let color_override = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:cushion/color"))
|
||||
.and_then(|tag| match tag {
|
||||
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
|
||||
/// `CushionItem.useOn` up to `Cushion.canBePlacedAt`, which the client
|
||||
/// checks too: on the top face only, at the click's height above the
|
||||
/// clicked block (or the replaceable block itself). The position and
|
||||
/// spawn box of a cushion that may go there.
|
||||
pub(super) fn cushion_spawn(&self, hit: &Hit) -> Option<(Vec3, Aabb)> {
|
||||
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
|
||||
// (cauldrons, hoppers and composters) use the collision ray's actual height.
|
||||
let mut adjusted = Hit {
|
||||
@@ -186,7 +174,7 @@ impl Game<'_> {
|
||||
}
|
||||
let hit = &adjusted;
|
||||
if hit.face != [0, 1, 0] {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
// BlockPlaceContext: a replaceable clicked block is replaced.
|
||||
let clicked = self
|
||||
@@ -205,9 +193,30 @@ impl Game<'_> {
|
||||
[position.x - 0.5, position.y, position.z - 0.5],
|
||||
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
|
||||
));
|
||||
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
|
||||
horizon_client::cushion::can_place(&PlacementWorld(self), bounds)
|
||||
.then_some((position, bounds))
|
||||
}
|
||||
|
||||
/// `CushionItem.useOn`: spawn the cushion where `cushion_spawn` allows,
|
||||
/// unless another cushion is in the way.
|
||||
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let Some(default_color) = info.cushion_color.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let color_override = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:cushion/color"))
|
||||
.and_then(|tag| match tag {
|
||||
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
|
||||
let Some((position, bounds)) = self.cushion_spawn(hit) else {
|
||||
return false;
|
||||
};
|
||||
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
|
||||
if self
|
||||
.cushions
|
||||
@@ -255,6 +264,8 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
// `CushionItem.useOn` returns SUCCESS: the hand swings.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -280,11 +291,9 @@ impl Game<'_> {
|
||||
if occupied {
|
||||
return false;
|
||||
}
|
||||
// `Player.startRiding` gives up the vehicle it already has; the
|
||||
// client never sends that click, so pass instead.
|
||||
if self.riding.is_some() {
|
||||
return false;
|
||||
}
|
||||
// `Entity.startRiding`: a passenger first gets off what it rides
|
||||
// (`stopRiding`), so a seated player moves to this cushion.
|
||||
self.stop_riding();
|
||||
self.riding = Some(id);
|
||||
let p = self.cushions[i].position;
|
||||
self.audio.play(
|
||||
@@ -394,6 +403,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
|
||||
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
|
||||
/// rather than one whose cushion was killed.
|
||||
pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) {
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.get_up",
|
||||
Some([c.position.x, c.position.y, c.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
let Some(c) = self
|
||||
@@ -405,22 +434,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
if keys.shift {
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
// `Cushion.removePassenger`: the get-up sound for a rider that
|
||||
// left rather than one whose cushion was killed.
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.get_up",
|
||||
Some([c.position.x, c.position.y, c.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.get_up_from_cushion(&c);
|
||||
return true;
|
||||
}
|
||||
let seat = c.seat();
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! `DebugStickItem`: selecting a block-state property with the left button and
|
||||
//! cycling its value with the right button, as vanilla does.
|
||||
//!
|
||||
//! The selected property is the `minecraft:debug_stick_state` component, a
|
||||
//! compound of block id to property name. Horizon keeps unmodelled components
|
||||
//! in `StackData::other`, so the component travels through NBT unchanged.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, items::ItemStack, text::Text};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::flags;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
const DEBUG_STICK: &str = "minecraft:debug_stick";
|
||||
|
||||
fn is_debug_stick(stack: &ItemStack) -> bool {
|
||||
&*stack.item == DEBUG_STICK
|
||||
}
|
||||
|
||||
/// `Util.findNextInIterable` / `findPreviousInIterable` on an index.
|
||||
///
|
||||
/// A missing current value starts at the first value forward and the last
|
||||
/// value backward; both wrap around.
|
||||
fn relative_index(len: usize, current: Option<usize>, backward: bool) -> usize {
|
||||
match (current, backward) {
|
||||
(Some(0), true) => len - 1,
|
||||
(Some(i), true) => i - 1,
|
||||
(Some(i), false) => (i + 1) % len,
|
||||
(None, true) => len - 1,
|
||||
(None, false) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(key: &str, args: &[&str]) -> Text {
|
||||
let args: Vec<Text> = args.iter().map(|arg| Text::literal(*arg)).collect();
|
||||
Text::translate(key, &args)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `DebugStickItem.useOn`: a right click cycles the value of the property
|
||||
/// in `debug_stick_state` (the first property when none is selected yet).
|
||||
/// `Some` is the vanilla result (`SUCCESS`/`FAIL`), `None` another item.
|
||||
pub(super) fn debug_stick_use_on(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
if !self.held().is_some_and(is_debug_stick) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(self.debug_stick_interact(pos, true)?))
|
||||
}
|
||||
|
||||
/// `DebugStickItem.canDestroyBlock`: a left click selects the next
|
||||
/// property and never destroys the block.
|
||||
pub(super) fn debug_stick_can_destroy(&mut self, pos: [i32; 3]) -> Result<bool> {
|
||||
if !self.held().is_some_and(is_debug_stick) {
|
||||
return Ok(true);
|
||||
}
|
||||
let _ = self.debug_stick_interact(pos, false)?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// `DebugStickItem.handleInteraction`: whether the interaction succeeded.
|
||||
fn debug_stick_interact(&mut self, pos: [i32; 3], cycle: bool) -> Result<bool> {
|
||||
// `Player.canUseGameMasterBlocks`: creative with game-master commands.
|
||||
if !self.can_use_game_master_blocks() {
|
||||
return Ok(false);
|
||||
}
|
||||
let state_id = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(state) = self.pack.assets.registry.state(state_id).cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
if state.properties.is_empty() {
|
||||
self.debug_stick_message(text(
|
||||
"item.minecraft.debug_stick.empty",
|
||||
&[state.name.as_str()],
|
||||
));
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let selected = self
|
||||
.held()
|
||||
.and_then(ItemStack::data)
|
||||
.and_then(|data| data.debug_stick_property(&state.name));
|
||||
// `Player.isSecondaryUseActive` / `isShiftKeyDown`: the acting
|
||||
// player's sneak input. `keys` holds the host's, toggled sneak
|
||||
// included; `enter_guest` fills it from the guest's sneak state.
|
||||
let backward = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
|
||||
if cycle {
|
||||
// `cycleState`: a property selected on another block falls back to
|
||||
// the first one. The property list is sorted by name, as vanilla's
|
||||
// `ImmutableSortedMap`.
|
||||
let (property, value) = {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let property = selected
|
||||
.as_deref()
|
||||
.filter(|property| state.properties.contains_key(*property))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| state.properties.keys().next().unwrap().clone());
|
||||
let values = registry.property_values(&state.name, &property);
|
||||
if values.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let current = state.properties.get(&property).map(String::as_str);
|
||||
let current = current.and_then(|value| values.iter().position(|v| *v == value));
|
||||
let value = values[relative_index(values.len(), current, backward)].to_owned();
|
||||
(property, value)
|
||||
};
|
||||
let mut next = state;
|
||||
next.properties.insert(property.clone(), value.clone());
|
||||
let Ok(next_id) = self.pack.assets.registry.resolve(&next) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
// `Level.setBlock(pos, newState, 18)`: clients and the known shape,
|
||||
// without neighbour updates.
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(
|
||||
level,
|
||||
ticks,
|
||||
at,
|
||||
next_id,
|
||||
flags::UPDATE_CLIENTS | flags::UPDATE_KNOWN_SHAPE,
|
||||
);
|
||||
});
|
||||
self.process_level_events()?;
|
||||
self.debug_stick_message(text(
|
||||
"item.minecraft.debug_stick.update",
|
||||
&[property.as_str(), value.as_str()],
|
||||
));
|
||||
} else {
|
||||
// `getRelative(properties, property, backward)`; the property list
|
||||
// is sorted, so its key order is vanilla's.
|
||||
let keys: Vec<&String> = state.properties.keys().collect();
|
||||
let current = selected
|
||||
.as_deref()
|
||||
.and_then(|selected| keys.iter().position(|key| key.as_str() == selected));
|
||||
let property = keys[relative_index(keys.len(), current, backward)].clone();
|
||||
let value = state.properties.get(&property).cloned().unwrap_or_default();
|
||||
self.set_debug_stick_property(&state.name, &property);
|
||||
self.debug_stick_message(text(
|
||||
"item.minecraft.debug_stick.select",
|
||||
&[property.as_str(), value.as_str()],
|
||||
));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `DebugStickState.withProperty(block, property)` on the acting hand.
|
||||
fn set_debug_stick_property(&mut self, block: &str, property: &str) {
|
||||
let Some(stack) = self.held_mut().as_mut() else {
|
||||
return;
|
||||
};
|
||||
stack.data_mut().set_debug_stick_property(block, property);
|
||||
stack.tidy();
|
||||
}
|
||||
|
||||
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
|
||||
/// game's HUD; a server client receives it as a packet.
|
||||
fn debug_stick_message(&mut self, message: Text) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_packets
|
||||
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
|
||||
horizon_protocol::packets::play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&message.component()),
|
||||
overlay: true,
|
||||
},
|
||||
));
|
||||
} else if self.options.dedicated.is_none() {
|
||||
self.titles.set_overlay(message, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::relative_index;
|
||||
|
||||
#[test]
|
||||
fn relative_index_wraps_like_util() {
|
||||
assert_eq!(relative_index(4, None, false), 0);
|
||||
assert_eq!(relative_index(4, None, true), 3);
|
||||
assert_eq!(relative_index(4, Some(0), false), 1);
|
||||
assert_eq!(relative_index(4, Some(0), true), 3);
|
||||
assert_eq!(relative_index(4, Some(2), false), 3);
|
||||
assert_eq!(relative_index(4, Some(2), true), 1);
|
||||
assert_eq!(relative_index(4, Some(3), false), 0);
|
||||
assert_eq!(relative_index(4, Some(3), true), 2);
|
||||
}
|
||||
}
|
||||
@@ -763,6 +763,8 @@ impl Game<'_> {
|
||||
self.sync_ops(&lists);
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.spawn_protection = props.spawn_protection;
|
||||
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
|
||||
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_client::{
|
||||
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
text::{ClickEvent, Text},
|
||||
ui,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -544,6 +544,7 @@ impl Game<'_> {
|
||||
};
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
let mut action = None;
|
||||
let mut text_click: Option<ClickEvent> = None;
|
||||
open.focus = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
|
||||
@@ -592,11 +593,39 @@ impl Game<'_> {
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
action = Some(Some(index));
|
||||
}
|
||||
// AbstractStringWidget.setComponentClickHandler: the click event
|
||||
// of the styled text under a centred message line.
|
||||
Part::Line(line, x, y)
|
||||
if text_click.is_none()
|
||||
&& cursor.0 >= x
|
||||
&& (0.0..9.0).contains(&(cursor.1 - y)) =>
|
||||
{
|
||||
let content = line.resolve_legacy();
|
||||
let mut left = cursor.0 - x;
|
||||
for (c, style) in content.chars() {
|
||||
left -= self.gui.atlas.font.advance(c, style);
|
||||
if left < 0.0 {
|
||||
text_click = style.click.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(action) = action else {
|
||||
return Ok(());
|
||||
// A text click runs its event (DialogScreen.runAction) with no button sound.
|
||||
let click = match text_click {
|
||||
Some(ClickEvent::OpenUrl(url)) => Click::OpenUrl(url),
|
||||
Some(ClickEvent::ChangePage(page)) => Click::ChangePage(page),
|
||||
Some(ClickEvent::SuggestCommand(command)) => Click::SuggestCommand(command),
|
||||
Some(ClickEvent::RunCommand(command)) => Click::RunCommand(command),
|
||||
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
|
||||
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
|
||||
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
|
||||
};
|
||||
let after = open.dialog.after_action;
|
||||
return self.run_dialog_action(Some(click), after);
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
let Some(index) = action else {
|
||||
|
||||
@@ -362,7 +362,7 @@ impl Game<'_> {
|
||||
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
|
||||
/// a burst at the player's feet that only knocks back and triggers
|
||||
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
|
||||
fn post_attack_bursts(&mut self, held: &ItemStack, victim: &str) {
|
||||
pub(super) fn post_attack_bursts(&mut self, held: &ItemStack, victim: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -329,7 +329,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn explosion_hurt_entities(&mut self, center: [f64; 3], radius: f32, damage_type: &str) {
|
||||
fn explosion_hurt_entities(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
damage_type: &str,
|
||||
interaction: BlockInteraction,
|
||||
) {
|
||||
if radius < 1.0e-5 {
|
||||
return;
|
||||
}
|
||||
@@ -440,6 +446,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.mobs = mobs;
|
||||
self.explosion_breaks_frames(center, diameter, interaction);
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
@@ -579,7 +586,7 @@ impl Game<'_> {
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.exploded_positions(center, radius, resists);
|
||||
self.explosion_hurt_entities(center, radius, damage_type);
|
||||
self.explosion_hurt_entities(center, radius, damage_type, interaction);
|
||||
if interaction != BlockInteraction::Keep {
|
||||
// Util.shuffle(list, level.random)
|
||||
for i in (2..=targets.len()).rev() {
|
||||
|
||||
@@ -46,6 +46,8 @@ pub(super) struct Hook {
|
||||
open_water: bool,
|
||||
luck: i32,
|
||||
lure: i32,
|
||||
/// The position a server's update put it heading for.
|
||||
lerp: HookLerp,
|
||||
}
|
||||
/// A player a hook can catch (`canHitEntity`): their entity id, which
|
||||
/// guest they are (none: the host) and their box.
|
||||
@@ -146,6 +148,7 @@ impl Game<'_> {
|
||||
open_water: true,
|
||||
luck,
|
||||
lure,
|
||||
lerp: HookLerp::default(),
|
||||
});
|
||||
}
|
||||
self.swing_arm();
|
||||
@@ -896,6 +899,81 @@ pub(super) struct RemoteHook {
|
||||
owner: u32,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
velocity: Vec3,
|
||||
state: State,
|
||||
on_ground: bool,
|
||||
lerp: HookLerp,
|
||||
}
|
||||
|
||||
/// The `onGround` of a server's update (`handleMoveEntity`), except that a
|
||||
/// hook resting on the ground stays so while the update does not lift it: the
|
||||
/// server's tracker can send the flag a tick behind the landing.
|
||||
fn resting(was: bool, at: Vec3, target: [f64; 3], sent: bool) -> bool {
|
||||
sent || (was && target[1] <= at.y + 1.0e-3)
|
||||
}
|
||||
|
||||
/// `LinearInterpolationHandler`: where a server's update has a hook heading
|
||||
/// and the steps left (`DEFAULT_INTERPOLATION_STEPS` 3).
|
||||
#[derive(Default)]
|
||||
pub(super) struct HookLerp {
|
||||
target: Vec3,
|
||||
steps: u32,
|
||||
}
|
||||
impl HookLerp {
|
||||
/// `interpolateTo`: a new target restarts the steps, the same one does not.
|
||||
fn start(&mut self, target: [f64; 3]) {
|
||||
let target = Vec3::new(target[0], target[1], target[2]);
|
||||
if self.steps == 0 || self.target != target {
|
||||
self.target = target;
|
||||
self.steps = 3;
|
||||
}
|
||||
}
|
||||
/// `doInterpolate`: a step of the way there.
|
||||
fn step(&mut self, at: Vec3) -> Vec3 {
|
||||
if self.steps == 0 {
|
||||
return at;
|
||||
}
|
||||
let n = f64::from(self.steps);
|
||||
self.steps -= 1;
|
||||
Vec3::new(
|
||||
at.x + (self.target.x - at.x) / n,
|
||||
at.y + (self.target.y - at.y) / n,
|
||||
at.z + (self.target.z - at.z) / n,
|
||||
)
|
||||
}
|
||||
/// `applyPredictedMovement`: what the hook moved by itself carries the
|
||||
/// target along while it is heading there.
|
||||
fn moved(&mut self, by: Vec3) {
|
||||
if self.steps > 0 {
|
||||
self.target = Vec3::new(
|
||||
self.target.x + by.x,
|
||||
self.target.y + by.y,
|
||||
self.target.z + by.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hook {
|
||||
pub(super) fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// A server's update of this client's own hook (`handleMoveEntity` also
|
||||
/// sets `onGround`).
|
||||
pub(super) fn move_to(&mut self, target: [f64; 3], on_ground: bool) {
|
||||
self.on_ground = resting(self.on_ground, self.position, target, on_ground);
|
||||
self.lerp.start(target);
|
||||
}
|
||||
}
|
||||
impl RemoteHook {
|
||||
pub(super) fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// A server's update of another player's hook.
|
||||
pub(super) fn move_to(&mut self, target: [f64; 3], on_ground: bool) {
|
||||
self.on_ground = resting(self.on_ground, self.position, target, on_ground);
|
||||
self.lerp.start(target);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hook {
|
||||
@@ -917,6 +995,7 @@ impl Hook {
|
||||
open_water: true,
|
||||
luck: 0,
|
||||
lure: 0,
|
||||
lerp: HookLerp::default(),
|
||||
}
|
||||
}
|
||||
/// `FishingHook`'s entity for the players tracking it: the owner's id in
|
||||
@@ -963,52 +1042,195 @@ impl Game<'_> {
|
||||
|
||||
/// `handleAddEntity` for a fishing bobber: this player's own becomes its
|
||||
/// hook (`Player.fishing`), another player's is drawn on its line to them.
|
||||
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, owner: i32) {
|
||||
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, velocity: Vec3, owner: i32) {
|
||||
let own = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if own == Some(owner) {
|
||||
self.hook = Some(Hook::remote(id, at));
|
||||
let mut hook = Hook::remote(id, at);
|
||||
hook.velocity = velocity;
|
||||
self.hook = Some(hook);
|
||||
} else {
|
||||
self.remote_hooks.push(RemoteHook {
|
||||
id,
|
||||
owner: owner as u32,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
state: State::Flying,
|
||||
on_ground: false,
|
||||
lerp: HookLerp::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of interpolation for the hooks a server tracks.
|
||||
pub(super) fn step_remote_hooks(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(h) = self.hook.as_mut() {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
for h in &mut self.remote_hooks {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
}
|
||||
|
||||
/// `FishingHook.onSyncedDataUpdated`: this player's hook is biting
|
||||
/// (DATA_BITING), which `status` shows as a nibble.
|
||||
pub(super) fn remote_hook_data(&mut self, id: u32, values: &[(u8, i32, Value)]) {
|
||||
let Some(h) = self
|
||||
// DATA_HOOKED_ENTITY (the hooked id plus one, 0 for none): a hook on a
|
||||
// client follows what it hooked (`FishingHook.onSyncedDataUpdated`).
|
||||
let hooked = |value: &Value| match value {
|
||||
Value::Int(v) if *v > 0 => State::Hooked(*v as u32 - 1),
|
||||
_ => State::Flying,
|
||||
};
|
||||
if let Some(h) = self
|
||||
.hook
|
||||
.as_mut()
|
||||
.filter(|h| h.id == id && self.remote.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (index, _, value) in values {
|
||||
if let (9, Value::Bool(biting)) = (index, value) {
|
||||
h.nibble = i32::from(*biting);
|
||||
{
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
(9, Value::Bool(biting)) => h.nibble = i32::from(*biting),
|
||||
(8, value) => h.state = hooked(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if let Some(h) = self.remote_hooks.iter_mut().find(|h| h.id == id) {
|
||||
for (index, _, value) in values {
|
||||
if *index == 8 {
|
||||
h.state = hooked(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.lerpMotion` for a hook a server tracks: the motion its own tick
|
||||
/// moves it on with between the tracker's updates.
|
||||
pub(super) fn remote_hook_motion(&mut self, id: u32, v: Vec3) -> bool {
|
||||
if self.remote.is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(h) = self.hook.as_mut().filter(|h| h.id == id) {
|
||||
h.velocity = v;
|
||||
} else if let Some(h) = self.remote_hooks.iter_mut().find(|h| h.id == id) {
|
||||
h.velocity = v;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `FishingHook.tick` on a client: the interpolation step towards the
|
||||
/// server's last update, then its own movement between the tracker's
|
||||
/// updates (every five ticks), floating in water and riding what it
|
||||
/// hooked, as the server's does.
|
||||
pub(super) fn tick_remote_hooks(&mut self) {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(mut h) = self.hook.take() {
|
||||
h.previous = h.position;
|
||||
h.position = h.lerp.step(h.position);
|
||||
let before = h.position;
|
||||
(h.position, h.velocity, h.state, h.on_ground) =
|
||||
self.client_hook_step((h.position, h.velocity, h.state, h.on_ground));
|
||||
h.lerp.moved(h.position.subtract(before));
|
||||
self.hook = Some(h);
|
||||
}
|
||||
let mut hooks = std::mem::take(&mut self.remote_hooks);
|
||||
for h in &mut hooks {
|
||||
h.previous = h.position;
|
||||
h.position = h.lerp.step(h.position);
|
||||
let before = h.position;
|
||||
(h.position, h.velocity, h.state, h.on_ground) =
|
||||
self.client_hook_step((h.position, h.velocity, h.state, h.on_ground));
|
||||
h.lerp.moved(h.position.subtract(before));
|
||||
}
|
||||
self.remote_hooks = hooks;
|
||||
}
|
||||
|
||||
/// One tick of `FishingHook.tick`'s movement without the fishing (the
|
||||
/// server's): position, motion, state and whether it rests on the ground.
|
||||
fn client_hook_step(
|
||||
&mut self,
|
||||
(mut position, mut velocity, state, mut on_ground): (Vec3, Vec3, State, bool),
|
||||
) -> (Vec3, Vec3, State, bool) {
|
||||
let b = [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
];
|
||||
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
|
||||
let in_water = liquid > 0.0;
|
||||
match state {
|
||||
State::Flying if in_water => {
|
||||
velocity = Vec3::new(velocity.x * 0.3, velocity.y * 0.2, velocity.z * 0.3);
|
||||
return (position, velocity, State::Bobbing, on_ground);
|
||||
}
|
||||
// A landed hook: the server stopped it dead (zero motion) and sends no
|
||||
// more than the odd update; it does not fall on from here.
|
||||
State::Flying if velocity == Vec3::ZERO && on_ground => {
|
||||
return (position, velocity, state, on_ground);
|
||||
}
|
||||
State::Flying => {}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: at 0.8 of the hooked entity's height.
|
||||
let hooked = if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.entity_id as u32 == id)
|
||||
{
|
||||
self.player
|
||||
.as_ref()
|
||||
.map(|p| (p.position, f64::from(p.dimensions.height)))
|
||||
} else {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.alive())
|
||||
.map(|m| (m.position, f64::from(m.height())))
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
|
||||
})
|
||||
};
|
||||
return match hooked {
|
||||
Some((at, height)) => (
|
||||
Vec3::new(at.x, at.y + height * 0.8, at.z),
|
||||
Vec3::ZERO,
|
||||
state,
|
||||
on_ground,
|
||||
),
|
||||
None => (position, velocity, State::Flying, on_ground),
|
||||
};
|
||||
}
|
||||
State::Bobbing => {
|
||||
let mut force = position.y + velocity.y - f64::from(b[1]) - f64::from(liquid);
|
||||
if force.abs() < 0.01 {
|
||||
force += force.signum() * 0.1;
|
||||
}
|
||||
let r = f64::from(self.world.random.next_float());
|
||||
velocity = Vec3::new(
|
||||
velocity.x * 0.9,
|
||||
velocity.y - force * r * 0.2,
|
||||
velocity.z * 0.9,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !in_water && !on_ground {
|
||||
velocity.y -= 0.03;
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(bounds(position), velocity, 0.0, on_ground) {
|
||||
let horizontal =
|
||||
(moved.x - velocity.x).abs() > 1.0e-7 || (moved.z - velocity.z).abs() > 1.0e-7;
|
||||
on_ground = velocity.y < 0.0 && moved.y > velocity.y;
|
||||
position = Vec3::new(
|
||||
position.x + moved.x,
|
||||
position.y + moved.y,
|
||||
position.z + moved.z,
|
||||
);
|
||||
if state == State::Flying && (on_ground || horizontal) {
|
||||
velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
velocity = Vec3::new(velocity.x * 0.92, velocity.y * 0.92, velocity.z * 0.92);
|
||||
(position, velocity, state, on_ground)
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_hook(&mut self, id: u32) {
|
||||
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
|
||||
@@ -296,6 +296,46 @@ impl Game<'_> {
|
||||
self.spawn_item(item, at, [0.0; 3], 10);
|
||||
}
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
|
||||
/// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`).
|
||||
/// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast
|
||||
/// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through
|
||||
/// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames.
|
||||
/// Not ported: `ignoreExplosion` also spares frames when the exploding
|
||||
/// entity is in water.
|
||||
pub(super) fn explosion_breaks_frames(
|
||||
&mut self,
|
||||
blast: [f64; 3],
|
||||
diameter: f64,
|
||||
interaction: super::explosion::BlockInteraction,
|
||||
) {
|
||||
if !self.ui.rules.bool("mob_griefing")
|
||||
&& (interaction == super::explosion::BlockInteraction::Keep
|
||||
|| self.combat.explosion_source.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.item_frames.len() {
|
||||
let c = center(&self.item_frames[i]);
|
||||
let dist =
|
||||
((c[0] - blast[0]).powi(2) + (c[1] - blast[1]).powi(2) + (c[2] - blast[2]).powi(2))
|
||||
.sqrt()
|
||||
/ diameter;
|
||||
if dist > 1.0 || self.item_frames[i].fixed {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let f = self.item_frames.remove(i);
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// BlockAttachedEntity.tick for frames.
|
||||
pub(super) fn tick_item_frames(&mut self) {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
|
||||
@@ -11,7 +11,8 @@ use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Client `EntityGetter.getEntityCollisions` for the player: the
|
||||
/// entities that `canBeCollidedWith` it, solid happy ghasts and boats
|
||||
/// entities that `canBeCollidedWith` it, solid happy ghasts, living
|
||||
/// shulkers (`Shulker.canBeCollidedWith`) and boats
|
||||
/// (`AbstractBoat.canBeCollidedWith`), except the player's own vehicle.
|
||||
pub(super) fn player_entity_collisions(&self) -> Vec<Aabb> {
|
||||
let Some(p) = &self.player else {
|
||||
@@ -20,9 +21,10 @@ impl Game<'_> {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
(m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& Some(m.id) != self.riding
|
||||
&& happy_ghast::collides_with_player(m, p.position.y)
|
||||
&& happy_ghast::collides_with_player(m, p.position.y))
|
||||
|| (m.species() == horizon_client::mobs::Species::Shulker && m.alive())
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(
|
||||
|
||||
@@ -20,15 +20,21 @@ impl Game<'_> {
|
||||
/// (`BlockPattern.find`), snow golems first: the snow column ("^", "#",
|
||||
/// "#") spawns at its bottom, the iron T ("~^~", "###", "~#~") under its
|
||||
/// head. The blocks come column by column, top first, as
|
||||
/// `clearPatternBlocks` visits them.
|
||||
fn find_golem(&self, head: [i32; 3]) -> Option<(&'static str, Vec<[i32; 3]>, [i32; 3])> {
|
||||
/// `clearPatternBlocks` visits them. With `base`, the head's cell takes
|
||||
/// any block, as in the base patterns (" ", "#", "#") and ("~ ~", "###",
|
||||
/// "~#~") that `canSpawnGolem` checks before the head is there.
|
||||
fn find_golem(
|
||||
&self,
|
||||
head: [i32; 3],
|
||||
base: bool,
|
||||
) -> Option<(&'static str, Vec<[i32; 3]>, [i32; 3])> {
|
||||
let air = |p: [i32; 3]| self.res.facts().is_air(self.world.block(p[0], p[1], p[2]));
|
||||
let snow = |c: char, p: [i32; 3]| match c {
|
||||
'^' => self.is_golem_head(p),
|
||||
'^' => base || self.is_golem_head(p),
|
||||
_ => self.golem_block(p) == "minecraft:snow_block",
|
||||
};
|
||||
let iron = |c: char, p: [i32; 3]| match c {
|
||||
'^' => self.is_golem_head(p),
|
||||
'^' => base || self.is_golem_head(p),
|
||||
'#' => self.golem_block(p) == "minecraft:iron_block",
|
||||
_ => air(p),
|
||||
};
|
||||
@@ -46,7 +52,7 @@ impl Game<'_> {
|
||||
/// `CarvedPumpkinBlock.canSpawnGolem`: a snow or iron golem base, or a
|
||||
/// copper block (`#minecraft:copper`) below.
|
||||
pub(super) fn can_spawn_golem(&self, head: [i32; 3]) -> bool {
|
||||
self.find_golem(head).is_some() || self.copper_golem_base(head)
|
||||
self.find_golem(head, true).is_some() || self.copper_golem_base(head)
|
||||
}
|
||||
/// The copper golem pattern ("^", "#"): a block of `#minecraft:copper` under the head.
|
||||
fn copper_golem_base(&self, head: [i32; 3]) -> bool {
|
||||
@@ -63,7 +69,7 @@ impl Game<'_> {
|
||||
if !self.is_golem_head(head) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((kind, blocks, base)) = self.find_golem(head) else {
|
||||
let Some((kind, blocks, base)) = self.find_golem(head, false) else {
|
||||
return self.try_spawn_copper_golem(head);
|
||||
};
|
||||
// CarvedPumpkinBlock.spawnGolemInWorld
|
||||
@@ -113,7 +119,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Whether placing a golem head at `head` completes a golem.
|
||||
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
|
||||
self.find_golem(head).is_some() || self.copper_golem_base(head)
|
||||
self.find_golem(head, true).is_some() || self.copper_golem_base(head)
|
||||
}
|
||||
/// `Level.setBlock` of the golem head `state` at `head`, then
|
||||
/// `CarvedPumpkinBlock.onPlace`. When the head completes a golem, onPlace
|
||||
|
||||
@@ -19,7 +19,6 @@ use super::{
|
||||
use horizon_client::{
|
||||
Result,
|
||||
audio::Audio,
|
||||
hud::HudAtlas,
|
||||
lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig},
|
||||
settings::Settings,
|
||||
title::WorldOptions,
|
||||
@@ -52,6 +51,9 @@ struct Flags {
|
||||
/// server follows while it runs (IntegratedServer.tickServer).
|
||||
render_distance: AtomicI32,
|
||||
simulation_distance: AtomicI32,
|
||||
/// `options.entityDistanceScaling` (an `f32`'s bits), which
|
||||
/// `IntegratedServer.getScaledTrackingDistance` reads on every call.
|
||||
entity_distance: AtomicU32,
|
||||
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
|
||||
saving: AtomicBool,
|
||||
}
|
||||
@@ -79,12 +81,16 @@ impl Integrated {
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
self.flags.paused.store(paused, Ordering::Relaxed);
|
||||
}
|
||||
/// The window's render and simulation distances, for the server to follow.
|
||||
pub fn set_distances(&self, render: i32, simulation: i32) {
|
||||
/// The window's render and simulation distances and its Entity Distance
|
||||
/// option, for the server to follow.
|
||||
pub fn set_distances(&self, render: i32, simulation: i32, entity: f32) {
|
||||
self.flags.render_distance.store(render, Ordering::Relaxed);
|
||||
self.flags
|
||||
.simulation_distance
|
||||
.store(simulation, Ordering::Relaxed);
|
||||
self.flags
|
||||
.entity_distance
|
||||
.store(entity.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
/// `IntegratedServer.getPort` while `isPublished`.
|
||||
pub fn published(&self) -> Option<u16> {
|
||||
@@ -255,6 +261,9 @@ impl Game<'_> {
|
||||
flags
|
||||
.simulation_distance
|
||||
.store(settings.simulation_distance, Ordering::Relaxed);
|
||||
flags
|
||||
.entity_distance
|
||||
.store(settings.entity_distance.to_bits(), Ordering::Relaxed);
|
||||
let (ready_tx, ready) = channel();
|
||||
let (tasks, server_tasks) = channel();
|
||||
let server_flags = flags.clone();
|
||||
@@ -330,6 +339,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.server_packs_world_pack(&folder);
|
||||
self.connect_local(pipe, &player)?;
|
||||
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
|
||||
// the Select World list shows, and needs the save folder for it.
|
||||
@@ -663,6 +673,9 @@ impl Game<'_> {
|
||||
/// IntegratedServer.tickServer: the server follows the window's render
|
||||
/// and simulation distances when they change.
|
||||
fn follow_distances(&mut self, flags: &Flags) {
|
||||
// IntegratedServer.getScaledTrackingDistance reads the option each time.
|
||||
self.settings.entity_distance =
|
||||
f32::from_bits(flags.entity_distance.load(Ordering::Relaxed));
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -836,15 +849,7 @@ fn server_game(
|
||||
options: Options,
|
||||
settings: Settings,
|
||||
) -> Result<Game<'_>> {
|
||||
// It draws nothing: only the font, which books and chat measure with.
|
||||
let atlas = HudAtlas::fonts(&options.resources)?;
|
||||
let gui = UiResources {
|
||||
atlas,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
}),
|
||||
};
|
||||
let gui = UiResources::server(&options.resources)?;
|
||||
let audio = Audio::silent(&options.resources);
|
||||
Game::new(res, pack, gui, options, settings, audio, None)
|
||||
}
|
||||
|
||||
@@ -435,12 +435,12 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(16)));
|
||||
}
|
||||
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
|
||||
// MushroomCow.DATA_TYPE (after AgeableMob's two): 0 red, 1 brown.
|
||||
"minecraft:mooshroom" => {
|
||||
if e.variant == 1 {
|
||||
meta.push((17, serializer::INT, Value::Int(1)));
|
||||
meta.push((18, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (16: with the dimension it
|
||||
// makes it shake) and Piglin.DATA_IS_DANCING (after the baby flag and
|
||||
@@ -714,6 +714,15 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// GlowSquid.DATA_DARK_TICKS_REMAINING (after AgeableMob's two): the
|
||||
// dimming a hit gave, counted down by the server and the client alike.
|
||||
"minecraft:glow_squid" => {
|
||||
let ticks = horizon_client::mobs::aquatic::dark_ticks(m);
|
||||
if ticks != 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(ticks)));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
|
||||
"minecraft:creaking" => {
|
||||
if e.flag2 {
|
||||
@@ -1418,7 +1427,8 @@ impl Game<'_> {
|
||||
crouching: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
spin_attack: p.auto_spin_attack_ticks > 0,
|
||||
on_fire: p.fire_ticks > 0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
|
||||
invisible: self.potions.effects.get("invisibility").is_some() || self.spectator(),
|
||||
glowing: self.potions.effects.get("glowing").is_some(),
|
||||
},
|
||||
})
|
||||
@@ -1589,6 +1599,13 @@ impl Game<'_> {
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
self.ui.rules.bool("elytra_movement_check"),
|
||||
);
|
||||
// IntegratedServer.getScaledTrackingDistance: the Entity Distance
|
||||
// option scales what an integrated server or a LAN host tracks (a
|
||||
// dedicated one keeps its entity-broadcast-range-percentage).
|
||||
if host.hub.config.integrated || !host.hub.config.dedicated {
|
||||
host.hub.entity_range_percent =
|
||||
(self.settings.entity_distance * 100.0).round() as i32;
|
||||
}
|
||||
host.hub
|
||||
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
|
||||
// A server with several levels runs a pass for each
|
||||
@@ -1616,7 +1633,17 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::PlayerChat { .. } => {}
|
||||
HostEvent::PlayerChat { sender, .. } => {
|
||||
if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == sender)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
self.detect_rate_spam(&mut host, conn, false);
|
||||
}
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
@@ -1835,9 +1862,18 @@ impl Game<'_> {
|
||||
if self.audio.capture.is_none() {
|
||||
return;
|
||||
}
|
||||
// The acting guest's own break: `Block.playerWillDestroy` excludes the
|
||||
// player from the destroy events up to the broken block's. What a
|
||||
// break sets off after that (a torch popping off, a stalk falling) is
|
||||
// `destroyBlock` with no player, which the breaker hears and sees too.
|
||||
let mut own_break = self.lan_break_pos.is_some();
|
||||
for e in events {
|
||||
match e {
|
||||
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
|
||||
let own = *id == 2001 && own_break;
|
||||
if own && self.lan_break_pos == Some([pos.x, pos.y, pos.z]) {
|
||||
own_break = false;
|
||||
}
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, anvil result sounds, or a
|
||||
// finished brushing or an eye inserted in a portal frame (these calls name no
|
||||
@@ -1846,7 +1882,8 @@ impl Game<'_> {
|
||||
let except = if matches!(
|
||||
*id,
|
||||
2015 | 1503 | 1505 | 1029 | 1030 | 1045 | 1052 | 2019 | 2020 | 3000 | 3008
|
||||
) {
|
||||
) || (*id == 2001 && self.lan_break_pos.is_some() && !own)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -2175,6 +2212,7 @@ impl Game<'_> {
|
||||
.armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.armor = g.armor.clone();
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.avatar_pose = g.pose();
|
||||
m.extra.sprinting = g.sprinting;
|
||||
|
||||
@@ -20,8 +20,12 @@ use horizon_client::{
|
||||
menu::{Rules, Slots},
|
||||
},
|
||||
motion::{MotionEvent, MotionWorld, PlayerMotion},
|
||||
pose::Dimensions,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C, Serverbound as S},
|
||||
text::literal,
|
||||
@@ -43,6 +47,10 @@ pub(super) struct GuestPlay {
|
||||
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
|
||||
/// item a creative player drops from the creative screen, one less each tick.
|
||||
creative_drops: u32,
|
||||
/// `ServerGamePacketListenerImpl.chatSpamThrottler`: 20 for each chat message, one less each tick.
|
||||
pub(super) chat_spam: u32,
|
||||
/// `commandSpamThrottler`: 20 for each command, one less each tick.
|
||||
pub(super) command_spam: u32,
|
||||
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
|
||||
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
@@ -131,6 +139,8 @@ pub(super) struct GuestSwap {
|
||||
selected: usize,
|
||||
mode: horizon_client::command::args::GameMode,
|
||||
creative: bool,
|
||||
/// The host's command permission, swapped for the guest's for its turn.
|
||||
permission: u8,
|
||||
xp: Experience,
|
||||
host_keys: std::collections::HashSet<KeyCode>,
|
||||
screen: Screen,
|
||||
@@ -270,6 +280,12 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
let i = host.hub.guests.iter().position(|g| g.conn == conn)?;
|
||||
// `ServerPlayer.permissions()`: the guest's own command level for its
|
||||
// turn, so game-master checks see it (not the host's).
|
||||
let permission = std::mem::replace(
|
||||
&mut self.ui.registries.permission,
|
||||
host.hub.guests[i].permission,
|
||||
);
|
||||
let fresh = host.hub.guests[i].motion.is_none();
|
||||
if fresh {
|
||||
let p = Vec3::new(
|
||||
@@ -307,6 +323,7 @@ impl Game<'_> {
|
||||
let g = &mut host.hub.guests[i];
|
||||
// The guest's body.
|
||||
let Some(mut motion) = g.motion.take() else {
|
||||
self.ui.registries.permission = permission;
|
||||
host.play.insert(conn, play);
|
||||
return None;
|
||||
};
|
||||
@@ -462,6 +479,7 @@ impl Game<'_> {
|
||||
selected,
|
||||
mode,
|
||||
creative,
|
||||
permission,
|
||||
xp,
|
||||
host_keys,
|
||||
screen,
|
||||
@@ -524,6 +542,7 @@ impl Game<'_> {
|
||||
selected,
|
||||
mode,
|
||||
creative,
|
||||
permission,
|
||||
xp,
|
||||
host_keys,
|
||||
screen,
|
||||
@@ -568,6 +587,8 @@ impl Game<'_> {
|
||||
projectiles,
|
||||
modifiers,
|
||||
} = swap;
|
||||
// `ServerPlayer.permissions()` back to the host's after the turn.
|
||||
self.ui.registries.permission = permission;
|
||||
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);
|
||||
@@ -1091,12 +1112,19 @@ impl Game<'_> {
|
||||
// The pause between two blocks is the client's (MultiPlayerGameMode's
|
||||
// destroyDelay); the server digs from the START it receives.
|
||||
play.destroy_delay = 0;
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a survival START
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a creative START
|
||||
// destroys the block at once (destroyAndAck "creative destroy"),
|
||||
// so a UseItemOn sent behind it in the same tick (both buttons
|
||||
// at once, #3006) replaces the air it left. A survival START
|
||||
// attacks the block at once, so a tap released in the same tick
|
||||
// still plays a note. The dig is marked begun for tick_mining.
|
||||
let pos = a.pos;
|
||||
self.as_guest(host, conn, |g| {
|
||||
if g.may_build() && !g.creative {
|
||||
let destroyed = self.as_guest(host, conn, |g| {
|
||||
if g.creative {
|
||||
let _ = g.break_block(pos);
|
||||
return true;
|
||||
}
|
||||
if g.may_build() {
|
||||
let _ = g.attack_block(pos);
|
||||
g.mining = Some(Mining {
|
||||
pos,
|
||||
@@ -1104,7 +1132,13 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
false
|
||||
});
|
||||
if destroyed == Some(true)
|
||||
&& let Some(play) = host.play.get_mut(&conn)
|
||||
{
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
1 => {}
|
||||
3 => {
|
||||
@@ -1826,7 +1860,10 @@ impl Game<'_> {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
{
|
||||
let host_on_ground = self.player.as_ref().map(|p| p.ground.on_ground);
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim, host_on_ground);
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.weapon_hit_player(on_ground);
|
||||
g.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
g.stop_sprint_after_extra_knockback();
|
||||
@@ -2230,6 +2267,11 @@ impl Game<'_> {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
// ServerGamePacketListenerImpl.tick: the chat and command throttlers
|
||||
// decay one a tick, dead or alive.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.chat_spam = play.chat_spam.saturating_sub(1);
|
||||
play.command_spam = play.command_spam.saturating_sub(1);
|
||||
self.guest_sleep_tick(host, gi);
|
||||
if host.hub.guests[gi].dead {
|
||||
self.guest_tick_death(host, gi);
|
||||
@@ -2330,6 +2372,7 @@ impl Game<'_> {
|
||||
host.hub.flush_progress(conn);
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_arrows(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
self.guest_die(host, gi, conn);
|
||||
}
|
||||
@@ -2345,6 +2388,7 @@ impl Game<'_> {
|
||||
play.hook = None;
|
||||
}
|
||||
}
|
||||
let here = host.hub.here(&host.hub.guests[gi]);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
@@ -2361,6 +2405,11 @@ 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());
|
||||
// ServerPlayer.die: the neutral mobs near a guest in this level
|
||||
// forget their anger at them.
|
||||
if here {
|
||||
self.tell_neutral_mobs_player_died(at, Some(conn), &host.mob_players);
|
||||
}
|
||||
// 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.
|
||||
@@ -2583,7 +2632,7 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
|
||||
return;
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.dimensions());
|
||||
let at = g.position;
|
||||
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
|
||||
let riding = g.vehicle.is_some();
|
||||
@@ -2672,10 +2721,10 @@ impl Game<'_> {
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
// LivingEntity.aiStep: a Riptide spin counts down on the server's
|
||||
// copy too, or it would stay in the spin pose (eye 0.4) for good.
|
||||
w.player.count_down_server_spin();
|
||||
// Player.tick ends with updatePlayerPose: a swimmer, or a player
|
||||
// crawling under a block, takes the short box its moves are checked with.
|
||||
let _ = w.update_player_pose();
|
||||
// LivingEntity.tick: a changed scale rebuilds the box. Player.tick
|
||||
// ends with updatePlayerPose: a swimmer, or a player crawling
|
||||
// under a block, takes the short box its moves are checked with.
|
||||
let _ = w.server_player_pose_tick();
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
@@ -2764,6 +2813,20 @@ impl Game<'_> {
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(Some(host), state, position, count, speed),
|
||||
// LivingEntity.causeFallDamage: Player.playSound skips the
|
||||
// guest, whose own client plays it; everyone else hears it.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
..
|
||||
}) => {
|
||||
if let Some(name) = sound.player_fall_sound() {
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
self.audio.play_unshared(name, Some(at), volume, pitch);
|
||||
host.hub.sound(name, at, volume, pitch, source, Some(conn));
|
||||
}
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// LivingEntity.baseTick's entity event 67, to every client
|
||||
// around, the guest's own included; this window draws it too.
|
||||
@@ -2898,15 +2961,16 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// `Player.isInWall`: a suffocating block in the 0.48-wide box at the
|
||||
/// eyes. Suffocating is a full collision box that is also a full render
|
||||
/// cube (`BlockBehaviour.isSuffocating`'s default; glass and the like
|
||||
/// opt out), as the mobs' check has it.
|
||||
fn in_wall(&self, at: [f64; 3], eye_height: f64) -> bool {
|
||||
let (y, half) = ((at[1] + eye_height).floor() as i32, 0.24);
|
||||
let range = |v: f64| (v - half).floor() as i32..=(v + half).floor() as i32;
|
||||
range(at[0]).any(|x| {
|
||||
range(at[2]).any(|z| {
|
||||
/// `Entity.isInWall`: a suffocating block in the box at the eyes as wide
|
||||
/// as 0.8 of the body (0.48 at scale 1, 0.048 at scale 0.1). Suffocating
|
||||
/// is a full collision box that is also a full render cube
|
||||
/// (`BlockBehaviour.isSuffocating`'s default; glass and the like opt
|
||||
/// out), as the mobs' check has it.
|
||||
fn in_wall(&self, at: [f64; 3], body: Dimensions) -> bool {
|
||||
let y = (at[1] + f64::from(body.eye_height)).floor() as i32;
|
||||
let [xs, zs] = eye_columns(at, body);
|
||||
xs.into_iter().any(|x| {
|
||||
zs.clone().any(|z| {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
f.full_collision && f.solid_render
|
||||
})
|
||||
@@ -3062,7 +3126,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Dropped items touching a guest go into their inventory.
|
||||
/// Dropped items touching a guest go into their inventory
|
||||
/// (`ItemEntity.playerTouch`, then `ServerPlayer.onItemPickup`).
|
||||
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let (conn, mode, position, id, uuid) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
@@ -3095,16 +3160,28 @@ impl Game<'_> {
|
||||
for i in near.into_iter().rev() {
|
||||
let (stack, item_id) = (self.dropped[i].stack.clone(), self.dropped[i].id);
|
||||
let before = stack.count;
|
||||
let thrower = self.dropped[i].thrower;
|
||||
let rest = self.as_guest(host, conn, |g| g.give(stack)).flatten();
|
||||
match rest {
|
||||
None => {
|
||||
let item = self.dropped.remove(i);
|
||||
if let Some(thrower) = thrower {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.trigger_thrown_picked_up_by_player(&item.stack, thrower);
|
||||
});
|
||||
}
|
||||
self.guest_pickup(host, id, item);
|
||||
taken.push((item_id, before));
|
||||
}
|
||||
Some(rest) if rest.count < before => {
|
||||
self.guest_pickup(host, id, self.dropped[i].clone());
|
||||
self.dropped[i].stack = rest;
|
||||
if let Some(thrower) = thrower {
|
||||
let left = self.dropped[i].stack.clone();
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.trigger_thrown_picked_up_by_player(&left, thrower);
|
||||
});
|
||||
}
|
||||
taken.push((item_id, before - self.dropped[i].stack.count));
|
||||
}
|
||||
Some(_) => {}
|
||||
@@ -3117,6 +3194,75 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.aiStep` touching arrows in the guest's box grown by (1, 0.5, 1):
|
||||
/// `AbstractArrow.playerTouch` for a guest, as `touch_arrows` does for the
|
||||
/// host's player. The guest's own client plays the pickup sound
|
||||
/// (`handleTakeItemEntity`).
|
||||
fn guest_arrows(&mut self, host: &mut LanHost, gi: usize) {
|
||||
use horizon_client::arrows::Touch;
|
||||
let (conn, id, at, creative, unable, dims) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(
|
||||
g.conn,
|
||||
g.entity_id,
|
||||
g.position,
|
||||
g.creative(),
|
||||
g.spectator() || g.dead || g.health <= 0.0,
|
||||
g.dimensions(),
|
||||
)
|
||||
};
|
||||
if unable || self.creatures.projectiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let w = f64::from(dims.width) / 2.0;
|
||||
let reach = Aabb::new(
|
||||
Vec3::new(at[0] - w, at[1], at[2] - w),
|
||||
Vec3::new(at[0] + w, at[1] + f64::from(dims.height), at[2] + w),
|
||||
)
|
||||
.inflate(Vec3::new(1.0, 0.5, 1.0));
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
let mut picked = Vec::new();
|
||||
projectiles.retain(|shot| {
|
||||
let Some(a) = shot.arrow.as_ref().filter(|a| !a.removed) else {
|
||||
return true;
|
||||
};
|
||||
let p = shot.position;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
|
||||
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
|
||||
);
|
||||
if !bounds.intersects(reach) {
|
||||
return true;
|
||||
}
|
||||
let taken = match a.player_touch(creative) {
|
||||
Touch::Ignore => false,
|
||||
Touch::Take => true,
|
||||
// Inventory.add: a creative player takes what does not fit.
|
||||
Touch::Give(stack) => {
|
||||
self.as_guest(host, conn, |g| g.give(stack))
|
||||
.flatten()
|
||||
.is_none()
|
||||
|| creative
|
||||
}
|
||||
};
|
||||
if taken {
|
||||
picked.push(shot.clone());
|
||||
}
|
||||
!taken
|
||||
});
|
||||
self.creatures.projectiles = projectiles;
|
||||
for shot in picked {
|
||||
host.hub.take_item(shot.id as i32, id, 1, at);
|
||||
if let Some(target) = super::pickups::guest_target(host, id as u32) {
|
||||
self.add_pickup_toward(
|
||||
super::pickups::Taken::Arrow(Box::new(shot)),
|
||||
super::pickups::Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.tick`'s takeXpDelay and `Player.aiStep`'s touch of the
|
||||
/// experience orbs in the guest's box grown by 1, 0.5, 1: one of them at
|
||||
/// random, at most every 2 ticks (`ExperienceOrb.playerTouch`), repairs
|
||||
@@ -3387,3 +3533,37 @@ fn swing_packet(id: i32, hand: Hand, animation: SwingAnimation) -> C {
|
||||
duration: animation.duration,
|
||||
})
|
||||
}
|
||||
|
||||
/// The block columns `Entity.isInWall` looks at: `AABB.ofSize` around the
|
||||
/// eyes, 0.8 of the body's width across (`BlockPos.betweenClosedStream`).
|
||||
fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>; 2] {
|
||||
let half = f64::from(body.width * 0.8) / 2.0;
|
||||
let range = |v: f64| (v - half).floor() as i32..=(v + half).floor() as i32;
|
||||
[range(at[0]), range(at[2])]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::eye_columns;
|
||||
use horizon_client::pose::Pose;
|
||||
|
||||
/// #3684: a player shrunk to `minecraft:scale` 0.1 who stands 0.05 from a
|
||||
/// wall has eyes 0.048 wide, which don't reach into it: they don't
|
||||
/// suffocate there, as a full-sized player 0.3 from the wall doesn't.
|
||||
#[test]
|
||||
fn a_small_player_checks_only_the_blocks_at_their_small_eyes() {
|
||||
let at = [205.95, 100.0, 530.5];
|
||||
assert_eq!(
|
||||
eye_columns(at, Pose::Standing.dimensions(0.1)),
|
||||
[205..=205, 530..=530]
|
||||
);
|
||||
assert_eq!(
|
||||
eye_columns(at, Pose::Standing.dimensions(1.0)),
|
||||
[205..=206, 530..=530]
|
||||
);
|
||||
assert_eq!(
|
||||
eye_columns([205.7, 100.0, 530.5], Pose::Standing.dimensions(1.0)),
|
||||
[205..=205, 530..=530]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,35 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.detectRateSpam: a chat message or command adds 20 to the
|
||||
/// guest's throttler; over the threshold, a guest who is not an operator is kicked.
|
||||
pub(super) fn detect_rate_spam(&mut self, host: &mut LanHost, conn: u32, command: bool) {
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let threshold = if command {
|
||||
host.hub.command_spam_threshold
|
||||
} else {
|
||||
host.hub.chat_spam_threshold
|
||||
};
|
||||
let uuid = host.hub.guests[i].uuid;
|
||||
let play = host.play.entry(conn).or_default();
|
||||
let count = if command {
|
||||
&mut play.command_spam
|
||||
} else {
|
||||
&mut play.chat_spam
|
||||
};
|
||||
*count = count.saturating_add(20);
|
||||
// TickThrottler.isUnderThreshold: a threshold of 0 or less never kicks.
|
||||
let under = threshold <= 0 || i64::from(*count) < i64::from(threshold);
|
||||
if !under && !host.hub.ops.contains_key(&uuid) {
|
||||
host.hub.kick(
|
||||
conn,
|
||||
horizon_protocol::text::translatable("disconnect.spam", &[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// handleChatCommand (performUnsignedChatCommand): refused when the
|
||||
/// server enforces secure chat and the command has a message to sign.
|
||||
pub(super) fn guest_chat_command(&mut self, host: &mut LanHost, conn: u32, command: &str) {
|
||||
@@ -77,9 +106,11 @@ impl Game<'_> {
|
||||
content: component_nbt(&failure.component()),
|
||||
overlay: false,
|
||||
}));
|
||||
self.detect_rate_spam(host, conn, true);
|
||||
return;
|
||||
}
|
||||
self.guest_command(host, conn, command, None);
|
||||
self.detect_rate_spam(host, conn, true);
|
||||
}
|
||||
|
||||
/// handleSignedChatCommand: the acknowledgements, then each message
|
||||
@@ -95,9 +126,11 @@ impl Game<'_> {
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
let mut signed = None;
|
||||
let mut ran = false;
|
||||
if let Some(last_seen) = host.hub.apply_last_seen(i, &p.last_seen, &mut events)
|
||||
&& host.hub.try_handle_chat(i, &p.command, true)
|
||||
{
|
||||
ran = true;
|
||||
let permission = host.hub.guests[i].permission;
|
||||
let parsed = self.signable_arguments(permission, &p.command);
|
||||
match host
|
||||
@@ -114,6 +147,10 @@ impl Game<'_> {
|
||||
if let Some(signed) = signed {
|
||||
self.guest_command(host, conn, &p.command, Some(signed));
|
||||
}
|
||||
// The lambda counts the command even when its arguments failed to decode.
|
||||
if ran {
|
||||
self.detect_rate_spam(host, conn, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// SayCommand, EmoteCommands and MsgCommand for a guest: chat to the
|
||||
|
||||
@@ -342,6 +342,8 @@ pub(super) struct Present {
|
||||
pub creative: bool,
|
||||
pub spectator: bool,
|
||||
pub alive: bool,
|
||||
/// Trial Omen, else Bad Omen, among the guest's effects.
|
||||
pub omen: Option<horizon_client::mobs::trial_spawner::Omen>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -355,6 +357,12 @@ impl Game<'_> {
|
||||
.filter(|g| g.loaded)
|
||||
.map(|g| {
|
||||
let [x, y, z] = g.position;
|
||||
// The acting guest's effects are on the game while their turn runs.
|
||||
let effects = if self.lan_acting == Some(g.conn) {
|
||||
&self.potions.effects
|
||||
} else {
|
||||
&g.effects
|
||||
};
|
||||
Present {
|
||||
uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid),
|
||||
position: g.position,
|
||||
@@ -363,6 +371,7 @@ impl Game<'_> {
|
||||
creative: g.creative(),
|
||||
spectator: g.spectator(),
|
||||
alive: !g.dead,
|
||||
omen: omen_of(effects),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -376,6 +385,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `TrialSpawner.findPlayerWithOminousEffect`: Trial Omen first, then Bad Omen.
|
||||
pub(super) fn omen_of(
|
||||
effects: &horizon_client::effects::PlayerEffects,
|
||||
) -> Option<horizon_client::mobs::trial_spawner::Omen> {
|
||||
use horizon_client::mobs::trial_spawner::Omen;
|
||||
if effects.get("trial_omen").is_some() {
|
||||
Some(Omen::Trial)
|
||||
} else {
|
||||
effects.get("bad_omen").map(|_| Omen::Bad)
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest's bounding box, by its pose (`Avatar.POSES`).
|
||||
pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
|
||||
g.bounds()
|
||||
|
||||
@@ -217,6 +217,11 @@ impl Game<'_> {
|
||||
)),
|
||||
T::DragonBreath | T::SculkCharge => Some(ParticleData::Scalar(options.scale)),
|
||||
T::Shriek => Some(ParticleData::Delay(options.scale as i32)),
|
||||
T::Geyser | T::GeyserPlume => Some(ParticleData::Geyser(options.geyser?.0)),
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
let (water_blocks, burst_impulse_base) = options.geyser?;
|
||||
Some(ParticleData::GeyserBase(water_blocks, burst_impulse_base))
|
||||
}
|
||||
T::Item | T::Trail | T::Vibration => return None,
|
||||
_ => None,
|
||||
};
|
||||
@@ -266,6 +271,10 @@ impl Game<'_> {
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
Some(ParticleData::Delay(d)) => ParticleOptions::shriek(*d),
|
||||
Some(ParticleData::Geyser(water_blocks)) => ParticleOptions::geyser(ty, *water_blocks),
|
||||
Some(ParticleData::GeyserBase(water_blocks, burst_impulse_base)) => {
|
||||
ParticleOptions::geyser_base(ty, *water_blocks, *burst_impulse_base)
|
||||
}
|
||||
Some(ParticleData::Trail(target, color, duration)) => {
|
||||
ParticleOptions::trail(*target, *color as u32 & 0xFF_FFFF, *duration)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ impl Game<'_> {
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
{
|
||||
self.weapon_hit_player(Self::pvp_victim_on_ground(&host, Victim::Guest(conn), None));
|
||||
self.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
@@ -116,6 +117,23 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the player a hit landed on stands on the ground (the host's
|
||||
/// `ground` is passed in as `host_on_ground` when the host is the victim).
|
||||
pub(super) fn pvp_victim_on_ground(
|
||||
host: &LanHost,
|
||||
victim: Victim,
|
||||
host_on_ground: Option<bool>,
|
||||
) -> bool {
|
||||
match victim {
|
||||
Victim::Host => host_on_ground.unwrap_or(true),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref())
|
||||
.is_some_and(|p| p.ground.on_ground),
|
||||
}
|
||||
}
|
||||
|
||||
/// The player `id` that guest `conn` attacks, if it is one.
|
||||
pub(super) fn pvp_victim(host: &LanHost, conn: u32, id: i32) -> Option<Victim> {
|
||||
if id == host.host_entity {
|
||||
|
||||
@@ -237,9 +237,16 @@ impl Game<'_> {
|
||||
/// the knockback around the target.
|
||||
fn mace_hurt_enemy(&mut self, index: usize) {
|
||||
let target_on_ground = self.mobs[index].on_ground;
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(fall) = self.mace_smash_self(target_on_ground) {
|
||||
self.smash_knockback(index, fall);
|
||||
}
|
||||
}
|
||||
/// The attacker's side of `MaceItem.hurtEnemy` for a smash: the fall
|
||||
/// stops, its damage is waived from where the hit landed, and the ground
|
||||
/// or air smash sound plays. The fall distance it was, if there is a
|
||||
/// player.
|
||||
fn mace_smash_self(&mut self, target_on_ground: bool) -> Option<f64> {
|
||||
let p = self.player.as_mut()?;
|
||||
p.velocity = Vec3::new(p.velocity.x, f64::from(0.01f32), p.velocity.z);
|
||||
// A server player's client is told (ClientboundSetEntityMotionPacket): it moves by
|
||||
// itself, so without it a Wind Burst's push would add to the speed of the fall.
|
||||
@@ -263,7 +270,34 @@ impl Game<'_> {
|
||||
"minecraft:item.mace.smash_air"
|
||||
};
|
||||
self.audio.play(sound, Some(feet), 1.0, 1.0);
|
||||
self.smash_knockback(index, fall);
|
||||
Some(fall)
|
||||
}
|
||||
/// `Player.itemAttackInteraction` after the player's hit hurt another
|
||||
/// player: the weapon's side of it, as `weapon_hit` does for a mob. A
|
||||
/// smash stops the fall and its Wind Burst (a `post_attack` explosion on
|
||||
/// the attacker) launches the attacker.
|
||||
pub(super) fn weapon_hit_player(&mut self, victim_on_ground: bool) {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let smash = self.smash_attack();
|
||||
if smash {
|
||||
self.mace_smash_self(victim_on_ground);
|
||||
}
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:player");
|
||||
// MaceItem.postHurtEnemy
|
||||
if smash && let Some(p) = self.player.as_mut() {
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.weapon_wear(&stack);
|
||||
}
|
||||
/// `MaceItem.knockback`: the dust pillar (level event 2013) under the
|
||||
/// target, then every living entity within 3.5 blocks of it but the
|
||||
|
||||
@@ -62,6 +62,19 @@ impl CartKind {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// Whether this cart's `interact` passes on the client, so that
|
||||
/// `Minecraft.startUseItem` uses the held item instead: `MinecartTNT` has
|
||||
/// none of its own (`Entity.interact`), `MinecartCommandBlock.interact`
|
||||
/// needs a game master, and `Minecart.interact` a player not sneaking at
|
||||
/// an empty cart.
|
||||
pub fn interact_passes(self, sneaking: bool, ridden: bool, game_master: bool) -> bool {
|
||||
match self {
|
||||
Self::Tnt => true,
|
||||
Self::CommandBlock => !game_master,
|
||||
Self::Rideable => sneaking || ridden,
|
||||
Self::Chest | Self::Hopper | Self::Furnace => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Minecart {
|
||||
@@ -1598,6 +1611,20 @@ impl Minecart {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_tnt_cart_lets_the_held_item_be_used() {
|
||||
// Entity.interact passes: a crossbow aimed at it shoots.
|
||||
assert!(CartKind::Tnt.interact_passes(false, false, true));
|
||||
assert!(CartKind::CommandBlock.interact_passes(false, false, false));
|
||||
assert!(!CartKind::CommandBlock.interact_passes(false, false, true));
|
||||
assert!(!CartKind::Rideable.interact_passes(false, false, false));
|
||||
assert!(CartKind::Rideable.interact_passes(true, false, false));
|
||||
assert!(CartKind::Rideable.interact_passes(false, true, false));
|
||||
for kind in [CartKind::Chest, CartKind::Hopper, CartKind::Furnace] {
|
||||
assert!(!kind.interact_passes(true, true, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_offset_matches_vanilla_for_each_cart_kind() {
|
||||
assert_eq!(display_offset(CartKind::Hopper), 1.0);
|
||||
|
||||
@@ -186,7 +186,12 @@ impl Game<'_> {
|
||||
let tamed = m.extra.tame;
|
||||
let sound = if item == "minecraft:saddle" && tamed && !m.extra.saddled {
|
||||
m.set_saddle(held);
|
||||
Some("minecraft:entity.horse.saddle")
|
||||
// Camel.getSaddleSound (the equip sound of a camel's saddle).
|
||||
Some(match species {
|
||||
Species::Camel => "minecraft:entity.camel.saddle",
|
||||
Species::CamelHusk => "minecraft:entity.camel_husk.saddle",
|
||||
_ => "minecraft:entity.horse.saddle",
|
||||
})
|
||||
} else if item.ends_with("_horse_armor")
|
||||
&& species == Species::Horse
|
||||
&& tamed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Hook points for native mods (docs/native-mods.md, "Hook points"): empty
|
||||
//! Hook points for native mods (docs/native-mods.md, "Mods on the hook points"): empty
|
||||
//! functions the game calls at fixed places, for horizon-mixins bundles to
|
||||
//! hook with HEAD. Hooks that answer take an out parameter (`claimed`,
|
||||
//! `zoom`…) so several mods can hook the same point. A native mod keeps its
|
||||
|
||||
@@ -427,18 +427,30 @@ impl Game<'_> {
|
||||
if !alive || self.riding.is_some() || self.sleeping.is_some() {
|
||||
return;
|
||||
}
|
||||
// The client's LocalPlayer has the same cooldown (the Respawn
|
||||
// packet's spawn info): standing in the arrival portal, its overlay
|
||||
// fades instead of building up again.
|
||||
let cooling = self.portals.player.cooldown > 0;
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
self.portals.client_inside = true;
|
||||
if !cooling {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
/// The same on a server's client: `Entity.canUsePortal` (alive, not a
|
||||
/// passenger), then only the client's `setAsInsidePortal`, which drives
|
||||
/// the confusion effect (`client_portal_effect`).
|
||||
/// the confusion effect (`client_portal_effect`). On a server's client
|
||||
/// `portals.player` holds only the LocalPlayer's `portalCooldown`.
|
||||
pub(super) fn client_inside_nether_portal(&mut self) {
|
||||
let alive = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.health.dead_or_dying());
|
||||
if alive && self.riding.is_none() {
|
||||
if !alive || self.riding.is_some() {
|
||||
return;
|
||||
}
|
||||
if self.portals.player.cooldown > 0 {
|
||||
self.portals.player.cooldown = PLAYER_DELAY;
|
||||
} else {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,11 +181,23 @@ impl Game<'_> {
|
||||
.options
|
||||
.shortcut(&self.gui.atlas, shortcut, &mut self.clipboard)
|
||||
}
|
||||
pub(super) fn options_scroll(&mut self, amount: f32) {
|
||||
pub(super) fn options_scroll(&mut self, amount: f32) -> Result<()> {
|
||||
let framebuffer = self.renderer.as_ref().map_or([1280, 720], |r| {
|
||||
[r.config.width as i32, r.config.height as i32]
|
||||
});
|
||||
if self.ui.options.page() == Page::Video && self.has_control_down() {
|
||||
// VideoSettingsScreen.mouseScrolled: Ctrl with the wheel steps the GUI scale.
|
||||
let mut ui = std::mem::take(&mut self.ui.options);
|
||||
let events = ui.video_wheel_gui_scale(&mut self.settings, framebuffer, amount);
|
||||
self.ui.options = ui;
|
||||
return self.handle_option_events(events);
|
||||
}
|
||||
let size = self.gui_size();
|
||||
self.ui
|
||||
.options
|
||||
.scroll_at(&self.settings, size, self.cursor, amount);
|
||||
let events =
|
||||
self.ui
|
||||
.options
|
||||
.wheel(&mut self.settings, size, self.cursor, framebuffer, amount);
|
||||
self.handle_option_events(events)
|
||||
}
|
||||
/// Mouse buttons pressed while a key is being bound.
|
||||
pub(super) fn options_waiting_for_key(&self) -> bool {
|
||||
|
||||
@@ -477,6 +477,9 @@ impl Game<'_> {
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
horizon_client::mobs::camel::finish_load(&mut m, self.world.game_time);
|
||||
}
|
||||
// `NeutralMob.readPersistentAngerSaveData`: the anger runs until its
|
||||
// end time, and the mob is angry at the target again.
|
||||
m.extra.anger = horizon_client::mobs::anger_left(m.extra.anger_end, self.world.game_time);
|
||||
@@ -661,6 +664,13 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
if horizon_client::mobs::camel::is_camel(self.mobs[i].species())
|
||||
&& let Tag::Compound(c) = &mut tag
|
||||
{
|
||||
let tick =
|
||||
horizon_client::mobs::camel::synced_pose_tick(&self.mobs[i], self.world.game_time);
|
||||
c.insert("LastPoseTick".into(), Tag::Long(tick));
|
||||
}
|
||||
self.write_leash(&mut tag, i);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
|
||||
@@ -374,6 +374,7 @@ impl Game<'_> {
|
||||
let (yaw, pitch) = (self.yaw, self.pitch);
|
||||
let main = self.inventory[self.selected].clone();
|
||||
let off = self.offhand.as_ref().map(|s| s.item.clone());
|
||||
let armor = self.armor.clone();
|
||||
let riding = self.riding.is_some();
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
let avatar = self.avatar;
|
||||
@@ -434,7 +435,8 @@ impl Game<'_> {
|
||||
m.extra.chest = self.armor[1].as_ref().map(|s| s.item.clone());
|
||||
m.extra.legs = self.armor[2].as_ref().map(|s| s.item.clone());
|
||||
m.extra.feet = self.armor[3].as_ref().map(|s| s.item.clone());
|
||||
m.extra.armor = self.armor.clone();
|
||||
// The worn stacks, for the elytra's glint (`WingsLayer`).
|
||||
m.extra.armor = armor;
|
||||
m.extra.crouching = crouching;
|
||||
m.extra.passenger = riding;
|
||||
m.velocity = velocity;
|
||||
@@ -680,9 +682,26 @@ impl Game<'_> {
|
||||
m.extra.elytra.angles(partial),
|
||||
m.extra.crouching,
|
||||
light,
|
||||
self.wings_overlay(
|
||||
m.extra.armor[1]
|
||||
.as_ref()
|
||||
.filter(|s| chest == Some(&*s.item)),
|
||||
),
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `WingsLayer` draws through `EquipmentLayerRenderer.renderLayers`: the
|
||||
/// armour glint when the worn elytra `hasFoil()`.
|
||||
pub(super) fn wings_overlay(&self, stack: Option<&horizon_client::items::ItemStack>) -> u32 {
|
||||
let item_glint = |item: &str| {
|
||||
let props = &self.res.items().enchantments.props;
|
||||
props.get(item).is_some_and(|p| p.glint)
|
||||
};
|
||||
match stack {
|
||||
Some(s) if s.has_foil(item_glint(&s.item)) => horizon_client::mobs::render::ARMOR_GLINT,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// `WingsLayer.submit` for any wearer: the wings of `chest`'s equipment
|
||||
/// asset on `base` (the wearer's model transform), `baby` for the baby
|
||||
/// elytra model. `cape` is a player's own cape texture, which has its
|
||||
@@ -697,6 +716,7 @@ impl Game<'_> {
|
||||
angles: [f32; 3],
|
||||
crouching: bool,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(asset) = chest
|
||||
@@ -736,7 +756,7 @@ impl Game<'_> {
|
||||
},
|
||||
[rect.u0, rect.v0, rect.u1, rect.v1],
|
||||
light,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -1025,7 +1045,7 @@ mod preview_tests {
|
||||
return;
|
||||
};
|
||||
let m = Mob::new(1, kind.clone(), Vec3::ZERO, 0.0);
|
||||
let armour = [(&*kind.model, [0.5, 0.5, 0.75, 0.75], u32::MAX)];
|
||||
let armour = [(&*kind.model, [0.5, 0.5, 0.75, 0.75], u32::MAX, 0)];
|
||||
let atlas = |_: &str| Some([0.0, 0.0, 0.25, 0.25]);
|
||||
let data = horizon_client::mobs::MobData {
|
||||
states: Vec::new(),
|
||||
|
||||
@@ -223,10 +223,26 @@ impl Game<'_> {
|
||||
// Nuggets come whole, anything else one at a time.
|
||||
let (taken_item, id) = (e.clone(), self.mobs[i].id);
|
||||
let item = e.stack.item.clone();
|
||||
let carried = e.stack.clone();
|
||||
let owner = e.thrower;
|
||||
self.add_pickup(
|
||||
super::pickups::Taken::Item(Box::new(taken_item)),
|
||||
super::pickups::Collector::Mob(id),
|
||||
);
|
||||
// LivingEntity.onItemPickup, before PiglinAi.pickUpItem splits the stack.
|
||||
// The owner is the host or a guest: whoever it is gets the trigger.
|
||||
if let Some(owner) = owner {
|
||||
let target = self.mob_entity(&self.mobs[i]);
|
||||
self.for_each_player(|g| {
|
||||
if g.acting_uuid() == owner {
|
||||
g.trigger_item_entity(
|
||||
"minecraft:thrown_item_picked_up_by_entity",
|
||||
&carried,
|
||||
&target,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
let stack = if &*item == "minecraft:gold_nugget" {
|
||||
self.dropped.remove(j).stack
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Potent sulfur geysers: the tickers `PotentSulfurBlock.getTicker` gives
|
||||
//! `PotentSulfurBlockEntity` by the block's state. The server's make living
|
||||
//! entities near the gas nauseous, count a dormant or erupting geyser down to
|
||||
//! its next change of state and push the entities an eruption throws up. The
|
||||
//! client's draw the gas clouds and the eruption's plume, play its sound and
|
||||
//! push the local player, who moves on its own client and not on the server.
|
||||
use super::{
|
||||
Game,
|
||||
potions::{mob_can_be_affected, mob_effect_id},
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
block_entities::Pos,
|
||||
containers::BlockEntity,
|
||||
effects::EffectInstance,
|
||||
mobs::Mob,
|
||||
particles::{ParticleLevel, ParticleOptions, ParticleType as T},
|
||||
potent_sulfur::{
|
||||
can_be_reached_by_noxious_gas, find_noxious_gas_source_block, unobstructed_block_count,
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::{PositionalRandom, RandomSource, XoroshiroRandom},
|
||||
};
|
||||
use horizon_world::{level::flags, registry::BlockStateId};
|
||||
|
||||
/// `PotentSulfurBlockEntity.GEYSER_SALT`.
|
||||
const GEYSER_SALT: i64 = -904_011_478;
|
||||
/// `PotentSulfurBlockEntity.EFFECT_APPLICATION_FREQUENCY_TICKS`.
|
||||
const EFFECT_APPLICATION_FREQUENCY_TICKS: i64 = 10;
|
||||
/// `PotentSulfurBlockEntity.EFFECT_DURATION_IN_TICKS`.
|
||||
const EFFECT_DURATION_IN_TICKS: i32 = 80;
|
||||
/// `PotentSulfurBlockEntity.PARTICLE_FREQUENCY_TICKS`.
|
||||
const PARTICLE_FREQUENCY_TICKS: i64 = 20;
|
||||
/// `PotentSulfurBlockEntity.SOUND_FREQUENCY_TICKS`.
|
||||
const SOUND_FREQUENCY_TICKS: i64 = 40;
|
||||
/// `SoundEvents.GEYSER_ERUPTION_ACTIVE`.
|
||||
const GEYSER_ERUPTION_ACTIVE: &str = "minecraft:block.potent_sulfur.geyser_eruption_active";
|
||||
/// `SoundEvents.GEYSER_CONTINUOUS_ACTIVE`.
|
||||
const GEYSER_CONTINUOUS_ACTIVE: &str =
|
||||
"minecraft:block.potent_sulfur.geyser_continuous_eruption_active";
|
||||
|
||||
/// `PotentSulfurState`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Geyser {
|
||||
Dry,
|
||||
Wet,
|
||||
Dormant,
|
||||
Erupting,
|
||||
Continuous,
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.geyserPositional`: the random of the sulfur block
|
||||
/// at `pos`, forked from the level seed and the geyser salt.
|
||||
fn geyser_positional(seed: i64, pos: Pos) -> XoroshiroRandom {
|
||||
let mut base = XoroshiroRandom::new(seed ^ GEYSER_SALT);
|
||||
base.fork_positional()
|
||||
.at(BlockPos::new(pos.0, pos.1, pos.2))
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.getNearbyLivingEntities`: the box of the source
|
||||
/// block grown 2.5 blocks sideways.
|
||||
fn nearby_living_area(source: [i32; 3]) -> Aabb {
|
||||
let [x, y, z] = source.map(f64::from);
|
||||
Aabb::new(
|
||||
Vec3::new(x - 2.5, y, z - 2.5),
|
||||
Vec3::new(x + 1.0 + 2.5, y + 1.0, z + 1.0 + 2.5),
|
||||
)
|
||||
}
|
||||
|
||||
/// The box `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` pushes entities in:
|
||||
/// the block above the sulfur, `expandTowards` the unobstructed height.
|
||||
fn launch_area(pos: Pos, height: i32) -> Aabb {
|
||||
let (x, y, z) = (f64::from(pos.0), f64::from(pos.1 + 1), f64::from(pos.2));
|
||||
let up = f64::from(height - 1);
|
||||
let (min_y, max_y) = if up < 0.0 {
|
||||
(y + up, y + 1.0)
|
||||
} else {
|
||||
(y, y + 1.0 + up)
|
||||
};
|
||||
Aabb::new(Vec3::new(x, min_y, z), Vec3::new(x + 1.0, max_y, z + 1.0))
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.applyNauseaEffect` on a mob, if it can be
|
||||
/// affected.
|
||||
fn apply_nausea_effect_to_mob(
|
||||
mob: &mut Mob,
|
||||
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
|
||||
) {
|
||||
if mob_can_be_affected(tags, &mob.kind.id, "nausea")
|
||||
&& let Some(nausea) = mob_effect_id("nausea")
|
||||
{
|
||||
mob.add_effect(nausea, EFFECT_DURATION_IN_TICKS, 0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The `potent_sulfur_state` of the block at `pos`, with its block state.
|
||||
fn potent_sulfur_state(&self, pos: Pos) -> Option<(BlockStateId, Geyser)> {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let value = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)?
|
||||
.properties
|
||||
.get("potent_sulfur_state")?;
|
||||
let geyser = match value.as_str() {
|
||||
"dry" => Geyser::Dry,
|
||||
"wet" => Geyser::Wet,
|
||||
"dormant" => Geyser::Dormant,
|
||||
"erupting" => Geyser::Erupting,
|
||||
"continuous" => Geyser::Continuous,
|
||||
_ => return None,
|
||||
};
|
||||
Some((state, geyser))
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock` of the sulfur at
|
||||
/// `pos`.
|
||||
fn potent_sulfur_source(&self, pos: Pos) -> Option<[i32; 3]> {
|
||||
let level = ParticleLevel {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
};
|
||||
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlock.getTicker` on the server, for the block entity at
|
||||
/// `pos`: a wet block gives nausea; a dormant one counts down, then gives
|
||||
/// nausea; an erupting one pushes entities, then counts down; a continuous
|
||||
/// one pushes entities. A dry block has no ticker.
|
||||
pub(super) fn tick_potent_sulfur(&mut self, pos: Pos) -> Result<()> {
|
||||
let Some((state, geyser)) = self.potent_sulfur_state(pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
match geyser {
|
||||
Geyser::Dry => {}
|
||||
Geyser::Wet => self.server_nausea_effect_ticker(pos),
|
||||
Geyser::Dormant => {
|
||||
self.server_waiting_countdown_ticker(pos, state, geyser)?;
|
||||
self.server_nausea_effect_ticker(pos);
|
||||
}
|
||||
Geyser::Erupting => {
|
||||
self.launch_entity_ticker(pos);
|
||||
self.server_waiting_countdown_ticker(pos, state, geyser)?;
|
||||
}
|
||||
Geyser::Continuous => self.launch_entity_ticker(pos),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.SERVER_NAUSEA_EFFECT_TICKER`: every 10 ticks,
|
||||
/// the living entities near the source block that its gas reaches (at
|
||||
/// their eyes) get nausea for 4 seconds.
|
||||
fn server_nausea_effect_ticker(&mut self, pos: Pos) {
|
||||
if self.world.game_time % EFFECT_APPLICATION_FREQUENCY_TICKS != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(source) = self.potent_sulfur_source(pos) else {
|
||||
return;
|
||||
};
|
||||
let area = nearby_living_area(source);
|
||||
for player in self
|
||||
.fight_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive && p.bounds.intersects(area))
|
||||
{
|
||||
self.as_fight_player(player.who, |g| g.apply_nausea_effect_to_player(source));
|
||||
}
|
||||
let level = ParticleLevel {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
};
|
||||
let tags = &self.res.stations().enchantments.entity_tags;
|
||||
for mob in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.alive() && m.bounds().intersects(area))
|
||||
{
|
||||
let eye = [mob.position.x, mob.eye_y(), mob.position.z];
|
||||
if can_be_reached_by_noxious_gas(&level, source, eye) {
|
||||
apply_nausea_effect_to_mob(mob, tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.applyNauseaEffect` on the player acting (a
|
||||
/// guest is swapped in as the player) when the gas reaches their eyes;
|
||||
/// spectators are not affected.
|
||||
fn apply_nausea_effect_to_player(&mut self, source: [i32; 3]) {
|
||||
if self.spectator() {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let eye = [
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.eye_height),
|
||||
p.position.z,
|
||||
];
|
||||
let reached = {
|
||||
let level = ParticleLevel {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
};
|
||||
can_be_reached_by_noxious_gas(&level, source, eye)
|
||||
};
|
||||
if reached {
|
||||
// MobEffectInstance(NAUSEA, 80, 0, true, true).
|
||||
let mut nausea = EffectInstance::new("nausea", EFFECT_DURATION_IN_TICKS, 0);
|
||||
nausea.ambient = true;
|
||||
self.add_player_effect(nausea);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.SERVER_WAITING_COUNTDOWN_TICKER`: every second
|
||||
/// the countdown runs down; a dormant geyser waits `10 * (water - 1)` plus
|
||||
/// 15 to 30, an erupting one `water - 1` plus 1 or 2, and at zero the
|
||||
/// block turns erupting (dormant) or dormant (erupting). `state` is the
|
||||
/// block state the tick began with.
|
||||
fn server_waiting_countdown_ticker(
|
||||
&mut self,
|
||||
pos: Pos,
|
||||
state: BlockStateId,
|
||||
geyser: Geyser,
|
||||
) -> Result<()> {
|
||||
if self.world.game_time % 20 != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(source) = self.potent_sulfur_source(pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(BlockEntity::PotentSulfur(sulfur)) = self.world.block_entities.get_mut(&pos)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if sulfur.waiting_countdown <= 0 {
|
||||
let water_blocks = source[1] - pos.1 - 1;
|
||||
let mut positional = geyser_positional(self.level.seed, pos);
|
||||
sulfur.waiting_countdown = if geyser == Geyser::Dormant {
|
||||
10 * (water_blocks - 1)
|
||||
+ positional.next_int_between_inclusive(15, 30).unwrap_or(15)
|
||||
} else {
|
||||
positional.next_int();
|
||||
water_blocks - 1 + positional.next_int_between_inclusive(1, 2).unwrap_or(1)
|
||||
};
|
||||
}
|
||||
if sulfur.waiting_countdown > 0 {
|
||||
sulfur.waiting_countdown -= 1;
|
||||
}
|
||||
if sulfur.waiting_countdown != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let to_set = if geyser == Geyser::Dormant {
|
||||
"erupting"
|
||||
} else {
|
||||
"dormant"
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut next) = registry.state(state).cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
next.properties
|
||||
.insert("potent_sulfur_state".into(), to_set.into());
|
||||
let Some(next) = registry.id(&next) else {
|
||||
return Ok(());
|
||||
};
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, next, flags::UPDATE_ALL));
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
if to_set == "dormant" {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_deactivate",
|
||||
[pos.0, pos.1, pos.2],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
self.process_level_events()
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the server: mobs and
|
||||
/// items and primed TNT in the geyser's column with less than
|
||||
/// `0.3 + 0.1 * water` upward speed get 0.2 more, unless they ride
|
||||
/// something or are of a type geysers do not move. Players are moved by
|
||||
/// their own client.
|
||||
fn launch_entity_ticker(&mut self, pos: Pos) {
|
||||
let Some(source) = self.potent_sulfur_source(pos) else {
|
||||
return;
|
||||
};
|
||||
let water_blocks = source[1] - pos.1 - 1;
|
||||
let level = ParticleLevel {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
};
|
||||
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
|
||||
let area = launch_area(pos, height);
|
||||
let limit = f64::from(0.3f32) + f64::from(water_blocks) * 0.1;
|
||||
let push = f64::from(0.2f32);
|
||||
let tags = &self.res.stations().enchantments.entity_tags;
|
||||
let immune = tags.get("minecraft:not_affected_by_geysers");
|
||||
for mob in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.alive() && m.bounds().intersects(area))
|
||||
{
|
||||
// Entity.checkFallDistanceAccumulation.
|
||||
if mob.velocity.y > -0.5 && mob.fall_distance > 1.0 {
|
||||
mob.fall_distance = 1.0;
|
||||
}
|
||||
if !mob.extra.passenger
|
||||
&& !immune.is_some_and(|t| t.contains(&mob.kind.id))
|
||||
&& mob.velocity.y < limit
|
||||
{
|
||||
mob.velocity.y += push;
|
||||
}
|
||||
}
|
||||
for item in self
|
||||
.dropped
|
||||
.iter_mut()
|
||||
.filter(|i| i.bounds().intersects(area))
|
||||
{
|
||||
if item.velocity.y < limit {
|
||||
item.velocity.y += push;
|
||||
}
|
||||
}
|
||||
for tnt in self.tnts.iter_mut().filter(|t| t.bounds().intersects(area)) {
|
||||
if tnt.velocity.y < limit {
|
||||
tnt.velocity.y += push;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the client: the local
|
||||
/// player in the column, unless spectating, flying or riding.
|
||||
fn launch_local_player_ticker(&mut self, pos: Pos) {
|
||||
let Some(source) = self.potent_sulfur_source(pos) else {
|
||||
return;
|
||||
};
|
||||
let water_blocks = source[1] - pos.1 - 1;
|
||||
let height = {
|
||||
let level = ParticleLevel {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
};
|
||||
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
|
||||
};
|
||||
let area = launch_area(pos, height);
|
||||
let (spectator, riding) = (self.spectator(), self.riding.is_some());
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if spectator || p.health.dead_or_dying() || !p.bounds().intersects(area) {
|
||||
return;
|
||||
}
|
||||
// Entity.checkFallDistanceAccumulation.
|
||||
if p.velocity.y > -0.5 && p.fall_distance > 1.0 {
|
||||
p.fall_distance = 1.0;
|
||||
}
|
||||
if !p.flying && !riding && p.velocity.y < f64::from(0.3f32) + f64::from(water_blocks) * 0.1
|
||||
{
|
||||
p.velocity.y += f64::from(0.2f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.spawnGeyserParticle`: the eruption particle at
|
||||
/// the bottom of the source block, as high as the water column is deep.
|
||||
fn spawn_geyser_particle(&mut self, sulfur: Pos, source: [i32; 3]) {
|
||||
let water_blocks = source[1] - sulfur.1 - 1;
|
||||
self.particles.add(
|
||||
&ParticleOptions::geyser(T::Geyser, water_blocks),
|
||||
[
|
||||
f64::from(source[0]) + 0.5,
|
||||
f64::from(source[1]),
|
||||
f64::from(source[2]) + 0.5,
|
||||
],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.spawnNoxiousGasCloudParticle` at the centre of
|
||||
/// the source block.
|
||||
fn spawn_noxious_gas_cloud_particle(&mut self, source: [i32; 3]) {
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(T::NoxiousGasCloud),
|
||||
source.map(|c| f64::from(c) + 0.5),
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlock.getTicker` on the client, for the block entity at
|
||||
/// `pos`: a wet or dormant geyser releases gas clouds every second; an
|
||||
/// erupting or continuous one shows its plume every second (from its last
|
||||
/// eruption event), plays its sound every two, and pushes the local
|
||||
/// player.
|
||||
pub(super) fn client_tick_potent_sulfur(&mut self, pos: Pos) {
|
||||
let Some((_, geyser)) = self.potent_sulfur_state(pos) else {
|
||||
return;
|
||||
};
|
||||
let time = self.world.game_time;
|
||||
// PotentSulfurBlockEntity.setLevel.
|
||||
let eruption_tick = match self.world.block_entities.get_mut(&pos) {
|
||||
Some(BlockEntity::PotentSulfur(sulfur)) => {
|
||||
if sulfur.eruption_tick == -1 {
|
||||
sulfur.eruption_tick = time;
|
||||
}
|
||||
sulfur.eruption_tick
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
match geyser {
|
||||
Geyser::Dry => {}
|
||||
// CLIENT_NOXIOUS_GAS_TICKER.
|
||||
Geyser::Wet | Geyser::Dormant => {
|
||||
if time % PARTICLE_FREQUENCY_TICKS == 0
|
||||
&& let Some(source) = self.potent_sulfur_source(pos)
|
||||
{
|
||||
self.spawn_noxious_gas_cloud_particle(source);
|
||||
}
|
||||
}
|
||||
// CLIENT_GEYSER_PLUME_TICKER, then LAUNCH_ENTITY_TICKER.
|
||||
Geyser::Erupting | Geyser::Continuous => {
|
||||
if let Some(source) = self.potent_sulfur_source(pos) {
|
||||
let eruption_time = time - eruption_tick;
|
||||
if eruption_time % PARTICLE_FREQUENCY_TICKS == 0 {
|
||||
self.spawn_geyser_particle(pos, source);
|
||||
}
|
||||
if eruption_time % SOUND_FREQUENCY_TICKS == 0 {
|
||||
let sound = if geyser == Geyser::Erupting {
|
||||
GEYSER_ERUPTION_ACTIVE
|
||||
} else {
|
||||
GEYSER_CONTINUOUS_ACTIVE
|
||||
};
|
||||
let at = source.map(|c| f64::from(c) + 0.5);
|
||||
// playLocalSound: guests play their own.
|
||||
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
self.launch_local_player_ticker(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientLevel.tickBlockEntities` for the potent sulfur of a server's
|
||||
/// level: every one the client knows ticks.
|
||||
pub(super) fn tick_remote_potent_sulfur(&mut self) {
|
||||
let keys: Vec<Pos> = self
|
||||
.world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter_map(|(k, e)| matches!(e, BlockEntity::PotentSulfur(_)).then_some(*k))
|
||||
.collect();
|
||||
for key in keys {
|
||||
self.client_tick_potent_sulfur(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlock.triggerEvent`: the eruption block event stamps the
|
||||
/// game time the plume counts from; false when `pos` holds no potent
|
||||
/// sulfur block entity.
|
||||
pub(super) fn potent_sulfur_eruption_event(&mut self, pos: Pos) -> bool {
|
||||
let time = self.world.game_time;
|
||||
match self.world.block_entities.get_mut(&pos) {
|
||||
Some(BlockEntity::PotentSulfur(sulfur)) => {
|
||||
sulfur.eruption_tick = time;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::{
|
||||
dragon::{Breath, FightPlayer, FightWho},
|
||||
item_draw::owner_at,
|
||||
remote_projectiles::cloud_client_tick,
|
||||
splash_water::thrown_block_hit,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
@@ -90,7 +91,7 @@ pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
|
||||
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
|
||||
/// decide first, then the tag of mobs that ignore poison and regeneration.
|
||||
fn mob_can_be_affected(
|
||||
pub(super) fn mob_can_be_affected(
|
||||
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
|
||||
kind: &str,
|
||||
effect: &str,
|
||||
@@ -781,6 +782,9 @@ impl Game<'_> {
|
||||
p.status = status;
|
||||
}
|
||||
}
|
||||
/// `ThrowableProjectile.tick` for the potions in flight: the motion, then
|
||||
/// the first block or mob the step meets (`ProjectileUtil.getHitResultOnMoveVector`),
|
||||
/// where the potion splashes (`Projectile.onHit`).
|
||||
fn tick_thrown_potions(&mut self) {
|
||||
let mut thrown = std::mem::take(&mut self.potions.thrown);
|
||||
let mut landed = Vec::new();
|
||||
@@ -814,20 +818,25 @@ impl Game<'_> {
|
||||
&& p.y <= m.position.y + m.kind.height
|
||||
});
|
||||
if self.res.facts().get(state).full_collision || mob.is_some() {
|
||||
hit = Some((p, mob.map(|m| m.position)));
|
||||
// A block hit has the face it came in by
|
||||
// (`onHitBlock`); a mob hit has none.
|
||||
let block = mob
|
||||
.is_none()
|
||||
.then(|| thrown_block_hit(t.position, p, t.velocity));
|
||||
hit = Some((p, mob.map(|m| m.position), block));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((p, mob)) = hit {
|
||||
landed.push((t.stack.clone(), p, mob, t.age, t.owner.clone()));
|
||||
if let Some((p, mob, block)) = hit {
|
||||
landed.push((t.stack.clone(), p, mob, block, t.age, t.owner.clone()));
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at, mob, age, owner) in landed {
|
||||
self.splash(&stack, at, mob, age, owner);
|
||||
for (stack, at, mob, block, age, owner) in landed {
|
||||
self.splash(&stack, at, mob, block, age, owner);
|
||||
}
|
||||
}
|
||||
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
|
||||
@@ -1128,13 +1137,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
|
||||
/// for a lingering potion; `mob` is where it hit a mob, `age` its age.
|
||||
/// `AbstractThrownPotion.onHit` with `ThrownSplashPotion.onHitAsPotion`, or
|
||||
/// `ThrownLingeringPotion.onHitAsPotion` for a lingering potion, read from
|
||||
/// the stack's `potion_contents`; `mob` is where it hit a mob, `hit` the
|
||||
/// block and face it hit, `age` its age.
|
||||
fn splash(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
at: Vec3,
|
||||
mob: Option<Vec3>,
|
||||
hit: Option<([i32; 3], Direction)>,
|
||||
age: u32,
|
||||
owner: Option<Text>,
|
||||
) {
|
||||
@@ -1149,6 +1161,12 @@ impl Game<'_> {
|
||||
self.spawn_orbs([at.x, at.y, at.z], value as u32);
|
||||
return;
|
||||
}
|
||||
// Projectile.onHit calls onHitBlock first, then the potion's
|
||||
// affectEntitiesAround; both read the stack's `potion_contents`.
|
||||
if let Some((cell, face)) = hit {
|
||||
self.potion_hit_block(stack, cell, face);
|
||||
}
|
||||
self.affect_entities_around(stack, at, owner.is_some());
|
||||
let lingering = &*stack.item == "minecraft:lingering_potion";
|
||||
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
|
||||
// potion with instant effects, 2002 and 1053 otherwise.
|
||||
@@ -1161,6 +1179,33 @@ impl Game<'_> {
|
||||
let color = brewing::potion_color(stack.potion());
|
||||
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
|
||||
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
|
||||
// AbstractThrownPotion.affectEntitiesAround: a water potion (the only
|
||||
// #rehydrates_axolotls) refills the air of the axolotls in the
|
||||
// projectile's box inflated by 4, 2 and 4 (Axolotl.rehydrate, 1800).
|
||||
if stack.potion() == Some("minecraft:water") {
|
||||
let (lo, hi) = (
|
||||
[at.x - 0.125 - 4.0, at.y - 2.0, at.z - 0.125 - 4.0],
|
||||
[at.x + 0.125 + 4.0, at.y + 0.25 + 2.0, at.z + 0.125 + 4.0],
|
||||
);
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.species() == horizon_client::mobs::Species::Axolotl && !m.removed)
|
||||
{
|
||||
let (w, h, _) = m.dims();
|
||||
let half = w / 2.0;
|
||||
let (x, y, z) = (m.position.x, m.position.y, m.position.z);
|
||||
if x - half < hi[0]
|
||||
&& x + half > lo[0]
|
||||
&& y < hi[1]
|
||||
&& y + h > lo[1]
|
||||
&& z - half < hi[2]
|
||||
&& z + half > lo[2]
|
||||
{
|
||||
m.air = (m.air + 1800).min(horizon_client::mobs::axolotl::MAX_AIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(def) = potion else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// Entity.playEntityOnFireExtinguishedSound
|
||||
fn extinguish_sound(&mut self, at: [f64; 3]) {
|
||||
pub(super) fn extinguish_sound(&mut self, at: [f64; 3]) {
|
||||
let r = self.particles.random();
|
||||
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
|
||||
self.audio.play(
|
||||
|
||||
@@ -1101,6 +1101,20 @@ impl Game<'_> {
|
||||
let calls = screen::ghost(self.swap_index(), ox as i32, oy as i32, big_result, &ghosts);
|
||||
self.paint_book(batch, &calls);
|
||||
}
|
||||
/// `GhostSlots.extractTooltip`: the stack a ghost shows on menu slot
|
||||
/// `slot` now, cycling like its draw; only while the open book is visible
|
||||
/// (`RecipeBookComponent.extractTooltip`).
|
||||
pub(super) fn ghost_stack(&self, slot: usize) -> Option<ItemStack> {
|
||||
if !self.book_open(self.active_book()?) {
|
||||
return None;
|
||||
}
|
||||
let ghost = self
|
||||
.recipe_ui
|
||||
.ghost
|
||||
.iter()
|
||||
.find(|g| g.slot == slot && !g.items.is_empty())?;
|
||||
Some(ghost.items[self.swap_index() % ghost.items.len()].clone())
|
||||
}
|
||||
/// `RecipeBookComponent.extractRenderState` and `extractTooltip`, laid
|
||||
/// out by `horizon_client::recipe_book_screen`.
|
||||
fn draw_book_panel(&mut self, batch: &mut HudBatch, book_type: BookType) {
|
||||
|
||||
@@ -177,6 +177,10 @@ pub(super) struct Remote {
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
pub chat_received: u64,
|
||||
/// `ChatListener.delayedMessageQueue`: player chat held back by Chat Delay.
|
||||
chat_queue: VecDeque<(horizon_client::text::Text, horizon_client::chat::ChatSource)>,
|
||||
/// `ChatListener.previousMessageTime`: when a message last showed, if one has.
|
||||
chat_previous: Option<Instant>,
|
||||
/// The server's command tree (ClientboundCommandsPacket).
|
||||
pub commands: Option<horizon_client::command::Dispatcher>,
|
||||
players: HashMap<u128, String>,
|
||||
@@ -195,6 +199,10 @@ pub(super) struct Remote {
|
||||
/// The profile name each player entity was created with (`RemotePlayer.getGameProfile`): it outlives the
|
||||
/// player-list entry, which servers remove to hide their NPCs from the list.
|
||||
level_player_names: HashMap<i32, String>,
|
||||
/// The `textures` each player entity found in the player list first
|
||||
/// (`AbstractClientPlayer.getPlayerInfo` keeps that `PlayerInfo`): its skin
|
||||
/// outlives the entry, which NPC plugins remove a moment after spawning.
|
||||
level_player_textures: HashMap<i32, horizon_client::skins::Textures>,
|
||||
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
|
||||
/// theirs in `Mob::glowing`.
|
||||
glowing: std::collections::HashSet<i32>,
|
||||
@@ -507,6 +515,8 @@ impl Game<'_> {
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
chat_queue: VecDeque::new(),
|
||||
chat_previous: None,
|
||||
commands: None,
|
||||
players: HashMap::new(),
|
||||
tab_players: HashMap::new(),
|
||||
@@ -516,6 +526,7 @@ impl Game<'_> {
|
||||
online_mode: false,
|
||||
level_players: HashMap::new(),
|
||||
level_player_names: HashMap::new(),
|
||||
level_player_textures: HashMap::new(),
|
||||
glowing: Default::default(),
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
@@ -757,9 +768,9 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
content: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
) {
|
||||
) -> bool {
|
||||
if !self.ui.chat.shows(source) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let text = content.plain();
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
@@ -781,6 +792,98 @@ impl Game<'_> {
|
||||
r.chat.remove(0);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `ChatListener.handleMessage` for player and disguised chat: held in the
|
||||
/// queue while Chat Delay is set, shown at once otherwise.
|
||||
pub(super) fn handle_player_chat(&mut self, line: horizon_client::text::Text) {
|
||||
let source = horizon_client::chat::ChatSource::Player;
|
||||
if self.chat_delay_ms() > 0 {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_queue.push_back((line, source));
|
||||
}
|
||||
} else {
|
||||
self.accept_chat(line, source);
|
||||
}
|
||||
}
|
||||
|
||||
/// A message's `accept`: shown, it starts the Chat Delay wait again.
|
||||
fn accept_chat(
|
||||
&mut self,
|
||||
line: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
) -> bool {
|
||||
let shown = self.chat_line(line, source);
|
||||
if shown && let Some(r) = self.remote.as_mut() {
|
||||
r.chat_previous = Some(Instant::now());
|
||||
}
|
||||
shown
|
||||
}
|
||||
|
||||
/// `ChatListener.messageDelay`: Chat Delay in milliseconds, as Java
|
||||
/// truncates it.
|
||||
fn chat_delay_ms(&self) -> i64 {
|
||||
(self.settings.chat_delay * 1000.0) as i64
|
||||
}
|
||||
|
||||
/// `ChatListener.tick`: while paused the wait does not run down; with no
|
||||
/// Chat Delay the queue flushes; otherwise the next message the chat takes
|
||||
/// shows once the delay has passed since the last shown one.
|
||||
pub(super) fn tick_chat_queue(&mut self) {
|
||||
let delay = self.chat_delay_ms();
|
||||
// Minecraft.isPaused: a single-player pausing screen, not every pause menu.
|
||||
if self.pause {
|
||||
if delay > 0
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.chat_previous = r.chat_previous.map(|t| t + Duration::from_millis(50));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if delay == 0 {
|
||||
if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| !r.chat_queue.is_empty())
|
||||
{
|
||||
self.flush_chat_queue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let ready = self.remote.as_ref().is_some_and(|r| {
|
||||
r.chat_previous
|
||||
.is_none_or(|t| t.elapsed() >= Duration::from_millis(delay as u64))
|
||||
});
|
||||
if !ready {
|
||||
return;
|
||||
}
|
||||
// A message the Chat option hides is dropped and the next one is tried,
|
||||
// until one shows.
|
||||
while let Some((line, source)) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
|
||||
{
|
||||
if self.accept_chat(line, source) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatListener.flushQueue`: every held-back message shows now, and the
|
||||
/// delay starts over.
|
||||
fn flush_chat_queue(&mut self) {
|
||||
let Some(queue) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.chat_queue))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (line, source) in queue {
|
||||
self.chat_line(line, source);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_previous = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The minecarts the server sent, for the control socket's `status`
|
||||
@@ -1158,6 +1261,7 @@ impl Game<'_> {
|
||||
// player entities re-map to their profiles on AddEntity.
|
||||
r.level_players.clear();
|
||||
r.level_player_names.clear();
|
||||
r.level_player_textures.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
r.chunk_center = (0, 0);
|
||||
}
|
||||
@@ -1176,6 +1280,9 @@ impl Game<'_> {
|
||||
// The seed in SpawnInfo is already the hashed one the client's BiomeManager holds.
|
||||
self.streaming.biome_zoom = horizon_core::biome::BiomeZoom::new(spawn.seed);
|
||||
self.player = None;
|
||||
// handleLogin / handleRespawn: LocalPlayer.setPortalCooldown, so a
|
||||
// player who arrives standing in a portal gets no overlay from it.
|
||||
self.portals.player.cooldown = spawn.portal_cooldown;
|
||||
self.apply_settings();
|
||||
}
|
||||
|
||||
@@ -1282,6 +1389,15 @@ impl Game<'_> {
|
||||
self.command_block_data_arrived(key);
|
||||
}
|
||||
}
|
||||
// `handleBlockEntityData`: a command block's data arriving while its
|
||||
// edit screen is open refreshes the screen.
|
||||
if self
|
||||
.command_block_screen
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.pos == key)
|
||||
{
|
||||
self.update_command_block_screen();
|
||||
}
|
||||
}
|
||||
/// `handleAnimate`: a player wakes up, or an entity gets the critical
|
||||
/// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter.
|
||||
@@ -1592,6 +1708,18 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A fishing hook keeps a LinearInterpolationHandler: eased towards the
|
||||
// update over three ticks, on top of its own movement (`tick_remote_hooks`).
|
||||
if path.is_some() {
|
||||
if let Some(h) = self.hook.as_mut().filter(|h| h.id() == id as u32) {
|
||||
h.move_to(position, on_ground);
|
||||
return;
|
||||
}
|
||||
if let Some(h) = self.remote_hooks.iter_mut().find(|h| h.id() == id as u32) {
|
||||
h.move_to(position, on_ground);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A thrown trident is an arrow: placed at once, flown on by its tick.
|
||||
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
|
||||
if path.is_some() {
|
||||
@@ -1921,6 +2049,7 @@ impl Game<'_> {
|
||||
})
|
||||
.clamp(-90.0, 90.0);
|
||||
}
|
||||
C::PlayerLookAt(p) => self.remote_look_at(&p),
|
||||
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
|
||||
C::LightUpdate(p) => self.remote_light_update(&p),
|
||||
C::ForgetLevelChunk(p) => {
|
||||
@@ -2029,6 +2158,8 @@ impl Game<'_> {
|
||||
let rotation =
|
||||
[p.yaw, p.pitch, p.head_yaw].map(horizon_protocol::buf::unpack_degrees);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// A new entity looks its PlayerInfo up afresh.
|
||||
r.level_player_textures.remove(&p.id);
|
||||
if kind == "minecraft:player" {
|
||||
r.level_players.insert(p.id, p.uuid);
|
||||
// createEntityFromPacket: the player takes the profile of its list entry.
|
||||
@@ -2067,6 +2198,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.level_players.remove(&id);
|
||||
r.level_player_names.remove(&id);
|
||||
r.level_player_textures.remove(&id);
|
||||
}
|
||||
self.remove_entity(id);
|
||||
}
|
||||
@@ -2525,6 +2657,13 @@ impl Game<'_> {
|
||||
{
|
||||
horizon_client::mobs::aquatic::squid_cycle_event(m);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 55: another player swapped
|
||||
// its main and off hand (`swapHandItems`).
|
||||
if p.event == 55
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
|
||||
}
|
||||
// EntityEvent.DEATH: start the death animation.
|
||||
if p.event == 3
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
@@ -2944,12 +3083,12 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line, horizon_client::chat::ChatSource::Player);
|
||||
self.handle_player_chat(line);
|
||||
}
|
||||
C::DisguisedChat(p) => {
|
||||
let content = horizon_client::scoreboard::tag_to_json(&p.message);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line, horizon_client::chat::ChatSource::Player);
|
||||
self.handle_player_chat(line);
|
||||
}
|
||||
C::PlayerInfoUpdate(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -3353,15 +3492,16 @@ impl Game<'_> {
|
||||
/// never in the tab list: resolve each profile's textures and give the
|
||||
/// level's player models their skin.
|
||||
fn update_remote_skins(&mut self) {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut entries = skin_entries(
|
||||
&r.level_players,
|
||||
&r.skin_textures,
|
||||
&mut r.level_player_textures,
|
||||
);
|
||||
let r = &*r;
|
||||
let local = r.profile;
|
||||
let mut entries: Vec<(u128, horizon_client::skins::Textures)> = r
|
||||
.skin_textures
|
||||
.iter()
|
||||
.map(|(uuid, textures)| (*uuid, textures.clone()))
|
||||
.collect();
|
||||
// Minecraft.profileFuture: the signed-in account's own profile, fetched from the
|
||||
// services, gives the local player its skin where the server sends none (single
|
||||
// player's offline-mode server).
|
||||
@@ -3700,6 +3840,7 @@ impl Game<'_> {
|
||||
t.velocity = v;
|
||||
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
|
||||
r.velocity = v;
|
||||
} else if self.remote_hook_motion(id as u32, v) {
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
@@ -3753,6 +3894,34 @@ impl Game<'_> {
|
||||
handler.cancel();
|
||||
}
|
||||
}
|
||||
/// `ClientPacketListener.handleLookAt`: `Entity.lookAt` from this
|
||||
/// player's feet or eyes, towards the packet's entity (its feet or eyes)
|
||||
/// when this client tracks it, else towards the packet's point.
|
||||
fn remote_look_at(&mut self, p: &play::PlayerLookAt) {
|
||||
// `EntityAnchorArgument.Anchor`: 0 feet, 1 eyes.
|
||||
const EYES: i32 = 1;
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let mut from = [player.position.x, player.position.y, player.position.z];
|
||||
if p.from == EYES {
|
||||
from[1] += f64::from(player.dimensions.eye_height);
|
||||
}
|
||||
let to = p
|
||||
.entity
|
||||
.as_ref()
|
||||
.and_then(|e| {
|
||||
let m = self.mobs.iter().find(|m| m.id == e.id as u32)?;
|
||||
let y = if e.anchor == EYES {
|
||||
m.eye_y()
|
||||
} else {
|
||||
m.position.y
|
||||
};
|
||||
Some([m.position.x, y, m.position.z])
|
||||
})
|
||||
.unwrap_or(p.target);
|
||||
(self.yaw, self.pitch) = look_at_rotation(from, to);
|
||||
}
|
||||
/// ClientPacketListener.handleMovePlayer.
|
||||
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
|
||||
use play::relative as rel;
|
||||
@@ -3915,6 +4084,7 @@ impl Game<'_> {
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_remote_spawners();
|
||||
self.client_tick_beacons();
|
||||
self.tick_remote_potent_sulfur();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
@@ -4129,6 +4299,21 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
}
|
||||
// LivingEntity.causeFallDamage on the client's own level: the
|
||||
// fall sound is heard here (LocalPlayer.playSound); the
|
||||
// server's copy skips this player (Player.playSound).
|
||||
horizon_client::motion::MotionEvent::Fall(
|
||||
horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
if let Some(name) = sound.player_fall_sound() {
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -4451,6 +4636,12 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Chicken {
|
||||
horizon_client::mobs::farm::chicken_flap(m);
|
||||
}
|
||||
// Parrot.calculateFlapping: the wings follow the server's
|
||||
// on-ground flag and the shoulder it sits on (the glide is the
|
||||
// server's).
|
||||
if m.species() == horizon_client::mobs::Species::Parrot {
|
||||
horizon_client::mobs::parrot::flap(m);
|
||||
}
|
||||
// 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).
|
||||
@@ -4500,7 +4691,7 @@ impl Game<'_> {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
// Squid.aiStep follows LivingEntity's interpolation and head turn.
|
||||
horizon_client::mobs::aquatic::squid_client_step(m);
|
||||
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
|
||||
// WalkAnimationState.update from the horizontal distance moved.
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
|
||||
@@ -4755,6 +4946,9 @@ impl Game<'_> {
|
||||
|
||||
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
|
||||
fn remote_mining(&mut self, start: bool) {
|
||||
let debug_stick = self
|
||||
.held()
|
||||
.is_some_and(|stack| &*stack.item == "minecraft:debug_stick");
|
||||
if !self
|
||||
.res
|
||||
.items()
|
||||
@@ -4787,7 +4981,11 @@ impl Game<'_> {
|
||||
}
|
||||
if self.creative {
|
||||
let seq = self.next_sequence();
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
// DebugStickItem.canDestroyBlock never lets the client break the
|
||||
// block; the server only selects the property on this START.
|
||||
if !debug_stick {
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
}
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: hit.pos,
|
||||
@@ -4837,7 +5035,9 @@ impl Game<'_> {
|
||||
self.tutorial_destroy(state, 0.0);
|
||||
if progress >= 1.0 {
|
||||
// startDestroyBlock: instant survival breaks have no destroy delay.
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
if !debug_stick {
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -4862,7 +5062,11 @@ impl Game<'_> {
|
||||
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
|
||||
if done >= 1.0 {
|
||||
let seq = self.next_sequence();
|
||||
self.predict_break(pos, state, seq);
|
||||
// DebugStickItem.canDestroyBlock: the server selects the property
|
||||
// and sends the block update instead; never predict a break.
|
||||
if !debug_stick {
|
||||
self.predict_break(pos, state, seq);
|
||||
}
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 3,
|
||||
pos,
|
||||
@@ -4967,16 +5171,31 @@ impl Game<'_> {
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
// The main hand's turn takes it; there is no off hand turn.
|
||||
if hand == Hand::Off {
|
||||
// A cart whose interact passes (a TNT minecart) leaves each hand's
|
||||
// turn to its item: a crossbow aimed at it shoots.
|
||||
let passes = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == hit.id)
|
||||
.is_some_and(|c| {
|
||||
let ridden = self.riding == Some(c.id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(c.id));
|
||||
c.kind
|
||||
.interact_passes(sneaking, ridden, self.can_use_game_master_blocks())
|
||||
});
|
||||
// Otherwise the main hand's turn takes it; there is no off hand turn.
|
||||
if hand == Hand::Off && !passes {
|
||||
return false;
|
||||
}
|
||||
self.send(S::Interact(play::Interact {
|
||||
id: hit.id,
|
||||
hand: 0,
|
||||
hand: hand_id(hand),
|
||||
location: hit.location,
|
||||
secondary: sneaking,
|
||||
}));
|
||||
if passes {
|
||||
return false;
|
||||
}
|
||||
self.swing_arm();
|
||||
return true;
|
||||
}
|
||||
@@ -5030,6 +5249,9 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|h| self.res.items().get(&h.item))
|
||||
.is_some_and(|i| i.block.is_some());
|
||||
let debug_stick = held
|
||||
.as_ref()
|
||||
.is_some_and(|stack| &*stack.item == "minecraft:debug_stick");
|
||||
if let Some(hit) = self.pick() {
|
||||
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
|
||||
// without predicting the inventory swap or swinging.
|
||||
@@ -5056,6 +5278,13 @@ impl Game<'_> {
|
||||
};
|
||||
(result, DryUse::default())
|
||||
};
|
||||
// DebugStickItem.useOn once the block passed: SUCCESS on the client
|
||||
// too (the hand swings below); only the server changes the block.
|
||||
let (result, changes) = if debug_stick && !spectator && !result.consumes_action() {
|
||||
(InteractionResult::Success, DryUse::default())
|
||||
} else {
|
||||
(result, changes)
|
||||
};
|
||||
let reacts = result.consumes_action();
|
||||
// ArmorStandItem.useOn runs in startPrediction before the packet
|
||||
// goes out (the server may spawn the stand at once).
|
||||
@@ -5115,6 +5344,16 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} else if held
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:bone_meal")
|
||||
&& self.remote_bone_meal_succeeds(&hit)
|
||||
{
|
||||
// BoneMealItem.useOn succeeded on this client's level (SUCCESS;
|
||||
// the server grows the plant): this hand swings and the click
|
||||
// ends, so the other hand (a torch) is never tried.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if stand_fits {
|
||||
// ArmorStandItem.useOn on a client level: SUCCESS once the
|
||||
// stand fits, before the server spawns it. This hand swings
|
||||
@@ -5162,6 +5401,7 @@ impl Game<'_> {
|
||||
/// `playSound(player, ...)`: the server sends them to the others only):
|
||||
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
|
||||
/// block (stripping, scraping, removing wax, tilling, making a path).
|
||||
/// A cushion succeeds where it may be placed and spawns on the server.
|
||||
/// Whether the item's use succeeded, which ends the click.
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
|
||||
let at = [
|
||||
@@ -5189,11 +5429,30 @@ impl Game<'_> {
|
||||
self.transform_particle(&particle, hit.pos);
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
true
|
||||
} else if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.cushion_color.is_some())
|
||||
{
|
||||
// CushionItem.useOn on a client level: SUCCESS once
|
||||
// `canBePlacedAt` holds, before the server's own checks.
|
||||
self.cushion_spawn(hit).is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `BoneMealItem.useOn` on this client's level (a copy: nothing grows
|
||||
/// here): whether it succeeds.
|
||||
fn remote_bone_meal_succeeds(&mut self, hit: &super::Hit) -> bool {
|
||||
let pos = horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let face = horizon_core::geometry::Direction::ALL[face_direction(hit.face) as usize];
|
||||
self.with_level(|b, level, _| {
|
||||
b.bone_meal_succeeds_on_client(&mut DryLevel::new(level), pos, face)
|
||||
})
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
|
||||
/// use changed on this client's level at once, each kept as predicted
|
||||
/// under the use's sequence until the server acknowledges it (a use the
|
||||
@@ -5262,6 +5521,12 @@ impl Game<'_> {
|
||||
{
|
||||
return (InteractionResult::Success, DryUse::default());
|
||||
}
|
||||
// ComposterBlock.useItemOn: a compostable item on a composter below
|
||||
// level 8 is SUCCESS on the client too (only the server adds the
|
||||
// layer), so the other hand (a torch) is never tried.
|
||||
if self.composter_takes(hit.pos, held) {
|
||||
return (InteractionResult::Success, DryUse::default());
|
||||
}
|
||||
use horizon_core::geometry::Direction;
|
||||
let face = match hit.face {
|
||||
[0, 1, 0] => Direction::Up,
|
||||
@@ -5890,7 +6155,7 @@ impl Game<'_> {
|
||||
match key {
|
||||
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
|
||||
// ChatScreen, over the server's command tree.
|
||||
self.open_chat(if key == KeyCode::Slash { "/" } else { "" });
|
||||
self.open_chat_method(if key == KeyCode::Slash { "/" } else { "" });
|
||||
Ok(true)
|
||||
}
|
||||
KeyCode::KeyQ => {
|
||||
@@ -5920,6 +6185,7 @@ impl Game<'_> {
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
self.refresh_saved_hotbars();
|
||||
} else {
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
@@ -6628,6 +6894,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.lookAt`'s rotation from `from` towards `to`, as (yaw, pitch).
|
||||
fn look_at_rotation(from: [f64; 3], to: [f64; 3]) -> (f32, f32) {
|
||||
use horizon_core::geometry::trig::atan2;
|
||||
let (dx, dy, dz) = (to[0] - from[0], to[1] - from[1], to[2] - from[2]);
|
||||
let horizontal = (dx * dx + dz * dz).sqrt();
|
||||
// `* 180.0F / (float)Math.PI` on the double.
|
||||
let degrees = |r: f64| r * 180.0 / f64::from(std::f32::consts::PI);
|
||||
let pitch = wrap_degrees((-degrees(atan2(dy, horizontal))) as f32);
|
||||
let yaw = wrap_degrees(degrees(atan2(dz, dx)) as f32 - 90.0);
|
||||
(yaw, pitch)
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(a: f32) -> f32 {
|
||||
let a = a % 360.0;
|
||||
@@ -6954,6 +7232,12 @@ impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_
|
||||
}
|
||||
|
||||
impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.0.tick_hit = Some(self.0.pick());
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.0.tick_hit = None;
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
self.0.using.is_some()
|
||||
}
|
||||
@@ -7034,6 +7318,34 @@ impl Remote {
|
||||
}
|
||||
}
|
||||
|
||||
/// The textures to resolve skins from: every list entry's, and for a player
|
||||
/// entity whose entry is gone the profile it kept. `kept` first takes, for each
|
||||
/// player entity without one, its entry's textures (`AbstractClientPlayer.getPlayerInfo`
|
||||
/// looks the PlayerInfo up once and holds it, removed from the list or not).
|
||||
fn skin_entries(
|
||||
level_players: &HashMap<i32, u128>,
|
||||
listed: &HashMap<u128, horizon_client::skins::Textures>,
|
||||
kept: &mut HashMap<i32, horizon_client::skins::Textures>,
|
||||
) -> Vec<(u128, horizon_client::skins::Textures)> {
|
||||
for (id, uuid) in level_players {
|
||||
if !kept.contains_key(id)
|
||||
&& let Some(textures) = listed.get(uuid)
|
||||
{
|
||||
kept.insert(*id, textures.clone());
|
||||
}
|
||||
}
|
||||
let mut entries: Vec<_> = listed.iter().map(|(u, t)| (*u, t.clone())).collect();
|
||||
for (id, textures) in kept.iter() {
|
||||
if let Some(uuid) = level_players.get(id)
|
||||
&& !listed.contains_key(uuid)
|
||||
&& !entries.iter().any(|(u, _)| u == uuid)
|
||||
{
|
||||
entries.push((*uuid, textures.clone()));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// `AttributeInstance.calculateValue` before the attribute's range: the base,
|
||||
/// `ADD_VALUE`s, then `ADD_MULTIPLIED_BASE`s of that, then each
|
||||
/// `ADD_MULTIPLIED_TOTAL`.
|
||||
@@ -7054,6 +7366,45 @@ fn snapshot_value(s: &play::AttributeSnapshot) -> f64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// An NPC plugin lists the NPC, spawns it, and takes the entry off the list:
|
||||
/// the entity keeps the skin it found; a player spawned before its entry
|
||||
/// arrives takes the entry once it comes.
|
||||
#[test]
|
||||
fn a_player_entity_keeps_the_skin_its_entry_gave() {
|
||||
use horizon_client::skins::Textures;
|
||||
use std::collections::HashMap;
|
||||
let npc = Textures {
|
||||
skin: Some("http://textures.minecraft.net/texture/npc".into()),
|
||||
signed: true,
|
||||
..Textures::default()
|
||||
};
|
||||
let (mut listed, mut kept) = (HashMap::from([(7u128, npc.clone())]), HashMap::new());
|
||||
let mut players = HashMap::from([(2i32, 7u128), (3, 8)]);
|
||||
assert_eq!(
|
||||
super::skin_entries(&players, &listed, &mut kept),
|
||||
[(7, npc.clone())]
|
||||
);
|
||||
listed.remove(&7);
|
||||
assert_eq!(
|
||||
super::skin_entries(&players, &listed, &mut kept),
|
||||
[(7, npc.clone())]
|
||||
);
|
||||
// Player 8's entry comes after its entity.
|
||||
let late = Textures {
|
||||
skin: Some("http://textures.minecraft.net/texture/late".into()),
|
||||
..npc.clone()
|
||||
};
|
||||
listed.insert(8, late.clone());
|
||||
let mut entries = super::skin_entries(&players, &listed, &mut kept);
|
||||
entries.sort_by_key(|(u, _)| *u);
|
||||
assert_eq!(entries, [(7, npc), (8, late)]);
|
||||
// A removed entity forgets its profile with it.
|
||||
players.remove(&2);
|
||||
kept.remove(&2);
|
||||
listed.clear();
|
||||
assert_eq!(super::skin_entries(&players, &listed, &mut kept).len(), 1);
|
||||
}
|
||||
|
||||
/// An NPC plugin's scale: a base the plugin sets, or a modifier on 1.
|
||||
#[test]
|
||||
fn attribute_values_add_then_multiply_as_vanilla() {
|
||||
@@ -7080,6 +7431,43 @@ mod tests {
|
||||
|
||||
use super::Game;
|
||||
|
||||
/// `Entity.lookAt`: yaw 0 faces +Z, -90 faces +X; pitch -90 looks up.
|
||||
#[test]
|
||||
fn look_at_turns_towards_the_target() {
|
||||
let close = |(yaw, pitch): (f32, f32), want: (f32, f32)| {
|
||||
assert!(
|
||||
(yaw - want.0).abs() < 1e-3 && (pitch - want.1).abs() < 1e-3,
|
||||
"{yaw} {pitch}, want {want:?}"
|
||||
);
|
||||
};
|
||||
let at = [10.0, 64.0, -5.0];
|
||||
let off = |d: [f64; 3]| [at[0] + d[0], at[1] + d[1], at[2] + d[2]];
|
||||
close(
|
||||
super::look_at_rotation(at, off([0.0, 0.0, 3.0])),
|
||||
(0.0, 0.0),
|
||||
);
|
||||
close(
|
||||
super::look_at_rotation(at, off([3.0, 0.0, 0.0])),
|
||||
(-90.0, 0.0),
|
||||
);
|
||||
close(
|
||||
super::look_at_rotation(at, off([-3.0, 0.0, 0.0])),
|
||||
(90.0, 0.0),
|
||||
);
|
||||
close(
|
||||
super::look_at_rotation(at, off([0.0, 0.0, -3.0])),
|
||||
(-180.0, 0.0),
|
||||
);
|
||||
close(
|
||||
super::look_at_rotation(at, off([0.0, 2.0, 2.0])),
|
||||
(0.0, -45.0),
|
||||
);
|
||||
close(
|
||||
super::look_at_rotation(at, off([0.0, -4.0, 4.0])),
|
||||
(0.0, 45.0),
|
||||
);
|
||||
}
|
||||
|
||||
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
|
||||
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
|
||||
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
|
||||
|
||||
@@ -119,8 +119,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
}
|
||||
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on.
|
||||
(Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0,
|
||||
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
|
||||
(Species::Mooshroom, 17, Value::Int(variant)) => {
|
||||
// MushroomCow.DATA_TYPE (after AgeableMob's DATA_BABY_ID and
|
||||
// AGE_LOCKED): 0 red, 1 brown.
|
||||
(Species::Mooshroom, 18, Value::Int(variant)) => {
|
||||
m.extra.variant = (*variant == 1) as u8
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING: the dance pose.
|
||||
@@ -180,6 +181,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
}
|
||||
// Vex.DATA_FLAGS_ID: charging in bit 0.
|
||||
(Species::Vex, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
|
||||
// GlowSquid.DATA_DARK_TICKS_REMAINING (after AgeableMob's two).
|
||||
(Species::GlowSquid, 18, Value::Int(ticks)) => {
|
||||
horizon_client::mobs::aquatic::set_dark_ticks(m, *ticks);
|
||||
}
|
||||
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
|
||||
(Species::Creaking, 16, Value::Bool(can_move)) => m.extra.flag2 = !*can_move,
|
||||
(Species::Creaking, 17, Value::Bool(active)) => m.extra.flag = *active,
|
||||
@@ -525,6 +530,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mooshroom_takes_its_type_after_the_age_lock() {
|
||||
let Some(mut m) = mob("minecraft:mooshroom") else {
|
||||
return;
|
||||
};
|
||||
// AgeableMob.AGE_LOCKED (17) is not the colour.
|
||||
apply(&mut m, &[(17, s::BOOLEAN, Value::Bool(true))]);
|
||||
assert_eq!(m.extra.variant, 0);
|
||||
// MushroomCow.DATA_TYPE (18): what MushroomCow.thunderHit changes.
|
||||
apply(&mut m, &[(18, s::INT, Value::Int(1))]);
|
||||
assert_eq!(m.extra.variant, 1);
|
||||
apply(&mut m, &[(18, s::INT, Value::Int(0))]);
|
||||
assert_eq!(m.extra.variant, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
|
||||
let Some(mut m) = mob("minecraft:sheep") else {
|
||||
|
||||
@@ -285,7 +285,7 @@ impl Game<'_> {
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
}
|
||||
// FishingHook.recreateFromPacket: the owner's id is the data.
|
||||
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, data),
|
||||
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, velocity, data),
|
||||
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
@@ -599,7 +599,7 @@ impl Game<'_> {
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.step_remote_thrown();
|
||||
self.step_remote_hooks(&mut step);
|
||||
self.tick_remote_hooks();
|
||||
self.end.step_remote_eyes();
|
||||
self.tick_remote_clouds();
|
||||
// The dragon's fireballs fly on between the tracker's updates, with
|
||||
|
||||
@@ -167,9 +167,11 @@ impl Game<'_> {
|
||||
Some(c.seat())
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
// AbstractBoat.tick on the controlling client: steer and move.
|
||||
let mut b = self.boats[i].clone();
|
||||
// It steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick, so the paddle packet describes the motion the
|
||||
// server is sent; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
let b = self.tick_boat(b, true);
|
||||
// positionRider: the seat turns with the boat; clampRotation.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
|
||||
@@ -290,6 +290,11 @@ impl Game<'_> {
|
||||
let brightness = self.eye_brightness(e);
|
||||
let inside_portal = self.client_portal_effect();
|
||||
self.screen_fx.tick(brightness, inside_portal);
|
||||
// LocalPlayer.aiStep: processPortalCooldown after the transition
|
||||
// effect (a local world's player counts down in handlePortal).
|
||||
if self.remote.is_some() && self.portals.player.cooldown > 0 {
|
||||
self.portals.player.cooldown -= 1;
|
||||
}
|
||||
let nausea = self
|
||||
.potions
|
||||
.effects
|
||||
|
||||
@@ -251,6 +251,21 @@ impl Game<'_> {
|
||||
];
|
||||
self.block_explosion(center, in_water.then_some(pos))
|
||||
}
|
||||
/// `StrawBedBlock.destroyBed`: the break sound, then the bed is air.
|
||||
pub(super) fn destroy_straw_bed(&mut self, head: [i32; 3]) -> Result<()> {
|
||||
let center = [
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + 0.5,
|
||||
f64::from(head[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
"minecraft:block.straw_bed.break_leave",
|
||||
Some(center),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.level_set(head, self.world.air)
|
||||
}
|
||||
/// `BedRule.destroyOnUse` of the dimension's `bed_rule` and
|
||||
/// `straw_bed_rule`: true in the Nether and the End.
|
||||
pub(super) fn beds_destroy_on_use(&self) -> bool {
|
||||
@@ -268,14 +283,7 @@ impl Game<'_> {
|
||||
f64::from(head[2]) + 0.5,
|
||||
];
|
||||
if &*name == "minecraft:straw_bed" {
|
||||
// StrawBedBlock.destroyBed.
|
||||
self.audio.play(
|
||||
"minecraft:block.straw_bed.break_leave",
|
||||
Some(center),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
return self.level_set(head, self.world.air);
|
||||
return self.destroy_straw_bed(head);
|
||||
}
|
||||
// removeBlock(head), then the foot behind it if it is still this bed.
|
||||
let facing = self
|
||||
|
||||
@@ -1614,7 +1614,16 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn tick_mob_sculk_events(&mut self) {
|
||||
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>, Option<u32>)> = self
|
||||
let snapshot: Vec<(
|
||||
u32,
|
||||
[f64; 3],
|
||||
bool,
|
||||
bool,
|
||||
Species,
|
||||
Option<i64>,
|
||||
Option<u32>,
|
||||
u32,
|
||||
)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
@@ -1627,10 +1636,11 @@ impl Game<'_> {
|
||||
m.species(),
|
||||
m.last_damage.map(|(_, t)| t),
|
||||
m.extra.mount,
|
||||
m.age,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (id, at, on_ground, in_water, species, damaged, vehicle) in snapshot {
|
||||
for (id, at, on_ground, in_water, species, damaged, vehicle, age) in snapshot {
|
||||
if on_ground {
|
||||
self.step_on_redstone_ore(at, false);
|
||||
}
|
||||
@@ -1640,6 +1650,19 @@ impl Game<'_> {
|
||||
let emits = !matches!(species, Species::Shulker);
|
||||
if emits {
|
||||
self.entity_motion_events(Who::Mob(id), at, on_ground, in_water, false, false);
|
||||
// Entity.processFlappingMovement with Bee.isFlapping: a bee off
|
||||
// the ground over air flaps on every other tick (the mob's own
|
||||
// `MobEvent::GameEvent` is not read here).
|
||||
if species == Species::Bee && !on_ground && age.is_multiple_of(2) {
|
||||
let below = self.world.block(
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 1.0e-5).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
if self.res.facts().is_air(below) {
|
||||
self.post_entity_event("minecraft:flap", Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = damaged {
|
||||
let seen = self.sculk.damaged.insert(id, t);
|
||||
|
||||
@@ -201,6 +201,9 @@ pub(super) fn saves() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_default().join("saves")
|
||||
}
|
||||
|
||||
/// `WorldOpenFlows.WORLD_PACK_ID`: the id of a world's own resource pack.
|
||||
const WORLD_PACK_ID: u128 = 0x640a6a92_b6cb_48a0_b391_831586500359;
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
const RELOAD_LINES: [&str; 4] = [
|
||||
"Preparing resources",
|
||||
@@ -217,6 +220,19 @@ pub(super) struct PackReload {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `WorldOpenFlows.loadBundledResourcePack`: a world's own
|
||||
/// `resourcepacks/resources.zip` is pushed as a local pack before the world
|
||||
/// connects, after `configureForLocalWorld` so the pack is accepted at once.
|
||||
pub(super) fn server_packs_world_pack(&mut self, folder: &str) {
|
||||
let path = saves()
|
||||
.join(folder)
|
||||
.join("resourcepacks")
|
||||
.join("resources.zip");
|
||||
if path.is_file() {
|
||||
self.server_packs.packs.local_world();
|
||||
self.server_packs.packs.push_local(WORLD_PACK_ID, path);
|
||||
}
|
||||
}
|
||||
/// `ConnectScreen`: the server's setting decides what its packs do
|
||||
/// (`configureForServerControl`); single player's own server takes them
|
||||
/// all, quietly (`configureForLocalWorld`).
|
||||
|
||||
@@ -373,8 +373,11 @@ impl Game<'_> {
|
||||
};
|
||||
let at = [destination.x, destination.y, destination.z];
|
||||
let ctx = self.server_ctx();
|
||||
// TeleportTransition.PLAY_PORTAL_SOUND.
|
||||
host.hub.change_level(i, at, yaw, None, true, &ctx);
|
||||
// Through a portal: the client waits on its loading screen until
|
||||
// the exit is found (`arrive_server_trips`). The cooldown is the one
|
||||
// Entity.handlePortal set before the teleport.
|
||||
host.hub
|
||||
.change_level(i, at, yaw, None, true, PLAYER_DELAY, &ctx);
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.trigger_dimension(source, target, [from.x, from.y, from.z])
|
||||
});
|
||||
@@ -543,6 +546,8 @@ impl Game<'_> {
|
||||
if trip.placed {
|
||||
continue;
|
||||
}
|
||||
// A portal's trip (`start_server_trip`): its client waits to land.
|
||||
let portal = trip.end || trip.plan.is_some();
|
||||
self.resolve_arrivals();
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if let Some(config) = trip.respawn {
|
||||
@@ -621,8 +626,12 @@ impl Game<'_> {
|
||||
g.yaw = yaw;
|
||||
// A new body at the exit: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
host.hub
|
||||
.teleport(i, Some([position.x, position.y, position.z]));
|
||||
let at = [position.x, position.y, position.z];
|
||||
if portal {
|
||||
host.hub.land(i, at);
|
||||
} else {
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,9 +679,10 @@ impl Game<'_> {
|
||||
let origin = g.position;
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
let cooldown = host.play.get(&t.conn).map_or(0, |p| p.portal.cooldown);
|
||||
let ctx = self.server_ctx();
|
||||
host.hub
|
||||
.change_level(i, t.at, yaw, Some(pitch), false, &ctx);
|
||||
.change_level(i, t.at, yaw, Some(pitch), false, cooldown, &ctx);
|
||||
self.as_guest(host, t.conn, |g| g.trigger_dimension(source, t.dim, origin));
|
||||
self.server_trips.retain(|trip| !trip.is(t.conn));
|
||||
self.server_trips.push(Trip {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
//! The sign editing screen (`SignEditScreen` / `HangingSignEditScreen`):
|
||||
//! opened when a sign is placed, four lines limited to the sign's line
|
||||
//! width, Up/Down/Enter move between lines, Escape or Done closes.
|
||||
use super::{Game, lan::LanHost};
|
||||
use super::{
|
||||
Game,
|
||||
commands::{Target, execute::Source},
|
||||
lan::LanHost,
|
||||
};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
signs::{DYE_TEXT, wood_type},
|
||||
signs::{ClickAction, DYE_TEXT, click_action, wood_type},
|
||||
ui::EditBox,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
@@ -90,11 +94,124 @@ impl Game<'_> {
|
||||
let diff = if diff >= 180.0 { 360.0 - diff } else { diff };
|
||||
diff <= 90.0
|
||||
}
|
||||
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed
|
||||
/// sign plays the failed-interaction sound; otherwise a player who may
|
||||
/// build edits the side they face.
|
||||
/// `SignBlockEntity.canExecuteClickCommands` for the side the acting
|
||||
/// player faces: a waxed sign with a `run_command` click event on it.
|
||||
pub(super) fn sign_can_execute_click_commands(&self, pos: (i32, i32, i32)) -> bool {
|
||||
// Only a waxed sign can; skip the facing maths for everything else.
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&pos) else {
|
||||
return false;
|
||||
};
|
||||
sign.waxed && sign.can_execute_click_commands(self.facing_sign_front(pos))
|
||||
}
|
||||
/// `SignBlockEntity.createCommandSourceStack`: the acting player runs the
|
||||
/// command from the middle of the sign's block, rotation zero, at the
|
||||
/// game master level, with `CommandSource.NULL` (no feedback, no failure
|
||||
/// messages).
|
||||
fn sign_command_source(&self, pos: [i32; 3]) -> Source {
|
||||
Source {
|
||||
entity: self.player.is_some().then_some(Target::Player),
|
||||
pos: pos.map(|v| f64::from(v) + 0.5),
|
||||
rot: [0.0, 0.0],
|
||||
eyes: false,
|
||||
silent: true,
|
||||
store: None,
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
block: None,
|
||||
}
|
||||
}
|
||||
/// Run a sign's `run_command` line as the prefixed command vanilla runs
|
||||
/// it as: a leading slash is allowed, errors are not reported (the NULL
|
||||
/// source).
|
||||
fn run_sign_command(&mut self, pos: [i32; 3], command: &str) {
|
||||
let source = self.sign_command_source(pos);
|
||||
let line = command.strip_prefix('/').unwrap_or(command);
|
||||
self.exec = Default::default();
|
||||
let permission = std::mem::replace(&mut self.ui.registries.permission, 2);
|
||||
let _ = self.execute_line(line, source);
|
||||
self.ui.registries.permission = permission;
|
||||
// InventoryChangeTrigger fires as slots change, not on the next tick.
|
||||
if self.player.is_some() {
|
||||
self.check_inventory_trigger();
|
||||
}
|
||||
}
|
||||
/// `SignBlockEntity.executeClickCommandsIfPresent`: every line of the
|
||||
/// side whose style has a `run_command`, `show_dialog` or `custom` click
|
||||
/// event runs it as the acting player when the sign allows op features;
|
||||
/// otherwise the player is told click actions are disabled. Whether any
|
||||
/// line had one.
|
||||
fn execute_sign_click_commands(&mut self, pos: [i32; 3], front: bool) -> bool {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return false;
|
||||
};
|
||||
let allow_op_features = sign.allow_op_features;
|
||||
let side = if front { &sign.front } else { &sign.back };
|
||||
// The side as it was when the loop began: a command may change it.
|
||||
let events: Vec<_> = side.click.iter().flatten().cloned().collect();
|
||||
let mut any_click_command = false;
|
||||
for event in &events {
|
||||
match click_action(event) {
|
||||
Some(ClickAction::RunCommand(command)) => {
|
||||
if allow_op_features {
|
||||
self.run_sign_command(pos, &command);
|
||||
}
|
||||
any_click_command = true;
|
||||
}
|
||||
Some(ClickAction::ShowDialog(dialog)) => {
|
||||
if allow_op_features {
|
||||
self.show_click_dialog(dialog);
|
||||
}
|
||||
any_click_command = true;
|
||||
}
|
||||
// MinecraftServer.handleCustomClickAction only logs it.
|
||||
Some(ClickAction::Custom { id, payload }) => {
|
||||
if allow_op_features {
|
||||
log::debug!("Received custom click action {id} with payload {payload:?}");
|
||||
}
|
||||
any_click_command = true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if !allow_op_features && any_click_command {
|
||||
self.sign_click_actions_disabled();
|
||||
}
|
||||
any_click_command
|
||||
}
|
||||
/// `Player.sendOverlayMessage(CLICK_ACTIONS_DISABLED)`: red text on the
|
||||
/// action bar. A guest gets a packet, so its own client translates it.
|
||||
fn sign_click_actions_disabled(&mut self) {
|
||||
const KEY: &str = "sign.click_actions_disabled";
|
||||
if self.lan_acting.is_some() {
|
||||
let message = horizon_client::text::Text::translate(KEY, &[])
|
||||
.with_color(horizon_client::text::RED);
|
||||
self.guest_packets.push(C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&message.component()),
|
||||
overlay: true,
|
||||
}));
|
||||
} else {
|
||||
self.message = Some((
|
||||
self.translate(KEY, "Click actions are disabled for this sign"),
|
||||
std::time::Instant::now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): the side
|
||||
/// the player faces runs its click commands first; then a waxed sign
|
||||
/// plays the failed-interaction sound, a sign that ran one does nothing
|
||||
/// more, and otherwise a player who may build edits the side they face.
|
||||
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Sign(_))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let front = self.facing_sign_front(key);
|
||||
let executed = self.execute_sign_click_commands(pos, front);
|
||||
// The commands may have changed the sign, or removed it.
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
@@ -107,8 +224,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio
|
||||
.play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0);
|
||||
} else if self.may_build() {
|
||||
let front = self.facing_sign_front(key);
|
||||
} else if !executed && self.may_build() {
|
||||
self.open_sign_editor(key, front);
|
||||
}
|
||||
}
|
||||
@@ -183,6 +299,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.mark_block_entity_updated(key);
|
||||
self.execute_sign_click_commands(pos, front);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! `AbstractThrownPotion`: what a splash or lingering potion does around where
|
||||
//! it lands besides its effects. A potion in `#minecraft:douses_fire` puts out
|
||||
//! the fire, candles and campfires next to the block it hit; one in
|
||||
//! `#minecraft:hurts_water_sensitive_entities` hurts the water-sensitive mobs
|
||||
//! near it and one in `#minecraft:extinguishes_entities` puts out burning
|
||||
//! entities. Only plain water is in those tags in vanilla.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{Damage, Species, Who},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
|
||||
/// The block a thrown potion hit and the face it hit it on, as the
|
||||
/// `BlockHitResult` of `ProjectileUtil.getHitResultOnMoveVector`: `end` is a
|
||||
/// point of the path inside the block, `start` where the step began. The face
|
||||
/// is the one the path enters the block cube through, or, when the step began
|
||||
/// inside the block, the one facing back along the motion.
|
||||
pub(super) fn thrown_block_hit(start: Vec3, end: Vec3, velocity: Vec3) -> ([i32; 3], Direction) {
|
||||
let cell = [end.x, end.y, end.z].map(|v| v.floor() as i32);
|
||||
let face = Aabb::clip_boxes(
|
||||
[Aabb::unit_cube(Vec3::ZERO)],
|
||||
start,
|
||||
end,
|
||||
BlockPos::new(cell[0], cell[1], cell[2]),
|
||||
)
|
||||
.map_or_else(
|
||||
|| Direction::approximate_nearest(velocity.reverse()),
|
||||
|hit| hit.direction,
|
||||
);
|
||||
(cell, face)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `PotionContents.is`: the potion of `stack` is in the potion tag `tag`
|
||||
/// (`minecraft:douses_fire` and the others, read from the data pack's
|
||||
/// `tags/potion`). A stack with no potion is in no tag.
|
||||
pub(super) fn potion_in_tag(&self, stack: &ItemStack, tag: &str) -> bool {
|
||||
stack.potion().is_some_and(|potion| {
|
||||
self.res
|
||||
.stations()
|
||||
.trades
|
||||
.potion_tags
|
||||
.get(tag)
|
||||
.is_some_and(|members| members.iter().any(|m| m.as_str() == potion))
|
||||
})
|
||||
}
|
||||
|
||||
/// `AbstractThrownPotion.onHitBlock` on the server: a potion (the stack's
|
||||
/// `potion_contents`) in `#douses_fire` douses the cell in front of the
|
||||
/// face it hit, the cell behind that (the block it hit) and the four
|
||||
/// horizontal neighbours of the cell in front. `hit` is the block and
|
||||
/// `face` the face it was hit on.
|
||||
pub(super) fn potion_hit_block(&mut self, stack: &ItemStack, hit: [i32; 3], face: Direction) {
|
||||
// `PotionContents.is(PotionTags.DOUSES_FIRE)` on the stack's `potion_contents`.
|
||||
if !self.potion_in_tag(stack, "minecraft:douses_fire") {
|
||||
return;
|
||||
}
|
||||
let relative = |pos: [i32; 3], direction: Direction| {
|
||||
let n = direction.normal();
|
||||
[pos[0] + n[0], pos[1] + n[1], pos[2] + n[2]]
|
||||
};
|
||||
let effect = relative(hit, face);
|
||||
self.douse_fire(effect);
|
||||
self.douse_fire(relative(effect, face.opposite()));
|
||||
// Direction.Plane.HORIZONTAL.
|
||||
for direction in [
|
||||
Direction::North,
|
||||
Direction::East,
|
||||
Direction::South,
|
||||
Direction::West,
|
||||
] {
|
||||
self.douse_fire(relative(effect, direction));
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractThrownPotion.douseFire`: a fire block is destroyed without
|
||||
/// drops, a lit candle or candle cake goes out, and a lit campfire goes
|
||||
/// out after level event 1009 and its block-change game event.
|
||||
fn douse_fire(&mut self, pos: [i32; 3]) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let fire = self.block_in_tag("minecraft:fire", &name);
|
||||
if fire {
|
||||
// Level.destroyBlock(pos, false, this).
|
||||
self.with_level(|b, level, ticks| b.destroy_block(level, ticks, at, false));
|
||||
}
|
||||
let candle =
|
||||
!fire && self.with_level(|b, level, ticks| b.extinguish_candle(level, ticks, at));
|
||||
if !fire
|
||||
&& !candle
|
||||
&& let Some(mut lit) = self.lit_campfire(state, &name)
|
||||
{
|
||||
self.level_event_at(1009, pos, 0);
|
||||
// CampfireBlock.douse: the block-change game event, whose source
|
||||
// is the potion's owner (not tracked here).
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, None);
|
||||
lit.properties.insert("lit".into(), "false".into());
|
||||
if let Ok(unlit) = self.pack.assets.registry.resolve(&lit) {
|
||||
let _ = self.level_set(pos, unlit);
|
||||
}
|
||||
}
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
|
||||
/// `CampfireBlock.isLitCampfire`: the state of a lit block in
|
||||
/// `#minecraft:campfires`.
|
||||
fn lit_campfire(&self, state: BlockStateId, name: &str) -> Option<BlockState> {
|
||||
if !self.block_in_tag("minecraft:campfires", name) {
|
||||
return None;
|
||||
}
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// `AbstractThrownPotion.affectEntitiesAround`: the living entities whose
|
||||
/// box meets the potion's box grown by (4, 2, 4) and that are less than 4
|
||||
/// blocks from it. A potion in `#hurts_water_sensitive_entities` hurts
|
||||
/// the water-sensitive ones for 1 (`indirectMagic`, the owner being a
|
||||
/// player when `by_player`), an Enderman then trying to teleport
|
||||
/// (`Enderman.hurtServer`); one in `#extinguishes_entities` puts out
|
||||
/// those that burn; one in `#rehydrates_axolotls` refills the air of
|
||||
/// the axolotls in the box.
|
||||
pub(super) fn affect_entities_around(&mut self, stack: &ItemStack, at: Vec3, by_player: bool) {
|
||||
let hurts = self.potion_in_tag(stack, "minecraft:hurts_water_sensitive_entities");
|
||||
let extinguishes = self.potion_in_tag(stack, "minecraft:extinguishes_entities");
|
||||
// The potion's box is 0.25 wide and high.
|
||||
let reach = Aabb::new(
|
||||
Vec3::new(at.x - 0.125, at.y, at.z - 0.125),
|
||||
Vec3::new(at.x + 0.125, at.y + 0.25, at.z + 0.125),
|
||||
)
|
||||
.inflate(Vec3::new(4.0, 2.0, 4.0));
|
||||
let near = |p: Vec3| {
|
||||
let (dx, dy, dz) = (p.x - at.x, p.y - at.y, p.z - at.z);
|
||||
dx * dx + dy * dy + dz * dz < 16.0
|
||||
};
|
||||
if hurts || extinguishes {
|
||||
let source =
|
||||
Damage::indirect_magic(by_player.then_some(Who::Player), [at.x, at.y, at.z]);
|
||||
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
|
||||
// WATER_SENSITIVE_OR_ON_FIRE.
|
||||
let sensitive = m.kind.flags.water_sensitive;
|
||||
if !(sensitive || m.is_on_fire())
|
||||
|| !reach.intersects(m.bounds())
|
||||
|| !near(m.position)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if hurts && sensitive {
|
||||
let invulnerable = m.invulnerable_flag;
|
||||
m.damage(1.0, source);
|
||||
// `Enderman.hurtServer` for a thrown potion: the hit is
|
||||
// `Enderman.hurtWithCleanWater`, then
|
||||
// `Enderman.repeatedlyTryToTeleport`, which does nothing
|
||||
// while it rides or once it is dead (`teleport` needs
|
||||
// `isAlive`) and which the AI step runs.
|
||||
if m.species() == Species::Enderman
|
||||
&& !invulnerable
|
||||
&& m.alive()
|
||||
&& !m.extra.passenger
|
||||
{
|
||||
m.extra.counter2 = 64;
|
||||
}
|
||||
}
|
||||
if extinguishes && m.is_on_fire() && m.alive() {
|
||||
m.extinguish_fire();
|
||||
}
|
||||
}
|
||||
}
|
||||
if extinguishes {
|
||||
self.for_each_player(|g| g.extinguish_player_in_splash(reach, at));
|
||||
// Armor stands are living entities too.
|
||||
let mut put_out = Vec::new();
|
||||
for stand in self.armor_stands.iter_mut() {
|
||||
if stand.fire_ticks > 0
|
||||
&& stand.health > 0.5
|
||||
&& reach.intersects(stand.bounds())
|
||||
&& near(stand.position)
|
||||
{
|
||||
stand.fire_ticks = 0;
|
||||
put_out.push([stand.position.x, stand.position.y, stand.position.z]);
|
||||
}
|
||||
}
|
||||
for stand_at in put_out {
|
||||
self.extinguish_sound(stand_at);
|
||||
}
|
||||
}
|
||||
if self.potion_in_tag(stack, "minecraft:rehydrates_axolotls") {
|
||||
// Axolotl.rehydrate: 1800 more air, up to the maximum.
|
||||
let (lo, hi) = (
|
||||
[at.x - 0.125 - 4.0, at.y - 2.0, at.z - 0.125 - 4.0],
|
||||
[at.x + 0.125 + 4.0, at.y + 0.25 + 2.0, at.z + 0.125 + 4.0],
|
||||
);
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.species() == Species::Axolotl && !m.removed)
|
||||
{
|
||||
let (w, h, _) = m.dims();
|
||||
let half = w / 2.0;
|
||||
let (x, y, z) = (m.position.x, m.position.y, m.position.z);
|
||||
if x - half < hi[0]
|
||||
&& x + half > lo[0]
|
||||
&& y < hi[1]
|
||||
&& y + h > lo[1]
|
||||
&& z - half < hi[2]
|
||||
&& z + half > lo[2]
|
||||
{
|
||||
m.air = (m.air + 1800).min(horizon_client::mobs::axolotl::MAX_AIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.extinguishFire` for the player in place (the host's or a
|
||||
/// guest's) when it burns, is alive and is in a splash's reach: the
|
||||
/// extinguish sound, then the fire out.
|
||||
fn extinguish_player_in_splash(&mut self, reach: Aabb, at: Vec3) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
|
||||
Vec3::new(
|
||||
p.position.x + w,
|
||||
p.position.y + f64::from(p.dimensions.height),
|
||||
p.position.z + w,
|
||||
),
|
||||
);
|
||||
let (dx, dy, dz) = (
|
||||
p.position.x - at.x,
|
||||
p.position.y - at.y,
|
||||
p.position.z - at.z,
|
||||
);
|
||||
if p.fire_ticks <= 0
|
||||
|| p.health.dead_or_dying()
|
||||
|| !reach.intersects(bounds)
|
||||
|| dx * dx + dy * dy + dz * dz >= 16.0
|
||||
{
|
||||
return;
|
||||
}
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
self.extinguish_sound(feet);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
let ticks = p.fire_ticks.min(0);
|
||||
p.set_remaining_fire_ticks(ticks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,9 +149,12 @@ impl StartupMenu {
|
||||
)
|
||||
}
|
||||
|
||||
fn title_key(&mut self, event: &KeyEvent) {
|
||||
/// `Screen.keyPressed` on the title screens: the action a key starts (Enter confirms, see `confirm_key` in title.rs).
|
||||
fn title_key(&mut self, event: &KeyEvent) -> Action {
|
||||
let shortcut = self.shortcut(event);
|
||||
let Some(title) = &mut self.title else { return };
|
||||
let Some(title) = &mut self.title else {
|
||||
return Action::None;
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
if title.has_caret_field() {
|
||||
title.field_shortcut(shortcut, &mut self.clipboard);
|
||||
@@ -164,13 +167,16 @@ impl StartupMenu {
|
||||
{
|
||||
title.search_changed();
|
||||
}
|
||||
return;
|
||||
return Action::None;
|
||||
}
|
||||
match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::Escape) => title.escape(),
|
||||
PhysicalKey::Code(KeyCode::F5) => title.refresh_servers(),
|
||||
PhysicalKey::Code(KeyCode::Tab) => title.tab(),
|
||||
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => title.confirm_edit(),
|
||||
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => return title.confirm_key(),
|
||||
PhysicalKey::Code(KeyCode::Space) if title.list_focused() => {
|
||||
return title.confirm_key();
|
||||
}
|
||||
PhysicalKey::Code(
|
||||
key @ (KeyCode::Backspace
|
||||
| KeyCode::Delete
|
||||
@@ -202,6 +208,7 @@ impl StartupMenu {
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +251,7 @@ pub(super) fn spawn_loader(
|
||||
&settings,
|
||||
started,
|
||||
true,
|
||||
false,
|
||||
&progress,
|
||||
&mut |gui, root| {
|
||||
let _ = sender.send(LoaderMsg::Ui(gui, root.to_path_buf()));
|
||||
@@ -603,14 +611,17 @@ impl Loading {
|
||||
} else {
|
||||
raw
|
||||
} * self.settings.mouse_wheel_sensitivity;
|
||||
let batch = HudBatch::new(self.menu_size());
|
||||
let size = self.menu_size();
|
||||
let batch = HudBatch::new(size);
|
||||
if self.menu.options_open {
|
||||
self.menu.options.scroll_at(
|
||||
&self.settings,
|
||||
let events = self.menu.options.wheel(
|
||||
&mut self.settings,
|
||||
(batch.width, batch.height),
|
||||
self.menu.cursor,
|
||||
[size[0] as i32, size[1] as i32],
|
||||
amount,
|
||||
);
|
||||
self.menu_option_events(events);
|
||||
} else if let Some(title) = &mut self.menu.title {
|
||||
title.scroll_worlds(amount, self.menu.cursor, (batch.width, batch.height));
|
||||
}
|
||||
@@ -638,8 +649,8 @@ impl Loading {
|
||||
self.menu_option_events(events);
|
||||
}
|
||||
} else {
|
||||
self.menu.title_key(&event);
|
||||
self.save_menu_address();
|
||||
let action = self.menu.title_key(&event);
|
||||
self.menu_action(event_loop, action);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -2489,12 +2489,17 @@ impl Game<'_> {
|
||||
}
|
||||
/// Hovered-slot tooltip on the plain inventory, crafting and container screens.
|
||||
pub(super) fn draw_slot_tooltip(&mut self, batch: &mut HudBatch) {
|
||||
if self.carried.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) else {
|
||||
return;
|
||||
};
|
||||
// The recipe book's tooltip is drawn after the slot's own, so a ghost wins.
|
||||
if let Some(ghost) = self.ghost_stack(slot) {
|
||||
self.draw_stack_tooltip(batch, &ghost, self.cursor.0, self.cursor.1);
|
||||
return;
|
||||
}
|
||||
if self.carried.is_some() {
|
||||
return;
|
||||
}
|
||||
let stack = self.slot_view(slot);
|
||||
if let Some(stack) = stack {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
|
||||
@@ -275,6 +275,8 @@ impl Game<'_> {
|
||||
let scroll = self.stats_ui.scroll.amount;
|
||||
let row_y = |i: usize| top + 4.0 + i as f32 * row_h - scroll;
|
||||
let visible = |y: f32| y >= top && y + row_h <= bottom;
|
||||
// The hovered item's tooltip, drawn over the list.
|
||||
let mut tip = None;
|
||||
match self.stats_ui.tab {
|
||||
0 => {
|
||||
let mut sorted: Vec<(Text, String)> = CUSTOM
|
||||
@@ -348,6 +350,10 @@ impl Game<'_> {
|
||||
y + 1.0,
|
||||
);
|
||||
self.draw_item(batch, &stack, left + 2.0, y + 2.0);
|
||||
// `ItemRowWidget.extractTooltip`: the item's tooltip at the slot's corner.
|
||||
if ui::hovered(self.cursor, left + 1.0, y + 1.0, 18.0, 18.0) {
|
||||
tip = Some((stack.clone(), left + 19.0, y + 19.0));
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let color = if (i + 1) % 2 == 0 {
|
||||
u32::MAX
|
||||
@@ -424,6 +430,9 @@ impl Game<'_> {
|
||||
self.stats_ui.scroll.amount,
|
||||
);
|
||||
self.draw_stats_footer(batch, w, h);
|
||||
if let Some((stack, x, y)) = tip {
|
||||
self.draw_stack_tooltip(batch, &stack, x, y);
|
||||
}
|
||||
}
|
||||
fn draw_stats_footer(&self, batch: &mut HudBatch, w: f32, h: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
|
||||
@@ -10,8 +10,8 @@ use horizon_client::{
|
||||
Who,
|
||||
spawner::SpawnerSpawn,
|
||||
trial_spawner::{
|
||||
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
|
||||
TrialSpawnerExt, TrialSpawnerLevel, TrialState, Uuid,
|
||||
TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner, TrialSpawnerExt,
|
||||
TrialSpawnerLevel, TrialState, Uuid,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -41,8 +41,7 @@ impl TrialSpawnerLevel for GameLevel<'_, '_> {
|
||||
position: g.position,
|
||||
height: g.height,
|
||||
alive: g.alive,
|
||||
// Guests carry no effects yet.
|
||||
omen: None,
|
||||
omen: g.omen,
|
||||
});
|
||||
let (Some(p), Some(view)) = (self.game.player.as_ref(), self.game.player_view()) else {
|
||||
return guests.collect();
|
||||
@@ -56,11 +55,7 @@ impl TrialSpawnerLevel for GameLevel<'_, '_> {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
height: p.dimensions.height,
|
||||
alive: self.game.level.health > 0.0,
|
||||
omen: if self.game.potions.effects.get("trial_omen").is_some() {
|
||||
Some(Omen::Trial)
|
||||
} else {
|
||||
self.game.potions.effects.get("bad_omen").map(|_| Omen::Bad)
|
||||
},
|
||||
omen: super::lan_mobs::omen_of(&self.game.potions.effects),
|
||||
};
|
||||
std::iter::once(host).chain(guests).collect()
|
||||
}
|
||||
@@ -247,7 +242,7 @@ impl Game<'_> {
|
||||
let who = by_uuid.get(&uuid).map(|id| Who::Mob(*id));
|
||||
self.post_block_game_event("minecraft:entity_place", at, who, None);
|
||||
}
|
||||
TrialEvent::TrialOmen(_) => self.bad_omen_to_trial_omen(),
|
||||
TrialEvent::TrialOmen(uuid) => self.bad_omen_to_trial_omen(uuid),
|
||||
TrialEvent::Discard(uuid) => {
|
||||
let id = self.persist.uuids.iter().find(|(_, u)| **u == uuid);
|
||||
if let Some((id, _)) = id
|
||||
@@ -292,22 +287,27 @@ impl Game<'_> {
|
||||
self.level_set([key.0, key.1, key.2], id)
|
||||
}
|
||||
/// `transformBadOmenIntoTrialOmen`: Bad Omen becomes Trial Omen, 15
|
||||
/// minutes per level, with Trial Omen's sound when it is new.
|
||||
fn bad_omen_to_trial_omen(&mut self) {
|
||||
let Some(amplifier) = self.potions.effects.amplifier("bad_omen") else {
|
||||
return;
|
||||
/// minutes per level, with Trial Omen's sound when it is new. `uuid` is
|
||||
/// the player found: the one in the game's frame, else a guest.
|
||||
fn bad_omen_to_trial_omen(&mut self, uuid: Uuid) {
|
||||
let eye = if self.acting_uuid() == uuid {
|
||||
transform_bad_omen(&mut self.potions.effects)
|
||||
.then(|| self.eye_position().unwrap_or_default())
|
||||
} else {
|
||||
let acting = self.lan_acting;
|
||||
let Some(guest) = self.lan.as_mut().and_then(|h| {
|
||||
h.hub.guests.iter_mut().find(|g| {
|
||||
horizon_client::lan::playerdata::uuid_ints(g.uuid) == uuid
|
||||
&& Some(g.conn) != acting
|
||||
})
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let [x, y, z] = guest.position;
|
||||
let eye = [x, y + guest.eye_height(), z];
|
||||
transform_bad_omen(&mut guest.effects).then_some(eye)
|
||||
};
|
||||
self.potions.effects.list.retain(|e| e.id != "bad_omen");
|
||||
let new = self.potions.effects.get("trial_omen").is_none();
|
||||
self.potions
|
||||
.effects
|
||||
.add(horizon_client::effects::EffectInstance::new(
|
||||
"trial_omen",
|
||||
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1),
|
||||
0,
|
||||
));
|
||||
if new {
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
if let Some(eye) = eye {
|
||||
self.audio
|
||||
.play("minecraft:event.mob_effect.trial_omen", Some(eye), 1.0, 1.0);
|
||||
}
|
||||
@@ -320,3 +320,44 @@ impl Game<'_> {
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap Bad Omen for Trial Omen on `effects`; true when Trial Omen is new.
|
||||
fn transform_bad_omen(effects: &mut horizon_client::effects::PlayerEffects) -> bool {
|
||||
let Some(amplifier) = effects.amplifier("bad_omen") else {
|
||||
return false;
|
||||
};
|
||||
effects.list.retain(|e| e.id != "bad_omen");
|
||||
let new = effects.get("trial_omen").is_none();
|
||||
effects.add(horizon_client::effects::EffectInstance::new(
|
||||
"trial_omen",
|
||||
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1),
|
||||
0,
|
||||
));
|
||||
new
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod omen_tests {
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
effects::{EffectInstance, PlayerEffects},
|
||||
mobs::trial_spawner::Omen,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn bad_omen_becomes_trial_omen_per_level() {
|
||||
let omen_of = super::super::lan_mobs::omen_of;
|
||||
let mut e = PlayerEffects::default();
|
||||
assert_eq!(omen_of(&e), None);
|
||||
e.add(EffectInstance::new("bad_omen", 100, 1));
|
||||
assert_eq!(omen_of(&e), Some(Omen::Bad));
|
||||
assert!(transform_bad_omen(&mut e));
|
||||
assert!(e.get("bad_omen").is_none());
|
||||
assert_eq!(
|
||||
e.get("trial_omen").unwrap().duration,
|
||||
TRIAL_OMEN_TICKS_PER_LEVEL * 2
|
||||
);
|
||||
assert_eq!(omen_of(&e), Some(Omen::Trial));
|
||||
assert!(!transform_bad_omen(&mut e));
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,8 @@ impl Game<'_> {
|
||||
} else if slot >= Creative::DISPLAY {
|
||||
// An item tab's display slot shows the tab's list.
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
items.get(first * 9 + slot - Creative::DISPLAY).cloned()
|
||||
self.creative_screen
|
||||
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY)
|
||||
} else {
|
||||
self.slot_mut(slot).clone()
|
||||
};
|
||||
|
||||
@@ -236,6 +236,11 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
|
||||
} else if path.ends_with("_banner_pattern") {
|
||||
// The item name component of the pattern items that have one.
|
||||
Some(format!("item.minecraft.{path}.new"))
|
||||
} else if &*stack.item == "minecraft:shield"
|
||||
&& let Some(color) = stack.data().and_then(|d| d.base_color.as_deref())
|
||||
{
|
||||
// `ShieldItem.getName`: the banner's base colour names the shield.
|
||||
Some(format!("item.minecraft.shield.{color}"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -979,6 +979,9 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
.next_spawn_data
|
||||
.as_ref()
|
||||
.map(|d| Tag::compound([("spawn_data", d.encode())])),
|
||||
// CommandBlockEntity.getUpdateTag: saveWithoutMetadata, the command
|
||||
// and its output settings (the edit screen reads them).
|
||||
BlockEntity::CommandBlock(c) => Some(Tag::compound(c.save())),
|
||||
// SkullBlockEntity: the profile is the whole reason a head differs.
|
||||
BlockEntity::Skull(s) => {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
@@ -797,8 +797,9 @@ fn login_and_configure(
|
||||
channel: "minecraft:brand".into(),
|
||||
data: {
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
// MinecraftServer.getServerModName.
|
||||
w.string("vanilla");
|
||||
// MinecraftServer.getServerModName: "vanilla" there; a modded
|
||||
// server names itself, and Horizon's says "horizon", as its client does.
|
||||
w.string("horizon");
|
||||
w.data
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -129,6 +129,10 @@ pub struct Guest {
|
||||
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
|
||||
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
|
||||
pub awaiting_teleport: Option<(i32, [f64; 3])>,
|
||||
/// Through a portal, until `LanHub::land` gives the client its position:
|
||||
/// the moves it sent from the old level are ignored, as they would be
|
||||
/// while it had a teleport to accept.
|
||||
pub landing: bool,
|
||||
pub teleport_counter: i32,
|
||||
/// The hub tick the pending teleport was (re)sent on (awaitingTeleportTime).
|
||||
pub teleport_tick: u64,
|
||||
@@ -309,6 +313,7 @@ impl Guest {
|
||||
spawn: None,
|
||||
respawn_elsewhere: None,
|
||||
awaiting_teleport: None,
|
||||
landing: false,
|
||||
teleport_counter: 0,
|
||||
teleport_tick: 0,
|
||||
first_good: position,
|
||||
|
||||
@@ -202,6 +202,11 @@ pub struct Hub {
|
||||
/// `spawn-protection` (dedicated servers): the radius around the world
|
||||
/// spawn that only operators may change.
|
||||
pub spawn_protection: i32,
|
||||
/// `chat-spam-threshold-seconds` as a TickThrottler threshold (20 a second):
|
||||
/// over it, a guest who is not an operator is kicked. 0 never kicks.
|
||||
pub chat_spam_threshold: i32,
|
||||
/// `command-spam-threshold-seconds`, as `chat_spam_threshold`.
|
||||
pub command_spam_threshold: i32,
|
||||
chat_type: i32,
|
||||
dimension_type: i32,
|
||||
clock_ids: Vec<i32>,
|
||||
@@ -373,6 +378,8 @@ impl Hub {
|
||||
ops: Default::default(),
|
||||
entity_range_percent: 100,
|
||||
spawn_protection: 0,
|
||||
chat_spam_threshold: 0,
|
||||
command_spam_threshold: 0,
|
||||
weather: (false, 0.0, 0.0),
|
||||
level_sync: Default::default(),
|
||||
chat_type,
|
||||
@@ -621,7 +628,7 @@ impl Hub {
|
||||
latency: Some(latency),
|
||||
display_name: Some(None),
|
||||
list_order: Some(0),
|
||||
show_hat: Some(true),
|
||||
show_hat: Some(guest.is_none_or(|g| g.info.model_customisation & 0x40 != 0)),
|
||||
}
|
||||
}
|
||||
/// ClientboundPlayerInfoUpdatePacket.createPlayerInitializing: every action.
|
||||
@@ -629,7 +636,8 @@ impl Hub {
|
||||
|
||||
// ---- joining -------------------------------------------------------
|
||||
|
||||
fn spawn_info(&self, i: usize) -> play::SpawnInfo {
|
||||
/// `CommonPlayerSpawnInfo`, with the player's `portalCooldown`.
|
||||
fn spawn_info(&self, i: usize, portal_cooldown: i32) -> play::SpawnInfo {
|
||||
let g = &self.guests[i];
|
||||
play::SpawnInfo {
|
||||
dimension_type: self.dimension_type,
|
||||
@@ -647,7 +655,7 @@ impl Hub {
|
||||
dimension: dimension.clone(),
|
||||
pos: *pos,
|
||||
}),
|
||||
portal_cooldown: 0,
|
||||
portal_cooldown,
|
||||
sea_level: 63,
|
||||
}
|
||||
}
|
||||
@@ -715,7 +723,7 @@ impl Hub {
|
||||
reduced_debug_info: false,
|
||||
show_death_screen: !self.immediate_respawn,
|
||||
limited_crafting: false,
|
||||
spawn: self.spawn_info(i),
|
||||
spawn: self.spawn_info(i, 0),
|
||||
// MinecraftServer.usesAuthentication, enforceSecureProfile.
|
||||
online_mode: self.lan.shared.config.online.is_some(),
|
||||
enforces_secure_chat: self.enforces_secure_chat(),
|
||||
@@ -788,7 +796,7 @@ impl Hub {
|
||||
entries: vec![self.player_info(uuid, &name, mode, 0)],
|
||||
};
|
||||
self.broadcast(&C::PlayerInfoUpdate(newcomer));
|
||||
self.send_level_info(i, ctx);
|
||||
self.send_level_info(i, ctx, true);
|
||||
// ServerLevel.addNewPlayer: the chunk tracking starts at the player.
|
||||
let g = &mut self.guests[i];
|
||||
let (cx, cz) = g.chunk();
|
||||
@@ -1031,8 +1039,10 @@ impl Hub {
|
||||
})
|
||||
}
|
||||
|
||||
/// PlayerList.sendLevelInfo.
|
||||
fn send_level_info(&mut self, i: usize, ctx: &TickCtx) {
|
||||
/// PlayerList.sendLevelInfo; `load_start` false holds back
|
||||
/// LEVEL_CHUNKS_LOAD_START for a player who has no position yet
|
||||
/// ([`Self::land`] sends it).
|
||||
fn send_level_info(&mut self, i: usize, ctx: &TickCtx, load_start: bool) {
|
||||
let spawn = ctx.spawn;
|
||||
let g = &self.guests[i];
|
||||
g.send(&C::InitializeBorder(super::border::initialize(ctx.border)));
|
||||
@@ -1053,10 +1063,12 @@ impl Hub {
|
||||
param: w.thunder_level.1,
|
||||
}));
|
||||
}
|
||||
g.send(&C::GameEvent(play::GameEvent {
|
||||
event: game_event::LEVEL_CHUNKS_LOAD_START,
|
||||
param: 0.0,
|
||||
}));
|
||||
if load_start {
|
||||
g.send(&C::GameEvent(play::GameEvent {
|
||||
event: game_event::LEVEL_CHUNKS_LOAD_START,
|
||||
param: 0.0,
|
||||
}));
|
||||
}
|
||||
// TickRateManager.updateJoiningPlayer.
|
||||
g.send(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
|
||||
g.send(&C::TickingStep(play::TickingStep {
|
||||
@@ -2099,6 +2111,15 @@ impl Hub {
|
||||
d > f64::from(1.0E-5_f32)
|
||||
}
|
||||
|
||||
/// A move still queued for guest `i`, which a packet the game handles
|
||||
/// after this pass must not overtake.
|
||||
fn move_queued(&self, i: usize) -> bool {
|
||||
self.guests[i]
|
||||
.inbox
|
||||
.iter()
|
||||
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
|
||||
}
|
||||
|
||||
fn process_inbox(&mut self, i: usize, ctx: &mut TickCtx, events: &mut Vec<HostEvent>) {
|
||||
// ServerGamePacketListenerImpl.tickPlayer ran between the last
|
||||
// tick's packets and these: resetPosition, and the moves it counted.
|
||||
@@ -2208,10 +2229,25 @@ impl Hub {
|
||||
}
|
||||
S::ClientInformation(info) => {
|
||||
let g = &mut self.guests[i];
|
||||
// PlayerModelPart.HAT (bit 6): the tab list shows the hat layer.
|
||||
let was_hat = g.info.model_customisation & 0x40 != 0;
|
||||
g.view_distance =
|
||||
i32::from(info.view_distance).clamp(2, self.config.view_distance);
|
||||
g.info = info;
|
||||
g.center = None;
|
||||
let hat = g.info.model_customisation & 0x40 != 0;
|
||||
let uuid = g.uuid;
|
||||
// ServerGamePacketListenerImpl.handleClientInformation: UPDATE_HAT to everyone.
|
||||
if hat != was_hat {
|
||||
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: 128,
|
||||
entries: vec![play::PlayerInfo {
|
||||
uuid,
|
||||
show_hat: Some(hat),
|
||||
..Default::default()
|
||||
}],
|
||||
}));
|
||||
}
|
||||
}
|
||||
S::PlayerInput(p) => {
|
||||
self.guests[i].input = p.flags;
|
||||
@@ -2245,11 +2281,19 @@ impl Hub {
|
||||
// and the move.
|
||||
S::PlayerAction(p) if p.action <= 3 => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
|
||||
if self.guests[i]
|
||||
.inbox
|
||||
.iter()
|
||||
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
|
||||
{
|
||||
if self.move_queued(i) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// handleUseItemOn is the game's too, and BlockItem.canPlace checks
|
||||
// the player's box where they clicked from (Level.isUnobstructed), as
|
||||
// their client's prediction did. After the move behind it, a player
|
||||
// jumping up a pillar was already clear of the cell their client had
|
||||
// refused: the block went down without the place sound that only the
|
||||
// placer's client plays (BlockItem.place's playSound(player, ...)).
|
||||
S::UseItemOn(p) => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::UseItemOn(p))));
|
||||
if self.move_queued(i) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2286,7 +2330,7 @@ impl Hub {
|
||||
}
|
||||
g.position_this_tick = true;
|
||||
}
|
||||
if !g.loaded && g.ticks == 0 && pos.is_none() {
|
||||
if g.landing || !g.loaded && g.ticks == 0 && pos.is_none() {
|
||||
return;
|
||||
}
|
||||
// updateAwaitingTeleport: moves wait for the client to accept the
|
||||
@@ -2567,7 +2611,7 @@ impl Hub {
|
||||
/// level loading screen from the Respawn packet until the level info's "level chunks load
|
||||
/// start" event and the chunk under the player, so the packets follow vanilla's order.
|
||||
pub fn respawn(&mut self, i: usize, at: [f64; 3], ctx: &TickCtx) {
|
||||
let info = self.spawn_info(i);
|
||||
let info = self.spawn_info(i, 0);
|
||||
self.close_container_for(i);
|
||||
let g = &mut self.guests[i];
|
||||
// PlayerList.respawn: into the level in place (the respawn level).
|
||||
@@ -2629,7 +2673,7 @@ impl Hub {
|
||||
g.sent_xp = Some(xp);
|
||||
// ServerPlayer.sendPostEffects.
|
||||
g.send(&raw(ids::POST_EFFECTS, vec![0]));
|
||||
self.send_level_info(i, ctx);
|
||||
self.send_level_info(i, ctx, true);
|
||||
self.send_permission_level(i);
|
||||
// ServerLevel.addRespawnedPlayer: the chunk tracking starts again at the player.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -2675,16 +2719,23 @@ impl Hub {
|
||||
/// names (`set_dimension`): ClientboundRespawnPacket keeping all data,
|
||||
/// then vanilla's packet order. Nothing tracked in the old level is
|
||||
/// removed one by one: the client drops its whole level.
|
||||
///
|
||||
/// Through a `portal`, vanilla finds the exit (an End platform, a Nether
|
||||
/// portal, a spawn column) before the teleport; here it is found once
|
||||
/// the destination has loaded around `at`. Until [`Self::land`] the
|
||||
/// client gets no position, so it stays on its level loading screen
|
||||
/// instead of showing the player in the air at `at`.
|
||||
pub fn change_level(
|
||||
&mut self,
|
||||
i: usize,
|
||||
at: [f64; 3],
|
||||
yaw: f32,
|
||||
pitch: Option<f32>,
|
||||
portal_sound: bool,
|
||||
portal: bool,
|
||||
portal_cooldown: i32,
|
||||
ctx: &TickCtx,
|
||||
) {
|
||||
let info = self.spawn_info(i);
|
||||
let info = self.spawn_info(i, portal_cooldown);
|
||||
self.close_container_for(i);
|
||||
let g = &mut self.guests[i];
|
||||
g.dimension = self.config.dimension.clone();
|
||||
@@ -2708,14 +2759,22 @@ impl Hub {
|
||||
locked: ctx.difficulty_locked,
|
||||
}));
|
||||
self.send_permission_level(i);
|
||||
self.teleport(i, None);
|
||||
if portal {
|
||||
// A teleport still pending from the old level is void: its late
|
||||
// acceptance must not move the player back there.
|
||||
let g = &mut self.guests[i];
|
||||
g.landing = true;
|
||||
g.awaiting_teleport = None;
|
||||
} else {
|
||||
self.teleport(i, None);
|
||||
}
|
||||
let g = &mut self.guests[i];
|
||||
g.send(&C::PlayerAbilities(play::PlayerAbilities {
|
||||
flags: g.ability_flags(),
|
||||
flying_speed: 0.05,
|
||||
walking_speed: 0.1,
|
||||
}));
|
||||
self.send_level_info(i, ctx);
|
||||
self.send_level_info(i, ctx, !portal);
|
||||
// PlayerList.sendAllPlayerInfo: the inventory and the held slot.
|
||||
self.send_inventory(i);
|
||||
let g = &mut self.guests[i];
|
||||
@@ -2726,15 +2785,6 @@ impl Hub {
|
||||
let g = &mut self.guests[i];
|
||||
// ServerPlayer.sendPostEffects.
|
||||
g.send(&raw(ids::POST_EFFECTS, vec![0]));
|
||||
// TeleportTransition.PLAY_PORTAL_SOUND.
|
||||
if portal_sound {
|
||||
g.send(&C::LevelEvent(play::LevelEvent {
|
||||
kind: 1032,
|
||||
pos: [0, 0, 0],
|
||||
data: 0,
|
||||
global: false,
|
||||
}));
|
||||
}
|
||||
// lastSentExp, lastSentHealth and lastSentFood; the client's new
|
||||
// player starts with the default air and no absorption.
|
||||
g.sent_health = None;
|
||||
@@ -2748,6 +2798,25 @@ impl Hub {
|
||||
}));
|
||||
g.center_sent = Some((cx, cz));
|
||||
}
|
||||
|
||||
/// The rest of a portal's [`Self::change_level`] once its exit is found:
|
||||
/// the position, LEVEL_CHUNKS_LOAD_START (the client's loading screen
|
||||
/// closes once its chunk is in) and TeleportTransition.PLAY_PORTAL_SOUND.
|
||||
pub fn land(&mut self, i: usize, at: [f64; 3]) {
|
||||
self.guests[i].landing = false;
|
||||
self.teleport(i, Some(at));
|
||||
let g = &self.guests[i];
|
||||
g.send(&C::GameEvent(play::GameEvent {
|
||||
event: game_event::LEVEL_CHUNKS_LOAD_START,
|
||||
param: 0.0,
|
||||
}));
|
||||
g.send(&C::LevelEvent(play::LevelEvent {
|
||||
kind: 1032,
|
||||
pos: [0, 0, 0],
|
||||
data: 0,
|
||||
global: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet Horizon sends as raw bytes (no fields worth a type).
|
||||
@@ -2790,7 +2859,8 @@ pub fn guest_body(g: &Guest) -> BodyFlags {
|
||||
crouching: m.is_some_and(|m| m.pose == crate::pose::Pose::Crouching),
|
||||
spin_attack: m.is_some_and(|m| m.auto_spin_attack_ticks > 0),
|
||||
on_fire: m.is_some_and(|m| m.fire_ticks > 0),
|
||||
invisible: g.effects.get("invisibility").is_some(),
|
||||
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
|
||||
invisible: g.effects.get("invisibility").is_some() || g.spectator(),
|
||||
glowing: g.effects.get("glowing").is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,15 @@ pub enum SceneSound {
|
||||
BigFall,
|
||||
}
|
||||
impl SceneSound {
|
||||
/// The sound event of `LivingEntity.getFallDamageSound` for a player, if
|
||||
/// this is one of the two fall sounds.
|
||||
pub fn player_fall_sound(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::BigFall => Some("minecraft:entity.player.big_fall"),
|
||||
Self::SmallFall => Some("minecraft:entity.player.small_fall"),
|
||||
Self::Block(_) => None,
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::SmallFall => "small",
|
||||
@@ -529,3 +538,23 @@ impl LivingFallWorld for FallCall<'_, '_, '_, '_> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fall_sounds_map_to_player_events() {
|
||||
assert_eq!(
|
||||
SceneSound::BigFall.player_fall_sound(),
|
||||
Some("minecraft:entity.player.big_fall")
|
||||
);
|
||||
assert_eq!(
|
||||
SceneSound::SmallFall.player_fall_sound(),
|
||||
Some("minecraft:entity.player.small_fall")
|
||||
);
|
||||
assert_eq!(
|
||||
SceneSound::Block(LandingSound::HoneyFall).player_fall_sound(),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@ pub use horizon_client_render::{
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hud, hud_layout,
|
||||
hud_overlays, inventory_effects, java_text, level_loading, light, lod, logging, menu_blur,
|
||||
mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, providers, screen_draw,
|
||||
screen_effects, sculk, settings, shader_pack_list, signs, skins, sound_engine, sound_events,
|
||||
spans, split_layout, sprites, streaming, tab_list, telemetry, testgen, title_edition, trims,
|
||||
ui, vault, weather, widgets, world, worldborder, zip,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, potent_sulfur,
|
||||
providers, screen_draw, screen_effects, sculk, settings, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, spans, split_layout, sprites, streaming, tab_list, telemetry,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
|
||||
@@ -528,6 +528,9 @@ pub enum DamageKind {
|
||||
/// `dry_out`: an axolotl out of water for too long.
|
||||
DryOut,
|
||||
Magic,
|
||||
/// `indirect_magic`: a thrown potion's hit (`DamageSources.indirectMagic`),
|
||||
/// which, unlike `magic`, knocks back.
|
||||
IndirectMagic,
|
||||
Sting,
|
||||
Cactus,
|
||||
/// `sweet_berry_bush`.
|
||||
@@ -616,6 +619,16 @@ impl Damage {
|
||||
from: Some(from),
|
||||
}
|
||||
}
|
||||
/// `DamageSources.indirectMagic(potion, owner)`: `attacker` is the owner
|
||||
/// (none for a dispenser's potion), `from` the potion's position, which
|
||||
/// the knockback pushes away from.
|
||||
pub fn indirect_magic(attacker: Option<Who>, from: [f64; 3]) -> Self {
|
||||
Self {
|
||||
kind: DamageKind::IndirectMagic,
|
||||
attacker,
|
||||
from: Some(from),
|
||||
}
|
||||
}
|
||||
pub fn mob(id: u32, from: [f64; 3]) -> Self {
|
||||
Self {
|
||||
kind: DamageKind::MobAttack,
|
||||
@@ -652,6 +665,7 @@ impl Damage {
|
||||
| DamageKind::OnFire
|
||||
| DamageKind::Drown
|
||||
| DamageKind::Magic
|
||||
| DamageKind::IndirectMagic
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Generic
|
||||
| DamageKind::SonicBoom
|
||||
@@ -927,6 +941,8 @@ pub struct Extra {
|
||||
pub memories: Vec<(&'static str, i64, i64)>,
|
||||
/// A camel's `LAST_POSE_CHANGE_TICK`.
|
||||
pub pose_change_tick: i64,
|
||||
/// Whether a camel sits (`egg_time` is then the age it sat down at).
|
||||
pub camel_sitting: bool,
|
||||
/// `Entity.DATA_POSE` as a mob of a server has it (`sync::pose_id`).
|
||||
pub pose: u8,
|
||||
/// The school leader a fish follows, and a tropical fish that swims alone.
|
||||
@@ -1680,6 +1696,23 @@ impl Mob {
|
||||
.max((f64::from(ticks) * self.burning_time()).ceil() as i32);
|
||||
self.ticks_frozen = 0;
|
||||
}
|
||||
/// `Entity.extinguishFire`: a burning mob first plays the extinguish sound
|
||||
/// (`playEntityOnFireExtinguishedSound`: volume 0.7, pitch from two draws
|
||||
/// of its random, at its feet), then `clearFire` keeps only the
|
||||
/// fire-immune countdown.
|
||||
pub fn extinguish_fire(&mut self) {
|
||||
if self.is_on_fire() {
|
||||
let pitch = 1.6 + (self.random.next_float() - self.random.next_float()) * 0.4;
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
self.outbox.push(MobEvent::Sound(
|
||||
"entity.generic.extinguish_fire",
|
||||
at,
|
||||
0.7,
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
self.fire_ticks = self.fire_ticks.min(0);
|
||||
}
|
||||
/// `Entity.isFullyFrozen`.
|
||||
pub fn is_fully_frozen(&self) -> bool {
|
||||
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
|
||||
@@ -2279,7 +2312,10 @@ impl Mob {
|
||||
amount = 0.0;
|
||||
} else if matches!(
|
||||
source.kind,
|
||||
DamageKind::Magic | DamageKind::SonicBoom | DamageKind::Thorns
|
||||
DamageKind::Magic
|
||||
| DamageKind::IndirectMagic
|
||||
| DamageKind::SonicBoom
|
||||
| DamageKind::Thorns
|
||||
) {
|
||||
amount *= 0.15;
|
||||
}
|
||||
@@ -2368,6 +2404,9 @@ impl Mob {
|
||||
if equine::is_equine(self.species()) {
|
||||
equine::after_hurt_server(self);
|
||||
}
|
||||
if self.species() == Species::GlowSquid {
|
||||
aquatic::hurt_server(self);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A splash or cloud's harming effect on this mob: the potion's
|
||||
@@ -2389,6 +2428,7 @@ impl Mob {
|
||||
source.kind,
|
||||
DamageKind::Drown
|
||||
| DamageKind::Magic
|
||||
| DamageKind::IndirectMagic
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Thorns
|
||||
| DamageKind::OutOfWorld
|
||||
@@ -2666,7 +2706,12 @@ pub fn render_model(
|
||||
layer: overlay,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
// The armour glint scrolls the model UV in the shader.
|
||||
glint_uv: if overlay == render::ARMOR_GLINT {
|
||||
[v[3], v[4]]
|
||||
} else {
|
||||
[0.0, 0.0]
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user