mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
24
Commits
No files matched your search
@@ -186,7 +186,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
|
||||
@@ -13,14 +13,6 @@ else (videos, Discord and other community material) lives elsewhere.
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
|
||||
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
|
||||
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
|
||||
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
|
||||
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
|
||||
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
|
||||
here.
|
||||
|
||||
## Build it
|
||||
|
||||
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
|
||||
|
||||
Generated
+7
-23
@@ -1418,7 +1418,6 @@ dependencies = [
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-server-files",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
@@ -1529,6 +1528,7 @@ dependencies = [
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
@@ -1592,6 +1592,7 @@ dependencies = [
|
||||
"horizon-optifine",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-render-pipeline",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
@@ -1650,15 +1651,6 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1676,7 +1668,6 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hkdf",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"if-addrs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1802,25 +1793,13 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
@@ -1912,6 +1891,10 @@ dependencies = [
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-render-pipeline"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
@@ -1974,6 +1957,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"horizon-core",
|
||||
"horizon-render-pipeline",
|
||||
"naga",
|
||||
"png",
|
||||
"zip",
|
||||
|
||||
+1
-2
@@ -34,8 +34,6 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
@@ -45,6 +43,7 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-render-pipeline",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
|
||||
@@ -15,10 +15,6 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// Afri-Network, a partner server the game added once below the official server (#9299) until
|
||||
/// the partnership ended: kept so the game can take back out the entry it added.
|
||||
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
@@ -7,8 +7,6 @@ rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
# The moderation durations, shared with `/tempban`.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
@@ -10,9 +10,6 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use horizon_server_files::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
@@ -477,6 +474,61 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
|
||||
@@ -670,39 +670,6 @@ pub fn with_horizon_icon(
|
||||
attributes
|
||||
}
|
||||
|
||||
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
|
||||
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// [`monitor`]'s size, refresh rate and scale.
|
||||
pub fn display(window: &winit::window::Window) -> Option<Display> {
|
||||
monitor(window).map(|m| Display {
|
||||
width: m.size().width,
|
||||
height: m.size().height,
|
||||
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
scale: m.scale_factor(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -42,6 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -109,12 +111,16 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
default = ["audio", "controllers", "discord", "friends"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
|
||||
friends = ["dep:horizon-friends"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
|
||||
@@ -26,6 +26,7 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
|
||||
@@ -362,7 +362,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -430,7 +430,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -121,8 +121,8 @@ impl CommandBlock {
|
||||
condition_met: flag("conditionMet", false),
|
||||
}
|
||||
}
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is the component
|
||||
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
|
||||
/// plain string component.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![
|
||||
("Command", Tag::String(self.command.as_str().into())),
|
||||
@@ -135,7 +135,7 @@ impl CommandBlock {
|
||||
if self.track_output
|
||||
&& let Some(last) = &self.last_output
|
||||
{
|
||||
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
|
||||
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
|
||||
}
|
||||
out.push((
|
||||
"UpdateLastExecution",
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
|
||||
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
|
||||
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
|
||||
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
|
||||
//! vanilla sky. A pack without layers keeps the vanilla sky.
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
|
||||
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
|
||||
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
|
||||
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
|
||||
//! vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glam::Mat4;
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
|
||||
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
|
||||
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
|
||||
/// A layer's texture, RGBA8.
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// How a layer mixes with what is drawn below it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Blend {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Dodge,
|
||||
Burn,
|
||||
Screen,
|
||||
Replace,
|
||||
Overlay,
|
||||
Alpha,
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
@@ -41,44 +34,62 @@ pub struct Draw {
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// What a frame's layers may depend on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Conditions<'a> {
|
||||
/// Ticks of the day, counting the days.
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
/// The biome at the camera, namespaced.
|
||||
pub biome: &'a str,
|
||||
/// The camera's block height.
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// The world the layers were last drawn for.
|
||||
pub world: Option<i32>,
|
||||
/// Each layer's eased visibility, 0 to 1.
|
||||
pub eased: Vec<f32>,
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
}
|
||||
|
||||
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
|
||||
pub trait SkyLayers: Send + Sync {
|
||||
/// Every layer's texture: [`Draw::image`] indexes it.
|
||||
fn images(&self) -> &[Image];
|
||||
/// Whether `world` has layers.
|
||||
fn has(&self, world: i32) -> bool;
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
|
||||
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
fn draws(
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
pub fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
|
||||
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
|
||||
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
pub fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
@@ -86,30 +97,147 @@ pub trait SkyLayers: Send + Sync {
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
);
|
||||
}
|
||||
|
||||
/// No layers: the vanilla sky.
|
||||
struct NoLayers;
|
||||
|
||||
impl SkyLayers for NoLayers {
|
||||
fn images(&self) -> &[Image] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn has(&self, _: i32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
|
||||
) {
|
||||
out.clear();
|
||||
s.world = None;
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), Transition::default());
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
eased.value = target;
|
||||
target
|
||||
} else {
|
||||
eased.step(target, seconds, layer.transition)
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: layer.blend,
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
|
||||
crate::pack_hooks::formats()
|
||||
.custom_sky(root)
|
||||
.unwrap_or_else(|| Arc::new(NoLayers))
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::sky::Now;
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Now::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control) are kept here too.
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -145,6 +145,8 @@ pub struct Extras {
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
|
||||
@@ -231,6 +233,7 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
@@ -335,6 +338,7 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
@@ -372,7 +376,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\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:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\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:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.pregen),
|
||||
bool_text(self.fullbright),
|
||||
@@ -414,6 +418,7 @@ impl Extras {
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.repro_keys),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
@@ -608,6 +613,8 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
@@ -684,6 +691,7 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
@@ -716,6 +724,7 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
@@ -759,6 +768,7 @@ impl Item {
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
Item::PackBrowser => "Resource pack browser",
|
||||
@@ -782,9 +792,10 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -833,6 +844,9 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -913,6 +927,7 @@ impl Item {
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
@@ -971,6 +986,7 @@ impl Item {
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
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::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
@@ -1088,7 +1104,7 @@ mod tests {
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles && !d.pregen);
|
||||
assert!(!d.music_page && d.music_off.is_empty());
|
||||
assert!(!d.wheel_cycles_settings);
|
||||
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();
|
||||
@@ -1127,6 +1143,7 @@ mod tests {
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
|
||||
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
@@ -8,7 +7,6 @@ use crate::{
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
use horizon_hooks::pages::{self as added, Act, Label};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
@@ -33,121 +31,6 @@ pub(crate) enum ExtraRow {
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
|
||||
Added(usize, usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered, all places.
|
||||
fn pages() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// The sections of this page, by their index among the registered pages.
|
||||
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
|
||||
let pages = pages().iter().copied().enumerate();
|
||||
pages.filter(|(_, page)| page.place() == added::Place::Extras)
|
||||
}
|
||||
|
||||
fn label_text(label: Label) -> String {
|
||||
match label {
|
||||
Label::Key(key) => lang::get(key),
|
||||
Label::Value(text) => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's group named `name`, made at the end if it has none.
|
||||
fn group_named<'a>(
|
||||
groups: &'a mut Vec<Group<ExtraRow>>,
|
||||
name: &'static str,
|
||||
) -> &'a mut Group<ExtraRow> {
|
||||
let at = groups.iter().position(|group| group.name == name);
|
||||
let at = at.unwrap_or_else(|| {
|
||||
groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
});
|
||||
&mut groups[at]
|
||||
}
|
||||
|
||||
/// The sections' rows, in the groups their titles and headings name.
|
||||
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
for (page, section) in sections() {
|
||||
let mut rows = Vec::new();
|
||||
section.rows(&mut rows);
|
||||
let mut name = section.title();
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
let id = ExtraRow::Added(page, r);
|
||||
let row = match row {
|
||||
added::Row::Heading(key) => {
|
||||
name = key;
|
||||
continue;
|
||||
}
|
||||
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
|
||||
added::Row::Toggle { label, on } => {
|
||||
Row::new(id, lang::get(label), Value::Switch(on))
|
||||
}
|
||||
added::Row::Cycle { label, value } => {
|
||||
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
|
||||
}
|
||||
added::Row::Slider { label, value, text } => {
|
||||
let position = value.clamp(0.0, 1.0);
|
||||
let text = label_text(text);
|
||||
let value = Value::Slider {
|
||||
position,
|
||||
steps: None,
|
||||
text,
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
}
|
||||
// A row kind newer than this engine.
|
||||
_ => continue,
|
||||
};
|
||||
let row = match section.tooltip(r) {
|
||||
Some(about) => row.about(label_text(about)),
|
||||
None => row,
|
||||
};
|
||||
group_named(groups, name).rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
|
||||
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
|
||||
let Some(section) = pages().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
section.act(r, act);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
|
||||
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
|
||||
fn steps(page: usize, r: usize) -> bool {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(section) = pages().get(page) {
|
||||
section.rows(&mut rows);
|
||||
}
|
||||
matches!(
|
||||
rows.get(r),
|
||||
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
@@ -202,14 +85,9 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let mut added = Vec::new();
|
||||
for (_, section) in sections() {
|
||||
section.rows(&mut added);
|
||||
}
|
||||
let cosmetics = horizon_cosmetics::enabled();
|
||||
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
@@ -229,7 +107,6 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
add_sections(&mut groups);
|
||||
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
@@ -248,29 +125,16 @@ impl Screen for ExtrasScreen {
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
ExtraRow::Added(page, r) => {
|
||||
act(page, r, if back { Act::PressBack } else { Act::Press })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
|
||||
if let ExtraRow::Added(page, r) = row {
|
||||
act(page, r, Act::Slide(position));
|
||||
}
|
||||
}
|
||||
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
|
||||
/// let the list scroll.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
|
||||
match row {
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
ExtraRow::Added(page, r) if steps(page, r) => {
|
||||
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
let ExtraRow::Item(item) = row else {
|
||||
return None;
|
||||
};
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,148 +292,4 @@ mod tests {
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
|
||||
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
|
||||
/// number that steps either way, a note and a volume.
|
||||
struct Section {
|
||||
on: std::sync::Mutex<bool>,
|
||||
step: std::sync::Mutex<i32>,
|
||||
volume: std::sync::Mutex<f32>,
|
||||
}
|
||||
impl added::Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.section"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.section.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Heading("test.section.heading"));
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.section.step",
|
||||
value: Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Note("a note".into()));
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.section.volume",
|
||||
value: *self.volume.lock().unwrap(),
|
||||
text: Label::Value(String::new()),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
match (row, act) {
|
||||
(0, Act::Press | Act::PressBack) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page for Horizon Settings: never on Extras.
|
||||
struct Elsewhere;
|
||||
impl added::Page for Elsewhere {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
fn with_section() -> &'static Section {
|
||||
let section: &'static Section = Box::leak(Box::new(Section {
|
||||
on: false.into(),
|
||||
step: 0.into(),
|
||||
volume: 0.5.into(),
|
||||
}));
|
||||
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
|
||||
&Elsewhere as &dyn added::Page,
|
||||
section as &dyn added::Page,
|
||||
]));
|
||||
TEST_PAGES.set(Some(pages));
|
||||
section
|
||||
}
|
||||
|
||||
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
|
||||
/// starts a group after the page's; a page for elsewhere adds nothing.
|
||||
#[test]
|
||||
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
|
||||
with_section();
|
||||
let s = Settings::default();
|
||||
let sheets = ExtrasScreen.sheets(&s);
|
||||
let groups = &sheets[0].groups;
|
||||
let ids = |name: &str| -> Vec<ExtraRow> {
|
||||
let group = groups.iter().find(|g| g.name == name).unwrap();
|
||||
group.rows.iter().map(|r| r.id).collect()
|
||||
};
|
||||
let multiplayer = ids("horizon.extras.header.multiplayer");
|
||||
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
|
||||
assert_eq!(groups.last().unwrap().name, "test.section.heading");
|
||||
let own = [
|
||||
ExtraRow::Added(1, 2),
|
||||
ExtraRow::Added(1, 3),
|
||||
ExtraRow::Added(1, 4),
|
||||
];
|
||||
assert_eq!(ids("test.section.heading"), own);
|
||||
let all = rows_of(&s);
|
||||
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
|
||||
}
|
||||
|
||||
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
|
||||
/// note takes no click; the rows show what the section holds after each.
|
||||
#[test]
|
||||
fn a_sections_rows_act_on_it() {
|
||||
let section = with_section();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let switch = ExtraRow::Added(1, 0);
|
||||
let at = shell.point_at(&s, SIZE, switch);
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(*section.on.lock().unwrap());
|
||||
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
|
||||
assert_eq!(value(&s, switch), Value::Switch(true));
|
||||
let step = ExtraRow::Added(1, 2);
|
||||
let mut screen = ExtrasScreen;
|
||||
screen.press(&mut s, step, true);
|
||||
assert_eq!(value(&s, step), Value::Choice("-1".into()));
|
||||
assert!(screen.wheel_step(&mut s, step, false).is_some());
|
||||
assert_eq!(*section.step.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
|
||||
None
|
||||
);
|
||||
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, note, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
|
||||
assert_eq!(*section.volume.lock().unwrap(), 0.25);
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,6 @@ mod reuse;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
mod off_page;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
@@ -251,7 +250,6 @@ mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
mod world_warp;
|
||||
use off_page::move_off_page;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -728,6 +726,62 @@ impl Part {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Starts moving a page of a large arena far larger than its ranges in use
|
||||
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
|
||||
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
|
||||
@@ -947,8 +1001,8 @@ pub struct Frame<'a> {
|
||||
pub outline_after_terrain: bool,
|
||||
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
|
||||
pub outline_high_contrast: bool,
|
||||
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
|
||||
pub pvp: crate::pvp::Frame,
|
||||
/// The outline, hurt overlay and glint colours a crate above set (`horizon_hooks::look`).
|
||||
pub look: horizon_hooks::look::Frame,
|
||||
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
|
||||
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
|
||||
/// over everything.
|
||||
@@ -1275,7 +1329,7 @@ pub struct Renderer {
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
stars: stars::Stars,
|
||||
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
custom_sky: custom_sky::CustomSky,
|
||||
custom_sky_draws: Vec<crate::custom_sky::Draw>,
|
||||
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
|
||||
@@ -5463,11 +5517,11 @@ impl Renderer {
|
||||
))
|
||||
});
|
||||
}
|
||||
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
|
||||
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
|
||||
self.custom_sky.set_pack(&self.device, &self.queue, pack);
|
||||
}
|
||||
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
|
||||
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
|
||||
/// frame, empty when the world's sky has none.
|
||||
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
|
||||
&mut self.custom_sky_draws
|
||||
@@ -8452,9 +8506,9 @@ impl Renderer {
|
||||
colored_count: [
|
||||
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
|
||||
// PvP & HUD's hit colour: entity.wgsl's hurt texel, 0 for vanilla's.
|
||||
frame.pvp.hurt_texel,
|
||||
frame.look.hurt_texel,
|
||||
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
|
||||
frame.pvp.glint,
|
||||
frame.look.glint,
|
||||
0,
|
||||
],
|
||||
emissive_layers: {
|
||||
@@ -9441,7 +9495,7 @@ impl Renderer {
|
||||
}
|
||||
};
|
||||
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
|
||||
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
|
||||
let (custom, scale) = frame.look.outline.map_or((None, 1.0), |o| (Some(o.0), o.1));
|
||||
if frame.outline_high_contrast {
|
||||
stroke(0xff00_0000, 7.0 * scale, lines);
|
||||
stroke(custom.unwrap_or(0xff57_ffe1), width * scale, lines);
|
||||
@@ -10062,11 +10116,7 @@ impl Renderer {
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
|
||||
// after the world pass, as `GameRenderer.render` hands them the main target's.
|
||||
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
|
||||
let keep_depth = pack_reads_world
|
||||
|| post_reads_depth
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth
|
||||
@@ -15668,11 +15718,11 @@ mod shadow_caster_tests {
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
|
||||
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
|
||||
let shadow = horizon_shaders::Shadow {
|
||||
let shadow = horizon_render_pipeline::Shadow {
|
||||
distance: 128.0,
|
||||
near,
|
||||
far,
|
||||
..horizon_shaders::Shadow::default()
|
||||
..horizon_render_pipeline::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = pack_post::ShadowView {
|
||||
|
||||
@@ -524,7 +524,7 @@ impl Scene {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
|
||||
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
|
||||
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
|
||||
use glam::Mat4;
|
||||
use horizon_optifine::sky::{Blend, Face};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::custom_sky::{Blend, Draw, SkyLayers};
|
||||
use crate::custom_sky::{CustomSky as Pack, Draw};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
@@ -84,76 +85,6 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
|
||||
/// the top row, `West`, `North`, `East` below.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Face {
|
||||
Bottom,
|
||||
Top,
|
||||
South,
|
||||
West,
|
||||
North,
|
||||
East,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
const ALL: [Self; 6] = [
|
||||
Self::Bottom,
|
||||
Self::Top,
|
||||
Self::South,
|
||||
Self::West,
|
||||
Self::North,
|
||||
Self::East,
|
||||
];
|
||||
|
||||
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
|
||||
fn cell(self) -> [f32; 4] {
|
||||
let (col, row) = match self {
|
||||
Self::Bottom => (0, 0),
|
||||
Self::Top => (1, 0),
|
||||
Self::South => (2, 0),
|
||||
Self::West => (0, 1),
|
||||
Self::North => (1, 1),
|
||||
Self::East => (2, 1),
|
||||
};
|
||||
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
|
||||
[u, v, u + 1.0 / 3.0, v + 0.5]
|
||||
}
|
||||
|
||||
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
|
||||
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
|
||||
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
|
||||
/// its cell, the bottom face north at the top (the template's `N` marks).
|
||||
fn corners(self) -> [[f32; 3]; 4] {
|
||||
match self {
|
||||
// Seen looking north: west on the left.
|
||||
Self::North => [
|
||||
[-1., 1., -1.],
|
||||
[1., 1., -1.],
|
||||
[1., -1., -1.],
|
||||
[-1., -1., -1.],
|
||||
],
|
||||
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
|
||||
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
|
||||
Self::West => [
|
||||
[-1., 1., 1.],
|
||||
[-1., 1., -1.],
|
||||
[-1., -1., -1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
// Looking up with north toward the cell's bottom edge: east on the right.
|
||||
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
|
||||
// Looking down with north toward the cell's top edge: east on the right.
|
||||
Self::Bottom => [
|
||||
[-1., -1., -1.],
|
||||
[1., -1., -1.],
|
||||
[1., -1., 1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
|
||||
fn mesh() -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(36);
|
||||
@@ -325,15 +256,10 @@ impl CustomSky {
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pack: &dyn SkyLayers,
|
||||
) {
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images()
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
@@ -487,38 +413,6 @@ impl CustomSky {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grid_holds_each_face_once_and_faces_point_out() {
|
||||
let mut cells: Vec<_> = Face::ALL
|
||||
.iter()
|
||||
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
|
||||
.collect();
|
||||
cells.dedup();
|
||||
assert_eq!(cells.len(), 6);
|
||||
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
|
||||
for face in Face::ALL {
|
||||
let c = face.corners();
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
|
||||
let n = [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
let centre: [f32; 3] =
|
||||
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
|
||||
// Seen from inside, u runs right and v down, so u x v points away from the camera.
|
||||
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
|
||||
assert!(dot > 0.0, "{face:?} is mirrored");
|
||||
}
|
||||
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
|
||||
assert_eq!(Face::Top.corners()[2][2], -1.0);
|
||||
assert_eq!(Face::Top.corners()[3][2], -1.0);
|
||||
// The bottom cell's top edge (v0) is north.
|
||||
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
|
||||
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
|
||||
}
|
||||
|
||||
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
|
||||
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
|
||||
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
|
||||
|
||||
@@ -6,7 +6,7 @@ struct Layer {
|
||||
// Projection * view (camera-relative) * the layer's rotation.
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
brightness: f32,
|
||||
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
|
||||
@@ -338,7 +338,7 @@ pub struct NativeFrame {
|
||||
reflection_sampler: wgpu::Sampler,
|
||||
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
|
||||
pub shadow_map: super::native_shadow::ShadowMap,
|
||||
shadow_camera: Option<horizon_shaders::Shadow>,
|
||||
shadow_camera: Option<horizon_render_pipeline::Shadow>,
|
||||
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
|
||||
/// is drawn and `has_reflection` stays 0.
|
||||
pub reflects: bool,
|
||||
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
|
||||
size: [u32; 2],
|
||||
seconds: f32,
|
||||
index: u32,
|
||||
shadow: Option<&horizon_shaders::Shadow>,
|
||||
shadow: Option<&horizon_render_pipeline::Shadow>,
|
||||
) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_render_pipeline::Shadow;
|
||||
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
//! `move_off_page`, moved out of `gfx.rs` unchanged.
|
||||
use super::*;
|
||||
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
pub(super) fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use horizon_shaders::{BufferObject, CustomImage};
|
||||
|
||||
pub(super) struct PackImage {
|
||||
pub(super) def: CustomImage,
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
|
||||
use horizon_shaders::{Group, Post};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
@@ -892,7 +893,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
|
||||
}
|
||||
|
||||
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
|
||||
let shadow_color = horizon_shaders::shadow_color(name);
|
||||
let shadow_color = horizon_render_pipeline::shadow_color(name);
|
||||
let shadow_depth = match name {
|
||||
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
|
||||
"shadowtex1" | "shadowtex1HW" => Some(1),
|
||||
@@ -911,7 +912,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
};
|
||||
}
|
||||
}
|
||||
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
|
||||
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
|
||||
// colortex buffers are not this pass's.
|
||||
return Source::Dummy;
|
||||
}
|
||||
@@ -925,7 +926,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
horizon_shaders::WATER_MASK => Source::WaterMask,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1582,7 +1583,7 @@ fn bindings(
|
||||
!unbound
|
||||
&& (t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_shaders::buffer_index(&t.name)
|
||||
|| horizon_render_pipeline::buffer_index(&t.name)
|
||||
.is_none_or(|b| released.1 & 1 << b == 0))
|
||||
});
|
||||
let image = pack
|
||||
@@ -2432,7 +2433,7 @@ impl PackPost {
|
||||
let shadow_config = post.config.shadow.clone();
|
||||
let noise_size = post.config.noise_resolution.unwrap_or(256);
|
||||
let half_lives = post.config.half_lives;
|
||||
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
|
||||
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
|
||||
if let Some(t) = ticks {
|
||||
notes.push(format!("`{name}` is {t} ticks"));
|
||||
}
|
||||
|
||||
@@ -602,7 +602,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -440,7 +440,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
|
||||
@@ -409,7 +409,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
|
||||
//! Square, `shadowMapResolution` pixels.
|
||||
|
||||
use horizon_shaders::SHADOW_COLORS;
|
||||
use horizon_render_pipeline::SHADOW_COLORS;
|
||||
|
||||
type Ops<T> = wgpu::Operations<T>;
|
||||
type Color = wgpu::Color;
|
||||
@@ -97,7 +97,11 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
|
||||
pub fn reset_history(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
) {
|
||||
let mut colors = 0;
|
||||
let mut doubled = 0;
|
||||
let formats = std::array::from_fn(|n| match &self.color[n] {
|
||||
@@ -125,7 +129,7 @@ impl ShadowTargets {
|
||||
/// resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
doubled: u16,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
|
||||
//! which of them a program's sampler reads.
|
||||
|
||||
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
|
||||
use horizon_render_pipeline::Kind;
|
||||
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
|
||||
|
||||
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
|
||||
pub(super) enum Image {
|
||||
@@ -118,12 +119,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
let buffer = horizon_shaders::buffer_index(name);
|
||||
let buffer = horizon_render_pipeline::buffer_index(name);
|
||||
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)
|
||||
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
|
||||
});
|
||||
staged.or_else(|| {
|
||||
textures.iter().position(|t| {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
//! [-w, w], and the depth buffers packs read hold GL window depth.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
|
||||
use horizon_shaders::Shadow;
|
||||
use horizon_render_pipeline::Shadow;
|
||||
|
||||
use super::Frame;
|
||||
|
||||
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
|
||||
pub half_lives: [Option<f32>; 4],
|
||||
}
|
||||
|
||||
@@ -2664,7 +2664,7 @@ pub(super) mod initial_history_tests {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -89,8 +89,6 @@ pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_formats;
|
||||
pub mod pack_hooks;
|
||||
pub mod pack_options_ui;
|
||||
pub mod pack_palette;
|
||||
pub mod pack_screen;
|
||||
|
||||
@@ -339,7 +339,6 @@ impl Opt {
|
||||
Opt::SharePresence => "options.sharePresence",
|
||||
Opt::DirectionalAudio => "options.directionalAudio",
|
||||
Opt::MusicToast => "options.musicToast",
|
||||
Opt::Page(page, row) => pages::slider_caption(page, row),
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -403,7 +402,6 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
Opt::Page(page, row) => pages::slider_kind(page, row),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -880,7 +878,6 @@ impl Opt {
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
Opt::Page(page, row) => pages::slider_text(page, row),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
@@ -955,7 +952,6 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
Opt::Page(page, row) => pages::slide(page, row, v),
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -1628,6 +1624,8 @@ pub struct OptionsUi {
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
|
||||
/// (page, row) ([`pages::PAGE_KEY`]).
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// The pages crates registered (`horizon_hooks::pages`).
|
||||
pages: &'static [&'static dyn horizon_hooks::pages::Page],
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
|
||||
@@ -1739,6 +1737,7 @@ impl Default for OptionsUi {
|
||||
scrollbar_drag_y: None,
|
||||
selected_key: None,
|
||||
selected_native_key: None,
|
||||
pages: horizon_hooks::pages::engine::all(),
|
||||
selected_pad_action: None,
|
||||
pad_glyphs: crate::pad_glyph::Glyphs::default(),
|
||||
pad_driving: false,
|
||||
@@ -1870,6 +1869,25 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Rows of an OptionsList: (big?, options) or a header.
|
||||
enum Row {
|
||||
Big(Opt),
|
||||
@@ -1889,17 +1907,14 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
return None;
|
||||
};
|
||||
let key = opt.tooltip_key(s);
|
||||
let Opt::Page(page, row) = opt else {
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
};
|
||||
return pages::tooltip(*page, *row);
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
};
|
||||
let mut tip = None;
|
||||
if let Act::Extra(item) = act {
|
||||
tip = Some(Text::literal(item.description_text()));
|
||||
}
|
||||
if let Act::Push(page) = act
|
||||
&& let Some(text) = pages::horizon_page_tooltip(*page)
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
{
|
||||
tip = Some(tr(text));
|
||||
}
|
||||
@@ -1919,7 +1934,6 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
}
|
||||
match act {
|
||||
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
|
||||
Act::PageRow(page, row) => pages::tooltip(*page, *row),
|
||||
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
|
||||
Some(installed) => {
|
||||
let description = installed
|
||||
|
||||
@@ -8,88 +8,9 @@ use horizon_hooks::pages as added;
|
||||
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
|
||||
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered.
|
||||
fn registered() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// A registered page's title (a translation key).
|
||||
pub(super) fn title(page: usize) -> &'static str {
|
||||
registered().get(page).map_or("", |p| p.title())
|
||||
}
|
||||
|
||||
/// Row `r` of a registered page, as it is now.
|
||||
fn row(page: usize, r: usize) -> Option<added::Row> {
|
||||
let mut rows = Vec::new();
|
||||
registered().get(page)?.rows(&mut rows);
|
||||
rows.into_iter().nth(r)
|
||||
}
|
||||
|
||||
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
|
||||
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { label, .. }) => label,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: its position.
|
||||
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: the value its message shows after the caption.
|
||||
pub(super) fn slider_text(page: usize, r: usize) -> Text {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { text, .. }) => label_text(text),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider moved.
|
||||
pub(super) fn slide(page: usize, r: usize, value: f32) {
|
||||
if let Some(registered) = registered().get(page) {
|
||||
registered.act(r, added::Act::Slide(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a registered page's row says on hover.
|
||||
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
|
||||
registered().get(page)?.tooltip(r).map(label_text)
|
||||
}
|
||||
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
added::engine::all().get(page).map_or("", |p| p.title())
|
||||
}
|
||||
|
||||
fn label_text(label: added::Label) -> Text {
|
||||
@@ -124,22 +45,29 @@ impl OptionsUi {
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.agent", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
];
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai)
|
||||
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
|
||||
)
|
||||
.collect();
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
|
||||
fn added_entries(&self, rows: &mut Vec<Row>) {
|
||||
let mut entries = registered()
|
||||
let mut entries = self
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
|
||||
@@ -151,7 +79,7 @@ impl OptionsUi {
|
||||
|
||||
/// A registered page's rows, asked of it each time the page is drawn.
|
||||
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
let Some(registered) = self.pages.get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
@@ -170,7 +98,6 @@ impl OptionsUi {
|
||||
added::Row::Note(text) => Row::Note(text),
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
@@ -193,7 +120,7 @@ impl OptionsUi {
|
||||
|
||||
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
|
||||
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
let Some(registered) = self.pages.get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
@@ -272,7 +199,7 @@ impl OptionsUi {
|
||||
) -> Vec<Event> {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
if name == PAGE_KEY {
|
||||
if let Some(page) = registered().get(i) {
|
||||
if let Some(page) = self.pages.get(i) {
|
||||
page.act(k, added::Act::Bind(value));
|
||||
}
|
||||
return vec![Event::Changed];
|
||||
@@ -294,12 +221,11 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
|
||||
/// A page with a heading, a switch, a number that steps either way and a key.
|
||||
struct Sample {
|
||||
on: Mutex<bool>,
|
||||
step: Mutex<i32>,
|
||||
key: Mutex<String>,
|
||||
volume: Mutex<f32>,
|
||||
}
|
||||
|
||||
impl added::Page for Sample {
|
||||
@@ -326,12 +252,6 @@ mod tests {
|
||||
label: "test.sample.key",
|
||||
key: self.key.lock().unwrap().clone(),
|
||||
});
|
||||
let volume = *self.volume.lock().unwrap();
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.sample.volume",
|
||||
value: volume,
|
||||
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: added::Act) {
|
||||
match (row, act) {
|
||||
@@ -342,20 +262,15 @@ mod tests {
|
||||
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
|
||||
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
other => panic!("row and act the page doesn't expect: {other:?}"),
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, row: usize) -> Option<added::Label> {
|
||||
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
|
||||
}
|
||||
}
|
||||
|
||||
static SAMPLE: Sample = Sample {
|
||||
on: Mutex::new(false),
|
||||
step: Mutex::new(0),
|
||||
key: Mutex::new(String::new()),
|
||||
volume: Mutex::new(0.0),
|
||||
};
|
||||
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
|
||||
|
||||
@@ -382,8 +297,10 @@ mod tests {
|
||||
#[test]
|
||||
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&PAGES));
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut ui = OptionsUi {
|
||||
pages: &PAGES,
|
||||
..OptionsUi::default()
|
||||
};
|
||||
ui.show(Page::Horizon);
|
||||
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
|
||||
assert_eq!(ui.page(), Page::Added(0));
|
||||
@@ -406,53 +323,5 @@ mod tests {
|
||||
assert_eq!(events, [Event::Changed]);
|
||||
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
|
||||
assert_eq!(ui.selected_key, None);
|
||||
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let slider = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the volume's slider");
|
||||
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
|
||||
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
|
||||
let volume = *SAMPLE.volume.lock().unwrap();
|
||||
assert!((0.45..0.55).contains(&volume), "{volume}");
|
||||
assert!(ui.release().contains(&Event::Changed));
|
||||
// The slider shows the page's value once let go.
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let shown = widgets.iter().find_map(|w| match w.kind {
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
value,
|
||||
..
|
||||
} => Some(value),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(shown, Some(volume));
|
||||
// The page's tooltip for its switch.
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 1),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the switch");
|
||||
assert_eq!(
|
||||
tooltip_text(&s, switch).map(|t| t.plain()),
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
|
||||
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
|
||||
//! and each frame's choice of layers, their brightness and rotation. A pack without
|
||||
//! `optifine/sky` has none, and the sky stays vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
|
||||
|
||||
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's sky blend for OptiFine's.
|
||||
fn blend(blend: Blend) -> engine::Blend {
|
||||
match blend {
|
||||
Blend::Add => engine::Blend::Add,
|
||||
Blend::Subtract => engine::Blend::Subtract,
|
||||
Blend::Multiply => engine::Blend::Multiply,
|
||||
Blend::Dodge => engine::Blend::Dodge,
|
||||
Blend::Burn => engine::Blend::Burn,
|
||||
Blend::Screen => engine::Blend::Screen,
|
||||
Blend::Replace => engine::Blend::Replace,
|
||||
Blend::Overlay => engine::Blend::Overlay,
|
||||
Blend::Alpha => engine::Blend::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
impl SkyLayers for CustomSky {
|
||||
fn images(&self) -> &[Image] {
|
||||
&self.images
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
|
||||
/// their fade times.
|
||||
fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &engine::Conditions,
|
||||
sun_turns: f32,
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
let conditions = &Conditions {
|
||||
day_time: conditions.day_time,
|
||||
weather: match conditions.weather {
|
||||
engine::Weather::Clear => Now::Clear,
|
||||
engine::Weather::Rain => Now::Rain,
|
||||
engine::Weather::Thunder => Now::Thunder,
|
||||
},
|
||||
biome: conditions.biome,
|
||||
y: conditions.y,
|
||||
};
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), 0.0);
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
*eased = target;
|
||||
target
|
||||
} else {
|
||||
let mut t = Transition { value: *eased };
|
||||
*eased = t.step(target, seconds, layer.transition);
|
||||
*eased
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: blend(layer.blend),
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::custom_sky::{Conditions, Weather};
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Weather::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, engine::Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
|
||||
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
mod custom_sky;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::pack_hooks::PackFormats;
|
||||
|
||||
/// OptiFine's and Polytone's formats.
|
||||
pub struct Formats;
|
||||
|
||||
impl PackFormats for Formats {
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
|
||||
pub fn install() {
|
||||
crate::pack_hooks::install(&Formats);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
|
||||
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
|
||||
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
|
||||
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
|
||||
//! packs load as vanilla reads them.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
|
||||
/// (its folder at `root`); the default is "nothing in this format".
|
||||
pub trait PackFormats: Send + Sync {
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
struct Vanilla;
|
||||
|
||||
impl PackFormats for Vanilla {}
|
||||
|
||||
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
|
||||
|
||||
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
|
||||
/// ignored, so each test or probe that needs the formats may install them.
|
||||
pub fn install(formats: &'static dyn PackFormats) {
|
||||
let _ = FORMATS.set(formats);
|
||||
}
|
||||
|
||||
/// The installed pack formats, or vanilla's alone.
|
||||
pub fn formats() -> &'static dyn PackFormats {
|
||||
FORMATS.get().copied().unwrap_or(&Vanilla)
|
||||
}
|
||||
@@ -31,7 +31,6 @@ mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
mod spawn;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -1223,9 +1222,8 @@ pub struct ParticleLevel<'a> {
|
||||
pub collision: &'a BlockCollisionRegistry<'a>,
|
||||
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
|
||||
pub player: Option<([f64; 3], f64)>,
|
||||
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
|
||||
}
|
||||
impl<'a> ParticleLevel<'a> {
|
||||
impl ParticleLevel<'_> {
|
||||
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.world,
|
||||
@@ -1233,6 +1231,9 @@ impl<'a> ParticleLevel<'a> {
|
||||
subject: None,
|
||||
};
|
||||
let movement = Vec3::new(d[0], d[1], d[2]);
|
||||
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
|
||||
// block of the swept box's cells can collide, it finds no shape and the
|
||||
// movement stands, as the resolution below would return it.
|
||||
if scene.cells_without_collision(bb.expand_towards(movement)) {
|
||||
return d;
|
||||
}
|
||||
@@ -1248,46 +1249,38 @@ impl<'a> ParticleLevel<'a> {
|
||||
p[2].floor() as i32,
|
||||
]
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let chunk = match self.cached_chunk.get() {
|
||||
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
|
||||
_ => {
|
||||
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
|
||||
self.cached_chunk.set(Some(((cx, cz), c)));
|
||||
c
|
||||
}
|
||||
};
|
||||
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
|
||||
}
|
||||
/// The fluid at a block and `FluidState.getHeight`.
|
||||
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
if s.fluid == Fluid::None {
|
||||
return (Fluid::None, 0.0);
|
||||
}
|
||||
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
|
||||
let h = if above.fluid == s.fluid {
|
||||
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
|
||||
let height = if above.fluid == s.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(s.fluid_level) / 9.0
|
||||
};
|
||||
(s.fluid, h)
|
||||
(s.fluid, height)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
|
||||
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
|
||||
let state = self.block(b[0], b[1], b[2]);
|
||||
let state = self.world.block(b[0], b[1], b[2]);
|
||||
match self.collision.cached_shapes().cached_shape(state) {
|
||||
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
|
||||
Ok(Some(_)) => 0.0,
|
||||
_ if self.facts.get(state).full_collision => 1.0,
|
||||
_ => 0.0,
|
||||
_ => {
|
||||
if self.facts.get(state).full_collision {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2027,6 +2020,73 @@ impl ParticleEngine {
|
||||
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
|
||||
})
|
||||
}
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if count == 0 {
|
||||
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
|
||||
self.add(options, at, v);
|
||||
return;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let r = &mut self.random;
|
||||
let p = [
|
||||
at[0] + r.next_gaussian() * spread[0],
|
||||
at[1] + r.next_gaussian() * spread[1],
|
||||
at[2] + r.next_gaussian() * spread[2],
|
||||
];
|
||||
let v = [
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
];
|
||||
self.add(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
|
||||
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
|
||||
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
if let Some(requests) = &mut self.created_requests {
|
||||
requests.push((options.clone(), p, v));
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(mut particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
|
||||
if let Some(registry) = &self.polytone
|
||||
&& registry.modifies_particles()
|
||||
{
|
||||
let id = match &options.mod_particle {
|
||||
Some(name) => name.clone(),
|
||||
None => format!("minecraft:{}", options.ty.name()),
|
||||
};
|
||||
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
|
||||
}
|
||||
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
|
||||
// shortcut: counts live plus queued particles by scanning instead of
|
||||
// vanilla's counted map; use a counter if spore spawning shows in profiles.
|
||||
if particle.ty == ParticleType::SporeBlossomAir
|
||||
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
|
||||
&& self
|
||||
.particles
|
||||
.iter()
|
||||
.chain(self.pending.iter())
|
||||
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
|
||||
.count()
|
||||
>= SPORE_BLOSSOM_LIMIT
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.pending.push(particle);
|
||||
}
|
||||
}
|
||||
/// How many particles of each type were made so far.
|
||||
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
|
||||
&self.made
|
||||
@@ -3992,12 +4052,15 @@ impl ParticleEngine {
|
||||
}
|
||||
Behaviour::Lava => {
|
||||
p.base_tick(level);
|
||||
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
if !p.removed {
|
||||
let odds = p.age as f32 / p.lifetime as f32;
|
||||
if p.random.next_float() > odds {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
@@ -4081,7 +4144,9 @@ impl ParticleEngine {
|
||||
*c *= f64::from(0.98f32);
|
||||
}
|
||||
if p.on_ground {
|
||||
p.removed |= p.random.next_float() < 0.5;
|
||||
if p.random.next_float() < 0.5 {
|
||||
p.removed = true;
|
||||
}
|
||||
p.velocity[0] *= f64::from(0.7f32);
|
||||
p.velocity[2] *= f64::from(0.7f32);
|
||||
}
|
||||
@@ -4121,10 +4186,13 @@ impl ParticleEngine {
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
|
||||
if !(fluid == Fluid::Water && source)
|
||||
|| p.position[1] >= y_end
|
||||
|| p.position[1] <= y_prev
|
||||
{
|
||||
if !(p.removed || (fluid == Fluid::Water && source)) {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] >= y_end {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] <= y_prev {
|
||||
p.removed = true;
|
||||
}
|
||||
// randomHorizontalWiggling, then a second, horizontal move.
|
||||
@@ -4148,8 +4216,9 @@ impl ParticleEngine {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
@@ -4613,7 +4682,7 @@ impl ParticleEngine {
|
||||
self.sprite_from_age(p);
|
||||
half(p);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
p.velocity[1] -= f64::from(0.0074f32);
|
||||
}
|
||||
}
|
||||
@@ -4621,7 +4690,7 @@ impl ParticleEngine {
|
||||
Behaviour::Firefly => {
|
||||
p.base_tick(level);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
p.removed = true;
|
||||
} else {
|
||||
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
|
||||
@@ -5085,12 +5154,11 @@ fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
a[0]
|
||||
} else {
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
return a[0];
|
||||
}
|
||||
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
struct Starter {
|
||||
@@ -5521,7 +5589,6 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.set_sprites.push(vec![10, 11, 12]);
|
||||
@@ -5613,7 +5680,6 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
|
||||
|
||||
@@ -124,7 +124,6 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut e = engine();
|
||||
let mut p = e
|
||||
|
||||
@@ -490,14 +490,14 @@ enum Subject<'a> {
|
||||
}
|
||||
|
||||
/// What one expression runs against: its subject, the frame and the level.
|
||||
struct Ctx<'a, 'l> {
|
||||
struct Ctx<'a> {
|
||||
subject: Subject<'a>,
|
||||
random: &'a mut LegacyRandom,
|
||||
custom: &'a mut f64,
|
||||
customs: &'a mut BTreeMap<String, f64>,
|
||||
frame: &'a Frame,
|
||||
registry: &'a Registry,
|
||||
level: Option<&'a ParticleLevel<'l>>,
|
||||
level: Option<&'a ParticleLevel<'a>>,
|
||||
blocks: Vec<[i32; 3]>,
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ fn axis(name: &str) -> usize {
|
||||
usize::from(name.as_bytes()[0] - b'x')
|
||||
}
|
||||
|
||||
impl Ctx<'_, '_> {
|
||||
impl Ctx<'_> {
|
||||
fn position(&self) -> [f64; 3] {
|
||||
match &self.subject {
|
||||
Subject::Particle(p) => p.position,
|
||||
@@ -683,7 +683,7 @@ impl Ctx<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for Ctx<'_, '_> {
|
||||
impl Host for Ctx<'_> {
|
||||
fn root(&mut self, root: Root) -> V {
|
||||
match root {
|
||||
Root::Object => V::Object(SELF),
|
||||
@@ -1662,7 +1662,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// Air has no emitters; the torch fires its first emitter only.
|
||||
@@ -1702,7 +1701,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
@@ -1724,7 +1722,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
@@ -1770,7 +1767,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let frame = registry.frame.clone();
|
||||
@@ -1824,7 +1820,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let mut engine = engine(registry);
|
||||
@@ -1858,7 +1853,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -1911,7 +1905,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -1973,7 +1966,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
|
||||
@@ -2022,7 +2014,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -2089,7 +2080,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
|
||||
// The chunk's biome is id 0: plains, whose foliage is #336699.
|
||||
@@ -2208,7 +2198,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
|
||||
@@ -2287,7 +2276,6 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// The test chunk's biome is the table's first: a cold one first, then a warm one.
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
//! Where particles come into the engine: a server's particle packet and a single particle made
|
||||
//! where the game asks for one.
|
||||
use super::*;
|
||||
|
||||
impl ParticleEngine {
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if count == 0 {
|
||||
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
|
||||
self.add(options, at, v);
|
||||
return;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let r = &mut self.random;
|
||||
let p = [
|
||||
at[0] + r.next_gaussian() * spread[0],
|
||||
at[1] + r.next_gaussian() * spread[1],
|
||||
at[2] + r.next_gaussian() * spread[2],
|
||||
];
|
||||
let v = [
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
];
|
||||
self.add(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
|
||||
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
|
||||
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
if let Some(requests) = &mut self.created_requests {
|
||||
requests.push((options.clone(), p, v));
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(mut particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
|
||||
if let Some(registry) = &self.polytone
|
||||
&& registry.modifies_particles()
|
||||
{
|
||||
let id = match &options.mod_particle {
|
||||
Some(name) => name.clone(),
|
||||
None => format!("minecraft:{}", options.ty.name()),
|
||||
};
|
||||
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
|
||||
}
|
||||
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
|
||||
// shortcut: counts live plus queued particles by scanning instead of
|
||||
// vanilla's counted map; use a counter if spore spawning shows in profiles.
|
||||
if particle.ty == ParticleType::SporeBlossomAir
|
||||
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
|
||||
&& self
|
||||
.particles
|
||||
.iter()
|
||||
.chain(self.pending.iter())
|
||||
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
|
||||
.count()
|
||||
>= SPORE_BLOSSOM_LIMIT
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.pending.push(particle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,6 @@ fn particle_tick_cost() {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! Horizon Settings > PvP & HUD. Every option is off by default, so the game looks like
|
||||
//! vanilla until the player changes one, and none of them shows anything vanilla doesn't
|
||||
//! already tell the player.
|
||||
use horizon_hooks::look::Look;
|
||||
|
||||
/// Colours the PvP options pick from. `Inverted` is vanilla's crosshair blend: the colour
|
||||
/// under it, inverted.
|
||||
@@ -263,15 +264,6 @@ impl ViewWeather {
|
||||
Self::ALL.into_iter().find(|c| c.name() == s)
|
||||
}
|
||||
}
|
||||
/// The day time this player sees with `fixed` set: the same day (the moon's phase follows the
|
||||
/// world's), at that time.
|
||||
pub fn view_day_time(world: i64, fixed: Option<ViewTime>) -> i64 {
|
||||
match fixed {
|
||||
Some(t) => world.div_euclid(24000) * 24000 + t.day_time(),
|
||||
None => world,
|
||||
}
|
||||
}
|
||||
|
||||
/// The crosshair's look. Its defaults draw vanilla's: a 15-pixel cross, one pixel thick,
|
||||
/// inverting what is under it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -528,24 +520,36 @@ impl Hit {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the renderer reads of these options each frame (`FrameInputs::pvp`); the default is
|
||||
/// vanilla's look.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Frame {
|
||||
/// The block outline's stroke ([`Outline::stroke`]).
|
||||
pub outline: Option<(u32, f32)>,
|
||||
/// The hurt overlay texel ([`Hit::texel`]), 0 for vanilla's.
|
||||
pub hurt_texel: u32,
|
||||
/// The glint's colour ([`Tweaks::glint_texel`]), 0 for vanilla's.
|
||||
pub glint: u32,
|
||||
}
|
||||
impl Pvp {
|
||||
pub fn frame(&self) -> Frame {
|
||||
Frame {
|
||||
outline: self.outline.stroke(),
|
||||
hurt_texel: self.hit.texel(),
|
||||
glint: self.tweaks.glint_texel(),
|
||||
}
|
||||
/// What the engine reads of these options (`horizon_hooks::look`): all it draws differently.
|
||||
/// Sets PvP & HUD's fields of `look`, the others' stay.
|
||||
pub fn apply(&self, look: &mut Look) {
|
||||
let (c, t) = (self.combat, self.tweaks);
|
||||
look.legacy_pose = c.legacy;
|
||||
look.hide_attack_indicator = c.legacy && !c.indicator;
|
||||
look.mute_attack_sounds = c.legacy && c.sounds;
|
||||
look.hide_attack_particles = c.legacy;
|
||||
look.frame.outline = self.outline.stroke();
|
||||
look.frame.hurt_texel = self.hit.texel();
|
||||
look.frame.glint = t.glint_texel();
|
||||
look.shield_size = t.shield_size;
|
||||
look.totem_scale = f32::from(t.totem_size) / 100.0;
|
||||
look.fire_lower = f32::from(t.fire_lower) / 100.0;
|
||||
look.clear_lava = t.clear_lava;
|
||||
look.clear_water = t.clear_water;
|
||||
look.chat.timestamps = self.chat.timestamps;
|
||||
look.chat.compact = self.chat.compact;
|
||||
look.chat.copy = self.chat.copy;
|
||||
look.chat.search = self.chat.search;
|
||||
look.hide_effect_icons = self.hud.effects;
|
||||
look.time_of_day = t.time.map(ViewTime::day_time);
|
||||
look.weather = t.weather.map(ViewWeather::levels);
|
||||
}
|
||||
/// [`Pvp::apply`] on vanilla's look.
|
||||
pub fn look(&self) -> Look {
|
||||
let mut look = Look::default();
|
||||
self.apply(&mut look);
|
||||
look
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,15 +745,6 @@ pub struct ChatOptions {
|
||||
/// Ctrl+F in the chat searches the chat's messages.
|
||||
pub search: bool,
|
||||
}
|
||||
impl ChatOptions {
|
||||
/// What `chat::Chat` lays its lines out with.
|
||||
pub fn qol(&self) -> crate::chat::ChatQol {
|
||||
crate::chat::ChatQol {
|
||||
timestamps: self.timestamps,
|
||||
compact: self.compact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Combat feel (`docs/pvp.md`): how fights look. Legacy draws them as 1.8.9 did; what the
|
||||
/// game sends to a server never changes, so a vanilla server and its anticheat see 26.3.
|
||||
@@ -766,21 +761,6 @@ pub struct Combat {
|
||||
/// (`horizon_client::legacy_combat`). Never used as a client of a server.
|
||||
pub rules: bool,
|
||||
}
|
||||
impl Combat {
|
||||
/// Whether this client leaves out sound `name` a server sent: under Legacy with its
|
||||
/// sounds, the `entity.player.attack.*` family (strong, weak, crit, sweep, knockback,
|
||||
/// nodamage), which 1.9 brought with the attack cooldown.
|
||||
pub fn mutes_sound(&self, name: &str) -> bool {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
self.legacy && self.sounds && name.starts_with("entity.player.attack.")
|
||||
}
|
||||
/// Whether this client leaves out particles of `ty` a server sent: under Legacy, the
|
||||
/// sweep and the damage hearts, which 1.9 brought (1.8.9's hits show crits only).
|
||||
pub fn hides_particle(&self, ty: crate::particles::ParticleType) -> bool {
|
||||
use crate::particles::ParticleType as T;
|
||||
self.legacy && matches!(ty, T::SweepAttack | T::DamageIndicator)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pvp {
|
||||
@@ -1707,18 +1687,6 @@ mod tests {
|
||||
assert_eq!(fresh.extras.pvp.tweaks.fire_lower, PRESET_FIRE_LOWER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_time_keeps_the_worlds_day() {
|
||||
// Day 9 at 5000: the moon's phase (the day number) stays, the time of day is the chosen.
|
||||
let world = 9 * 24000 + 5000;
|
||||
assert_eq!(view_day_time(world, None), world);
|
||||
assert_eq!(
|
||||
view_day_time(world, Some(ViewTime::Midnight)),
|
||||
9 * 24000 + 18000
|
||||
);
|
||||
assert_eq!(view_day_time(-100, Some(ViewTime::Noon)), -24000 + 6000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_glint_colour_reaches_the_shaders_as_rgb_bytes() {
|
||||
let mut t = Tweaks::default();
|
||||
@@ -1805,31 +1773,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy leaves out what 1.9 added to a hit: its sounds only with the sounds option, the
|
||||
/// sweep and hearts always; the indicator stays when kept. Modern changes nothing.
|
||||
#[test]
|
||||
fn legacy_leaves_out_what_1_9_added_to_a_hit() {
|
||||
use crate::particles::ParticleType as T;
|
||||
let modern = Combat::default();
|
||||
let legacy = Combat {
|
||||
legacy: true,
|
||||
..Combat::default()
|
||||
};
|
||||
let sounds = Combat {
|
||||
sounds: true,
|
||||
..legacy
|
||||
};
|
||||
for name in [
|
||||
"minecraft:entity.player.attack.strong",
|
||||
"entity.player.attack.sweep",
|
||||
"minecraft:entity.player.attack.nodamage",
|
||||
] {
|
||||
assert!(sounds.mutes_sound(name), "{name}");
|
||||
assert!(!legacy.mutes_sound(name), "only with the sounds option");
|
||||
assert!(!modern.mutes_sound(name));
|
||||
}
|
||||
// 1.8.9 had the hurt sound: it stays.
|
||||
assert!(!sounds.mutes_sound("minecraft:entity.player.hurt"));
|
||||
assert!(legacy.hides_particle(T::SweepAttack) && legacy.hides_particle(T::DamageIndicator));
|
||||
assert!(!legacy.hides_particle(T::Crit) && !legacy.hides_particle(T::EnchantedHit));
|
||||
assert!(!modern.hides_particle(T::SweepAttack));
|
||||
fn combat_feel_reaches_the_look() {
|
||||
let mut pvp = Pvp::default();
|
||||
assert_eq!(pvp.look(), Look::default(), "vanilla's look");
|
||||
let strong = "minecraft:entity.player.attack.strong";
|
||||
pvp.combat.legacy = true;
|
||||
let look = pvp.look();
|
||||
assert!(look.legacy_pose && look.hide_attack_particles && look.hide_attack_indicator);
|
||||
assert!(!look.mutes_sound(strong), "only with the sounds option");
|
||||
pvp.combat.sounds = true;
|
||||
pvp.combat.indicator = true;
|
||||
let look = pvp.look();
|
||||
assert!(look.mutes_sound(strong) && !look.hide_attack_indicator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_time_weather_and_sizes_reach_the_look() {
|
||||
let mut pvp = Pvp::default();
|
||||
pvp.tweaks.time = Some(ViewTime::Midnight);
|
||||
pvp.tweaks.weather = Some(ViewWeather::Thunder);
|
||||
pvp.tweaks.totem_size = 50;
|
||||
pvp.tweaks.fire_lower = 40;
|
||||
let look = pvp.look();
|
||||
assert_eq!(look.day_time(9 * 24000 + 5000), 9 * 24000 + 18000);
|
||||
assert_eq!(look.weather, Some((1.0, 1.0)));
|
||||
assert_eq!((look.totem_scale, look.fire_lower), (0.5, 0.4));
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,13 @@ impl Settings {
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
|
||||
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
|
||||
/// settings were made from P1's rather than loaded.
|
||||
pub fn load_stores(path: &Path) {
|
||||
let dir = path.parent();
|
||||
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
@@ -864,8 +871,6 @@ pub enum Opt {
|
||||
PadGameplayGuide,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
|
||||
Page(usize, usize),
|
||||
/// 3D skin layers: the player's outer layer as extruded slabs.
|
||||
SkinLayers3d,
|
||||
MainHand,
|
||||
|
||||
@@ -192,12 +192,7 @@ impl SignData {
|
||||
let side = |t: Option<&Tag>| -> SignText {
|
||||
let mut out = SignText::default();
|
||||
let Some(t) = t else { return out };
|
||||
if let Some(messages) = t.get("messages") {
|
||||
// `SignText.CODEC` reads a side all or nothing: a messages
|
||||
// field that is not a list leaves the side empty.
|
||||
let Tag::List(messages) = messages else {
|
||||
return SignText::default();
|
||||
};
|
||||
if let Some(Tag::List(messages)) = t.get("messages") {
|
||||
for (i, m) in messages.iter().take(4).enumerate() {
|
||||
out.lines[i] = component_text(m);
|
||||
out.click[i] = click_event(m);
|
||||
@@ -312,27 +307,6 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `SignText.CODEC` reads messages as a list: a string field fails the
|
||||
/// whole side, taking its colour and glow with it.
|
||||
#[test]
|
||||
fn messages_that_are_not_a_list_load_an_empty_side() {
|
||||
let tag = Tag::compound([(
|
||||
"front_text",
|
||||
Tag::compound([
|
||||
("messages", Tag::String("not a list".into())),
|
||||
("color", Tag::String("red".into())),
|
||||
("has_glowing_text", Tag::Byte(1)),
|
||||
]),
|
||||
)]);
|
||||
let mut decoded = SignData::default();
|
||||
decoded.decode(&tag);
|
||||
assert_eq!(
|
||||
decoded.front,
|
||||
SignText::default(),
|
||||
"a failed message list takes the side's colour and glow with it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waxed_and_glowing_text_take_any_number() {
|
||||
let tag = Tag::compound([
|
||||
|
||||
@@ -1238,9 +1238,6 @@ pub struct EditBox {
|
||||
/// Grey completion shown after the text (EditBox.setSuggestion).
|
||||
pub suggestion: Option<String>,
|
||||
pub text_color: u32,
|
||||
/// `EditBox.setCentered`: the text starts in the middle of the box's
|
||||
/// width, not at its left edge (`BookSignScreen`'s title).
|
||||
pub centered: bool,
|
||||
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
|
||||
/// made read-only, which still shows its value and caret.
|
||||
pub text_color_uneditable: u32,
|
||||
@@ -1539,7 +1536,6 @@ impl EditBox {
|
||||
focused_at: Instant::now(),
|
||||
suggestion: None,
|
||||
text_color: rgba(224, 224, 224, 255),
|
||||
centered: false,
|
||||
text_color_uneditable: rgba(112, 112, 112, 255),
|
||||
editable: true,
|
||||
message: String::new(),
|
||||
@@ -1563,16 +1559,6 @@ impl EditBox {
|
||||
self.y
|
||||
}
|
||||
}
|
||||
/// `EditBox.updateTextPosition`: where the text starts, `(width - text
|
||||
/// width) / 2` in for a centred box (vanilla's `textX`), else at
|
||||
/// `text_x` (4 in from a border).
|
||||
pub fn text_start_x(&self, text_width: f32) -> f32 {
|
||||
if self.centered {
|
||||
self.x + ((self.width - text_width) / 2.0).floor()
|
||||
} else {
|
||||
self.text_x()
|
||||
}
|
||||
}
|
||||
pub fn reset_blink(&mut self) {
|
||||
self.focused_at = Instant::now();
|
||||
}
|
||||
@@ -1655,13 +1641,8 @@ impl EditBox {
|
||||
/// EditBox.findClickedPositionInText: the character boundary under GUI `x`.
|
||||
pub fn position_at(&mut self, atlas: &HudAtlas, x: f32) -> usize {
|
||||
self.layout(atlas);
|
||||
let room = (x.floor() - self.text_x()).min(self.inner_width());
|
||||
let rest: String = self.value.chars().skip(self.display).collect();
|
||||
let shown: String = rest
|
||||
.chars()
|
||||
.take(atlas.font.fit(&rest, self.inner_width()))
|
||||
.collect();
|
||||
let start = self.text_start_x(atlas.font.width_text(&Text::literal(shown.as_str())));
|
||||
let room = (x.floor() - start).min(self.inner_width());
|
||||
self.display + atlas.font.fit(&rest, room)
|
||||
}
|
||||
/// EditBox.onClick: the cursor to the pointer, extending the selection
|
||||
@@ -1868,12 +1849,7 @@ impl EditBox {
|
||||
),
|
||||
}
|
||||
};
|
||||
let start = if self.centered {
|
||||
self.text_start_x(atlas.font.width_text(&Text::literal(displayed.as_str())))
|
||||
} else {
|
||||
self.text_x()
|
||||
};
|
||||
let (tx, ty) = (start, self.text_y());
|
||||
let (tx, ty) = (self.text_x(), self.text_y());
|
||||
let mut draw_x = tx;
|
||||
if !displayed.is_empty() {
|
||||
let half = if on_screen {
|
||||
@@ -2674,19 +2650,4 @@ mod tests {
|
||||
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
|
||||
assert_eq!(edit.value, "name");
|
||||
}
|
||||
|
||||
/// `EditBox.setCentered` (`BookSignScreen`'s title box): with the flag the
|
||||
/// text starts `(width - text width) / 2` in, which is also where a click
|
||||
/// maps; without it the text starts at the box's left edge.
|
||||
#[test]
|
||||
fn a_centred_box_starts_its_text_in_the_middle() {
|
||||
let mut edit = EditBox::new(10.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
assert_eq!(edit.text_start_x(0.0), 10.0);
|
||||
edit.centered = true;
|
||||
assert_eq!(edit.text_start_x(0.0), 67.0);
|
||||
assert_eq!(edit.text_start_x(18.0), 58.0);
|
||||
// (114 - 5) / 2 = 54.5, rounded down.
|
||||
assert_eq!(edit.text_start_x(5.0), 64.0);
|
||||
}
|
||||
}
|
||||
@@ -119,13 +119,15 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["friends"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
|
||||
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
|
||||
friends = ["dep:horizon-friends"]
|
||||
horizon-extras = ["discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
|
||||
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
|
||||
@@ -97,7 +97,6 @@ pub fn main() -> Result<()> {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(&root, &Default::default(), &Default::default());
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
|
||||
@@ -194,7 +194,6 @@ pub fn main() -> Result<()> {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let v = |key: &str| -> [f64; 3] {
|
||||
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
|
||||
|
||||
@@ -612,7 +612,6 @@ fn do_animate_tick(
|
||||
facts: ctx.facts,
|
||||
collision: ctx.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
engine.polytone_animate(&level, pos);
|
||||
let facts = ctx.facts.get(state);
|
||||
@@ -1995,10 +1994,8 @@ pub fn campfire_particles<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlock.makeParticles`: the cosy or signal smoke through
|
||||
/// `addAlwaysVisibleParticle(.., true, ..)` (it overrides the limiter but still
|
||||
/// rolls the Particles option); `smoking` adds the plain smoke puff at the logs
|
||||
/// through `addParticle`, so the limiter can drop it.
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
|
||||
/// `smoking` adds the plain smoke puff at the logs.
|
||||
pub fn make_campfire_smoke(
|
||||
engine: &mut ParticleEngine,
|
||||
pos: [i32; 3],
|
||||
@@ -2017,13 +2014,7 @@ pub fn make_campfire_smoke(
|
||||
} else {
|
||||
T::CampfireCosySmoke
|
||||
};
|
||||
engine.add_with(
|
||||
&ParticleOptions::simple(ty),
|
||||
[x, y, z],
|
||||
[0.0, 0.07, 0.0],
|
||||
true,
|
||||
true,
|
||||
);
|
||||
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
|
||||
if smoking {
|
||||
let r = engine.random();
|
||||
let dx = r.next_double() / 4.0 * sign(r);
|
||||
@@ -2033,49 +2024,13 @@ pub fn make_campfire_smoke(
|
||||
f64::from(pos[1]) + 0.4,
|
||||
f64::from(pos[2]) + 0.5 + dz,
|
||||
];
|
||||
add(engine, T::Smoke, at, [0.0, 0.005, 0.0]);
|
||||
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `CampfireBlock.makeParticles` through `ClientLevel.doAddParticle`: the
|
||||
/// cosy smoke overrides the limiter, the plain smoke does not, so it is
|
||||
/// dropped beyond 32 blocks and at Particles: Minimal, and at Decreased
|
||||
/// each puff is dropped when `calculateParticleLevel`'s `nextInt(3)` is 0.
|
||||
#[test]
|
||||
fn doused_campfire_smoke_goes_through_the_particle_limiter() {
|
||||
use horizon_core::random::RandomSource as _;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let run = |level: u8, camera: [f64; 3]| {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(5, 9, 0);
|
||||
engine.created_requests = Some(Vec::new());
|
||||
engine.particle_level = level;
|
||||
engine.camera = camera;
|
||||
for _ in 0..20 {
|
||||
make_campfire_smoke(&mut engine, [0, 64, 0], false, true);
|
||||
}
|
||||
let made = engine.created_requests.take().unwrap();
|
||||
let count = |ty| made.iter().filter(|(o, ..)| o.ty == ty).count();
|
||||
(count(T::CampfireCosySmoke), count(T::Smoke))
|
||||
};
|
||||
let near = [0.5, 65.0, 0.5];
|
||||
assert_eq!(run(0, near), (20, 20), "All");
|
||||
assert_eq!(run(2, near), (20, 0), "Minimal");
|
||||
assert_eq!(run(0, [100.5, 65.0, 0.5]), (20, 0), "100 blocks away");
|
||||
// Decreased: per puff the cosy smoke's roll, then the plain smoke's.
|
||||
let mut level = LegacyRandom::new(9);
|
||||
let kept = (0..20)
|
||||
.filter(|_| {
|
||||
level.next_int_bound(3).unwrap();
|
||||
level.next_int_bound(3).unwrap() != 0
|
||||
})
|
||||
.count();
|
||||
assert!(0 < kept && kept < 20);
|
||||
assert_eq!(run(1, near), (20, kept), "Decreased");
|
||||
}
|
||||
#[test]
|
||||
fn classifies_animated_blocks() {
|
||||
let none = |_: &str| None;
|
||||
|
||||
@@ -19,13 +19,13 @@ pub use horizon_client_render::{
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
|
||||
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
|
||||
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
|
||||
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
@@ -58,6 +58,8 @@ pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
#[cfg(feature = "discord")]
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
@@ -68,6 +70,7 @@ pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
pub mod first_person;
|
||||
pub mod flatgen;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
@@ -84,6 +87,7 @@ pub mod item_tooltip;
|
||||
pub mod join_seq;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod last_server;
|
||||
pub mod legacy_combat;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
|
||||
+29
-184
@@ -1,30 +1,21 @@
|
||||
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
|
||||
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
|
||||
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
|
||||
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
|
||||
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
|
||||
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
|
||||
//! or with `HORIZON_NO_DISCORD=1`.
|
||||
//!
|
||||
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
|
||||
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
|
||||
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
|
||||
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
|
||||
//! executable has no presence.
|
||||
#![forbid(unsafe_code)]
|
||||
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
|
||||
//! `game/discord.rs` says what the player does.
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::thread::Thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
|
||||
use horizon_platform::pages::{Act, Label, Page, Place, Row};
|
||||
use horizon_platform::settings::Store;
|
||||
use horizon_command::args::GameMode;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
@@ -49,134 +40,24 @@ const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
/// The presence's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Presence = Presence;
|
||||
|
||||
pub struct Presence;
|
||||
|
||||
impl Hooks for Presence {
|
||||
/// Starts the presence thread with the game's window (never for a dedicated server).
|
||||
fn client_started(&self, client: &hooks::Client) {
|
||||
start(client.automated);
|
||||
}
|
||||
|
||||
fn activity(&self, now: Activity) {
|
||||
set(match now {
|
||||
Activity::Menus => Status::Menus,
|
||||
Activity::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
} => Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
},
|
||||
// Anything a later game says that this crate doesn't know yet.
|
||||
_ => Status::Off,
|
||||
});
|
||||
}
|
||||
|
||||
/// On quit: the activity is cleared.
|
||||
fn shutdown(&self) {
|
||||
stop();
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
|
||||
const KEY: &str = "discordPresence";
|
||||
/// The switch's row, and what it says on hover.
|
||||
const LABEL: &str = "horizon.extras.discord_presence";
|
||||
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
|
||||
|
||||
/// The switch (on by default): the player's own settings' line, the game's first player's. A
|
||||
/// splitscreen player's settings file loads after the first player's and keeps it.
|
||||
static ON: AtomicBool = AtomicBool::new(true);
|
||||
/// Whether the player's settings were loaded once: the later loads are splitscreen players'.
|
||||
static LOADED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Turns the presence on or off; off clears the activity at once.
|
||||
pub fn set_enabled(on: bool) {
|
||||
ON.store(on, Ordering::Relaxed);
|
||||
let mut shared = lock();
|
||||
let status = shown(shared.game);
|
||||
let changed = shared.wanted.update(status, unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What Discord is told for the game's `status`: nothing with the switch off.
|
||||
fn shown(status: Status) -> Status {
|
||||
if enabled() { status } else { Status::Off }
|
||||
}
|
||||
|
||||
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
|
||||
pub static SETTING: Setting = Setting;
|
||||
|
||||
pub struct Setting;
|
||||
|
||||
impl Store for Setting {
|
||||
fn file(&self) -> &'static str {
|
||||
"horizon-extras.txt"
|
||||
}
|
||||
fn owns(&self, key: &str) -> bool {
|
||||
key == KEY
|
||||
}
|
||||
fn load(&self, _key: &str, value: &str) {
|
||||
// The first player's settings load first; the first load is theirs.
|
||||
if LOADED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
"true" => set_enabled(true),
|
||||
"false" => set_enabled(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
line(KEY, if enabled() { "true" } else { "false" });
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
enabled()
|
||||
}
|
||||
}
|
||||
|
||||
/// The switch's row in the Extras page's Multiplayer group, for
|
||||
/// `horizon_platform::pages::register`.
|
||||
pub static SECTION: Section = Section;
|
||||
|
||||
pub struct Section;
|
||||
|
||||
impl Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon-discord.extras"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
rows.push(Row::Toggle {
|
||||
label: LABEL,
|
||||
on: enabled(),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
if row == 0 && matches!(act, Act::Press | Act::PressBack) {
|
||||
set_enabled(!enabled());
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, _row: usize) -> Option<Label> {
|
||||
Some(Label::Key(ABOUT))
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world. Its address and name are never shown.
|
||||
Multiplayer,
|
||||
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
|
||||
/// may join.
|
||||
Hosting {
|
||||
players: usize,
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -216,15 +97,11 @@ impl Wanted {
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// What the game said last.
|
||||
game: Status,
|
||||
/// What Discord is to show: the game's, or nothing with the switch off.
|
||||
wanted: Wanted,
|
||||
stop: bool,
|
||||
stopped: bool,
|
||||
}
|
||||
static SHARED: Mutex<Shared> = Mutex::new(Shared {
|
||||
game: Status::Menus,
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
@@ -237,8 +114,7 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
|
||||
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
|
||||
/// The status [`set`] gave before, the switch included, is the first one sent.
|
||||
pub fn start(automated: bool) {
|
||||
pub fn start(enabled: bool, automated: bool) {
|
||||
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
|
||||
if THREAD.get().is_some()
|
||||
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|
||||
@@ -246,6 +122,9 @@ pub fn start(automated: bool) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
@@ -257,13 +136,12 @@ pub fn start(automated: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
pub fn set(status: Status) {
|
||||
let mut shared = lock();
|
||||
shared.game = status;
|
||||
let changed = shared.wanted.update(shown(status), unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
@@ -704,39 +582,6 @@ mod tests {
|
||||
|
||||
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
|
||||
#[cfg(unix)]
|
||||
/// The switch as the player's settings and the Extras page see it. The only test that
|
||||
/// touches the presence's statics.
|
||||
#[test]
|
||||
fn the_switch_clears_the_activity_and_keeps_the_first_players_line() {
|
||||
let wanted = || lock().wanted.status;
|
||||
Setting.load(KEY, "false");
|
||||
// A splitscreen player's settings file loads later and keeps the first player's.
|
||||
Setting.load(KEY, "true");
|
||||
set(Status::Menus);
|
||||
assert!(!enabled());
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
let mut rows = Vec::new();
|
||||
SECTION.rows(&mut rows);
|
||||
assert_eq!(
|
||||
rows,
|
||||
[Row::Toggle {
|
||||
label: LABEL,
|
||||
on: false
|
||||
}]
|
||||
);
|
||||
let mut lines = Vec::new();
|
||||
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
|
||||
assert_eq!(lines, ["discordPresence:false"]);
|
||||
assert!(!Setting.is_default());
|
||||
// On from the page: what the game said last is shown at once.
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Menus);
|
||||
assert!(Setting.is_default());
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
SECTION.act(0, Act::Press);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talks_to_discord_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
|
||||
@@ -84,7 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
|
||||
@@ -45,30 +45,6 @@ pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64
|
||||
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
|
||||
[flow.x, flow.y, flow.z]
|
||||
}
|
||||
/// `Entity.move`'s fall-reset ray along a move from `from`, up to 8 blocks:
|
||||
/// `ClipContext.Fluid.WATER` (the water's shape) and
|
||||
/// `ClipContext.Block.FALLDAMAGE_RESETTING` (a block `tagged` says is a reset
|
||||
/// block clips as a full block). Both shapes are queried for every visited
|
||||
/// block, the water's first (its first query fixes the shared cached shape);
|
||||
/// either one hitting ends the ray.
|
||||
pub fn fall_reset_hit(
|
||||
world: &World,
|
||||
fluids: &FluidResources,
|
||||
tagged: impl Fn(BlockPos) -> bool,
|
||||
from: Vec3,
|
||||
moved: Vec3,
|
||||
) -> bool {
|
||||
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
|
||||
let water = water_clip(world, fluids, from, to, p);
|
||||
let reset = tagged(p)
|
||||
&& horizon_core::geometry::VoxelShape::block()
|
||||
.clip(from, to, p)
|
||||
.is_some();
|
||||
(water || reset).then_some(())
|
||||
});
|
||||
matches!(hit, Ok(Some(())))
|
||||
}
|
||||
/// `ClipContext.Fluid.WATER` in a fall-reset ray through `p`: the water's
|
||||
/// shape from the shared `FlowingFluid.shapes` cache (the first query of a
|
||||
/// fluid state fixes its height), as the player's own ray reads it.
|
||||
|
||||
@@ -233,7 +233,7 @@ fn is_animal(id: &str) -> bool {
|
||||
use crate::mobs::persistent_anger;
|
||||
/// The single player's UUID (`offline_uuid("Player")`): the entity the
|
||||
/// neutral mobs are angry at.
|
||||
pub(crate) fn player_uuid() -> [i32; 4] {
|
||||
fn player_uuid() -> [i32; 4] {
|
||||
let u = horizon_protocol::crypto::offline_uuid("Player");
|
||||
[
|
||||
(u >> 96) as u32 as i32,
|
||||
@@ -1297,7 +1297,7 @@ pub fn mob_to_nbt(
|
||||
crate::mobs::nautilus::to_nbt(m, map);
|
||||
}
|
||||
"minecraft:sulfur_cube" => crate::mobs::sulfur_cube::to_nbt(m, map),
|
||||
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map, &mut uuid_of),
|
||||
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map),
|
||||
"minecraft:sniffer" => crate::mobs::sniffer::to_nbt(m, map),
|
||||
"minecraft:wither" => {
|
||||
map.insert("Invul".into(), Tag::Int(m.extra.counter));
|
||||
|
||||
@@ -13,8 +13,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
@@ -23,6 +21,11 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Auto-reconnect, a Horizon extra still in the engine crates, runs on the connection hooks as
|
||||
// the crates above do (horizon_client::reconnect; docs/layers.md, plan M5).
|
||||
if cfg!(feature = "horizon-extras") {
|
||||
let _ = horizon_hooks::register(&horizon_client::reconnect::HOOKS);
|
||||
}
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
|
||||
@@ -189,6 +189,52 @@ impl hud::Canvas for Canvas<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws on an open container screen (`horizon_hooks::containers`): `draw` gets a canvas whose
|
||||
/// (0, 0) is the panel's top left corner, at `origin` on the screen. It has no corners: `place`
|
||||
/// answers `None`.
|
||||
pub fn draw_on_panel(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
origin: (f32, f32),
|
||||
draw: impl FnOnce(&mut dyn hud::Canvas),
|
||||
) {
|
||||
draw(&mut Panel {
|
||||
batch,
|
||||
atlas,
|
||||
origin,
|
||||
});
|
||||
}
|
||||
|
||||
struct Panel<'a> {
|
||||
batch: &'a mut HudBatch,
|
||||
atlas: &'a HudAtlas,
|
||||
origin: (f32, f32),
|
||||
}
|
||||
|
||||
impl hud::Canvas for Panel<'_> {
|
||||
fn size(&self) -> (f32, f32) {
|
||||
(self.batch.width, self.batch.height)
|
||||
}
|
||||
fn tr(&self, key: &str) -> String {
|
||||
crate::lang::get(key)
|
||||
}
|
||||
fn text_width(&self, text: &str) -> f32 {
|
||||
HudBatch::text_width(self.atlas, text)
|
||||
}
|
||||
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool) {
|
||||
let (x, y) = (self.origin.0 + x, self.origin.1 + y);
|
||||
self.batch
|
||||
.text(self.atlas, text, x, y, argb_color(argb), shadow);
|
||||
}
|
||||
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32) {
|
||||
let (x, y) = (self.origin.0 + x, self.origin.1 + y);
|
||||
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
|
||||
}
|
||||
fn place(&mut self, _: hud::Corner, _: f32, _: f32) -> Option<(f32, f32)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
//! Play with Friends: Horizon's own sharing of the open world behind an
|
||||
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
|
||||
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
|
||||
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
|
||||
//! a screen to vanilla: the menu background, a centred title and text,
|
||||
//! vanilla's buttons and Done in the footer.
|
||||
use horizon_client::{
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
horizon_client::lang::get_or(key, english)
|
||||
crate::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
@@ -245,8 +245,8 @@ pub fn draw(
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if matches!(b.action, Action::Done) {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
crate::widgets::declare_action(
|
||||
crate::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
@@ -49,7 +49,6 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
|
||||
}
|
||||
}
|
||||
|
||||
mod activity;
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
@@ -129,6 +128,8 @@ mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
#[cfg(feature = "discord")]
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -236,6 +237,7 @@ mod llamas;
|
||||
mod loading_screen;
|
||||
mod loading_window;
|
||||
mod locator_bar;
|
||||
mod look;
|
||||
mod loom;
|
||||
mod mace;
|
||||
mod machines;
|
||||
@@ -382,7 +384,6 @@ mod potions;
|
||||
mod powder_snow;
|
||||
mod prediction;
|
||||
mod pumpkin;
|
||||
mod pvp;
|
||||
mod quick_actions;
|
||||
mod raids;
|
||||
mod random_textures;
|
||||
@@ -466,7 +467,6 @@ mod trial_spawner;
|
||||
mod trial_visuals;
|
||||
mod trident;
|
||||
mod tutorial;
|
||||
mod tweaks;
|
||||
mod ui_control;
|
||||
mod use_hand;
|
||||
mod using_item;
|
||||
@@ -649,7 +649,7 @@ enum Screen {
|
||||
/// WorldOptionsScreen (game/lan_screen.rs).
|
||||
WorldOptions,
|
||||
/// Play with Friends, from the pause menu's Friends button
|
||||
/// (game/friends.rs, game/friends/screen.rs).
|
||||
/// (`friends_screen`, game/friends.rs).
|
||||
Friends,
|
||||
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
|
||||
Social,
|
||||
@@ -2017,8 +2017,8 @@ pub struct PackResources {
|
||||
clouds: Arc<(u32, u32, Vec<bool>)>,
|
||||
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
|
||||
forcefield: Arc<(u32, u32, Vec<u8>)>,
|
||||
/// Custom sky layers (OptiFine's `optifine/sky`) with their textures; none without them.
|
||||
custom_sky: Arc<dyn horizon_client::custom_sky::SkyLayers>,
|
||||
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
|
||||
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
|
||||
/// The overworld's, Nether's and End's attribute stacks with the packs' Polytone biome and
|
||||
/// dimension modifiers (#11486), when a pack has some: what the frame draws.
|
||||
environments: Option<Arc<[Environment; 3]>>,
|
||||
@@ -2221,7 +2221,7 @@ struct Game<'a> {
|
||||
/// `AbstractContainerScreen`'s quick craft and skip-release state.
|
||||
inv_clicks: horizon_client::container_screen::Clicks,
|
||||
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
|
||||
tweaks: tweaks::Tweaks,
|
||||
tweaks: extras::mouse_tweaks::Tweaks,
|
||||
/// The wheel and hovered slot of `BundleMouseActions`.
|
||||
bundle_mouse: bundles::BundleMouse,
|
||||
/// The last inventory click, for the double click that gathers a stack.
|
||||
@@ -2810,10 +2810,9 @@ struct Game<'a> {
|
||||
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
|
||||
/// Recent frame times for the performance overlay.
|
||||
frame_graph: horizon_client::perf_overlay::FrameGraph,
|
||||
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
|
||||
/// Horizon's extras (mods' minimap and counters; PvP & HUD's): `game/extras.rs`.
|
||||
extras: extras::ExtrasState,
|
||||
/// The PvP & HUD options' state (`game/pvp.rs`).
|
||||
pvp: pvp::PvpState,
|
||||
pvp: extras::pvp::PvpState,
|
||||
/// Horizon mods (`game/mods.rs`), none running unless turned on.
|
||||
mods: mods::ModsState,
|
||||
/// Every item and recipe, for mods (`game/native_recipes.rs`), made once.
|
||||
@@ -2878,9 +2877,9 @@ struct Game<'a> {
|
||||
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
remote: Option<remote::Remote>,
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// The multiplayer server connected to last, and whether it is a Realm; the connection's
|
||||
/// lifecycle reaches the hooks through it (`last_server.rs`).
|
||||
last_server: horizon_client::last_server::LastServer,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
@@ -5489,6 +5488,34 @@ impl Game<'_> {
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
|
||||
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
|
||||
/// moves only while the world runs.
|
||||
pub(super) fn visual_ticks(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.world.game_time.max(0) as u64
|
||||
} else {
|
||||
self.ticks
|
||||
}
|
||||
}
|
||||
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
|
||||
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
|
||||
pub(super) fn texture_clock(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks()
|
||||
} else {
|
||||
self.texture_ticks
|
||||
}
|
||||
}
|
||||
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
|
||||
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
|
||||
pub(super) fn visual_millis(&self) -> i64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks() as i64 * 50
|
||||
} else {
|
||||
self.started.elapsed().as_millis() as i64
|
||||
}
|
||||
}
|
||||
/// `PresenceHandler.getPresenceStatus`: what an all-sharing player is doing, for
|
||||
/// the friends service's `updatePresence`.
|
||||
fn presence_status(&self) -> horizon_client::social::PresenceStatus {
|
||||
@@ -5503,7 +5530,7 @@ impl Game<'_> {
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
// `getCurrentServer().isRealm()`: the connection came from Realms.
|
||||
if self.realm_session {
|
||||
if self.last_server.realm {
|
||||
PresenceStatus::PlayingRealms
|
||||
} else {
|
||||
PresenceStatus::PlayingServer
|
||||
@@ -5846,7 +5873,7 @@ impl Game<'_> {
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
self.pad.guide_shown.clear();
|
||||
let mut batch = HudBatch::new(size);
|
||||
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
|
||||
batch.glint_colour = self.glint_texel();
|
||||
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
|
||||
batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32
|
||||
+ self.clock.fraction() as f32)
|
||||
@@ -5959,8 +5986,7 @@ impl Game<'_> {
|
||||
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
|
||||
// extractVehicleHealth and the experience bar, laid out by hud_layout.
|
||||
// Combat feel Legacy hides the attack indicator unless the player keeps it.
|
||||
let combat = self.settings.extras.pvp.combat;
|
||||
let indicator = if combat.legacy && !combat.indicator {
|
||||
let indicator = if self.attack_indicator_hidden() {
|
||||
0
|
||||
} else {
|
||||
self.settings.attack_indicator
|
||||
@@ -6024,7 +6050,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
|
||||
// The PvP & HUD effects widget takes the icons' place.
|
||||
if !g.pvp_effects_shown() {
|
||||
if !g.effect_icons_hidden() {
|
||||
g.draw_effect_icons(batch);
|
||||
}
|
||||
});
|
||||
@@ -6459,7 +6485,7 @@ impl Game<'_> {
|
||||
self.draw_hovered_slot_highlight(batch, false, ox, oy);
|
||||
self.draw_inventory_slots(batch, ox, oy);
|
||||
self.draw_hovered_slot_highlight(batch, true, ox, oy);
|
||||
self.draw_sort_buttons(batch, ox, oy);
|
||||
self.draw_container_extras(batch, ox, oy);
|
||||
self.draw_recipe_book(batch);
|
||||
let effect_tip = if self.container.is_none() && self.craft_size == 2 && !self.mount_active()
|
||||
{
|
||||
@@ -6474,6 +6500,18 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
// `Gui.setScreen` clears the old screen's focus before it goes: the
|
||||
// key that closed the container (Tab bound to the inventory) has
|
||||
@@ -6517,6 +6555,20 @@ impl Game<'_> {
|
||||
}
|
||||
self.close_ender_chest();
|
||||
}
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
@@ -9843,7 +9895,7 @@ impl Game<'_> {
|
||||
outline,
|
||||
outline_after_terrain,
|
||||
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
|
||||
pvp: self.settings.extras.pvp.frame(),
|
||||
look: self.look_frame(),
|
||||
debug_lines: &debug_lines,
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
@@ -10654,7 +10706,7 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Inventory
|
||||
&& d != 0.0
|
||||
&& !bundle
|
||||
&& !self.tweak_scroll(d, self.inventory_target().0)
|
||||
&& !self.container_scroll(d)
|
||||
{
|
||||
self.station_scroll(d);
|
||||
}
|
||||
@@ -10902,7 +10954,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
if pressed
|
||||
&& (self.sort_mouse_press(button)
|
||||
&& (self.container_mouse_press(button)
|
||||
|| self.hud_editor_key_press(&horizon_client::keybinds::mouse_name(
|
||||
button,
|
||||
)))
|
||||
@@ -11403,7 +11455,8 @@ impl Game<'_> {
|
||||
self.heal_hold_latches();
|
||||
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
|
||||
horizon_hooks::engine::published(self.is_published());
|
||||
self.report_activity();
|
||||
#[cfg(feature = "discord")]
|
||||
self.update_discord_presence();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
@@ -11443,7 +11496,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.extras_frame(elapsed);
|
||||
self.extras_retry()?;
|
||||
self.reconnect_when_due()?;
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
@@ -11554,7 +11607,7 @@ impl Game<'_> {
|
||||
self.settings.simulation_distance,
|
||||
self.settings.entity_distance,
|
||||
);
|
||||
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
|
||||
server.set_legacy_rules(self.pvp_legacy_rules());
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let mut consumed = false;
|
||||
@@ -12154,7 +12207,7 @@ impl<'a> Game<'a> {
|
||||
persist: Default::default(),
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
last_server: Default::default(),
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
@@ -12663,8 +12716,17 @@ pub fn run_with() -> Result<()> {
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
let game_dir = horizon_client::logging::player_dir();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
let player_dir = horizon_client::logging::player_dir();
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
if cfg!(feature = "horizon-extras")
|
||||
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
|
||||
{
|
||||
log::warn!("could not add the official server to servers.dat: {e}");
|
||||
}
|
||||
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
|
||||
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
|
||||
}
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
@@ -12692,8 +12754,15 @@ pub fn run_with() -> Result<()> {
|
||||
if options.execution_role.is_client() {
|
||||
narrator::start(&early_settings);
|
||||
}
|
||||
// A game window's hooks (Discord's presence): never for a server.
|
||||
activity::client_started(&options);
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
#[cfg(feature = "discord")]
|
||||
if options.dedicated.is_none() {
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
|
||||
}
|
||||
// Snapshots of derived tables live in the game folder, never beside the
|
||||
// shared resources.
|
||||
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
|
||||
//! layer's Discord presence shows it).
|
||||
use super::*;
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_hooks::{Activity, Client, Dimension, Play};
|
||||
|
||||
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
|
||||
/// `Hooks::client_started`.
|
||||
pub(super) fn client_started(options: &Options) {
|
||||
if options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_hooks::engine::activity(Activity::Menus);
|
||||
horizon_hooks::engine::client_started(&Client::new(automated));
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the hooks.
|
||||
pub(super) fn report_activity(&self) {
|
||||
// One status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
horizon_hooks::engine::activity(self.activity());
|
||||
}
|
||||
fn activity(&self) -> Activity {
|
||||
if self.title.is_some() {
|
||||
return Activity::Menus;
|
||||
}
|
||||
let max = horizon_client::lan::MAX_PLAYERS;
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Activity::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: match self.mode {
|
||||
GameMode::Survival => horizon_hooks::GameMode::Survival,
|
||||
GameMode::Creative => horizon_hooks::GameMode::Creative,
|
||||
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
|
||||
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,15 +686,23 @@ impl Game<'_> {
|
||||
if fall == 0.0 || length < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
|
||||
let reset = &self.res.motion().fall_reset;
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
|
||||
from,
|
||||
moved,
|
||||
);
|
||||
if hit { 0.0 } else { fall }
|
||||
let fluids = &self.res.motion().fluids;
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
|
||||
// A tagged block clips as a full block, not its collider.
|
||||
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
|
||||
if reset.contains(&**name) && VoxelShape::block().clip(from, to, p).is_some() {
|
||||
return Some(());
|
||||
}
|
||||
// The water's shape, through the shared fluid shape cache.
|
||||
horizon_client::entities::water_clip(&self.world, fluids, from, to, p).then_some(())
|
||||
});
|
||||
if matches!(hit, Ok(Some(()))) {
|
||||
0.0
|
||||
} else {
|
||||
fall
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
|
||||
/// `b` already where it landed): on the ground the fall resets; a move
|
||||
|
||||
@@ -33,8 +33,6 @@ fn book_title_box() -> ui::EditBox {
|
||||
edit.bordered = false;
|
||||
edit.max_length = 15;
|
||||
edit.text_color = BLACK;
|
||||
// BookSignScreen sets the title box centred.
|
||||
edit.centered = true;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
@@ -70,23 +68,6 @@ pub(super) enum BookScreen {
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
},
|
||||
}
|
||||
impl BookScreen {
|
||||
/// `BookSignScreen`'s constructor: its title box opens with the title
|
||||
/// kept from the last time, which Cancel stored (`titleValue`).
|
||||
fn begin_signing(&mut self) {
|
||||
if let Self::Edit {
|
||||
signing,
|
||||
title,
|
||||
title_edit,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
*signing = true;
|
||||
title_edit.set_value(title.as_str());
|
||||
title_edit.reset_blink();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The pages a book screen reads: a written book's components (so their
|
||||
/// click events stay), else a writable book's text.
|
||||
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
@@ -1005,7 +986,7 @@ impl Game<'_> {
|
||||
title,
|
||||
..
|
||||
}) if !title.trim().is_empty() => self.save_book(true),
|
||||
Some(book @ BookScreen::Edit { .. }) => book.begin_signing(),
|
||||
Some(BookScreen::Edit { signing, .. }) => *signing = true,
|
||||
_ => return Err("not a book and quill".into()),
|
||||
},
|
||||
Some("done") => match self.book.as_mut() {
|
||||
@@ -1207,8 +1188,8 @@ impl Game<'_> {
|
||||
self.turn_page(false);
|
||||
} else if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
if let Some(book) = self.book.as_mut() {
|
||||
book.begin_signing();
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = true;
|
||||
}
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
@@ -1637,19 +1618,6 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_box_tests {
|
||||
use super::book_title_box;
|
||||
|
||||
/// `BookSignScreen` sets its 15 character title box centred and unbordered.
|
||||
#[test]
|
||||
fn the_sign_title_box_is_centred() {
|
||||
let edit = book_title_box();
|
||||
assert!(edit.centered);
|
||||
assert!(!edit.bordered);
|
||||
assert_eq!(edit.max_length, 15);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod lectern_books_tag_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
@@ -1826,46 +1794,3 @@ mod lectern_reach_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sign_tests {
|
||||
use super::{BookScreen, book_page_box, book_title_box};
|
||||
|
||||
/// `BookSignScreen`'s constructor sets its title box to `titleValue`, what
|
||||
/// Cancel kept; reopening the sign screen shows the title typed before,
|
||||
/// not an empty box beside a stale `title`.
|
||||
#[test]
|
||||
fn the_sign_screen_opens_with_the_title_it_kept() {
|
||||
let mut book = BookScreen::Edit {
|
||||
slot: 0,
|
||||
pages: vec!["page".into()],
|
||||
page: 0,
|
||||
signing: false,
|
||||
title: "My Book".into(),
|
||||
title_edit: book_title_box(),
|
||||
page_edit: book_page_box(),
|
||||
};
|
||||
assert_eq!(book_title_value(&book), "");
|
||||
book.begin_signing();
|
||||
let BookScreen::Edit {
|
||||
signing,
|
||||
title,
|
||||
title_edit,
|
||||
..
|
||||
} = &book
|
||||
else {
|
||||
panic!("the book stays open");
|
||||
};
|
||||
assert!(signing);
|
||||
assert_eq!(title.as_str(), "My Book");
|
||||
assert_eq!(title_edit.value, "My Book");
|
||||
assert_eq!(title_edit.cursor, 7);
|
||||
}
|
||||
|
||||
fn book_title_value(book: &BookScreen) -> String {
|
||||
match book {
|
||||
BookScreen::Edit { title_edit, .. } => title_edit.value.clone(),
|
||||
BookScreen::View { .. } => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1278,7 +1278,7 @@ impl Game<'_> {
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shift = self.has_shift_down();
|
||||
// PvP & HUD's chat search: Ctrl+F starts and stops it, Enter stops it (sends nothing).
|
||||
if self.settings.extras.pvp.chat.search
|
||||
if self.chat_look().search
|
||||
&& match key {
|
||||
KeyCode::KeyF => ctrl,
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.pvp.chat_search,
|
||||
@@ -1487,7 +1487,7 @@ impl Game<'_> {
|
||||
cursor.1 as f64,
|
||||
);
|
||||
// PvP & HUD's copy on click: a message where the click has nothing else to do.
|
||||
if self.settings.extras.pvp.chat.copy
|
||||
if self.chat_look().copy
|
||||
&& !self.has_shift_down()
|
||||
&& style.as_ref().is_none_or(|s| s.click.is_none())
|
||||
&& let Some(message) =
|
||||
@@ -1665,8 +1665,8 @@ impl Game<'_> {
|
||||
/// Chat lines over the HUD (focused while the chat screen is open).
|
||||
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let options = self.settings.extras.pvp.chat;
|
||||
self.ui.chat.set_qol(&self.gui.atlas, options.qol());
|
||||
let options = self.chat_look();
|
||||
self.ui.chat.set_qol(&self.gui.atlas, self.chat_qol());
|
||||
// A search ends with its option; a chat closed some other way ends it in keep_chat_draft.
|
||||
if self.pvp.chat_search && self.ui.chat_screen.is_none() {
|
||||
// The chat went without closing (leaving the world): nothing to give back.
|
||||
|
||||
@@ -42,8 +42,8 @@ impl Reply {
|
||||
}
|
||||
|
||||
pub(super) fn duration(millis: i64) -> Text {
|
||||
use horizon_server_files::duration::{DurationUnit, duration_parts};
|
||||
let (count, unit) = duration_parts(millis);
|
||||
use horizon_chat_filter::DurationUnit;
|
||||
let (count, unit) = horizon_chat_filter::duration_parts(millis);
|
||||
let key = match (unit, count == 1) {
|
||||
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
|
||||
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
|
||||
@@ -93,7 +93,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
|
||||
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
|
||||
match words
|
||||
.get(2)
|
||||
.and_then(|w| horizon_server_files::duration::parse_duration(w))
|
||||
.and_then(|w| horizon_chat_filter::parse_duration(w))
|
||||
{
|
||||
Some(ms) => (Some(ms), 3),
|
||||
None => (None, 2),
|
||||
@@ -133,7 +133,7 @@ impl Game<'_> {
|
||||
count: u32,
|
||||
action: Action,
|
||||
) {
|
||||
let now = horizon_server_files::duration::now_millis();
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let summary = format!(
|
||||
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
|
||||
place.name(),
|
||||
@@ -281,7 +281,7 @@ impl Game<'_> {
|
||||
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
|
||||
match words {
|
||||
["mutelist"] => {
|
||||
let now = horizon_server_files::duration::now_millis();
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let mutes = host.hub.mutes();
|
||||
if mutes.is_empty() {
|
||||
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
|
||||
@@ -319,15 +319,13 @@ impl Game<'_> {
|
||||
let (time, from) = mute_time(words);
|
||||
vec![self.mute(host, name, time, reason(from), by)]
|
||||
}
|
||||
["tempmute", name, time, ..] => {
|
||||
match horizon_server_files::duration::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
)],
|
||||
}
|
||||
}
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
)],
|
||||
},
|
||||
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
|
||||
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
|
||||
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
|
||||
@@ -355,10 +353,10 @@ impl Game<'_> {
|
||||
let Some((uuid, name)) = user else {
|
||||
return Reply::no_player();
|
||||
};
|
||||
let until = time.map(|ms| horizon_server_files::duration::now_millis() + ms);
|
||||
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
|
||||
let what = time.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|ms| format!("for {}", horizon_server_files::duration::duration_text(ms)),
|
||||
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
|
||||
);
|
||||
let why = reason
|
||||
.as_deref()
|
||||
|
||||
@@ -227,16 +227,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
for m in self.mobs.iter() {
|
||||
// A mob with no effects swirls nothing: skipped before its size
|
||||
// is worked out, which every mob in a crowded world would pay
|
||||
// for (#14053).
|
||||
if !m.alive() || (m.extra.effect_particles.is_empty() && m.extra.effects.is_empty()) {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
}
|
||||
let size = (f64::from(m.width()), f64::from(m.height()));
|
||||
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
|
||||
if m.extra.effect_particles.is_empty() {
|
||||
// A mob of this game: its own effects, visible and not ambient.
|
||||
if m.extra.effects.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let list: Vec<_> = m
|
||||
.extra
|
||||
.effects
|
||||
|
||||
@@ -92,7 +92,6 @@ fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
|
||||
facts: game.res.facts(),
|
||||
collision: game.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
game.particles.tick(&level);
|
||||
};
|
||||
|
||||
@@ -1567,11 +1567,11 @@ impl Game<'_> {
|
||||
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
|
||||
"dirty_light": self.world.dirty_light.len(),
|
||||
"renderer": renderer,
|
||||
"display": self.window.as_deref().and_then(horizon_client::window::display).map(|d| json!({
|
||||
"width": d.width,
|
||||
"height": d.height,
|
||||
"scale": d.scale,
|
||||
"refresh_hz": d.refresh_hz,
|
||||
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
|
||||
"width": m.size().width,
|
||||
"height": m.size().height,
|
||||
"scale": m.scale_factor(),
|
||||
"refresh_hz": m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
})),
|
||||
"uptime_seconds": self.started.elapsed().as_secs_f64(),
|
||||
"server": self.remote_status(),
|
||||
|
||||
@@ -623,6 +623,3 @@ fn stack_drop_requires_ctrl_even_with_sprint_latched() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "play_ping_tests.rs"]
|
||||
mod play_ping_tests;
|
||||
@@ -2,10 +2,8 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod mob_geometry;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
mod spawn_eggs;
|
||||
|
||||
use super::{
|
||||
Dim, Game, Hand, InteractTarget, entity_culling::EntityCullDecision,
|
||||
@@ -7452,6 +7450,171 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
|
||||
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
|
||||
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
|
||||
/// does, and one egg used. False (PASS) when the mob has no baby form.
|
||||
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|
||||
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
|| parent.species() == Species::WanderingTrader
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (kind, at) = (
|
||||
parent.kind.id.clone(),
|
||||
[parent.position.x, parent.position.y, parent.position.z],
|
||||
);
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
|
||||
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
|
||||
// it south without a level-random draw.
|
||||
let Some(id) =
|
||||
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
if species == Species::Fox {
|
||||
let spawner = self.acting_player_uuid();
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
|
||||
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
|
||||
let hit = self.bucket_hit()?;
|
||||
matches!(
|
||||
&*self.block_name(hit.pos),
|
||||
"minecraft:water" | "minecraft:lava"
|
||||
)
|
||||
.then_some(hit)
|
||||
}
|
||||
/// Shared peaceful-mode eligibility for spawn eggs.
|
||||
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
|
||||
let kind = item.trim_end_matches("_spawn_egg");
|
||||
self.ui.difficulty != 0
|
||||
|| self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
|
||||
}
|
||||
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
|
||||
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
|
||||
let Some(hit) = (if fluid {
|
||||
self.spawn_egg_fluid_hit()
|
||||
} else {
|
||||
self.pick()
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
|
||||
if !self.spawn_egg_allowed(&stack.item) {
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
if fluid
|
||||
&& !self.may_build()
|
||||
&& !self.can_place_on_in_adventure([
|
||||
hit.pos[0] - hit.face[0],
|
||||
hit.pos[1] - hit.face[1],
|
||||
hit.pos[2] - hit.face[2],
|
||||
])
|
||||
{
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
|
||||
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
|
||||
stack,
|
||||
self.acting_player_is_op(),
|
||||
);
|
||||
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
|
||||
if done {
|
||||
self.swing_arm();
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// useOn: a clicked block without a collision shape (vines, grass)
|
||||
// takes the mob itself, any other the one on the clicked face.
|
||||
let p = if fluid || self.collision_empty(hit.pos) {
|
||||
hit.pos
|
||||
} else {
|
||||
[
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
};
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
// SpawnEggItem.spawnMob: `EntityType.spawn`.
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
|
||||
// `PostSpawnProcessor` puts the egg's components on it.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
{
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*self.held_mut() = None;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
|
||||
/// the mob it spawned, once the guards let it through, as
|
||||
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
|
||||
/// it, loaded back into the same mob (`/data merge entity`'s loader).
|
||||
pub(super) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let op = self.acting_player_is_op();
|
||||
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let own = self.mob_tree_nbt(i);
|
||||
let tag = horizon_client::data_cmd::merged(&own, data);
|
||||
// Nothing to report: the mob's own compound always loads back.
|
||||
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
|
||||
}
|
||||
/// A beam target's centre (half its height), interpolated.
|
||||
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
|
||||
match who? {
|
||||
@@ -7622,6 +7785,624 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
// The coarse mob bounds pay off only in dense async scenes; otherwise,
|
||||
// keep the existing exact mesh-culling path.
|
||||
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
|
||||
&& self
|
||||
.async_occlusion_culler
|
||||
.as_ref()
|
||||
.is_some_and(|culler| culler.camera_is_stationary())
|
||||
&& self.settings.horizon.entity_culling_precheck != 0
|
||||
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
|
||||
&& !self.pack_shadow_casters.0
|
||||
&& horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
self.mob_geometry_inner::<true>(camera, alpha, out);
|
||||
} else {
|
||||
self.mob_geometry_inner::<false>(camera, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn mob_geometry_inner<const PRECHECK: bool>(
|
||||
&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,
|
||||
day_time: self.world.day_time as f64 + alpha,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0.0,
|
||||
Dim::Nether => -1.0,
|
||||
Dim::End => 1.0,
|
||||
},
|
||||
player: self.player.as_ref().map_or(camera.to_array(), |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
}),
|
||||
player_rot: [self.pitch, self.yaw],
|
||||
paused: self.pause,
|
||||
first_person: self.camera_type.first_person(),
|
||||
third_person_reversed: self.camera_type
|
||||
== super::perspective::CameraType::ThirdPersonFront,
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
// LevelRenderer: the entity the view looks out of is not drawn
|
||||
// in first person.
|
||||
if self.hides_as_camera(mob.id) {
|
||||
continue;
|
||||
}
|
||||
let is_player = &*mob.kind.id == "minecraft:player";
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
|
||||
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
|
||||
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
|
||||
if let Some(view) = &self.view_frustum
|
||||
&& beam.is_none()
|
||||
{
|
||||
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
|
||||
let (lo, hi) = (
|
||||
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
|
||||
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
|
||||
);
|
||||
if !view.intersects(lo, hi) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
// A player keeps its armour, elytra and held items
|
||||
// (`LivingEntityRenderer.submit` skips only the body).
|
||||
let layers_only = mob.invisible && outline.is_none() && is_player;
|
||||
if mob.invisible && outline.is_none() && !layers_only {
|
||||
continue;
|
||||
}
|
||||
let culling = if PRECHECK
|
||||
&& outline.is_none()
|
||||
&& !is_player
|
||||
&& mob.kind.id.starts_with("minecraft:")
|
||||
&& [
|
||||
&mob.extra.head,
|
||||
&mob.extra.chest,
|
||||
&mob.extra.legs,
|
||||
&mob.extra.feet,
|
||||
&mob.extra.main_hand,
|
||||
&mob.extra.off_hand,
|
||||
]
|
||||
.iter()
|
||||
.all(|item| item.is_none())
|
||||
&& beam.is_none()
|
||||
{
|
||||
self.entity_cull_decision(mob, alpha)
|
||||
} else {
|
||||
EntityCullDecision::CheckVertices
|
||||
};
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
|
||||
// probe (its eye) is in, at the position this frame draws rather
|
||||
// than this tick's.
|
||||
let probe = mob.light_probe(alpha);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
|
||||
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
|
||||
// renderers' overrides; the sky light stays the level's.
|
||||
if mob.fire_ticks > 0
|
||||
|| matches!(
|
||||
mob.species(),
|
||||
Species::Allay
|
||||
| Species::Blaze
|
||||
| Species::MagmaCube
|
||||
| Species::Vex
|
||||
| Species::Wither
|
||||
)
|
||||
{
|
||||
packed = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
}
|
||||
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
|
||||
// dark ticks run out (the block light is the second byte).
|
||||
if mob.species() == Species::GlowSquid {
|
||||
let lit = (packed >> 12) & 15;
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
// A native mod's own model in place of the vanilla one
|
||||
// (`native_mob_submit`), before any of the vanilla model's parts
|
||||
// (a sulfur cube's swallowed block) are drawn.
|
||||
// A player's own hook (`native_player_submit`) is asked for the
|
||||
// other players in view.
|
||||
let who = is_player
|
||||
.then(|| self.remote_player_name(mob.id))
|
||||
.flatten()
|
||||
.map(|name| NativePlayerRender { name, local: false });
|
||||
let modded = if layers_only {
|
||||
false
|
||||
} else if let Some(who) = who {
|
||||
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
|
||||
} else {
|
||||
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
};
|
||||
if modded {
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if outline.is_none() {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
self.sulfur_cube_body_geometry(
|
||||
mob,
|
||||
alpha as f32,
|
||||
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
|
||||
let armor = if is_player {
|
||||
self.worn_armor_layers(mob)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
|
||||
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
|
||||
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
|
||||
// then only the outline draws.
|
||||
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
|
||||
let posable = skinning
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& silhouette_on_gpu
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
|
||||
);
|
||||
let mut skinned = None;
|
||||
if posable {
|
||||
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::new(&mut models, &mut frame);
|
||||
horizon_client::mobs::render::render_skinned(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
&mut skin,
|
||||
);
|
||||
skinned = Some((mark, skin.bounds));
|
||||
} else {
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
|
||||
// fs_hdr_mark); a tier's window lifts only their bright texels.
|
||||
if let Some(tier) = match mob.species() {
|
||||
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
|
||||
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
|
||||
_ => None,
|
||||
} {
|
||||
for v in &mut out[body..] {
|
||||
v.layer |= u32::from(tier) << 16;
|
||||
}
|
||||
}
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
let layers = horizon_client::mobs::render::layers_shown(mob);
|
||||
if is_player && layers {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
|
||||
// and SpinAttackEffectLayer. Only the cape hides with the body;
|
||||
// the others have no `isInvisible` check.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
if !layers_only {
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
if let Some((_, name)) = self
|
||||
.remote_player_names()
|
||||
.into_iter()
|
||||
.find(|(id, _)| *id == mob.id)
|
||||
{
|
||||
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& super::cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
None => (mark, Some((lo, hi))),
|
||||
});
|
||||
}
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
// Held items join the GPU path's draws, their silhouette too.
|
||||
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if let Some(((first, _, _), _)) = skinned {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.skin_frame.borrow_mut().outline_since(first, color);
|
||||
}
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Glowing outlines and a guardian's beam remain visible independently
|
||||
// of whether the body is hidden behind terrain.
|
||||
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = beam
|
||||
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
|
||||
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
|
||||
{
|
||||
let a = alpha;
|
||||
let eye = glam::DVec3::new(
|
||||
mob.previous.x + (mob.position.x - mob.previous.x) * a,
|
||||
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
|
||||
mob.previous.z + (mob.position.z - mob.previous.z) * a,
|
||||
) - camera;
|
||||
horizon_client::mobs::guardian::beam_geometry(
|
||||
eye.as_vec3(),
|
||||
(target - camera).as_vec3(),
|
||||
time,
|
||||
scale,
|
||||
uv,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
// What follows isn't a tracked mob: close the last one's span.
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
|
||||
let live = self.creatures.projectiles.iter().map(|a| {
|
||||
let at = [
|
||||
a.previous.x + (a.position.x - a.previous.x) * alpha,
|
||||
a.previous.y + (a.position.y - a.previous.y) * alpha,
|
||||
a.previous.z + (a.position.z - a.previous.z) * alpha,
|
||||
];
|
||||
(a, at)
|
||||
});
|
||||
// ItemPickupParticle: a picked-up arrow flying to its collector.
|
||||
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
|
||||
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
|
||||
_ => None,
|
||||
});
|
||||
// A glowing projectile's outline: what it drew up to the next one.
|
||||
let mut glowing = None;
|
||||
for (a, at) in live.chain(taken) {
|
||||
if let Some((start, rgb)) = glowing.take() {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
if a.id != 0
|
||||
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
|
||||
{
|
||||
glowing = Some((out.len(), rgb));
|
||||
}
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
// EntityRenderer.getPackedLightCoords: the block its light probe
|
||||
// (the eye) is in, at the position this frame draws rather than
|
||||
// this tick's, and not the one under its feet.
|
||||
let light = self.world.light(
|
||||
at[0].floor() as i32,
|
||||
a.light_probe_y(alpha).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
|
||||
// on fire are drawn at 15 block light (the fireball renderers'
|
||||
// own override); the sky light stays the level's.
|
||||
if let Some(block) = a.block_light_override() {
|
||||
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
|
||||
}
|
||||
if a.is_arrow() {
|
||||
let Some(model) = self.arrow_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
|
||||
let texture = if a.is_spectral_arrow() {
|
||||
"entity/projectiles/arrow_spectral"
|
||||
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
|
||||
"entity/projectiles/arrow_tipped"
|
||||
} else {
|
||||
"entity/projectiles/arrow"
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(texture).copied() else {
|
||||
continue;
|
||||
};
|
||||
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
|
||||
// and the same for the pitch, so a turning arrow is drawn
|
||||
// between the two ticks it turned over.
|
||||
let alpha32 = alpha as f32;
|
||||
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
|
||||
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z(pitch.to_radians());
|
||||
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
|
||||
// rolls the root while the shake timer runs.
|
||||
let shake = a
|
||||
.arrow
|
||||
.as_ref()
|
||||
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
|
||||
let roll = if shake > 0.0 {
|
||||
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pose_for = |part: &horizon_client::mobs::Part| {
|
||||
let mut pose = part.pose;
|
||||
if std::ptr::eq(part, model) {
|
||||
pose[5] += roll;
|
||||
}
|
||||
pose
|
||||
};
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
m,
|
||||
&pose_for,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
|
||||
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ThrownTridentRenderer.
|
||||
if matches!(a.kind, ProjectileKind::Trident) {
|
||||
let Some(model) = self.trident_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
|
||||
continue;
|
||||
};
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
|
||||
let poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
m,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// WindChargeRenderer.
|
||||
if matches!(
|
||||
a.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
) {
|
||||
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
|
||||
continue;
|
||||
}
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
let model = &self.entity_models.shulker_bullet;
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
use horizon_core::geometry::trig;
|
||||
let tc = a.age as f32 + alpha as f32;
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("main", a.yaw.to_radians());
|
||||
poses.xrot("main", a.pitch.to_radians());
|
||||
let light = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_x(
|
||||
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_z(
|
||||
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
|
||||
let uv = [r.u0, r.v0, r.u1, r.v1];
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// The translucent shell blends over the opaque core
|
||||
// (`RenderTypes.entityTranslucent`).
|
||||
let shell = out.len();
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
|
||||
&poses,
|
||||
uv,
|
||||
0x26FF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
horizon_client::gfx::mark_oit(&mut out[shell..]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::wither::skull_kind(a) {
|
||||
let model = &self.entity_models.wither_skull;
|
||||
let tex = if dangerous {
|
||||
"entity/wither/wither_invulnerable"
|
||||
} else {
|
||||
"entity/wither/wither"
|
||||
};
|
||||
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("head", a.yaw.to_radians());
|
||||
poses.xrot("head", a.pitch.to_radians());
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(stack) = a.drawn_item() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation());
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
// `Entity.displayFireAnimation`: a thrown item that flew through a
|
||||
// fire block burns (`ThrowableProjectile.tick`'s
|
||||
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
|
||||
// over its box as any other entity renderer does.
|
||||
if a.on_fire() {
|
||||
let size = a.kind.size() as f32;
|
||||
flame_geometry(atlas, c, size, size, self.yaw, light, out);
|
||||
}
|
||||
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
|
||||
// returns (a fire charge until `setItem` gave it another), its model
|
||||
// at the renderer's scale for the kind. `packed` is already 15, the
|
||||
// renderer's full bright.
|
||||
if let Some(stack) = a.item.as_ref() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation())
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
let Some(name) = a.item_sprite() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let lift = is_item_billboard(&a.kind);
|
||||
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
|
||||
for (v, uv) in quad
|
||||
.iter()
|
||||
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
|
||||
{
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((start, rgb)) = glowing {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownItemRenderer.submit` turns the item by `CameraRenderState.orientation`
|
||||
/// (`Camera.setRotation`): the billboard's right and up axes are the camera's.
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
//! Drawing the mobs, players and projectiles the client sees, each frame (`mob_geometry`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(in crate::game) fn mob_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// The coarse mob bounds pay off only in dense async scenes; otherwise,
|
||||
// keep the existing exact mesh-culling path.
|
||||
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
|
||||
&& self
|
||||
.async_occlusion_culler
|
||||
.as_ref()
|
||||
.is_some_and(|culler| culler.camera_is_stationary())
|
||||
&& self.settings.horizon.entity_culling_precheck != 0
|
||||
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
|
||||
&& !self.pack_shadow_casters.0
|
||||
&& horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
self.mob_geometry_inner::<true>(camera, alpha, out);
|
||||
} else {
|
||||
self.mob_geometry_inner::<false>(camera, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn mob_geometry_inner<const PRECHECK: bool>(
|
||||
&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,
|
||||
day_time: self.world.day_time as f64 + alpha,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0.0,
|
||||
Dim::Nether => -1.0,
|
||||
Dim::End => 1.0,
|
||||
},
|
||||
player: self.player.as_ref().map_or(camera.to_array(), |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
}),
|
||||
player_rot: [self.pitch, self.yaw],
|
||||
paused: self.pause,
|
||||
first_person: self.camera_type.first_person(),
|
||||
third_person_reversed: self.camera_type
|
||||
== super::super::perspective::CameraType::ThirdPersonFront,
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
// LevelRenderer: the entity the view looks out of is not drawn
|
||||
// in first person.
|
||||
if self.hides_as_camera(mob.id) {
|
||||
continue;
|
||||
}
|
||||
let is_player = &*mob.kind.id == "minecraft:player";
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
|
||||
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
|
||||
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
|
||||
if let Some(view) = &self.view_frustum
|
||||
&& beam.is_none()
|
||||
{
|
||||
let (lo, hi) = super::super::entity_culling::cull_box(mob, alpha);
|
||||
let (lo, hi) = (
|
||||
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
|
||||
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
|
||||
);
|
||||
if !view.intersects(lo, hi) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
// A player keeps its armour, elytra and held items
|
||||
// (`LivingEntityRenderer.submit` skips only the body).
|
||||
let layers_only = mob.invisible && outline.is_none() && is_player;
|
||||
if mob.invisible && outline.is_none() && !layers_only {
|
||||
continue;
|
||||
}
|
||||
let culling = if PRECHECK
|
||||
&& outline.is_none()
|
||||
&& !is_player
|
||||
&& mob.kind.id.starts_with("minecraft:")
|
||||
&& [
|
||||
&mob.extra.head,
|
||||
&mob.extra.chest,
|
||||
&mob.extra.legs,
|
||||
&mob.extra.feet,
|
||||
&mob.extra.main_hand,
|
||||
&mob.extra.off_hand,
|
||||
]
|
||||
.iter()
|
||||
.all(|item| item.is_none())
|
||||
&& beam.is_none()
|
||||
{
|
||||
self.entity_cull_decision(mob, alpha)
|
||||
} else {
|
||||
EntityCullDecision::CheckVertices
|
||||
};
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
|
||||
// probe (its eye) is in, at the position this frame draws rather
|
||||
// than this tick's.
|
||||
let probe = mob.light_probe(alpha);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
|
||||
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
|
||||
// renderers' overrides; the sky light stays the level's.
|
||||
if mob.fire_ticks > 0
|
||||
|| matches!(
|
||||
mob.species(),
|
||||
Species::Allay
|
||||
| Species::Blaze
|
||||
| Species::MagmaCube
|
||||
| Species::Vex
|
||||
| Species::Wither
|
||||
)
|
||||
{
|
||||
packed = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
}
|
||||
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
|
||||
// dark ticks run out (the block light is the second byte).
|
||||
if mob.species() == Species::GlowSquid {
|
||||
let lit = (packed >> 12) & 15;
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
// A native mod's own model in place of the vanilla one
|
||||
// (`native_mob_submit`), before any of the vanilla model's parts
|
||||
// (a sulfur cube's swallowed block) are drawn.
|
||||
// A player's own hook (`native_player_submit`) is asked for the
|
||||
// other players in view.
|
||||
let who = is_player
|
||||
.then(|| self.remote_player_name(mob.id))
|
||||
.flatten()
|
||||
.map(|name| NativePlayerRender { name, local: false });
|
||||
let modded = if layers_only {
|
||||
false
|
||||
} else if let Some(who) = who {
|
||||
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
|
||||
} else {
|
||||
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
};
|
||||
if modded {
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if outline.is_none() {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
self.sulfur_cube_body_geometry(
|
||||
mob,
|
||||
alpha as f32,
|
||||
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
|
||||
let armor = if is_player {
|
||||
self.worn_armor_layers(mob)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
|
||||
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
|
||||
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
|
||||
// then only the outline draws.
|
||||
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
|
||||
let posable = skinning
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& silhouette_on_gpu
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
|
||||
);
|
||||
let mut skinned = None;
|
||||
if posable {
|
||||
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::new(&mut models, &mut frame);
|
||||
horizon_client::mobs::render::render_skinned(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
&mut skin,
|
||||
);
|
||||
skinned = Some((mark, skin.bounds));
|
||||
} else {
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
|
||||
// fs_hdr_mark); a tier's window lifts only their bright texels.
|
||||
if let Some(tier) = match mob.species() {
|
||||
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
|
||||
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
|
||||
_ => None,
|
||||
} {
|
||||
for v in &mut out[body..] {
|
||||
v.layer |= u32::from(tier) << 16;
|
||||
}
|
||||
}
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
let layers = horizon_client::mobs::render::layers_shown(mob);
|
||||
if is_player && layers {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
|
||||
// and SpinAttackEffectLayer. Only the cape hides with the body;
|
||||
// the others have no `isInvisible` check.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
if !layers_only {
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
if let Some((_, name)) = self
|
||||
.remote_player_names()
|
||||
.into_iter()
|
||||
.find(|(id, _)| *id == mob.id)
|
||||
{
|
||||
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& super::super::cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
None => (mark, Some((lo, hi))),
|
||||
});
|
||||
}
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
// Held items join the GPU path's draws, their silhouette too.
|
||||
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if let Some(((first, _, _), _)) = skinned {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.skin_frame.borrow_mut().outline_since(first, color);
|
||||
}
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Glowing outlines and a guardian's beam remain visible independently
|
||||
// of whether the body is hidden behind terrain.
|
||||
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = beam
|
||||
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
|
||||
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
|
||||
{
|
||||
let a = alpha;
|
||||
let eye = glam::DVec3::new(
|
||||
mob.previous.x + (mob.position.x - mob.previous.x) * a,
|
||||
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
|
||||
mob.previous.z + (mob.position.z - mob.previous.z) * a,
|
||||
) - camera;
|
||||
horizon_client::mobs::guardian::beam_geometry(
|
||||
eye.as_vec3(),
|
||||
(target - camera).as_vec3(),
|
||||
time,
|
||||
scale,
|
||||
uv,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
// What follows isn't a tracked mob: close the last one's span.
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
|
||||
let live = self.creatures.projectiles.iter().map(|a| {
|
||||
let at = [
|
||||
a.previous.x + (a.position.x - a.previous.x) * alpha,
|
||||
a.previous.y + (a.position.y - a.previous.y) * alpha,
|
||||
a.previous.z + (a.position.z - a.previous.z) * alpha,
|
||||
];
|
||||
(a, at)
|
||||
});
|
||||
// ItemPickupParticle: a picked-up arrow flying to its collector.
|
||||
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
|
||||
super::super::pickups::Taken::Arrow(a) => {
|
||||
Some((&**a, super::super::pickups::position(p, alpha)))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
// A glowing projectile's outline: what it drew up to the next one.
|
||||
let mut glowing = None;
|
||||
for (a, at) in live.chain(taken) {
|
||||
if let Some((start, rgb)) = glowing.take() {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
if a.id != 0
|
||||
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
|
||||
{
|
||||
glowing = Some((out.len(), rgb));
|
||||
}
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
// EntityRenderer.getPackedLightCoords: the block its light probe
|
||||
// (the eye) is in, at the position this frame draws rather than
|
||||
// this tick's, and not the one under its feet.
|
||||
let light = self.world.light(
|
||||
at[0].floor() as i32,
|
||||
a.light_probe_y(alpha).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
|
||||
// on fire are drawn at 15 block light (the fireball renderers'
|
||||
// own override); the sky light stays the level's.
|
||||
if let Some(block) = a.block_light_override() {
|
||||
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
|
||||
}
|
||||
if a.is_arrow() {
|
||||
let Some(model) = self.arrow_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
|
||||
let texture = if a.is_spectral_arrow() {
|
||||
"entity/projectiles/arrow_spectral"
|
||||
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
|
||||
"entity/projectiles/arrow_tipped"
|
||||
} else {
|
||||
"entity/projectiles/arrow"
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(texture).copied() else {
|
||||
continue;
|
||||
};
|
||||
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
|
||||
// and the same for the pitch, so a turning arrow is drawn
|
||||
// between the two ticks it turned over.
|
||||
let alpha32 = alpha as f32;
|
||||
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
|
||||
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z(pitch.to_radians());
|
||||
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
|
||||
// rolls the root while the shake timer runs.
|
||||
let shake = a
|
||||
.arrow
|
||||
.as_ref()
|
||||
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
|
||||
let roll = if shake > 0.0 {
|
||||
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pose_for = |part: &horizon_client::mobs::Part| {
|
||||
let mut pose = part.pose;
|
||||
if std::ptr::eq(part, model) {
|
||||
pose[5] += roll;
|
||||
}
|
||||
pose
|
||||
};
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
m,
|
||||
&pose_for,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
|
||||
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ThrownTridentRenderer.
|
||||
if matches!(a.kind, ProjectileKind::Trident) {
|
||||
let Some(model) = self.trident_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
|
||||
continue;
|
||||
};
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
|
||||
let poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
m,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// WindChargeRenderer.
|
||||
if matches!(
|
||||
a.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
) {
|
||||
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
|
||||
continue;
|
||||
}
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
let model = &self.entity_models.shulker_bullet;
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
use horizon_core::geometry::trig;
|
||||
let tc = a.age as f32 + alpha as f32;
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("main", a.yaw.to_radians());
|
||||
poses.xrot("main", a.pitch.to_radians());
|
||||
let light = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_x(
|
||||
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_z(
|
||||
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
|
||||
let uv = [r.u0, r.v0, r.u1, r.v1];
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// The translucent shell blends over the opaque core
|
||||
// (`RenderTypes.entityTranslucent`).
|
||||
let shell = out.len();
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
|
||||
&poses,
|
||||
uv,
|
||||
0x26FF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
horizon_client::gfx::mark_oit(&mut out[shell..]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::super::wither::skull_kind(a) {
|
||||
let model = &self.entity_models.wither_skull;
|
||||
let tex = if dangerous {
|
||||
"entity/wither/wither_invulnerable"
|
||||
} else {
|
||||
"entity/wither/wither"
|
||||
};
|
||||
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("head", a.yaw.to_radians());
|
||||
poses.xrot("head", a.pitch.to_radians());
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(stack) = a.drawn_item() {
|
||||
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation());
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
// `Entity.displayFireAnimation`: a thrown item that flew through a
|
||||
// fire block burns (`ThrowableProjectile.tick`'s
|
||||
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
|
||||
// over its box as any other entity renderer does.
|
||||
if a.on_fire() {
|
||||
let size = a.kind.size() as f32;
|
||||
flame_geometry(atlas, c, size, size, self.yaw, light, out);
|
||||
}
|
||||
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
|
||||
// returns (a fire charge until `setItem` gave it another), its model
|
||||
// at the renderer's scale for the kind. `packed` is already 15, the
|
||||
// renderer's full bright.
|
||||
if let Some(stack) = a.item.as_ref() {
|
||||
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation())
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
let Some(name) = a.item_sprite() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let lift = is_item_billboard(&a.kind);
|
||||
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
|
||||
for (v, uv) in quad
|
||||
.iter()
|
||||
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
|
||||
{
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((start, rgb)) = glowing {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//! Spawn eggs: a baby from an egg used on its own kind, an egg used on a block or a fluid, and the
|
||||
//! egg's entity data on the mob it spawned.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
|
||||
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
|
||||
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
|
||||
/// does, and one egg used. False (PASS) when the mob has no baby form.
|
||||
pub(super) fn spawn_egg_offspring(&mut self, index: usize) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|
||||
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
|| parent.species() == Species::WanderingTrader
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (kind, at) = (
|
||||
parent.kind.id.clone(),
|
||||
[parent.position.x, parent.position.y, parent.position.z],
|
||||
);
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
|
||||
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
|
||||
// it south without a level-random draw.
|
||||
let Some(id) =
|
||||
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
if species == Species::Fox {
|
||||
let spawner = self.acting_player_uuid();
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
|
||||
pub(in crate::game) fn spawn_egg_fluid_hit(&self) -> Option<super::super::Hit> {
|
||||
let hit = self.bucket_hit()?;
|
||||
matches!(
|
||||
&*self.block_name(hit.pos),
|
||||
"minecraft:water" | "minecraft:lava"
|
||||
)
|
||||
.then_some(hit)
|
||||
}
|
||||
/// Shared peaceful-mode eligibility for spawn eggs.
|
||||
pub(in crate::game) fn spawn_egg_allowed(&self, item: &str) -> bool {
|
||||
let kind = item.trim_end_matches("_spawn_egg");
|
||||
self.ui.difficulty != 0
|
||||
|| self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
|
||||
}
|
||||
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
|
||||
pub(super) fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let fluid = matches!(self.guest_use, Some(super::super::GuestUse::Item(_)));
|
||||
let Some(hit) = (if fluid {
|
||||
self.spawn_egg_fluid_hit()
|
||||
} else {
|
||||
self.pick()
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
|
||||
if !self.spawn_egg_allowed(&stack.item) {
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
if fluid
|
||||
&& !self.may_build()
|
||||
&& !self.can_place_on_in_adventure([
|
||||
hit.pos[0] - hit.face[0],
|
||||
hit.pos[1] - hit.face[1],
|
||||
hit.pos[2] - hit.face[2],
|
||||
])
|
||||
{
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
|
||||
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
|
||||
stack,
|
||||
self.acting_player_is_op(),
|
||||
);
|
||||
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
|
||||
if done {
|
||||
self.swing_arm();
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// useOn: a clicked block without a collision shape (vines, grass)
|
||||
// takes the mob itself, any other the one on the clicked face.
|
||||
let p = if fluid || self.collision_empty(hit.pos) {
|
||||
hit.pos
|
||||
} else {
|
||||
[
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
};
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
// SpawnEggItem.spawnMob: `EntityType.spawn`.
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
|
||||
// `PostSpawnProcessor` puts the egg's components on it.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
{
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*self.held_mut() = None;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
|
||||
/// the mob it spawned, once the guards let it through, as
|
||||
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
|
||||
/// it, loaded back into the same mob (`/data merge entity`'s loader).
|
||||
pub(in crate::game) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let op = self.acting_player_is_op();
|
||||
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let own = self.mob_tree_nbt(i);
|
||||
let tag = horizon_client::data_cmd::merged(&own, data);
|
||||
// Nothing to report: the mob's own compound always loads back.
|
||||
let _ = self.apply_entity_nbt(super::super::commands::Target::Mob(id), tag);
|
||||
}
|
||||
}
|
||||
@@ -471,44 +471,13 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
self.cushions[i].check_interval = 0;
|
||||
if !self.check_cushion(i) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Cushion.tickAtCheckInterval` plus the survive check; true when the cushion broke.
|
||||
fn check_cushion(&mut self, i: usize) -> bool {
|
||||
let c = self.cushions[i].clone();
|
||||
let p = c.position;
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
// collidedWithFluid: the fluid box of the cushion's own block against its box.
|
||||
if let Some((height, _)) =
|
||||
self.fluid_height_of(x, y, z, Some(horizon_client::blocks::Fluid::Lava))
|
||||
{
|
||||
let fluid = Aabb::new(
|
||||
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
|
||||
Vec3::new(
|
||||
f64::from(x) + 1.0,
|
||||
f64::from(y) + f64::from(height),
|
||||
f64::from(z) + 1.0,
|
||||
),
|
||||
);
|
||||
if aabb(c.bounds())
|
||||
.deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))
|
||||
.intersects(fluid)
|
||||
{
|
||||
// LAVA_IGNITE's lavaHurt deals 4 damage, which breaks the cushion.
|
||||
let c = self.cushions[i].clone();
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Custom skies (`horizon_client::custom_sky`, OptiFine's format): each frame, the layers of the
|
||||
//! world's sky whose day, weather, biome and height hold at the camera, handed to the renderer,
|
||||
//! which draws them over the vanilla sky. A pack without layers hands it none.
|
||||
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
|
||||
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
|
||||
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
|
||||
use super::*;
|
||||
use horizon_client::custom_sky::{Conditions, Weather};
|
||||
use horizon_optifine::sky::{Conditions, Now};
|
||||
|
||||
/// The layers' eased brightness between frames, and when the last frame was.
|
||||
#[derive(Default)]
|
||||
@@ -46,11 +46,11 @@ impl Game<'_> {
|
||||
let mut state = std::mem::take(&mut self.custom_sky.state);
|
||||
// Level.isThundering / isRaining.
|
||||
let weather = if thunder > 0.9 && rain > 0.2 {
|
||||
Weather::Thunder
|
||||
Now::Thunder
|
||||
} else if rain > 0.2 {
|
||||
Weather::Rain
|
||||
Now::Rain
|
||||
} else {
|
||||
Weather::Clear
|
||||
Now::Clear
|
||||
};
|
||||
let conditions = Conditions {
|
||||
day_time: self.view_day_time(),
|
||||
|
||||
@@ -1249,7 +1249,7 @@ impl Game<'_> {
|
||||
self.sync_ops(&lists);
|
||||
let anticheat = load_anticheat();
|
||||
let chat_filter = load_chat_filter();
|
||||
self.settings.extras.pvp.combat.rules = load_combat();
|
||||
self.set_pvp_legacy_rules(load_combat());
|
||||
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
|
||||
let far = props
|
||||
.raw
|
||||
@@ -2671,7 +2671,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_temporarily_banned_player_is_refused_with_the_end_until_it_has_passed() {
|
||||
use super::{BanEntry, BanList, Lists, NameAndId};
|
||||
use horizon_server_files::duration::now_millis;
|
||||
use horizon_chat_filter::now_millis;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-can-login-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut lists = Lists {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
|
||||
use super::*;
|
||||
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
// One Discord status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
if !self.settings.extras.discord_presence {
|
||||
return Status::Off;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return Status::Menus;
|
||||
}
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Status::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: self.mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,21 @@
|
||||
//! (`native_minimap`: its tiles, image and HUD) and zoom (`native_zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`native_clicks`, `native_frame_rate`).
|
||||
//! Each hook does nothing while its extra is off and no mod uses it.
|
||||
//!
|
||||
//! Each Horizon extra's game glue is a child module here, so it leaves the engine in one move
|
||||
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options),
|
||||
//! `mouse_tweaks` and `sorting` (the inventory extras).
|
||||
pub(in crate::game) mod mouse_tweaks;
|
||||
pub(in crate::game) mod pvp;
|
||||
mod reconnect;
|
||||
mod sorting;
|
||||
|
||||
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
extras_hud::{self, Corners},
|
||||
hud::{HudBatch, MINIMAP_SPRITE},
|
||||
minimap::{self, Layer, Minimap, View},
|
||||
multiplayer,
|
||||
reconnect::{Retry, Tracker},
|
||||
title::Mode,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use winit::event::MouseButton;
|
||||
@@ -24,8 +30,6 @@ pub(super) struct ExtrasState {
|
||||
minimap: Minimap,
|
||||
/// The dimension the minimap's tiles belong to.
|
||||
minimap_dim: Option<Dim>,
|
||||
/// Auto-reconnect's attempts at the current server.
|
||||
reconnect: Tracker,
|
||||
}
|
||||
/// CPU time the minimap's tiles may take per frame.
|
||||
const MINIMAP_BUDGET: Duration = Duration::from_micros(300);
|
||||
@@ -57,6 +61,7 @@ impl Game<'_> {
|
||||
if !playing {
|
||||
self.extras.mouse = [false; 2];
|
||||
}
|
||||
self.sync_reconnect_setting();
|
||||
self.update_minimap();
|
||||
// For mods (`render.frame-stats`, `native_frame_rate`).
|
||||
let now = self.extras_now();
|
||||
@@ -78,74 +83,6 @@ impl Game<'_> {
|
||||
let cps = (l.per_second(now) as u32, r.per_second(now) as u32);
|
||||
(cps, self.extras.mouse)
|
||||
}
|
||||
/// Auto-reconnect: a connection to `address` as `name` starts.
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str, lan: bool) {
|
||||
self.extras.reconnect.connecting(address, name, lan);
|
||||
}
|
||||
/// The server this game connected to last.
|
||||
pub(super) fn server_address(&self) -> Option<&str> {
|
||||
self.extras.reconnect.address()
|
||||
}
|
||||
/// The server this game connected to last was found on the local network.
|
||||
pub(super) fn server_is_lan(&self) -> bool {
|
||||
self.extras.reconnect.lan()
|
||||
}
|
||||
/// Auto-reconnect: the server let the player in.
|
||||
pub(super) fn extras_joined(&mut self) {
|
||||
self.extras.reconnect.joined(std::time::Instant::now());
|
||||
}
|
||||
/// Auto-reconnect: the player left the server on purpose.
|
||||
pub(super) fn extras_left(&mut self) {
|
||||
self.extras.reconnect.reset();
|
||||
}
|
||||
/// Auto-reconnect's next attempt after a disconnect, when it is on and
|
||||
/// the reason allows one.
|
||||
pub(super) fn reconnect_after(
|
||||
&mut self,
|
||||
reason: &str,
|
||||
key: Option<&str>,
|
||||
from_server: bool,
|
||||
) -> Option<Retry> {
|
||||
if !self.settings.extras.auto_reconnect {
|
||||
self.extras.reconnect.reset();
|
||||
return None;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
self.extras.reconnect.ended(reason, key, from_server, now)
|
||||
}
|
||||
/// Show the attempt's countdown on the Disconnected screen.
|
||||
pub(super) fn offer_reconnect(&mut self, retry: Option<Retry>) {
|
||||
let Some(retry) = retry else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = self.title.as_mut()
|
||||
&& let Mode::Multiplayer(multiplayer::Screen::Disconnected { retry: slot, .. }) =
|
||||
&mut title.mode
|
||||
{
|
||||
log::info!(
|
||||
"Horizon: reconnecting to {} in {} s",
|
||||
horizon_client::net::shown_address(&retry.address),
|
||||
retry.seconds_left(std::time::Instant::now())
|
||||
);
|
||||
*slot = Some(retry);
|
||||
}
|
||||
}
|
||||
/// Auto-reconnect: connect again once the countdown is over. An
|
||||
/// online-mode server gets the signed-in account again: `connect_server`
|
||||
/// takes it from `auth::current()`, held in memory for the whole run
|
||||
/// (the launcher's session file is gone after the first read).
|
||||
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
|
||||
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
|
||||
self.connect_server_with_options(
|
||||
&retry.address,
|
||||
&retry.name,
|
||||
None,
|
||||
retry.lan,
|
||||
self.realm_session,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The minimap a mod shows (`native_minimap`): its corner, its side
|
||||
/// in GUI pixels and its view of the player.
|
||||
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
|
||||
@@ -284,7 +221,7 @@ impl Game<'_> {
|
||||
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
|
||||
fn effect_rows_height(&self) -> f32 {
|
||||
let effects = &self.potions.effects.list;
|
||||
if effects.is_empty() || self.screen != Screen::Playing || self.pvp_effects_shown() {
|
||||
if effects.is_empty() || self.screen != Screen::Playing || self.effect_icons_hidden() {
|
||||
return 0.0;
|
||||
}
|
||||
let harmful = effects.iter().any(|e| {
|
||||
|
||||
+19
-166
@@ -17,26 +17,12 @@
|
||||
//! other side. Down over a result slot takes one craft or the whole furnace
|
||||
//! output to the inventory; nothing ever goes into a result slot.
|
||||
//!
|
||||
//! Sorting draws a small button by a menu's storage slots and one by the
|
||||
//! inventory's (an optional key sorts the part under the cursor); the
|
||||
//! clicks come from `inv_sort::plan`.
|
||||
use super::{Creative, Game, Screen};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
inv_sort,
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
menu::Input,
|
||||
stations::hover_name,
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// A sort button's side in GUI pixels.
|
||||
const SORT_BUTTON: f32 = 9.0;
|
||||
//! Sorting, which clicks through `tweak_click` too, is in `sorting.rs`.
|
||||
use crate::game::{Creative, Game, Screen};
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, menu::Input};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Tweaks {
|
||||
pub(in crate::game) struct Tweaks {
|
||||
drag: Option<Drag>,
|
||||
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
|
||||
wheel: f32,
|
||||
@@ -44,7 +30,7 @@ pub(super) struct Tweaks {
|
||||
/// A server's menu the tweaks run in: their clicks are vanilla's in these,
|
||||
/// and the menu engine knows which slot takes what. Not the menus whose slots
|
||||
/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters).
|
||||
pub(super) fn tweak_menu(name: &str) -> bool {
|
||||
pub(in crate::game) fn tweak_menu(name: &str) -> bool {
|
||||
name == "inventory"
|
||||
|| name.starts_with("minecraft:generic_9x")
|
||||
|| matches!(
|
||||
@@ -72,7 +58,7 @@ struct Drag {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The open screen is one the tweaks run in, with the extra on.
|
||||
pub(super) fn tweaks_on(&self) -> bool {
|
||||
pub(in crate::game) fn tweaks_on(&self) -> bool {
|
||||
if !self.settings.extras.mouse_tweaks
|
||||
|| self.screen != Screen::Inventory
|
||||
|| self.station.is_some()
|
||||
@@ -87,7 +73,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// One ordinary click on a game slot. False when nothing took it.
|
||||
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
|
||||
pub(in crate::game) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
|
||||
if self.remote.is_some() {
|
||||
return self.remote_menu_ops(&[(Some(slot), button, input)]);
|
||||
}
|
||||
@@ -103,7 +89,7 @@ impl Game<'_> {
|
||||
/// drag (`QUICK_CRAFT`) and not a click: a server that has the hand empty
|
||||
/// (the stack it was to come from just burned as fuel) ignores a drag, but
|
||||
/// a click would pick up what's in `slot`.
|
||||
fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
|
||||
pub(in crate::game) fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
|
||||
let kind = i32::from(right) << 2;
|
||||
let ops = [
|
||||
(None, kind, Input::QuickCraft),
|
||||
@@ -123,7 +109,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// A press on `slot`: true when the tweaks took it (a right-drag
|
||||
/// placing), else vanilla's press runs and the tweaks watch the drag.
|
||||
pub(super) fn tweak_press(&mut self, slot: Option<usize>, right: bool, shift: bool) -> bool {
|
||||
pub(in crate::game) fn tweak_press(
|
||||
&mut self,
|
||||
slot: Option<usize>,
|
||||
right: bool,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
self.tweaks.drag = None;
|
||||
let Some(slot) = slot.filter(|&s| s != 200 && s < Creative::DISPLAY) else {
|
||||
return false;
|
||||
@@ -174,7 +165,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The cursor moved while a button is held: true when the tweaks own
|
||||
/// the drag (vanilla's quick craft drag doesn't grow).
|
||||
pub(super) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
|
||||
pub(in crate::game) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
|
||||
let Some(mut drag) = self.tweaks.drag.take() else {
|
||||
return false;
|
||||
};
|
||||
@@ -222,11 +213,11 @@ impl Game<'_> {
|
||||
owned
|
||||
}
|
||||
/// The button went up: the drag ends.
|
||||
pub(super) fn tweak_release(&mut self) {
|
||||
pub(in crate::game) fn tweak_release(&mut self) {
|
||||
self.tweaks.drag = None;
|
||||
}
|
||||
/// The wheel over an inventory screen: true when the tweaks used it.
|
||||
pub(super) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
|
||||
pub(in crate::game) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
|
||||
if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() {
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +247,7 @@ impl Game<'_> {
|
||||
result
|
||||
}
|
||||
/// `slot` takes one more of `stack`, by the open menu's rules.
|
||||
fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
|
||||
pub(in crate::game) fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
|
||||
let mut takes = false;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
takes = to_menu(slot).is_some_and(|i| menu.takes_one(env, i, stack));
|
||||
@@ -266,7 +257,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Where vanilla's quick move would put `slot`'s items, tried on a copy
|
||||
/// of the menu that's dropped (`Some(false)` keeps nothing).
|
||||
fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
|
||||
pub(in crate::game) fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
|
||||
let slots = self.slots();
|
||||
let mut landing = Vec::new();
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
@@ -356,144 +347,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The sort buttons of the open screen, with the extra on: their
|
||||
/// corner (from the screen's) and the slots each sorts. Any menu screen
|
||||
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
|
||||
/// right of its storage slots (a chest's title line) when it has some,
|
||||
/// one by the main inventory's (the "Inventory" label's line).
|
||||
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
|
||||
use super::NativeSlotRole as Role;
|
||||
if !self.settings.extras.sort {
|
||||
return Vec::new();
|
||||
}
|
||||
let roles = self.native_slot_roles();
|
||||
let of = |role| -> Vec<usize> {
|
||||
roles
|
||||
.iter()
|
||||
.filter(|(_, r)| *r == role)
|
||||
.map(|(s, _)| *s)
|
||||
.collect()
|
||||
};
|
||||
// Over the section's last column: (160, 5) over a chest's slots.
|
||||
let right = |slots: &[usize]| {
|
||||
slots
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).0 + 8.0)
|
||||
.fold(f32::MIN, f32::max)
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
let storage = of(Role::Storage);
|
||||
if !storage.is_empty() {
|
||||
let top = storage
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).1 - 13.0)
|
||||
.fold(f32::MAX, f32::min);
|
||||
buttons.push(((right(&storage), top), storage));
|
||||
}
|
||||
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
|
||||
// above the first row): a mod's screen places its inventory itself.
|
||||
let main = of(Role::Inventory);
|
||||
if !main.is_empty() {
|
||||
let y = self
|
||||
.native_screen_inventory_y()
|
||||
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
|
||||
buttons.push(((right(&main), y), main));
|
||||
}
|
||||
buttons
|
||||
}
|
||||
/// The sort buttons: a bordered square with three bars, lighter under
|
||||
/// the cursor.
|
||||
pub(super) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
for ((x, y), _) in self.sort_buttons() {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
|
||||
let face = if hovered {
|
||||
rgba(220, 220, 220, 255)
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
face,
|
||||
);
|
||||
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
|
||||
let by = y + 2.0 + 2.0 * i as f32;
|
||||
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A left press on a sort button sorts its slots (true).
|
||||
pub(super) fn sort_press(&mut self) -> bool {
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
let (ox, oy) = self.inventory_origin(&batch);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
|
||||
});
|
||||
let Some((_, slots)) = hit else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// The sort key: sorts the part under the cursor (the container when
|
||||
/// the cursor is over no slot).
|
||||
pub(super) fn sort_key_press(&mut self, key: KeyCode) -> bool {
|
||||
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
|
||||
}
|
||||
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
|
||||
self.sort_input(&keybinds::mouse_name(button))
|
||||
}
|
||||
fn sort_input(&mut self, name: &str) -> bool {
|
||||
let e = &self.settings.extras;
|
||||
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
|
||||
return false;
|
||||
}
|
||||
let buttons = self.sort_buttons();
|
||||
let hovered = self.inventory_target().0;
|
||||
let slots = buttons
|
||||
.iter()
|
||||
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
|
||||
.or(buttons.first())
|
||||
.map(|(_, slots)| slots.clone());
|
||||
let Some(slots) = slots else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
|
||||
fn sort_slots(&mut self, slots: &[usize]) {
|
||||
if self.carried.is_some() {
|
||||
return;
|
||||
}
|
||||
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
|
||||
let clicks = {
|
||||
let rank = |s: &ItemStack| {
|
||||
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
|
||||
(order, hover_name(&self.pack.names, s))
|
||||
};
|
||||
let max = |s: &ItemStack| self.max_stack(s);
|
||||
inv_sort::plan(&views, &rank, &max)
|
||||
};
|
||||
for i in clicks {
|
||||
self.tweak_click(slots[i], 0, Input::Pickup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tweak_menu;
|
||||
+27
-40
@@ -1,6 +1,6 @@
|
||||
//! The PvP & HUD page's options in the game (`horizon_client::pvp`, `docs/pvp.md`). Each
|
||||
//! does nothing while it is off.
|
||||
use super::{Dim, Game};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use winit::event::MouseButton;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PvpState {
|
||||
pub(in crate::game) struct PvpState {
|
||||
crosshair: CrosshairCache,
|
||||
/// Each armour slot's item, damage and maximum damage, and whether durability is in percent.
|
||||
armour: Widget<([Option<(Arc<str>, u32, u32)>; 4], bool)>,
|
||||
@@ -41,13 +41,13 @@ pub(super) struct PvpState {
|
||||
speed: pvp_hud::Speed,
|
||||
speed_shown: Widget<u32>,
|
||||
/// Each widget drawn last frame and its box on screen, for the HUD editor.
|
||||
pub(super) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
|
||||
pub(in crate::game) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
|
||||
/// The HUD editor, while it is open.
|
||||
pub(super) editor: Option<super::hud_editor::HudEditor>,
|
||||
pub(in crate::game) editor: Option<crate::game::hud_editor::HudEditor>,
|
||||
/// The open chat's box holds a chat search's query (Ctrl+F).
|
||||
pub(super) chat_search: bool,
|
||||
pub(in crate::game) chat_search: bool,
|
||||
/// What the chat box held when the search borrowed it.
|
||||
pub(super) chat_search_aside: String,
|
||||
pub(in crate::game) chat_search_aside: String,
|
||||
}
|
||||
/// `GuiGraphicsExtractor`'s grey for a detail line (an effect's time left).
|
||||
const DETAIL: u32 = 0xFF7F_7F7F;
|
||||
@@ -78,13 +78,13 @@ const ARROWS: [&str; 3] = [
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the custom crosshair replaces vanilla's sprite.
|
||||
pub(super) fn custom_crosshair(&self) -> bool {
|
||||
pub(in crate::game) fn custom_crosshair(&self) -> bool {
|
||||
self.settings.extras.pvp.crosshair.custom
|
||||
}
|
||||
/// The custom crosshair, centred where vanilla's 15x15 sprite is (`Hud.extractCrosshair`):
|
||||
/// its outline in black, then the shape, inverting what is under it when its colour is
|
||||
/// `Inverted`. The runs are cached, so a frame only adds their quads.
|
||||
pub(super) fn draw_custom_crosshair(&mut self, batch: &mut HudBatch, w: i32, h: i32) {
|
||||
pub(in crate::game) fn draw_custom_crosshair(&mut self, batch: &mut HudBatch, w: i32, h: i32) {
|
||||
let mut c = self.settings.extras.pvp.crosshair;
|
||||
if let Some(hover) = c.hover
|
||||
&& self.crosshair_living() == Some(true)
|
||||
@@ -114,7 +114,7 @@ impl Game<'_> {
|
||||
/// the outline's edges come from) as translucent faces drawn after translucent terrain,
|
||||
/// pushed out a little so they don't fight the block's own faces. Only while the outline
|
||||
/// shows (`outline` is its edges).
|
||||
pub(super) fn outline_fill(
|
||||
pub(in crate::game) fn outline_fill(
|
||||
&self,
|
||||
outline: &[[f64; 6]],
|
||||
camera: glam::DVec3,
|
||||
@@ -176,42 +176,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Once per frame: the options that live outside the game's state.
|
||||
pub(super) fn pvp_frame(&mut self) {
|
||||
/// Once per frame: the options that live outside the game's state, and the look the engine
|
||||
/// draws with (`game/look.rs`), the only way it reads them.
|
||||
pub(in crate::game) fn pvp_frame(&mut self) {
|
||||
let tweaks = self.settings.extras.pvp.tweaks;
|
||||
horizon_client::enchant::set_numeric_levels(tweaks.numeric_enchants);
|
||||
horizon_hooks::look::update(|look| self.settings.extras.pvp.apply(look));
|
||||
}
|
||||
/// Whether this window may draw PvP & HUD's own time and weather: only a client of a server
|
||||
/// (single player's included) draws them, never the server that ticks the world.
|
||||
fn own_view(&self) -> bool {
|
||||
self.remote.is_some() && self.dim == Dim::Overworld
|
||||
/// Legacy rules: the hits a server resolves follow 1.8.9 (`legacy_rules.rs`).
|
||||
pub(in crate::game) fn pvp_legacy_rules(&self) -> bool {
|
||||
self.settings.extras.pvp.combat.rules
|
||||
}
|
||||
/// The day time the frame draws (sky, sun, moon, the sky's light): PvP & HUD's time of day,
|
||||
/// else the world's. The world's own `day_time` and everything that plays by it stay.
|
||||
pub(super) fn view_day_time(&self) -> i64 {
|
||||
let fixed = self.settings.extras.pvp.tweaks.time;
|
||||
horizon_client::pvp::view_day_time(self.world.day_time, fixed.filter(|_| self.own_view()))
|
||||
}
|
||||
/// The rain and thunder levels the frame draws (rain, the sky's colour and light, fog): PvP &
|
||||
/// HUD's weather, else the world's. Gameplay (riptide, mobs, crops) keeps the world's.
|
||||
pub(super) fn view_weather(&self, partial: f32) -> (f32, f32) {
|
||||
match self.settings.extras.pvp.tweaks.weather {
|
||||
Some(w) if self.own_view() && self.can_have_weather() => w.levels(),
|
||||
_ => self.weather_levels(partial),
|
||||
}
|
||||
/// The server's legacy rules, from server.toml or the window's options.
|
||||
pub(in crate::game) fn set_pvp_legacy_rules(&mut self, on: bool) {
|
||||
self.settings.extras.pvp.combat.rules = on;
|
||||
}
|
||||
/// This player attacked entity `id`, `distance` from the eyes to where the crosshair's
|
||||
/// ray met its box (the combo counter waits for the server's report; the reach widget
|
||||
/// shows the distance).
|
||||
pub(super) fn pvp_attacked(&mut self, id: i32, distance: f64) {
|
||||
pub(in crate::game) fn pvp_attacked(&mut self, id: i32, distance: f64) {
|
||||
let now = self.started.elapsed().as_secs_f64();
|
||||
self.pvp.combo.attacked(id, now);
|
||||
self.pvp.reach.hit(distance, now);
|
||||
}
|
||||
/// A mouse press, counted for clicks per second while playing with a widget that shows them.
|
||||
pub(super) fn pvp_click(&mut self, button: MouseButton) {
|
||||
pub(in crate::game) fn pvp_click(&mut self, button: MouseButton) {
|
||||
let hud = &self.settings.extras.pvp.hud;
|
||||
if !(hud.cps || hud.keys) || self.screen != super::Screen::Playing {
|
||||
if !(hud.cps || hud.keys) || self.screen != crate::game::Screen::Playing {
|
||||
return;
|
||||
}
|
||||
let i = match button {
|
||||
@@ -232,7 +223,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The server reports entity `id` hurt; `own` when it is this player,
|
||||
/// `by_me` whether this player was the damage's direct entity (if named).
|
||||
pub(super) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
|
||||
pub(in crate::game) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
|
||||
if own {
|
||||
self.pvp.combo.broken();
|
||||
} else {
|
||||
@@ -240,13 +231,9 @@ impl Game<'_> {
|
||||
self.pvp.combo.hurt(id, by_me, now);
|
||||
}
|
||||
}
|
||||
/// Whether the effects widget replaces vanilla's effect icons (`Hud.extractEffects`).
|
||||
pub(super) fn pvp_effects_shown(&self) -> bool {
|
||||
self.settings.extras.pvp.hud.effects
|
||||
}
|
||||
/// The HUD widgets that are on, after the minimap and before mods' boxes. Each rebuilds its
|
||||
/// lines only when what it shows changed.
|
||||
pub(super) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
pub(in crate::game) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
let hud = self.settings.extras.pvp.hud;
|
||||
if self.player.is_none() || !hud.any() {
|
||||
// The editor frames only what was drawn.
|
||||
@@ -267,8 +254,8 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32);
|
||||
(at, pvp_hud::facing(self.yaw))
|
||||
});
|
||||
// The address is copied only when it changed (`server_address` borrows the whole game).
|
||||
let server = self.server_address().filter(|_| multiplayer);
|
||||
// The address is copied only when it changed.
|
||||
let server = self.last_server.address().filter(|_| multiplayer);
|
||||
let server = (hud.server && self.pvp.server.next.as_deref() != server)
|
||||
.then(|| server.map(str::to_owned));
|
||||
let now = self.started.elapsed().as_secs_f64();
|
||||
@@ -691,7 +678,7 @@ impl Game<'_> {
|
||||
false => s.item == held.item,
|
||||
};
|
||||
let slots = self.inventory.iter().map(Option::as_ref).enumerate();
|
||||
let offhand = std::iter::once((super::shield::OFFHAND_SLOT, self.offhand.as_ref()));
|
||||
let offhand = std::iter::once((crate::game::shield::OFFHAND_SLOT, self.offhand.as_ref()));
|
||||
let mut found = slots
|
||||
.chain(offhand)
|
||||
.filter_map(|(i, s)| Some((i, s?)))
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Auto-reconnect (`autoReconnect`, docs/extras.md) runs on the engine's connection hooks
|
||||
//! (`horizon_client::reconnect::HOOKS`, registered in `entry.rs`). Its setting still lives in
|
||||
//! the engine's extras, so it is handed over here; it leaves with the extras page (plan M5).
|
||||
use crate::game::Game;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Once per frame: the `autoReconnect` setting, for the hook to read when a connection ends.
|
||||
pub(in crate::game) fn sync_reconnect_setting(&self) {
|
||||
horizon_client::reconnect::HOOKS.set_enabled(self.settings.extras.auto_reconnect);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Sorting (`sort`, `sortKey`; #332, `docs/extras.md`), off by default: a small button by a
|
||||
//! menu's storage slots and one by the inventory's (an optional key sorts the part under the
|
||||
//! cursor). The clicks come from `inv_sort::plan` and go through the Mouse Tweaks' ordinary slot
|
||||
//! clicks (`mouse_tweaks.rs`), so they work wherever vanilla's do.
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
inv_sort,
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
menu::Input,
|
||||
stations::hover_name,
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// A sort button's side in GUI pixels.
|
||||
const SORT_BUTTON: f32 = 9.0;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The sort buttons of the open screen, with the extra on: their
|
||||
/// corner (from the screen's) and the slots each sorts. Any menu screen
|
||||
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
|
||||
/// right of its storage slots (a chest's title line) when it has some,
|
||||
/// one by the main inventory's (the "Inventory" label's line).
|
||||
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
|
||||
use crate::game::NativeSlotRole as Role;
|
||||
if !self.settings.extras.sort {
|
||||
return Vec::new();
|
||||
}
|
||||
let roles = self.native_slot_roles();
|
||||
let of = |role| -> Vec<usize> {
|
||||
roles
|
||||
.iter()
|
||||
.filter(|(_, r)| *r == role)
|
||||
.map(|(s, _)| *s)
|
||||
.collect()
|
||||
};
|
||||
// Over the section's last column: (160, 5) over a chest's slots.
|
||||
let right = |slots: &[usize]| {
|
||||
slots
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).0 + 8.0)
|
||||
.fold(f32::MIN, f32::max)
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
let storage = of(Role::Storage);
|
||||
if !storage.is_empty() {
|
||||
let top = storage
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).1 - 13.0)
|
||||
.fold(f32::MAX, f32::min);
|
||||
buttons.push(((right(&storage), top), storage));
|
||||
}
|
||||
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
|
||||
// above the first row): a mod's screen places its inventory itself.
|
||||
let main = of(Role::Inventory);
|
||||
if !main.is_empty() {
|
||||
let y = self
|
||||
.native_screen_inventory_y()
|
||||
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
|
||||
buttons.push(((right(&main), y), main));
|
||||
}
|
||||
buttons
|
||||
}
|
||||
/// The sort buttons: a bordered square with three bars, lighter under
|
||||
/// the cursor.
|
||||
pub(in crate::game) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
for ((x, y), _) in self.sort_buttons() {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
|
||||
let face = if hovered {
|
||||
rgba(220, 220, 220, 255)
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
face,
|
||||
);
|
||||
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
|
||||
let by = y + 2.0 + 2.0 * i as f32;
|
||||
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A left press on a sort button sorts its slots (true).
|
||||
pub(in crate::game) fn sort_press(&mut self) -> bool {
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
let (ox, oy) = self.inventory_origin(&batch);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
|
||||
});
|
||||
let Some((_, slots)) = hit else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// The sort key: sorts the part under the cursor (the container when
|
||||
/// the cursor is over no slot).
|
||||
pub(in crate::game) fn sort_key_press(&mut self, key: KeyCode) -> bool {
|
||||
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
|
||||
}
|
||||
pub(in crate::game) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
|
||||
self.sort_input(&keybinds::mouse_name(button))
|
||||
}
|
||||
fn sort_input(&mut self, name: &str) -> bool {
|
||||
let e = &self.settings.extras;
|
||||
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
|
||||
return false;
|
||||
}
|
||||
let buttons = self.sort_buttons();
|
||||
let hovered = self.inventory_target().0;
|
||||
let slots = buttons
|
||||
.iter()
|
||||
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
|
||||
.or(buttons.first())
|
||||
.map(|(_, slots)| slots.clone());
|
||||
let Some(slots) = slots else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
|
||||
fn sort_slots(&mut self, slots: &[usize]) {
|
||||
if self.carried.is_some() {
|
||||
return;
|
||||
}
|
||||
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
|
||||
let clicks = {
|
||||
let rank = |s: &ItemStack| {
|
||||
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
|
||||
(order, hover_name(&self.pack.names, s))
|
||||
};
|
||||
let max = |s: &ItemStack| self.max_stack(s);
|
||||
inv_sort::plan(&views, &rank, &max)
|
||||
};
|
||||
for i in clicks {
|
||||
self.tweak_click(slots[i], 0, Input::Pickup);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ impl Game<'_> {
|
||||
let off_hand_empty = items[1].is_none();
|
||||
// Combat feel Legacy: a sword is held up while use is held, as 1.8.9's block looked.
|
||||
// Only the picture changes: the clicks and what they send stay 26.3's.
|
||||
let legacy_block = self.settings.extras.pvp.combat.legacy
|
||||
let legacy_block = self.legacy_pose()
|
||||
&& self.right
|
||||
&& self.screen == Screen::Playing
|
||||
&& items[0]
|
||||
@@ -426,7 +426,7 @@ impl Game<'_> {
|
||||
let mut m = to_world
|
||||
* horizon_client::first_person::item_pose(left, inverse_height, attack, pose);
|
||||
// PvP & HUD's shield size (100% is vanilla's), toward the hand holding it.
|
||||
let shield_size = self.settings.extras.pvp.tweaks.shield_size;
|
||||
let shield_size = self.shield_size();
|
||||
if shield_size != 100 && &*stack.item == "minecraft:shield" {
|
||||
m *= glam::Mat4::from_scale(glam::Vec3::splat(f32::from(shield_size) / 100.0));
|
||||
}
|
||||
@@ -449,7 +449,7 @@ impl Game<'_> {
|
||||
};
|
||||
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand);
|
||||
// Combat feel Legacy: 1.8.9's pose for a flat item drawn with vanilla's display.
|
||||
if self.settings.extras.pvp.combat.legacy
|
||||
if self.legacy_pose()
|
||||
&& !state.layers.is_empty()
|
||||
&& state
|
||||
.layers
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
//! Play with friends, host side: "Start Sharing" on the Play with Friends
|
||||
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! it isn't (with Open to LAN's defaults) and shares that port behind an
|
||||
//! invite code (horizon-friends). The code holds this computer's addresses
|
||||
//! and the key, so friends join with no server of ours; a relay the player
|
||||
//! configured adds a short code and a fallback. The integrated server is the
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address. The joining side is horizon-friends' (`game`), registered by
|
||||
//! horizon-official.
|
||||
//! loopback address.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
friends_screen::{self, Action, FriendsView},
|
||||
hud::HudBatch,
|
||||
lang,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod screen;
|
||||
|
||||
use screen::{self as friends_screen, Action, FriendsView};
|
||||
pub use screen::{TITLE, title};
|
||||
|
||||
pub(super) struct Sharing {
|
||||
host: Host,
|
||||
/// Sharing opened the world to LAN, so stopping closes it again.
|
||||
@@ -240,11 +240,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pause menu offers Play with Friends (a singleplayer world).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.friends_view().shown
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
let guests = match &self.lan {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
|
||||
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
|
||||
//! no world is shared, the pause menu has no Friends button (`FriendsView::shown` is false, as on
|
||||
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, hud::HudBatch};
|
||||
use horizon_client::{Result, friends_screen::FriendsView, hud::HudBatch};
|
||||
|
||||
pub const HELP: &[&str] = &[];
|
||||
|
||||
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
pub fn title() -> String {
|
||||
horizon_client::lang::get_or("horizon.friends.title", TITLE)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn friends_lan_closed(&mut self) {}
|
||||
|
||||
@@ -24,8 +18,8 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {}
|
||||
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
false
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
FriendsView::default()
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
|
||||
@@ -568,7 +568,7 @@ impl Game<'_> {
|
||||
self.hands_busy(),
|
||||
self.hand_strength.swap_scale(1.0, speed),
|
||||
changed,
|
||||
self.settings.extras.pvp.combat.legacy,
|
||||
self.legacy_pose(),
|
||||
);
|
||||
for h in 0..2 {
|
||||
if replace[h] {
|
||||
|
||||
@@ -477,7 +477,7 @@ impl Game<'_> {
|
||||
.store(settings.entity_distance.to_bits(), Ordering::Relaxed);
|
||||
flags
|
||||
.legacy_rules
|
||||
.store(settings.extras.pvp.combat.rules, Ordering::Relaxed);
|
||||
.store(self.pvp_legacy_rules(), Ordering::Relaxed);
|
||||
let (ready_tx, ready) = channel();
|
||||
let (tasks, server_tasks) = channel();
|
||||
let server_flags = flags.clone();
|
||||
@@ -1095,7 +1095,7 @@ impl Game<'_> {
|
||||
// IntegratedServer.getScaledTrackingDistance reads the option each time.
|
||||
self.settings.entity_distance =
|
||||
f32::from_bits(flags.entity_distance.load(Ordering::Relaxed));
|
||||
self.settings.extras.pvp.combat.rules = flags.legacy_rules.load(Ordering::Relaxed);
|
||||
self.set_pvp_legacy_rules(flags.legacy_rules.load(Ordering::Relaxed));
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
//! drags, throws and the press/release of a slot click.
|
||||
use super::*;
|
||||
|
||||
/// What crates above the engine get of these screens (`horizon_hooks::containers`).
|
||||
mod containers;
|
||||
|
||||
/// Q throws the whole stack with Control down: the same modifier word the
|
||||
/// screens read (`has_control_down`), never a latched sprint toggle.
|
||||
pub(super) fn drop_whole_stack(modifiers: winit::keyboard::ModifiersState) -> bool {
|
||||
@@ -161,7 +164,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.tweak_press(slot, right, shift) {
|
||||
if self.tweak_press(slot, right, shift) || self.container_press(right) {
|
||||
return;
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
@@ -263,7 +266,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// Mouse movement while a quick craft drag is running.
|
||||
pub(super) fn inventory_drag_move(&mut self) {
|
||||
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0) {
|
||||
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0)
|
||||
|| self.container_drag()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some((ty, slots)) = self.inv_drag.clone() else {
|
||||
@@ -426,6 +431,7 @@ impl Game<'_> {
|
||||
pub(super) fn inventory_mouse_release(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::{Release, Target};
|
||||
self.tweak_release();
|
||||
self.container_release();
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
|
||||
let release = self.inv_clicks.release(
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! The engine's side of the container screen API (`horizon_hooks::containers`, docs/layers.md,
|
||||
//! "Container screens"): the open menu screen as a [`Container`] for the length of one hook call,
|
||||
//! and the input and drawing that reach the hooks. With no hook registered nothing here is built.
|
||||
use crate::game::{Game, NativeSlotRole, Screen};
|
||||
use horizon_client::{
|
||||
extras_hud, hud::HudBatch, items::ItemStack, keybinds, menu::Input, stations::hover_name,
|
||||
};
|
||||
use horizon_hooks::{
|
||||
containers::{Click, Container, Role, Slot, SlotId, Stack},
|
||||
engine,
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// The open screen's menu, over the game for one call. `origin`: the panel's top left corner in
|
||||
/// GUI pixels.
|
||||
struct Open<'g, 'a> {
|
||||
game: &'g mut Game<'a>,
|
||||
origin: (f32, f32),
|
||||
}
|
||||
|
||||
/// The same, read only: what drawing gets.
|
||||
struct View<'g, 'a> {
|
||||
game: &'g Game<'a>,
|
||||
origin: (f32, f32),
|
||||
}
|
||||
|
||||
fn role(role: NativeSlotRole) -> Role {
|
||||
match role {
|
||||
NativeSlotRole::Hotbar => Role::Hotbar,
|
||||
NativeSlotRole::Inventory => Role::Inventory,
|
||||
NativeSlotRole::Storage => Role::Storage,
|
||||
NativeSlotRole::CraftingGrid | NativeSlotRole::Input => Role::Input,
|
||||
NativeSlotRole::Result => Role::Result,
|
||||
NativeSlotRole::Equipment => Role::Equipment,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stacks that merge (`same_item_same_components`) share a kind.
|
||||
fn kind(stack: &ItemStack) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
stack.item.hash(&mut h);
|
||||
stack.damage.hash(&mut h);
|
||||
format!("{:?}", stack.extra).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn slot_id(slot: usize) -> SlotId {
|
||||
SlotId(u32::try_from(slot).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
fn game_slot(slot: SlotId) -> usize {
|
||||
slot.0 as usize
|
||||
}
|
||||
|
||||
impl<'a> Game<'a> {
|
||||
/// The open menu's type: the server's, else the menu this window opened itself.
|
||||
fn container_menu(&self) -> &str {
|
||||
if let Some(menu) = self.remote_menu_type() {
|
||||
return menu;
|
||||
}
|
||||
use horizon_client::containers::BlockEntity;
|
||||
if self.mount_active() {
|
||||
return "horizon:mount";
|
||||
}
|
||||
if self.station.is_some() {
|
||||
return "horizon:station";
|
||||
}
|
||||
match self.container_kind() {
|
||||
None if self.craft_size == 3 => "minecraft:crafting",
|
||||
None => "inventory",
|
||||
Some(BlockEntity::Chest(_)) => "minecraft:generic_9x3",
|
||||
Some(BlockEntity::Furnace(_)) => "minecraft:furnace",
|
||||
Some(_) => "horizon:block",
|
||||
}
|
||||
}
|
||||
fn container_slots(&self) -> Vec<Slot> {
|
||||
self.native_slot_roles()
|
||||
.into_iter()
|
||||
.map(|(s, r)| {
|
||||
let (x, y) = self.slot_position(s);
|
||||
Slot {
|
||||
id: slot_id(s),
|
||||
role: role(r),
|
||||
x,
|
||||
y,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn container_stack(&self, stack: &ItemStack) -> Stack {
|
||||
Stack {
|
||||
item: stack.item.to_string(),
|
||||
count: stack.count,
|
||||
max: self.max_stack(stack),
|
||||
kind: kind(stack),
|
||||
}
|
||||
}
|
||||
/// The stack of `kind` the screen shows, on the cursor or in a slot.
|
||||
fn container_find(&mut self, of: &Stack) -> Option<ItemStack> {
|
||||
if let Some(c) = self.carried.as_ref().filter(|c| kind(c) == of.kind) {
|
||||
return Some(c.clone());
|
||||
}
|
||||
self.slots()
|
||||
.into_iter()
|
||||
.filter_map(|s| self.slot_view(s))
|
||||
.find(|s| kind(s) == of.kind)
|
||||
}
|
||||
/// The panel's top left corner, None without a window.
|
||||
fn container_origin(&self) -> Option<(f32, f32)> {
|
||||
let r = self.renderer.as_ref()?;
|
||||
Some(self.inventory_origin(&HudBatch::new([r.config.width, r.config.height])))
|
||||
}
|
||||
/// The open screen for the hooks' input, None when no hook may take it.
|
||||
fn container_open(&mut self) -> Option<Open<'_, 'a>> {
|
||||
if self.screen != Screen::Inventory || !engine::containers() {
|
||||
return None;
|
||||
}
|
||||
let origin = self.container_origin()?;
|
||||
Some(Open { game: self, origin })
|
||||
}
|
||||
fn hovered(&self) -> Option<SlotId> {
|
||||
self.inventory_target().0.map(slot_id)
|
||||
}
|
||||
/// A press over the panel: true when a hook took it, and vanilla's drag doesn't start.
|
||||
pub(in crate::game) fn container_press(&mut self, right: bool) -> bool {
|
||||
let slot = self.hovered();
|
||||
let taken = self
|
||||
.container_open()
|
||||
.is_some_and(|mut c| engine::container_press(&mut c, slot, right));
|
||||
if taken {
|
||||
self.inv_drag = None;
|
||||
self.inv_clicks.skip_release();
|
||||
}
|
||||
taken
|
||||
}
|
||||
/// The cursor moved with a button held: true when a hook owns the drag, which ends vanilla's.
|
||||
pub(in crate::game) fn container_drag(&mut self) -> bool {
|
||||
let slot = self.hovered();
|
||||
let taken = self
|
||||
.container_open()
|
||||
.is_some_and(|mut c| engine::container_drag(&mut c, slot));
|
||||
if taken {
|
||||
self.inv_drag = None;
|
||||
self.inv_clicks.skip_release();
|
||||
}
|
||||
taken
|
||||
}
|
||||
pub(in crate::game) fn container_release(&mut self) {
|
||||
if let Some(mut c) = self.container_open() {
|
||||
engine::container_release(&mut c);
|
||||
}
|
||||
}
|
||||
/// The wheel over an inventory screen: true when the Mouse Tweaks or a hook used it.
|
||||
pub(in crate::game) fn container_scroll(&mut self, steps: f32) -> bool {
|
||||
let slot = self.inventory_target().0;
|
||||
if self.tweak_scroll(steps, slot) {
|
||||
return true;
|
||||
}
|
||||
let slot = slot.map(slot_id);
|
||||
self.container_open()
|
||||
.is_some_and(|mut c| engine::container_wheel(&mut c, slot, steps))
|
||||
}
|
||||
/// A key the screen got: sorting's key, else a hook's.
|
||||
pub(in crate::game) fn container_key_press(&mut self, key: KeyCode) -> bool {
|
||||
self.sort_key_press(key)
|
||||
|| keybinds::key_name(key).is_some_and(|name| self.container_key(name))
|
||||
}
|
||||
/// A mouse button the screen got, as a key.
|
||||
pub(in crate::game) fn container_mouse_press(&mut self, button: MouseButton) -> bool {
|
||||
self.sort_mouse_press(button) || self.container_key(&keybinds::mouse_name(button))
|
||||
}
|
||||
fn container_key(&mut self, name: &str) -> bool {
|
||||
if self.recipe_search_focused() {
|
||||
return false;
|
||||
}
|
||||
let slot = self.hovered();
|
||||
self.container_open()
|
||||
.is_some_and(|mut c| engine::container_key(&mut c, slot, name))
|
||||
}
|
||||
/// Draws the sort buttons and the hooks' drawing over the panel at (`ox`, `oy`).
|
||||
pub(in crate::game) fn draw_container_extras(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
self.draw_sort_buttons(batch, ox, oy);
|
||||
if !engine::containers() {
|
||||
return;
|
||||
}
|
||||
let view = View {
|
||||
game: self,
|
||||
origin: (ox, oy),
|
||||
};
|
||||
extras_hud::draw_on_panel(batch, &self.gui.atlas, (ox, oy), |canvas| {
|
||||
engine::container_draw(&view, canvas);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What both readings answer alike.
|
||||
macro_rules! reads {
|
||||
() => {
|
||||
fn menu(&self) -> &str {
|
||||
self.game.container_menu()
|
||||
}
|
||||
fn slots(&self) -> Vec<Slot> {
|
||||
self.game.container_slots()
|
||||
}
|
||||
fn carried(&self) -> Option<Stack> {
|
||||
let c = self.game.carried.as_ref()?;
|
||||
Some(self.game.container_stack(c))
|
||||
}
|
||||
fn spread(&self) -> usize {
|
||||
self.game.inv_drag.as_ref().map_or(0, |(_, s)| s.len())
|
||||
}
|
||||
fn cursor(&self) -> (f32, f32) {
|
||||
let (x, y) = self.game.cursor;
|
||||
(x - self.origin.0, y - self.origin.1)
|
||||
}
|
||||
fn shift(&self) -> bool {
|
||||
self.game.has_shift_down()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl Container for Open<'_, '_> {
|
||||
reads!();
|
||||
fn stack(&mut self, slot: SlotId) -> Option<Stack> {
|
||||
let s = self.game.slot_view(game_slot(slot))?;
|
||||
Some(self.game.container_stack(&s))
|
||||
}
|
||||
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool {
|
||||
self.game
|
||||
.container_find(of)
|
||||
.is_some_and(|s| self.game.tweak_takes_one(game_slot(slot), &s))
|
||||
}
|
||||
fn landing(&mut self, slot: SlotId) -> Vec<SlotId> {
|
||||
let landing = self.game.tweak_landing(game_slot(slot));
|
||||
landing.into_iter().map(slot_id).collect()
|
||||
}
|
||||
fn click(&mut self, slot: SlotId, click: Click) -> bool {
|
||||
let slot = game_slot(slot);
|
||||
match click {
|
||||
Click::Pick { right } => self.game.tweak_click(slot, i32::from(right), Input::Pickup),
|
||||
Click::QuickMove => self.game.tweak_click(slot, 0, Input::QuickMove),
|
||||
Click::Put { one } => self.game.tweak_put(slot, one),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn order(&mut self, slot: SlotId) -> (u32, String) {
|
||||
let Some(s) = self.game.slot_view(game_slot(slot)) else {
|
||||
return (u32::MAX, String::new());
|
||||
};
|
||||
let order = self.game.creative_screen.order_of(&s);
|
||||
let order = order.map_or(u32::MAX, |o| u32::try_from(o).unwrap_or(u32::MAX));
|
||||
(order, hover_name(&self.game.pack.names, &s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawing gets `&dyn Container`, which can't call the `&mut self` reads.
|
||||
impl Container for View<'_, '_> {
|
||||
reads!();
|
||||
fn stack(&mut self, _: SlotId) -> Option<Stack> {
|
||||
None
|
||||
}
|
||||
fn takes_one(&mut self, _: SlotId, _: &Stack) -> bool {
|
||||
false
|
||||
}
|
||||
fn landing(&mut self, _: SlotId) -> Vec<SlotId> {
|
||||
Vec::new()
|
||||
}
|
||||
fn click(&mut self, _: SlotId, _: Click) -> bool {
|
||||
false
|
||||
}
|
||||
fn order(&mut self, _: SlotId) -> (u32, String) {
|
||||
(u32::MAX, String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::items::ItemStack;
|
||||
|
||||
fn stack(item: &str, count: u32, damage: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
item: item.into(),
|
||||
count,
|
||||
damage,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_that_merge_share_a_kind() {
|
||||
let a = kind(&stack("minecraft:stone", 1, 0));
|
||||
assert_eq!(
|
||||
a,
|
||||
kind(&stack("minecraft:stone", 64, 0)),
|
||||
"the count is no part"
|
||||
);
|
||||
assert_ne!(a, kind(&stack("minecraft:dirt", 1, 0)));
|
||||
let worn = stack("minecraft:iron_sword", 1, 3);
|
||||
assert_ne!(kind(&worn), kind(&stack("minecraft:iron_sword", 1, 0)));
|
||||
}
|
||||
}
|
||||
@@ -571,7 +571,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
// Combat feel Legacy draws a hit as 1.8.9 did: no sweep, no damage hearts.
|
||||
if self.settings.extras.pvp.combat.hides_particle(options.ty) {
|
||||
if self.particle_hidden(options.ty) {
|
||||
return;
|
||||
}
|
||||
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
|
||||
|
||||
@@ -152,14 +152,6 @@ impl WorldOptions {
|
||||
}
|
||||
self.port.value.parse::<u16>().ok().filter(|p| *p >= 1024)
|
||||
}
|
||||
|
||||
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: the port box explains itself while
|
||||
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
|
||||
/// shows there too; a remote world has no such box.
|
||||
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
|
||||
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
|
||||
.then(|| Text::translate("lanServer.port", &[]))
|
||||
}
|
||||
fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
default_mode: self.default_mode,
|
||||
@@ -1011,10 +1003,6 @@ impl Game<'_> {
|
||||
hover,
|
||||
);
|
||||
}
|
||||
// The widgets are drawn; the port box's tooltip goes over them.
|
||||
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
|
||||
ui::tooltip(batch, atlas, &[tip], cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse wheel over the screen (ScrollableLayout, 10 pixels a step).
|
||||
@@ -1911,15 +1899,4 @@ mod tests {
|
||||
assert!(o.locked);
|
||||
assert!(!o.active(Button::Difficulty));
|
||||
}
|
||||
|
||||
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
|
||||
/// label; a remote world (`o.remote`) has no port box to hover.
|
||||
#[test]
|
||||
fn the_port_box_explains_itself_on_hover() {
|
||||
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
|
||||
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ impl Game<'_> {
|
||||
/// Whether the hits this game resolves follow 1.8.9: legacy rules are on and this game
|
||||
/// is the server (its own server thread, a LAN host, a dedicated server).
|
||||
pub(super) fn legacy_rules(&self) -> bool {
|
||||
self.remote.is_none() && self.settings.extras.pvp.combat.rules
|
||||
self.remote.is_none() && self.pvp_legacy_rules()
|
||||
}
|
||||
|
||||
/// `melee_damage` under legacy rules: (damage, attack strength, critical, enchanted).
|
||||
|
||||
@@ -34,6 +34,8 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
narrator::stop();
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
|
||||
@@ -91,7 +91,7 @@ pub(super) fn build_renderer(
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
|
||||
renderer.set_custom_sky(&*pack.custom_sky);
|
||||
renderer.set_custom_sky(&pack.custom_sky);
|
||||
renderer.set_shared_material_maps(&atlas.material_maps);
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
@@ -200,7 +200,7 @@ pub(super) fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
look: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! What a crate above may change in how the game draws (`horizon_hooks::look`): the engine reads
|
||||
//! it here only, and each reading is vanilla's while nothing set a look. PvP & HUD sets it today
|
||||
//! (`game/extras/pvp.rs`).
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{chat::ChatQol, particles::ParticleType};
|
||||
use horizon_hooks::look::{Chat, Frame, engine::get as look};
|
||||
|
||||
impl Game<'_> {
|
||||
/// 1.8.9's pictures of a fight: the sword block, the flat item pose and the hand swing.
|
||||
pub(super) fn legacy_pose(&self) -> bool {
|
||||
look().legacy_pose
|
||||
}
|
||||
pub(super) fn attack_indicator_hidden(&self) -> bool {
|
||||
look().hide_attack_indicator
|
||||
}
|
||||
/// A sound a server sent that this player doesn't hear (`Look::mutes_sound`).
|
||||
pub(super) fn sound_muted(&self, name: &str) -> bool {
|
||||
look().mutes_sound(name)
|
||||
}
|
||||
/// A particle a server sent that this player doesn't see: with the attack particles hidden,
|
||||
/// the sweep and the damage hearts, which 1.9 added to a hit (1.8.9's show crits only).
|
||||
pub(super) fn particle_hidden(&self, ty: ParticleType) -> bool {
|
||||
let attack = matches!(
|
||||
ty,
|
||||
ParticleType::SweepAttack | ParticleType::DamageIndicator
|
||||
);
|
||||
attack && look().hide_attack_particles
|
||||
}
|
||||
/// The block outline, hurt overlay and glint colours the renderer draws with.
|
||||
pub(super) fn look_frame(&self) -> Frame {
|
||||
look().frame
|
||||
}
|
||||
/// The enchantment glint's colour texel, 0 for vanilla's.
|
||||
pub(super) fn glint_texel(&self) -> u32 {
|
||||
look().frame.glint
|
||||
}
|
||||
/// The held shield's size in percent (100 is vanilla's).
|
||||
pub(super) fn shield_size(&self) -> u8 {
|
||||
look().shield_size
|
||||
}
|
||||
/// The totem animation's scale (1 is vanilla's).
|
||||
pub(super) fn totem_scale(&self) -> f32 {
|
||||
look().totem_scale
|
||||
}
|
||||
/// How far the first-person fire overlay is lowered, as a fraction (0 is vanilla's).
|
||||
pub(super) fn fire_lower(&self) -> f32 {
|
||||
look().fire_lower
|
||||
}
|
||||
/// Lava's fog as with fire resistance, and water's as fully adapted.
|
||||
pub(super) fn clear_fluids(&self) -> (bool, bool) {
|
||||
let look = look();
|
||||
(look.clear_lava, look.clear_water)
|
||||
}
|
||||
/// The chat's extras: Ctrl+F search, copy on click, timestamps, repeats counted.
|
||||
pub(super) fn chat_look(&self) -> Chat {
|
||||
look().chat
|
||||
}
|
||||
/// What the chat lays its lines out with.
|
||||
pub(super) fn chat_qol(&self) -> ChatQol {
|
||||
let chat = look().chat;
|
||||
ChatQol {
|
||||
timestamps: chat.timestamps,
|
||||
compact: chat.compact,
|
||||
}
|
||||
}
|
||||
/// Whether vanilla's status effect icons are left out (`Hud.extractEffects`).
|
||||
pub(super) fn effect_icons_hidden(&self) -> bool {
|
||||
look().hide_effect_icons
|
||||
}
|
||||
/// Whether this window may draw its own time and weather: only a client of a server (single
|
||||
/// player's included) draws them, never the server that ticks the world.
|
||||
fn own_view(&self) -> bool {
|
||||
self.remote.is_some() && self.dim == Dim::Overworld
|
||||
}
|
||||
/// The day time the frame draws (sky, sun, moon, the sky's light): the look's time of day,
|
||||
/// else the world's. The world's own `day_time` and everything that plays by it stay.
|
||||
pub(super) fn view_day_time(&self) -> i64 {
|
||||
if !self.own_view() {
|
||||
return self.world.day_time;
|
||||
}
|
||||
look().day_time(self.world.day_time)
|
||||
}
|
||||
/// The rain and thunder levels the frame draws (rain, the sky's colour and light, fog): the
|
||||
/// look's weather, else the world's. Gameplay (riptide, mobs, crops) keeps the world's.
|
||||
pub(super) fn view_weather(&self, partial: f32) -> (f32, f32) {
|
||||
match look().weather {
|
||||
Some(levels) if self.own_view() && self.can_have_weather() => levels,
|
||||
_ => self.weather_levels(partial),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -899,32 +899,4 @@ impl Game<'_> {
|
||||
f.input_clicked(world_recipes!(self), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,6 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod index;
|
||||
mod nbt;
|
||||
pub(super) use index::CartIndex;
|
||||
use index::cart_at;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(super) enum CartKind {
|
||||
@@ -191,6 +187,102 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
|
||||
mod display;
|
||||
use display::{cart_render_bounds, display_offset, shown_unless_air};
|
||||
pub(super) use display::{default_display_offset, forget_unreadable_display};
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(super) struct CartIndex {
|
||||
carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
|
||||
/// the push the cart loses (the entity gets a quarter of it back).
|
||||
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
@@ -2892,6 +2984,177 @@ impl Game<'_> {
|
||||
.render_state(&scene, state.0, m, light, out);
|
||||
}
|
||||
}
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
|
||||
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
|
||||
/// TNT cart.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
|
||||
use super::*;
|
||||
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct CartIndex {
|
||||
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
pub(super) near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
pub(super) fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
pub(super) fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
pub(super) fn carts_near(
|
||||
&mut self,
|
||||
carts: &[Minecart],
|
||||
i: usize,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
pub(super) fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
//! Saving and loading a minecart.
|
||||
use super::*;
|
||||
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ impl Game<'_> {
|
||||
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
|
||||
/// in `slot` for the mob `m`.
|
||||
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
|
||||
let (armor, toughness, attack) = self.attributes(m, stack, slot);
|
||||
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
|
||||
Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
@@ -173,18 +173,22 @@ impl Game<'_> {
|
||||
|
||||
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
|
||||
/// attribute starts from the mob's own value. (armour, toughness, damage)
|
||||
fn attributes(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> (f64, f64, f64) {
|
||||
let modifiers = self.items().attribute_modifiers(stack);
|
||||
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
|
||||
let empty: &[(String, String, f64, String, String)] = &[];
|
||||
let modifiers = self
|
||||
.items()
|
||||
.get(id)
|
||||
.map_or(empty, |i| i.modifiers.as_slice());
|
||||
(
|
||||
equip::attribute_with(&modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
|
||||
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
|
||||
equip::attribute_with(
|
||||
&modifiers,
|
||||
modifiers,
|
||||
"minecraft:armor_toughness",
|
||||
f64::from(m.kind.armor_toughness),
|
||||
slot,
|
||||
),
|
||||
equip::attribute_with(
|
||||
&modifiers,
|
||||
modifiers,
|
||||
"minecraft:attack_damage",
|
||||
f64::from(m.attrs.attack_damage),
|
||||
slot,
|
||||
|
||||
@@ -25,7 +25,6 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
self.particles.tick(&level);
|
||||
}
|
||||
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user