Merge main into #6972

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 11:07:17 +01:00
commit 805ce265cd
269 files changed
+20319 -2191

No files matched your search

+20
View File
@@ -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 }}
+1 -1
View File
@@ -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
View File
@@ -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)
+32
View File
@@ -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.
+65 -1
View File
@@ -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, _) =
+14 -2
View File
@@ -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));
+9 -1
View File
@@ -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.
+24 -4
View File
@@ -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);
}
+663
View File
@@ -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);
}
}
+466 -7
View File
@@ -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();
+24 -4
View File
@@ -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);
}
+95 -3
View File
@@ -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
+162 -3
View File
@@ -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;
+9
View File
@@ -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,
+4
View File
@@ -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
+34 -11
View File
@@ -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;
+63 -5
View File
@@ -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 = [
+2 -1
View File
@@ -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(&region).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(&region).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();
}
}
+29 -10
View File
@@ -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,
+24
View File
@@ -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>,
+114 -9
View File
@@ -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,
+38 -10
View File
@@ -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(
+13 -12
View File
@@ -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;
+50
View File
@@ -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,
})
}
}
+71 -1
View File
@@ -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.
+159 -25
View File
@@ -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,
+148 -22
View File
@@ -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]);
+36 -10
View File
@@ -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));
File diff suppressed because it is too large. Load diff
+113 -24
View File
@@ -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)
+10 -1
View File
@@ -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
+7 -2
View File
@@ -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) {
+32
View File
@@ -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) {
+33 -41
View File
@@ -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]);
}
}
+27
View File
@@ -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
+1 -1
View File
@@ -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),
+168 -140
View File
@@ -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(
+4 -4
View File
@@ -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));
+26 -14
View File
@@ -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;
+117
View File
@@ -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;
}
+495 -14
View File
@@ -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
+177 -315
View File
@@ -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.
+47
View File
@@ -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()
+2 -2
View File
@@ -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);
+11
View File
@@ -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]);
+14 -7
View File
@@ -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,
&registry,
@@ -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)));
+15 -3
View File
@@ -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);
+164 -27
View File
@@ -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:?}"),
}
}
}
+90 -55
View File
@@ -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,
+80 -28
View File
@@ -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