mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128575e4e5 | ||
|
|
cb870cf80d | ||
|
|
8aed306751 | ||
|
|
bb19f898e4 | ||
|
|
bbc17333bd | ||
|
|
8630bbed74 | ||
|
|
2c5baa6a60 | ||
|
|
ed35d477be | ||
|
|
7a236ba054 | ||
|
|
bf125a1a69 | ||
|
|
7e6d2121b7 | ||
|
|
a89865b1c5 | ||
|
|
c8e48f9cea | ||
|
|
bdb6452da9 | ||
|
|
2ddd9705d9 | ||
|
|
868d3b76a5 | ||
|
|
b4f24319ac | ||
|
|
962f5949ac | ||
|
|
5e9024e0a1 | ||
|
|
559c2d4e81 | ||
|
|
ee449e0ef2 | ||
|
|
dc3d97ce25 | ||
|
|
cab5f23234 | ||
|
|
2c5d1990ce | ||
|
|
ec3e4df6e3 | ||
|
|
a0f02b61ea | ||
|
|
7f2e64b603 | ||
|
|
c873af989a | ||
|
|
84baabff3c | ||
|
|
6622e459b2 | ||
|
|
600a863354 | ||
|
|
b77e4c4924 | ||
|
|
861b9e7b16 | ||
|
|
b75193985b | ||
|
|
1a07a02990 | ||
|
|
c221aa9ec9 | ||
|
|
3e77ec425f | ||
|
|
2a018699ac | ||
|
|
5757de7e74 | ||
|
|
916ab6b8b1 | ||
|
|
b56e58fde3 | ||
|
|
0df07ea3cf | ||
|
|
e527784bc3 | ||
|
|
5dd06d27cb | ||
|
|
3c5a95239d | ||
|
|
af593b6559 | ||
|
|
aa060c5fe1 | ||
|
|
07ff96c50c | ||
|
|
19c23a96c6 | ||
|
|
29d8c49bc2 | ||
|
|
ed3fabb7b4 | ||
|
|
83c50cf720 | ||
|
|
15da067a21 | ||
|
|
382776e2ee | ||
|
|
9163335028 | ||
|
|
d3392cb35b | ||
|
|
26d55e5d39 | ||
|
|
f82a1bd4fb | ||
|
|
60b7307b3f |
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` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
|
||||
/// game (the engine has none of its own).
|
||||
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
|
||||
/// keeps the first icon.
|
||||
pub fn set_icon_png(png: &'static [u8]) {
|
||||
let _ = ICON_PNG.set(png);
|
||||
}
|
||||
|
||||
/// The icon given to [`set_icon_png`], if any.
|
||||
pub fn icon_png() -> Option<&'static [u8]> {
|
||||
ICON_PNG.get().copied()
|
||||
}
|
||||
|
||||
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
|
||||
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
|
||||
/// can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
|
||||
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
|
||||
pub fn with_horizon_icon(
|
||||
attributes: winit::window::WindowAttributes,
|
||||
) -> winit::window::WindowAttributes {
|
||||
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
|
||||
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
|
||||
/// icon from appearing.
|
||||
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -706,7 +727,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -72,7 +72,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
@@ -132,6 +131,8 @@ windows-segment-heap = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# ui_theme's unit tests draw text in the design system's fonts.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
|
||||
@@ -30,7 +30,6 @@ horizon-render-pipeline = { path = "../horizon-render-pipeline" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -82,5 +81,7 @@ test-support = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -362,7 +362,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
look: Default::default(),
|
||||
pvp: 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,
|
||||
look: Default::default(),
|
||||
pvp: 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 written as a
|
||||
/// plain string component.
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is the component
|
||||
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
|
||||
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", Tag::String(last.plain().as_str().into())));
|
||||
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
|
||||
}
|
||||
out.push((
|
||||
"UpdateLastExecution",
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
use glam::Mat4;
|
||||
|
||||
/// A layer's texture, RGBA8.
|
||||
/// 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).
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
@@ -34,62 +41,44 @@ 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 {
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
/// The world the layers were last drawn for.
|
||||
pub world: Option<i32>,
|
||||
/// Each layer's eased visibility, 0 to 1.
|
||||
pub eased: Vec<f32>,
|
||||
}
|
||||
|
||||
/// 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(
|
||||
/// 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(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
@@ -97,147 +86,30 @@ impl CustomSky {
|
||||
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();
|
||||
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,
|
||||
});
|
||||
}
|
||||
s.world = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
/// 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))
|
||||
}
|
||||
@@ -1001,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 outline, hurt overlay and glint colours a crate above set (`horizon_hooks::look`).
|
||||
pub look: horizon_hooks::look::Frame,
|
||||
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
|
||||
pub pvp: crate::pvp::Frame,
|
||||
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
|
||||
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
|
||||
/// over everything.
|
||||
@@ -1329,7 +1329,7 @@ pub struct Renderer {
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
stars: stars::Stars,
|
||||
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
/// 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`).
|
||||
@@ -5517,11 +5517,11 @@ impl Renderer {
|
||||
))
|
||||
});
|
||||
}
|
||||
/// 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) {
|
||||
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
|
||||
self.custom_sky.set_pack(&self.device, &self.queue, pack);
|
||||
}
|
||||
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
|
||||
/// The custom sky layers the next frame draws (`SkyLayers::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
|
||||
@@ -8506,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.look.hurt_texel,
|
||||
frame.pvp.hurt_texel,
|
||||
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
|
||||
frame.look.glint,
|
||||
frame.pvp.glint,
|
||||
0,
|
||||
],
|
||||
emissive_layers: {
|
||||
@@ -9495,7 +9495,7 @@ impl Renderer {
|
||||
}
|
||||
};
|
||||
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
|
||||
let (custom, scale) = frame.look.outline.map_or((None, 1.0), |o| (Some(o.0), o.1));
|
||||
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
|
||||
if frame.outline_high_contrast {
|
||||
stroke(0xff00_0000, 7.0 * scale, lines);
|
||||
stroke(custom.unwrap_or(0xff57_ffe1), width * scale, lines);
|
||||
|
||||
@@ -524,7 +524,7 @@ impl Scene {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
look: Default::default(),
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! Custom sky layers (`crate::custom_sky`) 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::{CustomSky as Pack, Draw};
|
||||
use crate::custom_sky::{Blend, Draw, SkyLayers};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
@@ -85,6 +84,76 @@ 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);
|
||||
@@ -256,10 +325,15 @@ impl CustomSky {
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
pub(super) fn set_pack(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pack: &dyn SkyLayers,
|
||||
) {
|
||||
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.
|
||||
@@ -413,6 +487,38 @@ 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,
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
|
||||
@@ -1001,12 +1001,14 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
|
||||
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
|
||||
/// given: an empty mask.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let (w, h, rgba) = horizon_client_input::window::icon_png()
|
||||
.and_then(|png| crate::hud::decode_bytes(png).ok())
|
||||
.unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
|
||||
@@ -602,7 +602,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
look: Default::default(),
|
||||
pvp: 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,
|
||||
look: Default::default(),
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -409,7 +409,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
look: Default::default(),
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -2664,7 +2664,7 @@ pub(super) mod initial_history_tests {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
look: Default::default(),
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
|
||||
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
|
||||
//! with a thin edge, so the world shows through, flat controls, and the violet
|
||||
//! face on the one main button, in vanilla's font.
|
||||
//! Its colours come from `menu_style`, given at startup (in the official game the design
|
||||
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
|
||||
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
|
||||
//! the one main button, in vanilla's font.
|
||||
use crate::menu_style::{self, SettingsColours};
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -36,54 +41,91 @@ impl Cost {
|
||||
/// `0xRRGGBB`: the palette's success, warn and danger.
|
||||
pub fn rgb(self) -> u32 {
|
||||
match self {
|
||||
Cost::None => B.ink_soft,
|
||||
Cost::Low | Cost::Saves => B.success,
|
||||
Cost::Medium => B.warn,
|
||||
Cost::High => B.danger,
|
||||
Cost::None => b().ink_soft,
|
||||
Cost::Low | Cost::Saves => b().success,
|
||||
Cost::Medium => b().warn,
|
||||
Cost::High => b().danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
|
||||
const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
}
|
||||
use menu_style::hud_colour as hex;
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
/// Their 1 px edge.
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
/// The open page in the page list.
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
const BUTTON: u32 = hex(B.ink, 20);
|
||||
const BUTTON_HOVER: u32 = hex(B.ink, 41);
|
||||
const TRACK: u32 = hex(B.ink, 64);
|
||||
const HANDLE: u32 = hex(B.ink, 255);
|
||||
fn button_fill() -> u32 {
|
||||
hex(b().ink, 20)
|
||||
}
|
||||
fn button_hover() -> u32 {
|
||||
hex(b().ink, 41)
|
||||
}
|
||||
fn track_fill() -> u32 {
|
||||
hex(b().ink, 64)
|
||||
}
|
||||
fn handle_fill() -> u32 {
|
||||
hex(b().ink, 255)
|
||||
}
|
||||
/// The one main button.
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
/// A tick box's edge when off.
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const ACCENT: u32 = hex(B.accent, 255);
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
/// Vanilla's white text, and its grey for what can't be used now.
|
||||
pub const WHITE: u32 = u32::MAX;
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub const SOFT: u32 = hex(B.ink_soft, 255);
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
|
||||
pub const GREY: u32 = hex(0xaaaaaa, 255);
|
||||
/// A light veil over the backdrop, under the panels.
|
||||
pub const VEIL: u32 = hex(B.page, 77);
|
||||
pub fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
|
||||
/// Sizes in GUI pixels.
|
||||
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
|
||||
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
|
||||
}
|
||||
/// The screen's plate, and the strip under it: dark glass with a thin edge.
|
||||
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GLASS);
|
||||
ring(batch, atlas, rect, EDGE);
|
||||
fill(batch, atlas, rect, glass());
|
||||
ring(batch, atlas, rect, edge());
|
||||
}
|
||||
/// A group's panel on the plate, its rows on it.
|
||||
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
fill(batch, atlas, rect, group_fill());
|
||||
}
|
||||
/// A sunk well: the search field, the value stepper.
|
||||
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
}
|
||||
/// The scroller: a faint track and its thumb.
|
||||
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
fill(batch, atlas, track, row_rule());
|
||||
fill(batch, atlas, thumb, track_fill());
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
|
||||
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
|
||||
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
|
||||
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
let edge = match (on, live) {
|
||||
(true, true) => ACCENT,
|
||||
(true, false) => MUTED,
|
||||
(false, _) => BOX_EDGE,
|
||||
(true, true) => accent(),
|
||||
(true, false) => muted(),
|
||||
(false, _) => box_edge(),
|
||||
};
|
||||
ring(batch, atlas, rect, edge);
|
||||
if on {
|
||||
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
for (i, c) in row.chars().enumerate() {
|
||||
if c == '#' {
|
||||
let (px, py) = (x + i as f32, y + j as f32);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
|
||||
fill(batch, atlas, [px, py, 1.0, 1.0], color);
|
||||
}
|
||||
}
|
||||
@@ -198,17 +240,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(true, false, true) => BUTTON_HOVER,
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
};
|
||||
fill(batch, atlas, rect, face);
|
||||
if hover && live {
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
@@ -252,7 +294,7 @@ pub fn slider(
|
||||
(hover, live): (bool, bool),
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -263,17 +305,22 @@ pub fn slider(
|
||||
);
|
||||
let line = y + h - 3.0;
|
||||
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
fill(batch, atlas, [x, line, w, 2.0], track_fill());
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x, line, at - x, 2.0],
|
||||
if live { ACCENT } else { MUTED },
|
||||
if live { accent() } else { muted() },
|
||||
);
|
||||
let handle = [at, line - 3.0, 4.0, 8.0];
|
||||
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
handle,
|
||||
if live { handle_fill() } else { muted() },
|
||||
);
|
||||
if hover && live {
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
|
||||
}
|
||||
}
|
||||
/// A value that a press steps through: in a well, between the arrows that say so.
|
||||
@@ -290,13 +337,13 @@ pub fn cycler(
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let arrows = if hover && live { WHITE } else { MUTED };
|
||||
let arrows = if hover && live { WHITE } else { muted() };
|
||||
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
|
||||
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
|
||||
let color = match (live, hover) {
|
||||
(false, _) => MUTED,
|
||||
(false, _) => muted(),
|
||||
(true, true) => WHITE,
|
||||
(true, false) => SOFT,
|
||||
(true, false) => soft(),
|
||||
};
|
||||
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
|
||||
//! no motion ever decides what input does.
|
||||
//!
|
||||
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
|
||||
//! of them, at the theme's duration or a shorter one of its own.
|
||||
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
|
||||
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
|
||||
//! own.
|
||||
//!
|
||||
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
|
||||
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
|
||||
@@ -16,7 +17,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! // Half way through: partly faded in, partly grown from 94%.
|
||||
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
|
||||
//! let alpha = motion::fade(open, clock, opened);
|
||||
@@ -26,7 +27,6 @@
|
||||
//! let still = Clock::new(opened, true);
|
||||
//! assert_eq!(motion::fade(open, still, opened), 1.0);
|
||||
//! ```
|
||||
use horizon_theme::Motion;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The time a frame shows, built once a frame by the game.
|
||||
@@ -50,28 +50,20 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and one of the design system's easing curves.
|
||||
/// A duration and an easing curve.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
|
||||
/// the same curves.
|
||||
pub const fn new(duration: Duration, motion: Motion) -> Self {
|
||||
Self {
|
||||
duration,
|
||||
curve: motion.curve,
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme motion as it is: its own duration and curve.
|
||||
pub const fn of(motion: Motion) -> Self {
|
||||
Self::new(motion.duration(), motion)
|
||||
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
|
||||
/// curves.
|
||||
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
|
||||
Self { duration, curve }
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -84,7 +76,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -98,6 +90,29 @@ impl Tween {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
|
||||
/// returns y.
|
||||
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 0.0 || t == 1.0 {
|
||||
return t;
|
||||
}
|
||||
let at = |a: f32, b: f32, s: f32| {
|
||||
let u = 1.0 - s;
|
||||
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
|
||||
};
|
||||
let (mut lo, mut hi) = (0f32, 1f32);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if at(x1, x2, mid) < t {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
at(y1, y2, (lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// An alpha from 0 to 1 as something fades in.
|
||||
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
|
||||
tween.progress(clock, since)
|
||||
@@ -145,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
|
||||
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
|
||||
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
|
||||
@@ -160,8 +175,7 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
assert_eq!(fade(tween, still, t0), 1.0);
|
||||
assert_eq!(fade_out(tween, still, t0), 0.0);
|
||||
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
|
||||
@@ -173,7 +187,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -184,10 +198,25 @@ mod tests {
|
||||
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_the_design_systems() {
|
||||
for motion in horizon_theme::MOTION {
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
assert_eq!(
|
||||
bezier(motion.curve, t),
|
||||
motion.ease(t),
|
||||
"{} at {t}",
|
||||
motion.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_takes_the_short_way_round() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
|
||||
let from = 7.0 * FRAC_PI_4;
|
||||
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::{colour, fit};
|
||||
use crate::menu_style::{self, OverlayColours};
|
||||
|
||||
/// The overlays' colours (`menu_style`, given at startup).
|
||||
fn overlay() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
|
||||
pub fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
|
||||
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
|
||||
/// The selected tab's fill and text, and the others'.
|
||||
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
|
||||
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
|
||||
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
|
||||
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub fn selected_text() -> u32 {
|
||||
colour(overlay().page, 255)
|
||||
}
|
||||
pub fn tab_fill() -> u32 {
|
||||
colour(overlay().page, 191)
|
||||
}
|
||||
pub fn tab_text() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
|
||||
for tab in &row.tabs {
|
||||
let [tx, ty, tw, th] = tab.area;
|
||||
let (fill, text) = if tab.selected {
|
||||
(SELECTED_FILL, SELECTED_TEXT)
|
||||
(SELECTED_FILL, selected_text())
|
||||
} else {
|
||||
(TAB_FILL, TAB_TEXT)
|
||||
(tab_fill(), tab_text())
|
||||
};
|
||||
let tab_start = batch.vertices.len();
|
||||
// Cut where the edges' fades start, so each fade runs over its own piece.
|
||||
@@ -380,15 +400,16 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu_blur;
|
||||
pub mod menu_style;
|
||||
pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
@@ -89,6 +90,8 @@ 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;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
|
||||
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
|
||||
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
|
||||
//! read it.
|
||||
//!
|
||||
//! The layer above gives it once at startup ([`install`]), before the first frame; the
|
||||
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
|
||||
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
|
||||
//! use their own fonts. A read is one atomic load, and only those screens make it.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MenuStyle {
|
||||
pub settings: SettingsColours,
|
||||
pub overlay: OverlayColours,
|
||||
pub sizes: Sizes,
|
||||
/// The overlays' TrueType faces; none: they draw no text.
|
||||
pub fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SettingsColours {
|
||||
/// The plate behind everything, drawn see-through.
|
||||
pub page: u32,
|
||||
/// Text, and the light the panels, rules, hovers and controls are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// Labels, descriptions, what can't be used.
|
||||
pub muted: u32,
|
||||
/// Wells and the dark edge of small marks.
|
||||
pub outline: u32,
|
||||
/// Focus, a checked box, a slider's fill.
|
||||
pub accent: u32,
|
||||
/// The one main button, and while hovered.
|
||||
pub face: u32,
|
||||
pub face_hover: u32,
|
||||
pub success: u32,
|
||||
pub warn: u32,
|
||||
pub danger: u32,
|
||||
}
|
||||
|
||||
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OverlayColours {
|
||||
/// The dark page under text and tabs.
|
||||
pub page: u32,
|
||||
/// Muted text over a veiled backdrop.
|
||||
pub backdrop_muted: u32,
|
||||
/// Text and the light hover and pressed plates are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// One-pixel rules between rows.
|
||||
pub rule: u32,
|
||||
/// The main action, and its text.
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
}
|
||||
|
||||
/// `ui_theme`'s type scale and shapes, in dp.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sizes {
|
||||
pub caption: f32,
|
||||
pub label: f32,
|
||||
pub ui: f32,
|
||||
pub body: f32,
|
||||
pub title: f32,
|
||||
/// The boot screen's wordmark at its smallest, and its console lines.
|
||||
pub display: f32,
|
||||
pub micro: f32,
|
||||
/// Fields; plates (hover rows, selection); a large control's height.
|
||||
pub radius: f32,
|
||||
pub radius_plate: f32,
|
||||
pub control_large: f32,
|
||||
}
|
||||
|
||||
/// TrueType faces (`ui_theme::Face`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fonts {
|
||||
pub regular: &'static [u8],
|
||||
pub medium: &'static [u8],
|
||||
pub semibold: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
/// The look with nothing given: vanilla's greys and chat colours.
|
||||
pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: 0x101010,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
muted: 0xa0a0a0,
|
||||
outline: 0x000000,
|
||||
accent: 0xffffff,
|
||||
face: 0x555555,
|
||||
face_hover: 0x6f6f6f,
|
||||
success: 0x55ff55,
|
||||
warn: 0xffff55,
|
||||
danger: 0xff5555,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: 0x101010,
|
||||
backdrop_muted: 0xa0a0a0,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
label: 13.0,
|
||||
ui: 14.0,
|
||||
body: 15.0,
|
||||
title: 28.0,
|
||||
display: 44.0,
|
||||
micro: 11.0,
|
||||
radius: 0.0,
|
||||
radius_plate: 0.0,
|
||||
control_large: 40.0,
|
||||
},
|
||||
fonts: None,
|
||||
};
|
||||
|
||||
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
|
||||
|
||||
/// Give the menus their look: call it once at startup. A second call keeps the first look and
|
||||
/// returns false.
|
||||
pub fn install(style: MenuStyle) -> bool {
|
||||
STYLE.set(style).is_ok()
|
||||
}
|
||||
|
||||
/// The look given to [`install`], or [`NEUTRAL`].
|
||||
#[inline]
|
||||
pub fn get() -> &'static MenuStyle {
|
||||
STYLE.get().unwrap_or(&NEUTRAL)
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
|
||||
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
|
||||
}
|
||||
@@ -1331,8 +1331,6 @@ pub enum Page {
|
||||
ModProject,
|
||||
/// A native mod's settings page: its index in `mods.installed`.
|
||||
NativeMod(usize),
|
||||
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
|
||||
Added(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
|
||||
@@ -1392,8 +1390,6 @@ enum Act {
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
NativeButton(usize, usize),
|
||||
/// A registered page's row: (page, row).
|
||||
PageRow(usize, usize),
|
||||
OpenModFolder,
|
||||
/// Browse mods: the page of listing `.0` of `mod_browser`.
|
||||
ModProject(usize),
|
||||
@@ -1621,11 +1617,8 @@ pub struct OptionsUi {
|
||||
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
|
||||
/// mod's, `selected_native_key`).
|
||||
pub selected_key: Option<&'static str>,
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
|
||||
/// (page, row) ([`pages::PAGE_KEY`]).
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`.
|
||||
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
|
||||
@@ -1737,7 +1730,6 @@ 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,
|
||||
@@ -2375,7 +2367,6 @@ impl OptionsUi {
|
||||
Page::ModBrowser => "horizon.mods.browse.title",
|
||||
Page::ModProject => "horizon.mods.browse.project",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::Added(i) => pages::title(i),
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::PackOptions => "horizon.pack.options.title",
|
||||
@@ -2656,7 +2647,47 @@ impl OptionsUi {
|
||||
flush(&mut rows, &mut section);
|
||||
rows
|
||||
}
|
||||
Page::Horizon => self.horizon_rows(s),
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
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()
|
||||
}
|
||||
Page::Agent => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::AgentControl),
|
||||
@@ -2939,7 +2970,6 @@ impl OptionsUi {
|
||||
Page::ModBrowser => self.mod_browser_rows(),
|
||||
Page::ModProject => self.mod_project_rows(s),
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::Added(i) => self.added_rows(i),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -4208,7 +4238,6 @@ impl OptionsUi {
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
@@ -4712,8 +4741,8 @@ impl OptionsUi {
|
||||
let Some(name) = self.selected_key.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if name == NATIVE_KEY || name == pages::PAGE_KEY {
|
||||
return self.bind_native_key(s, name, value);
|
||||
if name == NATIVE_KEY {
|
||||
return self.bind_native_key(s, value);
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
@@ -5097,7 +5126,6 @@ impl OptionsUi {
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
|
||||
@@ -1,151 +1,8 @@
|
||||
//! The pages crates add to the options screens: a native mod's settings page (its keys and
|
||||
//! buttons), and the pages crates above the engine register (`horizon_hooks::pages`, docs/layers.md
|
||||
//! "Platform registries") with their entries on Horizon Settings. Kept out of options_ui.rs, which
|
||||
//! may not grow (tools/check_file_size.py).
|
||||
//! buttons). Kept out of options_ui.rs, which may not grow (tools/check_file_size.py).
|
||||
use super::*;
|
||||
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";
|
||||
|
||||
/// A registered page's title (a translation key).
|
||||
pub(super) fn title(page: usize) -> &'static str {
|
||||
added::engine::all().get(page).map_or("", |p| p.title())
|
||||
}
|
||||
|
||||
fn label_text(label: added::Label) -> Text {
|
||||
match label {
|
||||
added::Label::Key(key) => tr(key),
|
||||
added::Label::Value(value) => Text::literal(value),
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionsUi {
|
||||
/// Horizon Settings: its pages, then the pages crates registered for it.
|
||||
pub(super) fn horizon_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open = |key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
let mut rows: Vec<Row> = vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
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 = self
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
|
||||
.map(|(i, page)| button(0.0, 0.0, 150.0, tr(page.title()), Act::Push(Page::Added(i))));
|
||||
while let Some(first) = entries.next() {
|
||||
rows.push(Row::Buttons(first, entries.next()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) = self.pages.get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
let act = Act::PageRow(page, r);
|
||||
let wide = |label: Text| Row::Buttons(button(0.0, 0.0, 310.0, label, act.clone()), None);
|
||||
match row {
|
||||
added::Row::Heading(key) => Row::Heading(key.to_owned()),
|
||||
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::Button { label } => wide(tr(label)),
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
&& self.selected_native_key == Some((page, r))
|
||||
{
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, tr(label), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, act);
|
||||
Row::Buttons(inactive(caption), Some(change))
|
||||
}
|
||||
// A row kind newer than this engine: shown empty.
|
||||
_ => Row::Note(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) = self.pages.get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
match rows.get(r) {
|
||||
Some(added::Row::Key { .. }) => {
|
||||
self.selected_key = Some(PAGE_KEY);
|
||||
self.selected_native_key = Some((page, r));
|
||||
Vec::new()
|
||||
}
|
||||
Some(_) => {
|
||||
registered.act(
|
||||
r,
|
||||
if shift {
|
||||
added::Act::PressBack
|
||||
} else {
|
||||
added::Act::Press
|
||||
},
|
||||
);
|
||||
vec![Event::Changed]
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
@@ -189,21 +46,9 @@ impl OptionsUi {
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
|
||||
/// The key pressed for the native mod's key, or the registered page's ([`PAGE_KEY`]), that
|
||||
/// waited for one.
|
||||
pub(super) fn bind_native_key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
name: &str,
|
||||
value: String,
|
||||
) -> Vec<Event> {
|
||||
/// The key pressed for the native mod's key that waited for one.
|
||||
pub(super) fn bind_native_key(&mut self, s: &mut Settings, value: String) -> Vec<Event> {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
if name == PAGE_KEY {
|
||||
if let Some(page) = self.pages.get(i) {
|
||||
page.act(k, added::Act::Bind(value));
|
||||
}
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -215,113 +60,3 @@ impl OptionsUi {
|
||||
vec![Event::Changed]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl added::Page for Sample {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.sample"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.sample.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Heading("test.sample.heading"));
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.sample.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.sample.step",
|
||||
value: added::Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Key {
|
||||
label: "test.sample.key",
|
||||
key: self.key.lock().unwrap().clone(),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: added::Act) {
|
||||
match (row, act) {
|
||||
(1, added::Act::Press) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(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,
|
||||
other => panic!("row and act the page doesn't expect: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static SAMPLE: Sample = Sample {
|
||||
on: Mutex::new(false),
|
||||
step: Mutex::new(0),
|
||||
key: Mutex::new(String::new()),
|
||||
};
|
||||
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
|
||||
|
||||
/// Clicks the active button that does `act`, with Shift held or not.
|
||||
fn press(ui: &mut OptionsUi, s: &mut Settings, act: Act, shift: bool) -> Vec<Event> {
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
let (x, y) = widgets
|
||||
.iter()
|
||||
.find_map(|w| match &w.kind {
|
||||
WKind::Button {
|
||||
act: a,
|
||||
active: true,
|
||||
..
|
||||
} if *a == act => Some((w.x + 1.0, w.y + 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no button does {act:?}"));
|
||||
ui.shift = shift;
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(s, &atlas, x, y, size, [480, 240], false, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
|
||||
let mut s = Settings::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));
|
||||
// Rows: the heading (0), the switch (1), the step (2), the key (3).
|
||||
let events = press(&mut ui, &mut s, Act::PageRow(0, 1), false);
|
||||
assert_eq!(
|
||||
events.last(),
|
||||
Some(&Event::Changed),
|
||||
"the settings get saved"
|
||||
);
|
||||
assert!(*SAMPLE.on.lock().unwrap());
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
assert_eq!(*SAMPLE.step.lock().unwrap(), -1, "Shift steps back");
|
||||
// A key row waits for the next key, as on Key Binds, and the page gets its name.
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
let atlas = super::super::tests::atlas();
|
||||
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyZ, None);
|
||||
assert_eq!(events, [Event::Changed]);
|
||||
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
|
||||
assert_eq!(ui.selected_key, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1222,8 +1222,9 @@ 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 ParticleLevel<'_> {
|
||||
impl<'a> ParticleLevel<'a> {
|
||||
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.world,
|
||||
@@ -1231,9 +1232,6 @@ impl ParticleLevel<'_> {
|
||||
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;
|
||||
}
|
||||
@@ -1249,38 +1247,46 @@ impl ParticleLevel<'_> {
|
||||
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.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
if s.fluid == Fluid::None {
|
||||
return (Fluid::None, 0.0);
|
||||
}
|
||||
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
|
||||
let height = if above.fluid == s.fluid {
|
||||
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
|
||||
let h = if above.fluid == s.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(s.fluid_level) / 9.0
|
||||
};
|
||||
(s.fluid, height)
|
||||
(s.fluid, h)
|
||||
}
|
||||
/// 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.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.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.world.block(b[0], b[1], b[2]);
|
||||
let state = self.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
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ if self.facts.get(state).full_collision => 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4052,15 +4058,12 @@ impl ParticleEngine {
|
||||
}
|
||||
Behaviour::Lava => {
|
||||
p.base_tick(level);
|
||||
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,
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
@@ -4144,9 +4147,7 @@ impl ParticleEngine {
|
||||
*c *= f64::from(0.98f32);
|
||||
}
|
||||
if p.on_ground {
|
||||
if p.random.next_float() < 0.5 {
|
||||
p.removed = true;
|
||||
}
|
||||
p.removed |= p.random.next_float() < 0.5;
|
||||
p.velocity[0] *= f64::from(0.7f32);
|
||||
p.velocity[2] *= f64::from(0.7f32);
|
||||
}
|
||||
@@ -4186,13 +4187,10 @@ impl ParticleEngine {
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
|
||||
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 {
|
||||
if !(fluid == Fluid::Water && source)
|
||||
|| p.position[1] >= y_end
|
||||
|| p.position[1] <= y_prev
|
||||
{
|
||||
p.removed = true;
|
||||
}
|
||||
// randomHorizontalWiggling, then a second, horizontal move.
|
||||
@@ -4216,9 +4214,8 @@ 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 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
@@ -4682,7 +4679,7 @@ impl ParticleEngine {
|
||||
self.sprite_from_age(p);
|
||||
half(p);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
p.velocity[1] -= f64::from(0.0074f32);
|
||||
}
|
||||
}
|
||||
@@ -4690,7 +4687,7 @@ impl ParticleEngine {
|
||||
Behaviour::Firefly => {
|
||||
p.base_tick(level);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if !level.facts.is_air(level.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);
|
||||
@@ -5154,11 +5151,12 @@ fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
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)
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
a[0]
|
||||
} else {
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Starter {
|
||||
@@ -5589,6 +5587,7 @@ 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]);
|
||||
@@ -5680,6 +5679,7 @@ 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,6 +124,7 @@ 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> {
|
||||
struct Ctx<'a, 'l> {
|
||||
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<'a>>,
|
||||
level: Option<&'a ParticleLevel<'l>>,
|
||||
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,6 +1662,7 @@ 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.
|
||||
@@ -1701,6 +1702,7 @@ 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]);
|
||||
@@ -1722,6 +1724,7 @@ 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]);
|
||||
@@ -1767,6 +1770,7 @@ 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();
|
||||
@@ -1820,6 +1824,7 @@ 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);
|
||||
@@ -1853,6 +1858,7 @@ 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 {
|
||||
@@ -1905,6 +1911,7 @@ 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 {
|
||||
@@ -1966,6 +1973,7 @@ 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]);
|
||||
@@ -2014,6 +2022,7 @@ 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 {
|
||||
@@ -2080,6 +2089,7 @@ 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.
|
||||
@@ -2198,6 +2208,7 @@ 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]);
|
||||
@@ -2276,6 +2287,7 @@ 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.
|
||||
|
||||
@@ -93,6 +93,7 @@ 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,7 +4,6 @@
|
||||
//! 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.
|
||||
@@ -264,6 +263,15 @@ 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)]
|
||||
@@ -520,36 +528,24 @@ 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 {
|
||||
/// 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
|
||||
pub fn frame(&self) -> Frame {
|
||||
Frame {
|
||||
outline: self.outline.stroke(),
|
||||
hurt_texel: self.hit.texel(),
|
||||
glint: self.tweaks.glint_texel(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +741,15 @@ 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.
|
||||
@@ -761,6 +766,21 @@ 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 {
|
||||
@@ -1687,6 +1707,18 @@ 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();
|
||||
@@ -1773,33 +1805,31 @@ 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 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));
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -337,13 +337,6 @@ 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()
|
||||
|
||||
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
|
||||
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
|
||||
}
|
||||
|
||||
/// The mark and the screen's name over the search field.
|
||||
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
|
||||
let (x, y) = (cols.side_x, cols.side_y);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
|
||||
let horizon = Text::literal("Horizon");
|
||||
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
|
||||
let title = Text::literal(title);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
|
||||
}
|
||||
|
||||
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
|
||||
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
|
||||
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
|
||||
}
|
||||
if open {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
|
||||
}
|
||||
let color = match (open || hover, has) {
|
||||
(true, _) => style::WHITE,
|
||||
(false, true) => style::SOFT,
|
||||
(false, false) => style::MUTED,
|
||||
(false, true) => style::soft(),
|
||||
(false, false) => style::muted(),
|
||||
};
|
||||
let ty = py + ((ph - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
|
||||
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
|
||||
search.draw(batch, atlas, None);
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
}
|
||||
if !search.value.is_empty() {
|
||||
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
let color = if hover { style::WHITE } else { style::MUTED };
|
||||
let color = if hover { style::WHITE } else { style::muted() };
|
||||
let cy = y + ((h - 8.0) / 2.0).floor();
|
||||
ui::centered_text(
|
||||
batch,
|
||||
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
|
||||
batch,
|
||||
atlas,
|
||||
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
|
||||
style::HEAD_RULE,
|
||||
style::head_rule(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
}
|
||||
let Some(setting) = line.setting else {
|
||||
let (name, color, dy) = match line.group {
|
||||
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
|
||||
None => (page_name(line.page), style::ACCENT, 4.0),
|
||||
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
|
||||
None => (page_name(line.page), style::accent(), 4.0),
|
||||
};
|
||||
let name = Text::literal(name);
|
||||
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
|
||||
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
};
|
||||
if !after_name {
|
||||
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
|
||||
style::fill(batch, atlas, rule, style::ROW_RULE);
|
||||
style::fill(batch, atlas, rule, style::row_rule());
|
||||
}
|
||||
row(batch, setting, y);
|
||||
}
|
||||
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
&none,
|
||||
cols.list_x + 2.0,
|
||||
cols.top + 4.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
let active = live && (hover || focused || held);
|
||||
if active {
|
||||
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
|
||||
style::fill(batch, atlas, lit, style::HOVER);
|
||||
style::fill(batch, atlas, lit, style::hover());
|
||||
}
|
||||
crate::widgets::widget_cursor(hover, live);
|
||||
// The control socket's widget tree, the narrator and a controller's focus.
|
||||
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
if live {
|
||||
crate::widgets::target_as(rect, kind);
|
||||
}
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let soft = if live { style::SOFT } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let soft = if live { style::soft() } else { style::muted() };
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let lit = live && (hover || held);
|
||||
let left = rect[0] + style::INSET;
|
||||
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
|
||||
batch,
|
||||
atlas,
|
||||
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
|
||||
style::HOVER,
|
||||
style::hover(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -644,7 +644,7 @@ fn read_row(
|
||||
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
|
||||
let value = style::fit(atlas, value, room);
|
||||
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
|
||||
if focused {
|
||||
style::ring(
|
||||
batch,
|
||||
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
|
||||
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
|
||||
}
|
||||
let left = rect[0] + style::INSET;
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
|
||||
batch.text_styled(atlas, &shown, left, ty, color, true);
|
||||
if focused {
|
||||
@@ -799,7 +799,7 @@ fn side_lines(
|
||||
];
|
||||
crate::widgets::record(line, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, line, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
}
|
||||
/// A line under the page list: its inset, and its height.
|
||||
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
|
||||
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
|
||||
crate::widgets::record(text, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, text, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
|
||||
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
|
||||
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
|
||||
// The row's page is open: lit as the list's open page is.
|
||||
if list.lit == Some(*shown) {
|
||||
let row @ [x, y, _, h] = strip.area;
|
||||
style::fill(batch, atlas, row, style::OPEN);
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, row, style::open());
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
|
||||
}
|
||||
tabs::draw(batch, atlas, strip);
|
||||
}
|
||||
@@ -2134,7 +2134,14 @@ impl<S: Screen> Shell<S> {
|
||||
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
|
||||
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&name,
|
||||
rect[0] + style::INSET,
|
||||
ty,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
|
||||
@@ -2240,7 +2247,7 @@ impl<S: Screen> Shell<S> {
|
||||
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
|
||||
crate::widgets::record(about, cols.dock, false);
|
||||
let color = if why.is_some() {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
} else {
|
||||
style::GREY
|
||||
};
|
||||
|
||||
@@ -192,7 +192,12 @@ impl SignData {
|
||||
let side = |t: Option<&Tag>| -> SignText {
|
||||
let mut out = SignText::default();
|
||||
let Some(t) = t else { return out };
|
||||
if let Some(Tag::List(messages)) = t.get("messages") {
|
||||
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();
|
||||
};
|
||||
for (i, m) in messages.iter().take(4).enumerate() {
|
||||
out.lines[i] = component_text(m);
|
||||
out.click[i] = click_event(m);
|
||||
@@ -307,6 +312,27 @@ 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([
|
||||
|
||||
@@ -304,19 +304,19 @@ impl StudioUi {
|
||||
|
||||
// Card background & highlight
|
||||
if is_selected {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
} else if is_hovered {
|
||||
style::fill(batch, atlas, rect, style::HOVER);
|
||||
style::fill(batch, atlas, rect, style::hover());
|
||||
} else {
|
||||
style::well(batch, atlas, rect);
|
||||
}
|
||||
|
||||
let border_color = if is_selected {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if is_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::ROW_RULE
|
||||
style::row_rule()
|
||||
};
|
||||
style::ring(batch, atlas, rect, border_color);
|
||||
|
||||
|
||||
@@ -1238,6 +1238,9 @@ 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,
|
||||
@@ -1536,6 +1539,7 @@ 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(),
|
||||
@@ -1559,6 +1563,16 @@ 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();
|
||||
}
|
||||
@@ -1641,8 +1655,13 @@ 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
|
||||
@@ -1849,7 +1868,12 @@ impl EditBox {
|
||||
),
|
||||
}
|
||||
};
|
||||
let (tx, ty) = (self.text_x(), self.text_y());
|
||||
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 mut draw_x = tx;
|
||||
if !displayed.is_empty() {
|
||||
let half = if on_screen {
|
||||
@@ -2650,4 +2674,19 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ 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,6 +194,7 @@ 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,6 +612,7 @@ 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);
|
||||
@@ -1994,8 +1995,10 @@ pub fn campfire_particles<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
|
||||
/// `smoking` adds the plain smoke puff at the logs.
|
||||
/// `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.
|
||||
pub fn make_campfire_smoke(
|
||||
engine: &mut ParticleEngine,
|
||||
pos: [i32; 3],
|
||||
@@ -2014,7 +2017,13 @@ pub fn make_campfire_smoke(
|
||||
} else {
|
||||
T::CampfireCosySmoke
|
||||
};
|
||||
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
|
||||
engine.add_with(
|
||||
&ParticleOptions::simple(ty),
|
||||
[x, y, z],
|
||||
[0.0, 0.07, 0.0],
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if smoking {
|
||||
let r = engine.random();
|
||||
let dx = r.next_double() / 4.0 * sign(r);
|
||||
@@ -2024,13 +2033,49 @@ pub fn make_campfire_smoke(
|
||||
f64::from(pos[1]) + 0.4,
|
||||
f64::from(pos[2]) + 0.5 + dz,
|
||||
];
|
||||
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
|
||||
add(engine, 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;
|
||||
|
||||
@@ -17,15 +17,15 @@ pub use horizon_client_render::{
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
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_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,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, 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,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
@@ -87,7 +87,6 @@ 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;
|
||||
|
||||
@@ -13,6 +13,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
crate::horizon_look::give();
|
||||
// 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
|
||||
@@ -21,11 +24,6 @@ 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();
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
//! HUD boxes in the corners: the minimap's place and mods' panels, small
|
||||
//! boxes in GUI pixels stacked per corner (`docs/extras.md`), and the layers crates above the
|
||||
//! engine register (`horizon_hooks::hud`).
|
||||
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
|
||||
use crate::{
|
||||
extras::Corner,
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
};
|
||||
use horizon_hooks::hud;
|
||||
|
||||
/// The gap between an overlay and the screen edge or the next overlay.
|
||||
const MARGIN: f32 = 2.0;
|
||||
@@ -127,114 +125,6 @@ pub fn draw_lines(
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the HUD layers crates registered, after the extras' and mods' boxes, which they stack
|
||||
/// with. `shown`: whether each corner of `Corner::ALL` shows (the debug screen covers some).
|
||||
pub fn draw_layers(
|
||||
layers: &[&dyn hud::Layer],
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
shown: [bool; 4],
|
||||
) {
|
||||
let mut canvas = Canvas {
|
||||
batch,
|
||||
atlas,
|
||||
corners,
|
||||
shown,
|
||||
};
|
||||
for layer in layers {
|
||||
layer.draw(&mut canvas);
|
||||
}
|
||||
}
|
||||
|
||||
struct Canvas<'a> {
|
||||
batch: &'a mut HudBatch,
|
||||
atlas: &'a HudAtlas,
|
||||
corners: &'a mut Corners,
|
||||
shown: [bool; 4],
|
||||
}
|
||||
|
||||
/// `0xAARRGGBB` as the batch's colour.
|
||||
fn argb_color(argb: u32) -> u32 {
|
||||
let [a, r, g, b] = argb.to_be_bytes();
|
||||
rgba(r, g, b, a)
|
||||
}
|
||||
|
||||
impl hud::Canvas for Canvas<'_> {
|
||||
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) {
|
||||
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) {
|
||||
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
|
||||
}
|
||||
fn place(&mut self, corner: hud::Corner, w: f32, h: f32) -> Option<(f32, f32)> {
|
||||
let corner = match corner {
|
||||
hud::Corner::TopLeft => Corner::TopLeft,
|
||||
hud::Corner::TopRight => Corner::TopRight,
|
||||
hud::Corner::BottomLeft => Corner::BottomLeft,
|
||||
hud::Corner::BottomRight => Corner::BottomRight,
|
||||
};
|
||||
let i = Corner::ALL.iter().position(|c| *c == corner)?;
|
||||
self.shown[i].then(|| self.corners.place(corner, w, h))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
@@ -250,58 +140,4 @@ mod tests {
|
||||
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
|
||||
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
|
||||
}
|
||||
|
||||
/// Draws a box of one line in a corner and keeps where it went.
|
||||
struct Panel {
|
||||
corner: hud::Corner,
|
||||
at: std::sync::Mutex<Vec<Option<(f32, f32)>>>,
|
||||
}
|
||||
|
||||
impl hud::Layer for Panel {
|
||||
fn draw(&self, canvas: &mut dyn hud::Canvas) {
|
||||
let w = canvas.text_width("12 CPS") + 4.0;
|
||||
let at = canvas.place(self.corner, w, 12.0);
|
||||
if let Some((x, y)) = at {
|
||||
canvas.fill(x, y, w, 12.0, 0x7800_0000);
|
||||
canvas.text(x + 2.0, y + 2.0, "12 CPS", 0xFFFF_FFFF, true);
|
||||
}
|
||||
self.at.lock().unwrap().push(at);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_stack_with_the_extras_and_skip_hidden_corners() {
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let mut batch = HudBatch::new([800, 600]);
|
||||
let (width, height) = (batch.width, batch.height);
|
||||
let mut corners = Corners::new(width, height, 0.0);
|
||||
// The minimap already took the top right.
|
||||
corners.place(Corner::TopRight, 64.0, 64.0);
|
||||
let right = Panel {
|
||||
corner: hud::Corner::TopRight,
|
||||
at: Default::default(),
|
||||
};
|
||||
let left = Panel {
|
||||
corner: hud::Corner::TopLeft,
|
||||
at: Default::default(),
|
||||
};
|
||||
let layers: [&dyn hud::Layer; 2] = [&right, &left];
|
||||
let empty = batch.vertices.len();
|
||||
draw_layers(
|
||||
&layers,
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&mut corners,
|
||||
[false, true, true, true],
|
||||
);
|
||||
let (_, y) = right.at.lock().unwrap()[0].expect("the top right shows");
|
||||
assert_eq!(y, 2.0 + 64.0 + 2.0, "under the minimap");
|
||||
assert_eq!(
|
||||
*left.at.lock().unwrap(),
|
||||
[None],
|
||||
"the debug screen covers the top left"
|
||||
);
|
||||
assert!(batch.vertices.len() > empty, "the shown box is drawn");
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,6 @@ mod llamas;
|
||||
mod loading_screen;
|
||||
mod loading_window;
|
||||
mod locator_bar;
|
||||
mod look;
|
||||
mod loom;
|
||||
mod mace;
|
||||
mod machines;
|
||||
@@ -384,6 +383,7 @@ mod potions;
|
||||
mod powder_snow;
|
||||
mod prediction;
|
||||
mod pumpkin;
|
||||
mod pvp;
|
||||
mod quick_actions;
|
||||
mod raids;
|
||||
mod random_textures;
|
||||
@@ -467,6 +467,7 @@ mod trial_spawner;
|
||||
mod trial_visuals;
|
||||
mod trident;
|
||||
mod tutorial;
|
||||
mod tweaks;
|
||||
mod ui_control;
|
||||
mod use_hand;
|
||||
mod using_item;
|
||||
@@ -2017,8 +2018,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>)>,
|
||||
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
|
||||
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
|
||||
/// Custom sky layers (OptiFine's `optifine/sky`) with their textures; none without them.
|
||||
custom_sky: Arc<dyn horizon_client::custom_sky::SkyLayers>,
|
||||
/// 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 +2222,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: extras::mouse_tweaks::Tweaks,
|
||||
tweaks: 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,9 +2811,10 @@ 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 (mods' minimap and counters; PvP & HUD's): `game/extras.rs`.
|
||||
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
|
||||
extras: extras::ExtrasState,
|
||||
pvp: extras::pvp::PvpState,
|
||||
/// The PvP & HUD options' state (`game/pvp.rs`).
|
||||
pvp: 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.
|
||||
@@ -2877,9 +2879,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 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,
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
@@ -5530,7 +5532,7 @@ impl Game<'_> {
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
// `getCurrentServer().isRealm()`: the connection came from Realms.
|
||||
if self.last_server.realm {
|
||||
if self.realm_session {
|
||||
PresenceStatus::PlayingRealms
|
||||
} else {
|
||||
PresenceStatus::PlayingServer
|
||||
@@ -5873,7 +5875,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.glint_texel();
|
||||
batch.glint_colour = self.settings.extras.pvp.tweaks.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)
|
||||
@@ -5986,7 +5988,8 @@ 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 indicator = if self.attack_indicator_hidden() {
|
||||
let combat = self.settings.extras.pvp.combat;
|
||||
let indicator = if combat.legacy && !combat.indicator {
|
||||
0
|
||||
} else {
|
||||
self.settings.attack_indicator
|
||||
@@ -6050,7 +6053,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
|
||||
// The PvP & HUD effects widget takes the icons' place.
|
||||
if !g.effect_icons_hidden() {
|
||||
if !g.pvp_effects_shown() {
|
||||
g.draw_effect_icons(batch);
|
||||
}
|
||||
});
|
||||
@@ -6485,7 +6488,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_container_extras(batch, ox, oy);
|
||||
self.draw_sort_buttons(batch, ox, oy);
|
||||
self.draw_recipe_book(batch);
|
||||
let effect_tip = if self.container.is_none() && self.craft_size == 2 && !self.mount_active()
|
||||
{
|
||||
@@ -9895,7 +9898,7 @@ impl Game<'_> {
|
||||
outline,
|
||||
outline_after_terrain,
|
||||
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
|
||||
look: self.look_frame(),
|
||||
pvp: self.settings.extras.pvp.frame(),
|
||||
debug_lines: &debug_lines,
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
@@ -10706,7 +10709,7 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Inventory
|
||||
&& d != 0.0
|
||||
&& !bundle
|
||||
&& !self.container_scroll(d)
|
||||
&& !self.tweak_scroll(d, self.inventory_target().0)
|
||||
{
|
||||
self.station_scroll(d);
|
||||
}
|
||||
@@ -10954,7 +10957,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
if pressed
|
||||
&& (self.container_mouse_press(button)
|
||||
&& (self.sort_mouse_press(button)
|
||||
|| self.hud_editor_key_press(&horizon_client::keybinds::mouse_name(
|
||||
button,
|
||||
)))
|
||||
@@ -11496,7 +11499,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.extras_frame(elapsed);
|
||||
self.reconnect_when_due()?;
|
||||
self.extras_retry()?;
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
@@ -11607,7 +11610,7 @@ impl Game<'_> {
|
||||
self.settings.simulation_distance,
|
||||
self.settings.entity_distance,
|
||||
);
|
||||
server.set_legacy_rules(self.pvp_legacy_rules());
|
||||
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let mut consumed = false;
|
||||
@@ -12207,7 +12210,7 @@ impl<'a> Game<'a> {
|
||||
persist: Default::default(),
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
last_server: Default::default(),
|
||||
realm_session: false,
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
|
||||
@@ -33,6 +33,8 @@ 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
|
||||
}
|
||||
@@ -1618,6 +1620,19 @@ 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;
|
||||
|
||||
@@ -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.chat_look().search
|
||||
if self.settings.extras.pvp.chat.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.chat_look().copy
|
||||
if self.settings.extras.pvp.chat.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.chat_look();
|
||||
self.ui.chat.set_qol(&self.gui.atlas, self.chat_qol());
|
||||
let options = self.settings.extras.pvp.chat;
|
||||
self.ui.chat.set_qol(&self.gui.atlas, options.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.
|
||||
|
||||
@@ -227,16 +227,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
for m in self.mobs.iter() {
|
||||
if !m.alive() {
|
||||
// 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()) {
|
||||
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,6 +92,7 @@ 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);
|
||||
};
|
||||
|
||||
@@ -471,15 +471,46 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
self.cushions[i].check_interval = 0;
|
||||
let c = self.cushions[i].clone();
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
continue;
|
||||
if !self.check_cushion(i) {
|
||||
i += 1;
|
||||
}
|
||||
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.
|
||||
self.break_cushion(i, false);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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,
|
||||
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
|
||||
/// rather than one whose cushion was killed.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
use super::*;
|
||||
use horizon_optifine::sky::{Conditions, Now};
|
||||
use horizon_client::custom_sky::{Conditions, Weather};
|
||||
|
||||
/// 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 {
|
||||
Now::Thunder
|
||||
Weather::Thunder
|
||||
} else if rain > 0.2 {
|
||||
Now::Rain
|
||||
Weather::Rain
|
||||
} else {
|
||||
Now::Clear
|
||||
Weather::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.set_pvp_legacy_rules(load_combat());
|
||||
self.settings.extras.pvp.combat.rules = load_combat();
|
||||
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
|
||||
let far = props
|
||||
.raw
|
||||
|
||||
@@ -2,21 +2,15 @@
|
||||
//! (`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;
|
||||
@@ -30,6 +24,8 @@ 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);
|
||||
@@ -61,7 +57,6 @@ 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();
|
||||
@@ -83,6 +78,74 @@ 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)> {
|
||||
@@ -210,18 +273,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.draw_pvp_hud(batch, &mut corners);
|
||||
self.native_hud(batch, &mut corners);
|
||||
// The layers crates registered (`horizon_hooks::hud`): none, no canvas.
|
||||
let layers = horizon_hooks::hud::engine::all();
|
||||
if !layers.is_empty() {
|
||||
let shown = Corner::ALL.map(|corner| self.native_corner_shown(corner));
|
||||
extras_hud::draw_layers(layers, batch, &self.gui.atlas, &mut corners, shown);
|
||||
}
|
||||
}
|
||||
/// The height the status effect icons take at the top right
|
||||
/// (`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.effect_icons_hidden() {
|
||||
if effects.is_empty() || self.screen != Screen::Playing || self.pvp_effects_shown() {
|
||||
return 0.0;
|
||||
}
|
||||
let harmful = effects.iter().any(|e| {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//! 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.legacy_pose()
|
||||
let legacy_block = self.settings.extras.pvp.combat.legacy
|
||||
&& 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.shield_size();
|
||||
let shield_size = self.settings.extras.pvp.tweaks.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.legacy_pose()
|
||||
if self.settings.extras.pvp.combat.legacy
|
||||
&& !state.layers.is_empty()
|
||||
&& state
|
||||
.layers
|
||||
|
||||
@@ -568,7 +568,7 @@ impl Game<'_> {
|
||||
self.hands_busy(),
|
||||
self.hand_strength.swap_scale(1.0, speed),
|
||||
changed,
|
||||
self.legacy_pose(),
|
||||
self.settings.extras.pvp.combat.legacy,
|
||||
);
|
||||
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(self.pvp_legacy_rules(), Ordering::Relaxed);
|
||||
.store(settings.extras.pvp.combat.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.set_pvp_legacy_rules(flags.legacy_rules.load(Ordering::Relaxed));
|
||||
self.settings.extras.pvp.combat.rules = flags.legacy_rules.load(Ordering::Relaxed);
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
//! 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 {
|
||||
@@ -164,7 +161,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.tweak_press(slot, right, shift) || self.container_press(right) {
|
||||
if self.tweak_press(slot, right, shift) {
|
||||
return;
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
@@ -266,9 +263,7 @@ 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)
|
||||
|| self.container_drag()
|
||||
{
|
||||
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0) {
|
||||
return;
|
||||
}
|
||||
let Some((ty, slots)) = self.inv_drag.clone() else {
|
||||
@@ -431,7 +426,6 @@ 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(
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
//! 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.particle_hidden(options.ty) {
|
||||
if self.settings.extras.pvp.combat.hides_particle(options.ty) {
|
||||
return;
|
||||
}
|
||||
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
|
||||
|
||||
@@ -152,6 +152,14 @@ 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,
|
||||
@@ -1003,6 +1011,10 @@ 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).
|
||||
@@ -1899,4 +1911,15 @@ 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.pvp_legacy_rules()
|
||||
self.remote.is_none() && self.settings.extras.pvp.combat.rules
|
||||
}
|
||||
|
||||
/// `melee_damage` under legacy rules: (damage, attack strength, critical, enchanted).
|
||||
|
||||
@@ -98,9 +98,8 @@ impl Loading {
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
@@ -130,22 +129,25 @@ impl Loading {
|
||||
// redraw the same design when they start. GDI is unsafe:
|
||||
// horizon-threads holds it (this crate forbids unsafe code).
|
||||
#[cfg(target_os = "windows")]
|
||||
let boot_paint_stop = hwnd(&window).map(|hwnd| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
// In the fonts given to `menu_style`; with none, the window stays as it is.
|
||||
let boot_paint_stop = hwnd(&window)
|
||||
.zip(horizon_client::menu_style::get().fonts)
|
||||
.map(|(hwnd, fonts)| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
fonts.semibold,
|
||||
fonts.mono,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
|
||||
@@ -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,
|
||||
look: Default::default(),
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
self.particles.tick(&level);
|
||||
}
|
||||
|
||||
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
)
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let made = event_loop
|
||||
.create_window(attributes)
|
||||
.map_err(Into::into)
|
||||
|
||||
@@ -299,7 +299,7 @@ impl Game<'_> {
|
||||
/// address isn't, even on this computer or the local network: an address can be a proxy, a
|
||||
/// tunnel or a friend's relay as well.
|
||||
fn direct_craft_free_server(&self) -> bool {
|
||||
self.last_server.lan()
|
||||
self.server_is_lan()
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
|
||||
@@ -62,7 +62,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.container_key_press(key)
|
||||
if self.sort_key_press(key)
|
||||
|| horizon_client::keybinds::key_name(key)
|
||||
.is_some_and(|name| self.hud_editor_key_press(name))
|
||||
{
|
||||
|
||||
@@ -143,6 +143,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
|
||||
}
|
||||
@@ -198,6 +199,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
// The gas reads blocks only, so the mobs it reaches can be found
|
||||
// before any gets its effect.
|
||||
@@ -264,6 +266,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
can_be_reached_by_noxious_gas(&level, source, eye)
|
||||
};
|
||||
@@ -356,6 +359,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
|
||||
let area = launch_area(pos, height);
|
||||
@@ -419,6 +423,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
|
||||
};
|
||||
|
||||
+40
-27
@@ -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 crate::game::Game;
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use winit::event::MouseButton;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct PvpState {
|
||||
pub(super) 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(in crate::game) 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(in crate::game) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
|
||||
pub(super) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
|
||||
/// The HUD editor, while it is open.
|
||||
pub(in crate::game) editor: Option<crate::game::hud_editor::HudEditor>,
|
||||
pub(super) editor: Option<super::hud_editor::HudEditor>,
|
||||
/// The open chat's box holds a chat search's query (Ctrl+F).
|
||||
pub(in crate::game) chat_search: bool,
|
||||
pub(super) chat_search: bool,
|
||||
/// What the chat box held when the search borrowed it.
|
||||
pub(in crate::game) chat_search_aside: String,
|
||||
pub(super) 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(in crate::game) fn custom_crosshair(&self) -> bool {
|
||||
pub(super) 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(in crate::game) fn draw_custom_crosshair(&mut self, batch: &mut HudBatch, w: i32, h: i32) {
|
||||
pub(super) 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(in crate::game) fn outline_fill(
|
||||
pub(super) fn outline_fill(
|
||||
&self,
|
||||
outline: &[[f64; 6]],
|
||||
camera: glam::DVec3,
|
||||
@@ -176,33 +176,42 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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) {
|
||||
/// Once per frame: the options that live outside the game's state.
|
||||
pub(super) 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));
|
||||
}
|
||||
/// 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
|
||||
/// 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
|
||||
}
|
||||
/// 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;
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
/// 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(in crate::game) fn pvp_attacked(&mut self, id: i32, distance: f64) {
|
||||
pub(super) 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(in crate::game) fn pvp_click(&mut self, button: MouseButton) {
|
||||
pub(super) fn pvp_click(&mut self, button: MouseButton) {
|
||||
let hud = &self.settings.extras.pvp.hud;
|
||||
if !(hud.cps || hud.keys) || self.screen != crate::game::Screen::Playing {
|
||||
if !(hud.cps || hud.keys) || self.screen != super::Screen::Playing {
|
||||
return;
|
||||
}
|
||||
let i = match button {
|
||||
@@ -223,7 +232,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(in crate::game) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
|
||||
pub(super) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
|
||||
if own {
|
||||
self.pvp.combo.broken();
|
||||
} else {
|
||||
@@ -231,9 +240,13 @@ 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(in crate::game) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
pub(super) 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.
|
||||
@@ -254,8 +267,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.
|
||||
let server = self.last_server.address().filter(|_| multiplayer);
|
||||
// The address is copied only when it changed (`server_address` borrows the whole game).
|
||||
let server = self.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();
|
||||
@@ -678,7 +691,7 @@ impl Game<'_> {
|
||||
false => s.item == held.item,
|
||||
};
|
||||
let slots = self.inventory.iter().map(Option::as_ref).enumerate();
|
||||
let offhand = std::iter::once((crate::game::shield::OFFHAND_SLOT, self.offhand.as_ref()));
|
||||
let offhand = std::iter::once((super::shield::OFFHAND_SLOT, self.offhand.as_ref()));
|
||||
let mut found = slots
|
||||
.chain(offhand)
|
||||
.filter_map(|(i, s)| Some((i, s?)))
|
||||
@@ -60,6 +60,9 @@ use std::{
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
#[path = "remote_poll.rs"]
|
||||
mod poll;
|
||||
|
||||
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
|
||||
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
@@ -979,7 +982,7 @@ impl Game<'_> {
|
||||
lan: bool,
|
||||
realm: bool,
|
||||
) -> Result<()> {
|
||||
self.last_server.realm = realm;
|
||||
self.realm_session = realm;
|
||||
self.reset_tutorial();
|
||||
self.perf.world_opening();
|
||||
// A server's own biomes come over the connection; the last local
|
||||
@@ -1027,7 +1030,7 @@ impl Game<'_> {
|
||||
root.clone(),
|
||||
),
|
||||
None => {
|
||||
self.last_server.connecting(address, name, lan);
|
||||
self.extras_connecting(address, name, lan);
|
||||
match cookies {
|
||||
Some(cookies) => Session::connect_transferred(
|
||||
address,
|
||||
@@ -1522,7 +1525,7 @@ impl Game<'_> {
|
||||
self.finish_perf();
|
||||
if reason.is_none() {
|
||||
// Leaving on purpose: nothing for auto-reconnect to retry.
|
||||
self.last_server.left();
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.hud_disconnected();
|
||||
@@ -2174,288 +2177,6 @@ impl Game<'_> {
|
||||
pub(super) fn remote_backlog(&self) -> usize {
|
||||
self.remote.as_ref().map_or(0, |r| r.backlog.len())
|
||||
}
|
||||
/// A headless command oracle has a real connection but no movement tick.
|
||||
/// Acknowledge loaded terrain without predicting or sending movement.
|
||||
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
|
||||
self.poll_remote()?;
|
||||
let loaded = self.player.as_ref().is_some_and(|p| {
|
||||
self.streaming.is_lit(
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
)
|
||||
});
|
||||
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
|
||||
self.send(S::PlayerLoaded(Empty));
|
||||
let r = self.remote.as_mut().unwrap();
|
||||
r.loaded_sent = true;
|
||||
r.level_load.finish();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process what the network thread received (once per frame, like the
|
||||
/// client's packet processor), in order. Chunk packets get
|
||||
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
|
||||
/// the next chunk packet on wait for the next frame, unless they have
|
||||
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
|
||||
/// back what follows it.
|
||||
pub(super) fn poll_remote(&mut self) -> Result<()> {
|
||||
let polled = self.poll_remote_events();
|
||||
// The block events and block changes of a piston clock start and end
|
||||
// thousands of moves at once: the collision view and the renderer
|
||||
// take them all in one sync, before the tick or frame reads them.
|
||||
if std::mem::take(&mut self.moving_pistons_stale) {
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
polled
|
||||
}
|
||||
fn poll_remote_events(&mut self) -> Result<()> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut events = std::mem::take(&mut r.backlog);
|
||||
// Chunk packets start decoding now; the frame that reaches one in
|
||||
// order inserts its column.
|
||||
for event in r.session.poll() {
|
||||
events.push_back(match event {
|
||||
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
|
||||
match r.decoder.submit(r.chunk_format.clone(), p) {
|
||||
None => Received::Chunk(r.chunk_format.clone()),
|
||||
Some(p) => Received::Event(Event::Packet(p)),
|
||||
}
|
||||
}
|
||||
event => Received::Event(event),
|
||||
});
|
||||
}
|
||||
let overdue = r
|
||||
.backlog_since
|
||||
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
|
||||
let mut chunks = Duration::ZERO;
|
||||
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
|
||||
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
|
||||
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
|
||||
while let Some(item) = events.pop_front() {
|
||||
if let Received::Event(Event::Packet(packet)) = &item
|
||||
&& let C::ForgetLevelChunk(p) = &**packet
|
||||
{
|
||||
self.native_client_packet(packet);
|
||||
forgotten.insert((p.pos[0], p.pos[1]));
|
||||
continue;
|
||||
}
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
let chunk = matches!(item, Received::Chunk(_));
|
||||
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
|
||||
events.push_front(item);
|
||||
break;
|
||||
}
|
||||
let started = chunk.then(Instant::now);
|
||||
let event = match item {
|
||||
Received::Event(event) => event,
|
||||
Received::Chunk(format) => {
|
||||
// A column still decoding waits for the next frame, with
|
||||
// what came after it, overdue or not.
|
||||
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
|
||||
break;
|
||||
};
|
||||
let (packet, column) = match next {
|
||||
Next::Ready(decoded) => decoded,
|
||||
Next::Decoding => {
|
||||
events.push_front(Received::Chunk(format));
|
||||
break;
|
||||
}
|
||||
Next::Lost => {
|
||||
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.native_client_packet(&packet);
|
||||
if let C::LevelChunkWithLight(p) = *packet {
|
||||
self.remote_chunk(&p, Some((&format, column)));
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match event {
|
||||
Event::Status(s) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.status = s;
|
||||
}
|
||||
}
|
||||
Event::Joined {
|
||||
registries,
|
||||
name,
|
||||
uuid,
|
||||
} => {
|
||||
self.set_registries(registries);
|
||||
self.last_server.joined();
|
||||
// handleLogin: the level loading screen replaces any dialog.
|
||||
self.close_dialogs();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
// LevelLoadingScreen's line: no player name.
|
||||
r.status = horizon_client::lang::get_or(
|
||||
"multiplayer.downloadingTerrain",
|
||||
"Loading terrain...",
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Features(features) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.features = features;
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::QuietCommands => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.quiet_commands = true;
|
||||
}
|
||||
}
|
||||
Event::ModNet => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.mod_net = true;
|
||||
}
|
||||
}
|
||||
Event::FarTerrain(offer) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = Some(offer);
|
||||
}
|
||||
}
|
||||
Event::Reconfiguring => {
|
||||
// handleConfigurationStart: the held-back chat shows, then
|
||||
// clearClientLevel opens ServerReconfigScreen over no level.
|
||||
self.flush_chat_queue();
|
||||
self.send_chat_acknowledgement();
|
||||
self.close_dialogs();
|
||||
// The server configuring us now (a proxy's next one)
|
||||
// offers its own far terrain, or none, and announces its
|
||||
// own features again (a Horizon server's quiet commands).
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = None;
|
||||
remote.quiet_commands = false;
|
||||
remote.mod_net = false;
|
||||
}
|
||||
self.clear_lod();
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.reset_far_terrain();
|
||||
}
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(false);
|
||||
// clearClientLevel drops the client level now, so its
|
||||
// unload comes before the configuration screen, not
|
||||
// after the next login's load.
|
||||
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
|
||||
self.native_client_level_unload(&old);
|
||||
}
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.reconfiguring = Some(ticks);
|
||||
r.chat_tree_received = false;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
// handleConfigurationStart → clearClientLevel, and the next
|
||||
// ClientPacketListener starts with no players or scoreboard,
|
||||
// no waypoints (its own ClientWaypointManager: a proxy's next
|
||||
// server never untracks the last one's), and its new
|
||||
// ClientSuggestionProvider with no custom chat completions.
|
||||
// clearClientLevel clears the narrator.
|
||||
self.clear_narrator();
|
||||
self.hud_disconnected();
|
||||
self.score.board = Default::default();
|
||||
self.clear_waypoints();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.attachments.clear();
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
r.signatures = Default::default();
|
||||
r.custom_completions.clear();
|
||||
}
|
||||
self.ui.registries.custom_completions.clear();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.session.address != "local"
|
||||
{
|
||||
r.transfer = Some(format!("{host}:{port}"));
|
||||
}
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ServerLinks(links) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.server_links = super::server_links::trusted(links);
|
||||
}
|
||||
}
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
if r.decode_errors <= 20 {
|
||||
log::warn!("Horizon: skipped undecodable packet {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Disconnected {
|
||||
reason,
|
||||
key,
|
||||
from_server,
|
||||
} => {
|
||||
log::info!("Horizon: disconnected: {reason}");
|
||||
// ClientboundTransferPacket left a target: connect there
|
||||
// instead of showing the disconnect, the stored cookies
|
||||
// carried over (ClientCommonPacketListenerImpl.
|
||||
// handleTransfer → ConnectScreen.startConnecting).
|
||||
let transfer = self.remote.as_mut().and_then(|r| {
|
||||
r.transfer.take().map(|address| {
|
||||
(address, r.session.name.clone(), r.session.cookies.clone())
|
||||
})
|
||||
});
|
||||
if let Some((address, name, cookies)) = transfer {
|
||||
self.leave_server(None)?;
|
||||
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
|
||||
return Ok(());
|
||||
}
|
||||
let plan = self.last_server.ended(&reason, key.as_deref(), from_server);
|
||||
self.leave_server(Some(reason))?;
|
||||
horizon_client::last_server::offer(self.title.as_mut(), plan);
|
||||
return Ok(());
|
||||
}
|
||||
Event::Packet(packet) => self.handle_packet(*packet)?,
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
if self.remote.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.backlog_since = if events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.backlog_since.unwrap_or_else(Instant::now))
|
||||
};
|
||||
r.backlog = events;
|
||||
}
|
||||
self.send_suggestion_request();
|
||||
Ok(())
|
||||
}
|
||||
/// ClientSuggestionProvider.customSuggestion: the chat's completion
|
||||
/// request for an `ask_server` node goes to the server.
|
||||
fn send_suggestion_request(&mut self) {
|
||||
@@ -3873,7 +3594,7 @@ impl Game<'_> {
|
||||
C::Explode(p) => self.remote_explode(&p),
|
||||
C::Sound(p) => {
|
||||
if let Some(name) = self.remote_sound_name(&p.sound)
|
||||
&& !self.sound_muted(&name)
|
||||
&& !self.settings.extras.pvp.combat.mutes_sound(&name)
|
||||
{
|
||||
let at = [
|
||||
f64::from(p.x) / 8.0,
|
||||
@@ -3895,7 +3616,7 @@ impl Game<'_> {
|
||||
}
|
||||
C::SoundEntity(p) => {
|
||||
if let Some(name) = self.remote_sound_name(&p.sound)
|
||||
&& !self.sound_muted(&name)
|
||||
&& !self.settings.extras.pvp.combat.mutes_sound(&name)
|
||||
&& let Some(state) = self.remote_sound_entity_state(p.id)
|
||||
{
|
||||
let source = usize::try_from(p.source)
|
||||
@@ -6746,7 +6467,7 @@ impl Game<'_> {
|
||||
// (`KeyboardInput.tick`: 8 directions, full speed). The server only hears those keys
|
||||
// (`ServerboundPlayerInputPacket`), and an anticheat that predicts from them pulls back
|
||||
// a player moving at the stick's own angle and speed.
|
||||
if self.integrated.is_none() && self.last_server.address().is_some() {
|
||||
if self.integrated.is_none() && self.server_address().is_some() {
|
||||
keys.analog = None;
|
||||
}
|
||||
let [mut strafe, mut forward] = keys.direction();
|
||||
@@ -7083,24 +6804,24 @@ impl Game<'_> {
|
||||
let mut push = Vec3::ZERO;
|
||||
let order = |id: u32| r.ticked_before_player.contains(&id) == before_player;
|
||||
for m in self.mobs.iter().filter(|m| order(m.id)) {
|
||||
let listed = r
|
||||
.level_players
|
||||
.get(&(m.id as i32))
|
||||
.and_then(|uuid| r.tab_players.get(uuid));
|
||||
let spectator = listed.is_some_and(|t| t.game_mode == SPECTATOR);
|
||||
let mob_team = match listed {
|
||||
Some(t) => board.players_team(&t.name),
|
||||
None => teams.of(Who::Mob(m.id)),
|
||||
};
|
||||
// Bats never push; a spectator has no physics; passengers of the
|
||||
// same vehicle never push each other.
|
||||
// Bats never push, nor passengers of one vehicle, nor a spectator; the
|
||||
// box test comes before the lookups that far mobs never need (#14053).
|
||||
if !m.alive()
|
||||
|| spectator
|
||||
|| m.species() == horizon_client::mobs::Species::Bat
|
||||
|| self
|
||||
.riding
|
||||
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|
||||
|| !m.bounds().intersects(bounds)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let listed = r
|
||||
.level_players
|
||||
.get(&(m.id as i32))
|
||||
.and_then(|uuid| r.tab_players.get(uuid));
|
||||
let mob_team =
|
||||
listed.map_or_else(|| teams.of(Who::Mob(m.id)), |t| board.players_team(&t.name));
|
||||
if listed.is_some_and(|t| t.game_mode == SPECTATOR)
|
||||
|| !horizon_client::scoreboard::pushable_by(mob_team, player_team, true)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! The server connection's received events, once a frame (`poll_remote`): chunk packets
|
||||
//! within their per-frame budget, then every other packet in order (`handle_packet`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A headless command oracle has a real connection but no movement tick.
|
||||
/// Acknowledge loaded terrain without predicting or sending movement.
|
||||
pub(in crate::game) fn poll_stationary_client(&mut self) -> Result<()> {
|
||||
self.poll_remote()?;
|
||||
let loaded = self.player.as_ref().is_some_and(|p| {
|
||||
self.streaming.is_lit(
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
)
|
||||
});
|
||||
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
|
||||
self.send(S::PlayerLoaded(Empty));
|
||||
let r = self.remote.as_mut().unwrap();
|
||||
r.loaded_sent = true;
|
||||
r.level_load.finish();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process what the network thread received (once per frame, like the
|
||||
/// client's packet processor), in order. Chunk packets get
|
||||
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
|
||||
/// the next chunk packet on wait for the next frame, unless they have
|
||||
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
|
||||
/// back what follows it.
|
||||
pub(in crate::game) fn poll_remote(&mut self) -> Result<()> {
|
||||
let polled = self.poll_remote_events();
|
||||
// The block events and block changes of a piston clock start and end
|
||||
// thousands of moves at once: the collision view and the renderer
|
||||
// take them all in one sync, before the tick or frame reads them.
|
||||
if std::mem::take(&mut self.moving_pistons_stale) {
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
polled
|
||||
}
|
||||
fn poll_remote_events(&mut self) -> Result<()> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut events = std::mem::take(&mut r.backlog);
|
||||
// Chunk packets start decoding now; the frame that reaches one in
|
||||
// order inserts its column.
|
||||
for event in r.session.poll() {
|
||||
events.push_back(match event {
|
||||
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
|
||||
match r.decoder.submit(r.chunk_format.clone(), p) {
|
||||
None => Received::Chunk(r.chunk_format.clone()),
|
||||
Some(p) => Received::Event(Event::Packet(p)),
|
||||
}
|
||||
}
|
||||
event => Received::Event(event),
|
||||
});
|
||||
}
|
||||
let overdue = r
|
||||
.backlog_since
|
||||
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
|
||||
let mut chunks = Duration::ZERO;
|
||||
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
|
||||
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
|
||||
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
|
||||
while let Some(item) = events.pop_front() {
|
||||
if let Received::Event(Event::Packet(packet)) = &item
|
||||
&& let C::ForgetLevelChunk(p) = &**packet
|
||||
{
|
||||
self.native_client_packet(packet);
|
||||
forgotten.insert((p.pos[0], p.pos[1]));
|
||||
continue;
|
||||
}
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
let chunk = matches!(item, Received::Chunk(_));
|
||||
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
|
||||
events.push_front(item);
|
||||
break;
|
||||
}
|
||||
let started = chunk.then(Instant::now);
|
||||
let event = match item {
|
||||
Received::Event(event) => event,
|
||||
Received::Chunk(format) => {
|
||||
// A column still decoding waits for the next frame, with
|
||||
// what came after it, overdue or not.
|
||||
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
|
||||
break;
|
||||
};
|
||||
let (packet, column) = match next {
|
||||
Next::Ready(decoded) => decoded,
|
||||
Next::Decoding => {
|
||||
events.push_front(Received::Chunk(format));
|
||||
break;
|
||||
}
|
||||
Next::Lost => {
|
||||
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.native_client_packet(&packet);
|
||||
if let C::LevelChunkWithLight(p) = *packet {
|
||||
self.remote_chunk(&p, Some((&format, column)));
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match event {
|
||||
Event::Status(s) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.status = s;
|
||||
}
|
||||
}
|
||||
Event::Joined {
|
||||
registries,
|
||||
name,
|
||||
uuid,
|
||||
} => {
|
||||
self.set_registries(registries);
|
||||
self.extras_joined();
|
||||
// handleLogin: the level loading screen replaces any dialog.
|
||||
self.close_dialogs();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
// LevelLoadingScreen's line: no player name.
|
||||
r.status = horizon_client::lang::get_or(
|
||||
"multiplayer.downloadingTerrain",
|
||||
"Loading terrain...",
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Features(features) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.features = features;
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::QuietCommands => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.quiet_commands = true;
|
||||
}
|
||||
}
|
||||
Event::ModNet => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.mod_net = true;
|
||||
}
|
||||
}
|
||||
Event::FarTerrain(offer) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = Some(offer);
|
||||
}
|
||||
}
|
||||
Event::Reconfiguring => {
|
||||
// handleConfigurationStart: the held-back chat shows, then
|
||||
// clearClientLevel opens ServerReconfigScreen over no level.
|
||||
self.flush_chat_queue();
|
||||
self.send_chat_acknowledgement();
|
||||
self.close_dialogs();
|
||||
// The server configuring us now (a proxy's next one)
|
||||
// offers its own far terrain, or none, and announces its
|
||||
// own features again (a Horizon server's quiet commands).
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = None;
|
||||
remote.quiet_commands = false;
|
||||
remote.mod_net = false;
|
||||
}
|
||||
self.clear_lod();
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.reset_far_terrain();
|
||||
}
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(false);
|
||||
// clearClientLevel drops the client level now, so its
|
||||
// unload comes before the configuration screen, not
|
||||
// after the next login's load.
|
||||
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
|
||||
self.native_client_level_unload(&old);
|
||||
}
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.reconfiguring = Some(ticks);
|
||||
r.chat_tree_received = false;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
// handleConfigurationStart → clearClientLevel, and the next
|
||||
// ClientPacketListener starts with no players or scoreboard,
|
||||
// no waypoints (its own ClientWaypointManager: a proxy's next
|
||||
// server never untracks the last one's), and its new
|
||||
// ClientSuggestionProvider with no custom chat completions.
|
||||
// clearClientLevel clears the narrator.
|
||||
self.clear_narrator();
|
||||
self.hud_disconnected();
|
||||
self.score.board = Default::default();
|
||||
self.clear_waypoints();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.attachments.clear();
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
r.signatures = Default::default();
|
||||
r.custom_completions.clear();
|
||||
}
|
||||
self.ui.registries.custom_completions.clear();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.session.address != "local"
|
||||
{
|
||||
r.transfer = Some(format!("{host}:{port}"));
|
||||
}
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ServerLinks(links) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.server_links = super::super::server_links::trusted(links);
|
||||
}
|
||||
}
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
if r.decode_errors <= 20 {
|
||||
log::warn!("Horizon: skipped undecodable packet {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Disconnected {
|
||||
reason,
|
||||
key,
|
||||
from_server,
|
||||
} => {
|
||||
log::info!("Horizon: disconnected: {reason}");
|
||||
// ClientboundTransferPacket left a target: connect there
|
||||
// instead of showing the disconnect, the stored cookies
|
||||
// carried over (ClientCommonPacketListenerImpl.
|
||||
// handleTransfer → ConnectScreen.startConnecting).
|
||||
let transfer = self.remote.as_mut().and_then(|r| {
|
||||
r.transfer.take().map(|address| {
|
||||
(address, r.session.name.clone(), r.session.cookies.clone())
|
||||
})
|
||||
});
|
||||
if let Some((address, name, cookies)) = transfer {
|
||||
self.leave_server(None)?;
|
||||
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
|
||||
return Ok(());
|
||||
}
|
||||
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
|
||||
self.leave_server(Some(reason))?;
|
||||
self.offer_reconnect(retry);
|
||||
return Ok(());
|
||||
}
|
||||
Event::Packet(packet) => self.handle_packet(*packet)?,
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
if self.remote.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.backlog_since = if events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.backlog_since.unwrap_or_else(Instant::now))
|
||||
};
|
||||
r.backlog = events;
|
||||
}
|
||||
self.send_suggestion_request();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -164,8 +164,8 @@ impl Game<'_> {
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
spectator: self.spectator(),
|
||||
fire_resistance: self.potions.effects.get("fire_resistance").is_some()
|
||||
|| self.clear_fluids().0,
|
||||
full_water_vision: self.clear_fluids().1,
|
||||
|| self.settings.extras.pvp.tweaks.clear_lava,
|
||||
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
|
||||
night_vision: self.fog_night_vision(),
|
||||
environment: self.pack.polytone_colors.env,
|
||||
};
|
||||
@@ -405,7 +405,7 @@ impl Game<'_> {
|
||||
on_fire: self.camera_type.first_person()
|
||||
&& !self.spectator()
|
||||
&& self.player.as_ref().is_some_and(|p| p.fire_ticks > 0),
|
||||
fire_lower: self.fire_lower(),
|
||||
fire_lower: f32::from(self.settings.extras.pvp.tweaks.fire_lower) / 100.0,
|
||||
gui_overlays: &gui_overlays,
|
||||
})
|
||||
}
|
||||
@@ -679,6 +679,7 @@ impl Game<'_> {
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| ([p.position.x, p.position.y, p.position.z], p.velocity.y)),
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
laps.lap("tp_level");
|
||||
// Resource packs' Polytone entity emitters on the player (#11486): fog, dust and
|
||||
|
||||
@@ -190,7 +190,7 @@ impl PackResources {
|
||||
let clouds = named_scope(scope, "pack.clouds", || load_clouds(root));
|
||||
let forcefield = named_scope(scope, "pack.forcefield", || load_forcefield(root));
|
||||
let custom_sky = named_scope(scope, "pack.custom_sky", || {
|
||||
horizon_client::custom_sky::CustomSky::load(root)
|
||||
horizon_client::custom_sky::load(root)
|
||||
});
|
||||
let environments =
|
||||
named_scope(scope, "pack.environments", || pack_environments(root));
|
||||
@@ -246,7 +246,7 @@ impl PackResources {
|
||||
names: Arc::new(names),
|
||||
clouds: Arc::new(clouds),
|
||||
forcefield: Arc::new(forcefield),
|
||||
custom_sky: Arc::new(custom_sky),
|
||||
custom_sky,
|
||||
environments,
|
||||
polytone_particles,
|
||||
polytone_post,
|
||||
|
||||
@@ -24,6 +24,8 @@ pub(super) struct Social {
|
||||
names: HashMap<u128, String>,
|
||||
page: Page,
|
||||
pub(super) search: EditBox,
|
||||
/// `PlayerEntry.TOOLTIP_DELAY`: the row buttons' tooltips wait 500 ms.
|
||||
tooltip: social_screen::TooltipDelay,
|
||||
pub(super) scroll: f32,
|
||||
/// Opened from the pause menu: Done goes back there.
|
||||
from_pause: bool,
|
||||
@@ -38,6 +40,7 @@ impl Default for Social {
|
||||
names: HashMap::new(),
|
||||
page: Page::All,
|
||||
search,
|
||||
tooltip: Default::default(),
|
||||
scroll: 0.0,
|
||||
from_pause: false,
|
||||
}
|
||||
@@ -77,6 +80,7 @@ impl Game<'_> {
|
||||
self.hide_social_tutorial();
|
||||
self.social.page = Page::All;
|
||||
self.social.search.set_value("");
|
||||
self.social.tooltip = Default::default();
|
||||
self.social.scroll = 0.0;
|
||||
self.social.from_pause = from_pause;
|
||||
self.screen = Screen::Social;
|
||||
@@ -150,7 +154,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The server's name: a local world's name, a server's entry in the list.
|
||||
fn social_server_name(&self) -> String {
|
||||
match (&self.integrated, self.last_server.address()) {
|
||||
match (&self.integrated, self.server_address()) {
|
||||
(Some(server), _) => server.world_name.clone(),
|
||||
(None, Some(address)) if self.remote.is_some() => address.to_owned(),
|
||||
_ => self.level.name.clone(),
|
||||
@@ -168,6 +172,7 @@ impl Game<'_> {
|
||||
self.cursor,
|
||||
&view,
|
||||
&mut self.social.search,
|
||||
&mut self.social.tooltip,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,8 +261,6 @@ impl<'a> Split<'a> {
|
||||
.get(i.wrapping_sub(1))
|
||||
.map(|g| g.options.clone()),
|
||||
);
|
||||
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
self.turn = i;
|
||||
}
|
||||
|
||||
@@ -515,7 +513,7 @@ impl<'a> Split<'a> {
|
||||
if self.host.integrated.is_some() || self.host.remote.is_none() {
|
||||
return None;
|
||||
}
|
||||
self.host.last_server.address().map(str::to_owned)
|
||||
self.host.server_address().map(str::to_owned)
|
||||
}
|
||||
|
||||
/// The pads' players who accepted, and the testing players (`HORIZON_SPLIT`, one a frame),
|
||||
|
||||
@@ -126,7 +126,6 @@ impl Split<'_> {
|
||||
// Until it has a profile, a scratch options file: never P1's.
|
||||
let options = Self::folder()?.join(".joining/options.txt");
|
||||
OPTIONS.set(Some(options.clone()));
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
let mut settings = self.host.settings.clone();
|
||||
if pad.is_some() {
|
||||
// The Controller setting is P1's own: a player who pressed a pad to join plays
|
||||
@@ -136,7 +135,6 @@ impl Split<'_> {
|
||||
}
|
||||
let game = self.start_guest(seat, &options, settings);
|
||||
OPTIONS.set(None);
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
let mut game = game?;
|
||||
if let Some(pad) = pad {
|
||||
// The press that joined it doesn't act in its game.
|
||||
@@ -301,31 +299,16 @@ impl Split<'_> {
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
let options = profile.join("options.txt");
|
||||
let made = if options.exists() {
|
||||
None
|
||||
let mut settings = if options.exists() {
|
||||
horizon_client::settings::Settings::load(&options)
|
||||
} else {
|
||||
std::fs::create_dir_all(&profile)?;
|
||||
let mut new = self.host.settings.clone();
|
||||
// The Controller setting is P1's, as on the profile page (`join`).
|
||||
new.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// On P1's seat, so the crates' settings stores write P1's values too.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
new.save(&options);
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
Some(new)
|
||||
new
|
||||
};
|
||||
// The crates' stores load into the guest's seat, before its turn starts below.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
|
||||
let mut settings = match made {
|
||||
Some(new) => {
|
||||
horizon_client::settings::Settings::load_stores(&options);
|
||||
new
|
||||
}
|
||||
None => horizon_client::settings::Settings::load(&options),
|
||||
};
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
|
||||
settings.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
|
||||
|
||||
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
|
||||
continue;
|
||||
}
|
||||
// Opened without the focus: whoever types in the game's window keeps the keyboard.
|
||||
let attributes =
|
||||
horizon_client::window::with_horizon_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
let attributes = horizon_client::window::with_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
|
||||
// Space of its own that hides the other windows as it slides in.
|
||||
let placement = self.placement(*key).cloned();
|
||||
|
||||
@@ -206,7 +206,7 @@ impl Game<'_> {
|
||||
to_world.transform_vector3(b).normalize(),
|
||||
);
|
||||
// PvP & HUD's totem animation size (100% is vanilla's), about the item's centre.
|
||||
let size = self.totem_scale();
|
||||
let size = f32::from(self.settings.extras.pvp.tweaks.totem_size) / 100.0;
|
||||
let pose = pose * glam::Mat4::from_scale(glam::Vec3::splat(size));
|
||||
self.submit_item_lit(&state, to_world * pose, vertex_light(0xFF), lights, out);
|
||||
}
|
||||
|
||||
+166
-19
@@ -17,12 +17,26 @@
|
||||
//! 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, 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};
|
||||
//! 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;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct Tweaks {
|
||||
pub(super) struct Tweaks {
|
||||
drag: Option<Drag>,
|
||||
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
|
||||
wheel: f32,
|
||||
@@ -30,7 +44,7 @@ pub(in crate::game) 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(in crate::game) fn tweak_menu(name: &str) -> bool {
|
||||
pub(super) fn tweak_menu(name: &str) -> bool {
|
||||
name == "inventory"
|
||||
|| name.starts_with("minecraft:generic_9x")
|
||||
|| matches!(
|
||||
@@ -58,7 +72,7 @@ struct Drag {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The open screen is one the tweaks run in, with the extra on.
|
||||
pub(in crate::game) fn tweaks_on(&self) -> bool {
|
||||
pub(super) fn tweaks_on(&self) -> bool {
|
||||
if !self.settings.extras.mouse_tweaks
|
||||
|| self.screen != Screen::Inventory
|
||||
|| self.station.is_some()
|
||||
@@ -73,7 +87,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// One ordinary click on a game slot. False when nothing took it.
|
||||
pub(in crate::game) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
|
||||
pub(super) 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)]);
|
||||
}
|
||||
@@ -89,7 +103,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`.
|
||||
pub(in crate::game) fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
|
||||
fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
|
||||
let kind = i32::from(right) << 2;
|
||||
let ops = [
|
||||
(None, kind, Input::QuickCraft),
|
||||
@@ -109,12 +123,7 @@ 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(in crate::game) fn tweak_press(
|
||||
&mut self,
|
||||
slot: Option<usize>,
|
||||
right: bool,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
pub(super) 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;
|
||||
@@ -165,7 +174,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(in crate::game) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
|
||||
pub(super) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
|
||||
let Some(mut drag) = self.tweaks.drag.take() else {
|
||||
return false;
|
||||
};
|
||||
@@ -213,11 +222,11 @@ impl Game<'_> {
|
||||
owned
|
||||
}
|
||||
/// The button went up: the drag ends.
|
||||
pub(in crate::game) fn tweak_release(&mut self) {
|
||||
pub(super) fn tweak_release(&mut self) {
|
||||
self.tweaks.drag = None;
|
||||
}
|
||||
/// The wheel over an inventory screen: true when the tweaks used it.
|
||||
pub(in crate::game) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
|
||||
pub(super) 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;
|
||||
}
|
||||
@@ -247,7 +256,7 @@ impl Game<'_> {
|
||||
result
|
||||
}
|
||||
/// `slot` takes one more of `stack`, by the open menu's rules.
|
||||
pub(in crate::game) fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
|
||||
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));
|
||||
@@ -257,7 +266,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).
|
||||
pub(in crate::game) fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
|
||||
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| {
|
||||
@@ -347,6 +356,144 @@ 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;
|
||||
@@ -273,9 +273,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
//! Opening and closing a local world from the client: the title screen's actions, entering a
|
||||
//! world on the server thread, and saving and leaving it on exit. Also connecting again to a
|
||||
//! server when an attempt a hook planned is due (`last_server.rs`).
|
||||
//! world on the server thread, and saving and leaving it on exit.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Connects again once a planned attempt's 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 reconnect_when_due(&mut self) -> 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.last_server.realm,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `onAbort`: the confirm screen's Back or Escape reopens the Data
|
||||
/// Packs screen with the selection it had.
|
||||
pub(super) fn reopen_data_packs(&mut self, event_loop: &dyn EventLoopCtl) -> Result<()> {
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{Game, commands::Target};
|
||||
use horizon_client::{
|
||||
advancements as adv,
|
||||
gossip::GossipType,
|
||||
items::ItemStack,
|
||||
lan::playerdata,
|
||||
mobs::{Mob, Species, villager, zombie_villager},
|
||||
};
|
||||
@@ -143,7 +144,10 @@ impl Game<'_> {
|
||||
let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) else {
|
||||
return;
|
||||
};
|
||||
convert_to_villager(&old, v);
|
||||
let drops = convert_to_villager(&old, v);
|
||||
for stack in drops {
|
||||
self.spawn_at_location(old.position, 0.0, stack);
|
||||
}
|
||||
// `level.getPlayerByUUID(conversionStarter)`: the player who fed the
|
||||
// apple, if still in this level (the host or a server's guest).
|
||||
let starter = zombie_villager::cure(&old).and_then(|c| c.starter);
|
||||
@@ -165,6 +169,10 @@ impl Game<'_> {
|
||||
g.trigger_cured_zombie_villager(&zombie_view, &villager_view)
|
||||
});
|
||||
}
|
||||
// The nausea comes after the advancement criterion (finishConversion).
|
||||
if let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) {
|
||||
v.add_effect("minecraft:nausea", 200, 0);
|
||||
}
|
||||
if !old.silent {
|
||||
self.level_event_at(
|
||||
1027,
|
||||
@@ -278,11 +286,38 @@ pub(super) fn convert_to_zombie_villager(from: &Mob, to: &mut Mob) {
|
||||
carry_over(from, to);
|
||||
to.extra.age = if from.is_baby() { i32::MIN / 2 } else { 0 };
|
||||
}
|
||||
/// A cured zombie villager becomes a villager again, queasy for 10 seconds.
|
||||
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) {
|
||||
/// A cured zombie villager becomes a villager again (the caller adds the
|
||||
/// 10 second nausea after the cure criterion).
|
||||
/// `convertTo(.., keepEquipment = false)` leaves the villager bare, then
|
||||
/// `ZombieVillager.finishConversion` runs `Mob.dropPreservedEquipment` with
|
||||
/// "no `PREVENT_ARMOR_CHANGE`": kept (cursed) stacks go to the villager's
|
||||
/// slot `EquipmentSlot.getIndex() + 300` (its inventory), picked-up gear
|
||||
/// (drop chance > 1) is returned for the caller to drop, the rest vanishes.
|
||||
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) -> Vec<ItemStack> {
|
||||
use horizon_client::mobs::{drops, finalize};
|
||||
carry_over(from, to);
|
||||
let mut drops_out = Vec::new();
|
||||
// `EquipmentSlot.VALUES` order: main hand, off hand, feet, legs, chest, head.
|
||||
for (slot, name) in drops::SLOTS.iter().take(6).enumerate() {
|
||||
let Some(stack) = finalize::item_stack(from, slot) else {
|
||||
continue;
|
||||
};
|
||||
drops::clear_slot(to, name);
|
||||
if stack.enchantment_level("minecraft:binding_curse") > 0 {
|
||||
// `getIndex() + 300`: hands 0..1, then feet, legs, chest, head 0..3.
|
||||
let index = if slot < 2 { slot } else { slot - 2 };
|
||||
let inventory = &mut villager::ext_mut(to).inventory;
|
||||
if index < inventory.len() {
|
||||
inventory[index] = stack;
|
||||
} else {
|
||||
inventory.push(stack);
|
||||
}
|
||||
} else if drops::drop_chance(from, name) > 1.0 {
|
||||
drops_out.push(stack);
|
||||
}
|
||||
}
|
||||
to.extra.age = if from.is_baby() { -24000 } else { 0 };
|
||||
to.add_effect("minecraft:nausea", 200, 0);
|
||||
drops_out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -384,11 +419,34 @@ mod tests {
|
||||
assert!(zombie.invulnerable_flag);
|
||||
assert!(zombie.extra.left_handed);
|
||||
let mut cured = mob(3, Species::Villager, "minecraft:villager");
|
||||
convert_to_villager(&zombie, &mut cured);
|
||||
let drops = convert_to_villager(&zombie, &mut cured);
|
||||
assert!(drops.is_empty());
|
||||
assert!(cured.silent);
|
||||
assert!(cured.no_ai);
|
||||
assert!(cured.no_gravity);
|
||||
assert!(cured.invulnerable_flag);
|
||||
assert!(cured.extra.left_handed);
|
||||
// The cure keeps no equipment: picked-up gear drops, cursed gear goes
|
||||
// to the inventory, the rest vanishes.
|
||||
use horizon_client::mobs::finalize::{
|
||||
FEET, HEAD, MAINHAND, item_stack, set_drop_chance, set_item, set_item_stack,
|
||||
};
|
||||
let mut gear = mob(4, Species::ZombieVillager, "minecraft:zombie_villager");
|
||||
set_item(&mut gear, MAINHAND, "minecraft:iron_sword");
|
||||
set_drop_chance(&mut gear, MAINHAND, 2.0);
|
||||
set_item(&mut gear, HEAD, "minecraft:iron_helmet");
|
||||
let mut boots = ItemStack::new("minecraft:iron_boots", 1);
|
||||
boots.set_crafting_enchantments(vec![("minecraft:binding_curse".into(), 1)]);
|
||||
set_item_stack(&mut gear, FEET, boots);
|
||||
let mut bare = mob(5, Species::Villager, "minecraft:villager");
|
||||
let drops = convert_to_villager(&gear, &mut bare);
|
||||
assert_eq!(drops.len(), 1);
|
||||
assert_eq!(drops[0].item.as_ref(), "minecraft:iron_sword");
|
||||
for slot in 0..=5 {
|
||||
assert!(item_stack(&bare, slot).is_none(), "slot {slot}");
|
||||
}
|
||||
let kept = &villager::ext(&bare).unwrap().inventory;
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].item.as_ref(), "minecraft:iron_boots");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
|
||||
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
|
||||
//! (`docs/design-system.md`).
|
||||
//! Its own module so it can leave the engine crate as a pure file move, for the official
|
||||
//! executable to call.
|
||||
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
|
||||
use horizon_theme as theme;
|
||||
|
||||
/// Give the engine Horizon's look; call it once, before the first window opens.
|
||||
pub(crate) fn give() {
|
||||
horizon_client::window::set_icon_png(theme::ICON_PNG);
|
||||
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
|
||||
horizon_client::menu_style::install(MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: blocks.page,
|
||||
ink: blocks.ink,
|
||||
ink_soft: blocks.ink_soft,
|
||||
muted: blocks.muted,
|
||||
outline: blocks.outline,
|
||||
accent: blocks.accent,
|
||||
face: blocks.face,
|
||||
// The face's hover, lighter than `face`: the settings screen's own shade.
|
||||
face_hover: 0x6a50e6,
|
||||
success: blocks.success,
|
||||
warn: blocks.warn,
|
||||
danger: blocks.danger,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: dark.page,
|
||||
backdrop_muted: theme::BACKDROP_MUTED,
|
||||
ink: dark.ink,
|
||||
ink_soft: dark.ink_soft,
|
||||
rule: dark.rule,
|
||||
accent: dark.accent,
|
||||
on_accent: dark.on_accent,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: theme::CAPTION.px,
|
||||
label: theme::LABEL.px,
|
||||
ui: theme::UI.px,
|
||||
body: theme::BODY.px,
|
||||
title: theme::TITLE.px,
|
||||
display: theme::DISPLAY.px,
|
||||
micro: theme::MICRO.px,
|
||||
radius: theme::RADIUS,
|
||||
radius_plate: theme::RADIUS_PLATE,
|
||||
control_large: theme::CONTROL_LARGE,
|
||||
},
|
||||
fonts: Some(Fonts {
|
||||
regular: theme::fonts::GEIST_REGULAR,
|
||||
medium: theme::fonts::GEIST_MEDIUM,
|
||||
semibold: theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: theme::fonts::GEIST_MONO,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! The multiplayer server this window connected to last, which splitscreen players join and the
|
||||
//! friends service's presence names, and the connection's lifecycle as the hooks of the layers
|
||||
//! above hear it (`horizon_hooks`: connecting, joined, left, ended). A hook may plan another
|
||||
//! attempt when a connection ends; the Disconnected screen shows its countdown ([`offer`]).
|
||||
use crate::{multiplayer, net, title};
|
||||
use horizon_hooks::{Ended, Reconnect, Server, engine};
|
||||
|
||||
/// What the window knows of its last server. Forgotten when the player leaves it, or when a
|
||||
/// connection ends with no attempt planned.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LastServer {
|
||||
address: Option<String>,
|
||||
lan: bool,
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends service's
|
||||
/// presence status.
|
||||
pub realm: bool,
|
||||
}
|
||||
|
||||
impl LastServer {
|
||||
/// A connection to a multiplayer server starts.
|
||||
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
|
||||
self.address = Some(address.to_owned());
|
||||
self.lan = lan;
|
||||
engine::connecting(&Server::new(address, name, lan));
|
||||
}
|
||||
/// The server let the player in.
|
||||
pub fn joined(&self) {
|
||||
engine::joined();
|
||||
}
|
||||
/// The player left on purpose.
|
||||
pub fn left(&mut self) {
|
||||
self.forget();
|
||||
engine::left();
|
||||
}
|
||||
/// The connection ended: the attempt a hook plans, if any.
|
||||
pub fn ended(
|
||||
&mut self,
|
||||
reason: &str,
|
||||
key: Option<&str>,
|
||||
from_server: bool,
|
||||
) -> Option<Reconnect> {
|
||||
let plan = engine::ended(&Ended::new(reason, key, from_server));
|
||||
if plan.is_none() {
|
||||
self.forget();
|
||||
}
|
||||
plan
|
||||
}
|
||||
fn forget(&mut self) {
|
||||
self.address = None;
|
||||
self.lan = false;
|
||||
}
|
||||
/// The server's address, while the window keeps it.
|
||||
pub fn address(&self) -> Option<&str> {
|
||||
self.address.as_deref()
|
||||
}
|
||||
/// That server was found on the local network.
|
||||
pub fn lan(&self) -> bool {
|
||||
self.lan
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a planned attempt's countdown on the Disconnected screen.
|
||||
pub fn offer(title: Option<&mut title::Title>, plan: Option<Reconnect>) {
|
||||
let Some(plan) = plan else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = title
|
||||
&& let title::Mode::Multiplayer(multiplayer::Screen::Disconnected { retry, .. }) =
|
||||
&mut title.mode
|
||||
{
|
||||
log::info!(
|
||||
"Horizon: reconnecting to {} in {} s",
|
||||
net::shown_address(&plan.address),
|
||||
plan.seconds_left(std::time::Instant::now())
|
||||
);
|
||||
*retry = Some(plan);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_server_is_forgotten_when_left_or_lost_without_a_plan() {
|
||||
let mut last = LastServer::default();
|
||||
last.connecting("example.org", "Player", true);
|
||||
assert_eq!((last.address(), last.lan()), (Some("example.org"), true));
|
||||
// No hook plans an attempt in this test process.
|
||||
assert_eq!(last.ended("Timed out", None, false), None);
|
||||
assert_eq!((last.address(), last.lan()), (None, false));
|
||||
last.connecting("example.org:25566", "Player", false);
|
||||
last.left();
|
||||
assert_eq!(last.address(), None);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ extern crate self as horizon_client;
|
||||
pub use horizon_client_lib::*;
|
||||
pub mod entry;
|
||||
pub mod game;
|
||||
mod horizon_look;
|
||||
pub mod mod_api;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -140,8 +140,8 @@ pub enum Screen {
|
||||
},
|
||||
Disconnected {
|
||||
reason: String,
|
||||
/// The next attempt a hook planned (`last_server`), until it is cancelled.
|
||||
retry: Option<horizon_hooks::Reconnect>,
|
||||
/// Auto-reconnect's next attempt, until it is cancelled.
|
||||
retry: Option<crate::reconnect::Retry>,
|
||||
/// Wrapped reason lines, set by the last draw; the buttons sit below them.
|
||||
lines: std::cell::Cell<usize>,
|
||||
},
|
||||
@@ -1970,7 +1970,7 @@ impl Screen {
|
||||
}
|
||||
}
|
||||
/// Auto-reconnect's attempt, once its countdown is over.
|
||||
pub fn due_retry(&mut self) -> Option<horizon_hooks::Reconnect> {
|
||||
pub fn due_retry(&mut self) -> Option<crate::reconnect::Retry> {
|
||||
let Self::Disconnected { retry, .. } = self else {
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -46,11 +46,11 @@ impl NoResources {
|
||||
let x = ((ui.w - width) / 2.0).round();
|
||||
let lines: Vec<Vec<String>> = PARAGRAPHS
|
||||
.iter()
|
||||
.map(|p| wrap(ui, Face::Regular, BODY, p, width))
|
||||
.map(|p| wrap(ui, Face::Regular, body(), p, width))
|
||||
.collect();
|
||||
let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::<f32>()
|
||||
+ (lines.len() - 1) as f32 * PARAGRAPH_GAP;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + control() + 20.0 + SMALL_LINE;
|
||||
let y = ((ui.h - height) / 2.0).max(24.0).round();
|
||||
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
|
||||
let mut buttons = Vec::new();
|
||||
@@ -59,7 +59,7 @@ impl NoResources {
|
||||
("Download the launcher", Action::Download),
|
||||
("Quit", Action::Quit),
|
||||
] {
|
||||
let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round();
|
||||
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
|
||||
buttons.push(Button {
|
||||
label,
|
||||
action,
|
||||
@@ -80,21 +80,29 @@ impl NoResources {
|
||||
|
||||
pub fn draw(&self, ui: &mut Ui) {
|
||||
let layout = self.layout(ui);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::bg());
|
||||
let mut y = layout.y;
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
TITLE_SIZE,
|
||||
title_size(),
|
||||
TITLE_LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::TEXT,
|
||||
color::text(),
|
||||
TITLE,
|
||||
);
|
||||
y += TITLE_LINE + 16.0;
|
||||
for paragraph in &layout.lines {
|
||||
for line in paragraph {
|
||||
ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line);
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
body(),
|
||||
LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::text(),
|
||||
line,
|
||||
);
|
||||
y += LINE;
|
||||
}
|
||||
y += PARAGRAPH_GAP;
|
||||
@@ -102,20 +110,20 @@ impl NoResources {
|
||||
for button in &layout.buttons {
|
||||
let hovered = button.contains(ui.cursor);
|
||||
let (plate, ink) = match (button.action, hovered) {
|
||||
(Action::Download, false) => (color::ACCENT, color::ON_ACCENT),
|
||||
(Action::Download, false) => (color::accent(), color::on_accent()),
|
||||
(Action::Download, true) => {
|
||||
(color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT)
|
||||
(color::with_alpha(color::accent(), 0.85), color::on_accent())
|
||||
}
|
||||
(Action::Quit, false) => (color::RAISED, color::TEXT),
|
||||
(Action::Quit, true) => (color::HOVER, color::TEXT),
|
||||
(Action::Quit, false) => (color::raised(), color::text()),
|
||||
(Action::Quit, true) => (color::hover(), color::text()),
|
||||
};
|
||||
let h = CONTROL;
|
||||
let h = control();
|
||||
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
|
||||
let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label);
|
||||
let lw = ui.measure(Face::Medium, button_text(), button.label);
|
||||
let lx = button.x + ((button.w - lw) / 2.0).round();
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
BUTTON_TEXT,
|
||||
button_text(),
|
||||
h,
|
||||
lx,
|
||||
button.y,
|
||||
@@ -123,11 +131,11 @@ impl NoResources {
|
||||
button.label,
|
||||
);
|
||||
}
|
||||
let small_y = layout.buttons[0].y + CONTROL + 20.0;
|
||||
let small_y = layout.buttons[0].y + control() + 20.0;
|
||||
for (i, line) in wrap(
|
||||
ui,
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
&self.looked_in,
|
||||
layout.width,
|
||||
)
|
||||
@@ -137,11 +145,11 @@ impl NoResources {
|
||||
let line_y = small_y + i as f32 * SMALL_LINE;
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
SMALL_LINE,
|
||||
layout.x,
|
||||
line_y,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
line,
|
||||
);
|
||||
}
|
||||
@@ -157,14 +165,22 @@ impl NoResources {
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
|
||||
fn title_size() -> f32 {
|
||||
ui_theme::sizes().title
|
||||
}
|
||||
const TITLE_LINE: f32 = 40.0;
|
||||
const BODY: f32 = horizon_theme::BODY.px;
|
||||
fn body() -> f32 {
|
||||
ui_theme::sizes().body
|
||||
}
|
||||
const LINE: f32 = 24.0;
|
||||
const PARAGRAPH_GAP: f32 = 10.0;
|
||||
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
|
||||
fn button_text() -> f32 {
|
||||
ui_theme::sizes().ui
|
||||
}
|
||||
const SMALL_LINE: f32 = 18.0;
|
||||
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
|
||||
fn control() -> f32 {
|
||||
ui_theme::sizes().control_large
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
x: f32,
|
||||
@@ -184,7 +200,7 @@ struct Button {
|
||||
|
||||
impl Button {
|
||||
fn contains(&self, (cx, cy): (f32, f32)) -> bool {
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + CONTROL
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + control()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +248,7 @@ mod tests {
|
||||
for size in [[1280, 720], [800, 600], [2560, 1440]] {
|
||||
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
|
||||
let layout = screen.layout(ui);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + CONTROL / 2.0);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
|
||||
(
|
||||
center(&layout.buttons[0]),
|
||||
center(&layout.buttons[1]),
|
||||
@@ -255,7 +271,7 @@ mod tests {
|
||||
let layout = screen.layout(ui);
|
||||
let last = layout.buttons.last().unwrap();
|
||||
assert!(
|
||||
last.x + last.w <= ui.w && last.y + CONTROL <= ui.h,
|
||||
last.x + last.w <= ui.w && last.y + control() <= ui.h,
|
||||
"{size:?}"
|
||||
);
|
||||
});
|
||||
@@ -266,10 +282,10 @@ mod tests {
|
||||
fn text_wraps_within_the_column_and_keeps_every_word() {
|
||||
with_ui([1280, 720], (0.0, 0.0), |ui| {
|
||||
for paragraph in PARAGRAPHS {
|
||||
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
|
||||
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
|
||||
assert!(lines.len() > 1, "{paragraph}");
|
||||
for line in &lines {
|
||||
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
|
||||
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
|
||||
}
|
||||
assert_eq!(lines.join(" "), paragraph);
|
||||
}
|
||||
|
||||
@@ -293,16 +293,16 @@ impl Keyboard {
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::with_alpha(color::BG, 0.94),
|
||||
ui_theme::radius_plate(),
|
||||
color::with_alpha(color::bg(), 0.94),
|
||||
);
|
||||
ui.border(
|
||||
x0 - 12.0,
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::BORDER,
|
||||
ui_theme::radius_plate(),
|
||||
color::border(),
|
||||
);
|
||||
for (r, row) in self.rows().iter().enumerate() {
|
||||
let y = y0 + r as f32 * (KEY_H + GAP);
|
||||
@@ -314,13 +314,13 @@ impl Keyboard {
|
||||
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|
||||
|| matches!(key, Key::Layer) && self.symbols;
|
||||
let (plate, ink) = if focused {
|
||||
(color::ACCENT, color::ON_ACCENT)
|
||||
(color::accent(), color::on_accent())
|
||||
} else if lit {
|
||||
(color::HOVER, color::TEXT)
|
||||
(color::hover(), color::text())
|
||||
} else {
|
||||
(color::RAISED, color::TEXT)
|
||||
(color::raised(), color::text())
|
||||
};
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
|
||||
let label = match key {
|
||||
Key::Char(ch) => self.letter(*ch),
|
||||
Key::Shift if self.caps => "CAPS".into(),
|
||||
@@ -331,10 +331,10 @@ impl Keyboard {
|
||||
Key::Backspace => tr("horizon.osk.delete", "Delete"),
|
||||
Key::Done => tr("gui.done", "Done"),
|
||||
};
|
||||
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
|
||||
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
ui_theme::BODY,
|
||||
ui_theme::body(),
|
||||
KEY_H,
|
||||
x + ((w - lw) / 2.0).round(),
|
||||
y,
|
||||
@@ -359,10 +359,10 @@ impl Keyboard {
|
||||
.collect();
|
||||
ui.text_centered(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
x0 + width / 2.0,
|
||||
y0 + 5.0 * (KEY_H + GAP) + 2.0,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
&line.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,8 @@
|
||||
//! connection may be retried, and when. Only lost connections and the
|
||||
//! server's own shutdown or restart are retried, never kicks, bans or
|
||||
//! refusals; attempts back off and stop after a few.
|
||||
//!
|
||||
//! It runs on the engine's connection hooks ([`HOOKS`], `horizon_hooks`): the engine
|
||||
//! reports a connection's start, join, leave and end, and shows and runs the attempt
|
||||
//! this plans. Nothing else in the engine reads it, so it leaves for a Horizon crate
|
||||
//! as a file move once its setting can leave too (docs/layers.md, plan M5).
|
||||
use horizon_hooks::{Ended, Hooks, Server, seat};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A planned attempt.
|
||||
pub use horizon_hooks::Reconnect as Retry;
|
||||
|
||||
/// Seconds before each attempt; there are no more after the last.
|
||||
pub const DELAYS: [u64; 5] = [5, 10, 20, 40, 60];
|
||||
/// Staying connected this long counts the next drop as a fresh one.
|
||||
@@ -121,7 +111,43 @@ pub struct Tracker {
|
||||
attempts: usize,
|
||||
joined: Option<Instant>,
|
||||
}
|
||||
/// A planned attempt.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Retry {
|
||||
pub address: String,
|
||||
pub name: String,
|
||||
pub lan: bool,
|
||||
pub at: Instant,
|
||||
/// 1-based, of `DELAYS.len()`.
|
||||
pub attempt: usize,
|
||||
}
|
||||
impl Retry {
|
||||
/// Whole seconds left, rounded up.
|
||||
pub fn seconds_left(&self, now: Instant) -> u64 {
|
||||
let left = self.at.saturating_duration_since(now);
|
||||
left.as_secs() + u64::from(left.subsec_nanos() > 0)
|
||||
}
|
||||
pub fn due(&self, now: Instant) -> bool {
|
||||
now >= self.at
|
||||
}
|
||||
pub fn text(&self, now: Instant) -> String {
|
||||
format!(
|
||||
"Reconnecting in {} s (attempt {} of {})",
|
||||
self.seconds_left(now),
|
||||
self.attempt,
|
||||
DELAYS.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Tracker {
|
||||
/// The server connected to last (splitscreen players join the same one).
|
||||
pub fn address(&self) -> Option<&str> {
|
||||
self.address.as_deref()
|
||||
}
|
||||
/// That server was found on the local network.
|
||||
pub fn lan(&self) -> bool {
|
||||
self.lan
|
||||
}
|
||||
/// A connection to `address` as `name` starts: a new address starts
|
||||
/// counting anew.
|
||||
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
|
||||
@@ -170,65 +196,6 @@ impl Tracker {
|
||||
lan: self.lan,
|
||||
at: now + delay,
|
||||
attempt: self.attempts,
|
||||
attempts: DELAYS.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-reconnect on the engine's connection hooks: registered at startup with the other
|
||||
/// extras (`entry.rs`); it plans nothing while its setting is off. Splitscreen players share
|
||||
/// the hooks, so each seat (`horizon_hooks::seat`) has its own tracker and setting, as each
|
||||
/// player's game had before.
|
||||
pub static HOOKS: AutoReconnect = AutoReconnect {
|
||||
seats: Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
pub struct AutoReconnect {
|
||||
seats: Mutex<Vec<Seat>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Seat {
|
||||
tracker: Tracker,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl AutoReconnect {
|
||||
/// The `autoReconnect` setting of the player whose turn it is.
|
||||
pub fn set_enabled(&self, on: bool) {
|
||||
self.with(|s| s.enabled = on);
|
||||
}
|
||||
fn with<T>(&self, f: impl FnOnce(&mut Seat) -> T) -> T {
|
||||
let mut seats = self.seats.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let i = seat();
|
||||
if seats.len() <= i {
|
||||
seats.resize_with(i + 1, Seat::default);
|
||||
}
|
||||
f(&mut seats[i])
|
||||
}
|
||||
}
|
||||
|
||||
impl Hooks for AutoReconnect {
|
||||
fn connecting(&self, server: &Server<'_>) {
|
||||
self.with(|s| {
|
||||
s.tracker
|
||||
.connecting(server.address, server.name, server.lan)
|
||||
});
|
||||
}
|
||||
fn joined(&self) {
|
||||
self.with(|s| s.tracker.joined(Instant::now()));
|
||||
}
|
||||
fn left(&self) {
|
||||
self.with(|s| s.tracker.reset());
|
||||
}
|
||||
fn ended(&self, end: &Ended<'_>) -> Option<Retry> {
|
||||
self.with(|s| {
|
||||
if !s.enabled {
|
||||
s.tracker.reset();
|
||||
return None;
|
||||
}
|
||||
s.tracker
|
||||
.ended(end.reason, end.key, end.from_server, Instant::now())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -491,33 +458,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_splitscreen_seat_keeps_its_own_attempts_and_setting() {
|
||||
use horizon_hooks::engine::set_seat;
|
||||
let hooks = AutoReconnect {
|
||||
seats: Mutex::new(Vec::new()),
|
||||
};
|
||||
let lost = Ended::new("Timed out", Some("disconnect.timeout"), false);
|
||||
for (seat, on) in [(0, false), (1, true)] {
|
||||
set_seat(seat);
|
||||
hooks.set_enabled(on);
|
||||
hooks.connecting(&Server::new("example.org", "Player", false));
|
||||
}
|
||||
// P2 has it on: an attempt in 5 s. P1 has it off: none, and P2's state stays.
|
||||
set_seat(1);
|
||||
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(1));
|
||||
set_seat(0);
|
||||
assert_eq!(hooks.ended(&lost), None);
|
||||
set_seat(1);
|
||||
hooks.connecting(&Server::new("example.org", "Player", false));
|
||||
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(2));
|
||||
// P2 leaving on purpose starts it over.
|
||||
hooks.left();
|
||||
hooks.connecting(&Server::new("example.org", "Player", false));
|
||||
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(1));
|
||||
set_seat(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_countdown_rounds_up() {
|
||||
let now = Instant::now();
|
||||
@@ -527,7 +467,6 @@ mod tests {
|
||||
lan: false,
|
||||
at: now + Duration::from_millis(4200),
|
||||
attempt: 2,
|
||||
attempts: DELAYS.len(),
|
||||
};
|
||||
assert_eq!(r.seconds_left(now), 5);
|
||||
assert_eq!(r.text(now), "Reconnecting in 5 s (attempt 2 of 5)");
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const TITLE: &str = "gui.socialInteractions.title";
|
||||
/// `SocialInteractionsScreen`'s page.
|
||||
@@ -185,8 +186,35 @@ pub fn click(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The tooltip of what the cursor is over.
|
||||
fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Text> {
|
||||
/// `PlayerEntry.TOOLTIP_DELAY`: a row button's tooltip shows once the cursor
|
||||
/// has rested on that same button for half a second.
|
||||
pub const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The row button whose tooltip the cursor is over.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Tip {
|
||||
Report(u128),
|
||||
Toggle { id: u128, hidden: bool },
|
||||
}
|
||||
|
||||
impl Tip {
|
||||
fn text(self) -> Text {
|
||||
match self {
|
||||
Tip::Report(_) => {
|
||||
Text::translate("gui.socialInteractions.tooltip.report.disabled", &[])
|
||||
}
|
||||
Tip::Toggle { hidden: true, .. } => {
|
||||
Text::translate("gui.socialInteractions.tooltip.show", &[])
|
||||
}
|
||||
Tip::Toggle { hidden: false, .. } => {
|
||||
Text::translate("gui.socialInteractions.tooltip.hide", &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tooltip of what the cursor is over: the row button it names.
|
||||
fn tip_at(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Tip> {
|
||||
let (w, h) = size;
|
||||
if cursor.1 < LIST_START || cursor.1 >= list_end(h) {
|
||||
return None;
|
||||
@@ -198,28 +226,49 @@ fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Te
|
||||
}
|
||||
let (hide, report) = row_buttons(w, y);
|
||||
if ui::hovered(cursor, hide[0], hide[1], 20.0, 20.0) {
|
||||
let key = if e.hidden {
|
||||
"gui.socialInteractions.tooltip.show"
|
||||
} else {
|
||||
"gui.socialInteractions.tooltip.hide"
|
||||
};
|
||||
return Some(Text::translate(key, &[]));
|
||||
return Some(Tip::Toggle {
|
||||
id: e.id,
|
||||
hidden: e.hidden,
|
||||
});
|
||||
}
|
||||
if ui::hovered(cursor, report[0], report[1], 20.0, 20.0) {
|
||||
return Some(Text::translate(
|
||||
"gui.socialInteractions.tooltip.report.disabled",
|
||||
&[],
|
||||
));
|
||||
return Some(Tip::Report(e.id));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `WidgetTooltipHolder`'s delay: the tooltip of the control the cursor has
|
||||
/// rested on for `TOOLTIP_DELAY`, and nothing while it moves between them.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct TooltipDelay {
|
||||
hovered: Option<(Tip, Instant)>,
|
||||
}
|
||||
|
||||
impl TooltipDelay {
|
||||
/// The tip to draw at `now`, starting the wait when the cursor reaches a
|
||||
/// new button (vanilla's `displayStartTime`).
|
||||
pub fn shown(&mut self, hovered: Option<Tip>, now: Instant) -> Option<Tip> {
|
||||
let Some(tip) = hovered else {
|
||||
self.hovered = None;
|
||||
return None;
|
||||
};
|
||||
match self.hovered {
|
||||
Some((t, at)) if t == tip => (now.duration_since(at) > TOOLTIP_DELAY).then_some(tip),
|
||||
_ => {
|
||||
self.hovered = Some((tip, now));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
view: &SocialView,
|
||||
search: &mut ui::EditBox,
|
||||
tooltip: &mut TooltipDelay,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
@@ -419,8 +468,8 @@ pub fn draw(
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(text) = tooltip((w, h), cursor, view) {
|
||||
ui::tooltip(batch, atlas, &[text], cursor);
|
||||
if let Some(tip) = tooltip.shown(tip_at((w, h), cursor, view), Instant::now()) {
|
||||
ui::tooltip(batch, atlas, &[tip.text()], cursor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,4 +566,35 @@ mod tests {
|
||||
let names: Vec<_> = rows.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["me", "_x", "Alpha", "zed", "§odd"]);
|
||||
}
|
||||
|
||||
/// `PlayerEntry.TOOLTIP_DELAY` (500 ms): the row buttons' tooltips wait
|
||||
/// while container item tooltips do not. Moving to another button, or
|
||||
/// away and back, starts the wait again.
|
||||
#[test]
|
||||
fn a_row_buttons_tooltip_waits_half_a_second() {
|
||||
let start = Instant::now();
|
||||
let at = |ms| start + Duration::from_millis(ms);
|
||||
let mut delay = TooltipDelay::default();
|
||||
let tip = Tip::Toggle {
|
||||
id: 7,
|
||||
hidden: false,
|
||||
};
|
||||
assert_eq!(delay.shown(Some(tip), at(0)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(499)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(500)), None, "500 ms is not > 500");
|
||||
assert_eq!(delay.shown(Some(tip), at(501)), Some(tip));
|
||||
// Another row button is another widget: its own wait.
|
||||
let other = Tip::Toggle {
|
||||
id: 8,
|
||||
hidden: true,
|
||||
};
|
||||
assert_eq!(delay.shown(Some(other), at(502)), None);
|
||||
assert_eq!(delay.shown(Some(other), at(1002)), None);
|
||||
assert_eq!(delay.shown(Some(other), at(1003)), Some(other));
|
||||
// Leaving hides it at once; coming back starts over.
|
||||
assert_eq!(delay.shown(None, at(1004)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(1005)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(1505)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(1506)), Some(tip));
|
||||
}
|
||||
}
|
||||
@@ -408,7 +408,7 @@ impl TimelineUi {
|
||||
let tx = start_x + sec * px_per_sec;
|
||||
if tx <= start_x + w {
|
||||
// Major tick line
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
|
||||
// Label every 2 or 5 seconds depending on width
|
||||
let is_labeled = if total_secs > 60.0 {
|
||||
(sec as u32).is_multiple_of(10)
|
||||
@@ -424,7 +424,7 @@ impl TimelineUi {
|
||||
"horizon.studio.timeline.seconds",
|
||||
&[Text::literal(seconds)],
|
||||
);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
|
||||
}
|
||||
}
|
||||
sec += sec_step;
|
||||
@@ -459,7 +459,7 @@ impl TimelineUi {
|
||||
// Base lane well
|
||||
let lane_rect = [start_x, y, track_w, bar_h];
|
||||
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
|
||||
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
|
||||
style::ring(batch, atlas, lane_rect, style::row_rule());
|
||||
|
||||
let total_dur = self.duration_ms.max(1) as f32;
|
||||
|
||||
@@ -514,7 +514,7 @@ impl TimelineUi {
|
||||
&scene_title,
|
||||
seg_x + 5.0,
|
||||
y + 5.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -577,7 +577,7 @@ impl TimelineUi {
|
||||
let px = start_x + norm * track_w;
|
||||
|
||||
// Scrubber line from ruler through bottom of tracks
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
|
||||
|
||||
// Inverted triangle marker at ruler top
|
||||
let tri_color = style::WHITE;
|
||||
|
||||
@@ -178,7 +178,7 @@ impl TopBarUi {
|
||||
);
|
||||
}
|
||||
let color = if open {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
@@ -220,7 +220,7 @@ impl TopBarUi {
|
||||
if pause_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -339,7 +339,11 @@ impl TopBarUi {
|
||||
&Text::translate(key, &[]),
|
||||
x + 8.0,
|
||||
y + 6.0,
|
||||
if hovered { style::WHITE } else { style::MUTED },
|
||||
if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
if !shortcut.is_empty() {
|
||||
@@ -350,7 +354,7 @@ impl TopBarUi {
|
||||
&shortcut,
|
||||
x + menu_width - shortcut_width - 8.0,
|
||||
y + 6.0,
|
||||
style::SOFT,
|
||||
style::soft(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4707,7 +4707,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
}
|
||||
}
|
||||
/// The server auto-reconnect retries now, once its countdown is over.
|
||||
pub fn due_retry(&mut self) -> Option<horizon_hooks::Reconnect> {
|
||||
pub fn due_retry(&mut self) -> Option<crate::reconnect::Retry> {
|
||||
match &mut self.mode {
|
||||
Mode::Multiplayer(screen) => screen.due_retry(),
|
||||
_ => None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
|
||||
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
|
||||
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! The design system's widgets drawn in the game: palette, type scale, radii,
|
||||
//! control sizes and fonts from `menu_style`, given at startup (in the official
|
||||
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! here draws Horizon's own overlays that vanilla has no screen for: the
|
||||
//! on-screen keyboard a controller types with (`osk`), plus the
|
||||
//! `horizon.txt` preferences.
|
||||
@@ -39,44 +39,76 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
|
||||
let _ = std::fs::write(dir.join("horizon.txt"), out);
|
||||
}
|
||||
|
||||
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
|
||||
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
|
||||
/// translucent plates the overlays draw over the game.
|
||||
pub(crate) mod color {
|
||||
use horizon_theme::DARK as P;
|
||||
pub const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
use horizon_client_render::menu_style::{self, OverlayColours};
|
||||
pub use menu_style::hud_colour as hex;
|
||||
fn p() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
/// The page, under every veil.
|
||||
pub const BG: u32 = hex(P.page, 255);
|
||||
pub fn bg() -> u32 {
|
||||
hex(p().page, 255)
|
||||
}
|
||||
/// Hover plates, secondary buttons, fields, the segmented track: the
|
||||
/// dark palette's soft plate, as ink over the veil so the plates read the
|
||||
/// same over any backdrop (over the page colour they are `plate_soft`).
|
||||
pub const RAISED: u32 = hex(P.ink, 20);
|
||||
pub fn raised() -> u32 {
|
||||
hex(p().ink, 20)
|
||||
}
|
||||
/// Pressed plates and a switch that is off (`plate`, the same way).
|
||||
pub const HOVER: u32 = hex(P.ink, 36);
|
||||
pub fn hover() -> u32 {
|
||||
hex(p().ink, 36)
|
||||
}
|
||||
/// One-pixel rules between rows, slider tracks.
|
||||
pub const BORDER: u32 = hex(P.rule, 255);
|
||||
pub fn border() -> u32 {
|
||||
hex(p().rule, 255)
|
||||
}
|
||||
/// Text and selection (`ink`).
|
||||
pub const TEXT: u32 = hex(P.ink, 255);
|
||||
pub fn text() -> u32 {
|
||||
hex(p().ink, 255)
|
||||
}
|
||||
/// Values and secondary text (`ink_soft`).
|
||||
pub const MUTED: u32 = hex(P.ink_soft, 255);
|
||||
pub const ACCENT: u32 = hex(P.accent, 255);
|
||||
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(p().ink_soft, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(p().accent, 255)
|
||||
}
|
||||
pub fn on_accent() -> u32 {
|
||||
hex(p().on_accent, 255)
|
||||
}
|
||||
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
|
||||
let base = (c >> 24) as f32;
|
||||
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
|
||||
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
|
||||
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
|
||||
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
|
||||
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
|
||||
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
|
||||
/// Fields; plates (hover rows, selection); pills (buttons, segments).
|
||||
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
|
||||
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
|
||||
/// The type scale and shapes (`menu_style`), in dp.
|
||||
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
|
||||
&horizon_client_render::menu_style::get().sizes
|
||||
}
|
||||
pub(crate) fn small() -> f32 {
|
||||
sizes().caption
|
||||
}
|
||||
pub(crate) fn body() -> f32 {
|
||||
sizes().label
|
||||
}
|
||||
/// The boot screen's wordmark (`display`) and console lines (`micro`).
|
||||
fn display() -> f32 {
|
||||
sizes().display
|
||||
}
|
||||
fn micro() -> f32 {
|
||||
sizes().micro
|
||||
}
|
||||
/// Fields; plates (hover rows, selection).
|
||||
pub(crate) fn radius() -> f32 {
|
||||
sizes().radius
|
||||
}
|
||||
pub(crate) fn radius_plate() -> f32 {
|
||||
sizes().radius_plate
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Face {
|
||||
@@ -87,20 +119,34 @@ pub(crate) enum Face {
|
||||
/// Counts, timestamps and the boot screen's console lines.
|
||||
Mono,
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
|
||||
#[cfg(test)]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
|
||||
Some(horizon_client_render::menu_style::Fonts {
|
||||
regular: horizon_theme::fonts::GEIST_REGULAR,
|
||||
medium: horizon_theme::fonts::GEIST_MEDIUM,
|
||||
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: horizon_theme::fonts::GEIST_MONO,
|
||||
});
|
||||
struct Fonts {
|
||||
faces: [FontRef<'static>; 4],
|
||||
}
|
||||
impl Fonts {
|
||||
fn load() -> Self {
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
|
||||
Self {
|
||||
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
|
||||
fn load() -> Option<Self> {
|
||||
let given = horizon_client_render::menu_style::get()
|
||||
.fonts
|
||||
.or(TEST_FONTS)?;
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
|
||||
Some(Self {
|
||||
faces: [
|
||||
f(horizon_theme::fonts::GEIST_REGULAR),
|
||||
f(horizon_theme::fonts::GEIST_MEDIUM),
|
||||
f(horizon_theme::fonts::GEIST_SEMIBOLD),
|
||||
f(horizon_theme::fonts::GEIST_MONO),
|
||||
f(given.regular),
|
||||
f(given.medium),
|
||||
f(given.semibold),
|
||||
f(given.mono),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
fn face(&self, f: Face) -> &FontRef<'static> {
|
||||
&self.faces[f as usize]
|
||||
@@ -142,7 +188,8 @@ struct Cached {
|
||||
}
|
||||
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
|
||||
struct Cache {
|
||||
fonts: Fonts,
|
||||
/// None: no fonts were given, and text draws nothing.
|
||||
fonts: Option<Fonts>,
|
||||
entries: HashMap<Key, Cached>,
|
||||
shelf: (u32, u32, u32),
|
||||
pending: Vec<crate::font::Patch>,
|
||||
@@ -230,9 +277,16 @@ impl Cache {
|
||||
if let Some(c) = self.entries.get(&key) {
|
||||
return *c;
|
||||
}
|
||||
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
|
||||
let Some(fonts) = &self.fonts else {
|
||||
return Cached {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
};
|
||||
};
|
||||
let scale = fonts.scale(face, f32::from(size) / 4.0);
|
||||
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
|
||||
let made = match self.fonts.face(face).outline_glyph(glyph) {
|
||||
let made = match fonts.face(face).outline_glyph(glyph) {
|
||||
Some(outline) => {
|
||||
let b = outline.px_bounds();
|
||||
let (w, h) = (b.width() as u32, b.height() as u32);
|
||||
@@ -459,14 +513,17 @@ impl<'a> Ui<'a> {
|
||||
let atlas = self.atlas;
|
||||
let gui = self.scale;
|
||||
with_cache(atlas, |c| {
|
||||
let scale = c.fonts.scale(face, px);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
return (0.0, 0.0, 0.0, Vec::new());
|
||||
};
|
||||
let scale = fonts.scale(face, px);
|
||||
let (ascent, descent) = {
|
||||
let f = c.fonts.face(face).as_scaled(scale);
|
||||
let f = fonts.face(face).as_scaled(scale);
|
||||
(f.ascent(), f.descent())
|
||||
};
|
||||
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
|
||||
.map(|ch| {
|
||||
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
|
||||
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
|
||||
.then(|| atlas.font.glyph(ch))
|
||||
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
|
||||
.filter(|g| g.rect.is_some() || g.right <= 0.0);
|
||||
@@ -491,7 +548,10 @@ impl<'a> Ui<'a> {
|
||||
continue;
|
||||
}
|
||||
let (id, advance, kern) = {
|
||||
let font = c.fonts.face(face);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
break;
|
||||
};
|
||||
let font = fonts.face(face);
|
||||
let id = font.glyph_id(ch);
|
||||
let f = font.as_scaled(scale);
|
||||
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
|
||||
@@ -606,8 +666,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
/// The wordmark with its tagline, centred.
|
||||
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
|
||||
let small = (size * 0.25).clamp(MICRO, 18.0);
|
||||
let size = (ui.w / 16.0).clamp(display(), 96.0);
|
||||
let small = (size * 0.25).clamp(micro(), 18.0);
|
||||
let block = size * 1.45 + small * 0.6 + small * 1.5;
|
||||
let top = (ui.h - block) / 2.0;
|
||||
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
|
||||
@@ -629,7 +689,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
return;
|
||||
}
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
|
||||
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
|
||||
let max_w = ui.w / 2.0;
|
||||
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
|
||||
let mut y = ui.h - pad - line;
|
||||
@@ -696,7 +756,12 @@ mod tests {
|
||||
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
reset_atlas();
|
||||
let draw = |cache: &mut Cache| {
|
||||
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
|
||||
let glyph = cache
|
||||
.fonts
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.face(Face::Regular)
|
||||
.glyph_id('A');
|
||||
assert!(
|
||||
cache
|
||||
.glyph(&atlas, Face::Regular, glyph, 16.0)
|
||||
@@ -782,7 +847,7 @@ mod tests {
|
||||
let lines = wrapping_lines(
|
||||
&ui,
|
||||
Face::Mono,
|
||||
MICRO,
|
||||
micro(),
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
90.0,
|
||||
);
|
||||
@@ -790,7 +855,7 @@ mod tests {
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
|
||||
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
|
||||
"every wrapped line fits"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -812,7 +877,7 @@ mod tests {
|
||||
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
|
||||
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
|
||||
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
|
||||
let quads = batch
|
||||
.vertices
|
||||
.iter()
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
//! Container screens (docs/layers.md, "Container screens"): input and drawing on an open container
|
||||
//! screen (the inventory, chests, furnaces, crafting tables...) for a crate above the engine, as
|
||||
//! Mouse Tweaks and sorting need. The engine implements [`Container`] for the length of one call
|
||||
//! of a hook (`Hooks::container_press` and the others); everything in it is plain data.
|
||||
|
||||
/// A slot of the open screen: opaque, stable while the screen is open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct SlotId(pub u32);
|
||||
|
||||
/// What a slot is for.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// The player's hotbar.
|
||||
Hotbar,
|
||||
/// The player's main inventory, above the hotbar.
|
||||
Inventory,
|
||||
/// A slot that only holds items: a chest's, a hopper's, a mount's chest.
|
||||
Storage,
|
||||
/// A slot a recipe or a furnace reads: a crafting grid's, a furnace's input and fuel.
|
||||
Input,
|
||||
/// A slot that only gives: a crafting or furnace result.
|
||||
Result,
|
||||
/// The player's armour and off hand, a mount's saddle and armour.
|
||||
Equipment,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A slot drawn on the screen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Slot {
|
||||
pub id: SlotId,
|
||||
pub role: Role,
|
||||
/// Its top left corner in GUI pixels, from the panel's.
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// The items in a slot or on the cursor.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Stack {
|
||||
/// The item's id (`minecraft:stone`).
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
/// How many one slot holds of it.
|
||||
pub max: u32,
|
||||
/// Equal for stacks of the same item with the same components: those merge.
|
||||
pub kind: u64,
|
||||
}
|
||||
|
||||
/// One ordinary slot click, as vanilla's screen sends it.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Click {
|
||||
/// Picks up, puts down or swaps (`PICKUP`), the left or right button.
|
||||
Pick { right: bool },
|
||||
/// The Shift click (`QUICK_MOVE`).
|
||||
QuickMove,
|
||||
/// Puts the cursor's stack, or one of it, in the slot as a one-slot drag (`QUICK_CRAFT`): a
|
||||
/// server whose cursor is already empty ignores it, where a click would pick up the slot.
|
||||
Put { one: bool },
|
||||
}
|
||||
|
||||
/// The open screen's menu, for the length of one call. The engine implements it. The reads that
|
||||
/// ask the menu's rules take `&mut self`, as the engine's do; drawing gets the others only.
|
||||
pub trait Container {
|
||||
/// Its menu type: `inventory` for the player's own, else the registry name the server opened
|
||||
/// (`minecraft:generic_9x3`, `minecraft:furnace`...).
|
||||
fn menu(&self) -> &str;
|
||||
/// Every slot drawn, in the screen's order, in every menu: a crate keeps to the menus it acts
|
||||
/// in, by [`Container::menu`] (Mouse Tweaks stays out of those whose slots cost or trigger
|
||||
/// something: anvils, enchanting, merchants, beacons, crafters). Creative's tabs are no
|
||||
/// container screen.
|
||||
fn slots(&self) -> Vec<Slot>;
|
||||
fn stack(&mut self, slot: SlotId) -> Option<Stack>;
|
||||
/// The stack on the cursor.
|
||||
fn carried(&self) -> Option<Stack>;
|
||||
/// Whether `slot` takes one more of `of`, by the menu's rules (`mayPlace`, the slot's limit).
|
||||
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool;
|
||||
/// Where a Shift click on `slot` would put its items (`quickMoveStack`), first first.
|
||||
fn landing(&mut self, slot: SlotId) -> Vec<SlotId>;
|
||||
/// One ordinary click. False when nothing took it.
|
||||
fn click(&mut self, slot: SlotId, click: Click) -> bool;
|
||||
/// How many slots vanilla's drag of the cursor's stack spreads over now (0 while none runs).
|
||||
fn spread(&self) -> usize;
|
||||
/// The cursor, in GUI pixels from the panel's corner.
|
||||
fn cursor(&self) -> (f32, f32);
|
||||
/// Whether Shift is held.
|
||||
fn shift(&self) -> bool;
|
||||
/// The creative tabs' order of the stack in `slot` (`u32::MAX` when no tab lists it) and the
|
||||
/// name it shows: how sorting ranks it.
|
||||
fn order(&mut self, slot: SlotId) -> (u32, String);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//! HUD drawing (docs/layers.md, "Platform registries"): a crate above the engine draws on the HUD
|
||||
//! where the extras do, in the corners they share, without an engine file naming it. With no layer
|
||||
//! registered the engine builds no canvas.
|
||||
use crate::TooLate;
|
||||
use crate::registry::Registry;
|
||||
|
||||
/// A corner of the screen. Boxes placed in one stack away from its edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Corner {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
/// What a layer draws with, in GUI pixels (the player's GUI scale applied). Colours are
|
||||
/// `0xAARRGGBB`.
|
||||
pub trait Canvas {
|
||||
/// The screen's width and height.
|
||||
fn size(&self) -> (f32, f32);
|
||||
/// The text of a translation key in the player's language.
|
||||
fn tr(&self, key: &str) -> String;
|
||||
/// How wide `text` is drawn.
|
||||
fn text_width(&self, text: &str) -> f32;
|
||||
/// A line of text from its top-left corner, with the game's text shadow or not.
|
||||
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool);
|
||||
/// A filled rectangle.
|
||||
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32);
|
||||
/// Where a `w` × `h` box goes in `corner`: below (or above) what the corner holds already,
|
||||
/// the minimap, the PvP widgets and mods' boxes. `None` while the corner is hidden (the debug
|
||||
/// screen covers the top left): draw nothing there then.
|
||||
fn place(&mut self, corner: Corner, w: f32, h: f32) -> Option<(f32, f32)>;
|
||||
}
|
||||
|
||||
/// Something a crate draws on the HUD, every frame the HUD is drawn (not with F1, nor under a
|
||||
/// screen that hides it). It reads its own state and must return quickly.
|
||||
pub trait Layer: Sync {
|
||||
fn draw(&self, canvas: &mut dyn Canvas);
|
||||
}
|
||||
|
||||
static LAYERS: Registry<dyn Layer> = Registry::new();
|
||||
|
||||
/// Adds `layer` to the HUD, above the layers registered before it. Before the game starts, as
|
||||
/// [`crate::register`].
|
||||
pub fn register(layer: &'static dyn Layer) -> Result<(), TooLate> {
|
||||
LAYERS.register(layer)
|
||||
}
|
||||
|
||||
/// The engine's side. Only the engine calls it.
|
||||
pub mod engine {
|
||||
use super::*;
|
||||
|
||||
/// Every registered layer, sealing the list on the first call.
|
||||
pub fn all() -> &'static [&'static dyn Layer] {
|
||||
LAYERS.all()
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,14 @@
|
||||
//! does the matching thing.
|
||||
//!
|
||||
//! The platform registries (docs/layers.md, "Platform registries") have the same shape:
|
||||
//! [`settings`] for the settings each crate keeps in its own lines, [`pages`] for its settings
|
||||
//! pages, [`hud`] for what it draws on the HUD.
|
||||
//! [`settings`] for the settings each crate keeps in its own lines.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod containers;
|
||||
pub mod hud;
|
||||
pub mod look;
|
||||
pub mod pages;
|
||||
mod registry;
|
||||
pub mod settings;
|
||||
|
||||
use containers::{Container, SlotId};
|
||||
use registry::Registry;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
/// What a crate above the engine hears from the game. Every method has an empty default:
|
||||
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
|
||||
@@ -45,49 +37,6 @@ pub trait Hooks: Sync {
|
||||
|
||||
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
|
||||
fn shutdown(&self) {}
|
||||
|
||||
/// This window starts connecting to a multiplayer server (the server list, a direct
|
||||
/// connection, a Realm, a LAN world). Not single player's own server.
|
||||
fn connecting(&self, _server: &Server<'_>) {}
|
||||
|
||||
/// The server let the player in.
|
||||
fn joined(&self) {}
|
||||
|
||||
/// The player left the server on purpose: nothing to retry.
|
||||
fn left(&self) {}
|
||||
|
||||
/// The connection ended without the player leaving it (lost, closed, kicked, refused). A hook
|
||||
/// may plan another attempt: the engine shows its countdown on the Disconnected screen, with
|
||||
/// Cancel, and connects again when it is due. Every hook hears the end; the first plan wins.
|
||||
fn ended(&self, _end: &Ended<'_>) -> Option<Reconnect> {
|
||||
None
|
||||
}
|
||||
|
||||
// An open container screen (`containers`): each input hook returns whether it took the
|
||||
// input, and then vanilla's handling of it doesn't run. The first hook that takes it wins.
|
||||
|
||||
/// A mouse button went down over the panel, on `slot` or none.
|
||||
fn container_press(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _right: bool) -> bool {
|
||||
false
|
||||
}
|
||||
/// The cursor moved with a button held, now over `slot` or none.
|
||||
fn container_drag(&self, _c: &mut dyn Container, _slot: Option<SlotId>) -> bool {
|
||||
false
|
||||
}
|
||||
/// The button went up. Every hook hears it.
|
||||
fn container_release(&self, _c: &mut dyn Container) {}
|
||||
/// The wheel turned `steps` notches over `slot` or none (up is positive; a trackpad turns it
|
||||
/// in fractions).
|
||||
fn container_wheel(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _steps: f32) -> bool {
|
||||
false
|
||||
}
|
||||
/// A key or a mouse button the screen got, by its name (`key.keyboard.r`, `key.mouse.middle`),
|
||||
/// with the cursor over `slot` or none.
|
||||
fn container_key(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _key: &str) -> bool {
|
||||
false
|
||||
}
|
||||
/// Draws over the panel, from its top left corner, after the slots and before the tooltip.
|
||||
fn container_draw(&self, _c: &dyn Container, _canvas: &mut dyn hud::Canvas) {}
|
||||
}
|
||||
|
||||
/// What [`Hooks::started`] gets.
|
||||
@@ -103,77 +52,6 @@ impl<'a> Started<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::connecting`] gets.
|
||||
#[non_exhaustive]
|
||||
pub struct Server<'a> {
|
||||
/// As the player gave it (`host[:port]`).
|
||||
pub address: &'a str,
|
||||
/// The player name the connection uses.
|
||||
pub name: &'a str,
|
||||
/// Found on the local network.
|
||||
pub lan: bool,
|
||||
}
|
||||
|
||||
impl<'a> Server<'a> {
|
||||
pub fn new(address: &'a str, name: &'a str, lan: bool) -> Self {
|
||||
Self { address, name, lan }
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::ended`] gets.
|
||||
#[non_exhaustive]
|
||||
pub struct Ended<'a> {
|
||||
/// The reason shown on the Disconnected screen.
|
||||
pub reason: &'a str,
|
||||
/// Its translation key, when it has one (`disconnect.timeout`, `multiplayer.disconnect.kicked`).
|
||||
pub key: Option<&'a str>,
|
||||
/// The server sent it (a disconnect packet), rather than the connection failing.
|
||||
pub from_server: bool,
|
||||
}
|
||||
|
||||
impl<'a> Ended<'a> {
|
||||
pub fn new(reason: &'a str, key: Option<&'a str>, from_server: bool) -> Self {
|
||||
Self {
|
||||
reason,
|
||||
key,
|
||||
from_server,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Another attempt at a server, planned by [`Hooks::ended`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Reconnect {
|
||||
pub address: String,
|
||||
pub name: String,
|
||||
pub lan: bool,
|
||||
/// When to connect.
|
||||
pub at: Instant,
|
||||
/// This attempt, 1-based, of `attempts`.
|
||||
pub attempt: usize,
|
||||
pub attempts: usize,
|
||||
}
|
||||
|
||||
impl Reconnect {
|
||||
/// Whole seconds left, rounded up.
|
||||
pub fn seconds_left(&self, now: Instant) -> u64 {
|
||||
let left = self.at.saturating_duration_since(now);
|
||||
left.as_secs() + u64::from(left.subsec_nanos() > 0)
|
||||
}
|
||||
pub fn due(&self, now: Instant) -> bool {
|
||||
now >= self.at
|
||||
}
|
||||
/// The Disconnected screen's countdown line.
|
||||
pub fn text(&self, now: Instant) -> String {
|
||||
format!(
|
||||
"Reconnecting in {} s (attempt {} of {})",
|
||||
self.seconds_left(now),
|
||||
self.attempt,
|
||||
self.attempts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static HOOKS: Registry<dyn Hooks> = Registry::new();
|
||||
|
||||
/// Why [`register`] refused a hook.
|
||||
@@ -200,34 +78,15 @@ fn all() -> &'static [&'static dyn Hooks] {
|
||||
HOOKS.all()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SEAT: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
|
||||
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
|
||||
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
|
||||
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
|
||||
pub fn seat() -> usize {
|
||||
SEAT.get()
|
||||
}
|
||||
|
||||
/// The engine's side: where it calls the hooks. Only the engine calls these.
|
||||
pub mod engine {
|
||||
use super::*;
|
||||
|
||||
/// The player whose turn starts on this thread ([`seat`]).
|
||||
pub fn set_seat(seat: usize) {
|
||||
SEAT.set(seat);
|
||||
}
|
||||
|
||||
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
|
||||
/// registration ends.
|
||||
pub fn seal() {
|
||||
all();
|
||||
crate::settings::all();
|
||||
crate::pages::engine::all();
|
||||
crate::hud::engine::all();
|
||||
}
|
||||
|
||||
/// [`Hooks::started`].
|
||||
@@ -257,79 +116,6 @@ pub mod engine {
|
||||
hooks.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::connecting`].
|
||||
pub fn connecting(server: &Server<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.connecting(server);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::joined`].
|
||||
pub fn joined() {
|
||||
for hooks in all() {
|
||||
hooks.joined();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::left`].
|
||||
pub fn left() {
|
||||
for hooks in all() {
|
||||
hooks.left();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a container screen's input reaches a hook: false while none is registered, and
|
||||
/// the engine builds no `Container` then.
|
||||
pub fn containers() -> bool {
|
||||
!all().is_empty()
|
||||
}
|
||||
|
||||
/// [`Hooks::container_press`]: true when a hook took it.
|
||||
pub fn container_press(c: &mut dyn Container, slot: Option<SlotId>, right: bool) -> bool {
|
||||
all().iter().any(|h| h.container_press(c, slot, right))
|
||||
}
|
||||
|
||||
/// [`Hooks::container_drag`].
|
||||
pub fn container_drag(c: &mut dyn Container, slot: Option<SlotId>) -> bool {
|
||||
all().iter().any(|h| h.container_drag(c, slot))
|
||||
}
|
||||
|
||||
/// [`Hooks::container_release`], to every hook.
|
||||
pub fn container_release(c: &mut dyn Container) {
|
||||
for hooks in all() {
|
||||
hooks.container_release(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::container_wheel`].
|
||||
pub fn container_wheel(c: &mut dyn Container, slot: Option<SlotId>, steps: f32) -> bool {
|
||||
all().iter().any(|h| h.container_wheel(c, slot, steps))
|
||||
}
|
||||
|
||||
/// [`Hooks::container_key`].
|
||||
pub fn container_key(c: &mut dyn Container, slot: Option<SlotId>, key: &str) -> bool {
|
||||
all().iter().any(|h| h.container_key(c, slot, key))
|
||||
}
|
||||
|
||||
/// [`Hooks::container_draw`].
|
||||
pub fn container_draw(c: &dyn Container, canvas: &mut dyn crate::hud::Canvas) {
|
||||
for hooks in all() {
|
||||
hooks.container_draw(c, canvas);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::ended`]: every hook hears it, and the first plan is the one kept.
|
||||
pub fn ended(end: &Ended<'_>) -> Option<Reconnect> {
|
||||
let mut plan = None;
|
||||
for hooks in all() {
|
||||
let mine = hooks.ended(end);
|
||||
if plan.is_none() {
|
||||
plan = mine;
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -351,126 +137,27 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn ended(&self, end: &Ended<'_>) -> Option<Reconnect> {
|
||||
self.0.lock().unwrap().push(format!("ended {}", end.reason));
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Plans an attempt numbered by its field.
|
||||
struct Planner(usize);
|
||||
impl Hooks for Planner {
|
||||
fn ended(&self, end: &Ended<'_>) -> Option<Reconnect> {
|
||||
Some(Reconnect {
|
||||
address: end.reason.to_owned(),
|
||||
name: String::new(),
|
||||
lan: false,
|
||||
at: Instant::now(),
|
||||
attempt: self.0,
|
||||
attempts: 5,
|
||||
})
|
||||
}
|
||||
}
|
||||
static FIRST: Planner = Planner(1);
|
||||
static SECOND: Planner = Planner(2);
|
||||
|
||||
/// Implements nothing: the defaults do nothing.
|
||||
struct Silent;
|
||||
impl Hooks for Silent {}
|
||||
|
||||
static RECORDER: Recorder = Recorder(StdMutex::new(Vec::new()));
|
||||
|
||||
/// Takes its key on a container screen; notes what reached it.
|
||||
struct Keys(&'static str, StdMutex<Vec<String>>);
|
||||
impl Hooks for Keys {
|
||||
fn container_key(&self, _: &mut dyn Container, _: Option<SlotId>, key: &str) -> bool {
|
||||
self.1.lock().unwrap().push(format!("key {key}"));
|
||||
key == self.0
|
||||
}
|
||||
fn container_release(&self, _: &mut dyn Container) {
|
||||
self.1.lock().unwrap().push("release".into());
|
||||
}
|
||||
}
|
||||
static SORT: Keys = Keys("key.keyboard.r", StdMutex::new(Vec::new()));
|
||||
static ALSO_R: Keys = Keys("key.keyboard.r", StdMutex::new(Vec::new()));
|
||||
|
||||
/// A screen with nothing in it.
|
||||
struct Empty;
|
||||
impl Container for Empty {
|
||||
fn menu(&self) -> &str {
|
||||
"inventory"
|
||||
}
|
||||
fn slots(&self) -> Vec<containers::Slot> {
|
||||
Vec::new()
|
||||
}
|
||||
fn stack(&mut self, _: SlotId) -> Option<containers::Stack> {
|
||||
None
|
||||
}
|
||||
fn carried(&self) -> Option<containers::Stack> {
|
||||
None
|
||||
}
|
||||
fn takes_one(&mut self, _: SlotId, _: &containers::Stack) -> bool {
|
||||
false
|
||||
}
|
||||
fn landing(&mut self, _: SlotId) -> Vec<SlotId> {
|
||||
Vec::new()
|
||||
}
|
||||
fn click(&mut self, _: SlotId, _: containers::Click) -> bool {
|
||||
false
|
||||
}
|
||||
fn spread(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn cursor(&self) -> (f32, f32) {
|
||||
(0.0, 0.0)
|
||||
}
|
||||
fn shift(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn order(&mut self, _: SlotId) -> (u32, String) {
|
||||
(u32::MAX, String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_hooks_hear_the_game_and_late_ones_are_refused() {
|
||||
register(&RECORDER).unwrap();
|
||||
register(&Silent).unwrap();
|
||||
register(&FIRST).unwrap();
|
||||
register(&SECOND).unwrap();
|
||||
register(&SORT).unwrap();
|
||||
register(&ALSO_R).unwrap();
|
||||
engine::started(&Started::new("0.2.31"));
|
||||
// Changes only: the same state every frame is said once.
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
// Every hook hears the end; the first plan registered is the one kept.
|
||||
let plan = engine::ended(&Ended::new("lost", None, false)).unwrap();
|
||||
assert_eq!(plan.attempt, 1);
|
||||
// A container screen's key goes to the first hook that takes it, its release to all.
|
||||
assert!(engine::containers());
|
||||
assert!(!engine::container_key(&mut Empty, None, "key.keyboard.e"));
|
||||
assert!(engine::container_key(&mut Empty, None, "key.keyboard.r"));
|
||||
engine::container_release(&mut Empty);
|
||||
assert_eq!(
|
||||
*SORT.1.lock().unwrap(),
|
||||
["key key.keyboard.e", "key key.keyboard.r", "release"]
|
||||
);
|
||||
assert_eq!(
|
||||
*ALSO_R.1.lock().unwrap(),
|
||||
["key key.keyboard.e", "release"],
|
||||
"taken before it"
|
||||
);
|
||||
engine::shutdown();
|
||||
assert_eq!(
|
||||
*RECORDER.0.lock().unwrap(),
|
||||
[
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"ended lost"
|
||||
]
|
||||
["started 0.2.31", "published true", "published false"]
|
||||
);
|
||||
assert_eq!(register(&Silent), Err(TooLate));
|
||||
}
|
||||
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user