mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Merge main into #6972
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
805ce265cd
269 files changed
+20319
-2191
No files matched your search
@@ -15,6 +15,8 @@ name: launcher
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
@@ -241,6 +243,18 @@ jobs:
|
||||
# The client starts: --help prints before any window or GPU is needed.
|
||||
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
|
||||
soft help
|
||||
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
|
||||
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
setup="$RUNNER_TEMP/setup-smoke"
|
||||
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
|
||||
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
|
||||
installed="$setup/Horizon Launcher/horizon-launcher.exe"
|
||||
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
|
||||
"$installed" uninstall --quiet
|
||||
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
|
||||
rm -rf "$setup"
|
||||
fi
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
@@ -276,6 +290,12 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
ls -l out
|
||||
- name: Setup exe (Windows)
|
||||
# The launcher's binary under another name: started with no arguments it shows the per-user
|
||||
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
|
||||
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
|
||||
if: runner.os == 'Windows'
|
||||
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
|
||||
@@ -147,7 +147,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
|
||||
+1
-10
@@ -634,16 +634,6 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
|
||||
|
||||
</details>
|
||||
|
||||
#### HDR monitors
|
||||
|
||||
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
#### MSAA, anisotropic filtering, exclusive fullscreen
|
||||
|
||||
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
|
||||
@@ -904,6 +894,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
|
||||
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
@@ -115,3 +115,35 @@ licensed under the MIT license:
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## BlueMap
|
||||
|
||||
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
|
||||
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
|
||||
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
|
||||
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
|
||||
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
|
||||
`settings.json`, at commit
|
||||
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
|
||||
BlueMap is licensed under the MIT license:
|
||||
|
||||
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
|
||||
> Copyright (c) contributors
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
> of this software and associated documentation files (the "Software"), to deal
|
||||
> in the Software without restriction, including without limitation the rights
|
||||
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
> copies of the Software, and to permit persons to whom the Software is
|
||||
> furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in
|
||||
> all copies or substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
> THE SOFTWARE.
|
||||
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// renames (`url` / `command` / `page`).
|
||||
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
@@ -249,6 +249,13 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
Some("custom") => Some(crate::text::ClickEvent::Custom {
|
||||
id: crate::identifier::parse_identifier(&text("id")?)?,
|
||||
payload: e
|
||||
.get("payload")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(crate::scoreboard::json_to_tag),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -585,6 +592,63 @@ mod tests {
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
|
||||
use crate::text::ClickEvent;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
for (payload, expected) in [
|
||||
(None, None),
|
||||
(Some(serde_json::Value::Null), None),
|
||||
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
|
||||
(
|
||||
Some(serde_json::json!({"shop": "potions", "amount": 3})),
|
||||
Some(Tag::Compound(
|
||||
[
|
||||
("amount".into(), Tag::Int(3)),
|
||||
("shop".into(), Tag::String("potions".into())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
),
|
||||
] {
|
||||
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = payload;
|
||||
}
|
||||
let component = serde_json::json!({
|
||||
"text": "", "click_event": event,
|
||||
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
|
||||
});
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
for (_, style) in text.chars() {
|
||||
assert_eq!(
|
||||
style.click,
|
||||
Some(ClickEvent::Custom {
|
||||
id: "test:shop".into(),
|
||||
payload: expected.clone(),
|
||||
})
|
||||
);
|
||||
}
|
||||
let encoded = text.component();
|
||||
let decoded = to_text(&encoded, &mut |_| Text::empty());
|
||||
assert_eq!(decoded, text);
|
||||
if expected.is_none() {
|
||||
assert!(!encoded.to_string().contains("\"payload\""));
|
||||
}
|
||||
}
|
||||
for id in ["Bad ID", "Uppercase:shop"] {
|
||||
let text = to_text(
|
||||
&serde_json::json!({
|
||||
"text": "Shop", "click_event": {"action": "custom", "id": id}
|
||||
}),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
assert_eq!(text.spans[0].style.click, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
|
||||
}
|
||||
|
||||
/// Click actions carried by locally constructed text components.
|
||||
/// Click actions carried by text components.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
/// `ClickEvent.OpenUrl`: an http or https link.
|
||||
@@ -53,6 +53,11 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<horizon_world::nbt::Tag>,
|
||||
},
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -539,7 +544,7 @@ const STYLE_KEYS: [&str; 11] = [
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
/// `Style.Serializer`: the fields a style sets.
|
||||
fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = style.color {
|
||||
@@ -581,6 +586,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::Custom { id, payload } => {
|
||||
let mut event = json!({ "action": "custom", "id": id });
|
||||
if let Some(payload) = payload {
|
||||
event["payload"] = crate::scoreboard::tag_to_json(payload);
|
||||
}
|
||||
event
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
|
||||
@@ -105,6 +105,8 @@ pub struct RenderAssets {
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
/// Fusion connected textures (empty without Fusion's connecting textures and models).
|
||||
pub fusion: crate::fusion::FusionTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -224,7 +226,7 @@ impl RenderAssets {
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
};
|
||||
let models = if draws {
|
||||
let mut models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
@@ -244,6 +246,11 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut emissive = Vec::new();
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
@@ -304,6 +311,7 @@ impl RenderAssets {
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
fusion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,452 @@
|
||||
//! Finding a server's BlueMap without the player naming it
|
||||
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
|
||||
//! likely web addresses (`candidates`), taking only one whose `settings.json`
|
||||
//! is BlueMap's web-app settings, and the answer is kept per server in
|
||||
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
|
||||
//! A `blueMap:` line the player wrote always wins.
|
||||
use crate::bluemap::Settings;
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
/// The answer kept per server, beside its BlueMap tiles.
|
||||
pub const FILE: &str = "detected.txt";
|
||||
/// A server found without a map is asked again after this long.
|
||||
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
|
||||
/// A map found is checked again (its address first) after this long.
|
||||
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||
/// Each probe gets this long, connecting included.
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Probes start this far apart at least.
|
||||
pub const GAP: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Where a level's BlueMap comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// The player's `blueMap:` line, or a map found before.
|
||||
Url {
|
||||
url: String,
|
||||
maps: Vec<(String, String)>,
|
||||
},
|
||||
/// To be looked for at these addresses, in order.
|
||||
Detect(Vec<String>),
|
||||
}
|
||||
|
||||
/// What the server at `address` draws far terrain from, its BlueMap folder
|
||||
/// being `dir`: `None` when the player named no map and either auto-detect
|
||||
/// is off or the server was found without one recently.
|
||||
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
|
||||
if let Some(server) = settings.server(address) {
|
||||
return Some(Source::Url {
|
||||
url: server.url.clone(),
|
||||
maps: server.maps.clone(),
|
||||
});
|
||||
}
|
||||
if !settings.auto_detect {
|
||||
return None;
|
||||
}
|
||||
let mut probes = Vec::new();
|
||||
// Only an address this server's would be looked for at: a remembered
|
||||
// one from elsewhere (a copied folder) names someone else's.
|
||||
let ours = candidates(address);
|
||||
match remembered(dir, now) {
|
||||
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
|
||||
Remembered::Found(url) => {
|
||||
return Some(Source::Url {
|
||||
url,
|
||||
maps: Vec::new(),
|
||||
});
|
||||
}
|
||||
Remembered::Nothing => return None,
|
||||
Remembered::Stale(Some(url)) => probes.push(url),
|
||||
Remembered::Stale(None) => {}
|
||||
}
|
||||
for candidate in ours {
|
||||
if !probes.contains(&candidate) {
|
||||
probes.push(candidate);
|
||||
}
|
||||
}
|
||||
(!probes.is_empty()).then_some(Source::Detect(probes))
|
||||
}
|
||||
|
||||
/// The first labels a game server's host name takes beside its site's.
|
||||
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
|
||||
|
||||
/// Domains whose names belong to many customers (dynamic DNS, server
|
||||
/// hosts): `map.` of them is someone else's.
|
||||
const SHARED: &[&str] = &[
|
||||
"aternos.me",
|
||||
"ddns.net",
|
||||
"duckdns.org",
|
||||
"hopto.org",
|
||||
"minehut.gg",
|
||||
"no-ip.org",
|
||||
"playit.gg",
|
||||
"serveminecraft.net",
|
||||
"zapto.org",
|
||||
];
|
||||
|
||||
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
|
||||
/// web server (port 8100), the server's host, and its `map.` subdomain, also
|
||||
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
|
||||
pub fn candidates(address: &str) -> Vec<String> {
|
||||
let address = address.trim().to_ascii_lowercase();
|
||||
let host = if let Some(rest) = address.strip_prefix('[') {
|
||||
rest.split(']').next().unwrap_or(rest)
|
||||
} else if address.matches(':').count() > 1 {
|
||||
&address[..]
|
||||
} else {
|
||||
address.split(':').next().unwrap_or(&address)
|
||||
};
|
||||
let host = host.trim_end_matches('.');
|
||||
if host.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
// Certificates are for names: plain HTTP only.
|
||||
let host = match ip {
|
||||
IpAddr::V6(_) => format!("[{host}]"),
|
||||
IpAddr::V4(_) => host.to_owned(),
|
||||
};
|
||||
out.push(format!("http://{host}:8100"));
|
||||
out.push(format!("http://{host}"));
|
||||
return out;
|
||||
}
|
||||
if !host
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
out.push(format!("http://{host}:8100"));
|
||||
let labels = host.split('.').count();
|
||||
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
|
||||
if labels >= 2
|
||||
&& let Some(map) = map(host)
|
||||
{
|
||||
out.push(format!("https://{map}"));
|
||||
}
|
||||
out.push(format!("https://{host}"));
|
||||
if labels >= 2
|
||||
&& let Some(map) = map(host)
|
||||
{
|
||||
out.push(format!("http://{map}"));
|
||||
}
|
||||
out.push(format!("http://{host}"));
|
||||
// `play.example.net` → `map.example.net`, only for a game server's
|
||||
// usual names: the parent of `name.hosting.example` is another party's.
|
||||
if labels >= 3
|
||||
&& let Some((first, parent)) = host.split_once('.')
|
||||
&& GAME_LABELS.contains(&first)
|
||||
&& !SHARED.contains(&parent)
|
||||
&& let Some(map) = map(parent)
|
||||
{
|
||||
out.push(format!("https://{map}"));
|
||||
out.push(format!("http://{map}"));
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
out.retain(|url| {
|
||||
let new = !seen.contains(url);
|
||||
seen.push(url.clone());
|
||||
new
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
|
||||
/// and where their data lies, not any page a web server returns.
|
||||
pub fn is_web_app_settings(json: &[u8]) -> bool {
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
|
||||
return false;
|
||||
};
|
||||
let maps = v.get("maps").and_then(|m| m.as_array());
|
||||
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
|
||||
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
|
||||
}
|
||||
|
||||
/// Whether a BlueMap web app answers at `base`.
|
||||
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
|
||||
use std::io::Read;
|
||||
let Ok(response) = agent
|
||||
.get(&format!("{base}/settings.json"))
|
||||
.timeout(TIMEOUT)
|
||||
.call()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(256 << 10)
|
||||
.read_to_end(&mut body)
|
||||
.is_ok()
|
||||
&& is_web_app_settings(&body)
|
||||
}
|
||||
|
||||
/// What an earlier look found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Remembered {
|
||||
Found(String),
|
||||
/// Nothing, recently.
|
||||
Nothing,
|
||||
/// Never looked, or long enough ago to look again (first at the map
|
||||
/// found then, if any).
|
||||
Stale(Option<String>),
|
||||
}
|
||||
|
||||
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
|
||||
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
let mut words = text.split_whitespace();
|
||||
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
|
||||
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
|
||||
let Ok(age) = now.duration_since(at) else {
|
||||
return Remembered::Stale(None);
|
||||
};
|
||||
match (kind, words.next()) {
|
||||
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
|
||||
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
|
||||
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
|
||||
_ => Remembered::Stale(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps what a look found (`None`: no map).
|
||||
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
|
||||
let secs = now
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let text = match found {
|
||||
Some(url) => format!("found {secs} {url}\n"),
|
||||
None => format!("none {secs}\n"),
|
||||
};
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = std::fs::write(dir.join(FILE), text);
|
||||
}
|
||||
|
||||
/// The servers being looked at now, by BlueMap folder: one look a server at
|
||||
/// a time, however many levels open meanwhile.
|
||||
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The right to look for `dir`'s server's map, until dropped.
|
||||
pub struct Claim(PathBuf);
|
||||
impl Claim {
|
||||
/// `None` while another look at the same server runs.
|
||||
pub fn take(dir: &Path) -> Option<Self> {
|
||||
let mut looking = LOOKING.lock().unwrap();
|
||||
if looking.iter().any(|d| d == dir) {
|
||||
return None;
|
||||
}
|
||||
looking.push(dir.to_owned());
|
||||
Some(Self(dir.to_owned()))
|
||||
}
|
||||
}
|
||||
impl Drop for Claim {
|
||||
fn drop(&mut self) {
|
||||
LOOKING.lock().unwrap().retain(|d| *d != self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn candidates_cover_the_web_server_host_and_map_subdomains() {
|
||||
assert_eq!(
|
||||
candidates("Play.Example.net:25566"),
|
||||
[
|
||||
"http://play.example.net:8100",
|
||||
"https://map.play.example.net",
|
||||
"https://play.example.net",
|
||||
"http://map.play.example.net",
|
||||
"http://play.example.net",
|
||||
"https://map.example.net",
|
||||
"http://map.example.net",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("example.net"),
|
||||
[
|
||||
"http://example.net:8100",
|
||||
"https://map.example.net",
|
||||
"https://example.net",
|
||||
"http://map.example.net",
|
||||
"http://example.net",
|
||||
]
|
||||
);
|
||||
// Already the map's host: no `map.map.`.
|
||||
assert_eq!(
|
||||
candidates("map.example.net"),
|
||||
[
|
||||
"http://map.example.net:8100",
|
||||
"https://map.example.net",
|
||||
"http://map.example.net",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("192.0.2.7:25565"),
|
||||
["http://192.0.2.7:8100", "http://192.0.2.7"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("[2001:db8::7]:25565"),
|
||||
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("2001:db8::7"),
|
||||
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidates("localhost"),
|
||||
[
|
||||
"http://localhost:8100",
|
||||
"https://localhost",
|
||||
"http://localhost"
|
||||
]
|
||||
);
|
||||
// A hosted name's parent is the host's, not the server's.
|
||||
assert!(
|
||||
!candidates("play.ddns.net")
|
||||
.iter()
|
||||
.any(|url| url.ends_with("//map.ddns.net"))
|
||||
);
|
||||
assert!(
|
||||
!candidates("name.host.example")
|
||||
.iter()
|
||||
.any(|url| url.ends_with("//map.host.example"))
|
||||
);
|
||||
assert!(candidates("").is_empty());
|
||||
assert!(candidates("evil.example.net/path?x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_blue_maps_settings_count() {
|
||||
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
|
||||
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
|
||||
assert!(is_web_app_settings(web_app));
|
||||
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
|
||||
assert!(
|
||||
!is_web_app_settings(br#"{"maps":["world"]}"#),
|
||||
"no data root"
|
||||
);
|
||||
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"{"maps":[1],"mapDataRoot":"maps"}"#
|
||||
));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"{"maps":"world","mapDataRoot":"maps"}"#
|
||||
));
|
||||
assert!(!is_web_app_settings(
|
||||
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
|
||||
));
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-bluemap-detect-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_are_kept_and_looked_at_again_later() {
|
||||
let dir = scratch("remember");
|
||||
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let hour = Duration::from_secs(3600);
|
||||
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
|
||||
remember(&dir, None, t0);
|
||||
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
|
||||
// Written ahead of the clock: not trusted.
|
||||
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
|
||||
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
|
||||
remember(&dir, Some("https://map.example.net"), t0);
|
||||
assert_eq!(
|
||||
remembered(&dir, t0 + 2 * RETRY_NONE),
|
||||
Remembered::Found("https://map.example.net".into())
|
||||
);
|
||||
assert_eq!(
|
||||
remembered(&dir, t0 + RETRY_FOUND),
|
||||
Remembered::Stale(Some("https://map.example.net".into()))
|
||||
);
|
||||
std::fs::write(dir.join(FILE), "garbage").unwrap();
|
||||
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_players_map_wins_and_detection_waits_for_the_setting() {
|
||||
let dir = scratch("source");
|
||||
let now = SystemTime::now();
|
||||
let mut settings = Settings::default();
|
||||
let address = "play.example.net";
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
None,
|
||||
"off by default"
|
||||
);
|
||||
settings.auto_detect = true;
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
|
||||
panic!("looked for");
|
||||
};
|
||||
assert_eq!(probes, candidates(address));
|
||||
remember(&dir, None, now);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
None,
|
||||
"none, recently"
|
||||
);
|
||||
remember(&dir, Some("https://map.example.net"), now);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
Some(Source::Url {
|
||||
url: "https://map.example.net".into(),
|
||||
maps: Vec::new()
|
||||
})
|
||||
);
|
||||
// A remembered map this server's addresses don't include (a copied
|
||||
// folder's) is no map.
|
||||
remember(&dir, Some("https://tracker.example"), now);
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
|
||||
panic!("looked for, the remembered one ignored");
|
||||
};
|
||||
assert_eq!(probes, candidates(address));
|
||||
remember(&dir, Some("https://map.example.net"), now);
|
||||
// Past a week the found map is tried first, then the rest.
|
||||
let later = now + RETRY_FOUND;
|
||||
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
|
||||
panic!("looked for again");
|
||||
};
|
||||
assert_eq!(probes[0], "https://map.example.net");
|
||||
assert_eq!(probes.len(), candidates(address).len());
|
||||
settings.servers.push(
|
||||
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
source(&settings, address, &dir, now),
|
||||
Some(Source::Url {
|
||||
url: "http://example.net:8123".into(),
|
||||
maps: vec![("minecraft:the_nether".into(), "n".into())]
|
||||
})
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_look_a_server_at_a_time() {
|
||||
let dir = scratch("claim");
|
||||
let claim = Claim::take(&dir).unwrap();
|
||||
assert!(Claim::take(&dir).is_none());
|
||||
drop(claim);
|
||||
assert!(Claim::take(&dir).is_some());
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ mod tests {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
quad: ModelQuad {
|
||||
positions: [
|
||||
[0.0, 0.5, 0.0],
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum Pick {
|
||||
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
Quarters([TileLayer; 4]),
|
||||
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// A tile drawn over a face, after its own (`overlay*` methods).
|
||||
|
||||
@@ -389,6 +389,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:jukebox" {
|
||||
return Some(Self::Jukebox(Jukebox::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
@@ -729,6 +732,20 @@ mod tests {
|
||||
assert_eq!(entity.contents().len(), 1);
|
||||
}
|
||||
|
||||
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
|
||||
/// so the item's block entity data lands somewhere and the box can hold a
|
||||
/// disc without a player putting one in.
|
||||
#[test]
|
||||
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
|
||||
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
|
||||
assert_eq!(entity.id(), "minecraft:jukebox");
|
||||
assert!(matches!(entity, BlockEntity::Jukebox(_)));
|
||||
assert_eq!(entity.slots().len(), 1);
|
||||
assert!(entity.slots()[0].is_none());
|
||||
// Another block's name is not a jukebox.
|
||||
assert!(BlockEntity::for_block("minecraft:stone").is_none());
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -375,6 +375,14 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
|
||||
}
|
||||
|
||||
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
|
||||
// sky, so they take its value, which their blending would otherwise cover.
|
||||
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
|
||||
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
|
||||
if a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
|
||||
// vertex colour, with fog fading both RGB and alpha before additive blending.
|
||||
|
||||
@@ -135,6 +135,9 @@ pub struct Extras {
|
||||
pub splitscreen_three: u8,
|
||||
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
|
||||
pub splitscreen_names: bool,
|
||||
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
|
||||
/// on by default as vanilla); off, the wheel only scrolls the list.
|
||||
pub wheel_cycles_settings: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -177,6 +180,7 @@ impl Default for Extras {
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
splitscreen_names: true,
|
||||
wheel_cycles_settings: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +265,7 @@ impl Extras {
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
|
||||
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -268,7 +273,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -312,6 +317,7 @@ impl Extras {
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
bool_text(self.splitscreen_names),
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -456,6 +462,7 @@ pub enum Item {
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
WheelCyclesSettings,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -480,6 +487,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
@@ -503,6 +512,7 @@ impl Item {
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -525,6 +535,7 @@ impl Item {
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -556,6 +567,7 @@ impl Item {
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -568,7 +580,7 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
| ChatAnimation | WheelCyclesSettings => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -623,6 +635,9 @@ impl Item {
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -638,7 +653,8 @@ impl Item {
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -661,6 +677,7 @@ impl Item {
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -694,6 +711,7 @@ impl Item {
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -717,7 +735,7 @@ mod tests {
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles);
|
||||
assert!(d.discord_presence);
|
||||
assert!(d.discord_presence && d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -758,6 +776,8 @@ mod tests {
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
Item::WheelCyclesSettings.cycle(&mut e, false);
|
||||
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
|
||||
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
|
||||
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
|
||||
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
|
||||
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
|
||||
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
|
||||
//!
|
||||
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
|
||||
//! turned about x, or turned with `uvlock`, join a quarter turn off.
|
||||
//!
|
||||
//! The format is `horizon_optifine::fusion`'s.
|
||||
|
||||
use crate::connected::{Overlay, Pick, TileLayer};
|
||||
use crate::models::StateModels;
|
||||
use crate::sprites::Sprites;
|
||||
use horizon_optifine::cem::json::Json;
|
||||
use horizon_optifine::ctm::connect::{self, Axes, Face};
|
||||
use horizon_optifine::fusion::{
|
||||
Connection, Layout, Tiles,
|
||||
model::{Connections, ModelType},
|
||||
predicate::Query,
|
||||
texture::{Kind, Meta},
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
|
||||
|
||||
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
|
||||
struct Sheet {
|
||||
layout: Layout,
|
||||
tiles: Vec<Option<TileLayer>>,
|
||||
}
|
||||
|
||||
/// A sheet and what it joins by, blocks as sorted state ids.
|
||||
struct Rule {
|
||||
sheet: usize,
|
||||
connection: Connection<Vec<u32>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FusionTextures {
|
||||
sheets: Vec<Sheet>,
|
||||
/// By [`crate::models::BakedFace::fusion`].
|
||||
rules: Vec<Rule>,
|
||||
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
|
||||
blocks: Vec<u32>,
|
||||
}
|
||||
|
||||
/// A model's reference to a texture or another model (`block/glass`), namespaced.
|
||||
fn namespaced(reference: &str) -> String {
|
||||
horizon_optifine::matchers::namespaced(reference)
|
||||
}
|
||||
|
||||
fn read_model(root: &Path, name: &str) -> Option<Json> {
|
||||
let (ns, path) = name.split_once(':')?;
|
||||
let text = fs::read_to_string(root.join(format!("assets/{ns}/models/{path}.json"))).ok()?;
|
||||
Json::parse(&text).ok()
|
||||
}
|
||||
|
||||
/// What a model's face draws and joins by: its sprite, its side in the model, and its
|
||||
/// predicates (`None`: the texture's own).
|
||||
type ModelFace = (String, Face, Option<Connection>);
|
||||
|
||||
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
|
||||
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
|
||||
let Some(model) = read_model(root, name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if ModelType::of(&model) != Some(ModelType::Connecting) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
|
||||
// levels is a loop.
|
||||
let mut chain = vec![model];
|
||||
while chain.len() < 16
|
||||
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
|
||||
&& let Some(parent) = read_model(root, &namespaced(parent))
|
||||
{
|
||||
chain.push(parent);
|
||||
}
|
||||
// Parents first, so a child's keys replace theirs.
|
||||
let mut connections = Connections::Textures;
|
||||
let mut textures: HashMap<&str, &str> = HashMap::new();
|
||||
for model in chain.iter().rev() {
|
||||
match Connections::of(model) {
|
||||
Ok(own) => connections = own.inherit(&connections),
|
||||
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
|
||||
}
|
||||
for (key, value) in model
|
||||
.get("textures")
|
||||
.and_then(Json::as_object)
|
||||
.unwrap_or(&[])
|
||||
{
|
||||
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
|
||||
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
|
||||
if let Some(value) = value {
|
||||
textures.insert(key.as_str(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A `#key` as vanilla resolves it.
|
||||
let resolve = |reference: &str| {
|
||||
let mut value = reference;
|
||||
for _ in 0..16 {
|
||||
match value.strip_prefix('#') {
|
||||
Some(key) => value = textures.get(key)?,
|
||||
None => return Some(namespaced(value)),
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
// The elements are the nearest model's that has some.
|
||||
let elements = chain
|
||||
.iter()
|
||||
.find_map(|m| m.get("elements")?.as_array())
|
||||
.unwrap_or(&[]);
|
||||
let mut out: Vec<ModelFace> = Vec::new();
|
||||
let faces = elements
|
||||
.iter()
|
||||
.filter_map(|e| e.get("faces")?.as_object())
|
||||
.flatten();
|
||||
for (side, face) in faces {
|
||||
let side = match side.as_str() {
|
||||
"down" => Face::Down,
|
||||
"up" => Face::Up,
|
||||
"north" => Face::North,
|
||||
"south" => Face::South,
|
||||
"west" => Face::West,
|
||||
"east" => Face::East,
|
||||
_ => continue,
|
||||
};
|
||||
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(sprite) = resolve(texture) else {
|
||||
continue;
|
||||
};
|
||||
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
|
||||
let key = face.get("connections").and_then(Json::as_str);
|
||||
out.push((sprite, side, connections.for_face(key, texture).cloned()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
|
||||
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
|
||||
let mut n = face.normal();
|
||||
for _ in 0..x % 4 {
|
||||
// x=90 takes up to north.
|
||||
n = [n[0], n[2], -n[1]];
|
||||
}
|
||||
for _ in 0..y % 4 {
|
||||
// y=90 takes north to east.
|
||||
n = [-n[2], n[1], n[0]];
|
||||
}
|
||||
Face::ALL
|
||||
.into_iter()
|
||||
.find(|f| f.normal() == n)
|
||||
.expect("a quarter turn maps faces onto faces")
|
||||
}
|
||||
|
||||
/// The connecting textures under `root`, by sprite name.
|
||||
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
let mut out = HashMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let ns_name = ns.file_name().to_string_lossy().into_owned();
|
||||
let textures = ns.path().join("textures");
|
||||
let mut dirs = vec![textures.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
// Linked folders too: mods' content links into the packs' tree.
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
|
||||
fn sheet(
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
name: &str,
|
||||
meta: Meta,
|
||||
) -> Option<(Sheet, Connection)> {
|
||||
let Kind::Connecting {
|
||||
layout,
|
||||
connections,
|
||||
per_tile_animation,
|
||||
..
|
||||
} = meta.kind
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let tiles = sprites
|
||||
.insert_tiles(root, name, layout.grid(), per_tile_animation)
|
||||
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
|
||||
.ok()?;
|
||||
// A layer past the mesher's `u16` leaves the sheet to vanilla.
|
||||
let tiles = tiles
|
||||
.into_iter()
|
||||
.map(|t| match t {
|
||||
None => Some(None),
|
||||
Some(l) => Some(Some(TileLayer::Layer {
|
||||
layer: u16::try_from(l).ok()?,
|
||||
translucent: sprites.translucent[l as usize],
|
||||
opaque: sprites.opaque[l as usize],
|
||||
})),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((Sheet { layout, tiles }, connections))
|
||||
}
|
||||
|
||||
impl FusionTextures {
|
||||
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
|
||||
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
|
||||
/// shows one gets its rule.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
catalog: &mut StateModels,
|
||||
) -> Self {
|
||||
let mut textures = connecting_textures(root);
|
||||
if textures.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// The layers models show the sheets as: only their models are read.
|
||||
let shown: std::collections::HashSet<u32> = textures
|
||||
.keys()
|
||||
.filter_map(|name| sprites.layers.get(name).copied())
|
||||
.collect();
|
||||
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
|
||||
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
|
||||
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
|
||||
let mut rules: Vec<(usize, Connection)> = Vec::new();
|
||||
for (variant, model) in catalog.models_mut() {
|
||||
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
|
||||
continue;
|
||||
}
|
||||
let found = models
|
||||
.entry(namespaced(&variant.model))
|
||||
.or_insert_with_key(|name| connecting_model(root, name));
|
||||
for face in &mut model.faces {
|
||||
let sprite = &face.quad.material.sprite;
|
||||
// The model's face on the side this one turned to, else any with its sprite.
|
||||
let side = Face::ALL[face.quad.facing as usize];
|
||||
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
|
||||
let Some((_, _, connection)) = drawing
|
||||
.clone()
|
||||
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
|
||||
.or_else(|| drawing.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// The sheet, cut into tiles on first use.
|
||||
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
|
||||
let meta = textures.remove(sprite)?;
|
||||
sheets.push(sheet(root, sprites, sprite, meta)?);
|
||||
Some(sheets.len() - 1)
|
||||
});
|
||||
let Some(sheet) = sheet else {
|
||||
continue;
|
||||
};
|
||||
let connection = connection
|
||||
.clone()
|
||||
.unwrap_or_else(|| sheets[sheet].1.clone());
|
||||
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
|
||||
Some(rule) => rule,
|
||||
None => {
|
||||
rules.push((sheet, connection));
|
||||
rules.len() - 1
|
||||
}
|
||||
};
|
||||
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
|
||||
}
|
||||
}
|
||||
let states = (0..registry.len() as u32).map(|id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
|
||||
Self::build(sheets, rules, states)
|
||||
}
|
||||
|
||||
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
|
||||
fn build<'a>(
|
||||
sheets: Vec<Sheet>,
|
||||
rules: Vec<(usize, Connection)>,
|
||||
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
|
||||
) -> Self {
|
||||
let states: Vec<_> = states.collect();
|
||||
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
let mut names = HashMap::new();
|
||||
let blocks = (0..states.len() as u32)
|
||||
.map(|id| match states[id as usize] {
|
||||
Some((name, _)) => {
|
||||
block_states.entry(name).or_default().push(id);
|
||||
let next = names.len() as u32;
|
||||
*names.entry(name).or_insert(next)
|
||||
}
|
||||
None => u32::MAX,
|
||||
})
|
||||
.collect();
|
||||
// Block lists to the states they match.
|
||||
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
|
||||
let mut ids: Vec<u32> = list
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
let candidates = block_states
|
||||
.get(m.id.as_str())
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
candidates.iter().copied().filter(|&id| {
|
||||
states[id as usize].is_some_and(|(name, props)| {
|
||||
m.matches(name, |k| props.get(k).map(String::as_str))
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
};
|
||||
let rules = rules
|
||||
.into_iter()
|
||||
.map(|(sheet, connection)| Rule {
|
||||
sheet,
|
||||
connection: connection.map_blocks(&mut resolve),
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sheets,
|
||||
rules,
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
|
||||
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
|
||||
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
|
||||
/// pieces, tinted as the quad, go to `overlay`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn pick(
|
||||
&self,
|
||||
rule: NonZeroU16,
|
||||
state: u32,
|
||||
face: Face,
|
||||
tint: i32,
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
covers: impl Fn(u32) -> bool,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
let rule = self.rules.get(usize::from(rule.get() - 1))?;
|
||||
let normal = face.normal();
|
||||
let offsets = connect::offsets(Axes::cube(face));
|
||||
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
|
||||
let mask = rule.connection.mask(|bit, query| {
|
||||
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
|
||||
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
|
||||
match query {
|
||||
Query::SameBlock => {
|
||||
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
|
||||
}
|
||||
Query::SameState => neighbour([x, y, z]) == state,
|
||||
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
|
||||
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
|
||||
Query::FaceVisible => !covers(in_front()),
|
||||
}
|
||||
});
|
||||
let sheet = &self.sheets[rule.sheet];
|
||||
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
|
||||
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
|
||||
// never need.
|
||||
Some(match sheet.layout.tiles(mask) {
|
||||
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
|
||||
Tiles::Quarters(q) => match q.map(tile) {
|
||||
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
|
||||
_ => Pick::Nothing,
|
||||
},
|
||||
Tiles::Overlay(mask) => {
|
||||
for t in Layout::overlay_tiles(mask) {
|
||||
if let Some(TileLayer::Layer {
|
||||
layer, translucent, ..
|
||||
}) = tile(t)
|
||||
{
|
||||
overlay(Overlay {
|
||||
layer,
|
||||
translucent,
|
||||
tint: (tint >= 0).then_some((tint, None)),
|
||||
});
|
||||
}
|
||||
}
|
||||
Pick::Nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
|
||||
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
|
||||
let (w, h) = layout.grid();
|
||||
Sheet {
|
||||
layout,
|
||||
tiles: (0..(w * h) as u16)
|
||||
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn predicate(json: &str) -> Connection {
|
||||
Connection::parse(&Json::parse(json).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn layer(layer: u16) -> TileLayer {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lookups over blocks named `names` (a state each).
|
||||
fn world(
|
||||
names: &[&str],
|
||||
sheets: Vec<Sheet>,
|
||||
rules: Vec<(usize, Connection)>,
|
||||
) -> FusionTextures {
|
||||
let none = BlockProperties::default();
|
||||
let states = names.iter().map(|n| Some((*n, &none)));
|
||||
FusionTextures::build(sheets, rules, states)
|
||||
}
|
||||
|
||||
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
|
||||
/// `opaque`; with the overlays it hands out.
|
||||
fn pick_in(
|
||||
fusion: &FusionTextures,
|
||||
rule: u16,
|
||||
state: u32,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
opaque: &[u32],
|
||||
) -> (Option<Pick>, Vec<u16>) {
|
||||
// A face's rule counts from 1.
|
||||
let rule = NonZeroU16::new(rule + 1).unwrap();
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
let mut overlays = Vec::new();
|
||||
let pick = fusion.pick(
|
||||
rule,
|
||||
state,
|
||||
face,
|
||||
-1,
|
||||
at,
|
||||
|s| opaque.contains(&s),
|
||||
|o| overlays.push(o.layer),
|
||||
);
|
||||
(pick, overlays)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_pieces_join_the_same_block() {
|
||||
// Glass twice (two states of one block), then stone.
|
||||
let fusion = world(
|
||||
&[
|
||||
"minecraft:air",
|
||||
"minecraft:glass",
|
||||
"minecraft:glass",
|
||||
"minecraft:stone",
|
||||
],
|
||||
vec![sheet(Layout::Pieced, 100, &[])],
|
||||
vec![(0, Connection::SameBlock)],
|
||||
);
|
||||
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
|
||||
// Alone: every quarter the lone tile.
|
||||
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
|
||||
// A north face's right is west: glass there (in its other state) joins the right
|
||||
// quarters, the horizontal piece. Stone doesn't.
|
||||
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
|
||||
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
|
||||
assert_eq!(
|
||||
at(&[([-1, 0, 0], 3)]),
|
||||
Some(Pick::Quarters([layer(100); 4]))
|
||||
);
|
||||
// A rule that doesn't exist draws as vanilla.
|
||||
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_reach_toward_visible_sand() {
|
||||
let sand = predicate(
|
||||
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
|
||||
);
|
||||
let fusion = world(
|
||||
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
|
||||
vec![sheet(Layout::Overlay, 200, &[7])],
|
||||
vec![(0, sand)],
|
||||
);
|
||||
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
|
||||
// over a face that draws nothing itself.
|
||||
let north = [([0, 0, -1], 2)];
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
|
||||
(Some(Pick::Nothing), vec![213])
|
||||
);
|
||||
// Stone over that sand hides its top: no edge.
|
||||
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
|
||||
(Some(Pick::Nothing), vec![])
|
||||
);
|
||||
// Sand all round: the ring, (4, 1).
|
||||
let ring: Vec<_> = [-1, 0, 1]
|
||||
.into_iter()
|
||||
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
|
||||
.filter(|(o, _)| *o != [0, 0, 0])
|
||||
.collect();
|
||||
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
|
||||
// Its pieces take the quad's tint index.
|
||||
let mut tints = Vec::new();
|
||||
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
|
||||
let rule = NonZeroU16::new(1).unwrap();
|
||||
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
|
||||
assert_eq!(tints, [Some((0, None))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_front_and_empty_tiles() {
|
||||
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
|
||||
// lone tile empty.
|
||||
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
|
||||
let fusion = world(
|
||||
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
|
||||
vec![sheet(Layout::Full, 300, &[0])],
|
||||
vec![(0, front)],
|
||||
);
|
||||
// Sand on the block east of the top face: joined right, the full template's (1, 0).
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
|
||||
Some(Pick::Tile(layer(301)))
|
||||
);
|
||||
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
|
||||
assert_eq!(
|
||||
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
|
||||
Some(Pick::Nothing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_name_the_predicates_their_faces_draw() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
|
||||
let models = root.join("assets/demo/models/block");
|
||||
fs::create_dir_all(&models).unwrap();
|
||||
// A pane arm over a template whose `particle` names the pane sheet but draws no face
|
||||
// with it (as Fusion Connected Glass's posts do): only the faces count.
|
||||
fs::write(
|
||||
models.join("template.json"),
|
||||
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
|
||||
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
models.join("arm.json"),
|
||||
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
|
||||
"connections": {"edge": {"type": "true"}},
|
||||
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
models.join("plain.json"),
|
||||
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let arm = connecting_model(&root, "demo:block/arm");
|
||||
let plain = connecting_model(&root, "demo:block/plain");
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// `edge` has the model's predicate; `pane` none, so the texture's own.
|
||||
assert_eq!(
|
||||
arm,
|
||||
[
|
||||
(
|
||||
"demo:block/edge".to_owned(),
|
||||
Face::Up,
|
||||
Some(Connection::Const(true))
|
||||
),
|
||||
("demo:block/glass".to_owned(), Face::West, None),
|
||||
]
|
||||
);
|
||||
// Not a Fusion model: vanilla.
|
||||
assert!(plain.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_of_one_sprite_join_by_their_own_keys() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
|
||||
let models = root.join("assets/demo/models/block");
|
||||
fs::create_dir_all(&models).unwrap();
|
||||
// The wiki's example: four keys on one texture, two of them joining by `blue`.
|
||||
fs::write(
|
||||
models.join("tiles.json"),
|
||||
r##"{"loader": "fusion:model", "type": "connecting",
|
||||
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
|
||||
"connections": {"blue": {"type": "match_block", "block": "ice"},
|
||||
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
|
||||
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
|
||||
"up": {"texture": "#ice", "connections": "#blue"},
|
||||
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let faces = connecting_model(&root, "demo:block/tiles");
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
|
||||
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
|
||||
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
|
||||
assert_eq!(joins(Face::South), Some(Connection::SameState));
|
||||
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
|
||||
// the east.
|
||||
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
|
||||
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
|
||||
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
//! world coordinates keep full precision.
|
||||
use crate::{
|
||||
Result,
|
||||
block_entity_render::{StaticCacheFrameStats, StaticSectionRevision, StaticSectionSnapshot},
|
||||
environment::Sky,
|
||||
fast_hash::FxHashMap,
|
||||
graphics::GraphicsApi,
|
||||
hud::{HudBatch, HudRenderer},
|
||||
mesher::{Part as Quads, SectionMesh, Sides, TerrainVertex, Vertex},
|
||||
@@ -33,6 +35,12 @@ fn entity_shader() -> String {
|
||||
crate::overlay::wgsl_constants() + include_str!("entity.wgsl")
|
||||
}
|
||||
|
||||
/// The static block-entity vertex entry point shares entity shading but lives
|
||||
/// in its own source file and pipeline.
|
||||
fn static_entity_shader() -> String {
|
||||
entity_shader() + include_str!("static_entity.wgsl")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crumbling_tests;
|
||||
mod entity_batch;
|
||||
@@ -332,6 +340,20 @@ struct Globals {
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct StaticEntityUniform {
|
||||
camera_block: [i32; 4],
|
||||
camera_fraction: [f32; 4],
|
||||
}
|
||||
struct GpuStaticEntitySection {
|
||||
buffer: wgpu::Buffer,
|
||||
origin_buffer: wgpu::Buffer,
|
||||
origin: [i32; 3],
|
||||
capacity: u64,
|
||||
revision: u64,
|
||||
vertices: u32,
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
|
||||
/// it, at half the world's resolution (the material stage's `reflection`).
|
||||
@@ -525,6 +547,9 @@ const COMPACT_BUDGET: u64 = 32 << 20;
|
||||
/// A large translucent page starts to shrink (`Renderer::shrink_translucent`)
|
||||
/// this long after terrain last arrived, and `SHRINK_RETRY` after a try found
|
||||
/// no room.
|
||||
/// `Renderer::remove_columns` filters each store once rather than looking up every
|
||||
/// section key from this many keys, or from as many as there are resident sections.
|
||||
const BULK_REMOVAL_KEYS: usize = 256;
|
||||
const SHRINK_SETTLE: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
const SHRINK_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
/// Not before then may a large translucent page start to shrink.
|
||||
@@ -620,6 +645,12 @@ pub struct Frame<'a> {
|
||||
pub nether_lighting: bool,
|
||||
/// Camera-relative entity geometry textured from the shared atlas.
|
||||
pub entities: &'a [Vertex],
|
||||
/// Per-section cacheable block-entity models. Positions are section-local.
|
||||
pub static_block_entities: &'a [StaticSectionSnapshot],
|
||||
/// Sections currently intersecting the coarse camera frustum.
|
||||
pub visible_static_block_entity_sections: &'a [[i32; 3]],
|
||||
/// Cache work for this frame, reported in bench summaries.
|
||||
pub static_block_entity_stats: StaticCacheFrameStats,
|
||||
/// Which entity `entities`' vertices belong to: (first vertex, entity id), each run up
|
||||
/// to the next; id 0 for none. Frame generation follows these entities' own motion.
|
||||
pub entity_motion: &'a [(u32, u64)],
|
||||
@@ -812,6 +843,12 @@ pub struct Renderer {
|
||||
mirror_group: wgpu::BindGroup,
|
||||
mirror: Option<MirrorTarget>,
|
||||
entity_pipeline: wgpu::RenderPipeline,
|
||||
static_entity_pipeline: wgpu::RenderPipeline,
|
||||
static_entity_group: wgpu::BindGroup,
|
||||
static_entity_transform: wgpu::Buffer,
|
||||
static_entity_camera: Option<DVec3>,
|
||||
static_entity_catalog_revision: u64,
|
||||
static_entity_frame_stats: StaticCacheFrameStats,
|
||||
hdr_mark_pipeline: wgpu::RenderPipeline,
|
||||
hdr_hand_unmark_pipeline: wgpu::RenderPipeline,
|
||||
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
|
||||
@@ -822,6 +859,7 @@ pub struct Renderer {
|
||||
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
|
||||
water_mask_pipeline: wgpu::RenderPipeline,
|
||||
cloud_pipeline: wgpu::RenderPipeline,
|
||||
hdr_cloud_mark_pipeline: wgpu::RenderPipeline,
|
||||
cloud_mesh: crate::clouds::Mesh,
|
||||
cloud_buffer: wgpu::Buffer,
|
||||
cloud_capacity: u64,
|
||||
@@ -860,6 +898,9 @@ pub struct Renderer {
|
||||
hand_globals: wgpu::Buffer,
|
||||
entity_buffer: wgpu::Buffer,
|
||||
entity_capacity: u64,
|
||||
static_entity_sections: HashMap<[i32; 3], GpuStaticEntitySection>,
|
||||
static_entity_indices: wgpu::Buffer,
|
||||
static_entity_index_quads: u32,
|
||||
entity_batch: EntityBatch,
|
||||
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
|
||||
entity_skin: Option<entity_skin::EntitySkin>,
|
||||
@@ -921,7 +962,7 @@ pub struct Renderer {
|
||||
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
detail_columns: FxHashMap<(i32, i32), SectionMask>,
|
||||
/// Bumped whenever a column becomes or stops being complete.
|
||||
detail_revision: u64,
|
||||
arena: arena::Arena,
|
||||
@@ -970,10 +1011,17 @@ pub struct Renderer {
|
||||
/// The shadow camera's draws by page, in bands of distance from the light.
|
||||
shadow_runs: page_runs::PageRuns,
|
||||
/// Block-entity blocks per meshed section (world position, state id).
|
||||
block_entities: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
block_entities: FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
/// Changes on every mesh upload for that section; block-entity static
|
||||
/// geometry also depends on the section's packed light.
|
||||
block_entity_section_revisions: FxHashMap<SectionKey, u64>,
|
||||
/// Changes only when the block-entity positions/states in a section
|
||||
/// change. Empty-section tombstones remain until that section unloads so
|
||||
/// the static cache can remove deleted models without a global flush.
|
||||
block_entity_membership_revisions: FxHashMap<SectionKey, u64>,
|
||||
/// When each section was first uploaded (`RenderSection.uploadedTime`): kept across meshing
|
||||
/// it again, gone with its column. Sections that fade in over far terrain are not in it.
|
||||
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
|
||||
section_uploaded: FxHashMap<SectionKey, std::time::Instant>,
|
||||
/// When `section_uploaded` last gained a section: once the fade time has passed since,
|
||||
/// every section is whole and frames skip the per-section lookups.
|
||||
newest_upload: Option<std::time::Instant>,
|
||||
@@ -983,7 +1031,7 @@ pub struct Renderer {
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
block_entity_revision: u64,
|
||||
/// The light sources of each section and their revision, for `block_light`.
|
||||
light_blocks: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
light_blocks: FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
light_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
@@ -2712,6 +2760,10 @@ impl Renderer {
|
||||
label: Some("Horizon entities"),
|
||||
source: wgpu::ShaderSource::Wgsl(entity_shader().into()),
|
||||
});
|
||||
let static_entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon static block entities"),
|
||||
source: wgpu::ShaderSource::Wgsl(static_entity_shader().into()),
|
||||
});
|
||||
let entity_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Entity bindings"),
|
||||
entries: &[
|
||||
@@ -2777,6 +2829,19 @@ impl Renderer {
|
||||
),
|
||||
],
|
||||
});
|
||||
let static_entity_bindings =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Static block-entity transform"),
|
||||
entries: &[entry(
|
||||
0,
|
||||
wgpu::ShaderStages::VERTEX,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
)],
|
||||
});
|
||||
let weather = weather::Weather::new(
|
||||
&device,
|
||||
config.format.remove_srgb_suffix(),
|
||||
@@ -2825,6 +2890,23 @@ impl Renderer {
|
||||
},
|
||||
],
|
||||
});
|
||||
let static_entity_transform =
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Static block-entity camera position"),
|
||||
contents: bytemuck::bytes_of(&StaticEntityUniform {
|
||||
camera_block: [0; 4],
|
||||
camera_fraction: [0.0; 4],
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let static_entity_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Static block-entity transform"),
|
||||
layout: &static_entity_bindings,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: static_entity_transform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let hand_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("First-person hand resources"),
|
||||
layout: &entity_bindings,
|
||||
@@ -2868,6 +2950,11 @@ impl Renderer {
|
||||
bind_group_layouts: &[Some(&entity_bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let static_entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Static block-entity layout"),
|
||||
bind_group_layouts: &[Some(&entity_bindings), Some(&static_entity_bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let cloud_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Cloud bindings"),
|
||||
entries: &[entry(
|
||||
@@ -2911,6 +2998,19 @@ impl Renderer {
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
})];
|
||||
let static_entity_origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
|
||||
let static_entity_vertex = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &static_entity_origin_attributes,
|
||||
}),
|
||||
];
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
let entity_pipeline_with = |label,
|
||||
cull_mode,
|
||||
@@ -3045,6 +3145,17 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
true,
|
||||
);
|
||||
let hdr_cloud_mark_pipeline = entity_pipeline_with(
|
||||
"HDR cloud mark",
|
||||
Some(wgpu::Face::Back),
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
wgpu::BlendState::REPLACE,
|
||||
true,
|
||||
"fs_hdr_cloud_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: translucent, no depth test or
|
||||
// writes, culled like every world text pipeline (TEXT_SNIPPET keeps
|
||||
// RenderPipeline.Builder's default): a name tag or text display hides
|
||||
@@ -3083,6 +3194,41 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let static_entity_pipeline =
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Static block entities"),
|
||||
layout: Some(&static_entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &static_entity_shader,
|
||||
entry_point: Some("vs_static_entity"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &static_entity_vertex,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &static_entity_shader,
|
||||
entry_point: Some("fs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// fs_entity's discard handles binary cutout alpha. Any
|
||||
// sprite with fractional alpha stays on the blended
|
||||
// dynamic entity path.
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// RenderPipelines.ENERGY_SWIRL: BlendFunction.ADDITIVE (one, one), no culling; no depth
|
||||
// writes either: vanilla sorts the quads far to near, so every face passes the test.
|
||||
let one_one = wgpu::BlendComponent {
|
||||
@@ -3547,6 +3693,8 @@ impl Renderer {
|
||||
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let static_entity_index_quads = 1 << 14;
|
||||
let static_entity_indices = Self::quad_indices(&device, static_entity_index_quads);
|
||||
let flat_wanted = flat::wanted(&adapter, &device);
|
||||
// Metal draws solid terrain in two passes (`split_solid`): meshes put all
|
||||
// their opaque quads first, so each pass draws its part in a run or two.
|
||||
@@ -3730,6 +3878,7 @@ impl Renderer {
|
||||
mirror_group,
|
||||
mirror: None,
|
||||
entity_pipeline,
|
||||
static_entity_pipeline,
|
||||
hdr_mark_pipeline,
|
||||
hdr_hand_unmark_pipeline,
|
||||
hdr_mark_lightning_pipeline,
|
||||
@@ -3739,6 +3888,7 @@ impl Renderer {
|
||||
translucent_item_pipeline,
|
||||
water_mask_pipeline,
|
||||
cloud_pipeline,
|
||||
hdr_cloud_mark_pipeline,
|
||||
cloud_mesh: Default::default(),
|
||||
cloud_buffer,
|
||||
cloud_capacity: 4,
|
||||
@@ -3766,10 +3916,18 @@ impl Renderer {
|
||||
screen_capacity,
|
||||
entity_group,
|
||||
entity_bindings,
|
||||
static_entity_group,
|
||||
static_entity_transform,
|
||||
static_entity_camera: None,
|
||||
static_entity_catalog_revision: 0,
|
||||
static_entity_frame_stats: Default::default(),
|
||||
hand_group,
|
||||
hand_globals,
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
static_entity_sections: HashMap::default(),
|
||||
static_entity_indices,
|
||||
static_entity_index_quads,
|
||||
entity_batch: EntityBatch::default(),
|
||||
entity_skin,
|
||||
group,
|
||||
@@ -3835,6 +3993,8 @@ impl Renderer {
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
block_entities: Default::default(),
|
||||
block_entity_section_revisions: Default::default(),
|
||||
block_entity_membership_revisions: Default::default(),
|
||||
section_uploaded: Default::default(),
|
||||
newest_upload: None,
|
||||
newest_lod_fade: None,
|
||||
@@ -3898,6 +4058,19 @@ impl Renderer {
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
/// HDR lighting on the clouds just drawn: the sky's value (`fs_hdr_cloud_mark`).
|
||||
fn mark_clouds(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32) {
|
||||
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(&self.hdr_cloud_mark_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..quads * 6, 0, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
/// HDR lighting on translucent terrain, over the draws that just blended it.
|
||||
fn mark_translucent_terrain(
|
||||
&self,
|
||||
@@ -4136,6 +4309,17 @@ impl Renderer {
|
||||
pub fn section_count(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Whether the running native pack gives any block a `[[lights]]` colour.
|
||||
pub fn has_light_colors(&self) -> bool {
|
||||
self.native
|
||||
.as_ref()
|
||||
.is_some_and(|native| !native.light_colors.is_empty())
|
||||
}
|
||||
/// The running native pack's `[[lights]]` colour of the block `name`.
|
||||
pub fn light_color(&self, name: &str) -> Option<[f32; 3]> {
|
||||
let native = self.native.as_ref()?;
|
||||
horizon_shader_api::manifest::light_color(&native.light_colors, name)
|
||||
}
|
||||
/// The running native pack's `[[particle_lights]]`.
|
||||
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
|
||||
self.native.as_ref().map(|n| n.particle_lights.as_slice())
|
||||
@@ -4176,6 +4360,17 @@ impl Renderer {
|
||||
};
|
||||
if changed {
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.block_entity_membership_revisions
|
||||
.insert(key, self.block_entity_revision);
|
||||
}
|
||||
if !mesh.block_entities.is_empty()
|
||||
|| self.block_entity_membership_revisions.contains_key(&key)
|
||||
{
|
||||
// Packed light can change with an unchanged BE list. Keep a
|
||||
// tombstone for formerly occupied sections until unload so a
|
||||
// deleted last entity invalidates only its own cached section.
|
||||
self.block_entity_section_revisions
|
||||
.insert(key, next_block_entity_revision());
|
||||
}
|
||||
let lights_changed = if mesh.lights.is_empty() {
|
||||
self.light_blocks.remove(&key).is_some()
|
||||
@@ -4570,6 +4765,55 @@ impl Renderer {
|
||||
.newest_lod_fade
|
||||
.is_some_and(|t| now.saturating_duration_since(t) < lod::FADE)
|
||||
}
|
||||
/// Block entities in sections which can intersect the camera frustum.
|
||||
/// The section bounds include the same one-block margin as the per-block
|
||||
/// check in the game. Sections wholly inside the frustum skip that finer
|
||||
/// check; sections outside it are kept only when a renderer needs to draw
|
||||
/// geometry extending beyond its block (beacon beams, gateways, outlines).
|
||||
pub fn visible_block_entities_in_frustum<'a>(
|
||||
&'a self,
|
||||
fade_seconds: f32,
|
||||
camera: DVec3,
|
||||
frustum: Option<Frustum>,
|
||||
keep_section: impl Fn(u32) -> bool + 'a,
|
||||
keep_long_range: impl Fn(u32) -> bool + 'a,
|
||||
) -> impl Iterator<Item = (&'a ([i32; 3], u32), bool)> + 'a {
|
||||
let fade = fade_millis(fade_seconds);
|
||||
let now = Instant::now();
|
||||
let min_y = self.layout.min_y();
|
||||
self.block_entities
|
||||
.iter()
|
||||
.filter(move |(key, _)| {
|
||||
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
|
||||
>= CHUNK_VISIBILITY_THRESHOLD
|
||||
})
|
||||
.filter_map(move |(key, blocks)| {
|
||||
let origin = DVec3::new(
|
||||
f64::from(key.0 * 16 - 1),
|
||||
f64::from(min_y + key.1 * 16 - 1),
|
||||
f64::from(key.2 * 16 - 1),
|
||||
) - camera;
|
||||
let min = origin.as_vec3();
|
||||
// The last block's expanded bound ends at section_origin + 17.
|
||||
let max = min + Vec3::splat(18.0);
|
||||
let distance_squared = Vec3::ZERO.max(min).min(max).length_squared();
|
||||
let in_range = distance_squared <= 64.0 * 64.0
|
||||
|| (distance_squared <= 256.0 * 256.0
|
||||
&& blocks.iter().any(|(_, state)| keep_long_range(*state)));
|
||||
if !in_range {
|
||||
return None;
|
||||
}
|
||||
let (intersects, fully_inside) = frustum.map_or((true, true), |view| {
|
||||
let fully_inside = view.contains(min, max);
|
||||
(fully_inside || view.intersects(min, max), fully_inside)
|
||||
});
|
||||
(intersects || blocks.iter().any(|(_, state)| keep_section(*state)))
|
||||
.then_some((blocks, fully_inside))
|
||||
})
|
||||
.flat_map(|(blocks, fully_inside)| {
|
||||
blocks.iter().map(move |block| (block, fully_inside))
|
||||
})
|
||||
}
|
||||
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
|
||||
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
|
||||
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
|
||||
@@ -4580,9 +4824,27 @@ impl Renderer {
|
||||
pub fn block_entity_revision(&self) -> u64 {
|
||||
self.block_entity_revision
|
||||
}
|
||||
/// Both mesh/light and block-entity membership revisions for a section
|
||||
/// key (`x`, section-y relative to the dimension's minimum, `z`).
|
||||
pub fn block_entity_section_revision(
|
||||
&self,
|
||||
section: [i32; 3],
|
||||
) -> Option<StaticSectionRevision> {
|
||||
let key = (section[0], section[1], section[2]);
|
||||
Some(StaticSectionRevision {
|
||||
mesh: *self.block_entity_section_revisions.get(&key)?,
|
||||
block_entities: *self.block_entity_membership_revisions.get(&key)?,
|
||||
})
|
||||
}
|
||||
/// Block-entity cache work and upload volume from the last rendered frame.
|
||||
pub fn static_block_entity_frame_stats(&self) -> StaticCacheFrameStats {
|
||||
self.static_entity_frame_stats
|
||||
}
|
||||
/// Drops every section, for a level whose chunks hold `layout`.
|
||||
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
|
||||
self.block_entities.clear();
|
||||
self.block_entity_section_revisions.clear();
|
||||
self.block_entity_membership_revisions.clear();
|
||||
self.section_uploaded.clear();
|
||||
self.newest_upload = None;
|
||||
self.newest_lod_fade = None;
|
||||
@@ -4733,6 +4995,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
|
||||
let columns: Vec<(i32, i32)> = columns.into_iter().collect();
|
||||
// Most of the view at once (a teleport at render distance 32 drops some 3700
|
||||
// columns): a pass over each store instead of a lookup per section key, which
|
||||
// took 10 ms of one frame. Far terrain fades the sections out instead.
|
||||
let keys = columns.len() * self.layout.sections();
|
||||
if self.lods.is_empty()
|
||||
&& self.lod_cutout.is_empty()
|
||||
&& keys >= self.sections.len().max(BULK_REMOVAL_KEYS)
|
||||
{
|
||||
self.remove_columns_at_once(&columns);
|
||||
return;
|
||||
}
|
||||
let mut released = Vec::new();
|
||||
let mut block_entities = false;
|
||||
for (x, z) in columns {
|
||||
@@ -4744,6 +5018,8 @@ impl Renderer {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
self.block_entity_section_revisions.remove(&key);
|
||||
self.block_entity_membership_revisions.remove(&key);
|
||||
if self.light_blocks.remove(&key).is_some() {
|
||||
self.light_revision += 1;
|
||||
}
|
||||
@@ -4762,6 +5038,35 @@ impl Renderer {
|
||||
if block_entities {
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
}
|
||||
self.release_meshes(released);
|
||||
}
|
||||
/// `remove_columns` for a large share of the resident sections: each store filtered
|
||||
/// once by column.
|
||||
fn remove_columns_at_once(&mut self, columns: &[(i32, i32)]) {
|
||||
let gone: crate::fast_hash::FxHashSet<(i32, i32)> = columns.iter().copied().collect();
|
||||
let complete = self.complete_column();
|
||||
for &(x, z) in columns {
|
||||
if self.detail_columns.remove(&(x, z)) == Some(complete) {
|
||||
self.detail_revision += 1;
|
||||
}
|
||||
self.occlusion.remove_column(x, z);
|
||||
}
|
||||
let kept = |key: &SectionKey| !gone.contains(&(key.0, key.2));
|
||||
let before = self.block_entities.len();
|
||||
self.block_entities.retain(|key, _| kept(key));
|
||||
if self.block_entities.len() != before {
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
}
|
||||
let before = self.light_blocks.len();
|
||||
self.light_blocks.retain(|key, _| kept(key));
|
||||
self.light_revision += (before - self.light_blocks.len()) as u64;
|
||||
self.section_uploaded.retain(|key, _| kept(key));
|
||||
let released = self.sections.remove_columns(&gone);
|
||||
self.release_meshes(released);
|
||||
}
|
||||
/// Gives the removed sections' quads back to their arenas; the sections themselves
|
||||
/// (their sort centres) drop on a thread of their own when there are many.
|
||||
fn release_meshes(&mut self, released: Vec<SectionGpu>) {
|
||||
if let Some(flat) = &mut self.flat {
|
||||
for id in released.iter().filter_map(|s| s.flat_id) {
|
||||
flat.remove(id);
|
||||
@@ -4778,6 +5083,9 @@ impl Renderer {
|
||||
if let Some(arena) = &mut self.translucent_arena {
|
||||
arena.release_all(released.iter().filter_map(|s| s.vertex_ranges(true).1));
|
||||
}
|
||||
if released.len() >= BULK_REMOVAL_KEYS {
|
||||
arena::free_later(released);
|
||||
}
|
||||
}
|
||||
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
|
||||
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
|
||||
@@ -5228,9 +5536,11 @@ impl Renderer {
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The macros of the GL extensions this device gives shader packs: subgroups, when it
|
||||
/// has them.
|
||||
/// The macros this renderer gives shader packs: the GL extensions this device has
|
||||
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
|
||||
/// (`Macros::define_renderer`).
|
||||
pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
|
||||
macros.define_renderer(self.mipmaps);
|
||||
if self.device.features().contains(wgpu::Features::SUBGROUP) {
|
||||
for ext in [
|
||||
"basic",
|
||||
@@ -6676,6 +6986,125 @@ impl Renderer {
|
||||
0,
|
||||
);
|
||||
}
|
||||
let static_sections = frame.static_block_entities;
|
||||
let mut static_bytes_uploaded = 0;
|
||||
let catalog_revision = frame.static_block_entity_stats.snapshot_revision;
|
||||
if catalog_revision != self.static_entity_catalog_revision {
|
||||
self.static_entity_sections.retain(|section, _| {
|
||||
static_sections
|
||||
.binary_search_by_key(section, |snapshot| snapshot.section)
|
||||
.is_ok()
|
||||
});
|
||||
for snapshot in static_sections {
|
||||
let vertices = snapshot.vertices.len() as u64;
|
||||
let gpu = self
|
||||
.static_entity_sections
|
||||
.entry(snapshot.section)
|
||||
.or_insert_with(|| {
|
||||
let capacity = vertices.next_power_of_two().max(4);
|
||||
let origin_buffer =
|
||||
self.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Static block-entity section origin"),
|
||||
contents: bytemuck::bytes_of(&snapshot.origin),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
static_bytes_uploaded += std::mem::size_of_val(&snapshot.origin) as u64;
|
||||
GpuStaticEntitySection {
|
||||
buffer: self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Static block-entity section vertices"),
|
||||
size: capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
origin_buffer,
|
||||
origin: snapshot.origin,
|
||||
capacity,
|
||||
revision: 0,
|
||||
vertices: 0,
|
||||
}
|
||||
});
|
||||
if gpu.origin != snapshot.origin {
|
||||
gpu.origin_buffer =
|
||||
self.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Static block-entity section origin"),
|
||||
contents: bytemuck::bytes_of(&snapshot.origin),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
gpu.origin = snapshot.origin;
|
||||
static_bytes_uploaded += std::mem::size_of_val(&snapshot.origin) as u64;
|
||||
}
|
||||
if gpu.revision == snapshot.revision {
|
||||
continue;
|
||||
}
|
||||
if vertices > gpu.capacity {
|
||||
gpu.capacity = vertices.next_power_of_two();
|
||||
gpu.buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Static block-entity section vertices"),
|
||||
size: gpu.capacity * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&gpu.buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&snapshot.vertices),
|
||||
);
|
||||
static_bytes_uploaded += vertices * std::mem::size_of::<Vertex>() as u64;
|
||||
gpu.revision = snapshot.revision;
|
||||
gpu.vertices = vertices as u32;
|
||||
}
|
||||
self.static_entity_catalog_revision = catalog_revision;
|
||||
}
|
||||
let static_entity_quads = frame
|
||||
.visible_static_block_entity_sections
|
||||
.iter()
|
||||
.filter_map(|section| self.static_entity_sections.get(section))
|
||||
.map(|section| section.vertices / 4)
|
||||
.sum::<u32>();
|
||||
let max_static_section_quads = frame
|
||||
.visible_static_block_entity_sections
|
||||
.iter()
|
||||
.filter_map(|section| self.static_entity_sections.get(section))
|
||||
.map(|section| section.vertices / 4)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if max_static_section_quads > self.static_entity_index_quads {
|
||||
self.static_entity_index_quads = max_static_section_quads.next_power_of_two();
|
||||
self.static_entity_indices =
|
||||
Self::quad_indices(&self.device, self.static_entity_index_quads);
|
||||
}
|
||||
if !frame.visible_static_block_entity_sections.is_empty()
|
||||
&& self.static_entity_camera != Some(frame.camera)
|
||||
{
|
||||
let camera_floor = [
|
||||
frame.camera.x.floor() as i32,
|
||||
frame.camera.y.floor() as i32,
|
||||
frame.camera.z.floor() as i32,
|
||||
];
|
||||
let transform = StaticEntityUniform {
|
||||
camera_block: [camera_floor[0], camera_floor[1], camera_floor[2], 0],
|
||||
camera_fraction: [
|
||||
(frame.camera.x - f64::from(camera_floor[0])) as f32,
|
||||
(frame.camera.y - f64::from(camera_floor[1])) as f32,
|
||||
(frame.camera.z - f64::from(camera_floor[2])) as f32,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&self.static_entity_transform,
|
||||
0,
|
||||
bytemuck::bytes_of(&transform),
|
||||
);
|
||||
static_bytes_uploaded += std::mem::size_of::<StaticEntityUniform>() as u64;
|
||||
self.static_entity_camera = Some(frame.camera);
|
||||
}
|
||||
self.static_entity_frame_stats = frame.static_block_entity_stats;
|
||||
self.static_entity_frame_stats.static_bytes_uploaded = static_bytes_uploaded;
|
||||
self.entity_outline.prepare(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
@@ -7571,8 +8000,30 @@ impl Renderer {
|
||||
if surface.is_some() {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
if static_entity_quads > 0 {
|
||||
pass.set_pipeline(&self.static_entity_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.static_entity_group, &[]);
|
||||
pass.set_index_buffer(
|
||||
self.static_entity_indices.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
for section in frame.visible_static_block_entity_sections {
|
||||
let Some(gpu) = self.static_entity_sections.get(section) else {
|
||||
continue;
|
||||
};
|
||||
let quads = gpu.vertices / 4;
|
||||
if quads == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_vertex_buffer(0, gpu.buffer.slice(..));
|
||||
pass.set_vertex_buffer(1, gpu.origin_buffer.slice(..));
|
||||
pass.draw_indexed(0..quads * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if entity_quads > 0 {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -7913,6 +8364,9 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if cloud_quads > 0 && !pack_draws_clouds {
|
||||
self.mark_clouds(&mut pass, cloud_quads);
|
||||
}
|
||||
// HDR output's mark on what shines in the stages, as the classic
|
||||
// pass marks it after drawing it: eyes, the dragon's eyes and rays,
|
||||
// firework sparks and lightning.
|
||||
@@ -8016,6 +8470,7 @@ impl Renderer {
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
|
||||
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
|
||||
self.mark_clouds(&mut pass, cloud_quads);
|
||||
}
|
||||
// LevelRenderer.executeClassicTransparency: weather after translucent
|
||||
// terrain and clouds, so the water behind the rain is drawn first.
|
||||
@@ -9170,6 +9625,8 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::gfx::static_entity_shader;
|
||||
|
||||
fn adapter(device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo {
|
||||
name: "test adapter".into(),
|
||||
@@ -9577,6 +10034,8 @@ mod tests {
|
||||
} else if name == "oit_features.wgsl" {
|
||||
let base = super::entity_shader() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "static_entity.wgsl" {
|
||||
sources.push((path, static_entity_shader()));
|
||||
} else if name == "oit_border.wgsl" {
|
||||
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
|
||||
@@ -645,6 +645,7 @@ mod tests {
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
include_str!("../hdr_stats.wgsl"),
|
||||
include_str!("stars.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
|
||||
@@ -7,13 +7,19 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
|
||||
/// once every mesh is in) and the columns they leave out.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Pending {
|
||||
meshes: FxHashMap<TileKey, SectionMesh>,
|
||||
/// The order they go up in: as handed over (`Lod::update`: finer tiles
|
||||
/// first, so no tile clipped around a finer one is drawn before it).
|
||||
order: VecDeque<TileKey>,
|
||||
removed: FxHashSet<TileKey>,
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
}
|
||||
@@ -30,7 +36,9 @@ impl Pending {
|
||||
) {
|
||||
for (key, mesh) in meshes {
|
||||
self.removed.remove(&key);
|
||||
self.meshes.insert(key, mesh);
|
||||
if self.meshes.insert(key, mesh).is_none() {
|
||||
self.order.push_back(key);
|
||||
}
|
||||
}
|
||||
for key in removed {
|
||||
self.meshes.remove(&key);
|
||||
@@ -330,7 +338,10 @@ impl Renderer {
|
||||
.copied()
|
||||
.collect();
|
||||
while let Some(pending) = &mut self.lod_pending {
|
||||
let Some(&key) = pending.meshes.keys().next() else {
|
||||
// Keys meshed again keep their place; removed ones are skipped.
|
||||
let next = std::iter::from_fn(|| pending.order.pop_front())
|
||||
.find(|key| pending.meshes.contains_key(key));
|
||||
let Some(key) = next else {
|
||||
let pending = self.lod_pending.take().expect("pending tiles");
|
||||
// A tile fades in over the ones it replaces. One over nothing
|
||||
// shows whole: its hidden chunks would open holes to the sky
|
||||
@@ -498,7 +509,16 @@ mod tests {
|
||||
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
|
||||
removed.sort_unstable();
|
||||
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
|
||||
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
|
||||
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
|
||||
// They go up in the order handed over, each once.
|
||||
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
|
||||
let order: Vec<_> = pending
|
||||
.order
|
||||
.iter()
|
||||
.filter(|k| pending.meshes.contains_key(k))
|
||||
.map(|k| k.x)
|
||||
.collect();
|
||||
assert_eq!(order, [1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct NativePack {
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// `[[particle_lights]]`.
|
||||
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
|
||||
/// `[[lights]]`, for the colour of held and dropped items' light.
|
||||
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -136,6 +138,7 @@ impl NativePack {
|
||||
}
|
||||
let pack = Self {
|
||||
particle_lights: report.manifest.particle_lights.clone(),
|
||||
light_colors: report.manifest.lights.clone(),
|
||||
frame,
|
||||
sky,
|
||||
material,
|
||||
|
||||
@@ -478,6 +478,7 @@ struct ShadowCacheKey {
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
setup_pending: bool,
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
@@ -2286,6 +2287,7 @@ impl PackPost {
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
terrain,
|
||||
water,
|
||||
@@ -3338,6 +3340,7 @@ impl PackPost {
|
||||
if self.targets.as_ref().is_some_and(|t| t.size == size) {
|
||||
return;
|
||||
}
|
||||
self.setup_pending = true;
|
||||
let sizes: [[u32; 2]; BUFFERS] =
|
||||
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
|
||||
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
|
||||
@@ -3771,7 +3774,6 @@ impl PackPost {
|
||||
self.gpu.clear,
|
||||
&mut self.zeros,
|
||||
);
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
@@ -3901,8 +3903,17 @@ impl PackPost {
|
||||
.any(|(p, late)| p.is_some() && late)
|
||||
});
|
||||
let mut water_drawn = self.water.is_none() && self.weather.is_none() && !late_entities;
|
||||
let mut shadow_drawn = false;
|
||||
for pass in &mut passes {
|
||||
if !terrain_drawn && pass.group != Group::Prepare {
|
||||
if pass.group == Group::Setup && !self.setup_pending {
|
||||
continue;
|
||||
}
|
||||
let early = matches!(pass.group, Group::Setup | Group::Begin);
|
||||
if !early && !shadow_drawn {
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
shadow_drawn = true;
|
||||
}
|
||||
if !terrain_drawn && !early && pass.group != Group::Prepare {
|
||||
terrain_drawn = true;
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
@@ -3981,6 +3992,10 @@ impl PackPost {
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
self.passes = passes;
|
||||
self.setup_pending = false;
|
||||
if !shadow_drawn {
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
}
|
||||
if !terrain_drawn {
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(super) fn upload(
|
||||
let (view, sample_type) = match create(device, queue, &texture, features) {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
|
||||
notes.push(format!("{}: {e}", key(&t.stage, &t.name)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -60,8 +60,8 @@ pub(super) fn upload(
|
||||
Image::Atlas
|
||||
} else {
|
||||
notes.push(format!(
|
||||
"texture.{}.{}: `{location}` is not provided yet",
|
||||
t.stage, t.name
|
||||
"{}: `{location}` is not provided yet",
|
||||
key(&t.stage, &t.name)
|
||||
));
|
||||
continue;
|
||||
};
|
||||
@@ -86,6 +86,8 @@ pub(super) fn upload(
|
||||
fn stage(group: Group) -> &'static str {
|
||||
match group {
|
||||
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
|
||||
Group::Setup => "setup",
|
||||
Group::Begin => "begin",
|
||||
Group::Prepare => "prepare",
|
||||
Group::Deferred => "deferred",
|
||||
Group::Composite | Group::Final => "composite",
|
||||
@@ -95,7 +97,8 @@ fn stage(group: Group) -> &'static str {
|
||||
|
||||
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
|
||||
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
|
||||
/// may hold one texture per dimension.
|
||||
/// may hold one texture per dimension. Without one, a `customTexture` of that name and
|
||||
/// dimension, which every program reads.
|
||||
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
|
||||
let dimension = if ty.contains("1D") {
|
||||
wgpu::TextureViewDimension::D1
|
||||
@@ -105,14 +108,27 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
let buffer = horizon_shaders::buffer_index(name);
|
||||
textures.iter().position(|t| {
|
||||
let staged = textures.iter().position(|t| {
|
||||
t.stage == stage(group)
|
||||
&& t.dimension == dimension
|
||||
&& (t.name == name
|
||||
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|
||||
});
|
||||
staged.or_else(|| {
|
||||
textures.iter().position(|t| {
|
||||
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The `shaders.properties` key a texture came from, for notes.
|
||||
fn key(stage: &str, name: &str) -> String {
|
||||
match stage {
|
||||
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
|
||||
_ => format!("texture.{stage}.{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `texture.noise`, if the pack has one.
|
||||
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
|
||||
textures
|
||||
@@ -316,6 +332,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris's `customTexture.<name>`: every program reads it under its own name and dimension;
|
||||
/// a `texture.<stage>.<name>` of the same name comes first in its stage.
|
||||
#[test]
|
||||
fn custom_textures_reach_every_program() {
|
||||
use wgpu::TextureViewDimension as D;
|
||||
let every = horizon_shaders::EVERY_STAGE;
|
||||
let textures = [
|
||||
lightmap(every, "texClouds", D::D3),
|
||||
lightmap("composite", "texClouds", D::D3),
|
||||
lightmap(every, "colortex4", D::D2),
|
||||
];
|
||||
for group in [
|
||||
Group::Gbuffers,
|
||||
Group::Translucent,
|
||||
Group::Shadow,
|
||||
Group::Prepare,
|
||||
Group::Deferred,
|
||||
Group::ShadowComp,
|
||||
] {
|
||||
assert_eq!(
|
||||
find(&textures, group, "sampler3D", "texClouds"),
|
||||
Some(0),
|
||||
"{group:?}"
|
||||
);
|
||||
}
|
||||
// `final` reads the `composite` stage's textures too.
|
||||
for group in [Group::Composite, Group::Final] {
|
||||
assert_eq!(find(&textures, group, "sampler3D", "texClouds"), Some(1));
|
||||
}
|
||||
assert_eq!(
|
||||
find(&textures, Group::Gbuffers, "sampler2D", "texClouds"),
|
||||
None
|
||||
);
|
||||
// Only its own name: no other name of the same buffer.
|
||||
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), None);
|
||||
assert_eq!(
|
||||
find(&textures, Group::Final, "sampler2D", "colortex4"),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texels_pack_into_their_format() {
|
||||
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
|
||||
|
||||
@@ -964,6 +964,9 @@ mod initial_history_tests {
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
#[cfg(test)]
|
||||
use super::Frustum;
|
||||
use super::{Cull, SectionKey};
|
||||
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
streaming::in_view_distance,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
/// One chunk column's resident sections.
|
||||
@@ -141,6 +144,24 @@ impl<T> Resident<T> {
|
||||
column.low = column.low.min(key.1);
|
||||
column.high = column.high.max(key.1);
|
||||
}
|
||||
/// Removes every section of the `gone` columns, the others keeping their order: one
|
||||
/// pass over the store, where `remove` moves a slot and looks its column up for each.
|
||||
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
|
||||
let items = std::mem::take(&mut self.items);
|
||||
let bounds = std::mem::take(&mut self.bounds);
|
||||
self.slots.clear();
|
||||
self.columns.clear();
|
||||
self.column_index.clear();
|
||||
let mut removed = Vec::new();
|
||||
for ((key, value), bounds) in items.into_iter().zip(bounds) {
|
||||
if gone.contains(&(key.0, key.2)) {
|
||||
removed.push(value);
|
||||
} else {
|
||||
self.insert_with_bounds(key, value, bounds);
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
|
||||
let slot = self.slots.remove(&key)?;
|
||||
let c = self.column_index[&(key.0, key.2)] as usize;
|
||||
@@ -512,7 +533,19 @@ mod tests {
|
||||
((seed >> 4) % 6) as i32 - 1,
|
||||
((seed >> 8) % 5) as i32 - 2,
|
||||
);
|
||||
if (seed >> 12).is_multiple_of(3) {
|
||||
if (seed >> 12).is_multiple_of(97) {
|
||||
// Whole columns at once: the column of `key` and the next one along x.
|
||||
let gone: FxHashSet<(i32, i32)> =
|
||||
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
|
||||
let mut removed = r.remove_columns(&gone);
|
||||
let mut expected: Vec<u32> = model
|
||||
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
|
||||
.map(|(_, v)| v)
|
||||
.collect();
|
||||
removed.sort_unstable();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(removed, expected);
|
||||
} else if (seed >> 12).is_multiple_of(3) {
|
||||
assert_eq!(r.remove(key), model.remove(&key));
|
||||
} else {
|
||||
r.insert(key, step);
|
||||
|
||||
@@ -132,14 +132,15 @@ impl Stars {
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// RenderPipelines.STARS: the celestial blend (src alpha, one).
|
||||
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
|
||||
// colour premultiplied in fs_stars, so its alpha can carry HDR's mark.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent::OVER,
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
|
||||
@@ -11,6 +11,10 @@ struct Stars {
|
||||
return stars.clip_from_sky * vec4(position, 1.0);
|
||||
}
|
||||
|
||||
// The colour premultiplied, as the celestial blend (src alpha, one) would: the pipeline adds it.
|
||||
// The alpha is HDR output's mark (hdr.wgsl): stars are points of light, at the bright tier,
|
||||
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
return stars.color;
|
||||
let b = stars.color.a;
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -543,6 +543,9 @@ pub struct Graphics {
|
||||
pub lod_distance: u32,
|
||||
/// How finely far terrain is drawn; Full unless the player picks Reduced.
|
||||
pub far_detail: FarDetail,
|
||||
/// Far terrain from servers' BlueMap web maps; no server has one unless
|
||||
/// the player names it (`blueMap:` lines) or turns on auto-detect.
|
||||
pub blue_map: crate::bluemap::Settings,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
@@ -551,6 +554,20 @@ pub struct Graphics {
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Cache settled shulker-box models.
|
||||
pub cache_shulker_boxes: bool,
|
||||
/// Cache sign-text geometry.
|
||||
pub cache_signs: bool,
|
||||
/// Cache decorated pots outside their wobble animation.
|
||||
pub cache_decorated_pots: bool,
|
||||
/// Cache bells while they are not ringing.
|
||||
pub cache_bells: bool,
|
||||
/// Cache closed chests, including copper variants.
|
||||
pub cache_chests: bool,
|
||||
/// Cache banner poles while leaving their flags animated.
|
||||
pub cache_banners: bool,
|
||||
/// Cache copper-golem statue models.
|
||||
pub cache_copper_golem_statues: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
@@ -627,9 +644,18 @@ impl Default for Graphics {
|
||||
preset: Preset::Custom,
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
blue_map: crate::bluemap::Settings::default(),
|
||||
cave_culling: true,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
cache_shulker_boxes: false,
|
||||
// Sign text has used its renderer cache independently of the new model caches.
|
||||
cache_signs: true,
|
||||
cache_decorated_pots: false,
|
||||
cache_bells: false,
|
||||
cache_chests: false,
|
||||
cache_banners: false,
|
||||
cache_copper_golem_statues: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
@@ -670,6 +696,14 @@ impl Graphics {
|
||||
let mut g: Self = fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
.unwrap_or_default();
|
||||
g.blue_map.servers = match fs::read_to_string(dir.join(crate::bluemap::SERVERS_FILE)) {
|
||||
Ok(text) => crate::bluemap::Settings::parse_servers(&text),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}; no BlueMap is named", crate::bluemap::SERVERS_FILE);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
|
||||
g.hdr = false;
|
||||
g.save(dir);
|
||||
@@ -698,9 +732,38 @@ impl Graphics {
|
||||
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
|
||||
}
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"blueMapRadius" => {
|
||||
if let Some(radius) = crate::bluemap::Settings::parse_radius(value) {
|
||||
g.blue_map.radius = radius;
|
||||
}
|
||||
}
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"blockEntityCache" => {
|
||||
let enabled = flag(false);
|
||||
g.cache_shulker_boxes = enabled;
|
||||
g.cache_signs = enabled;
|
||||
g.cache_decorated_pots = enabled;
|
||||
g.cache_bells = enabled;
|
||||
g.cache_chests = enabled;
|
||||
g.cache_banners = enabled;
|
||||
g.cache_copper_golem_statues = enabled;
|
||||
}
|
||||
"blockEntityCacheShulkerBoxes" => {
|
||||
g.cache_shulker_boxes = flag(g.cache_shulker_boxes)
|
||||
}
|
||||
"blockEntityCacheSigns" => g.cache_signs = flag(g.cache_signs),
|
||||
"blockEntityCacheDecoratedPots" => {
|
||||
g.cache_decorated_pots = flag(g.cache_decorated_pots)
|
||||
}
|
||||
"blockEntityCacheBells" => g.cache_bells = flag(g.cache_bells),
|
||||
"blockEntityCacheChests" => g.cache_chests = flag(g.cache_chests),
|
||||
"blockEntityCacheBanners" => g.cache_banners = flag(g.cache_banners),
|
||||
"blockEntityCacheCopperGolemStatues" => {
|
||||
g.cache_copper_golem_statues = flag(g.cache_copper_golem_statues)
|
||||
}
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
@@ -766,14 +829,21 @@ impl Graphics {
|
||||
g
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.entity_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.cache_shulker_boxes),
|
||||
bool_text(self.cache_signs),
|
||||
bool_text(self.cache_decorated_pots),
|
||||
bool_text(self.cache_bells),
|
||||
bool_text(self.cache_chests),
|
||||
bool_text(self.cache_banners),
|
||||
bool_text(self.cache_copper_golem_statues),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
@@ -810,7 +880,9 @@ impl Graphics {
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
bool_text(self.translucent_batching),
|
||||
)
|
||||
);
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
@@ -937,9 +1009,22 @@ mod tests {
|
||||
preset: Preset::Balanced,
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
blue_map: crate::bluemap::Settings {
|
||||
radius: 40,
|
||||
auto_detect: true,
|
||||
// In their own file (`bluemap::SERVERS_FILE`).
|
||||
servers: Vec::new(),
|
||||
},
|
||||
cave_culling: false,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
cache_shulker_boxes: true,
|
||||
cache_signs: true,
|
||||
cache_decorated_pots: true,
|
||||
cache_bells: true,
|
||||
cache_chests: true,
|
||||
cache_banners: true,
|
||||
cache_copper_golem_statues: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
@@ -986,6 +1071,13 @@ mod tests {
|
||||
Graphics::parse("entityCulling:false\n").entity_culling,
|
||||
EntityCullingMode::Off
|
||||
);
|
||||
// A map named here (a shared instance's, a pack's) is no map.
|
||||
assert!(
|
||||
Graphics::parse("blueMap:play.example.net https://map.example.net\n")
|
||||
.blue_map
|
||||
.servers
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// Translucent Batching is on, in files from before it too; only false turns it off,
|
||||
// and only under its key: the old one held the false every save wrote while it was
|
||||
|
||||
@@ -28,9 +28,18 @@ pub enum H {
|
||||
Preset,
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
BlueMapAutoDetect,
|
||||
BlueMapRadius,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
CacheShulkerBoxes,
|
||||
CacheSigns,
|
||||
CacheDecoratedPots,
|
||||
CacheBells,
|
||||
CacheChests,
|
||||
CacheBanners,
|
||||
CacheCopperGolemStatues,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
Sky,
|
||||
@@ -183,6 +192,16 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
render,
|
||||
lod,
|
||||
far,
|
||||
h(
|
||||
H::BlueMapAutoDetect,
|
||||
None,
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
),
|
||||
h(
|
||||
H::BlueMapRadius,
|
||||
None,
|
||||
"How far around you BlueMap tiles are downloaded. Next join.",
|
||||
),
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
@@ -207,13 +226,49 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Hides entities behind opaque blocks.",
|
||||
),
|
||||
h(
|
||||
H::LeafCulling,
|
||||
Saves,
|
||||
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
|
||||
),
|
||||
Row::Header("Block Entities"),
|
||||
h(
|
||||
H::CacheShulkerBoxes,
|
||||
Saves,
|
||||
"Reuses settled boxes; lid movement stays animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheSigns,
|
||||
Saves,
|
||||
"Reuses sign text until text, light, or font changes.",
|
||||
),
|
||||
h(
|
||||
H::CacheDecoratedPots,
|
||||
Saves,
|
||||
"Reuses still pots; break wobble and sherd changes redraw.",
|
||||
),
|
||||
h(
|
||||
H::CacheBells,
|
||||
Saves,
|
||||
"Reuses still bells; ringing stays animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheChests,
|
||||
Saves,
|
||||
"Reuses closed chests; moving lids stay animated.",
|
||||
),
|
||||
h(
|
||||
H::CacheBanners,
|
||||
Saves,
|
||||
"Reuses poles; flags and pattern layers still update.",
|
||||
),
|
||||
h(
|
||||
H::CacheCopperGolemStatues,
|
||||
Saves,
|
||||
"Reuses statue geometry until its block state changes.",
|
||||
),
|
||||
Row::Header("Quality"),
|
||||
v(
|
||||
Opt::SmoothLighting,
|
||||
@@ -387,6 +442,8 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// BlueMap download radii, in chunks.
|
||||
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// up to the whole world.
|
||||
@@ -470,6 +527,11 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::EntityCulling), _) => match s.horizon.entity_culling {
|
||||
EntityCullingMode::Off => "No entity occlusion culling.",
|
||||
EntityCullingMode::Synchronous => "CPU occlusion test runs this frame.",
|
||||
EntityCullingMode::Asynchronous => "Occlusion checks run on a worker thread.",
|
||||
},
|
||||
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
|
||||
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
|
||||
LeafCullingMode::Solid => {
|
||||
@@ -548,9 +610,23 @@ impl H {
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::BlueMapAutoDetect => labelled("Find BlueMap", on_off(g.blue_map.auto_detect)),
|
||||
H::BlueMapRadius => labelled(
|
||||
"BlueMap Radius",
|
||||
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
|
||||
),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
|
||||
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
|
||||
H::CacheDecoratedPots => labelled("Decorated Pots", on_off(g.cache_decorated_pots)),
|
||||
H::CacheBells => labelled("Bells", on_off(g.cache_bells)),
|
||||
H::CacheChests => labelled("Chests", on_off(g.cache_chests)),
|
||||
H::CacheBanners => labelled("Banners", on_off(g.cache_banners)),
|
||||
H::CacheCopperGolemStatues => {
|
||||
labelled("Copper Golem Statues", on_off(g.cache_copper_golem_statues))
|
||||
}
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
@@ -686,6 +762,10 @@ impl H {
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::BlueMapAutoDetect => g.blue_map.auto_detect = !g.blue_map.auto_detect,
|
||||
H::BlueMapRadius => {
|
||||
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
@@ -693,6 +773,15 @@ impl H {
|
||||
H::LeafCulling => {
|
||||
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
|
||||
}
|
||||
H::CacheShulkerBoxes => g.cache_shulker_boxes = !g.cache_shulker_boxes,
|
||||
H::CacheSigns => g.cache_signs = !g.cache_signs,
|
||||
H::CacheDecoratedPots => g.cache_decorated_pots = !g.cache_decorated_pots,
|
||||
H::CacheBells => g.cache_bells = !g.cache_bells,
|
||||
H::CacheChests => g.cache_chests = !g.cache_chests,
|
||||
H::CacheBanners => g.cache_banners = !g.cache_banners,
|
||||
H::CacheCopperGolemStatues => {
|
||||
g.cache_copper_golem_statues = !g.cache_copper_golem_statues
|
||||
}
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
@@ -789,7 +878,15 @@ impl Item {
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
|
||||
Item::Horizon(H::CacheSigns) => g.cache_signs,
|
||||
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
|
||||
Item::Horizon(H::CacheBells) => g.cache_bells,
|
||||
Item::Horizon(H::CacheChests) => g.cache_chests,
|
||||
Item::Horizon(H::CacheBanners) => g.cache_banners,
|
||||
Item::Horizon(H::CacheCopperGolemStatues) => g.cache_copper_golem_statues,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
@@ -853,6 +950,8 @@ impl Item {
|
||||
self,
|
||||
Item::Horizon(
|
||||
H::Preset
|
||||
| H::BlueMapAutoDetect
|
||||
| H::BlueMapRadius
|
||||
| H::Overlay
|
||||
| H::ScreenshotQuality
|
||||
| H::Pacing
|
||||
@@ -1290,6 +1389,11 @@ impl GraphicsUi {
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Action> {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !s.extras.wheel_cycles_settings {
|
||||
self.scroll(level, s, h, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
// Horizon improvement: focused slider/cycle intercepts wheel first.
|
||||
if amount != 0.0
|
||||
&& self.can_change_value
|
||||
@@ -1818,6 +1922,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blue_map_settings_are_advanced_rendering_options() {
|
||||
let mut s = Settings::default();
|
||||
let find = Item::Horizon(H::BlueMapAutoDetect);
|
||||
let radius = Item::Horizon(H::BlueMapRadius);
|
||||
let advanced: Vec<_> = rows(Level::Advanced)
|
||||
.into_iter()
|
||||
.filter_map(|row| match row {
|
||||
Row::Setting(setting) => Some(setting.item),
|
||||
Row::Header(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(advanced.contains(&find) && advanced.contains(&radius));
|
||||
assert_eq!(find.switch(&s), Some(false), "off by default");
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 64 chunks");
|
||||
find.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.horizon.blue_map.auto_detect);
|
||||
radius.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.blue_map.radius, 128);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
radius.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(radius.label(&s).plain(), "BlueMap Radius: 32 chunks");
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
@@ -1907,8 +2036,8 @@ mod tests {
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
|
||||
|
||||
// The three culling settings share one group, below Rendering's chunk
|
||||
// updates and above Quality.
|
||||
// The culling group is followed by the separate block-entity cache
|
||||
// group, then Quality.
|
||||
let rows = rows(Level::Advanced);
|
||||
let culling = rows
|
||||
.iter()
|
||||
@@ -1928,6 +2057,27 @@ mod tests {
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 4),
|
||||
Some(Row::Header("Block Entities"))
|
||||
));
|
||||
for (offset, cache) in [
|
||||
H::CacheShulkerBoxes,
|
||||
H::CacheSigns,
|
||||
H::CacheDecoratedPots,
|
||||
H::CacheBells,
|
||||
H::CacheChests,
|
||||
H::CacheBanners,
|
||||
H::CacheCopperGolemStatues,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert!(matches!(
|
||||
rows.get(culling + 5 + offset),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(cache)
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
rows.get(culling + 12),
|
||||
Some(Row::Header("Quality"))
|
||||
));
|
||||
|
||||
@@ -2159,6 +2309,15 @@ mod tests {
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
|
||||
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
// With the "Scroll wheel changes settings" extra off, it scrolls instead.
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let scroll_before = ui.scroll.amount;
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
assert!(ui.scroll.amount > scroll_before);
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
ui.scroll.amount = scroll_before;
|
||||
|
||||
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
|
||||
s.horizon.chunk_updates = 16;
|
||||
|
||||
@@ -59,6 +59,9 @@ impl TextureFlags {
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
/// Identity of this packed atlas, used to invalidate cached model UV data
|
||||
/// even when a resource reload places sprites at the same coordinates.
|
||||
pub revision: u64,
|
||||
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
|
||||
/// compressed them (read them through `pixels()`).
|
||||
pub pixels: Vec<u8>,
|
||||
@@ -526,6 +529,10 @@ fn galactic_widths(
|
||||
widths
|
||||
}
|
||||
impl HudAtlas {
|
||||
fn next_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// The atlas's pixels: as built, or uncompressed again after
|
||||
/// `pack_pixels` (a renderer being rebuilt).
|
||||
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||
@@ -700,6 +707,7 @@ impl HudAtlas {
|
||||
);
|
||||
let white = sprites["horizon/white"];
|
||||
Self {
|
||||
revision: Self::next_revision(),
|
||||
font: crate::font::Font::load(
|
||||
Path::new("horizon-boot"),
|
||||
&sprites,
|
||||
@@ -1143,6 +1151,7 @@ impl HudAtlas {
|
||||
white,
|
||||
);
|
||||
Ok(Self {
|
||||
revision: Self::next_revision(),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
|
||||
@@ -13,6 +13,8 @@ pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod bluemap;
|
||||
pub mod bluemap_detect;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
@@ -38,6 +40,7 @@ pub mod extras;
|
||||
pub mod fast_hash;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
@@ -81,6 +84,7 @@ pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod server_terrain;
|
||||
pub mod settings;
|
||||
pub mod shader_options;
|
||||
pub mod shader_pack_list;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1607,12 +1607,38 @@ impl Mesher<'_> {
|
||||
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
|
||||
// drawn over it (a few per face at most; more are dropped).
|
||||
let connected = &self.render.connected;
|
||||
// Fusion's and then OptiFine's connected textures: another sprite, or one per quarter,
|
||||
// and the overlays drawn over it (a few per face at most; more are dropped).
|
||||
let (fusion, connected) = (&self.render.fusion, &self.render.connected);
|
||||
let mut overlays = [None; 8];
|
||||
let mut count = 0;
|
||||
let pick = if connected.is_empty() {
|
||||
let mut push = |overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let neighbour =
|
||||
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
|
||||
let fused = face.fusion.and_then(|rule| {
|
||||
fusion.pick(
|
||||
rule,
|
||||
id,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
q.tint_index,
|
||||
neighbour,
|
||||
|s| {
|
||||
self.tables
|
||||
.info
|
||||
.get(s as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
});
|
||||
let pick = if let Some(pick) = fused {
|
||||
pick
|
||||
} else if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
let tints = &*tints;
|
||||
@@ -1621,7 +1647,7 @@ impl Mesher<'_> {
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
neighbour,
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|
||||
|| {
|
||||
@@ -1631,12 +1657,7 @@ impl Mesher<'_> {
|
||||
CtmAxes::texture(&q.positions, &q.uv)
|
||||
}
|
||||
},
|
||||
|overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
};
|
||||
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
|
||||
@@ -1712,6 +1733,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Pick::Nothing => {}
|
||||
}
|
||||
// Overlays: the face again, after it (equal depth passes), tinted as their rule says.
|
||||
// `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent
|
||||
@@ -1724,6 +1746,7 @@ impl Mesher<'_> {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
Pick::Nothing => false,
|
||||
};
|
||||
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
|
||||
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
|
||||
|
||||
@@ -522,6 +522,7 @@ mod tests {
|
||||
let facts = BlockFacts {
|
||||
states: Vec::new(),
|
||||
air: horizon_world::registry::BlockStateId(0),
|
||||
woods: Default::default(),
|
||||
};
|
||||
let mut world = world_with(
|
||||
(-2..=2)
|
||||
@@ -609,6 +610,7 @@ mod tests {
|
||||
let facts = BlockFacts {
|
||||
states: Vec::new(),
|
||||
air: horizon_world::registry::BlockStateId(0),
|
||||
woods: Default::default(),
|
||||
};
|
||||
// Grass everywhere, one stone block 10 blocks north of the player.
|
||||
let mut world = world_with(
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct BakedFace {
|
||||
pub opaque: bool,
|
||||
/// Fractional alpha: drawn in the sorted translucent pass.
|
||||
pub translucent: bool,
|
||||
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
|
||||
/// two bytes, within the padding every face already has.
|
||||
pub fusion: Option<std::num::NonZeroU16>,
|
||||
}
|
||||
pub struct BakedModel {
|
||||
pub ambient_occlusion: bool,
|
||||
@@ -90,6 +93,7 @@ fn bake_resolved(
|
||||
opaque: sprites.region_opaque(layer, &quad.uv),
|
||||
quad,
|
||||
layer,
|
||||
fusion: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
@@ -343,6 +347,12 @@ impl StateModels {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Every baked model with the variant it was baked from, to change its faces.
|
||||
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
|
||||
self.baked
|
||||
.iter_mut()
|
||||
.map(|(variant, model)| (&*variant, model))
|
||||
}
|
||||
/// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`).
|
||||
pub fn standalone(&self, name: &str) -> Option<&BakedModel> {
|
||||
let &i = self.standalone.get(name)?;
|
||||
|
||||
@@ -119,6 +119,7 @@ mod tests {
|
||||
layer: 7,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.collect();
|
||||
let Some(styles) = cube_styles(&faces) else {
|
||||
@@ -153,6 +154,7 @@ mod tests {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.collect();
|
||||
faces[0].opaque = false;
|
||||
|
||||
@@ -3423,6 +3423,9 @@ impl OptionsUi {
|
||||
self.narrate_list(&widgets, list_key, (top, bottom), w, cursor);
|
||||
}
|
||||
let mut tooltip = None;
|
||||
// The widget a keyboard-focused tooltip belongs to: `MenuTooltipPositioner`
|
||||
// keeps it against that widget, not at the pointer.
|
||||
let mut tooltip_rect: Option<[f32; 4]> = None;
|
||||
for (wd_idx, wd) in widgets.iter().enumerate() {
|
||||
if !visible(wd) {
|
||||
continue;
|
||||
@@ -3432,9 +3435,9 @@ impl OptionsUi {
|
||||
.as_ref()
|
||||
.is_some_and(|fid| matches_focus(fid, wd_idx, wd));
|
||||
let start = batch.vertices.len();
|
||||
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom))
|
||||
|| is_focused;
|
||||
let pointer_over = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& (!wd.list || cursor.1 >= top && cursor.1 < bottom);
|
||||
let hover = pointer_over || is_focused;
|
||||
// A pack's option: its label within the widget (the name shortened), and on hover
|
||||
// the whole label when it was, then what the pack says of it.
|
||||
let shader = s.shader_options.as_ref();
|
||||
@@ -3478,6 +3481,7 @@ impl OptionsUi {
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
tooltip = Some(lines);
|
||||
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
|
||||
}
|
||||
}
|
||||
if hover && let Some(tip) = tooltip_text(s, wd) {
|
||||
@@ -3492,6 +3496,7 @@ impl OptionsUi {
|
||||
.collect::<Vec<_>>(),
|
||||
_ => atlas.font.split(&tip, 170.0),
|
||||
});
|
||||
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, .. } => {
|
||||
@@ -3663,7 +3668,10 @@ impl OptionsUi {
|
||||
self.settings_search = search;
|
||||
}
|
||||
if let Some(lines) = tooltip {
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
match tooltip_rect {
|
||||
Some(rect) => ui::tooltip_menu(batch, atlas, &lines, cursor, rect),
|
||||
None => ui::tooltip(batch, atlas, &lines, cursor),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Mouse press/drag. Returns events for the game.
|
||||
@@ -4225,7 +4233,8 @@ impl OptionsUi {
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !plain || !s.extras.wheel_cycles_settings {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -7313,6 +7322,55 @@ mod tests {
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_only_scrolls_with_the_extra_off() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let framebuffer = [854, 480];
|
||||
let size = (427.0, 240.0);
|
||||
let (widgets, top, bottom, content) = ui.layout(&s, size.0, size.1);
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the video page scrolls at this size"
|
||||
);
|
||||
let (opt, over) = widgets
|
||||
.iter()
|
||||
.find_map(|wd| match wd.kind {
|
||||
WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if !wd.list || (wd.y >= top && wd.y + wd.h <= bottom) => {
|
||||
Some((opt, (wd.x + 5.0, wd.y + 5.0)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("a visible cycle button");
|
||||
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let before = format!("{s:?}");
|
||||
let start = ui.scroll;
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before, "the hovered option stays put");
|
||||
assert!(!events.contains(&Event::Changed));
|
||||
assert!(
|
||||
ui.scroll > start,
|
||||
"the list scrolls under the cursor instead"
|
||||
);
|
||||
// Keyboard focus doesn't take the wheel either.
|
||||
ui.focus = Some(FocusId::Cycle(opt));
|
||||
ui.focus_from_keyboard = true;
|
||||
ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before);
|
||||
|
||||
// On (the default), vanilla's CycleButton.mouseScrolled again.
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
let before = format!("{s:?}");
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_ne!(format!("{s:?}"), before, "{opt:?} cycles");
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
|
||||
let stepped_opts = [
|
||||
|
||||
@@ -746,7 +746,8 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
// Vanilla's files in one file: it describes vanilla's root, not this mirror.
|
||||
if hidden.contains(&child) || child == horizon_core::prepared_files::FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
|
||||
@@ -0,0 +1,997 @@
|
||||
//! Far terrain on servers: every chunk a server sends is kept as its
|
||||
//! far-terrain summary (`lod::Column`s), on disk per server, world and
|
||||
//! dimension, and drawn beyond the server's view distance on this visit and
|
||||
//! the next ones. A server never sends its seed, so its
|
||||
//! distant terrain can't be generated as a local world's is.
|
||||
//!
|
||||
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
|
||||
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
|
||||
//! A chunk it doesn't have may be in the player's Bobby cache of the same
|
||||
//! level (`Bobby`), only ever read; past both, the server's BlueMap may fill
|
||||
//! in (`bluemap`).
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
bluemap::{BlueMap, Found},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
lod::{Column, Span, Spans, Surface, state_kinds, summarize},
|
||||
streaming::{LodDetail, LodSampler, LodSource},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
compression::Compression,
|
||||
nbt::{Limits, NamedTag, Tag},
|
||||
region::{ChunkRecord, RegionFile},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// Bumped when the summary layout changes; older records are ignored.
|
||||
const FORMAT: i32 = 1;
|
||||
/// Decoded chunks kept in memory, and open region files.
|
||||
const MAX_CHUNKS: usize = 8192;
|
||||
const MAX_REGIONS: usize = 64;
|
||||
/// The widest cell, in chunks, drawn from the chunks kept under it
|
||||
/// (`Sampler::reduced_column`): its regions all stay open.
|
||||
const MAX_KEPT_SIDE: i32 = 128;
|
||||
|
||||
type Columns = Arc<[Column; 256]>;
|
||||
|
||||
/// The folder of a server's terrain: its address as typed, lowercased, with
|
||||
/// the default port left out, and every character a file name can't hold
|
||||
/// replaced (`play.example.net:25566` → `play.example.net_25566`).
|
||||
pub fn server_folder(address: &str) -> String {
|
||||
let address = address.trim().to_ascii_lowercase();
|
||||
let address = address.strip_suffix(":25565").unwrap_or(&address);
|
||||
let name: String = address
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let name = name.trim_matches('.');
|
||||
if name.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
name.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// `root/<server>/<seed>/<namespace>/<path>` for a level key
|
||||
/// (`minecraft:overworld`) and the login's hashed seed, which keeps
|
||||
/// the worlds behind one address (a proxy's servers, a reset) apart.
|
||||
pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> PathBuf {
|
||||
root.join(server_folder(address))
|
||||
.join(hashed_seed.to_string())
|
||||
.join(level_path(level))
|
||||
}
|
||||
|
||||
/// The official launcher's game folder (`HORIZON_MINECRAFT_DIR` if set),
|
||||
/// where Bobby keeps its cache (`.bobby`): as
|
||||
/// `horizon_prepare::artifacts::minecraft_dir` finds it, which this crate
|
||||
/// can't depend on.
|
||||
pub fn minecraft_dir() -> PathBuf {
|
||||
let home = || {
|
||||
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
|
||||
std::env::var_os(var).map_or_else(|| PathBuf::from("."), PathBuf::from)
|
||||
};
|
||||
if let Some(dir) = std::env::var_os("HORIZON_MINECRAFT_DIR").filter(|d| !d.is_empty()) {
|
||||
let dir = PathBuf::from(dir);
|
||||
if let Ok(rest) = dir.strip_prefix("~") {
|
||||
return home().join(rest);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
home().join("Library/Application Support/minecraft")
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA")
|
||||
.map_or_else(home, PathBuf::from)
|
||||
.join(".minecraft")
|
||||
} else {
|
||||
home().join(".minecraft")
|
||||
}
|
||||
}
|
||||
|
||||
/// Bobby's folder of a level under `root` (`.bobby`), as Bobby leaves it on
|
||||
/// disk: `<address as typed, ':' as '_'>/<hashed seed>/<namespace>/<path>`.
|
||||
/// Names are compared without case, and `host` and `host_25565` both stand
|
||||
/// for the default port: neither was seen, so both are taken.
|
||||
pub fn bobby_level_dir(
|
||||
root: &Path,
|
||||
address: &str,
|
||||
hashed_seed: i64,
|
||||
level: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let typed = address.trim().replace(':', "_");
|
||||
let bare = typed.strip_suffix("_25565").unwrap_or(&typed);
|
||||
if bare.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names = [bare.to_owned(), format!("{bare}_25565")];
|
||||
let mut servers: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| names.iter().any(|n| n.eq_ignore_ascii_case(name)))
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
servers.sort();
|
||||
servers
|
||||
.into_iter()
|
||||
.map(|server| server.join(hashed_seed.to_string()).join(level_path(level)))
|
||||
.find(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// `<namespace>/<path>` of a level key.
|
||||
fn level_path(level: &str) -> PathBuf {
|
||||
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
|
||||
let clean = |s: &str| -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('.')
|
||||
.to_owned()
|
||||
};
|
||||
let mut dir = PathBuf::from(clean(namespace));
|
||||
for part in path.split('/').map(clean).filter(|p| !p.is_empty()) {
|
||||
dir.push(part);
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
struct Region {
|
||||
file: RegionFile,
|
||||
present: [bool; 1024],
|
||||
}
|
||||
|
||||
/// Open region files by region; `None`: the file doesn't exist.
|
||||
type Regions = FxHashMap<(i32, i32), Option<Region>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
regions: Regions,
|
||||
/// Bobby's, opened read-only.
|
||||
bobby: Regions,
|
||||
/// `None`: the chunk isn't stored.
|
||||
chunks: FxHashMap<(i32, i32), Option<Columns>>,
|
||||
}
|
||||
|
||||
/// A saved full chunk with its stored light, or `None` (`save::decode_lit`
|
||||
/// in the game).
|
||||
pub type DecodeChunk = Box<dyn Fn(&NamedTag) -> Option<Chunk> + Send + Sync>;
|
||||
|
||||
/// The chunks the player's Bobby kept of the same level: vanilla chunk NBT
|
||||
/// in region files, as Bobby leaves them on disk. Only ever read; a chunk
|
||||
/// far terrain needs from there is kept in the level's own folder.
|
||||
pub struct Bobby {
|
||||
dir: PathBuf,
|
||||
decode: DecodeChunk,
|
||||
/// `lod::state_kinds`, to summarize its chunks as loaded ones are.
|
||||
kinds: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Bobby {
|
||||
pub fn new(dir: PathBuf, decode: DecodeChunk, facts: &BlockFacts) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
decode,
|
||||
kinds: state_kinds(facts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One level's stored terrain, shared by the far-terrain threads. A chunk
|
||||
/// it doesn't have comes from Bobby's cache, else the server's BlueMap, if
|
||||
/// the player named it or Find BlueMap found it.
|
||||
pub struct ServerTerrain {
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
state: Mutex<State>,
|
||||
warned: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl ServerTerrain {
|
||||
pub fn new(dir: PathBuf, blue_map: Option<Arc<BlueMap>>) -> Arc<Self> {
|
||||
Self::with_bobby(dir, blue_map, None)
|
||||
}
|
||||
|
||||
pub fn with_bobby(
|
||||
dir: PathBuf,
|
||||
blue_map: Option<Arc<BlueMap>>,
|
||||
bobby: Option<Bobby>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
dir,
|
||||
blue_map,
|
||||
bobby,
|
||||
state: Mutex::default(),
|
||||
warned: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// BlueMap's coarser levels at block (x, z) (a void there is `Ready`).
|
||||
fn coarser(&self, x: i32, z: i32) -> Found<Column> {
|
||||
self.blue_map
|
||||
.as_ref()
|
||||
.map_or(Found::Absent, |map| map.coarser_column(x, z))
|
||||
}
|
||||
|
||||
/// A chunk's columns: stored, else BlueMap's (`Waiting`: on its way).
|
||||
fn chunk_or_map(&self, x: i32, z: i32) -> Found<Columns> {
|
||||
if let Some(columns) = self.chunk(x, z) {
|
||||
return Found::Ready(columns);
|
||||
}
|
||||
self.blue_map
|
||||
.as_ref()
|
||||
.map_or(Found::Absent, |map| map.chunk(x, z))
|
||||
}
|
||||
|
||||
/// The stored chunk nearest the middle of the `side` × `side` chunks
|
||||
/// from `(x, z)`, found in the region headers without reading any.
|
||||
#[cfg(test)]
|
||||
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
|
||||
self.kept(x, z, side).1
|
||||
}
|
||||
|
||||
/// How many of the `side` × `side` chunks from `(x, z)` are stored here
|
||||
/// or in Bobby's cache, and the one nearest their middle (`nearest`).
|
||||
fn kept(&self, x: i32, z: i32, side: i32) -> (usize, Option<(i32, i32)>) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let middle = (x * 2 + side, z * 2 + side);
|
||||
let mut count = 0;
|
||||
let mut best: Option<(i64, (i32, i32))> = None;
|
||||
for rz in z >> 5..=(z + side - 1) >> 5 {
|
||||
for rx in x >> 5..=(x + side - 1) >> 5 {
|
||||
let mut present = region(&mut state.regions, &self.dir, (rx, rz))
|
||||
.map_or([false; 1024], |r| r.present);
|
||||
if let Some(bobby) = &self.bobby
|
||||
&& let Some(theirs) = read_only(&mut state.bobby, &bobby.dir, (rx, rz))
|
||||
{
|
||||
for (here, there) in present.iter_mut().zip(theirs.present) {
|
||||
*here |= there;
|
||||
}
|
||||
}
|
||||
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
|
||||
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
|
||||
if cx < x || cz < z || cx >= x + side || cz >= z + side {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let (dx, dz) = (
|
||||
i64::from(cx * 2 + 1 - middle.0),
|
||||
i64::from(cz * 2 + 1 - middle.1),
|
||||
);
|
||||
let distance = dx * dx + dz * dz;
|
||||
if best.is_none_or(|(d, _)| distance < d) {
|
||||
best = Some((distance, (cx, cz)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(count, best.map(|(_, chunk)| chunk))
|
||||
}
|
||||
|
||||
/// A stored chunk's columns, else Bobby's (kept here from then on).
|
||||
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
if let Some(known) = self.state.lock().unwrap().chunks.get(&(x, z)) {
|
||||
return known.clone();
|
||||
}
|
||||
let columns = self.stored(x, z).or_else(|| self.bobby_chunk(x, z));
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Stored by another thread meanwhile (`write`): that, newer than
|
||||
// what was read here.
|
||||
if let Some(Some(stored)) = state.chunks.get(&(x, z)) {
|
||||
return Some(stored.clone());
|
||||
}
|
||||
remember(&mut state, (x, z), columns.clone());
|
||||
columns
|
||||
}
|
||||
|
||||
/// The chunk's record in the level's own folder; one of another game
|
||||
/// version or layout is none.
|
||||
fn stored(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let record = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
read(
|
||||
region(&mut state.regions, &self.dir, (x >> 5, z >> 5)),
|
||||
x,
|
||||
z,
|
||||
)
|
||||
}?;
|
||||
record
|
||||
.nbt(Limits::default())
|
||||
.ok()
|
||||
.and_then(|root| decode(&root.tag))
|
||||
}
|
||||
|
||||
/// A chunk in Bobby's cache, summarized and kept here. One of another
|
||||
/// game version, or that doesn't decode, is none.
|
||||
fn bobby_chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let bobby = self.bobby.as_ref()?;
|
||||
let (record, kept) = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
let kept =
|
||||
region(&mut state.regions, &self.dir, key).is_some_and(|r| r.present[slot(x, z)]);
|
||||
(
|
||||
read(read_only(&mut state.bobby, &bobby.dir, key), x, z),
|
||||
kept,
|
||||
)
|
||||
};
|
||||
let record = record?;
|
||||
let root = record.nbt(Limits::default()).ok()?;
|
||||
if root.tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION)) {
|
||||
return None;
|
||||
}
|
||||
let chunk = (bobby.decode)(&root).filter(|chunk| (chunk.x, chunk.z) == (x, z))?;
|
||||
let columns = summarize(&chunk, &bobby.kinds);
|
||||
// Not over a chunk the server sent while this one was decoded.
|
||||
if let Err(e) = self.write((x, z), &columns, Some(kept)) {
|
||||
self.warn_once(&e);
|
||||
}
|
||||
Some(columns)
|
||||
}
|
||||
|
||||
/// A chunk couldn't be kept (disk full, a folder it can't write): said
|
||||
/// once a level, not for every chunk after.
|
||||
fn warn_once(&self, error: &std::io::Error) {
|
||||
if !self.warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
log::warn!("Horizon: far terrain can't keep the server's chunks: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a chunk's columns, unless they are what is stored already.
|
||||
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
let known = self.state.lock().unwrap().chunks.get(&key).cloned();
|
||||
let old = known.unwrap_or_else(|| {
|
||||
let old = self.stored(key.0, key.1);
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if !state.chunks.contains_key(&key) {
|
||||
remember(&mut state, key, old.clone());
|
||||
}
|
||||
old
|
||||
});
|
||||
if old.is_some_and(|old| *old == **columns) {
|
||||
return Ok(());
|
||||
}
|
||||
self.write(key, columns, None)
|
||||
}
|
||||
|
||||
/// `kept`: whether the chunk's record was in the folder before; the
|
||||
/// columns aren't written over one another thread kept meanwhile.
|
||||
fn write(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
columns: &Columns,
|
||||
kept: Option<bool>,
|
||||
) -> std::io::Result<()> {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: encode(x, z, columns),
|
||||
};
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
if let Some(kept) = kept
|
||||
&& (matches!(state.chunks.get(&(x, z)), Some(Some(_)))
|
||||
|| (!kept
|
||||
&& region(&mut state.regions, &self.dir, key)
|
||||
.is_some_and(|r| r.present[slot(x, z)])))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if region(&mut state.regions, &self.dir, key).is_none() {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
|
||||
state.regions.insert(key, Some(Region::new(file)));
|
||||
}
|
||||
let region = region(&mut state.regions, &self.dir, key).expect("region just opened");
|
||||
region.file.write(ChunkPos::new(x, z), &record)?;
|
||||
region.present[slot(x, z)] = true;
|
||||
remember(&mut state, (x, z), Some(columns.clone()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
|
||||
dir.join(format!("r.{rx}.{rz}.mca"))
|
||||
}
|
||||
|
||||
/// A region of `dir`, opened on first use.
|
||||
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || {
|
||||
RegionFile::open_writable(region_path(dir, key), false)
|
||||
})
|
||||
}
|
||||
|
||||
/// A region of Bobby's `dir`, opened for reading only.
|
||||
fn read_only<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
open(regions, key, || RegionFile::open(region_path(dir, key)))
|
||||
}
|
||||
|
||||
fn open(
|
||||
regions: &mut Regions,
|
||||
key: (i32, i32),
|
||||
file: impl FnOnce() -> std::io::Result<RegionFile>,
|
||||
) -> Option<&mut Region> {
|
||||
if !regions.contains_key(&key) {
|
||||
if regions.len() >= MAX_REGIONS {
|
||||
regions.clear();
|
||||
}
|
||||
regions.insert(key, file().ok().map(Region::new));
|
||||
}
|
||||
regions.get_mut(&key)?.as_mut()
|
||||
}
|
||||
|
||||
fn read(region: Option<&mut Region>, x: i32, z: i32) -> Option<ChunkRecord> {
|
||||
let region = region.filter(|r| r.present[slot(x, z)])?;
|
||||
region.file.read(ChunkPos::new(x, z)).ok().flatten()
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn new(file: RegionFile) -> Self {
|
||||
let mut present = [false; 1024];
|
||||
for (pos, _) in file.chunks() {
|
||||
present[slot(pos.x, pos.z)] = true;
|
||||
}
|
||||
Self { file, present }
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(x: i32, z: i32) -> usize {
|
||||
(x & 31) as usize + (z & 31) as usize * 32
|
||||
}
|
||||
|
||||
fn remember(state: &mut State, key: (i32, i32), columns: Option<Columns>) {
|
||||
if state.chunks.len() >= MAX_CHUNKS {
|
||||
state.chunks.clear();
|
||||
}
|
||||
state.chunks.insert(key, columns);
|
||||
}
|
||||
|
||||
fn now() -> u32 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as u32)
|
||||
}
|
||||
|
||||
/// The columns as one int array: per column a surface (`0`, or `1`, height,
|
||||
/// state, water flag, water height, water state, biome), the span count, and
|
||||
/// three ints a span (min and max, state, biome and light).
|
||||
fn encode(x: i32, z: i32, columns: &[Column; 256]) -> Tag {
|
||||
let mut ints = Vec::with_capacity(256 * 8);
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => ints.push(0),
|
||||
Some(s) => {
|
||||
let (water, water_height, water_state) = s
|
||||
.water
|
||||
.map_or((0, 0, 0), |(h, state)| (1, h, state.0 as i32));
|
||||
ints.extend([
|
||||
1,
|
||||
s.height,
|
||||
s.state.0 as i32,
|
||||
water,
|
||||
water_height,
|
||||
water_state,
|
||||
i32::from(s.biome),
|
||||
]);
|
||||
}
|
||||
}
|
||||
ints.push(column.spans.len() as i32);
|
||||
for span in &column.spans {
|
||||
ints.extend([
|
||||
i32::from(span.min as u16) << 16 | i32::from(span.max as u16),
|
||||
span.state.0 as i32,
|
||||
i32::from(span.biome) << 16 | i32::from(span.light),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("HorizonFarTerrain", Tag::Int(FORMAT)),
|
||||
("xPos", Tag::Int(x)),
|
||||
("zPos", Tag::Int(z)),
|
||||
("columns", Tag::IntArray(ints)),
|
||||
])
|
||||
}
|
||||
|
||||
/// `encode`'s columns, or `None` for a record of another game version or
|
||||
/// layout (their block states and biomes are numbered differently).
|
||||
fn decode(tag: &Tag) -> Option<Columns> {
|
||||
if tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION))
|
||||
|| tag.get("HorizonFarTerrain") != Some(&Tag::Int(FORMAT))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let Some(Tag::IntArray(ints)) = tag.get("columns") else {
|
||||
return None;
|
||||
};
|
||||
let mut ints = ints.iter().copied();
|
||||
let mut next = || ints.next();
|
||||
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
|
||||
for column in &mut columns {
|
||||
column.surface = match next()? {
|
||||
0 => None,
|
||||
1 => {
|
||||
let height = next()?;
|
||||
let state = BlockStateId(next()? as u32);
|
||||
let water = next()?;
|
||||
let water_height = next()?;
|
||||
let water_state = BlockStateId(next()? as u32);
|
||||
let biome = next()? as u16;
|
||||
Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water: (water != 0).then_some((water_height, water_state)),
|
||||
biome,
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let count = usize::try_from(next()?).ok().filter(|&n| n <= 4096)?;
|
||||
column.spans = Spans::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let range = next()?;
|
||||
let state = BlockStateId(next()? as u32);
|
||||
let material = next()?;
|
||||
column.spans.push(Span {
|
||||
min: (range >> 16) as i16,
|
||||
max: range as i16,
|
||||
state,
|
||||
biome: (material >> 16) as u16,
|
||||
light: material as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
next().is_none().then(|| Arc::new(columns))
|
||||
}
|
||||
|
||||
impl LodSource for ServerTerrain {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
|
||||
Box::new(Sampler {
|
||||
terrain: self,
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
})
|
||||
}
|
||||
fn keeps_observed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
/// For a column whose chunk of the map is on its way: its coarser
|
||||
/// levels' meanwhile, as they come first (a void there is drawn as
|
||||
/// one). None there either is a hole (`waited`: the sample isn't drawn);
|
||||
/// either way the column is sampled again when its tile arrives
|
||||
/// (`Lod::refresh`).
|
||||
fn coarser(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.coarser(x, z) {
|
||||
Found::Ready(column) => column,
|
||||
Found::Waiting | Found::Absent => {
|
||||
self.waited = true;
|
||||
Column::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Sampler {
|
||||
terrain: Arc<ServerTerrain>,
|
||||
details: Vec<LodDetail>,
|
||||
/// A column was left empty for BlueMap's on its way, with no coarser
|
||||
/// level to stand in (`waited`).
|
||||
waited: bool,
|
||||
}
|
||||
|
||||
impl LodSampler for Sampler {
|
||||
fn fork(&self) -> Option<Box<dyn LodSampler>> {
|
||||
Some(Box::new(Self {
|
||||
terrain: self.terrain.clone(),
|
||||
details: Vec::new(),
|
||||
waited: false,
|
||||
}))
|
||||
}
|
||||
/// Stored chunks come back whole, else BlueMap's (its coarser levels'
|
||||
/// where the finest is out of reach); the others are known empty. One
|
||||
/// whose BlueMap tile is still downloading is asked again later.
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
|
||||
for &(x, z) in chunks {
|
||||
let columns = match self.terrain.chunk_or_map(x, z) {
|
||||
Found::Ready(columns) => columns,
|
||||
Found::Waiting => continue,
|
||||
Found::Absent => Arc::new(std::array::from_fn(|_| Column::default())),
|
||||
};
|
||||
self.details.push(((x, z), columns));
|
||||
}
|
||||
}
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
std::mem::take(&mut self.details)
|
||||
}
|
||||
fn observed(&mut self, key: (i32, i32), columns: &Columns) {
|
||||
if let Err(e) = self.terrain.store(key, columns) {
|
||||
self.terrain.warn_once(&e);
|
||||
}
|
||||
}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<Surface> {
|
||||
self.column(x, z).surface
|
||||
}
|
||||
fn column(&mut self, x: i32, z: i32) -> Column {
|
||||
match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(c) => c[((z & 15) * 16 + (x & 15)) as usize].clone(),
|
||||
Found::Waiting => self.coarser(x, z),
|
||||
Found::Absent => Column::default(),
|
||||
}
|
||||
}
|
||||
fn waited(&mut self) -> bool {
|
||||
std::mem::take(&mut self.waited)
|
||||
}
|
||||
/// A cell of `width` blocks from one stored chunk: its own area up to a
|
||||
/// chunk, else the stored chunk nearest the cell's middle (the server
|
||||
/// sent only the chunks a player came near), when most of the cell is
|
||||
/// stored. With a BlueMap, a wider
|
||||
/// cell takes the chunk at its middle if stored, else the map's pixel
|
||||
/// there, and the nearest stored chunk only while the map has none.
|
||||
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
|
||||
if width <= 16 {
|
||||
let chunk = match self.terrain.chunk_or_map(x >> 4, z >> 4) {
|
||||
Found::Ready(chunk) => chunk,
|
||||
Found::Waiting => return self.coarser(x, z),
|
||||
Found::Absent => return Column::default(),
|
||||
};
|
||||
let width = width.max(1) as usize;
|
||||
let lx = (x & 15) as usize & !(width - 1);
|
||||
let lz = (z & 15) as usize & !(width - 1);
|
||||
return Column::reduced(&chunk, lx, lz, width);
|
||||
}
|
||||
let mut waiting = false;
|
||||
if let Some(map) = &self.terrain.blue_map {
|
||||
let (mx, mz) = ((x + width / 2) >> 4, (z + width / 2) >> 4);
|
||||
if let Some(chunk) = self.terrain.chunk(mx, mz) {
|
||||
return Column::reduced(&chunk, 0, 0, 16);
|
||||
}
|
||||
match map.reduced_column(x, z, width) {
|
||||
Found::Ready(column) => return column,
|
||||
Found::Waiting => waiting = true,
|
||||
Found::Absent => {}
|
||||
}
|
||||
}
|
||||
// Only over a cell the server sent most of, as `Column::reduced`
|
||||
// keeps what fills half a cell: one chunk drawn as a whole cell stood
|
||||
// as a flat slab far wider than anything seen there, until finer
|
||||
// tiles replaced it with the nothing they know of.
|
||||
let side = width >> 4;
|
||||
// Wider cells are never mostly sent, and counting them would open
|
||||
// thousands of region files.
|
||||
if side > MAX_KEPT_SIDE {
|
||||
self.waited |= waiting;
|
||||
return Column::default();
|
||||
}
|
||||
let (count, nearest) = self.terrain.kept(x >> 4, z >> 4, side);
|
||||
let column = (count * 2 >= (side * side) as usize)
|
||||
.then(|| nearest.and_then(|(cx, cz)| self.terrain.chunk(cx, cz)))
|
||||
.flatten()
|
||||
.map(|chunk| Column::reduced(&chunk, 0, 0, 16));
|
||||
self.waited |= waiting && column.is_none();
|
||||
column.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn columns() -> Columns {
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
if i % 3 == 0 {
|
||||
return Column::default();
|
||||
}
|
||||
Column {
|
||||
surface: Some(Surface {
|
||||
height: 63 + i as i32 % 7,
|
||||
state: BlockStateId(9 + i as u32),
|
||||
water: (i % 2 == 0).then_some((62, BlockStateId(86))),
|
||||
biome: (i % 5) as u16,
|
||||
}),
|
||||
spans: vec![
|
||||
Span {
|
||||
min: -64,
|
||||
max: 40,
|
||||
state: BlockStateId(1),
|
||||
biome: 3,
|
||||
light: 0,
|
||||
},
|
||||
Span {
|
||||
min: 40,
|
||||
max: 63 + i as i16 % 7,
|
||||
state: BlockStateId(9),
|
||||
biome: 7,
|
||||
light: 0xF0,
|
||||
},
|
||||
]
|
||||
.into(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_survive_their_encoding() {
|
||||
let columns = columns();
|
||||
assert_eq!(decode(&encode(3, -4, &columns)), Some(columns));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_of_another_version_are_ignored() {
|
||||
let mut tag = encode(0, 0, &columns());
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert("DataVersion".into(), Tag::Int(DATA_VERSION - 1));
|
||||
}
|
||||
assert_eq!(decode(&tag), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_folders_are_plain_names() {
|
||||
assert_eq!(server_folder("Play.Example.net"), "play.example.net");
|
||||
assert_eq!(server_folder("play.example.net:25565"), "play.example.net");
|
||||
assert_eq!(
|
||||
server_folder("play.example.net:25566"),
|
||||
"play.example.net_25566"
|
||||
);
|
||||
assert_eq!(server_folder("[::1]:25570"), "___1__25570");
|
||||
assert_eq!(server_folder("../.."), "_");
|
||||
assert_eq!(
|
||||
level_dir(Path::new("/c"), "a", -7, "minecraft:the_nether"),
|
||||
Path::new("/c/a/-7/minecraft/the_nether")
|
||||
);
|
||||
assert_eq!(
|
||||
level_dir(Path::new("/c"), "a", 0, "mod:../../x"),
|
||||
Path::new("/c/a/0/mod/x")
|
||||
);
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_chunks_come_back_after_a_restart() {
|
||||
let dir = scratch("server-terrain");
|
||||
let columns = columns();
|
||||
{
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
assert_eq!(terrain.chunk(-33, 5), None);
|
||||
terrain.store((-33, 5), &columns).unwrap();
|
||||
}
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
assert_eq!(terrain.chunk(-33, 5), Some(columns.clone()));
|
||||
assert_eq!(terrain.nearest(-64, 0, 64), Some((-33, 5)));
|
||||
assert_eq!(terrain.nearest(-32, 0, 64), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
|
||||
assert_eq!(sampler.column(0, 0), Column::default());
|
||||
// One chunk doesn't stand for a far cell of 64 × 64.
|
||||
assert_eq!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_cells_are_drawn_where_most_of_them_was_kept() {
|
||||
let dir = scratch("server-terrain-wide");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
// A cell of 4 × 4 chunks, and a chunk of it kept.
|
||||
terrain.store((2, 1), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
for (x, z) in [(0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (1, 1)] {
|
||||
terrain.store((x, z), &columns).unwrap();
|
||||
assert_eq!(sampler.reduced_column(0, 0, 64), Column::default());
|
||||
}
|
||||
// Half of it: drawn from the kept chunk nearest its middle.
|
||||
terrain.store((2, 2), &columns).unwrap();
|
||||
assert_eq!(
|
||||
sampler.reduced_column(0, 0, 64),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Far terrain samples a cell before the server sent its chunk, then
|
||||
/// again once it is kept: nothing remembers the empty sample.
|
||||
#[test]
|
||||
fn a_chunk_kept_after_an_empty_sample_is_sampled_next_time() {
|
||||
let dir = scratch("server-terrain-late");
|
||||
let columns = columns();
|
||||
let terrain = ServerTerrain::new(dir.clone(), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
let mut other = sampler.fork().expect("a sampling thread's own");
|
||||
for width in [1, 4, 16] {
|
||||
assert_eq!(other.reduced_column(-16, 32, width), Column::default());
|
||||
}
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), vec![Column::default(); 256]);
|
||||
// `Job::Observe` stores it on the far-terrain worker.
|
||||
sampler.observed((-1, 2), &columns);
|
||||
assert_eq!(other.grid(-16, 32, 1, 16), columns.to_vec());
|
||||
assert_eq!(
|
||||
other.reduced_column(-16, 32, 16),
|
||||
Column::reduced(&columns, 0, 0, 16)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bobby_folders_follow_the_address_as_typed() {
|
||||
let root = scratch("bobby-folders");
|
||||
for dir in [
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether",
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld",
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
"Play.Example.net_25565/5/minecraft/overworld",
|
||||
] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
let find = |address: &str, seed: i64, level: &str| {
|
||||
bobby_level_dir(&root, address, seed, level)
|
||||
.map(|dir| dir.strip_prefix(&root).unwrap().to_owned())
|
||||
};
|
||||
let overworld = "minecraft:overworld";
|
||||
let first = Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(find("127.0.0.1", -9030034075591913594, overworld), first);
|
||||
assert_eq!(
|
||||
find("127.0.0.1:25565", -9030034075591913594, overworld),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_nether"),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/-9030034075591913594/minecraft/the_nether"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", 4696776658964135993, overworld),
|
||||
Some(PathBuf::from(
|
||||
"127.0.0.1/4696776658964135993/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
let other_port = Some(PathBuf::from(
|
||||
"localhost_25566/-7181178862026813658/minecraft/overworld",
|
||||
));
|
||||
assert_eq!(
|
||||
find("localhost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("LocalHost:25566", -7181178862026813658, overworld),
|
||||
other_port
|
||||
);
|
||||
assert_eq!(
|
||||
find("play.example.net", 5, overworld),
|
||||
Some(PathBuf::from(
|
||||
"Play.Example.net_25565/5/minecraft/overworld"
|
||||
))
|
||||
);
|
||||
// Another world behind the address, another dimension, another port.
|
||||
assert_eq!(find("127.0.0.1", 1111, overworld), None);
|
||||
assert_eq!(
|
||||
find("127.0.0.1", -9030034075591913594, "minecraft:the_end"),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("localhost", -7181178862026813658, overworld), None);
|
||||
assert_eq!(
|
||||
find("localhost:25567", -7181178862026813658, overworld),
|
||||
None
|
||||
);
|
||||
assert_eq!(find("", 0, overworld), None);
|
||||
assert_eq!(
|
||||
bobby_level_dir(&root.join("none"), "127.0.0.1", 0, overworld),
|
||||
None
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// A Bobby-shaped region of chunk records, written with `version`.
|
||||
fn bobby_region(dir: &Path, chunks: &[(i32, i32)], version: i32) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let (rx, rz) = (chunks[0].0 >> 5, chunks[0].1 >> 5);
|
||||
let mut file = RegionFile::open_writable(region_path(dir, (rx, rz)), true).unwrap();
|
||||
for &(x, z) in chunks {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([
|
||||
("DataVersion", Tag::Int(version)),
|
||||
("Status", Tag::String("full".into())),
|
||||
("xPos", Tag::Int(x)),
|
||||
("zPos", Tag::Int(z)),
|
||||
]),
|
||||
};
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, 0, Limits::default());
|
||||
file.write(ChunkPos::new(x, z), &record.unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stone from y 60 to 70 in every column (the game decodes vanilla chunk NBT
|
||||
/// with `save::decode_lit`; its palettes are tested there).
|
||||
fn stone(root: &NamedTag) -> Option<Chunk> {
|
||||
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (root.tag.get("xPos"), root.tag.get("zPos"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let mut chunk = Chunk::empty(*x, *z);
|
||||
for i in 0..256 {
|
||||
for y in 60..70 {
|
||||
chunk.set(i & 15, y, i >> 4, 1);
|
||||
}
|
||||
}
|
||||
Some(chunk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_only_bobby_has_are_read_once_and_kept() {
|
||||
let dir = scratch("server-terrain-bobby");
|
||||
let (own, theirs) = (dir.join("own"), dir.join("bobby"));
|
||||
bobby_region(&theirs, &[(-3, 5), (-2, 5), (-1, 6)], DATA_VERSION);
|
||||
bobby_region(&theirs.join("old"), &[(-3, 5)], DATA_VERSION - 1);
|
||||
let region = region_path(&theirs, (-1, 0));
|
||||
let before = std::fs::read(®ion).unwrap();
|
||||
// State 0 air, state 1 a kept block (`lod::state_kinds`).
|
||||
let bobby = |dir: &Path| Bobby {
|
||||
dir: dir.to_owned(),
|
||||
decode: Box::new(stone),
|
||||
kinds: vec![4, 1],
|
||||
};
|
||||
let terrain = ServerTerrain::with_bobby(own.clone(), None, Some(bobby(&theirs)));
|
||||
// Counted for wide cells before they are read.
|
||||
assert_eq!(terrain.kept(-4, 4, 4), (3, Some((-3, 5))));
|
||||
let expected = summarize(
|
||||
&stone(&NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([("xPos", Tag::Int(-2)), ("zPos", Tag::Int(5))]),
|
||||
})
|
||||
.unwrap(),
|
||||
&[4, 1],
|
||||
);
|
||||
assert!(expected[0].surface.is_some());
|
||||
assert_eq!(terrain.chunk(-2, 5), Some(expected.clone()));
|
||||
assert_eq!(terrain.chunk(-2, 6), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.column(-2 * 16 + 3, 5 * 16), expected[3]);
|
||||
drop(sampler);
|
||||
drop(terrain);
|
||||
// Kept in the level's own folder; Bobby's left as it was.
|
||||
let kept = ServerTerrain::new(own.clone(), None);
|
||||
assert_eq!(kept.chunk(-2, 5), Some(expected));
|
||||
assert_eq!(kept.chunk(-3, 5), None);
|
||||
assert_eq!(std::fs::read(®ion).unwrap(), before);
|
||||
// Another game version's chunk is none.
|
||||
let old =
|
||||
ServerTerrain::with_bobby(dir.join("own-old"), None, Some(bobby(&theirs.join("old"))));
|
||||
assert_eq!(old.chunk(-3, 5), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -222,10 +222,14 @@ fn component_text(tag: &Tag) -> String {
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// Whether a block name is a sign (standing, wall, hanging or wall hanging).
|
||||
/// Whether a vanilla block's name is a sign (standing, wall, hanging or wall
|
||||
/// hanging). A mod's sign goes by its template's class, whatever its name; a
|
||||
/// mod's `road_sign` of stone is no sign.
|
||||
pub fn sign_kind(name: &str) -> Option<bool> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
if name == "hanging_sign" || name.ends_with("_hanging_sign") {
|
||||
if name.contains(':') {
|
||||
None
|
||||
} else if name == "hanging_sign" || name.ends_with("_hanging_sign") {
|
||||
Some(true)
|
||||
} else if name == "sign" || name.ends_with("_sign") {
|
||||
Some(false)
|
||||
@@ -233,15 +237,17 @@ pub fn sign_kind(name: &str) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Wood type of a sign block (`SignBlock.getWoodType`), for the editor texture.
|
||||
pub fn wood_type(name: &str) -> &str {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
for suffix in ["_wall_hanging_sign", "_hanging_sign", "_wall_sign", "_sign"] {
|
||||
if let Some(wood) = name.strip_suffix(suffix) {
|
||||
return wood;
|
||||
/// The sprite of a wood type's editor background, `folder` being `signs` or
|
||||
/// `hanging_signs`: `textures/gui/<folder>/<wood>.png` (`SignEditScreen`), in
|
||||
/// a mod's namespace when the wood names one.
|
||||
pub fn editor_sprite(folder: &str, wood: &str) -> String {
|
||||
match wood.split_once(':') {
|
||||
Some((namespace, path)) if namespace != "minecraft" => {
|
||||
format!("{namespace}:gui/{folder}/{path}")
|
||||
}
|
||||
Some((_, path)) => format!("gui/{folder}/{path}"),
|
||||
None => format!("gui/{folder}/{wood}"),
|
||||
}
|
||||
"oak"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -262,12 +268,25 @@ mod tests {
|
||||
assert_eq!(decoded, data);
|
||||
}
|
||||
#[test]
|
||||
fn a_mods_sign_like_name_does_not_make_it_a_sign() {
|
||||
assert_eq!(sign_kind("ruby:road_sign"), None);
|
||||
assert!(crate::containers::BlockEntity::for_block("ruby:road_sign").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn names_and_colors() {
|
||||
assert_eq!(sign_kind("minecraft:oak_wall_hanging_sign"), Some(true));
|
||||
assert_eq!(sign_kind("minecraft:sign"), Some(false));
|
||||
assert_eq!(sign_kind("minecraft:spruce_wall_sign"), Some(false));
|
||||
assert_eq!(sign_kind("minecraft:stone"), None);
|
||||
assert_eq!(wood_type("minecraft:dark_oak_wall_sign"), "dark_oak");
|
||||
assert_eq!(editor_sprite("signs", "dark_oak"), "gui/signs/dark_oak");
|
||||
assert_eq!(
|
||||
editor_sprite("signs", "minecraft:cherry"),
|
||||
"gui/signs/cherry"
|
||||
);
|
||||
assert_eq!(
|
||||
editor_sprite("hanging_signs", "ruby:elm"),
|
||||
"ruby:gui/hanging_signs/elm"
|
||||
);
|
||||
assert_eq!(SignText::default().dark_color(), 0);
|
||||
let red = SignText {
|
||||
color: 14,
|
||||
|
||||
@@ -255,6 +255,30 @@ pub mod startup {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a world's view is still filling (between [`arm`] and the
|
||||
/// `world.view_full` mark).
|
||||
#[inline]
|
||||
pub fn filling() -> bool {
|
||||
on() && FILLING.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Values at this moment, as a counter track named `name` (Perfetto draws
|
||||
/// each value as a line): what the view waits on while it fills.
|
||||
pub fn counters(name: &'static str, values: &[(&'static str, usize)]) {
|
||||
if !on() {
|
||||
return;
|
||||
}
|
||||
let args: Vec<String> = values
|
||||
.iter()
|
||||
.map(|(k, v)| format!(r#""{k}":{v}"#))
|
||||
.collect();
|
||||
write(format_args!(
|
||||
r#""ph":"C","name":"{name}","ts":{},"args":{{{}}}"#,
|
||||
micros(Instant::now()),
|
||||
args.join(",")
|
||||
));
|
||||
}
|
||||
|
||||
/// Called each frame in a world: marks `world.view_full` the first frame
|
||||
/// after [`arm`] where nothing waits to load, generate, light, mesh or
|
||||
/// upload and the resident columns cover perf_bench's share of the view
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Static block-entity meshes use section-local positions and the shared
|
||||
// entity vertex/fragment helpers. The shader subtracts integer block origins
|
||||
// before converting to f32; CPU supplies the camera's fractional block offset.
|
||||
struct StaticEntityUniform {
|
||||
camera_block: vec4<i32>,
|
||||
camera_fraction: vec4<f32>,
|
||||
}
|
||||
@group(1) @binding(0) var<uniform> static_entity: StaticEntityUniform;
|
||||
|
||||
@vertex fn vs_static_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
|
||||
@location(7) section_origin: vec3<i32>) -> Out {
|
||||
let block_delta = section_origin - static_entity.camera_block.xyz;
|
||||
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
|
||||
var out = entity_vertex(camera_relative,
|
||||
uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
}
|
||||
@@ -173,6 +173,8 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// A loaded chunk's summary, made on the far-terrain worker.
|
||||
fn observed(&mut self, _key: (i32, i32), _columns: &Arc<[crate::lod::Column; 256]>) {}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
@@ -183,6 +185,13 @@ pub trait LodSampler: Send {
|
||||
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
|
||||
self.column(x, z)
|
||||
}
|
||||
/// Whether a column since the last call was left empty because its data
|
||||
/// is still on its way (a BlueMap tile downloading, nothing coarser to
|
||||
/// stand in): that sample isn't drawn, what it would replace is
|
||||
/// (`Lod::finish_any_sample`), and it is taken again later.
|
||||
fn waited(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
|
||||
/// when sampling failed and should be tried again later.
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
@@ -201,6 +210,11 @@ pub trait LodSampler: Send {
|
||||
}
|
||||
pub trait LodSource: Send + Sync {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler>;
|
||||
/// Whether its samplers' `observed` chunks become its own (a server's
|
||||
/// kept terrain): tiles sampled without one are sampled again.
|
||||
fn keeps_observed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
struct GeneratorSampler<G: ChunkGenerator> {
|
||||
generator: Arc<G>,
|
||||
|
||||
@@ -868,7 +868,77 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::widgets::frame_tooltip(lines);
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let mut x = cursor.0 + 12.0;
|
||||
let mut y = cursor.1 - 12.0;
|
||||
if x + w > batch.width {
|
||||
x = (cursor.0 - 24.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
}
|
||||
tooltip_box(batch, atlas, lines, x, y, w, h);
|
||||
}
|
||||
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
|
||||
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
|
||||
/// pointer where it is, and its tooltip stays against the widget at `rect`.
|
||||
pub fn tooltip_menu(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
lines: &[Text],
|
||||
cursor: (f32, f32),
|
||||
rect: [f32; 4],
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let (x, y) = menu_tooltip_position(
|
||||
(batch.width as i32, batch.height as i32),
|
||||
(cursor.0 as i32, cursor.1 as i32),
|
||||
rect.map(|v| v as i32),
|
||||
(w.ceil() as i32, h.ceil() as i32),
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
/// `MenuTooltipPositioner.positionTooltip`: the tooltip's top left with the
|
||||
/// pointer at `cursor`, the widget it belongs to at `rect`, and the screen.
|
||||
/// `MARGIN` 5 and `MOUSE_OFFSET_X` 12 on the horizontal, and the
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 and `MAX_DISTANCE_TO_WIDGET` 5 of the vertical
|
||||
/// offset from the widget's top or bottom edge.
|
||||
pub fn menu_tooltip_position(
|
||||
screen: (i32, i32),
|
||||
cursor: (i32, i32),
|
||||
rect: [i32; 4],
|
||||
size: (i32, i32),
|
||||
) -> (i32, i32) {
|
||||
let [_, y, _, height] = rect;
|
||||
let height = height.max(1);
|
||||
let mut pos = (cursor.0 + 12, cursor.1);
|
||||
if pos.0 + size.0 > screen.0 - 5 {
|
||||
pos.0 = (cursor.0 - 12 - size.0).max(9);
|
||||
}
|
||||
pos.1 += 3;
|
||||
// `bottom() + 3 + getOffset(0, 0, height)` is the widget's edge, `height`
|
||||
// further down; below that the tooltip goes under it, else over it.
|
||||
let below = size.1 + 6;
|
||||
if y + 2 * height + below <= screen.1 - 5 {
|
||||
pos.1 += widget_offset(pos.1, y, height);
|
||||
} else {
|
||||
pos.1 -= below + widget_offset(pos.1, y + height, height);
|
||||
}
|
||||
pos
|
||||
}
|
||||
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
|
||||
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
|
||||
/// The distance over the height is an int division in vanilla, so the lerp
|
||||
/// is `height - 3` until the pointer is a whole height away, then 5.
|
||||
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
|
||||
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
|
||||
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
|
||||
}
|
||||
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
@@ -878,14 +948,18 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
} else {
|
||||
10.0 * lines.len() as f32
|
||||
};
|
||||
let mut x = cursor.0 + 12.0;
|
||||
let mut y = cursor.1 - 12.0;
|
||||
if x + w > batch.width {
|
||||
x = (cursor.0 - 24.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
}
|
||||
(w, h)
|
||||
}
|
||||
fn tooltip_box(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
lines: &[Text],
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) {
|
||||
crate::widgets::frame_tooltip(lines);
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -2086,4 +2160,35 @@ mod tests {
|
||||
assert!(!delete_words(&mut v, 1));
|
||||
assert_eq!(v, "stone");
|
||||
}
|
||||
/// `MenuTooltipPositioner.positionTooltip`: the pointer's 12 px offset,
|
||||
/// the 5 px screen margin, and the 3 to 5 px the tooltip keeps from the
|
||||
/// widget's top or bottom edge.
|
||||
#[test]
|
||||
fn a_menu_tooltip_stays_by_its_widget() {
|
||||
let screen = (427, 240);
|
||||
let widget = [100, 100, 150, 20];
|
||||
let size = (80, 10);
|
||||
// Pointer at the widget's top edge: the tooltip drops 3 px, then
|
||||
// `getOffset`'s int division gives `height - 3` = 17 more.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 100), widget, size),
|
||||
(112, 120)
|
||||
);
|
||||
// Near the right edge its left side mirrors to the pointer's left.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (400, 100), widget, size),
|
||||
(308, 120)
|
||||
);
|
||||
// A whole widget height from the edge, the offset is 5.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 117), widget, size),
|
||||
(112, 125)
|
||||
);
|
||||
// A widget too close to the bottom: the tooltip goes above it,
|
||||
// 203 - (10 + 6) - 17.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
|
||||
(112, 170)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides entities behind opaque blocks.": "Versteckt Entitäten hinter undurchsichtigen Blöcken.",
|
||||
"No entity occlusion culling.": "Keine Verdeckungserkennung für Entitäten.",
|
||||
"CPU occlusion test runs this frame.": "Die CPU prüft die Verdeckung in diesem Frame.",
|
||||
"Occlusion checks run on a worker thread.": "Verdeckungsprüfungen laufen in einem Worker-Thread.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
|
||||
@@ -170,6 +170,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
|
||||
"No entity occlusion culling.": "No entity occlusion culling.",
|
||||
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
|
||||
"Occlusion checks run on a worker thread.": "Occlusion checks run on a worker thread.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
|
||||
@@ -221,6 +225,7 @@
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.header.menus": "Menus",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Dynamic lights",
|
||||
"horizon.extras.own_name_tag": "Own name tag",
|
||||
@@ -237,6 +242,7 @@
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.wheel_cycles_settings": "Scroll wheel changes settings",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
@@ -305,6 +311,7 @@
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.wheel_cycles_settings.tooltip": "The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
|
||||
@@ -357,6 +364,21 @@
|
||||
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Block Entities": "Block Entities",
|
||||
"Shulker Boxes": "Shulker Boxes",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Reuses settled boxes; lid movement stays animated.",
|
||||
"Signs": "Signs",
|
||||
"Reuses sign text until text, light, or font changes.": "Reuses sign text until text, light, or font changes.",
|
||||
"Decorated Pots": "Decorated Pots",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Reuses still pots; break wobble and sherd changes redraw.",
|
||||
"Bells": "Bells",
|
||||
"Reuses still bells; ringing stays animated.": "Reuses still bells; ringing stays animated.",
|
||||
"Chests": "Chests",
|
||||
"Reuses closed chests; moving lids stay animated.": "Reuses closed chests; moving lids stay animated.",
|
||||
"Banners": "Banners",
|
||||
"Reuses poles; flags and pattern layers still update.": "Reuses poles; flags and pattern layers still update.",
|
||||
"Copper Golem Statues": "Copper Golem Statues",
|
||||
"Reuses statue geometry until its block state changes.": "Reuses statue geometry until its block state changes.",
|
||||
"Leaf Culling": "Leaf Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
|
||||
"Distant Plant Dither": "Distant Plant Dither",
|
||||
@@ -367,6 +389,10 @@
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
"Shadow Cache": "Shadow Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
"Find BlueMap": "Find BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "On servers, finds their BlueMap web map for far terrain. Next join.",
|
||||
"BlueMap Radius": "BlueMap Radius",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "How far around you BlueMap tiles are downloaded. Next join.",
|
||||
"Synchronous": "Synchronous",
|
||||
"Asynchronous": "Asynchronous",
|
||||
"Solid": "Solid",
|
||||
|
||||
@@ -158,6 +158,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
|
||||
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
|
||||
"No entity occlusion culling.": "Sin oclusión de entidades.",
|
||||
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
|
||||
"Occlusion checks run on a worker thread.": "Las comprobaciones de oclusión se ejecutan en un hilo de trabajo.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides entities behind opaque blocks.": "Masque les entités derrière les blocs opaques.",
|
||||
"No entity occlusion culling.": "Aucun masquage des entités occultées.",
|
||||
"CPU occlusion test runs this frame.": "Le CPU teste l’occlusion sur cette image.",
|
||||
"Occlusion checks run on a worker thread.": "Les tests d’occlusion s’exécutent sur un thread de travail.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
@@ -355,6 +359,10 @@
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
|
||||
"Shadow Cache": "Cache des ombres",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
|
||||
"Find BlueMap": "Trouver BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Sur les serveurs, trouve leur carte web BlueMap pour le terrain lointain. À la prochaine connexion.",
|
||||
"BlueMap Radius": "Rayon BlueMap",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Distance autour de vous jusqu'où les tuiles BlueMap sont téléchargées. À la prochaine connexion.",
|
||||
"Synchronous": "Synchrone",
|
||||
"Asynchronous": "Asynchrone",
|
||||
"Solid": "Opaque",
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides entities behind opaque blocks.": "Ukrywa byty za nieprzezroczystymi blokami.",
|
||||
"No entity occlusion culling.": "Bez odrzucania zasłoniętych bytów.",
|
||||
"CPU occlusion test runs this frame.": "Test zasłonięcia wykonuje CPU w tej klatce.",
|
||||
"Occlusion checks run on a worker thread.": "Testy zasłonięcia działają w wątku roboczym.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
|
||||
@@ -157,6 +157,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
|
||||
"Hides entities behind opaque blocks.": "Скрывает сущности за непрозрачными блоками.",
|
||||
"No entity occlusion culling.": "Без отсечения сущностей по перекрытию.",
|
||||
"CPU occlusion test runs this frame.": "В этом кадре проверку перекрытия выполняет CPU.",
|
||||
"Occlusion checks run on a worker thread.": "Проверки перекрытия выполняются в рабочем потоке.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
|
||||
|
||||
@@ -158,6 +158,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
|
||||
"Hides entities behind opaque blocks.": "Opak blokların arkasındaki varlıkları gizler.",
|
||||
"No entity occlusion culling.": "Varlık oklüzyon ayıklaması kapalı.",
|
||||
"CPU occlusion test runs this frame.": "Oklüzyon denetimini bu karede CPU yapar.",
|
||||
"Occlusion checks run on a worker thread.": "Oklüzyon denetimleri bir iş parçacığında çalışır.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
|
||||
"Hides entities behind opaque blocks.": "Ховає сутності за непрозорими блоками.",
|
||||
"No entity occlusion culling.": "Відсікання закритих сутностей вимкнено.",
|
||||
"CPU occlusion test runs this frame.": "Цього кадру перекриття перевіряє CPU.",
|
||||
"Occlusion checks run on a worker thread.": "Перевірки перекриття виконуються в робочому потоці.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
@@ -327,6 +331,9 @@
|
||||
"horizon.extras.header.chat": "Чат",
|
||||
"horizon.extras.header.particles": "Частинки",
|
||||
"horizon.extras.header.sound": "Звук",
|
||||
"horizon.extras.header.menus": "Меню",
|
||||
"horizon.extras.wheel_cycles_settings": "Коліщатко змінює налаштування",
|
||||
"horizon.extras.wheel_cycles_settings.tooltip": "Коліщатко миші над кнопкою в налаштуваннях змінює її значення, як у ванільній грі. Вимкнено — коліщатко лише гортає список.",
|
||||
"horizon.extras.hide_block_particles": "Без частинок блоків",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
|
||||
"horizon.extras.splitscreen": "Розділений екран",
|
||||
|
||||
@@ -160,6 +160,10 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
|
||||
"Hides entities behind opaque blocks.": "隐藏不透明方块后的实体。",
|
||||
"No entity occlusion culling.": "不进行实体遮挡剔除。",
|
||||
"CPU occlusion test runs this frame.": "本帧由 CPU 执行遮挡检测。",
|
||||
"Occlusion checks run on a worker thread.": "遮挡检测在工作线程上运行。",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn main() -> Result<()> {
|
||||
));
|
||||
}
|
||||
}
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
ArrowEvent::Block { pos, reaction, .. } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
// TntBlock.prime: PrimedTnt draws its push from the
|
||||
// level random, then removeBlock(pos, false).
|
||||
|
||||
@@ -21,7 +21,7 @@ use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
@@ -364,6 +364,8 @@ pub fn main() -> Result<()> {
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
translucent_sprites: &HashSet::new(),
|
||||
atlas_revision: 1,
|
||||
nether: false,
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
|
||||
@@ -1171,6 +1171,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
player: player.clone(),
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
|
||||
@@ -117,6 +117,8 @@ pub enum ArrowEvent {
|
||||
Block {
|
||||
pos: BlockPos,
|
||||
reaction: BlockReaction,
|
||||
/// The arrow's owner (`Projectile.getOwner`), who owns a TNT it primes.
|
||||
owner: Option<Who>,
|
||||
},
|
||||
/// The SPLASH and PROJECTILE_SHOOT game events.
|
||||
SplashEvent,
|
||||
@@ -159,6 +161,7 @@ pub enum BlockReaction {
|
||||
#[derive(Clone)]
|
||||
pub struct ArrowState {
|
||||
pub in_ground: bool,
|
||||
pub canceled_impact_inside: bool,
|
||||
pub in_ground_time: i32,
|
||||
/// Ticks stuck (`life`): gone at 1200.
|
||||
pub life: i32,
|
||||
@@ -217,6 +220,7 @@ impl ArrowState {
|
||||
pub fn new(random: LegacyRandom) -> Self {
|
||||
Self {
|
||||
in_ground: false,
|
||||
canceled_impact_inside: false,
|
||||
in_ground_time: 0,
|
||||
life: 0,
|
||||
shake: 0,
|
||||
@@ -346,13 +350,21 @@ impl ArrowScene<'_, '_> {
|
||||
.map(String::as_str)
|
||||
}
|
||||
/// `ClipContext.Block.COLLIDER` with `Fluid.NONE`: the first block hit.
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, BlockPos, [i32; 3])> {
|
||||
fn clip(
|
||||
&self,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
ignored: Option<BlockPos>,
|
||||
) -> Option<(Vec3, BlockPos, [i32; 3])> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
if Some(p) == ignored {
|
||||
return None;
|
||||
}
|
||||
let state = self.block(p);
|
||||
if self.is_air(state) {
|
||||
return None;
|
||||
@@ -521,8 +533,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
|
||||
tick_with(p, scene, targets, true)
|
||||
}
|
||||
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
|
||||
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
|
||||
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
|
||||
/// flies through its full movement: not stuck, nothing hurt, no hit.
|
||||
pub fn tick_through(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
@@ -647,15 +658,18 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
f64::from(block_pos.z),
|
||||
);
|
||||
let at = self.position();
|
||||
if self
|
||||
let inside = self
|
||||
.scene
|
||||
.collision_boxes(state, block_pos)
|
||||
.iter()
|
||||
.any(|b| b.move_by(origin).contains(at))
|
||||
{
|
||||
.any(|b| b.move_by(origin).contains(at));
|
||||
if inside && !self.st.canceled_impact_inside {
|
||||
self.p.velocity = Vec3::ZERO;
|
||||
self.st.in_ground = true;
|
||||
}
|
||||
self.st.canceled_impact_inside &= inside;
|
||||
} else {
|
||||
self.st.canceled_impact_inside = false;
|
||||
}
|
||||
if self.st.shake > 0 {
|
||||
self.st.shake -= 1;
|
||||
@@ -741,7 +755,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
original.y + movement.y,
|
||||
original.z + movement.z,
|
||||
);
|
||||
if physics {
|
||||
if physics && impact {
|
||||
hit = self.step_move_and_hit(original, end, targets, impact);
|
||||
if matches!(hit, Hit::Entity { .. }) {
|
||||
return hit;
|
||||
@@ -749,6 +763,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
} else {
|
||||
self.p.position = end;
|
||||
self.apply_effects_from_blocks(original, end);
|
||||
if physics {
|
||||
self.st.canceled_impact_inside = true;
|
||||
}
|
||||
}
|
||||
self.finish();
|
||||
hit
|
||||
@@ -833,7 +850,11 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let ignored = self
|
||||
.st
|
||||
.canceled_impact_inside
|
||||
.then(|| self.block_position());
|
||||
let block = self.scene.clip(from, to, ignored);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
// findHitEntities: ProjectileUtil.computeMargin and the search box.
|
||||
let margin = f64::from(((self.p.age as i32 - 2) as f32 / 20.0).clamp(0.0, 0.3));
|
||||
@@ -914,7 +935,10 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
.inflate(Vec3::new(margin, margin, margin))
|
||||
.clip(from, to)?;
|
||||
let centre = b.center();
|
||||
let towards = self.scene.clip(outside, centre).map_or(centre, |h| h.0);
|
||||
let towards = self
|
||||
.scene
|
||||
.clip(outside, centre, None)
|
||||
.map_or(centre, |h| h.0);
|
||||
b.clip(outside, towards).map(|_| outside)
|
||||
}
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
@@ -982,7 +1006,11 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if self.st.on_fire()
|
||||
&& let Some(reaction) = self.block_reaction(state)
|
||||
{
|
||||
self.st.events.push(ArrowEvent::Block { pos, reaction });
|
||||
self.st.events.push(ArrowEvent::Block {
|
||||
pos,
|
||||
reaction,
|
||||
owner: self.p.owner,
|
||||
});
|
||||
}
|
||||
let v = self.p.velocity;
|
||||
let offset = f64::from(0.05f32);
|
||||
|
||||
@@ -29,6 +29,17 @@ pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
|
||||
Some((block_entity_nbt::id(data)?, data))
|
||||
}
|
||||
|
||||
/// `JukeboxBlock.setPlacedBy`: the item's `minecraft:block_entity_data`
|
||||
/// carries a `RecordItem`, so the placed jukebox holds a record and its
|
||||
/// `has_record` state has to say so. Vanilla reads the item's data, not the
|
||||
/// block entity the placement just filled.
|
||||
pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
|
||||
.is_some_and(|data| data.get("RecordItem").is_some())
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
@@ -602,6 +613,40 @@ mod tests {
|
||||
assert!(!op_warning(&chest));
|
||||
}
|
||||
|
||||
/// `JukeboxBlock.setPlacedBy` looks for a `RecordItem` in the placed
|
||||
/// item's block entity data, whatever the type it names: only that key
|
||||
/// turns the box's `has_record` on.
|
||||
#[test]
|
||||
fn a_jukebox_item_with_a_record_in_its_data_says_so() {
|
||||
let jukebox = |text: &str| {
|
||||
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:jukebox", 1)
|
||||
}
|
||||
};
|
||||
// A plain jukebox, and one whose data is only the type id: no record.
|
||||
assert!(!carries_record(&crate::items::ItemStack::new(
|
||||
"minecraft:jukebox",
|
||||
1
|
||||
)));
|
||||
assert!(!carries_record(&jukebox("{id:\"minecraft:jukebox\"}")));
|
||||
assert!(!carries_record(&jukebox(
|
||||
"{id:\"minecraft:jukebox\",Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}"
|
||||
)));
|
||||
// Creative's Ctrl + Pick Block on a jukebox holding a disc.
|
||||
assert!(carries_record(&jukebox(
|
||||
"{id:\"minecraft:jukebox\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
|
||||
)));
|
||||
// The key alone counts: `TypedEntityData.contains` never checks the
|
||||
// type, and the state it sets is the one the block is placed as.
|
||||
assert!(carries_record(&jukebox(
|
||||
"{id:\"minecraft:chest\",RecordItem:{id:\"minecraft:music_disc_cat\",count:1}}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_sign_gives_its_text_and_wax() {
|
||||
let sign = saved(
|
||||
|
||||
@@ -9,18 +9,19 @@ pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
|
||||
chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions, command_block,
|
||||
containers, crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
|
||||
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
|
||||
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
|
||||
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
|
||||
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
|
||||
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
|
||||
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
block_entity_anim, block_entity_render, bluemap, bluemap_detect, border_render, breaking,
|
||||
brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
|
||||
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask,
|
||||
server_terrain, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! path ends in `.json`, the game listens on a random localhost TCP port and
|
||||
//! writes `{"transport":"tcp","port":N,"token":"..."}` to the path, readable
|
||||
//! only by its user; a client's first line must then be `auth TOKEN`.
|
||||
use crate::block_entity_render::StaticCacheFrameStats;
|
||||
use std::{
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
@@ -411,6 +412,30 @@ mod tests {
|
||||
assert_eq!(random_token().len(), 32);
|
||||
assert!(same_token("abc", "abc") && !same_token("abc", "abd") && !same_token("a", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_summary_reports_block_entity_cache_work() {
|
||||
let mut stats = FrameStats::default();
|
||||
stats.record(10.0);
|
||||
stats.record_cpu(5.0);
|
||||
stats.record_block_entities(StaticCacheFrameStats {
|
||||
snapshot_rebuilds: 2,
|
||||
snapshot_bytes_built: 128,
|
||||
static_bytes_uploaded: 96,
|
||||
static_entities_drawn: 4,
|
||||
dynamic_entities_drawn: 3,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let summary = stats.summary();
|
||||
assert_eq!(summary["static_snapshot_rebuilds"], 2);
|
||||
assert_eq!(summary["static_snapshot_bytes_built"], 128);
|
||||
assert_eq!(summary["static_bytes_uploaded"], 96);
|
||||
assert_eq!(summary["static_entities_drawn"], 4);
|
||||
assert_eq!(summary["dynamic_entities_drawn"], 3);
|
||||
assert_eq!(summary["static_entities_drawn_avg_per_frame"], 4.0);
|
||||
assert_eq!(summary["dynamic_entities_drawn_avg_per_frame"], 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame durations since the last reset, summarized like the Java recorder,
|
||||
@@ -422,6 +447,11 @@ pub struct FrameStats {
|
||||
gpu: Vec<f32>,
|
||||
/// Milliseconds per frame phase (`PHASES`), one entry per rendered frame.
|
||||
phases: Vec<[f32; PHASES.len()]>,
|
||||
static_snapshot_rebuilds: u64,
|
||||
static_snapshot_bytes_built: u64,
|
||||
static_bytes_uploaded: u64,
|
||||
static_entities_drawn: u64,
|
||||
dynamic_entities_drawn: u64,
|
||||
}
|
||||
|
||||
/// The parts of a frame's CPU time: world streaming and uploads, game ticks,
|
||||
@@ -490,6 +520,14 @@ impl FrameStats {
|
||||
self.gpu.extend_from_slice(millis);
|
||||
}
|
||||
}
|
||||
/// Block-entity cache counters for the frame just rendered.
|
||||
pub fn record_block_entities(&mut self, stats: StaticCacheFrameStats) {
|
||||
self.static_snapshot_rebuilds += stats.snapshot_rebuilds;
|
||||
self.static_snapshot_bytes_built += stats.snapshot_bytes_built;
|
||||
self.static_bytes_uploaded += stats.static_bytes_uploaded;
|
||||
self.static_entities_drawn += stats.static_entities_drawn;
|
||||
self.dynamic_entities_drawn += stats.dynamic_entities_drawn;
|
||||
}
|
||||
/// Every recorded frame time, in milliseconds, in order.
|
||||
pub fn frame_millis(&self) -> &[f32] {
|
||||
&self.millis
|
||||
@@ -507,6 +545,11 @@ impl FrameStats {
|
||||
self.cpu.clear();
|
||||
self.gpu.clear();
|
||||
self.phases.clear();
|
||||
self.static_snapshot_rebuilds = 0;
|
||||
self.static_snapshot_bytes_built = 0;
|
||||
self.static_bytes_uploaded = 0;
|
||||
self.static_entities_drawn = 0;
|
||||
self.dynamic_entities_drawn = 0;
|
||||
}
|
||||
pub fn summary(&self) -> serde_json::Value {
|
||||
let mut sorted = self.millis.clone();
|
||||
@@ -550,6 +593,13 @@ impl FrameStats {
|
||||
"gpu": percentiles(&self.gpu),
|
||||
"phases_avg_ms": self.phase_average(0.0),
|
||||
"slow_phases_avg_ms": slow_phases,
|
||||
"static_snapshot_rebuilds": self.static_snapshot_rebuilds,
|
||||
"static_snapshot_bytes_built": self.static_snapshot_bytes_built,
|
||||
"static_bytes_uploaded": self.static_bytes_uploaded,
|
||||
"static_entities_drawn": self.static_entities_drawn,
|
||||
"dynamic_entities_drawn": self.dynamic_entities_drawn,
|
||||
"static_entities_drawn_avg_per_frame": self.static_entities_drawn as f64 / n as f64,
|
||||
"dynamic_entities_drawn_avg_per_frame": self.dynamic_entities_drawn as f64 / n as f64,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,33 @@
|
||||
use crate::{
|
||||
dialog::{Body, Control, Dialog, Kind},
|
||||
font::Font,
|
||||
text::Text,
|
||||
text::{Style, Text},
|
||||
};
|
||||
|
||||
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
|
||||
/// message glyph, using the same visual order and advances as rendering.
|
||||
pub fn line_style_at(
|
||||
font: &Font,
|
||||
text: &Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
cursor: (f32, f32),
|
||||
) -> Option<Style> {
|
||||
let mut left = cursor.0 - x;
|
||||
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
|
||||
return None;
|
||||
}
|
||||
let text = text.resolve_legacy();
|
||||
let visual = crate::bidi::visual(&text);
|
||||
for (c, style) in visual.chars() {
|
||||
left -= font.advance(c, style);
|
||||
if left < 0.0 {
|
||||
return Some(style.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
const BAR: f32 = 33.0;
|
||||
/// `Checkbox.getBoxSize`.
|
||||
@@ -433,6 +457,52 @@ pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn message_hit_testing_respects_sprite_advances_and_glyph_bounds() {
|
||||
use crate::{component, hud::Rect, text::ClickEvent};
|
||||
let font = Font::load(
|
||||
std::path::Path::new("/nonexistent-horizon-dialog-test-resources"),
|
||||
&std::collections::HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
let text = component::to_text(
|
||||
&serde_json::json!([
|
||||
{"object": "atlas", "sprite": "minecraft:item/potion"},
|
||||
{"object": "atlas", "sprite": "minecraft:item/potion", "click_event": {
|
||||
"action": "custom", "id": "test:shop"
|
||||
}},
|
||||
{"object": "atlas", "sprite": "minecraft:item/potion"},
|
||||
]),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
|
||||
assert!(style(9.9, 20.0).is_none());
|
||||
assert_eq!(style(17.9, 20.0).unwrap().click, None);
|
||||
for x in [18.0, 21.0, 25.9] {
|
||||
assert_eq!(
|
||||
style(x, 24.0).unwrap().click,
|
||||
Some(ClickEvent::Custom {
|
||||
id: "test:shop".into(),
|
||||
payload: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
assert_eq!(style(26.0, 20.0).unwrap().click, None);
|
||||
assert!(style(34.0, 20.0).is_none());
|
||||
assert!(style(21.0, 19.9).is_none());
|
||||
assert!(style(21.0, 29.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
|
||||
@@ -21,18 +21,44 @@ use horizon_world::{
|
||||
|
||||
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
|
||||
const ITEM_GRAVITY: f64 = 0.04;
|
||||
/// Non-living slime `bounceRestitution` (`1.0 * 0.8`) from
|
||||
/// Non-living slime `bounceRestitution` factor (`0.8f`) from
|
||||
/// `Entity.restituteMovementAfterCollisions`.
|
||||
const ITEM_SLIME_BOUNCE: f64 = 0.8;
|
||||
const ITEM_SLIME_BOUNCE: f32 = 0.8;
|
||||
|
||||
/// After a vertical collision: slime bounces a dropped item by 0.8, matching
|
||||
/// non-living `Entity.restituteMovementAfterCollisions`. Anything else zeros Y.
|
||||
fn item_vertical_collision_velocity(wanted_y: f64, slime: bool) -> f64 {
|
||||
if slime && wanted_y < 0.0 && -wanted_y > ITEM_GRAVITY {
|
||||
-wanted_y * ITEM_SLIME_BOUNCE
|
||||
} else {
|
||||
0.0
|
||||
/// `Entity.restituteMovementAfterCollisions` for the vertical axis of a
|
||||
/// non-living entity: `current_y` is its velocity before the move, `moved_y`
|
||||
/// how far the move got. Landing on slime (`below`) bounces by the block's
|
||||
/// restitution `1.0f * 0.8f`, with the gravity and air drag the part of the tick
|
||||
/// after the hit would have added back; any other collision zeroes Y.
|
||||
pub fn vertical_collision_velocity(
|
||||
current_y: f64,
|
||||
moved_y: f64,
|
||||
gravity: f64,
|
||||
below: bool,
|
||||
slime: bool,
|
||||
) -> f64 {
|
||||
if !(below && slime && -current_y > gravity) {
|
||||
return 0.0;
|
||||
}
|
||||
let restitution = f64::from(1.0_f32 * ITEM_SLIME_BOUNCE);
|
||||
let portion = moved_y / current_y;
|
||||
let drag = 1.0 + portion * (f64::from(0.98_f32) - 1.0);
|
||||
(portion * gravity - current_y) * drag * restitution
|
||||
}
|
||||
|
||||
/// `Entity.move`: the position only changes when the collided step is longer
|
||||
/// than `sqrt(1e-7)` or nothing was cut off; a tiny step against a wall or floor
|
||||
/// is dropped (its collision effects still run).
|
||||
pub fn move_applies(wanted: Vec3, moved: Vec3) -> bool {
|
||||
let moved_sq = moved.length_squared();
|
||||
moved_sq > 1.0e-7 || wanted.length_squared() - moved_sq < 1.0e-7
|
||||
}
|
||||
|
||||
/// `SlimeBlock.stepOn`: a slow walk or slide on slime drags the horizontal
|
||||
/// speed (`0.4 + |dy| * 0.2`); a fast vertical speed leaves it alone.
|
||||
pub fn slime_step_on_scale(velocity_y: f64) -> Option<f64> {
|
||||
let abs_delta_y = velocity_y.abs();
|
||||
(abs_delta_y < 0.1).then_some(0.4 + abs_delta_y * 0.2)
|
||||
}
|
||||
|
||||
fn slime_support_name(block: &str, moving_moved: Option<&str>) -> bool {
|
||||
@@ -91,6 +117,12 @@ pub struct ItemEntity {
|
||||
pub stuck: Vec3,
|
||||
/// `Entity.noGravity` (`NoGravity` in its save): `applyGravity` does nothing.
|
||||
pub no_gravity: bool,
|
||||
/// `Entity.verticalCollision`: the last move was stopped vertically.
|
||||
/// `ServerEntity.isFullPrecisionEncodingRequired` reads it for items, whose
|
||||
/// resting position is exact while a 1/4096 delta is not.
|
||||
pub vertical_collision: bool,
|
||||
/// `Entity.horizontalCollision`: the last move was stopped on x or z.
|
||||
pub horizontal_collision: bool,
|
||||
}
|
||||
/// Environment results needed by the entity owner, including lethal lava damage.
|
||||
#[derive(Debug)]
|
||||
@@ -272,14 +304,23 @@ impl ItemEntity {
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
if move_applies(wanted, moved) {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
}
|
||||
if !no_physics {
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
}
|
||||
// Entity.move: what the move was stopped on. They outlive the
|
||||
// tick, as vanilla's fields do, for the tracker that sends the
|
||||
// position at the type's update interval.
|
||||
self.vertical_collision = moved.y != wanted.y;
|
||||
// `Mth.equal` on x and z (1e-5 as a float), exact on y.
|
||||
let differs = |a: f64, b: f64| (b - a).abs() >= 9.999_999_747_378_752e-6;
|
||||
self.horizontal_collision = differs(moved.x, wanted.x) || differs(moved.z, wanted.z);
|
||||
if moved.x != wanted.x {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
@@ -291,7 +332,13 @@ impl ItemEntity {
|
||||
// (or a moving piston carrying one) bounces a dropped item;
|
||||
// MovingPistonBlock itself has no bounceRestitution.
|
||||
let slime = item_support_is_slime(world, facts, self.position);
|
||||
self.velocity.y = item_vertical_collision_velocity(wanted.y, slime);
|
||||
self.velocity.y = vertical_collision_velocity(
|
||||
wanted.y,
|
||||
moved.y,
|
||||
ITEM_GRAVITY,
|
||||
wanted.y < 0.0,
|
||||
slime,
|
||||
);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
|
||||
// (before air drag), matching ItemEntity.tick.
|
||||
@@ -305,6 +352,11 @@ impl ItemEntity {
|
||||
self.bounds(),
|
||||
&mut self.velocity,
|
||||
);
|
||||
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
|
||||
// vertical speed the bounce just left (before the air drag).
|
||||
if !no_physics {
|
||||
self.slime_step_on(world, facts);
|
||||
}
|
||||
let mut friction = 0.98;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
@@ -324,6 +376,9 @@ impl ItemEntity {
|
||||
}
|
||||
} else {
|
||||
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
|
||||
if !no_physics {
|
||||
self.slime_step_on(world, facts);
|
||||
}
|
||||
crate::motion::bubble_column_push(
|
||||
world,
|
||||
registry,
|
||||
@@ -398,6 +453,26 @@ impl ItemEntity {
|
||||
}
|
||||
stuck
|
||||
}
|
||||
/// `SlimeBlock.stepOn` for an item on the ground: slow vertical speed
|
||||
/// drags the horizontal speed, on idle ticks too.
|
||||
fn slime_step_on(&mut self, world: &World, facts: &BlockFacts) {
|
||||
if !self.on_ground {
|
||||
return;
|
||||
}
|
||||
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
|
||||
0.0,
|
||||
-f64::from(0.2_f32),
|
||||
0.0,
|
||||
)));
|
||||
if &*facts.get(world.block(p.x, p.y, p.z)).name != "minecraft:slime_block" {
|
||||
return;
|
||||
}
|
||||
if let Some(scale) = slime_step_on_scale(self.velocity.y) {
|
||||
self.velocity.x *= scale;
|
||||
self.velocity.z *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.causeBounce` for a dropped item. Vanilla then
|
||||
/// runs `Entity.move(MoverType.PISTON)`, which leaves the ground on an
|
||||
/// upward push; without that, `ItemEntity.tick` skips movement 3 of 4 ticks
|
||||
@@ -1182,6 +1257,8 @@ pub struct ArmorStand {
|
||||
pub enchantment_fall_distance: f64,
|
||||
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
|
||||
pub enchantment_equipment: [Option<ItemStack>; 6],
|
||||
/// `Entity.mainSupportingBlockPos` after the last move.
|
||||
pub support: horizon_world::ground_support::GroundState,
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -1256,6 +1333,22 @@ impl ArmorStand {
|
||||
enchantment_block: None,
|
||||
enchantment_fall_distance: 0.0,
|
||||
enchantment_equipment: Default::default(),
|
||||
support: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
|
||||
/// column 0.2 below the feet, the supporting block itself when `tall`
|
||||
/// says it is a fence, wall or fence gate, else the cell 0.2 below.
|
||||
pub fn on_pos_legacy(&self, tall: impl Fn(BlockPos) -> bool) -> BlockPos {
|
||||
let y = (self.position.y - f64::from(0.2_f32)).floor() as i32;
|
||||
match self.support.supporting_block {
|
||||
Some(p) if tall(p) => p,
|
||||
Some(p) => BlockPos::new(p.x, y, p.z),
|
||||
None => BlockPos::new(
|
||||
self.position.x.floor() as i32,
|
||||
y,
|
||||
self.position.z.floor() as i32,
|
||||
),
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
@@ -1394,6 +1487,24 @@ impl ArmorStand {
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
|
||||
// Entity.setOnGroundWithMovement: the block under the feet.
|
||||
let context = horizon_world::collision_context::CollisionContext::for_entity(
|
||||
horizon_world::collision_context::CollisionEntity {
|
||||
identity: horizon_world::entity_obstruction::EntityIdentity(u64::from(self.id)),
|
||||
y: self.position.y,
|
||||
descending: false,
|
||||
main_hand: Some(horizon_world::collision_context::CollisionItem::EMPTY),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let position = self.position;
|
||||
let _ =
|
||||
self.support
|
||||
.check_support(self.on_ground, self.bounds(), Some(wanted), |area| {
|
||||
horizon_world::ground_support::find_supporting_block(
|
||||
scene, context, position, area, 1_000_000,
|
||||
)
|
||||
});
|
||||
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
|
||||
velocity.x = 0.0;
|
||||
}
|
||||
@@ -1504,6 +1615,8 @@ mod fire_environment_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -1651,6 +1764,8 @@ mod merge_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -1990,6 +2105,8 @@ mod honey_slide_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::new(vx, vy, vz),
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -2061,22 +2178,38 @@ mod slime_bounce_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landing_on_slime_bounces_a_dropped_item() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, true), 0.8);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.5, true), 0.4);
|
||||
fn landing_on_slime_bounces_a_dropped_item_like_vanilla() {
|
||||
// Measured on a vanilla 26.3 server: an item falling at -0.6097 that
|
||||
// is stopped 0.4865 down leaves with 0.4950 / 0.98 after the bounce.
|
||||
let y = vertical_collision_velocity(-0.6097, -0.4865, ITEM_GRAVITY, true, true);
|
||||
assert!((y - 0.4950 / 0.98).abs() < 1e-3, "{y}");
|
||||
// A tiny landing: -0.0792 stopped after 0.06 gives 0.0862 (vanilla).
|
||||
let y = vertical_collision_velocity(-0.0792, -0.06, ITEM_GRAVITY, true, true);
|
||||
assert!((y - 0.0862).abs() < 1e-3, "{y}");
|
||||
assert_eq!(
|
||||
item_vertical_collision_velocity(-0.04, true),
|
||||
vertical_collision_velocity(-0.04, -0.01, ITEM_GRAVITY, true, true),
|
||||
0.0,
|
||||
"gravity alone is not a bounce"
|
||||
);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.041, true), 0.041 * 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_on_stone_or_hitting_a_ceiling_zeros_y() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, false), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(1.0, true), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(0.96, true), 0.0);
|
||||
assert_eq!(
|
||||
vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, false),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
vertical_collision_velocity(1.0, 0.5, ITEM_GRAVITY, false, true),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slime_step_on_drags_slow_vertical_speed_only() {
|
||||
let scale = slime_step_on_scale(0.0862).unwrap();
|
||||
assert!((scale - 0.41724).abs() < 1e-5);
|
||||
assert_eq!(slime_step_on_scale(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2120,6 +2253,8 @@ mod slime_bounce_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
@@ -2147,9 +2282,8 @@ mod slime_bounce_tests {
|
||||
fn a_slime_landing_stays_upward_after_item_friction() {
|
||||
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
|
||||
// negative on the ground. A slime bounce must remain positive.
|
||||
let bounced = item_vertical_collision_velocity(-1.0, true) * 0.98;
|
||||
let bounced = vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * 0.98;
|
||||
assert!(bounced > 0.0);
|
||||
assert_eq!(bounced, 0.8 * 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -245,6 +245,8 @@ mod tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
|
||||
@@ -2053,12 +2053,23 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `PrimedTnt.addAdditionalSaveData`: its `fuse`.
|
||||
/// `PrimedTnt.readAdditionalSaveData`: `explosion_power`, 4 by default and
|
||||
/// clamped to 0..128.
|
||||
pub fn tnt_power(tag: &Tag) -> f32 {
|
||||
match tag.get("explosion_power") {
|
||||
Some(Tag::Float(bits)) => f32::from_bits(*bits).clamp(0.0, 128.0),
|
||||
_ => 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `PrimedTnt.addAdditionalSaveData`: its `fuse` and, when it is not the
|
||||
/// default, its `explosion_power`.
|
||||
pub fn tnt_to_nbt(
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
on_ground: bool,
|
||||
fuse: i32,
|
||||
power: f32,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
@@ -2073,6 +2084,9 @@ pub fn tnt_to_nbt(
|
||||
}
|
||||
.tag(raw);
|
||||
put(&mut tag, "fuse", Tag::Short(fuse as i16));
|
||||
if power != 4.0 {
|
||||
put(&mut tag, "explosion_power", Tag::Float(power.to_bits()));
|
||||
}
|
||||
// PrimedTnt writes its block state (a summoned `block_state` is kept).
|
||||
if tag.get("block_state").is_none() {
|
||||
put(&mut tag, "block_state", string("minecraft:tnt"));
|
||||
@@ -2094,6 +2108,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
// Age -32768 marks items that never despawn; Horizon counts upwards.
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
|
||||
@@ -2384,26 +2400,37 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
|
||||
|
||||
/// The thrown item projectiles Horizon saves: each entity type's id is also
|
||||
/// the item it saves when none was loaded (`getDefaultItem`).
|
||||
const THROWABLES: [(ProjectileKind, &str); 3] = [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
const THROWABLE_ITEMS: [&str; 5] = [
|
||||
"minecraft:snowball",
|
||||
"minecraft:egg",
|
||||
"minecraft:ender_pearl",
|
||||
"minecraft:splash_potion",
|
||||
"minecraft:lingering_potion",
|
||||
];
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
|
||||
if let ProjectileKind::Thrown(t) = kind {
|
||||
return Some(t.item);
|
||||
/// The item a throwable flies as: its stack (a potion's), the mod type's
|
||||
/// item, or the item its entity type is named after.
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
|
||||
match kind {
|
||||
ProjectileKind::Thrown(t) => Some(t.item),
|
||||
ProjectileKind::Potion(stack) => Some(&stack.item),
|
||||
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
|
||||
}
|
||||
THROWABLES
|
||||
.iter()
|
||||
.find(|(k, _)| k == kind)
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
|
||||
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
|
||||
/// A potion flies as the item its entity type names, which is the item a
|
||||
/// summoned one has when it names none (`getDefaultItem`).
|
||||
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
|
||||
match THROWABLES.iter().find(|(_, i)| *i == id) {
|
||||
Some((kind, _)) => Some(kind.clone()),
|
||||
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
|
||||
if !THROWABLE_ITEMS.contains(&id) {
|
||||
return crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown);
|
||||
}
|
||||
match id {
|
||||
"minecraft:snowball" => Some(ProjectileKind::Snowball),
|
||||
"minecraft:egg" => Some(ProjectileKind::Egg),
|
||||
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
|
||||
_ => Some(ProjectileKind::Potion(Box::new(
|
||||
crate::items::ItemStack::new(id, 1),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2441,13 +2468,14 @@ pub fn throwable_to_nbt(
|
||||
if p.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
}
|
||||
// the loaded item stays (a blue egg's, a renamed one's).
|
||||
// the loaded item stays (a blue egg's, a renamed one's, a potion's
|
||||
// effects); when there is none, `getItem`'s default is written.
|
||||
if tag.get("Item").is_none() {
|
||||
put(
|
||||
&mut tag,
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
|
||||
);
|
||||
let stack = match &p.kind {
|
||||
ProjectileKind::Potion(stack) => (**stack).clone(),
|
||||
_ => crate::items::ItemStack::new(item, 1),
|
||||
};
|
||||
put(&mut tag, "Item", item_to_nbt(&stack));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
@@ -2455,7 +2483,14 @@ pub fn throwable_to_nbt(
|
||||
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
|
||||
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
|
||||
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = throwable_kind(&kind(tag)?)?;
|
||||
let mut kind = throwable_kind(&kind(tag)?)?;
|
||||
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
|
||||
// and all; a tag without one keeps `getItem`'s default.
|
||||
if let ProjectileKind::Potion(stack) = &mut kind
|
||||
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
|
||||
{
|
||||
**stack = item;
|
||||
}
|
||||
let owner = owner_uuid(tag);
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
@@ -3205,6 +3240,35 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tnt_power_tests {
|
||||
use super::*;
|
||||
|
||||
/// `PrimedTnt.readAdditionalSaveData`: `explosion_power` defaults to 4 and
|
||||
/// is clamped to 0..128 both ways, so a summoned `explosion_power:200` is
|
||||
/// 128 and one that is absent is ordinary TNT.
|
||||
#[test]
|
||||
fn explosion_power_is_read_clamped_and_defaulted() {
|
||||
let with = |power: Option<f32>| {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
if let Some(p) = power {
|
||||
map.insert("explosion_power".into(), Tag::Float(p.to_bits()));
|
||||
}
|
||||
Tag::Compound(map)
|
||||
};
|
||||
assert_eq!(tnt_power(&with(None)), 4.0);
|
||||
assert_eq!(tnt_power(&with(Some(10.0))), 10.0);
|
||||
assert_eq!(tnt_power(&with(Some(200.0))), 128.0);
|
||||
assert_eq!(tnt_power(&with(Some(-3.0))), 0.0);
|
||||
// And it round-trips through the save, which writes it only when it is
|
||||
// not the default.
|
||||
let saved = tnt_to_nbt(Vec3::ZERO, Vec3::ZERO, false, 20, 10.0, [1, 2, 3, 4], None);
|
||||
assert_eq!(tnt_power(&saved), 10.0);
|
||||
let plain = tnt_to_nbt(Vec3::ZERO, Vec3::ZERO, false, 20, 4.0, [1, 2, 3, 4], None);
|
||||
assert!(plain.get("explosion_power").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3372,6 +3436,8 @@ mod tests {
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::new(0.0, -0.04, 0.0),
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 120,
|
||||
pickup_delay: 10,
|
||||
ticks: 0,
|
||||
@@ -4789,6 +4855,66 @@ mod fireball_nbt_tests {
|
||||
assert!(back.get("Item").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summoned_potion_flies_as_the_item_its_compound_names() {
|
||||
// `ThrowableItemProjectile.getDefaultItem`: the item its entity type is
|
||||
// named after, so a bare summon is a splash potion with no effects and
|
||||
// the chunk saves it (ThrowableProjectile is not transient).
|
||||
for id in ["minecraft:splash_potion", "minecraft:lingering_potion"] {
|
||||
let bare = Tag::compound([
|
||||
("id", Tag::String(id.into())),
|
||||
("Pos", doubles(&[24.5, 216.0, 24.5])),
|
||||
]);
|
||||
let p = throwable_from_nbt(&bare, None).unwrap();
|
||||
assert_eq!(p.kind.id(), id);
|
||||
assert_eq!(
|
||||
p.kind,
|
||||
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
|
||||
);
|
||||
assert!(saved_with_chunk(&p));
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!((&*item.item, item.count), (id, 1));
|
||||
assert_eq!(item.potion(), None);
|
||||
}
|
||||
// `readAdditionalSaveData`: the `Item` it flies as, `potion_contents`
|
||||
// and all, so what it splashes is the potion's.
|
||||
let harming = Tag::compound([
|
||||
("id", Tag::String("minecraft:splash_potion".into())),
|
||||
("Pos", doubles(&[24.5, 216.0, 24.5])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(
|
||||
&crate::items::ItemStack::new("minecraft:splash_potion", 1)
|
||||
.with_potion("minecraft:harming"),
|
||||
),
|
||||
),
|
||||
]);
|
||||
let p = throwable_from_nbt(&harming, None).unwrap();
|
||||
let ProjectileKind::Potion(stack) = &p.kind else {
|
||||
panic!("a potion");
|
||||
};
|
||||
assert_eq!(stack.potion(), Some("minecraft:harming"));
|
||||
// The stack stays in the tag it was loaded from.
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, Some(&harming)).unwrap();
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(item.potion(), Some("minecraft:harming"));
|
||||
// A lingering potion is its own entity type.
|
||||
let lingering = Tag::compound([
|
||||
("id", Tag::String("minecraft:lingering_potion".into())),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(
|
||||
&crate::items::ItemStack::new("minecraft:lingering_potion", 1)
|
||||
.with_potion("minecraft:poison"),
|
||||
),
|
||||
),
|
||||
]);
|
||||
let p = throwable_from_nbt(&lingering, None).unwrap();
|
||||
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
|
||||
let player = Some([5, 6, 7, 8]);
|
||||
|
||||
@@ -132,13 +132,15 @@ pub enum Inside {
|
||||
/// (`from` to `to`; `original` the move it asked for, when the move was
|
||||
/// 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.
|
||||
/// its fall. Returns whether a cobweb made it stuck, and adds to `surfaces` the
|
||||
/// column blocks it is at the top of (`Entity.sendBubbleColumnParticles`).
|
||||
pub fn apply_effects_from_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
velocity: &mut Vec3,
|
||||
fall_distance: &mut f64,
|
||||
surfaces: &mut Vec<BlockPos>,
|
||||
mut block: impl FnMut(BlockPos) -> Inside,
|
||||
) -> bool {
|
||||
let mut stuck = false;
|
||||
@@ -153,6 +155,9 @@ pub fn apply_effects_from_blocks(
|
||||
if column.push(velocity) {
|
||||
*fall_distance = 0.0;
|
||||
}
|
||||
if column.surface {
|
||||
surfaces.push(p);
|
||||
}
|
||||
}
|
||||
Inside::BubbleColumn(_) | Inside::Nothing => {}
|
||||
});
|
||||
@@ -302,13 +307,21 @@ mod tests {
|
||||
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
|
||||
}
|
||||
});
|
||||
let stuck = apply_effects_from_blocks(
|
||||
from,
|
||||
to,
|
||||
original,
|
||||
&mut velocity,
|
||||
&mut fall,
|
||||
&mut Vec::new(),
|
||||
|p| {
|
||||
if is_web(p) {
|
||||
Inside::Cobweb
|
||||
} else {
|
||||
Inside::Nothing
|
||||
}
|
||||
},
|
||||
);
|
||||
assert_eq!(fall == 0.0, stuck);
|
||||
stuck
|
||||
}
|
||||
@@ -357,13 +370,22 @@ mod tests {
|
||||
}),
|
||||
_ => Inside::Nothing,
|
||||
};
|
||||
let tops = std::cell::RefCell::new(Vec::new());
|
||||
let step = |x: f64, from: f64, to: f64, vy: f64| {
|
||||
tops.borrow_mut().clear();
|
||||
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);
|
||||
let stuck = apply_effects_from_blocks(
|
||||
from,
|
||||
to,
|
||||
original,
|
||||
&mut velocity,
|
||||
&mut fall,
|
||||
&mut tops.borrow_mut(),
|
||||
block,
|
||||
);
|
||||
assert!(!stuck);
|
||||
(velocity.y, fall)
|
||||
};
|
||||
@@ -381,6 +403,10 @@ mod tests {
|
||||
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);
|
||||
// Only the surface block sends the splash and bubble particles.
|
||||
assert_eq!(*tops.borrow(), [BlockPos::new(0, 9, 0)]);
|
||||
step(0.5, 5.5, 5.46, -0.04);
|
||||
assert!(tops.borrow().is_empty());
|
||||
// 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));
|
||||
|
||||
+404
-189
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@
|
||||
//! the default pose, the hit wobble and humanoid armor layers.
|
||||
use super::{Game, InteractTarget};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
cit::{Hand, Kind, Rule},
|
||||
cit_armor,
|
||||
entities::ArmorStand,
|
||||
@@ -14,6 +15,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
@@ -35,10 +37,11 @@ fn layer_color(undyed: Option<u32>, stack: Option<&ItemStack>) -> u32 {
|
||||
|
||||
/// Armor stand models and the humanoid layers of every equipment asset.
|
||||
pub(super) struct ArmorStandAssets {
|
||||
pub main: Option<Part>,
|
||||
pub small: Option<Part>,
|
||||
/// Shared (`Arc`) so the GPU path (`mobs::skin`) keeps one stored copy of each.
|
||||
pub main: Option<Arc<Part>>,
|
||||
pub small: Option<Arc<Part>>,
|
||||
/// (slot model suffix, small) -> model.
|
||||
pub layers: HashMap<(&'static str, bool), Part>,
|
||||
pub layers: HashMap<(&'static str, bool), Arc<Part>>,
|
||||
/// The player's armour models by slot (helmet, chestplate, leggings, boots).
|
||||
pub player: HashMap<&'static str, Part>,
|
||||
/// asset id -> layer kind (humanoid / humanoid_leggings) -> each layer's (texture, undyed colour), in order.
|
||||
@@ -63,7 +66,7 @@ impl ArmorStandAssets {
|
||||
}
|
||||
for (small, model) in [(false, "armor_stand"), (true, "armor_stand_small")] {
|
||||
if let Some(part) = load(&format!("minecraft:{model}#{slot}")) {
|
||||
layers.insert((slot, small), part);
|
||||
layers.insert((slot, small), Arc::new(part));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,8 +130,8 @@ impl ArmorStandAssets {
|
||||
}
|
||||
}
|
||||
Self {
|
||||
main: load("minecraft:armor_stand#main"),
|
||||
small: load("minecraft:armor_stand_small#main"),
|
||||
main: load("minecraft:armor_stand#main").map(Arc::new),
|
||||
small: load("minecraft:armor_stand_small#main").map(Arc::new),
|
||||
layers,
|
||||
player,
|
||||
equipment,
|
||||
@@ -726,7 +729,7 @@ impl Game<'_> {
|
||||
let spread = [width / 4.0, height / 4.0, width / 4.0];
|
||||
self.particles
|
||||
.send_particles(&options, centre, spread, 0.05, 10);
|
||||
self.lan_level_particles(&options, centre, spread, 0.05, 10, Some(state));
|
||||
self.lan_level_particles(None, &options, centre, spread, 0.05, 10, Some(state));
|
||||
}
|
||||
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
|
||||
/// creative player breaks it, otherwise the first hit wobbles it and a
|
||||
@@ -778,7 +781,7 @@ impl Game<'_> {
|
||||
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_armor_stands(&mut self) {
|
||||
pub(super) fn tick_armor_stands(&mut self) -> Result<()> {
|
||||
let mut dead = Vec::new();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
@@ -805,11 +808,22 @@ impl Game<'_> {
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut tops: Vec<BlockPos> = Vec::new();
|
||||
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world, registry, facts, collision, bounds, velocity,
|
||||
horizon_client::motion::bubble_column_push_over(
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
collision,
|
||||
bounds,
|
||||
velocity,
|
||||
|pos| tops.push(pos),
|
||||
);
|
||||
});
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
let a = &mut self.armor_stands[i];
|
||||
if in_water {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
} else {
|
||||
@@ -819,16 +833,60 @@ impl Game<'_> {
|
||||
if a.on_ground {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
}
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
}
|
||||
// LivingEntity.checkFallDamage: onChangedBlock before the block's
|
||||
// fallOn, then aiStep's applyEffectsFromBlocks (stepOn, then the
|
||||
// blocks it is inside).
|
||||
if landed {
|
||||
self.armor_stand_location_effects(i, true);
|
||||
}
|
||||
self.armor_stand_turtle_eggs(i, landed)?;
|
||||
let a = &mut self.armor_stands[i];
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
}
|
||||
}
|
||||
for i in dead.into_iter().rev() {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// An armor stand is a `LivingEntity`, so the egg under it
|
||||
/// (`getOnPosLegacy`) cracks like under a mob: `TurtleEggBlock.fallOn` one
|
||||
/// landing in 3 (`Entity.move`'s `checkFallDamage`), then `stepOn` one tick
|
||||
/// in 100 while it is on the ground (`LivingEntity.aiStep`'s
|
||||
/// `applyEffectsFromBlocks`, after `travel`, so a marker that has landed
|
||||
/// still steps). `canDestroyEgg` wants mob griefing before the level's roll.
|
||||
fn armor_stand_turtle_eggs(&mut self, i: usize, landed: bool) -> Result<()> {
|
||||
let stand = &self.armor_stands[i];
|
||||
if !stand.on_ground || self.remote.is_some() || self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
let p = stand.on_pos_legacy(|p| {
|
||||
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
|
||||
let tags = &self.res.items().block_tags;
|
||||
[
|
||||
"minecraft:fences",
|
||||
"minecraft:walls",
|
||||
"minecraft:fence_gates",
|
||||
]
|
||||
.iter()
|
||||
.any(|t| tags.get(*t).is_some_and(|s| s.contains(&**name)))
|
||||
});
|
||||
for chance in [landed.then_some(3), Some(100)].into_iter().flatten() {
|
||||
let egg = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(p.x, p.y, p.z))
|
||||
.is_some_and(|s| s.name == "minecraft:turtle_egg");
|
||||
if egg
|
||||
&& self.ui.rules.bool("mob_griefing")
|
||||
&& self.world.random.next_int_bound(chance).unwrap_or(1) == 0
|
||||
{
|
||||
self.crack_turtle_egg([p.x, p.y, p.z])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast
|
||||
/// breaks a stand: its equipment pops above its block, the stand item does
|
||||
@@ -851,6 +909,8 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
|
||||
let skinning = self.skinning();
|
||||
for a in &self.armor_stands {
|
||||
let entity_start = out.len();
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
@@ -904,20 +964,37 @@ impl Game<'_> {
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
self.armor_stand_vertices(a, base, packed, out);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
let skinned = if skinning {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
self.armor_stand_vertices(a, base, packed, Some(&mut skin), out);
|
||||
Some((mark, skin.bounds))
|
||||
} else {
|
||||
self.armor_stand_vertices(a, base, packed, None, out);
|
||||
None
|
||||
};
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
}
|
||||
/// `ArmorStandRenderer.submit` for stand `a` under `base` (its
|
||||
/// `setupRotations`): the body, the hand and head items, the armour and
|
||||
/// the wings, at packed light `packed`.
|
||||
/// the wings, at packed light `packed`. With `skin`, the stand's own models
|
||||
/// (body and armour) are posed on the GPU; items and wings stay here.
|
||||
fn armor_stand_vertices(
|
||||
&self,
|
||||
a: &ArmorStand,
|
||||
base: glam::Mat4,
|
||||
packed: u32,
|
||||
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use horizon_client::mobs::render::put;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let uv_of = |name: &str| sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
@@ -961,11 +1038,13 @@ impl Game<'_> {
|
||||
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
|
||||
// layers (held items, custom head, armour) are submitted all the same.
|
||||
if !a.invisible {
|
||||
horizon_client::mobs::render::draw(
|
||||
put(
|
||||
&mut skin,
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
wood,
|
||||
0.0,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
@@ -973,11 +1052,13 @@ impl Game<'_> {
|
||||
);
|
||||
if let Some(glow) = glow_of("entity/armorstand/armorstand") {
|
||||
let full = horizon_client::mobs::render::FULL_BRIGHT;
|
||||
horizon_client::mobs::render::draw(
|
||||
put(
|
||||
&mut skin,
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
glow,
|
||||
0.0,
|
||||
0xFFFF_FFFF,
|
||||
full,
|
||||
0,
|
||||
@@ -986,7 +1067,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
// HumanoidArmorLayer: head, chest, legs, feet. One pose map for every layer
|
||||
// (`reset` keeps its capacity).
|
||||
let mut layer_poses = Poses::default();
|
||||
for (slot, layer) in [
|
||||
(5usize, "helmet"),
|
||||
(4, "chestplate"),
|
||||
@@ -1030,7 +1113,7 @@ impl Game<'_> {
|
||||
0
|
||||
};
|
||||
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
|
||||
let mut layer_poses = Poses::new(part);
|
||||
layer_poses.reset(part);
|
||||
apply_pose(&mut layer_poses, &a.pose);
|
||||
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
|
||||
// too; the shader glint goes on the first layer only (renderShaderGlint).
|
||||
@@ -1040,11 +1123,13 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, Some(stack));
|
||||
horizon_client::mobs::render::draw(
|
||||
put(
|
||||
&mut skin,
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
uv,
|
||||
0.0,
|
||||
color,
|
||||
packed,
|
||||
if i == 0 { overlay } else { 0 },
|
||||
@@ -1052,11 +1137,13 @@ impl Game<'_> {
|
||||
);
|
||||
// The layer's OptiFine emissive overlay, full bright without the glint.
|
||||
if let Some(glow) = glow_of(texture) {
|
||||
horizon_client::mobs::render::draw(
|
||||
put(
|
||||
&mut skin,
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
glow,
|
||||
0.0,
|
||||
color,
|
||||
horizon_client::mobs::render::FULL_BRIGHT,
|
||||
0,
|
||||
@@ -1069,11 +1156,13 @@ impl Game<'_> {
|
||||
.trim_texture(&asset, kind, stack)
|
||||
.and_then(|t| uv_of(&t.sprite()))
|
||||
{
|
||||
horizon_client::mobs::render::draw(
|
||||
put(
|
||||
&mut skin,
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
trim,
|
||||
0.0,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
overlay,
|
||||
@@ -1123,7 +1212,7 @@ impl Game<'_> {
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let mut verts = Vec::new();
|
||||
self.armor_stand_vertices(&stand, base, 240 | 240 << 8, &mut verts);
|
||||
self.armor_stand_vertices(&stand, base, 240 | 240 << 8, None, &mut verts);
|
||||
// ARMOR_STAND_ANGLE: rotationXYZ(0.43633232, 0, PI).
|
||||
let gui =
|
||||
glam::Mat4::from_translation(glam::Vec3::new((x0 + x1) / 2.0, (y0 + y1) / 2.0, 0.0))
|
||||
|
||||
@@ -146,9 +146,7 @@ impl Game<'_> {
|
||||
{
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
let block = b.block(state);
|
||||
b.update_neighbors_at(level, ticks, p, block);
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
b.trapped_chest_update_neighbors(level, ticks, p, state);
|
||||
});
|
||||
}
|
||||
(before == 0) != (count == 0)
|
||||
|
||||
@@ -45,6 +45,8 @@ pub(super) struct Boat {
|
||||
pub delta_rotation: f32,
|
||||
status: Status,
|
||||
old_status: Status,
|
||||
/// `AbstractBoat.oldStatus` is null until the first tick has set a status.
|
||||
status_known: bool,
|
||||
water_level: f64,
|
||||
land_friction: f32,
|
||||
out_of_control: f32,
|
||||
@@ -126,6 +128,7 @@ impl Boat {
|
||||
delta_rotation: 0.0,
|
||||
status: Status::InAir,
|
||||
old_status: Status::InAir,
|
||||
status_known: false,
|
||||
water_level: 0.0,
|
||||
land_friction: 0.0,
|
||||
out_of_control: 0.0,
|
||||
@@ -571,6 +574,7 @@ impl Game<'_> {
|
||||
if !controlled && self.remote.is_none() {
|
||||
self.tick_boat_leash(&mut b);
|
||||
}
|
||||
let had_status = std::mem::replace(&mut b.status_known, true);
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.tick_submersion();
|
||||
@@ -601,7 +605,10 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if b.old_status == Status::InAir && b.status != Status::InAir && b.status != Status::OnLand
|
||||
if had_status
|
||||
&& b.old_status == Status::InAir
|
||||
&& b.status != Status::InAir
|
||||
&& b.status != Status::OnLand
|
||||
{
|
||||
b.water_level = b.position.y;
|
||||
let target = self.water_level_above(&b) - HEIGHT + 0.101;
|
||||
@@ -841,6 +848,8 @@ impl Game<'_> {
|
||||
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
|
||||
let status = self.boat_status(&mut probe);
|
||||
self.boats[i].status = status;
|
||||
// `oldStatus = status` runs on every boat, so the next tick has one.
|
||||
self.boats[i].status_known = true;
|
||||
self.boats[i].tick_submersion();
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
|
||||
|
||||
@@ -170,6 +170,12 @@ impl Game<'_> {
|
||||
}
|
||||
// defaultHandleGameClickEvent: the dialog opens over the book.
|
||||
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
|
||||
// Screen.defaultHandleGameClickEvent: custom actions retain the book.
|
||||
ClickEvent::Custom { id, payload } => {
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.send_custom_click(&id, payload);
|
||||
}
|
||||
}
|
||||
// SuggestCommand's insertText does nothing on a book; a server's
|
||||
// text never opens a file.
|
||||
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
|
||||
@@ -773,8 +779,7 @@ impl Game<'_> {
|
||||
let p = BlockPos::new(key.0, key.1, key.2);
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
self.with_level(|b, level, ticks| {
|
||||
let block = b.block(state);
|
||||
b.update_neighbors_at(level, ticks, BlockPos::new(p.x, p.y - 1, p.z), block);
|
||||
b.lectern_update_below(level, ticks, p, state);
|
||||
});
|
||||
}
|
||||
fn set_lectern_property(&mut self, key: (i32, i32, i32), name: &str, value: bool) {
|
||||
|
||||
@@ -326,6 +326,32 @@ fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The caches `build_registries` reads (`resource_ids`, `block_properties`,
|
||||
/// `command_export`), filled on a helper thread while the title screen is up: the
|
||||
/// first world opened or server joined built them in its frame (20 ms of the Join
|
||||
/// click on an M4 Pro). Whoever needs one before it is done waits for it, as before.
|
||||
pub(super) fn preload_registries(&self) {
|
||||
if self.options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let dirs = [self.options.resources.clone(), self.ui.vanilla_root.clone()];
|
||||
let registry = self.pack.assets.registry;
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Registries preload".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
resource_ids(&dirs[0]);
|
||||
block_properties(registry);
|
||||
for dir in &dirs {
|
||||
if command_export(dir, true).is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("Horizon: registries preload: {e}");
|
||||
}
|
||||
}
|
||||
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
|
||||
pub(super) fn allow_commands(&self) -> bool {
|
||||
self.level.allow_commands == Some(true)
|
||||
@@ -987,6 +1013,12 @@ impl Game<'_> {
|
||||
// Screen.defaultHandleGameClickEvent: the dialog opens over the
|
||||
// screen, and closes back to it.
|
||||
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
|
||||
// Screen.defaultHandleGameClickEvent: custom actions retain chat.
|
||||
Some(ClickEvent::Custom { id, payload }) => {
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.send_custom_click(&id, payload);
|
||||
}
|
||||
}
|
||||
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
|
||||
Some(ClickEvent::ChangePage(_)) | None => {}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,12 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"mobs" => {
|
||||
// Observe authoritative AI state without a control-socket race.
|
||||
let record = self.harness_owner(|g| Ok(g.control_mobs(128.0)))?;
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
|
||||
@@ -101,9 +101,14 @@ fn java_float(v: f64) -> String {
|
||||
format!("{v}")
|
||||
}
|
||||
}
|
||||
/// String.format(Locale.ROOT, "%.<decimals>f"): Java rounds the shortest
|
||||
/// decimal text half-up, which `{:.N}` (exact value, half-even) does not.
|
||||
pub(super) fn percent_f(v: f64, decimals: usize) -> String {
|
||||
horizon_client::java_text::format_fixed(v, decimals)
|
||||
}
|
||||
/// String.format(Locale.ROOT, "%f").
|
||||
fn format_double(v: f64) -> String {
|
||||
format!("{v:.6}")
|
||||
percent_f(v, 6)
|
||||
}
|
||||
/// `EntitySelectorParser.createRotationPredicate`: the wrapped angle lies in
|
||||
/// the wrapped range, which runs through 180 when its minimum is above its
|
||||
@@ -845,41 +850,18 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The entities of the level in place a selector can find.
|
||||
/// The entities of the level in place a selector can find: one walk over
|
||||
/// the entity lists (a kind added there joins `@e` once it names its
|
||||
/// `Target`), then the kinds that are not on the walk. An
|
||||
/// unsorted selector lists them in that order.
|
||||
fn level_candidates(&self) -> Vec<Target> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
let mut candidates = Vec::new();
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.is_arrow()
|
||||
|| horizon_client::entity_nbt::saved_with_chunk(p)
|
||||
|| matches!(
|
||||
p.kind,
|
||||
K::LargeFireball { .. }
|
||||
| K::SmallFireball
|
||||
| K::WindCharge
|
||||
| K::BreezeWindCharge
|
||||
)
|
||||
})
|
||||
.map(|p| Target::Projectile(p.id)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
candidates.extend(entity.selector_target());
|
||||
});
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
// The End crystals and the dragon (EndCrystal and EnderDragon
|
||||
// are entities of their own).
|
||||
@@ -887,9 +869,6 @@ impl Game<'_> {
|
||||
if let Some(d) = &self.dragon {
|
||||
candidates.push(Target::Dragon(d.id));
|
||||
}
|
||||
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
|
||||
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
|
||||
candidates.extend(self.rockets.iter().map(|r| Target::Rocket(r.id)));
|
||||
candidates.extend(self.ominous_spawners.iter().map(|s| Target::Spawner(s.id)));
|
||||
candidates
|
||||
}
|
||||
@@ -1831,12 +1810,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
pos[1]
|
||||
};
|
||||
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
|
||||
let horizontal = (dx * dx + dz * dz).sqrt();
|
||||
Some([
|
||||
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
|
||||
(-(dy.atan2(horizontal).to_degrees())) as f32,
|
||||
])
|
||||
Some(world_cmds::look_at([pos[0], eye, pos[2]], f))
|
||||
};
|
||||
// A server's player in another level than the source's (`execute
|
||||
// in`, the console's Overworld) moves there (ServerPlayer.teleport
|
||||
@@ -3911,3 +3885,21 @@ mod look_at_tests {
|
||||
assert_eq!(non_player_entity_id(Target::Mob(u32::MAX - 4096)), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod percent_f_tests {
|
||||
use super::percent_f;
|
||||
|
||||
#[test]
|
||||
fn rounds_the_shortest_decimal_half_up_like_java() {
|
||||
// WorldBorderCommand: "%.1f" distance, "%.2f" buffer, "%.0f" size.
|
||||
assert_eq!(percent_f(10.35, 1), "10.4");
|
||||
assert_eq!(percent_f(5.125, 2), "5.13");
|
||||
assert_eq!(percent_f(10.5, 0), "11");
|
||||
assert_eq!(percent_f(2.5, 0), "3");
|
||||
// TeleportCommand.formatDouble: "%f".
|
||||
assert_eq!(percent_f(0.1234565, 6), "0.123457");
|
||||
assert_eq!(percent_f(-1.005, 2), "-1.01");
|
||||
assert_eq!(percent_f(3.0, 2), "3.00");
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,7 @@ impl Game<'_> {
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
)
|
||||
|
||||
@@ -160,7 +160,7 @@ fn tr_error(key: &str, args: &[Text]) -> CommandError {
|
||||
}
|
||||
/// `String.format(Locale.ROOT, "%.2f", v)`.
|
||||
fn two_places(v: f64) -> String {
|
||||
format!("{v:.2}")
|
||||
super::percent_f(v, 2)
|
||||
}
|
||||
/// `Util.getFilenameFormattedDateTime` (`yyyy-MM-dd_HH.mm.ss`, local time).
|
||||
fn filename_date_time() -> String {
|
||||
|
||||
@@ -23,7 +23,7 @@ fn holder(text: Text) -> Holder {
|
||||
}
|
||||
}
|
||||
fn ticks_to_seconds(ticks: i64) -> String {
|
||||
format!("{:.2}", ticks as f64 / 20.0)
|
||||
super::percent_f(ticks as f64 / 20.0, 2)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -312,7 +312,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn cmd_worldborder(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let float2 = |v: f64| format!("{v:.2}");
|
||||
let float2 = |v: f64| super::percent_f(v, 2);
|
||||
let double = match ctx.get("distance") {
|
||||
Some(Arg::Double(d)) => *d,
|
||||
_ => 0.0,
|
||||
@@ -326,7 +326,7 @@ impl Game<'_> {
|
||||
let size = self.border.size();
|
||||
self.feedback(Text::translate(
|
||||
"commands.worldborder.get",
|
||||
&[Text::literal(format!("{size:.0}"))],
|
||||
&[Text::literal(super::percent_f(size, 0))],
|
||||
));
|
||||
Ok((size + 0.5).floor() as i32)
|
||||
}
|
||||
@@ -346,7 +346,7 @@ impl Game<'_> {
|
||||
if distance > MAX_SIZE {
|
||||
return Err(err("commands.worldborder.set.failed.big", &[&"5.999997E7"]));
|
||||
}
|
||||
let shown = Text::literal(format!("{distance:.1}"));
|
||||
let shown = Text::literal(super::percent_f(distance, 1));
|
||||
if time > 0 {
|
||||
self.border.lerp_to(distance, time);
|
||||
let key = if distance > current {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `/recipe`, `/damage`, `/rotate`, `/swing`, `/playsound`, `/stopsound`,
|
||||
//! `/particle`, `/attribute`, `/ride` and `/spreadplayers`.
|
||||
use super::{Arg, Context, Game, R, Target, err};
|
||||
use super::{Arg, Context, Game, R, Target, err, wrap_degrees};
|
||||
use horizon_client::{
|
||||
mobs::{Damage, DamageKind, Who},
|
||||
scoreboard_cmd::{Holder, Tracker},
|
||||
@@ -376,23 +376,8 @@ impl Game<'_> {
|
||||
let Some(p) = self.target_position(t) else {
|
||||
continue;
|
||||
};
|
||||
let d = [pos[0] - p[0], pos[1] - p[1], pos[2] - p[2]];
|
||||
let dist2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
let (at, v) = if dist2 > f64::from(range) * f64::from(range) {
|
||||
if min_volume <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let dist = dist2.sqrt();
|
||||
(
|
||||
[
|
||||
p[0] + d[0] / dist * 2.0,
|
||||
p[1] + d[1] / dist * 2.0,
|
||||
p[2] + d[2] / dist * 2.0,
|
||||
],
|
||||
min_volume,
|
||||
)
|
||||
} else {
|
||||
(pos, volume)
|
||||
let Some((at, v)) = playsound_placement(pos, p, range, volume, min_volume) else {
|
||||
continue;
|
||||
};
|
||||
// The connection is sent the sound; the window's own player
|
||||
// plays it.
|
||||
@@ -1499,7 +1484,7 @@ impl Game<'_> {
|
||||
num(points.len()),
|
||||
float(centre_f[0]),
|
||||
float(centre_f[1]),
|
||||
Text::literal(format!("{min_distance:.2}")),
|
||||
Text::literal(super::percent_f(min_distance, 2)),
|
||||
],
|
||||
));
|
||||
}
|
||||
@@ -1553,7 +1538,7 @@ impl Game<'_> {
|
||||
num(points.len()),
|
||||
float(centre_f[0]),
|
||||
float(centre_f[1]),
|
||||
Text::literal(format!("{average:.2}")),
|
||||
Text::literal(super::percent_f(average, 2)),
|
||||
],
|
||||
));
|
||||
Ok(points.len() as i32)
|
||||
@@ -1612,11 +1597,124 @@ fn text_error(key: &str, args: &[Text]) -> horizon_client::command::CommandError
|
||||
horizon_client::command::CommandError::new(Text::translate(key, args))
|
||||
}
|
||||
|
||||
/// `Entity.lookAt`: yaw and pitch from `from` towards `to`.
|
||||
fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
|
||||
/// `PlaySoundCommand.playSound`: the point and the volume one target hears the
|
||||
/// sound at. A target past `range` hears nothing unless `minVolume` is above
|
||||
/// zero, and then the point moves two blocks from the target towards the sound
|
||||
/// and the volume is `minVolume`; a target exactly at `range` still hears it
|
||||
/// (the command's test is `>`, unlike `PlayerList.broadcast`'s `<`).
|
||||
fn playsound_placement(
|
||||
pos: [f64; 3],
|
||||
target: [f64; 3],
|
||||
range: f32,
|
||||
volume: f32,
|
||||
min_volume: f32,
|
||||
) -> Option<([f64; 3], f32)> {
|
||||
let d = [pos[0] - target[0], pos[1] - target[1], pos[2] - target[2]];
|
||||
let dist2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
// PlaySoundCommand compares against Mth.square(float range): the square
|
||||
// rounds in float and only then widens, so a point between that square and
|
||||
// the double square is out of range in vanilla.
|
||||
if dist2 <= f64::from(range * range) {
|
||||
return Some((pos, volume));
|
||||
}
|
||||
if min_volume <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let dist = dist2.sqrt();
|
||||
Some((
|
||||
[
|
||||
target[0] + d[0] / dist * 2.0,
|
||||
target[1] + d[1] / dist * 2.0,
|
||||
target[2] + d[2] / dist * 2.0,
|
||||
],
|
||||
min_volume,
|
||||
))
|
||||
}
|
||||
|
||||
/// `Entity.lookAt`: yaw and pitch from `from` towards `to`, the yaw wrapped
|
||||
/// to -180..180 as `Mth.wrapDegrees` does.
|
||||
pub(super) fn look_at(from: [f64; 3], to: [f64; 3]) -> [f32; 2] {
|
||||
let (dx, dy, dz) = (to[0] - from[0], to[1] - from[1], to[2] - from[2]);
|
||||
let horizontal = (dx * dx + dz * dz).sqrt();
|
||||
let pitch = (-(dy.atan2(horizontal).to_degrees())) as f32;
|
||||
let yaw = (dz.atan2(dx).to_degrees() - 90.0) as f32;
|
||||
[yaw.rem_euclid(360.0), pitch]
|
||||
// `Entity.lookAt` turns radians to degrees with the float constant
|
||||
// `57.2957763671875`, not `Math.toDegrees`.
|
||||
let pitch = wrap_degrees((-(dy.atan2(horizontal) * 57.295_776_367_187_5)) as f32);
|
||||
let yaw = (dz.atan2(dx) * 57.295_776_367_187_5) as f32 - 90.0;
|
||||
[wrap_degrees(yaw), pitch]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{look_at, playsound_placement};
|
||||
|
||||
fn assert_angle(got: f32, want: f32) {
|
||||
assert!((got - want).abs() < 1.0e-4, "{got} is not {want}");
|
||||
}
|
||||
|
||||
/// `Entity.lookAt` wraps the yaw with `Mth.wrapDegrees`, so a target to
|
||||
/// the north-west turns the entity to 135 degrees, not to -225, and a
|
||||
/// target due north leaves -180 rather than 180.
|
||||
#[test]
|
||||
fn a_facing_target_wraps_the_yaw_to_180() {
|
||||
let at = [0.0, 64.0, 0.0];
|
||||
assert_angle(look_at(at, [-1.0, 64.0, -1.0])[0], 135.0);
|
||||
assert_angle(look_at(at, [0.0, 64.0, -1.0])[0], -180.0);
|
||||
assert_angle(look_at(at, [1.0, 64.0, 0.0])[0], -90.0);
|
||||
assert_angle(look_at(at, [0.0, 64.0, 1.0])[0], 0.0);
|
||||
assert_angle(look_at(at, [-1.0, 64.0, 0.0])[0], 90.0);
|
||||
// The pitch is the elevation, unchanged; its own wrap is a no-op.
|
||||
assert_angle(look_at(at, [0.0, 65.0, 1.0])[1], -45.0);
|
||||
}
|
||||
|
||||
/// `PlaySoundCommand.playSound`: inside the range the point and volume are
|
||||
/// the command's own, exactly on it still is, past it there is nothing
|
||||
/// without a minimum volume, and with one the point is two blocks from the
|
||||
/// target towards the sound and the volume is that minimum.
|
||||
#[test]
|
||||
fn a_target_past_the_range_hears_it_two_blocks_away() {
|
||||
let pos = [0.0, 64.0, 0.0];
|
||||
let near = [0.0, 64.0, 15.0];
|
||||
assert_eq!(
|
||||
playsound_placement(pos, near, 16.0, 3.0, 0.0),
|
||||
Some((pos, 3.0))
|
||||
);
|
||||
let on_the_edge = [16.0, 64.0, 0.0];
|
||||
assert_eq!(
|
||||
playsound_placement(pos, on_the_edge, 16.0, 3.0, 0.0),
|
||||
Some((pos, 3.0))
|
||||
);
|
||||
let far = [0.0, 64.0, 32.0];
|
||||
assert_eq!(playsound_placement(pos, far, 16.0, 3.0, 0.0), None);
|
||||
assert_eq!(
|
||||
playsound_placement(pos, far, 16.0, 3.0, 0.5),
|
||||
Some(([0.0, 64.0, 30.0], 0.5))
|
||||
);
|
||||
}
|
||||
|
||||
/// The range is squared as a float, as `Mth.square(getRange(volume))` is:
|
||||
/// a point between that square and the double one is past the range in
|
||||
/// vanilla, so it gets the `minVolume` placement rather than full volume.
|
||||
#[test]
|
||||
fn the_range_square_rounds_as_a_float() {
|
||||
let volume = 1.1f32;
|
||||
let range = 16.0 * volume;
|
||||
let float_square = f64::from(range * range);
|
||||
let double_square = f64::from(range) * f64::from(range);
|
||||
assert!(float_square < double_square);
|
||||
let dist2 = (float_square + double_square) / 2.0;
|
||||
let pos = [0.0, 64.0, 0.0];
|
||||
let target = [dist2.sqrt(), 64.0, 0.0];
|
||||
assert_eq!(
|
||||
playsound_placement(pos, target, range, volume, 0.0),
|
||||
None,
|
||||
"past the float range"
|
||||
);
|
||||
let moved = playsound_placement(pos, target, range, volume, 0.7);
|
||||
let Some((point, heard)) = moved else {
|
||||
panic!("minVolume should place it");
|
||||
};
|
||||
assert_eq!(heard, 0.7);
|
||||
assert!((point[0] - (target[0] - 2.0)).abs() < 1e-9, "{point:?}");
|
||||
assert_eq!(point[1..], [64.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,33 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
|
||||
fn position(&mut self) -> Result<Vec3, Infallible> {
|
||||
Ok(self.at())
|
||||
}
|
||||
fn teleport_allowed(&mut self, target: &mut Vec3) -> Result<bool, Infallible> {
|
||||
// EntityTeleportEvent.ChorusFruit: only a server's player is heard.
|
||||
let (Some(uuid), Some(name)) = (self.0.lan_acting_uuid, self.0.lan_acting_name.clone())
|
||||
else {
|
||||
return Ok(true);
|
||||
};
|
||||
let from = self.at();
|
||||
let mut to = [target.x, target.y, target.z];
|
||||
let mut cancel = false;
|
||||
self.0.native_chorus_fruit_teleport(
|
||||
uuid,
|
||||
&name,
|
||||
[from.x, from.y, from.z],
|
||||
&mut to,
|
||||
&mut cancel,
|
||||
);
|
||||
// Keep the mod's spot inside the level as vanilla's own draw is (a
|
||||
// huge y would walk down a long way); one that is not a number keeps
|
||||
// the draw.
|
||||
if to.iter().all(|v| v.is_finite()) {
|
||||
let shape = self.0.level_shapes.get(self.0.dim.id());
|
||||
let high = shape.min_y + shape.logical_height - 1;
|
||||
let y = to[1].clamp(f64::from(shape.min_y), f64::from(high));
|
||||
*target = Vec3::new(to[0], y, to[2]);
|
||||
}
|
||||
Ok(!cancel)
|
||||
}
|
||||
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
|
||||
Ok(self
|
||||
.0
|
||||
|
||||
@@ -1236,7 +1236,7 @@ impl Game<'_> {
|
||||
"distance": self.settings.horizon.lod_distance,
|
||||
"effective_distance": self.lod_distance(),
|
||||
"terrain_distance": self.terrain_distance(),
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else if self.remote.is_some() { "server-terrain" } else { "local" },
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
|
||||
@@ -753,6 +753,7 @@ impl Game<'_> {
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
player,
|
||||
others,
|
||||
dragon: self.dragon_target(),
|
||||
leash_boats: &self.leash_boats,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
@@ -1729,11 +1730,19 @@ impl Game<'_> {
|
||||
let splash_options = ParticleOptions::simple(ParticleType::Splash);
|
||||
self.particles
|
||||
.send_particles(&splash_options, splash, [0.0; 3], 1.0, 1);
|
||||
self.lan_level_particles(&splash_options, splash, [0.0; 3], 1.0, 1, None);
|
||||
self.lan_level_particles(None, &splash_options, splash, [0.0; 3], 1.0, 1, None);
|
||||
let bubble_options = ParticleOptions::simple(ParticleType::Bubble);
|
||||
self.particles
|
||||
.send_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1);
|
||||
self.lan_level_particles(&bubble_options, bubble, [0.0, 0.01, 0.0], 0.2, 1, None);
|
||||
self.lan_level_particles(
|
||||
None,
|
||||
&bubble_options,
|
||||
bubble,
|
||||
[0.0, 0.01, 0.0],
|
||||
0.2,
|
||||
1,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
@@ -1769,7 +1778,7 @@ impl Game<'_> {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
self.lan_level_particles(&options, at, spread, speed, count, None);
|
||||
self.lan_level_particles(None, &options, at, spread, speed, count, None);
|
||||
}
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
@@ -2297,29 +2306,7 @@ impl Game<'_> {
|
||||
self.level_event_at(2001, pos, old.0 as i32);
|
||||
}
|
||||
}
|
||||
MobEvent::CrackTurtleEgg { pos } => {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let eggs = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name == "minecraft:turtle_egg")
|
||||
.and_then(|s| s.properties.get("eggs"))
|
||||
.and_then(|e| e.parse::<i32>().ok());
|
||||
if let Some(eggs) = eggs {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
// decreaseEggs: the break sound and the remaining
|
||||
// eggs (a stack of one is destroyed after it).
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.turtle_egg_destroyed_by_player(level, ticks, at, state);
|
||||
if eggs <= 1 {
|
||||
b.destroy_block(level, ticks, at, false);
|
||||
}
|
||||
});
|
||||
self.process_level_events()?;
|
||||
}
|
||||
}
|
||||
MobEvent::CrackTurtleEgg { pos } => self.crack_turtle_egg(pos)?,
|
||||
MobEvent::BedOccupied { pos, occupied } => self.set_bed_occupied(pos, occupied),
|
||||
MobEvent::SetDoorOpen { pos, open } => {
|
||||
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
@@ -2724,6 +2711,31 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `TurtleEggBlock.decreaseEggs` after an entity's step or landing won its
|
||||
/// roll (`destroyEgg`): the break sound and the remaining eggs (a stack of
|
||||
/// one is destroyed after it).
|
||||
pub(super) fn crack_turtle_egg(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let eggs = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name == "minecraft:turtle_egg")
|
||||
.and_then(|s| s.properties.get("eggs"))
|
||||
.and_then(|e| e.parse::<i32>().ok());
|
||||
if let Some(eggs) = eggs {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.turtle_egg_destroyed_by_player(level, ticks, at, state);
|
||||
if eggs <= 1 {
|
||||
b.destroy_block(level, ticks, at, false);
|
||||
}
|
||||
});
|
||||
self.process_level_events()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
|
||||
/// per egg, a day from grown, with the egg as its home (the sound, the
|
||||
/// crumbs and the block's removal came from the level).
|
||||
@@ -3501,6 +3513,7 @@ impl Game<'_> {
|
||||
let mut deflections: Vec<[f64; 3]> = Vec::new();
|
||||
let mut arrow_events = Vec::new();
|
||||
let mut trails = Vec::new();
|
||||
let mut bubble_tops = Vec::new();
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
// The impacts the mods may cancel (`native_projectile_impact`), on a server.
|
||||
let hear = self.native_hears_impacts();
|
||||
@@ -3623,7 +3636,7 @@ impl Game<'_> {
|
||||
// ThrownEnderpearl.onAboveBubbleColumn / onInsideBubbleColumn.
|
||||
if keep && hit == Hit::None && matches!(p.kind, ProjectileKind::EnderPearl) {
|
||||
let (at, h) = (p.position, 0.125);
|
||||
horizon_client::motion::bubble_column_push(
|
||||
horizon_client::motion::bubble_column_push_over(
|
||||
w.world,
|
||||
w.registry,
|
||||
w.facts,
|
||||
@@ -3633,6 +3646,7 @@ impl Game<'_> {
|
||||
Vec3::new(at.x + h, at.y + 2.0 * h, at.z + h),
|
||||
),
|
||||
&mut p.velocity,
|
||||
|pos| bubble_tops.push(pos),
|
||||
);
|
||||
}
|
||||
let events_from = arrow_events.len();
|
||||
@@ -3716,6 +3730,9 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.smoke_trails(trails);
|
||||
for pos in bubble_tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
for at in deflections {
|
||||
self.audio
|
||||
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
|
||||
@@ -3937,10 +3954,15 @@ impl Game<'_> {
|
||||
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
|
||||
}
|
||||
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
ArrowEvent::Block {
|
||||
pos,
|
||||
reaction,
|
||||
owner,
|
||||
} => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
// `TntBlock.onProjectileHit`: the arrow's owner lights it.
|
||||
if self.tnt_prime_allowed(pos) {
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.spawn_primed_tnt(pos, false, owner, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
@@ -4511,7 +4533,32 @@ impl Game<'_> {
|
||||
{
|
||||
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
|
||||
}
|
||||
if p.owner == Some(Who::Player) && self.player.is_some() {
|
||||
// To where the pearl started its last tick (oldPosition).
|
||||
let mut to = p.previous;
|
||||
let mut damage = 5.0;
|
||||
let mut cancel = false;
|
||||
// EntityTeleportEvent.EnderPearl: a server player's pearl.
|
||||
if p.owner == Some(Who::Player)
|
||||
&& let Some(from) = self.player.as_ref().map(|pl| pl.position)
|
||||
&& let (Some(uuid), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let mut target = [to.x, to.y, to.z];
|
||||
self.native_ender_pearl_teleport(
|
||||
uuid,
|
||||
&name,
|
||||
p.id,
|
||||
[from.x, from.y, from.z],
|
||||
&mut target,
|
||||
&mut damage,
|
||||
&mut cancel,
|
||||
);
|
||||
// A target that is not a number keeps the pearl's.
|
||||
if target.iter().all(|v| v.is_finite()) {
|
||||
to = Vec3::new(target[0], target[1], target[2]);
|
||||
}
|
||||
}
|
||||
if p.owner == Some(Who::Player) && self.player.is_some() && !cancel {
|
||||
// ThrownEnderpearl: 5% endermite where the player was
|
||||
// (when monsters spawn), teleport, 5 damage.
|
||||
if self.world.random.next_float() < 0.05
|
||||
@@ -4522,8 +4569,6 @@ impl Game<'_> {
|
||||
let from = pl.position;
|
||||
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
|
||||
}
|
||||
// To where the pearl started its last tick (oldPosition).
|
||||
let to = p.previous;
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.position = to;
|
||||
pl.velocity = Vec3::ZERO;
|
||||
@@ -4531,7 +4576,9 @@ impl Game<'_> {
|
||||
pl.teleported = true;
|
||||
}
|
||||
self.previous_position = to;
|
||||
self.hurt_player_by("minecraft:ender_pearl", 5.0);
|
||||
if damage > 0.0 {
|
||||
self.hurt_player_by("minecraft:ender_pearl", damage);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.player.teleport",
|
||||
Some([to.x, to.y, to.z]),
|
||||
@@ -4638,84 +4685,37 @@ impl Game<'_> {
|
||||
self.combat.explosion_in_water = false;
|
||||
result?;
|
||||
}
|
||||
(
|
||||
ProjectileKind::Potion {
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
..
|
||||
},
|
||||
_,
|
||||
) => {
|
||||
// AbstractThrownPotion.applySplash: within 4 blocks, scaled by distance.
|
||||
if let Some(pv) = self.player_view() {
|
||||
let d2 = (pv.position.x - at.x).powi(2)
|
||||
+ (pv.position.y - at.y).powi(2)
|
||||
+ (pv.position.z - at.z).powi(2);
|
||||
if d2 < 16.0 && !self.creative {
|
||||
let scale = 1.0 - d2.sqrt() / 4.0;
|
||||
let t = (f64::from(*ticks) * scale + 0.5) as i32;
|
||||
match *effect {
|
||||
"minecraft:instant_damage" => {
|
||||
// indirectMagic(potion, owner), built where the
|
||||
// hurt happens: the thrower, or the potion
|
||||
// itself when it has none. A status effect
|
||||
// deals no damage, so it records no source.
|
||||
let owner = self
|
||||
.combat
|
||||
.projectile_owner
|
||||
.filter(|id| self.mob_display_name(*id).is_some());
|
||||
match owner {
|
||||
Some(owner) => {
|
||||
self.note_attacker("minecraft:indirect_magic", owner)
|
||||
}
|
||||
None => self.note_direct_entity(
|
||||
"minecraft:indirect_magic",
|
||||
Text::translate("entity.minecraft.splash_potion", &[]),
|
||||
),
|
||||
}
|
||||
self.hurt_player_by(
|
||||
"minecraft:indirect_magic",
|
||||
6.0 * (1 << *amplifier) as f32 * scale as f32,
|
||||
)
|
||||
}
|
||||
e if t > 20 => self.give_player_effect(e, t, *amplifier),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// AbstractThrownPotion.onHit, then onHitAsPotion from the stack it
|
||||
// flies as: the splash happens where the hit was, a lingering
|
||||
// potion's cloud sits at the mob it hit (ThrownLingeringPotion),
|
||||
// and the potion that lands names the hurt it deals.
|
||||
(ProjectileKind::Potion(stack), hit) => {
|
||||
let block = match hit {
|
||||
Hit::Block { pos, face, .. } => Some((
|
||||
pos,
|
||||
Direction::approximate_nearest(Vec3::new(
|
||||
f64::from(face[0]),
|
||||
f64::from(face[1]),
|
||||
f64::from(face[2]),
|
||||
)),
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
let mob = match hit {
|
||||
Hit::Entity {
|
||||
who: Who::Player, ..
|
||||
} => self.player.as_ref().map(|pv| pv.position),
|
||||
Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} => self.mobs.iter().find(|m| m.id == id).map(|m| m.position),
|
||||
_ => None,
|
||||
};
|
||||
let owner = self.potion_owner(p.owner);
|
||||
self.splash(stack, at, mob, block, p.age, &owner);
|
||||
if stack.item.as_ref() != "minecraft:experience_bottle" {
|
||||
let item = stack.item.to_string();
|
||||
self.native_potion_splash(&item, [at.x, at.y, at.z]);
|
||||
}
|
||||
// A witch's healing potions reach the raiders around the splash.
|
||||
if matches!(
|
||||
*effect,
|
||||
"minecraft:instant_health" | "minecraft:regeneration"
|
||||
) {
|
||||
let center = at;
|
||||
for m in &mut self.mobs {
|
||||
let d2 = m.position.distance_squared(center);
|
||||
if !m.alive()
|
||||
|| d2 >= 16.0
|
||||
|| !horizon_client::mobs::raider::is_raider(m.species())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let scale = 1.0 - d2.sqrt() / 4.0;
|
||||
if *effect == "minecraft:instant_health" {
|
||||
let heal = (4 << *amplifier) as f64 * scale;
|
||||
m.health = (m.health + heal as f32).min(m.attrs.max_health);
|
||||
} else {
|
||||
let t = (f64::from(*ticks) * scale + 0.5) as i32;
|
||||
if t > 20 {
|
||||
m.add_effect(effect, t, *amplifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.splash_potion.break",
|
||||
Some(atp),
|
||||
1.0,
|
||||
self.world.random.next_float() * 0.1 + 0.9,
|
||||
);
|
||||
}
|
||||
(ProjectileKind::LlamaSpit, Hit::Entity { who, .. }) => match who {
|
||||
Who::Player => {
|
||||
@@ -6198,24 +6198,64 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Mob models and projectiles for the entity pass.
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
|
||||
let skinning = self
|
||||
/// The renderer's models for the GPU path (`mobs::skin`) this frame, if it
|
||||
/// poses entities: a new frame of draws, the store restarted when the
|
||||
/// renderer's no longer matches. Once per frame, before any entity draws.
|
||||
pub(super) fn begin_skinning(&self) {
|
||||
let stored = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.filter(|r| r.skins_entities())
|
||||
.and_then(|r| r.skinned_models());
|
||||
match skinning {
|
||||
match stored {
|
||||
Some(stored) => self
|
||||
.skin_models
|
||||
.borrow_mut()
|
||||
.begin(&mut self.skin_frame.borrow_mut(), stored),
|
||||
None => self.skin_frame.borrow_mut().clear_draws(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this frame's models can be posed on the GPU (`begin_skinning`).
|
||||
pub(super) fn skinning(&self) -> bool {
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.skins_entities() && r.skinned_models().is_some())
|
||||
}
|
||||
|
||||
/// Culls one entity's vertices from `entity_start`, counting what it drew
|
||||
/// on the GPU path by the corners of `skinned`'s bounds; a culled entity's
|
||||
/// posed draws since `skinned`'s mark are taken back.
|
||||
pub(super) fn cull_skinned(
|
||||
&self,
|
||||
out: &mut Vec<Vertex>,
|
||||
entity_start: usize,
|
||||
skinned: Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>,
|
||||
) {
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
out.extend([lo, hi].map(|pos| Vertex {
|
||||
pos: pos.to_array(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}));
|
||||
}
|
||||
let len = out.len();
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if out.len() < len {
|
||||
if let Some((mark, _)) = skinned {
|
||||
self.skin_frame.borrow_mut().truncate(mark);
|
||||
}
|
||||
} else if corners.is_some() {
|
||||
out.truncate(len - 2);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
let skinning = self.skinning();
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
@@ -6360,7 +6400,7 @@ impl Game<'_> {
|
||||
let body = out.len();
|
||||
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
|
||||
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
|
||||
let posable = skinning.is_some()
|
||||
let posable = skinning
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& outline.is_none()
|
||||
@@ -6455,22 +6495,7 @@ impl Game<'_> {
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
{
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
out.extend([lo, hi].map(|pos| Vertex {
|
||||
pos: pos.to_array(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}));
|
||||
}
|
||||
let len = out.len();
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if out.len() < len {
|
||||
if let Some((mark, _)) = skinned {
|
||||
self.skin_frame.borrow_mut().truncate(mark);
|
||||
}
|
||||
} else if corners.is_some() {
|
||||
out.truncate(len - 2);
|
||||
}
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
|
||||
@@ -7161,6 +7186,8 @@ mod pearl_tests {
|
||||
/// box (#3072), not the block at its feet.
|
||||
#[test]
|
||||
fn a_pearl_feels_the_water_its_box_touches() {
|
||||
// The throwable air drag is the float 0.99 widened to a double.
|
||||
let air = f64::from(0.99f32);
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -7207,6 +7234,7 @@ mod pearl_tests {
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
@@ -7248,7 +7276,7 @@ mod pearl_tests {
|
||||
assert!(p.position.y > 4.9, "the pearl dipped: {p:?}");
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.x - 0.3 * 0.99 * 0.99).abs() < 1e-9,
|
||||
(p.velocity.x - 0.3 * air * air).abs() < 1e-9,
|
||||
"a pearl above the surface slowed as in water: {p:?}"
|
||||
);
|
||||
// At the pool's edge: its feet over the dry block at x = 19, a side
|
||||
@@ -7257,12 +7285,12 @@ mod pearl_tests {
|
||||
let mut p = pearl(19.95, 4.3, Vec3::ZERO);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
|
||||
(p.velocity.y - (-0.03 * air)).abs() < 1e-9,
|
||||
"the first tick has the air drag: {p:?}"
|
||||
);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
|
||||
(p.velocity.y - (-0.03 * air - 0.03) * 0.8).abs() < 1e-9,
|
||||
"a pearl whose box is in the water kept the air drag: {p:?}"
|
||||
);
|
||||
// Thrown under water, a pearl moves with the air drag on its first
|
||||
@@ -7270,12 +7298,12 @@ mod pearl_tests {
|
||||
let mut p = pearl(30.5, 4.2, Vec3::ZERO);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
|
||||
(p.velocity.y - (-0.03 * air)).abs() < 1e-9,
|
||||
"the first tick under water has the air drag: {p:?}"
|
||||
);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
|
||||
(p.velocity.y - (-0.03 * air - 0.03) * 0.8).abs() < 1e-9,
|
||||
"the second tick under water has the water drag: {p:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1129,6 +1129,11 @@ impl Game<'_> {
|
||||
for b in &self.bolts {
|
||||
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
|
||||
}
|
||||
// AreaEffectCloud.getDimensions: diameter follows synced radius,
|
||||
// height is 0.5; EntityDimensions.scalable gives eyes at 85% height.
|
||||
for c in &self.potions.clouds {
|
||||
g.hitbox(c.at, (c.radius * 2.0, 0.5), 0.425, false, (0.0, 0.0));
|
||||
}
|
||||
// The dragon: 16 by 8, a living entity; its parts follow.
|
||||
if let Some(d) = &self.dragon {
|
||||
g.hitbox(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `ProjectileDeflection.AIM_DEFLECT` instead of hurting anything. It flies on
|
||||
//! along the player's view and is the player's now (`Projectile.deflect`
|
||||
//! sets the owner), so a ghast it meets dies of it (`Ghast.isReflectedFireball`).
|
||||
use super::{Game, ray_box, view_vector};
|
||||
use super::{Game, lan::LanHost, ray_box, view_vector};
|
||||
use horizon_client::{
|
||||
mobs::Who,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
@@ -125,7 +125,7 @@ impl Game<'_> {
|
||||
self.creatures.projectiles[i].kind,
|
||||
ProjectileKind::ShulkerBullet { .. }
|
||||
) {
|
||||
self.hit_shulker_bullet(i);
|
||||
self.hit_shulker_bullet(None, i);
|
||||
} else {
|
||||
self.deflect(i);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ impl Game<'_> {
|
||||
/// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit
|
||||
/// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is
|
||||
/// `Player.attack` on a target that is not a mob: its sounds, then the swing.
|
||||
pub(super) fn hit_shulker_bullet(&mut self, i: usize) {
|
||||
pub(super) fn hit_shulker_bullet(&mut self, host: Option<&LanHost>, i: usize) {
|
||||
let p = self.creatures.projectiles.remove(i);
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio
|
||||
@@ -144,7 +144,7 @@ impl Game<'_> {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15);
|
||||
self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
self.lan_level_particles(host, &options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
}
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false);
|
||||
self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32));
|
||||
|
||||
@@ -516,6 +516,22 @@ impl Game<'_> {
|
||||
match part {
|
||||
Part::Line(text, x, y) => {
|
||||
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
|
||||
// AbstractStringWidget: only the styled glyph under the
|
||||
// pointer requests a hand, not the whole message widget.
|
||||
if (!clipped || in_area(cursor.1))
|
||||
&& let Some(style) = horizon_client::dialog_screen::line_style_at(
|
||||
&atlas.font,
|
||||
&text,
|
||||
x,
|
||||
y,
|
||||
cursor,
|
||||
)
|
||||
{
|
||||
widgets::widget_cursor(style.click.is_some(), true);
|
||||
if let Some(hover) = style.hover {
|
||||
tooltip = Some(*hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
Part::Field { input, rect } => {
|
||||
let focused = open.focus == Some(input);
|
||||
@@ -824,20 +840,15 @@ impl Game<'_> {
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Part::Line(line, x, y) if text_click.is_none() => {
|
||||
text_click = horizon_client::dialog_screen::line_style_at(
|
||||
&self.gui.atlas.font,
|
||||
&line,
|
||||
x,
|
||||
y,
|
||||
cursor,
|
||||
)
|
||||
.and_then(|style| style.click);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -851,6 +862,7 @@ impl Game<'_> {
|
||||
Some(ClickEvent::RunCommand(command)) => Click::RunCommand(command),
|
||||
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
|
||||
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
|
||||
Some(ClickEvent::Custom { id, payload }) => Click::Custom { id, payload },
|
||||
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
|
||||
};
|
||||
let after = open.dialog.after_action;
|
||||
|
||||
@@ -139,6 +139,16 @@ pub(super) struct Crystal {
|
||||
/// `setPermanentlyInvulnerable` (respawned spike crystals).
|
||||
pub invulnerable: bool,
|
||||
}
|
||||
impl Crystal {
|
||||
/// `EntityType.END_CRYSTAL`: 2 x 2.
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 1.0, p.y, p.z - 1.0),
|
||||
Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Path {
|
||||
nodes: Vec<[i32; 3]>,
|
||||
@@ -635,6 +645,40 @@ impl Dragon {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` knockback on the dragon itself:
|
||||
/// `pushFromExplosion` adds `(1 - dist) * exposure` along the blast to
|
||||
/// its eye to the motion (`Entity.push`); the parts are not pushed.
|
||||
fn whole_box(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
)
|
||||
}
|
||||
fn explosion_push(&mut self, center: [f64; 3], radius: f32, exposure: f32) {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
let p = self.position;
|
||||
let at = |y: f64| [p.x - center[0], y - center[1], p.z - center[2]];
|
||||
let len = |v: [f64; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
let dist = len(at(p.y)) / diameter;
|
||||
if dist > 1.0 {
|
||||
return;
|
||||
}
|
||||
// The dragon's eye is 0.85 of its 8 block height, in floats.
|
||||
let away = at(p.y + f64::from(8.0f32 * 0.85f32));
|
||||
let l = len(away);
|
||||
// `Vec3.normalize` gives zero below 1.0E-5F, then `scale(power)`.
|
||||
if l < 9.999_999_747_378_752e-6 {
|
||||
return;
|
||||
}
|
||||
let dir = [away[0] / l, away[1] / l, away[2] / l];
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x + dir[0] * power,
|
||||
self.velocity.y + dir[1] * power,
|
||||
self.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
/// `DragonHoldingPatternPhase.onCrystalDestroyed`: strafe `player` if
|
||||
/// the dragon can attack them (`EnderDragon.canAttack`, on Peaceful too).
|
||||
/// The first such crystal wins: the dragon strafes from then on, and
|
||||
@@ -2144,6 +2188,27 @@ impl Game<'_> {
|
||||
set(d, 3 + i, x, y, z);
|
||||
}
|
||||
}
|
||||
/// `Level.getEntity`: revenge goals must resolve the dragon too.
|
||||
pub(super) fn dragon_target(&self) -> Option<(u32, horizon_client::mobs::TargetInfo)> {
|
||||
let d = self.dragon.as_ref().filter(|d| d.alive())?;
|
||||
let p = d.position;
|
||||
Some((
|
||||
d.id,
|
||||
horizon_client::mobs::TargetInfo {
|
||||
position: p,
|
||||
eye_y: p.y + 6.8,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
),
|
||||
alive: true,
|
||||
can_be_seen_as_enemy: true,
|
||||
is_player: false,
|
||||
creative: false,
|
||||
passenger: false,
|
||||
},
|
||||
))
|
||||
}
|
||||
/// Wing knockback, head and neck damage, and breaking blocks in the way.
|
||||
fn dragon_contacts(&mut self) {
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
@@ -2188,6 +2253,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// EnderDragon.aiStep queries living entities, not only players.
|
||||
if let Some(d) = self.dragon.as_ref() {
|
||||
let frame = horizon_client::mobs::dragon_contacts::Frame {
|
||||
dragon_id: d.id,
|
||||
origin: d.position,
|
||||
center: (bc, bz),
|
||||
wings: wings.map(|w| {
|
||||
w.inflate(Vec3::new(4.0, 2.0, 4.0))
|
||||
.move_by(Vec3::new(0.0, -2.0, 0.0))
|
||||
}),
|
||||
heads: heads.map(|h| h.inflate(Vec3::new(1.0, 1.0, 1.0))),
|
||||
sitting,
|
||||
recently_hurt,
|
||||
};
|
||||
// Keep team lookup in Game (including tamed pets' owner teams).
|
||||
let allowed: Vec<bool> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
horizon_client::scoreboard::teams_allow_damage(
|
||||
self.mob_team(d.id),
|
||||
self.mob_team_of(m),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (mob, allowed) in self.mobs.iter_mut().zip(allowed) {
|
||||
horizon_client::mobs::dragon_contacts::contact(mob, &frame, allowed);
|
||||
}
|
||||
}
|
||||
// checkWalls over head, neck and body.
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
let mut in_wall = false;
|
||||
@@ -3172,9 +3266,15 @@ impl Game<'_> {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
});
|
||||
let whole = d.whole_box();
|
||||
let (lo, hi) = (whole.min_position(), whole.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
for (part, amount) in hits {
|
||||
self.hurt_dragon(part, amount, true, by, DamageKind::Explosion);
|
||||
}
|
||||
if let Some(d) = self.dragon.as_mut().filter(|d| d.alive()) {
|
||||
d.explosion_push(center, radius, exposure);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -3210,6 +3310,8 @@ impl Game<'_> {
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
@@ -4555,6 +4657,21 @@ mod tests {
|
||||
assert_eq!(d.sitting_damage, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explosion_pushes_the_dragon_along_the_blast() {
|
||||
let mut d = dragon(flying());
|
||||
d.position = Vec3::new(0.0, 70.0, 0.0);
|
||||
d.velocity = Vec3::ZERO;
|
||||
// Power 5 right under the dragon: it is thrown up, away from the blast.
|
||||
d.explosion_push([0.0, 66.0, 0.0], 5.0, 1.0);
|
||||
assert!(d.velocity.y > 0.0 && d.velocity.x == 0.0 && d.velocity.z == 0.0);
|
||||
let up = d.velocity.y;
|
||||
// Out of reach or fully covered: no push.
|
||||
d.explosion_push([0.0, 40.0, 0.0], 5.0, 1.0);
|
||||
d.explosion_push([0.0, 66.0, 0.0], 5.0, 0.0);
|
||||
assert_eq!(d.velocity.y, up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explosions_hit_each_part_by_its_exposure() {
|
||||
let mut d = dragon(flying());
|
||||
|
||||
@@ -12,57 +12,95 @@ impl Game<'_> {
|
||||
self.luminance
|
||||
.get_or_init(|| Luminance::new(self.res.facts(), self.pack.assets.registry))
|
||||
}
|
||||
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
|
||||
let mut out = std::mem::take(&mut self.dynamic_lights);
|
||||
out.clear();
|
||||
/// The light sources this frame while the extra is on: the player's and mobs' held items and
|
||||
/// fire, and glowing dropped items, each with the item that gives the light (none for fire).
|
||||
pub(super) fn dynamic_light_sources(&self, alpha: f64) -> Vec<(Source, Option<String>)> {
|
||||
let mut sources = Vec::new();
|
||||
if !self.settings.extras.dynamic_lights {
|
||||
self.dynamic_lights = out;
|
||||
return;
|
||||
return sources;
|
||||
}
|
||||
let lum = self.luminance();
|
||||
let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i));
|
||||
let mut sources = Vec::new();
|
||||
// The brighter of two hands with what it holds, or fire if it outshines both.
|
||||
let brighter = |items: [Option<&str>; 2], on_fire: bool| {
|
||||
let (level, item) = items
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|i| (lum.of(i), Some(i)))
|
||||
.max_by_key(|(level, _)| *level)
|
||||
.unwrap_or((0, None));
|
||||
if on_fire && BURNING > level {
|
||||
(BURNING, None)
|
||||
} else {
|
||||
// Only a lit source names its item: nothing is allocated for a dark one.
|
||||
(level, item.filter(|_| level > 0).map(str::to_owned))
|
||||
}
|
||||
};
|
||||
if let Some(p) = &self.player
|
||||
&& !p.spectator
|
||||
{
|
||||
let feet = self
|
||||
.previous_position
|
||||
.lerp(p.position, self.clock.camera_fraction());
|
||||
let level = held(self.stack_in(self.selected).map(|s| &*s.item))
|
||||
.max(held(self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item)))
|
||||
.max(if p.fire_ticks > 0 { BURNING } else { 0 });
|
||||
let (level, item) = brighter(
|
||||
[
|
||||
self.stack_in(self.selected).map(|s| &*s.item),
|
||||
self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item),
|
||||
],
|
||||
p.fire_ticks > 0,
|
||||
);
|
||||
// About where the hands are.
|
||||
sources.push(Source {
|
||||
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
|
||||
level,
|
||||
});
|
||||
sources.push((
|
||||
Source {
|
||||
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
));
|
||||
}
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
let level = held(m.extra.main_hand.as_deref())
|
||||
.max(held(m.extra.off_hand.as_deref()))
|
||||
.max(if m.fire_ticks > 0 { BURNING } else { 0 });
|
||||
let (level, item) = brighter(
|
||||
[m.extra.main_hand.as_deref(), m.extra.off_hand.as_deref()],
|
||||
m.fire_ticks > 0,
|
||||
);
|
||||
if level == 0 {
|
||||
continue;
|
||||
}
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
sources.push(Source {
|
||||
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
|
||||
level,
|
||||
});
|
||||
sources.push((
|
||||
Source {
|
||||
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
));
|
||||
}
|
||||
for item in &self.dropped {
|
||||
let level = lum
|
||||
.of(&item.stack.item)
|
||||
.max(if item.fire_ticks > 0 { BURNING } else { 0 });
|
||||
for dropped in &self.dropped {
|
||||
let (level, item) =
|
||||
brighter([Some(&*dropped.stack.item), None], dropped.fire_ticks > 0);
|
||||
if level == 0 {
|
||||
continue;
|
||||
}
|
||||
let at = item.previous.lerp(item.position, alpha);
|
||||
sources.push(Source {
|
||||
at: DVec3::new(at.x, at.y + 0.25, at.z),
|
||||
level,
|
||||
});
|
||||
let at = dropped.previous.lerp(dropped.position, alpha);
|
||||
sources.push((
|
||||
Source {
|
||||
at: DVec3::new(at.x, at.y + 0.25, at.z),
|
||||
level,
|
||||
},
|
||||
item,
|
||||
));
|
||||
}
|
||||
sources
|
||||
}
|
||||
/// Fills `dynamic_lights` for the frame from [`Self::dynamic_light_sources`], the nearest
|
||||
/// [`dynamic_light::MAX`] of them.
|
||||
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
|
||||
let mut out = std::mem::take(&mut self.dynamic_lights);
|
||||
out.clear();
|
||||
let sources: Vec<Source> = self
|
||||
.dynamic_light_sources(alpha)
|
||||
.into_iter()
|
||||
.map(|(s, _)| s)
|
||||
.collect();
|
||||
dynamic_light::select(&sources, camera, &mut out);
|
||||
self.dynamic_lights = out;
|
||||
}
|
||||
|
||||
@@ -7,22 +7,56 @@
|
||||
//! `Game` end. The walk allocates nothing and dispatch is static: hot paths pay
|
||||
//! no vtable. A new kind is one line in `entity_lists!` and one `impl Entity`.
|
||||
|
||||
use super::commands::Target;
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{
|
||||
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
|
||||
};
|
||||
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::mobs::{Mob, Species};
|
||||
use horizon_client::projectiles::Projectile;
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{level::data::PushReaction, piston_push};
|
||||
|
||||
/// What `Entity.thunderHit` does to a kind: it sets the entity on fire and
|
||||
/// calls `hurtServer(lightning_bolt, 5)`, so a kind whose `hurtServer` does
|
||||
/// nothing (projectiles, rockets, TNT, falling blocks, armor stands here) stays
|
||||
/// untouched. A strike runs the stages in this order.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum ThunderHit {
|
||||
Untouched,
|
||||
/// Burns, takes 5 damage, and the kinds with their own `thunderHit` change.
|
||||
Mob,
|
||||
/// `VehicleEntity.hurtServer`: 5 x 10 is over the 40 that destroys it.
|
||||
Vehicle,
|
||||
/// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
|
||||
Orb,
|
||||
/// `ItemEntity.hurtServer`: burns up unless fire resistant.
|
||||
Item,
|
||||
}
|
||||
|
||||
/// What a blast knows about itself, for the entities in its reach to answer.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Blast {
|
||||
/// `VehicleEntity.ignoreExplosion`: a mob's blast, with mob griefing off.
|
||||
pub(super) vehicles_ignore: bool,
|
||||
/// `ItemEntity.ignoreExplosion` (`shouldAffectBlocklikeEntities`).
|
||||
pub(super) items_ignore: bool,
|
||||
/// The exploding mob, which `getEntities(source, box)` leaves out.
|
||||
pub(super) spared: Option<u32>,
|
||||
}
|
||||
|
||||
pub(super) trait Entity {
|
||||
/// `Entity.getId`.
|
||||
fn id(&self) -> u32;
|
||||
fn position(&self) -> Vec3;
|
||||
fn position_mut(&mut self) -> &mut Vec3;
|
||||
fn velocity_mut(&mut self) -> &mut Vec3;
|
||||
fn bounds(&self) -> Aabb;
|
||||
/// What a selector finds this entity as (`@e`), or `None` for one it cannot
|
||||
/// name yet. Required, so a new kind states which.
|
||||
fn selector_target(&self) -> Option<Target>;
|
||||
|
||||
fn on_ground(&self) -> bool {
|
||||
false
|
||||
@@ -34,6 +68,68 @@ pub(super) trait Entity {
|
||||
fn is_alive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `Entity.isRemoved`.
|
||||
fn is_removed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `instanceof LivingEntity`.
|
||||
fn is_living(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `Entity.isIgnoringBlockTriggers`.
|
||||
fn is_ignoring_block_triggers(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Whether it presses plates and tripwires: `getEntitiesOfClass` skips
|
||||
/// removed entities, and the block's predicate skips the ones above.
|
||||
fn triggers_blocks(&self) -> bool {
|
||||
!self.is_removed() && !self.is_ignoring_block_triggers()
|
||||
}
|
||||
/// Whether `tick_blocks` sends the blocks in its box to `entityInside`
|
||||
/// (`applyEffectsFromBlocks`). A kind that function sweeps another way, or
|
||||
/// whose tick skips that step, says no.
|
||||
fn sweeps_blocks_inside(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `Entity.thunderHit` for this kind.
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Untouched
|
||||
}
|
||||
/// The box a lightning strike tests against.
|
||||
fn thunder_bounds(&self) -> Aabb {
|
||||
self.bounds()
|
||||
}
|
||||
/// `Entity.ignoreExplosion`: the blast neither hurts nor pushes it. Also
|
||||
/// answers for what `getEntities(source, box)` never lists: the exploding
|
||||
/// mob, and the dead.
|
||||
fn ignores_explosion(&self, _blast: &Blast) -> bool {
|
||||
false
|
||||
}
|
||||
/// Whether a burst of wind moves it: what `Entity.push` moves, unless the
|
||||
/// kind says otherwise.
|
||||
fn pushed_by_wind(&self) -> bool {
|
||||
self.pushed_by_explosion()
|
||||
}
|
||||
/// Whether `Entity.push` moves it. An arrow in the ground, a wind charge and
|
||||
/// an armour stand (`explosion_breaks_decorations` takes those) stay put.
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The box the blast casts its rays at: `getSeenPercent`.
|
||||
fn explosion_box(&self) -> Aabb {
|
||||
self.bounds()
|
||||
}
|
||||
/// How far above its feet `pushFromExplosion` aims: `getEyeHeight`, which
|
||||
/// defaults to 0.85 of the box.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
let b = self.explosion_box();
|
||||
f64::from((b.max_position().y - b.min_position().y) as f32 * 0.85f32)
|
||||
}
|
||||
/// `Entity.push` with the explosion's knockback.
|
||||
fn push_from_explosion(&mut self, push: Vec3) {
|
||||
let v = self.velocity_mut();
|
||||
*v = Vec3::new(v.x + push.x, v.y + push.y, v.z + push.z);
|
||||
}
|
||||
/// `Entity.isSupportedBy`.
|
||||
fn is_supported_by(&self, _pos: BlockPos) -> bool {
|
||||
false
|
||||
@@ -42,6 +138,28 @@ pub(super) trait Entity {
|
||||
fn get_piston_push_reaction(&self) -> PushReaction {
|
||||
PushReaction::PushPull
|
||||
}
|
||||
/// Width and height of the box a portal sweep follows, or None for a kind
|
||||
/// the game does not carry through portals (so far armor stands and
|
||||
/// falling blocks; fishing hooks are off the walk, and
|
||||
/// `FishingHook.canUsePortal` is false in vanilla).
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
None
|
||||
}
|
||||
/// `Entity.canUsePortal(false)` for a kind in `portal_size`. A kind that
|
||||
/// cannot stays tracked, so its cooldown and process run out.
|
||||
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
|
||||
true
|
||||
}
|
||||
/// Where it stood at the start of its tick, for the kinds that store it
|
||||
/// (None: `portals.before` has it).
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
None
|
||||
}
|
||||
/// Whether this tick's movement can have entered a portal block: a stuck
|
||||
/// arrow or a trident in the ground that did not move enters none.
|
||||
fn portal_sweeps(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `Entity.move(MoverType.PISTON, …)` once the movement is resolved.
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
let position = self.position_mut();
|
||||
@@ -58,6 +176,9 @@ pub(super) trait Entity {
|
||||
/// The fields every kind stores under the same names.
|
||||
macro_rules! stored_motion {
|
||||
() => {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn position(&self) -> Vec3 {
|
||||
self.position
|
||||
}
|
||||
@@ -76,37 +197,66 @@ macro_rules! entity_lists {
|
||||
($then:ident, $($args:tt)*) => {
|
||||
$crate::game::entity::$then! {
|
||||
($($args)*)
|
||||
Mob: mobs,
|
||||
Item: dropped,
|
||||
Boat: boats,
|
||||
Cart: carts,
|
||||
ArmorStand: armor_stands,
|
||||
Tnt: tnts,
|
||||
Falling: falling,
|
||||
Orb: orbs,
|
||||
Projectile: creatures.projectiles,
|
||||
Rocket: rockets,
|
||||
Trident: tridents,
|
||||
Mob(Mob) mobs: mobs,
|
||||
Item(ItemEntity) dropped: dropped,
|
||||
Boat(Boat) boats: boats,
|
||||
Cart(Minecart) carts: carts,
|
||||
ArmorStand(ArmorStand) armor_stands: armor_stands,
|
||||
Tnt(PrimedTnt) tnts: tnts,
|
||||
Falling(FallingBlock) falling: falling,
|
||||
Orb(ExperienceOrb) orbs: orbs,
|
||||
Projectile(Projectile) projectiles: creatures.projectiles,
|
||||
Rocket(Rocket) rockets: rockets,
|
||||
Trident(ThrownTrident) tridents: tridents,
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use entity_lists;
|
||||
|
||||
macro_rules! define_entity_handles {
|
||||
(() $($kind:ident: $($list:ident).+,)*) => {
|
||||
(() $($kind:ident($ty:ty) $field:ident: $($list:ident).+,)*) => {
|
||||
/// Every list, borrowed apart from the rest of `Game`, for
|
||||
/// [`for_each_entity`] while the world is borrowed mutably.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct EntityLists<'w> {
|
||||
$(pub(super) $field: &'w [$ty],)*
|
||||
}
|
||||
|
||||
/// One entity, by list and index.
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||
pub(super) enum EntityHandle {
|
||||
$($kind(usize),)*
|
||||
}
|
||||
|
||||
/// Every list moved out of `Game`, for [`for_each_entity_mut`]: the
|
||||
/// world stays readable while the entities change.
|
||||
pub(super) struct TakenEntities {
|
||||
$(pub(super) $field: Vec<$ty>,)*
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Moves every list out; give it back with `restore_entities`.
|
||||
pub(super) fn take_entities(&mut self) -> TakenEntities {
|
||||
TakenEntities {
|
||||
$($field: std::mem::take(&mut self$(.$list)+),)*
|
||||
}
|
||||
}
|
||||
pub(super) fn restore_entities(&mut self, taken: TakenEntities) {
|
||||
$(self$(.$list)+ = taken.$field;)*
|
||||
}
|
||||
/// Every entity's handle: lengths read now, nothing allocated, and
|
||||
/// no borrow of `Game` held, so the caller can change it mid-walk.
|
||||
pub(super) fn entity_handles(&self) -> impl Iterator<Item = EntityHandle> + use<> {
|
||||
std::iter::empty()
|
||||
$(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))*
|
||||
}
|
||||
|
||||
/// [`Self::entity_handles`], last of each list first, so the
|
||||
/// caller can remove the entity it is on.
|
||||
pub(super) fn entity_handles_rev(&self) -> impl Iterator<Item = EntityHandle> + use<> {
|
||||
std::iter::empty()
|
||||
$(.chain((0..self$(.$list)+.len()).rev().map(EntityHandle::$kind)))*
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -126,7 +276,7 @@ pub(crate) use entity_borrow;
|
||||
|
||||
macro_rules! match_entity {
|
||||
(($borrow:ident, $entity:ident, $game:expr, $handle:expr, $body:expr)
|
||||
$($kind:ident: $($list:ident).+,)*) => {
|
||||
$($kind:ident($ty:ty) $field:ident: $($list:ident).+,)*) => {
|
||||
match $handle {
|
||||
$($crate::game::entity::EntityHandle::$kind(i) => {
|
||||
let $entity = $crate::game::entity::entity_borrow!($borrow, $game$(.$list)+[i]);
|
||||
@@ -146,6 +296,58 @@ macro_rules! with_entity {
|
||||
}
|
||||
pub(super) use with_entity;
|
||||
|
||||
macro_rules! borrow_lists {
|
||||
(($game:expr) $($kind:ident($ty:ty) $field:ident: $($list:ident).+,)*) => {
|
||||
$crate::game::entity::EntityLists {
|
||||
$($field: &$game$(.$list)+,)*
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use borrow_lists;
|
||||
|
||||
/// `$game`'s [`EntityLists`]; a macro so the borrow stays field by field.
|
||||
macro_rules! entity_lists_of {
|
||||
($game:expr) => {
|
||||
$crate::game::entity::entity_lists!(borrow_lists, $game)
|
||||
};
|
||||
}
|
||||
pub(super) use entity_lists_of;
|
||||
|
||||
macro_rules! walk_lists {
|
||||
(($lists:expr, $entity:ident, $body:block)
|
||||
$($kind:ident($ty:ty) $field:ident: $($list:ident).+,)*) => {
|
||||
$(for $entity in $lists.$field $body)*
|
||||
};
|
||||
}
|
||||
pub(crate) use walk_lists;
|
||||
|
||||
/// Runs `$body` for every entity, with `$entity` as a reference to its own
|
||||
/// type: one loop per list, each compiled for its kind, so nothing allocates
|
||||
/// or goes through a vtable and a check a kind answers with a constant folds
|
||||
/// away. A kind added to `entity_lists!` joins every walk.
|
||||
macro_rules! for_each_entity {
|
||||
($lists:expr, $entity:ident => $body:block) => {
|
||||
$crate::game::entity::entity_lists!(walk_lists, $lists, $entity, $body)
|
||||
};
|
||||
}
|
||||
pub(super) use for_each_entity;
|
||||
|
||||
macro_rules! walk_lists_mut {
|
||||
(($lists:expr, $entity:ident, $body:block)
|
||||
$($kind:ident($ty:ty) $field:ident: $($list:ident).+,)*) => {
|
||||
$(for $entity in $lists.$field.iter_mut() $body)*
|
||||
};
|
||||
}
|
||||
pub(crate) use walk_lists_mut;
|
||||
|
||||
/// [`for_each_entity`] over [`TakenEntities`], with the entity mutable.
|
||||
macro_rules! for_each_entity_mut {
|
||||
($lists:expr, $entity:ident => $body:block) => {
|
||||
$crate::game::entity::entity_lists!(walk_lists_mut, $lists, $entity, $body)
|
||||
};
|
||||
}
|
||||
pub(super) use for_each_entity_mut;
|
||||
|
||||
/// [`with_entity`], with the entity mutable.
|
||||
macro_rules! with_entity_mut {
|
||||
($game:expr, $handle:expr, $entity:ident => $body:expr) => {
|
||||
@@ -156,6 +358,15 @@ pub(super) use with_entity_mut;
|
||||
|
||||
impl Entity for Mob {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((self.width(), self.height()))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.mob_can_use_portal(self)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
(self.death_time == 0).then_some(Target::Mob(self.id))
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Mob::bounds(self)
|
||||
}
|
||||
@@ -168,13 +379,80 @@ impl Entity for Mob {
|
||||
fn is_alive(&self) -> bool {
|
||||
!self.removed && self.alive()
|
||||
}
|
||||
fn is_removed(&self) -> bool {
|
||||
self.removed
|
||||
}
|
||||
fn is_living(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `Bat.isIgnoringBlockTriggers`.
|
||||
fn is_ignoring_block_triggers(&self) -> bool {
|
||||
self.species() == Species::Bat
|
||||
}
|
||||
/// `Warden.ignoreExplosion`: while it digs or emerges.
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
!self.alive()
|
||||
|| blast.spared == Some(self.id)
|
||||
|| (self.species() == Species::Warden
|
||||
&& horizon_client::mobs::warden::digging_or_emerging(self))
|
||||
}
|
||||
/// The kind's own box, not the baby's or the slime's.
|
||||
fn explosion_box(&self) -> Aabb {
|
||||
let (feet, half) = (self.position, self.kind.width / 2.0);
|
||||
Aabb::new(
|
||||
Vec3::new(feet.x - half, feet.y, feet.z - half),
|
||||
Vec3::new(feet.x + half, feet.y + self.kind.height, feet.z + half),
|
||||
)
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
self.kind.eye_height
|
||||
}
|
||||
/// `Entity.push` sets `needsSync`.
|
||||
fn push_from_explosion(&mut self, push: Vec3) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x + push.x,
|
||||
self.velocity.y + push.y,
|
||||
self.velocity.z + push.z,
|
||||
);
|
||||
self.needs_sync = true;
|
||||
}
|
||||
fn is_supported_by(&self, pos: BlockPos) -> bool {
|
||||
self.supporting == Some([pos.x, pos.y, pos.z])
|
||||
}
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
if self.alive() {
|
||||
ThunderHit::Mob
|
||||
} else {
|
||||
ThunderHit::Untouched
|
||||
}
|
||||
}
|
||||
/// The kind's own size, as the strike has always read it.
|
||||
fn thunder_bounds(&self) -> Aabb {
|
||||
let (p, w) = (self.position, self.kind.width / 2.0);
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - w, p.y, p.z - w),
|
||||
Vec3::new(p.x + w, p.y + self.kind.height, p.z + w),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ItemEntity {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.25, 0.25))
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Item(self.id))
|
||||
}
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Item
|
||||
}
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
blast.items_ignore
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.2125
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
ItemEntity::bounds(self)
|
||||
}
|
||||
@@ -195,6 +473,18 @@ impl Entity for ItemEntity {
|
||||
|
||||
impl Entity for ExperienceOrb {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.5, 0.5))
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Orb(self.id))
|
||||
}
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Orb
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
f64::from(0.5f32 * 0.85f32)
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
ExperienceOrb::bounds(self)
|
||||
}
|
||||
@@ -215,6 +505,25 @@ impl Entity for ExperienceOrb {
|
||||
|
||||
impl Entity for Boat {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((super::boats::WIDTH as f32, super::boats::HEIGHT as f32))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.vehicle_can_use_portal(self.id)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Boat(self.id))
|
||||
}
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Vehicle
|
||||
}
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
blast.vehicles_ignore
|
||||
}
|
||||
/// `EntityType.OAK_BOAT & co`: the full 0.5625.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.5625
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Boat::bounds(self)
|
||||
}
|
||||
@@ -225,22 +534,96 @@ impl Entity for Boat {
|
||||
|
||||
impl Entity for Minecart {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.vehicle_can_use_portal(self.id)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Cart(self.id))
|
||||
}
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Vehicle
|
||||
}
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
blast.vehicles_ignore
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Minecart::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
/// `tick_blocks` sweeps minecarts along with the players.
|
||||
fn sweeps_blocks_inside(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Width and height `EntityType.ARMOR_STAND` gives a stand.
|
||||
fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
|
||||
if stand.marker {
|
||||
(0.0, 0.0)
|
||||
} else if stand.small {
|
||||
(0.25, f64::from(0.9875f32))
|
||||
} else {
|
||||
(0.5, f64::from(1.975f32))
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ArmorStand {
|
||||
stored_motion!();
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Stand(self.id))
|
||||
}
|
||||
/// `ArmorStand.ignoreExplosion`: a visible stand feels only a burst that
|
||||
/// affects block-like entities (`items_ignore` is that flag, negated).
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
self.invisible || blast.items_ignore
|
||||
}
|
||||
/// `EntityType.ARMOR_STAND`'s size, halved when small; a marker has none.
|
||||
fn explosion_box(&self) -> Aabb {
|
||||
let (w, h) = armor_stand_size(self);
|
||||
let half = w / 2.0;
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - half, p.y, p.z - half),
|
||||
Vec3::new(p.x + half, p.y + h, p.z + half),
|
||||
)
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
if self.marker {
|
||||
0.0
|
||||
} else if self.small {
|
||||
f64::from(0.9875f32)
|
||||
} else {
|
||||
f64::from(1.7775f32)
|
||||
}
|
||||
}
|
||||
/// A burst of wind pushes a stand that a blast only breaks.
|
||||
fn pushed_by_wind(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
ArmorStand::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn is_living(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `ArmorStand.isIgnoringBlockTriggers`.
|
||||
fn is_ignoring_block_triggers(&self) -> bool {
|
||||
self.marker
|
||||
}
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `ArmorStand.getPistonPushReaction`: a marker is ignored.
|
||||
fn get_piston_push_reaction(&self) -> PushReaction {
|
||||
if self.marker {
|
||||
@@ -253,6 +636,20 @@ impl Entity for ArmorStand {
|
||||
|
||||
impl Entity for PrimedTnt {
|
||||
stored_motion!();
|
||||
/// `PrimedTnt` is 0.98 across.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.98, 0.98))
|
||||
}
|
||||
fn can_use_portal(&self, game: &Game<'_>) -> bool {
|
||||
game.tnt_can_use_portal(self.id)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Tnt(self.id))
|
||||
}
|
||||
/// Pushed from its position, not its eyes.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
PrimedTnt::bounds(self)
|
||||
}
|
||||
@@ -269,6 +666,9 @@ impl Entity for PrimedTnt {
|
||||
|
||||
impl Entity for FallingBlock {
|
||||
stored_motion!();
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Falling(self.id))
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
horizon_client::falling_block::bounds(self.position)
|
||||
}
|
||||
@@ -276,6 +676,44 @@ impl Entity for FallingBlock {
|
||||
|
||||
impl Entity for Projectile {
|
||||
stored_motion!();
|
||||
/// Arrows and the thrown items and fireballs a chunk saves.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
(super::nether_portal::is_arrow(self) || super::nether_portal::is_portal_projectile(self))
|
||||
.then(|| super::nether_portal::projectile_box(self))
|
||||
}
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
Some(self.previous)
|
||||
}
|
||||
fn portal_sweeps(&self) -> bool {
|
||||
!super::nether_portal::is_arrow(self) || !self.stuck || self.previous != self.position
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
// The kinds /data can write (entity_nbt).
|
||||
let selectable = self.is_arrow()
|
||||
|| horizon_client::entity_nbt::saved_with_chunk(self)
|
||||
|| matches!(
|
||||
self.kind,
|
||||
K::LargeFireball { .. } | K::SmallFireball | K::WindCharge | K::BreezeWindCharge
|
||||
);
|
||||
selectable.then_some(Target::Projectile(self.id))
|
||||
}
|
||||
/// `AbstractArrow.push` does nothing in the ground; a wind charge's motion
|
||||
/// stays 0.
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
!self.stuck && !self.kind.ignores_blast()
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
self.kind.eye_height()
|
||||
}
|
||||
fn push_from_explosion(&mut self, push: Vec3) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x + push.x,
|
||||
self.velocity.y + push.y,
|
||||
self.velocity.z + push.z,
|
||||
);
|
||||
self.needs_sync = true;
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Projectile::bounds(self)
|
||||
}
|
||||
@@ -291,13 +729,52 @@ impl Entity for Projectile {
|
||||
|
||||
impl Entity for Rocket {
|
||||
stored_motion!();
|
||||
/// A rocket that carries its owner stays.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
(self.attached_to.is_none() && !self.attached).then_some((0.25, 0.25))
|
||||
}
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
Some(self.previous)
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Rocket(self.id))
|
||||
}
|
||||
/// 0.25 wide, so 0.2125 to the eyes.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.2125
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Rocket::bounds(self)
|
||||
}
|
||||
/// `FireworkRocketEntity.tick`: boosting a player skips the step.
|
||||
fn sweeps_blocks_inside(&self) -> bool {
|
||||
!self.attached
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.5, 0.5))
|
||||
}
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
Some(self.previous)
|
||||
}
|
||||
fn portal_sweeps(&self) -> bool {
|
||||
!self.in_ground || self.previous != self.position
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
// No `Target` variant for it yet: `@e` does not find tridents.
|
||||
None
|
||||
}
|
||||
/// Like any arrow (`AbstractArrow.push`) not pushed while in the ground.
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
!self.in_ground
|
||||
}
|
||||
/// `EntityType.TRIDENT`: eyes at 0.13.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
f64::from(0.13_f32)
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
@@ -305,4 +782,8 @@ impl Entity for ThrownTrident {
|
||||
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
|
||||
)
|
||||
}
|
||||
/// `GameLevel::tick_blocks` sweeps tridents itself.
|
||||
fn sweeps_blocks_inside(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,10 @@ fn optimized(root: &Path) -> BTreeMap<String, Part> {
|
||||
} else {
|
||||
"armor_stand"
|
||||
};
|
||||
models.insert(format!("{model}/{slot}"), part);
|
||||
models.insert(
|
||||
format!("{model}/{slot}"),
|
||||
std::sync::Arc::unwrap_or_clone(part),
|
||||
);
|
||||
}
|
||||
for (slot, part) in armor.player {
|
||||
models.insert(format!("player/{slot}"), part);
|
||||
@@ -153,7 +156,10 @@ fn optimized(root: &Path) -> BTreeMap<String, Part> {
|
||||
("armor_stand_small", armor.small),
|
||||
] {
|
||||
if let Some(part) = part {
|
||||
models.insert(format!("{model}/main"), part);
|
||||
models.insert(
|
||||
format!("{model}/main"),
|
||||
std::sync::Arc::unwrap_or_clone(part),
|
||||
);
|
||||
}
|
||||
}
|
||||
models
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! HashSet, so iteration and the following shuffle match vanilla's random
|
||||
//! draws), entity damage with exposure and knockback, block interaction with
|
||||
//! merged drops and TNT chain priming, and optional fire.
|
||||
use super::Game;
|
||||
use super::{
|
||||
Game,
|
||||
entity::{Blast, Entity, EntityHandle, with_entity, with_entity_mut},
|
||||
};
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
|
||||
};
|
||||
@@ -125,6 +128,34 @@ pub(super) fn blast_spares_decorations(
|
||||
source_in_water || (!mob_griefing && (interaction == BlockInteraction::Keep || by_mob))
|
||||
}
|
||||
|
||||
/// How far the blast's `center` is from `p`.
|
||||
fn blast_distance(center: [f64; 3], p: [f64; 3]) -> f64 {
|
||||
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// The unit vector from `center` to `origin`, 0 when they coincide.
|
||||
fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
|
||||
let d = [
|
||||
origin[0] - center[0],
|
||||
origin[1] - center[1],
|
||||
origin[2] - center[2],
|
||||
];
|
||||
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if l < 1.0e-4 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
[d[0] / l, d[1] / l, d[2] / l]
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity in the blast's reach: how far (as a share of the blast's
|
||||
/// diameter), how much of it the rays see, and the knockback.
|
||||
struct Hit {
|
||||
dist: f64,
|
||||
exposure: f32,
|
||||
push: Vec3,
|
||||
}
|
||||
|
||||
/// A mob in a blast's reach: `hurtServer` when its team allows the damage
|
||||
/// (`hurtable`), then `pushFromExplosion` whatever that returned, so a mob
|
||||
/// that refuses the hit (invulnerable, in its hurt cooldown, a wither and an
|
||||
@@ -472,6 +503,38 @@ impl Game<'_> {
|
||||
pushed
|
||||
}
|
||||
|
||||
/// Where a blast at `center` reaches `entity`, unless it ignores the blast
|
||||
/// or is out of range (`getEntities` and `ServerExplosion.hurtEntities`).
|
||||
/// Generic over the kind, so every walked list gets its own copy.
|
||||
fn blast_hit<E: Entity>(
|
||||
&self,
|
||||
entity: &E,
|
||||
center: [f64; 3],
|
||||
diameter: f64,
|
||||
blast: &Blast,
|
||||
) -> Option<Hit> {
|
||||
if entity.ignores_explosion(blast) || !entity.pushed_by_explosion() {
|
||||
return None;
|
||||
}
|
||||
let feet = entity.position();
|
||||
let dist = blast_distance(center, [feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let b = entity.explosion_box();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let dir = blast_direction(
|
||||
center,
|
||||
[feet.x, feet.y + entity.explosion_eye_height(), feet.z],
|
||||
);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
Some(Hit {
|
||||
dist,
|
||||
exposure,
|
||||
push: Vec3::new(dir[0] * power, dir[1] * power, dir[2] * power),
|
||||
})
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`. Returns the players whose clients add a
|
||||
/// push from the explosion packet, and how much (`explosion_hurt_player`).
|
||||
@@ -487,27 +550,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.explosion_hits_special(center, radius);
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
let distance = |p: [f64; 3]| {
|
||||
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2))
|
||||
.sqrt()
|
||||
};
|
||||
let damage = |dist: f64, exposure: f32| {
|
||||
let pow = (1.0 - dist) * f64::from(exposure);
|
||||
((pow * pow + pow) / 2.0 * 7.0 * diameter + 1.0) as f32
|
||||
};
|
||||
let direction = |origin: [f64; 3]| {
|
||||
let d = [
|
||||
origin[0] - center[0],
|
||||
origin[1] - center[1],
|
||||
origin[2] - center[2],
|
||||
];
|
||||
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if l < 1.0e-4 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
[d[0] / l, d[1] / l, d[2] / l]
|
||||
}
|
||||
};
|
||||
// `source.doTeamsAllowDamage(entity)`: the exploding mob spares its
|
||||
// team mates when its team has no friendly fire (they are still pushed).
|
||||
let exploding = self.combat.explosion_source;
|
||||
@@ -563,306 +609,122 @@ impl Game<'_> {
|
||||
.or(exploding)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species());
|
||||
// Mobs, but not the one exploding (`getEntities(source, box)`): a
|
||||
// wither's spawn blast neither hurts nor pushes it. `Warden.ignoreExplosion`:
|
||||
// nor does any blast hurt or push a digging or emerging warden.
|
||||
let spared = exploding.filter(|_| self.combat.explosion_source_named);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs.iter_mut().filter(|m| {
|
||||
m.alive()
|
||||
&& spared != Some(m.id)
|
||||
&& !(m.species() == horizon_client::mobs::Species::Warden
|
||||
&& horizon_client::mobs::warden::digging_or_emerging(m))
|
||||
}) {
|
||||
let hurtable = teams_allow(self, self.mob_team(m.id));
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = (m.kind.width / 2.0, m.kind.height);
|
||||
let eye = [feet[0], feet[1] + m.kind.eye_height, feet[2]];
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
// The exploding mob is the damage source's entity, so victims
|
||||
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`), else
|
||||
// the exploding mob itself.
|
||||
attacker: attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
// `ServerExplosion.hurtEntities`: every entity in reach, on the walk. Each
|
||||
// answers `ignoreExplosion` itself (`Entity::ignores_explosion`); the rest
|
||||
// is the same for all, and only what a blast does to a kind differs. The
|
||||
// walk covers the entities there are now: what the blast drops or spawns
|
||||
// is not hit by it. Destroyed items and orbs are squeezed out afterwards
|
||||
// (as `retain` does), and a destroyed boat or cart shifts its list, so
|
||||
// `gone` counts how far.
|
||||
let blast = Blast {
|
||||
vehicles_ignore: self.combat.blast_owner.is_none()
|
||||
&& self.blast_attacker().is_some()
|
||||
&& !self.ui.rules.bool("mob_griefing"),
|
||||
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
|
||||
spared: exploding.filter(|_| self.combat.explosion_source_named),
|
||||
};
|
||||
let (items_before, orbs_before) = (self.dropped_ids(), self.orb_ids());
|
||||
let (items_len, orbs_len) = (self.dropped.len(), self.orbs.len());
|
||||
let (mut items_kept, mut orbs_kept) = (0, 0);
|
||||
let (mut boats_gone, mut carts_gone) = (0, 0);
|
||||
for handle in self.entity_handles() {
|
||||
let handle = match handle {
|
||||
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
|
||||
EntityHandle::Cart(i) => EntityHandle::Cart(i - carts_gone),
|
||||
other => other,
|
||||
};
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`: its
|
||||
// base is 0 for every mob type, so iron golems and ravagers are pushed.
|
||||
let dir = direction(eye);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
hurt_and_push_mob(
|
||||
m,
|
||||
hurtable,
|
||||
cause,
|
||||
(damage(dist, exposure), source),
|
||||
[dir[0] * power, dir[1] * power, dir[2] * power],
|
||||
);
|
||||
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast));
|
||||
let mut destroyed = false;
|
||||
if let Some(Hit {
|
||||
dist,
|
||||
exposure,
|
||||
push,
|
||||
}) = hit
|
||||
{
|
||||
let amount = damage(dist, exposure);
|
||||
match handle {
|
||||
EntityHandle::Mob(i) => {
|
||||
let hurtable = teams_allow(self, self.mob_team(self.mobs[i].id));
|
||||
let m = &mut self.mobs[i];
|
||||
// The exploding mob is the damage source's entity, so
|
||||
// victims it kills drop its charged-creeper loot
|
||||
// (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`),
|
||||
// else the exploding mob itself.
|
||||
attacker: attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
|
||||
// its base is 0 for every mob type, so iron golems and
|
||||
// ravagers are pushed.
|
||||
hurt_and_push_mob(
|
||||
m,
|
||||
hurtable,
|
||||
cause,
|
||||
(amount, source),
|
||||
[push.x, push.y, push.z],
|
||||
);
|
||||
}
|
||||
// 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
EntityHandle::Item(i) => {
|
||||
destroyed = &*self.dropped[i].stack.item != "minecraft:nether_star"
|
||||
&& amount >= 5.0;
|
||||
if !destroyed {
|
||||
self.dropped[i].push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `ExperienceOrb.hurtServer` takes the damage off their 5 health.
|
||||
EntityHandle::Orb(i) => {
|
||||
destroyed = self.orbs[i].hurt(amount);
|
||||
if !destroyed {
|
||||
self.orbs[i].push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `VehicleEntity.hurtServer` (explosions ignite TNT carts);
|
||||
// a destroyed one is gone before the push.
|
||||
EntityHandle::Cart(i) => {
|
||||
let (id, len) = (self.carts[i].id, self.carts.len());
|
||||
self.hurt_cart(i, amount, false, true);
|
||||
carts_gone += len - self.carts.len();
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
c.push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `VehicleEntity.hurtServer`: damage * 10, broken past 40.
|
||||
EntityHandle::Boat(i) => {
|
||||
let (id, len) = (self.boats[i].id, self.boats.len());
|
||||
self.hurt_boat(i, amount, false);
|
||||
boats_gone += len - self.boats.len();
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `hurtServer` does nothing to the rest; `pushFromExplosion` moves them.
|
||||
other => with_entity_mut!(self, other, e => e.push_from_explosion(push)),
|
||||
}
|
||||
}
|
||||
match handle {
|
||||
EntityHandle::Item(i) if !destroyed => {
|
||||
self.dropped.swap(items_kept, i);
|
||||
items_kept += 1;
|
||||
}
|
||||
EntityHandle::Orb(i) if !destroyed => {
|
||||
self.orbs.swap(orbs_kept, i);
|
||||
orbs_kept += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.mobs = mobs;
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
|
||||
// first, so a blast that keeps blocks leaves every item untouched.
|
||||
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
|
||||
let before = self.dropped_ids();
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
if items_ignore {
|
||||
return true;
|
||||
}
|
||||
let feet = [item.position.x, item.position.y, item.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
return true;
|
||||
}
|
||||
let half = 0.125;
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - half, feet[1], feet[2] - half],
|
||||
[feet[0] + half, feet[1] + 0.25, feet[2] + half],
|
||||
);
|
||||
if &*item.stack.item != "minecraft:nether_star" && damage(dist, exposure) >= 5.0 {
|
||||
return false;
|
||||
}
|
||||
let dir = direction([feet[0], feet[1] + 0.2125, feet[2]]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
item.velocity = Vec3::new(
|
||||
item.velocity.x + dir[0] * power,
|
||||
item.velocity.y + dir[1] * power,
|
||||
item.velocity.z + dir[2] * power,
|
||||
);
|
||||
true
|
||||
});
|
||||
self.dropped = items;
|
||||
self.native_items_left_since(&before);
|
||||
// Experience orbs: `ExperienceOrb.hurtServer` takes the damage off
|
||||
// their 5 health, and `pushFromExplosion` moves the ones that stay
|
||||
// (a 0.5 x 0.5 box, the eyes at 0.85 of its height).
|
||||
let orbs_before = self.orb_ids();
|
||||
let mut orbs = std::mem::take(&mut self.orbs);
|
||||
orbs.retain_mut(|o| {
|
||||
let feet = [o.position.x, o.position.y, o.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
return true;
|
||||
}
|
||||
let half = 0.25;
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - half, feet[1], feet[2] - half],
|
||||
[feet[0] + half, feet[1] + 0.5, feet[2] + half],
|
||||
);
|
||||
if o.hurt(damage(dist, exposure)) {
|
||||
return false;
|
||||
}
|
||||
let dir = direction([feet[0], feet[1] + f64::from(0.5f32 * 0.85f32), feet[2]]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
o.velocity = Vec3::new(
|
||||
o.velocity.x + dir[0] * power,
|
||||
o.velocity.y + dir[1] * power,
|
||||
o.velocity.z + dir[2] * power,
|
||||
);
|
||||
true
|
||||
});
|
||||
self.orbs = orbs;
|
||||
self.dropped.drain(items_kept..items_len);
|
||||
self.orbs.drain(orbs_kept..orbs_len);
|
||||
self.native_items_left_since(&items_before);
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
// Primed TNT: knockback only, from its position (not its eyes).
|
||||
let mut tnts = std::mem::take(&mut self.tnts);
|
||||
for t in &mut tnts {
|
||||
let feet = [t.position.x, t.position.y, t.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let half = 0.49;
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - half, feet[1], feet[2] - half],
|
||||
[feet[0] + half, feet[1] + 0.98, feet[2] + half],
|
||||
);
|
||||
let dir = direction(feet);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x + dir[0] * power,
|
||||
t.velocity.y + dir[1] * power,
|
||||
t.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// `VehicleEntity.ignoreExplosion`: a mob's blast (not a primed TNT's)
|
||||
// spares boats and minecarts while mob griefing is off.
|
||||
let vehicles_ignore = self.combat.blast_owner.is_none()
|
||||
&& self.blast_attacker().is_some()
|
||||
&& !self.ui.rules.bool("mob_griefing");
|
||||
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
|
||||
// then the push from their eyes (0.85 of their height).
|
||||
let ids: Vec<u32> = if vehicles_ignore {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.carts.iter().map(|c| c.id).collect()
|
||||
};
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.carts[i].bounds();
|
||||
let feet = self.carts[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_cart(i, damage(dist, exposure), false, true);
|
||||
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
c.velocity = Vec3::new(
|
||||
c.velocity.x + dir[0] * power,
|
||||
c.velocity.y + dir[1] * power,
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Boats: `VehicleEntity.hurtServer` (damage * 10, broken past 40), then the
|
||||
// push from their eyes (`EntityType.OAK_BOAT & co`: the full 0.5625).
|
||||
let ids: Vec<u32> = if vehicles_ignore {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.boats.iter().map(|b| b.id).collect()
|
||||
};
|
||||
for id in ids {
|
||||
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.boats[i].bounds();
|
||||
let feet = self.boats[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_boat(i, damage(dist, exposure), false);
|
||||
let Some(b) = self.boats.iter_mut().find(|b| b.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let dir = direction([feet.x, feet.y + 0.5625, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
b.velocity = Vec3::new(
|
||||
b.velocity.x + dir[0] * power,
|
||||
b.velocity.y + dir[1] * power,
|
||||
b.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Falling blocks: `Entity.hurtServer` does nothing, `pushFromExplosion`
|
||||
// moves them from their eyes (0.85 of the 0.98 box).
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
let feet = f.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let b = horizon_client::falling_block::bounds(feet);
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
f.velocity = Vec3::new(
|
||||
f.velocity.x + dir[0] * power,
|
||||
f.velocity.y + dir[1] * power,
|
||||
f.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.falling = falling;
|
||||
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
|
||||
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
|
||||
// does nothing while it is in the ground; `hurtServer` never hurts them.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
for p in projectiles
|
||||
.iter_mut()
|
||||
.filter(|p| !p.stuck && !p.kind.ignores_blast())
|
||||
{
|
||||
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
|
||||
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let eye_height = p.kind.eye_height();
|
||||
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + dir[0] * power,
|
||||
p.velocity.y + dir[1] * power,
|
||||
p.velocity.z + dir[2] * power,
|
||||
);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
let feet = r.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.125, feet.y, feet.z - 0.125],
|
||||
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
r.velocity = Vec3::new(
|
||||
r.velocity.x + dir[0] * power,
|
||||
r.velocity.y + dir[1] * power,
|
||||
r.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
|
||||
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
|
||||
// (`AbstractArrow.push`) not pushed while in the ground.
|
||||
let mut tridents = std::mem::take(&mut self.tridents);
|
||||
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
|
||||
let feet = t.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.25, feet.y, feet.z - 0.25],
|
||||
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x + dir[0] * power,
|
||||
t.velocity.y + dir[1] * power,
|
||||
t.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.tridents = tridents;
|
||||
// Frames, paintings, cushions and armour stands last: vanilla lists
|
||||
// the entities in reach before hurting any, so the items they drop
|
||||
// here are neither destroyed nor pushed by the same blast.
|
||||
|
||||
@@ -186,6 +186,19 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The boat the hook's owner rides (`Entity.isPassengerOfSameVehicle`).
|
||||
fn own_vehicle(&self) -> Option<u32> {
|
||||
match self.lan_acting {
|
||||
None => self.riding,
|
||||
Some(conn) => self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.and_then(|g| g.vehicle)
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// `FishingHook.retrieve`: the rod damage it costs.
|
||||
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
|
||||
let Some(hook) = self.hook.take() else {
|
||||
@@ -212,6 +225,10 @@ impl Game<'_> {
|
||||
hooked_item = true;
|
||||
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
|
||||
t.velocity = pulled(t.velocity);
|
||||
} else if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
a.velocity = pulled(a.velocity);
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.velocity = pulled(b.velocity);
|
||||
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
|
||||
// A player: the hub moves them when the tick ends.
|
||||
self.creatures
|
||||
@@ -604,6 +621,24 @@ impl Game<'_> {
|
||||
.find(|t| t.conn != owner && touches(t.bounds))
|
||||
.map(|t| t.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
// Entity.canBeHitByProjectile: `isAlive() && isPickable()`;
|
||||
// ArmorStand.isPickable is false for a marker.
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.health > 0.0 && !a.marker && touches(a.bounds()))
|
||||
.map(|a| a.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
// AbstractBoat.isPickable; Projectile.canHitEntity skips the
|
||||
// boat the owner rides (approximating `leftOwner`, as the
|
||||
// players above skip their owner).
|
||||
let own = self.own_vehicle();
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| Some(b.id) != own && touches(b.bounds()))
|
||||
.map(|b| b.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -678,6 +713,18 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.armor_stands.iter().find(|a| a.id == id).map(|a| {
|
||||
let b = a.bounds();
|
||||
(a.position, b.max_position().y - b.min_position().y)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| (b.position, super::boats::HEIGHT))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
|
||||
@@ -715,12 +715,12 @@ impl Game<'_> {
|
||||
self.finish_open_world(opening.world?.finish()?)?;
|
||||
drop(open_step);
|
||||
if let Some(sync) = opening.sync {
|
||||
sync.adopt(&self.pack_files());
|
||||
startup::time("server.sync_adopt", || sync.adopt(&self.pack_files()));
|
||||
}
|
||||
let saved = Box::new(self.level.clone());
|
||||
// IntegratedServer runs the server mods; the window the client mods.
|
||||
self.mods.dev = self.options.dev_mods.clone();
|
||||
self.start_mods();
|
||||
startup::time("server.start_mods", || self.start_mods());
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
let view_distance = self.settings.render_distance.max(2);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! signal while playing, the comparator output, and saving.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_item,
|
||||
containers::{BlockEntity, Jukebox},
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
@@ -42,6 +43,16 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
|
||||
/// `block_entity_data` carries a `RecordItem` keeps that record, so
|
||||
/// `has_record` turns on: the model draws the disc and `useWithoutItem`
|
||||
/// pops it out. Vanilla reads the item's data, after
|
||||
/// `updateCustomBlockEntityTag` has filled the block entity.
|
||||
pub(super) fn jukebox_placed(&mut self, pos: [i32; 3], stack: &ItemStack) {
|
||||
if block_entity_item::carries_record(stack) {
|
||||
self.set_has_record(pos, true);
|
||||
}
|
||||
}
|
||||
/// `onSongChanged`: neighbours (the signal) and comparators.
|
||||
fn jukebox_changed(&mut self, pos: [i32; 3]) {
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
|
||||
@@ -1302,12 +1302,14 @@ impl Game<'_> {
|
||||
.get("minecraft:worldgen/biome")
|
||||
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
let mut encoder = ChunkEncoder::new(
|
||||
self.res
|
||||
.facts()
|
||||
.states
|
||||
.iter()
|
||||
.map(|s| (s.name.clone(), s.fluid_level > 0 || s.waterlogged)),
|
||||
let mut encoder = ChunkEncoder::with_signs(
|
||||
self.res.facts().states.iter().map(|s| {
|
||||
(
|
||||
s.name.clone(),
|
||||
s.fluid_level > 0 || s.waterlogged,
|
||||
s.sign_form(),
|
||||
)
|
||||
}),
|
||||
&sync.builtin,
|
||||
&biome_names,
|
||||
®istry,
|
||||
@@ -1819,8 +1821,13 @@ impl Game<'_> {
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
e.precise = d.needs_sync;
|
||||
// `ServerEntity.getRequiresPrecisePosition` is false for an item:
|
||||
// `needs_sync` asks for its data again, not for an exact position.
|
||||
// The collision case is `full_precision_required` in the tracker.
|
||||
e.precise = false;
|
||||
e.needs_sync = d.needs_sync;
|
||||
e.vertical_collision = d.vertical_collision;
|
||||
e.horizontal_collision = d.horizontal_collision;
|
||||
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
|
||||
if self.entity_glowing(d.id) {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
|
||||
|
||||
@@ -308,6 +308,16 @@ impl Game<'_> {
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
self.as_guest_with_host(host, conn, |g, _| f(g))
|
||||
}
|
||||
|
||||
/// `as_guest`, passing the active `&LanHost` to `f`.
|
||||
pub(super) fn as_guest_with_host<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self, &LanHost) -> R,
|
||||
) -> Option<R> {
|
||||
let swap = self.enter_guest(host, conn)?;
|
||||
super::dedicated::crash_fixture("guest", || {
|
||||
@@ -315,7 +325,7 @@ impl Game<'_> {
|
||||
});
|
||||
let dimension =
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self));
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self, host));
|
||||
self.leave_guest(host, swap);
|
||||
Some(result)
|
||||
}
|
||||
@@ -2205,6 +2215,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// AbstractContainerMenu.quickMoveStack of a mod container: the mods first.
|
||||
S::ContainerClick(p) if self.lan_mod_quick_move(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_merchant_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
|
||||
@@ -2991,13 +3003,13 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.position(|p| p.id != 0 && p.id as i32 == id && super::deflect::attackable(p))
|
||||
{
|
||||
self.as_guest(host, conn, |g| {
|
||||
self.as_guest_with_host(host, conn, |g, host| {
|
||||
if g.within_interaction_range(i) {
|
||||
if matches!(
|
||||
g.creatures.projectiles[i].kind,
|
||||
horizon_client::projectiles::ProjectileKind::ShulkerBullet { .. }
|
||||
) {
|
||||
g.hit_shulker_bullet(i);
|
||||
g.hit_shulker_bullet(Some(host), i);
|
||||
} else {
|
||||
g.deflect(i);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(super) struct GuestCrafting {
|
||||
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
|
||||
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
|
||||
/// container menu keeps the client in sync.
|
||||
machine: Option<(i32, Box<Menu>)>,
|
||||
pub(super) machine: Option<(i32, Box<Menu>)>,
|
||||
}
|
||||
|
||||
/// ServerboundContainerClickPacket's input.
|
||||
|
||||
@@ -46,6 +46,8 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
@@ -623,6 +625,17 @@ impl Game<'_> {
|
||||
K::WitherSkull { dangerous: true } => {
|
||||
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion's
|
||||
// `potion_contents`, whose effects the guest's splash and
|
||||
// whose colour its drawing need. A plain splash potion is the
|
||||
// client's default and is not sent.
|
||||
K::Potion(stack) if stack.extra.is_some() || stack.count != 1 => {
|
||||
e.meta.push((
|
||||
8,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(hub.stack(&Some((**stack).clone()))),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out.push(e);
|
||||
|
||||
@@ -154,6 +154,44 @@ pub(super) fn send_snowflake_particle(
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles`: sends a `ClientboundLevelParticlesPacket` to each player within 32 blocks.
|
||||
pub(super) fn send_hub_level_particles(
|
||||
hub: &Hub,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
state: Option<BlockStateId>,
|
||||
) {
|
||||
let Some(particle) = Game::hub_particle(hub, options, state) else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if horizon_only && g.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
@@ -184,9 +222,11 @@ impl Game<'_> {
|
||||
|
||||
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
|
||||
/// player within 32 blocks. The window draws its own copy. `state` is the
|
||||
/// block of a block particle.
|
||||
/// block of a block particle. `host` is the hub of a guest's turn, which
|
||||
/// is out of `self.lan` meanwhile.
|
||||
pub(super) fn lan_level_particles(
|
||||
&self,
|
||||
host: Option<&LanHost>,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
@@ -194,33 +234,10 @@ impl Game<'_> {
|
||||
count: u32,
|
||||
state: Option<BlockStateId>,
|
||||
) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, state))
|
||||
else {
|
||||
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
if horizon_only && g.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
send_hub_level_particles(hub, options, at, spread, speed, count, state);
|
||||
}
|
||||
|
||||
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
|
||||
@@ -727,8 +744,17 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::spread;
|
||||
use super::*;
|
||||
use horizon_client::lan::{
|
||||
Joined, Lan, NetConfig, Out, PlayerSender,
|
||||
chunks::ChunkEncoder,
|
||||
data,
|
||||
guest::Guest,
|
||||
hub::{Hub, HubConfig},
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_protocol::packets::play;
|
||||
|
||||
/// The Nether's evaporation smoke (ALTERNATIVE, dist 1, speed 0) lands
|
||||
/// inside the block from its corner and stands still; DEFAULT spreads
|
||||
@@ -750,4 +776,115 @@ mod tests {
|
||||
}
|
||||
assert!(below, "DEFAULT is a Gaussian around the position");
|
||||
}
|
||||
|
||||
/// `ShulkerBullet.hurtServer`: fifteen crit particles (spread 0.2, speed 0.0)
|
||||
/// are sent to tracking players within 32 blocks.
|
||||
#[test]
|
||||
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "ShulkerBulletTest".into(), 2)
|
||||
};
|
||||
let mut sync = data::SyncData::empty();
|
||||
sync.builtin.registries.insert(
|
||||
"minecraft:particle_type".to_string(),
|
||||
horizon_protocol::registry::Ids::from_names(vec!["minecraft:crit".to_string()]),
|
||||
);
|
||||
let sync = std::sync::Arc::new(sync);
|
||||
let lan = Lan::start_with(sync.clone(), config).unwrap();
|
||||
let encoder = ChunkEncoder::new(
|
||||
[] as [(std::sync::Arc<str>, bool); 0],
|
||||
&horizon_protocol::registry::Builtin::default(),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let hub_config = HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 10,
|
||||
simulation_distance: 10,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
};
|
||||
let mut hub = Hub::new(lan, sync, encoder, hub_config);
|
||||
|
||||
let (tx_near, rx_near) = std::sync::mpsc::channel();
|
||||
let mut near = Guest::new(
|
||||
Joined {
|
||||
conn: 1,
|
||||
uuid: 1,
|
||||
name: "NearPlayer".into(),
|
||||
properties: Vec::new(),
|
||||
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
|
||||
brand: "vanilla".into(),
|
||||
sender: PlayerSender::new(tx_near, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
},
|
||||
1,
|
||||
[5.0, 64.0, 5.0],
|
||||
8,
|
||||
);
|
||||
near.placed = true;
|
||||
near.dimension = "minecraft:overworld".into();
|
||||
hub.guests.push(near);
|
||||
|
||||
let (tx_far, rx_far) = std::sync::mpsc::channel();
|
||||
let mut far = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: "FarPlayer".into(),
|
||||
properties: Vec::new(),
|
||||
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
|
||||
brand: "vanilla".into(),
|
||||
sender: PlayerSender::new(tx_far, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25566".parse().unwrap(),
|
||||
},
|
||||
2,
|
||||
[100.0, 64.0, 100.0],
|
||||
8,
|
||||
);
|
||||
far.placed = true;
|
||||
far.dimension = "minecraft:overworld".into();
|
||||
hub.guests.push(far);
|
||||
|
||||
let at = [0.0, 64.0, 0.0];
|
||||
let options = ParticleOptions::simple(ParticleType::from_name("crit").unwrap());
|
||||
send_hub_level_particles(&hub, &options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
|
||||
assert!(
|
||||
rx_far.try_recv().is_err(),
|
||||
"far guest should not receive particles"
|
||||
);
|
||||
|
||||
let out = rx_near
|
||||
.try_recv()
|
||||
.expect("near guest should receive LevelParticles packet");
|
||||
let body = match out {
|
||||
Out::Packet(body) => body,
|
||||
_ => panic!("expected Out::Packet"),
|
||||
};
|
||||
let mut r = horizon_protocol::buf::Reader::new(&body);
|
||||
let id = r.varint().unwrap();
|
||||
let packet = play::Clientbound::decode(id, r.rest()).unwrap();
|
||||
match packet {
|
||||
play::Clientbound::LevelParticles(p) => {
|
||||
assert_eq!(p.count, 15);
|
||||
assert_eq!(p.dist, [0.2, 0.2, 0.2]);
|
||||
assert_eq!(p.max_speed, [0.0; 3]);
|
||||
assert_eq!(p.pos, at);
|
||||
assert_eq!(p.particle.id, 0);
|
||||
}
|
||||
other => panic!("expected LevelParticles packet, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,13 @@
|
||||
//! `thunderHit` (damage and burning, charged creepers, zombified piglins,
|
||||
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
|
||||
//! flash, the sounds and the branching additive bolt.
|
||||
use super::{Game, commands::Target};
|
||||
use super::{
|
||||
Game,
|
||||
commands::Target,
|
||||
entity::{
|
||||
Entity as _, EntityHandle, ThunderHit, entity_lists_of, for_each_entity, with_entity,
|
||||
},
|
||||
};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
|
||||
use horizon_core::{
|
||||
@@ -400,6 +406,16 @@ impl Game<'_> {
|
||||
if let Some(powered) = registry.id(&s) {
|
||||
// The rod's onPlace schedules the 8-tick unpowering.
|
||||
let _ = self.level_set(p, powered);
|
||||
self.with_level(|b, level, ticks| {
|
||||
if level.redstone_experiments {
|
||||
b.lightning_rod_update_neighbors(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
state,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Level event 3002 with the rod's axis: sparks along it.
|
||||
let axis = match s.properties.get("facing").map(String::as_str) {
|
||||
@@ -473,6 +489,23 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// How many entities of `stage` the strike reaches: one loop per list over
|
||||
/// the borrowed slices, where a kind that takes no stage folds away, so a
|
||||
/// stage nobody is in is skipped without walking the handles.
|
||||
fn thunder_count(&self, strike: Aabb, stage: ThunderHit) -> usize {
|
||||
let lists = entity_lists_of!(self);
|
||||
let mut n = 0;
|
||||
for_each_entity!(lists, e => {
|
||||
if e.thunder_hit() == stage && strike.intersects(e.thunder_bounds()) {
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
n
|
||||
}
|
||||
/// Whether the strike reaches `h` and its kind takes `stage`.
|
||||
fn thunder_struck(&self, h: EntityHandle, strike: Aabb, stage: ThunderHit) -> bool {
|
||||
with_entity!(self, h, e => e.thunder_hit() == stage && strike.intersects(e.thunder_bounds()))
|
||||
}
|
||||
/// `thunderHit` for entities whose box meets the strike box, 3 blocks
|
||||
/// around and up to 9 above (`getEntities(this, AABB)` tests boxes, not
|
||||
/// positions). Returns the mobs in that box before the hit, as
|
||||
@@ -493,36 +526,28 @@ impl Game<'_> {
|
||||
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
|
||||
))
|
||||
};
|
||||
let struck: Vec<(u32, EntityView)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.alive()
|
||||
&& meets(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width / 2.0,
|
||||
m.kind.height,
|
||||
)
|
||||
})
|
||||
.map(|m| (m.id, self.mob_entity(m)))
|
||||
.collect();
|
||||
b.hit.extend(struck.iter().map(|(id, _)| *id));
|
||||
let damage = Damage {
|
||||
kind: DamageKind::Generic,
|
||||
attacker: None,
|
||||
from: Some(b.at),
|
||||
};
|
||||
let mut struck = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let mut powered = Vec::new();
|
||||
let peaceful = self.ui.difficulty == 0;
|
||||
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
|
||||
if !meets(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width / 2.0,
|
||||
m.kind.height,
|
||||
) {
|
||||
let mut left = self.thunder_count(strike, ThunderHit::Mob);
|
||||
for h in self.entity_handles() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
let EntityHandle::Mob(i) = h else { continue };
|
||||
if !self.thunder_struck(h, strike, ThunderHit::Mob) {
|
||||
continue;
|
||||
}
|
||||
left -= 1;
|
||||
struck.push(self.mob_entity(&self.mobs[i]));
|
||||
let m = &mut self.mobs[i];
|
||||
b.hit.push(m.id);
|
||||
let setup = |m: &horizon_client::mobs::Mob| SpawnSetup {
|
||||
yaw: m.yaw,
|
||||
persistent: true,
|
||||
@@ -606,29 +631,34 @@ impl Game<'_> {
|
||||
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
|
||||
}
|
||||
}
|
||||
// Boats, then minecarts: `VehicleEntity.hurtServer` for 5 damage
|
||||
// (x 10 over the 40 a vehicle takes) destroys them. Lightning is no
|
||||
// fire source, so a TNT cart does not prime.
|
||||
let mut i = self.boats.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let bb = self.boats[i].bounds();
|
||||
if strike.intersects(bb) {
|
||||
self.hurt_boat(i, 5.0, false);
|
||||
}
|
||||
}
|
||||
let mut i = self.carts.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let bb = self.carts[i].bounds();
|
||||
if strike.intersects(bb) {
|
||||
self.hurt_cart(i, 5.0, false, false);
|
||||
}
|
||||
}
|
||||
// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
|
||||
// Vehicles, then orbs: `VehicleEntity.hurtServer` for 5 damage (x 10
|
||||
// over the 40 a vehicle takes) destroys them. Lightning is no fire
|
||||
// source, so a TNT cart does not prime. `ExperienceOrb.hurtServer`:
|
||||
// 5 damage off 5 health. Last first, since each removes its entry.
|
||||
let orbs_before = self.orb_ids();
|
||||
self.orbs
|
||||
.retain_mut(|o| !(strike.intersects(o.bounds()) && o.hurt(5.0)));
|
||||
let mut left = self.thunder_count(strike, ThunderHit::Vehicle)
|
||||
+ self.thunder_count(strike, ThunderHit::Orb);
|
||||
for h in self.entity_handles_rev() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
if !self.thunder_struck(h, strike, ThunderHit::Vehicle)
|
||||
&& !self.thunder_struck(h, strike, ThunderHit::Orb)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
left -= 1;
|
||||
match h {
|
||||
EntityHandle::Boat(i) => {
|
||||
self.hurt_boat(i, 5.0, false);
|
||||
}
|
||||
EntityHandle::Cart(i) => self.hurt_cart(i, 5.0, false, false),
|
||||
EntityHandle::Orb(i) if self.orbs[i].hurt(5.0) => {
|
||||
self.orbs.remove(i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
// Every player in the box, the server's players too (0.6 x 1.8).
|
||||
let strike_players = move |g: &mut Self| {
|
||||
@@ -648,21 +678,26 @@ impl Game<'_> {
|
||||
};
|
||||
strike_players(self);
|
||||
self.each_guest_hit(strike_players);
|
||||
b.hit_items.extend(
|
||||
self.dropped
|
||||
.iter()
|
||||
.filter(|e| meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25))
|
||||
.map(|e| e.id),
|
||||
);
|
||||
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
|
||||
let before = self.dropped_ids();
|
||||
self.dropped.retain(|e| {
|
||||
!meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25)
|
||||
|| e.stack.item.contains("netherite")
|
||||
|| (e.age < 20 && b.spared.contains(&e.id))
|
||||
});
|
||||
let mut left = self.thunder_count(strike, ThunderHit::Item);
|
||||
for h in self.entity_handles_rev() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
let EntityHandle::Item(i) = h else { continue };
|
||||
if !self.thunder_struck(h, strike, ThunderHit::Item) {
|
||||
continue;
|
||||
}
|
||||
left -= 1;
|
||||
let e = &self.dropped[i];
|
||||
b.hit_items.push(e.id);
|
||||
if !(e.stack.item.contains("netherite") || (e.age < 20 && b.spared.contains(&e.id))) {
|
||||
self.dropped.remove(i);
|
||||
}
|
||||
}
|
||||
self.native_items_left_since(&before);
|
||||
struck.into_iter().map(|(_, view)| view).collect()
|
||||
struck
|
||||
}
|
||||
/// `LightningBolt.shouldRenderAtSqrDistance`: a bolt is drawn within 64
|
||||
/// blocks times the view scale of the camera, and past that only its sky
|
||||
|
||||
@@ -1438,6 +1438,8 @@ mod item_column_tests {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
|
||||
@@ -131,6 +131,11 @@ pub(super) struct Minecart {
|
||||
pub loot: Option<horizon_client::save::RolledLoot>,
|
||||
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
|
||||
pub first_tick: bool,
|
||||
/// `Entity.isInWater` (`wasTouchingWater`): `AbstractMinecart.tick`
|
||||
/// updates it after the move (`updateFluidInteraction`), so the move
|
||||
/// goes by where the cart was a tick before, and a new cart's first
|
||||
/// move is a dry one.
|
||||
pub in_water: bool,
|
||||
/// Command block minecarts: the cart's `BaseCommandBlock` (command,
|
||||
/// success count, last output, output tracking). The block-only flags of
|
||||
/// `CommandBlock` stay unset.
|
||||
@@ -151,8 +156,9 @@ pub(super) struct Minecart {
|
||||
pub synced_command: String,
|
||||
pub synced_output: String,
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
/// `EntityType.MINECART`'s `sized(0.98F, 0.7F)`: float dimensions, widened.
|
||||
pub(super) const WIDTH: f64 = 0.98_f32 as f64;
|
||||
pub(super) const HEIGHT: f64 = 0.7_f32 as f64;
|
||||
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
|
||||
fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
@@ -294,6 +300,7 @@ impl Minecart {
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
in_water: false,
|
||||
command: horizon_client::command_block::CommandBlock::default(),
|
||||
tick_count: 0,
|
||||
last_activated: 0,
|
||||
@@ -384,15 +391,6 @@ impl Game<'_> {
|
||||
.get(self.world.block(x, y, z))
|
||||
.full_collision
|
||||
}
|
||||
fn cart_in_water(&self, c: &Minecart) -> bool {
|
||||
let p = c.position;
|
||||
let f = self.res.facts().get(self.world.block(
|
||||
p.x.floor() as i32,
|
||||
(p.y + 0.1).floor() as i32,
|
||||
p.z.floor() as i32,
|
||||
));
|
||||
f.fluid == Fluid::Water
|
||||
}
|
||||
/// `OldMinecartBehavior.getPos`: the point on the rail under (x, y, z).
|
||||
fn rail_pos(&self, x: f64, y: f64, z: f64) -> Option<[f64; 3]> {
|
||||
let [xt, yt, zt] = self.rail_block(Vec3::new(x, y, z));
|
||||
@@ -524,10 +522,11 @@ impl Game<'_> {
|
||||
if (moved.y - delta.y).abs() > 1.0e-7 {
|
||||
c.velocity.y = 0.0;
|
||||
}
|
||||
// Entity.checkFallDamage: the move's drop counts (not in water),
|
||||
// Entity.checkFallDamage: the move's drop counts (not in water, as of
|
||||
// the tick before: `isInWater` is `wasTouchingWater`),
|
||||
// and a landing hands the whole fall to causeFallDamage, which
|
||||
// sets a TNT minecart off at (fall / 10) squared.
|
||||
if moved.y < 0.0 && !self.cart_in_water(c) {
|
||||
if moved.y < 0.0 && !c.in_water {
|
||||
c.fall_distance -= f64::from(moved.y as f32);
|
||||
}
|
||||
if c.on_ground {
|
||||
@@ -540,11 +539,14 @@ impl Game<'_> {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
// Entity.move ends with applyEffectsFromBlocks: a bubble column lifts
|
||||
// or drags the cart (tick_cart runs it a second time, like vanilla).
|
||||
self.cart_block_effects(c);
|
||||
}
|
||||
fn max_speed(&self, c: &Minecart) -> f64 {
|
||||
let base = if self.cart_in_water(c) { 0.2 } else { 0.4 };
|
||||
let base = if c.in_water { 0.2 } else { 0.4 };
|
||||
if c.kind == CartKind::Furnace {
|
||||
base * if self.cart_in_water(c) { 0.75 } else { 0.5 }
|
||||
base * if c.in_water { 0.75 } else { 0.5 }
|
||||
} else {
|
||||
base
|
||||
}
|
||||
@@ -553,7 +555,7 @@ impl Game<'_> {
|
||||
/// `getSlowdownFactor`: 0.997 riding, the empty cart's own by behavior.
|
||||
fn natural_slowdown(&self, c: &mut Minecart, ridden: bool, factor: f64) -> Vec3 {
|
||||
let mut v = c.velocity;
|
||||
let water = self.cart_in_water(c);
|
||||
let water = c.in_water;
|
||||
if c.kind == CartKind::Furnace {
|
||||
if c.push.length_squared() > 1.0e-7 {
|
||||
c.push = push_along(c.push, v);
|
||||
@@ -568,7 +570,9 @@ impl Game<'_> {
|
||||
let f = if ridden { 0.997 } else { factor };
|
||||
let mut out = Vec3::new(v.x * f, 0.0, v.z * f);
|
||||
if water {
|
||||
out = Vec3::new(out.x * 0.95, 0.0, out.z * 0.95);
|
||||
// scale(0.95F): the float widened.
|
||||
let drag = f64::from(0.95_f32);
|
||||
out = Vec3::new(out.x * drag, 0.0, out.z * drag);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -595,7 +599,7 @@ impl Game<'_> {
|
||||
power = rail.powered;
|
||||
halt = !power;
|
||||
}
|
||||
let slide = if self.cart_in_water(c) {
|
||||
let slide = if c.in_water {
|
||||
0.0078125 * 0.2
|
||||
} else {
|
||||
0.0078125
|
||||
@@ -717,7 +721,7 @@ impl Game<'_> {
|
||||
/// `NewMinecartBehavior.getMaxSpeed`: the game rule over water's half,
|
||||
/// every cart alike (the old behavior's furnace bonus is its own).
|
||||
fn max_speed_new(&self, c: &Minecart) -> f64 {
|
||||
if self.cart_in_water(c) { 0.2 } else { 0.4 }
|
||||
if c.in_water { 0.2 } else { 0.4 }
|
||||
}
|
||||
/// `AbstractMinecart.getRedstoneDirection` (over the old port's
|
||||
/// conductor proxy): which way a powered rail pushes a resting cart.
|
||||
@@ -773,7 +777,7 @@ impl Game<'_> {
|
||||
// calculateSlopeSpeed (once per tick): the slide grows with the
|
||||
// speed, so a descending cart keeps its pace.
|
||||
if !slope_done {
|
||||
let base: f64 = if self.cart_in_water(c) {
|
||||
let base: f64 = if c.in_water {
|
||||
0.0078125 * 0.2
|
||||
} else {
|
||||
0.0078125
|
||||
@@ -947,13 +951,42 @@ impl Game<'_> {
|
||||
// unlike the rail paths, which re-apply the track's velocity.
|
||||
c.collision_speed = 0.0;
|
||||
if !c.on_ground {
|
||||
// scale(getAirDrag()): 0.95F, widened.
|
||||
let drag = f64::from(0.95_f32);
|
||||
c.velocity = Vec3::new(
|
||||
c.velocity.x * 0.95,
|
||||
c.velocity.y * 0.95,
|
||||
c.velocity.z * 0.95,
|
||||
c.velocity.x * drag,
|
||||
c.velocity.y * drag,
|
||||
c.velocity.z * drag,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it,
|
||||
/// and `onInsideBubbleColumn` resets the fall distance.
|
||||
fn cart_block_effects(&self, c: &mut Minecart) {
|
||||
if horizon_client::motion::bubble_column_push(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
c.bounds(),
|
||||
&mut c.velocity,
|
||||
) {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
/// Runs `push_from_cart` for the cart being ticked (`c` is a copy of the
|
||||
/// stored one): the stored cart is replaced by `c` for the push and `c`
|
||||
/// takes back what the push changed.
|
||||
fn push_from_ticked_cart(&mut self, c: &mut Minecart) {
|
||||
let Some(i) = self.carts.iter().position(|x| x.id == c.id) else {
|
||||
return;
|
||||
};
|
||||
self.carts[i] = c.clone();
|
||||
self.push_from_cart(i);
|
||||
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
|
||||
*c = self.carts[i].clone();
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
|
||||
c.first_tick = false;
|
||||
@@ -964,7 +997,7 @@ impl Game<'_> {
|
||||
c.damage = (c.damage - 1.0).max(0.0);
|
||||
// applyGravity
|
||||
if !c.no_gravity {
|
||||
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
|
||||
c.velocity.y -= if c.in_water { 0.005 } else { 0.04 };
|
||||
}
|
||||
let pos = self.rail_block(c.position);
|
||||
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
|
||||
@@ -993,6 +1026,25 @@ impl Game<'_> {
|
||||
c.flipped = !c.flipped;
|
||||
}
|
||||
c.yaw %= 360.0;
|
||||
// applyEffectsFromBlocks: a bubble column lifts or drags the cart.
|
||||
self.cart_block_effects(c);
|
||||
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
|
||||
// AbstractMinecart.tick's updateFluidInteraction.
|
||||
self.push_from_ticked_cart(c);
|
||||
// AbstractMinecart.tick: updateFluidInteraction, a stream pushes the cart.
|
||||
c.in_water = horizon_client::entities::update_entity_fluids(
|
||||
c.bounds(),
|
||||
c.position,
|
||||
c.bounds().max_position().y,
|
||||
&mut c.velocity,
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
)
|
||||
.0 > 0.0;
|
||||
// updateFluidInteraction: being in water resets the fall distance.
|
||||
if c.in_water {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
// Variants.
|
||||
match c.kind {
|
||||
CartKind::Furnace => {
|
||||
@@ -1418,7 +1470,6 @@ impl Game<'_> {
|
||||
if alive {
|
||||
let due = std::mem::take(&mut c.command_due);
|
||||
self.carts[i] = c;
|
||||
self.push_from_cart(i);
|
||||
if due {
|
||||
self.perform_cart_command(id);
|
||||
}
|
||||
@@ -1532,7 +1583,6 @@ impl Game<'_> {
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.eject_cart_mobs(i);
|
||||
self.release_vehicle_container(c.id);
|
||||
@@ -1973,6 +2023,7 @@ impl Game<'_> {
|
||||
tint: impl Fn(i32) -> Option<u32>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut normals = horizon_client::item_render::FaceNormals::new(m);
|
||||
for model in models {
|
||||
for face in &model.faces {
|
||||
let Some(s) = self
|
||||
@@ -2002,8 +2053,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let normal = if diffuse {
|
||||
use horizon_client::item_render::{direction_vector, transform_normal};
|
||||
let n = transform_normal(m, direction_vector(face.quad.facing));
|
||||
let n = normals.face(face.quad.facing);
|
||||
let n = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
[n[0], n[1], n[2], 127]
|
||||
} else {
|
||||
@@ -2215,6 +2265,8 @@ impl Game<'_> {
|
||||
world: &self.world,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
emissive: &self.gui.atlas.emissive,
|
||||
translucent_sprites: &self.gui.atlas.translucent,
|
||||
atlas_revision: self.gui.atlas.revision,
|
||||
nether: self.dim == super::Dim::Nether,
|
||||
lid: &lid,
|
||||
sign: &|_| None,
|
||||
|
||||
@@ -450,4 +450,7 @@ impl Host for super::Game<'_> {
|
||||
) -> u32 {
|
||||
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
|
||||
}
|
||||
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
|
||||
self.native_item_stack(batch, stack, at, size)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user