mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf125a1a69 | ||
|
|
7e6d2121b7 | ||
|
|
a89865b1c5 | ||
|
|
c8e48f9cea | ||
|
|
bdb6452da9 | ||
|
|
2ddd9705d9 | ||
|
|
868d3b76a5 | ||
|
|
b4f24319ac | ||
|
|
962f5949ac | ||
|
|
5e9024e0a1 | ||
|
|
559c2d4e81 | ||
|
|
ee449e0ef2 | ||
|
|
dc3d97ce25 | ||
|
|
cab5f23234 | ||
|
|
2c5d1990ce | ||
|
|
ec3e4df6e3 | ||
|
|
a0f02b61ea | ||
|
|
7f2e64b603 | ||
|
|
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
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -89,6 +89,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,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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,13 +19,13 @@ pub use horizon_client_render::{
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
|
||||
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2018,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]>>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.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::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(())
|
||||
}
|
||||
/// ClientSuggestionProvider.customSuggestion: the chat's completion
|
||||
/// request for an `ask_server` node goes to the server.
|
||||
fn send_suggestion_request(&mut self) {
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -168,6 +172,7 @@ impl Game<'_> {
|
||||
self.cursor,
|
||||
&view,
|
||||
&mut self.social.search,
|
||||
&mut self.social.tooltip,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,8 @@
|
||||
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
|
||||
//! windows' icon. Its own module so it can leave the engine crate as a pure file move, for the
|
||||
//! official executable to call.
|
||||
|
||||
/// Give the engine Horizon's look; call it once, before the first window opens.
|
||||
pub(crate) fn give() {
|
||||
horizon_client::window::set_icon_png(horizon_theme::ICON_PNG);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
//! `optifine/sky/world<N>/sky<M>.properties` (`world0` the Overworld, `world1` the End; the Nether
|
||||
//! draws no sky), and the `sun` and `moon_phases` textures that replace the vanilla ones.
|
||||
//!
|
||||
//! A layer is a skybox (one texture, six faces in a 3x2 grid, [`Face`]) drawn on top of the
|
||||
//! previous layer (or the vanilla sky) with a blend ([`Blend`]), at a brightness its fade times
|
||||
//! give for the time of day ([`Fade`]), on the days, in the weather, biomes and heights it names
|
||||
//! ([`SkyLayer::shows`]), and turning with the time of day around its axis ([`SkyLayer::turns`]).
|
||||
//! A layer is a skybox (one texture, six faces in a 3x2 grid: bottom, top and south over west,
|
||||
//! north and east, the renderer's custom sky layout) drawn on top of the previous layer (or the
|
||||
//! vanilla sky) with a blend ([`Blend`]), at a brightness its fade times give for the time of day
|
||||
//! ([`Fade`]), on the days, in the weather, biomes and heights it names ([`SkyLayer::shows`]), and
|
||||
//! turning with the time of day around its axis ([`SkyLayer::turns`]).
|
||||
//! Everything here is read once at load; a frame evaluates a few numbers per layer.
|
||||
//!
|
||||
//! Written from OptiFine's public documentation of the format (`OptiFineDoc/doc/sky.properties`
|
||||
@@ -354,76 +355,6 @@ impl Transition {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub enum Face {
|
||||
Bottom,
|
||||
Top,
|
||||
South,
|
||||
West,
|
||||
North,
|
||||
East,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
pub 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.
|
||||
pub 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).
|
||||
pub 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.],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Skies {
|
||||
/// Every `sky/world<N>/` of the pack under `root`. Layers draw in the order of their number
|
||||
/// (`sky1` before `sky10`); a file that fails to parse is skipped with a warning.
|
||||
@@ -673,38 +604,6 @@ mod tests {
|
||||
assert_eq!(t.step(0.2, 0.0, 0.0), 0.2);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reads_worlds_in_number_order_with_bodies() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sky-{}", std::process::id()));
|
||||
|
||||
@@ -16,6 +16,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
|
||||
- [Velocity forwarding](velocity-forwarding.md): the player-information exchange behind a Velocity proxy and how it was checked.
|
||||
- [The anticheat](anticheat.md): the dedicated server's own checks, off unless `server.toml`'s `[anticheat]` turns them on: violation levels, setback, alerts, kicks, the log, and how to write a check.
|
||||
- [The chat filter](chat-filter.md): the dedicated server refuses hate slurs in chat, message commands, signs, anvil names and book titles: the list and its normalising, mutes, temporary bans, `[chat_filter]`.
|
||||
- [Nix and NixOS](nix.md): the flake's packages: server, engine, prepare, client and launcher.
|
||||
- [Hosting a server: design](server.md): `horizon-server`, the design and the plan: install, `server.toml`, the CLI, the web panel, backups, services, Docker, and the bot benchmark against vanilla.
|
||||
- [The next server: design](server-next.md): Horizon's dedicated server against vanilla and Pumpkin (measured), the targets, parallel ticking that keeps vanilla's order, the network front, the Horizon fast path, the plugin API and the plan.
|
||||
- [Survival multiplayer](smp.md): the official server's `smp` native mod (economy, market, trades, games of chance, spawn, homes, teleport requests, warps, land claims) and the server hook points it needs; not part of Horizon's own server.
|
||||
|
||||
@@ -66,6 +66,8 @@ Options: `--version vX.Y.Z`, `--dir FOLDER`, `--bin FOLDER` (Windows: `-Version`
|
||||
such as Linux on ARM, build from source: `cargo build --release -p horizon-client --bin horizon-client -p
|
||||
horizon-server` and keep both programs in one folder.
|
||||
|
||||
On Nix and NixOS, the repository's flake builds `horizon-server` from source: [nix.md](nix.md).
|
||||
|
||||
The Horizon launcher also installs releases. Every release folder holds `horizon-server`, so you can run it
|
||||
from there too.
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Nix and NixOS
|
||||
|
||||
The repository is a flake (`flake.nix`, and the game's packages in `nix/`). It builds Horizon from source with nixpkgs'
|
||||
`rustPlatform` and holds none of Mojang's files: the game files still come from `horizon-prepare` and your own
|
||||
copy of Minecraft 26.3, as everywhere else ([hosting.md](hosting.md), step 2).
|
||||
|
||||
| Package | What | Systems |
|
||||
|---|---|---|
|
||||
| `horizon-server` | `horizon-server`, with `horizon-engine` (the game without sound or controllers) beside it | Linux, macOS (Apple Silicon) |
|
||||
| `horizon-engine` | The server build of the game alone | Linux, macOS |
|
||||
| `horizon-prepare` | `horizon-prepare`, with a JDK 25 for the exporters it compiles | Linux, macOS |
|
||||
| `horizon-client` | The game and `horizon-ctl` | Linux, macOS |
|
||||
| `horizon-launcher` | The launcher in an FHS environment (it builds native mods with Cargo) | Linux |
|
||||
|
||||
```sh
|
||||
nix run github:horizon-rust/horizon#horizon-server -- --version
|
||||
nix profile install github:horizon-rust/horizon#horizon-server
|
||||
```
|
||||
|
||||
`overlays.default` adds them to `pkgs`; on Linux, `nix develop` gives the build environment of the workspace.
|
||||
|
||||
## Preparing the game files
|
||||
|
||||
`horizon-prepare` works in a writable folder (its pinned hashes, the exporters' sources, scratch files). The
|
||||
Nix wrapper copies one to `$HORIZON_PREPARE_ROOT`, by default `~/.local/share/horizon-nix/prepare`, unless you
|
||||
pass `--root`. Write the files where you want them with `--output`:
|
||||
|
||||
```sh
|
||||
horizon-prepare --prepare --server-jar ~/Downloads/server.jar --output ~/horizon/26.3
|
||||
```
|
||||
@@ -7,10 +7,26 @@
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
linuxSystems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
systems = linuxSystems ++ [ "aarch64-darwin" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
forLinuxSystems = f: nixpkgs.lib.genAttrs linuxSystems (system: f nixpkgs.legacyPackages.${system});
|
||||
|
||||
# The game's packages, built from this workspace (nix/, docs/nix.md). Releases are tagged by
|
||||
# release.yml, which never uses this flake, so a build from it says which commit it is.
|
||||
version = "0-unstable-${builtins.substring 0 8 (self.lastModifiedDate or "19700101")}";
|
||||
gamePackages = pkgs:
|
||||
let
|
||||
call = pkgs.lib.callPackageWith (pkgs // packages // { inherit version; });
|
||||
packages = {
|
||||
horizon-engine = call ./nix/horizon-engine.nix { };
|
||||
horizon-server = call ./nix/horizon-server.nix { };
|
||||
horizon-prepare = call ./nix/horizon-prepare.nix { };
|
||||
horizon-client = call ./nix/horizon-client.nix { };
|
||||
};
|
||||
in packages;
|
||||
in {
|
||||
packages = forAllSystems (pkgs:
|
||||
packages = forAllSystems (pkgs: gamePackages pkgs // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux (
|
||||
let
|
||||
version = "0.2.0";
|
||||
pname = "horizon-launcher";
|
||||
@@ -127,14 +143,18 @@
|
||||
in {
|
||||
horizon-launcher = horizonLauncher;
|
||||
default = horizonLauncher;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
overlays.default = final: prev: {
|
||||
horizon-launcher = self.packages.${final.stdenv.hostPlatform.system}.horizon-launcher;
|
||||
};
|
||||
} // gamePackages final;
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
checks = forAllSystems (pkgs: {
|
||||
inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) horizon-server horizon-prepare;
|
||||
});
|
||||
|
||||
devShells = forLinuxSystems (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
coreutils
|
||||
|
||||
+252
-252
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,67 @@
|
||||
# The game (horizon-client, with sound, controllers and Horizon's extras) and horizon-ctl, the tool
|
||||
# that drives it (docs/agent-control.md). Start it with `horizon-client --resources DIR`, DIR being
|
||||
# what horizon-prepare wrote.
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
rustPlatform,
|
||||
pkg-config,
|
||||
callPackage,
|
||||
alsa-lib,
|
||||
udev,
|
||||
vulkan-loader,
|
||||
wayland,
|
||||
libxkbcommon,
|
||||
libx11,
|
||||
libxcursor,
|
||||
libxi,
|
||||
libxrandr,
|
||||
libxcb,
|
||||
version,
|
||||
}:
|
||||
let
|
||||
# wgpu and winit open these with dlopen, so the linker never records them.
|
||||
dlopened = [
|
||||
vulkan-loader
|
||||
wayland
|
||||
libxkbcommon
|
||||
libx11
|
||||
libxcursor
|
||||
libxi
|
||||
libxrandr
|
||||
libxcb
|
||||
];
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "horizon-client";
|
||||
inherit version;
|
||||
src = callPackage ./source.nix { };
|
||||
cargoLock.lockFile = ../Cargo.lock;
|
||||
|
||||
cargoBuildFlags = [
|
||||
"--package=horizon-client"
|
||||
"--bin=horizon-client"
|
||||
"--package=horizon-client-lib"
|
||||
"--bin=horizon-ctl"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
alsa-lib
|
||||
udev
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
patchelf --add-rpath ${lib.makeLibraryPath dlopened} $out/bin/horizon-client
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Horizon, Minecraft: Java Edition 26.3 rewritten in Rust, and horizon-ctl";
|
||||
homepage = "https://playhorizon.dev";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "horizon-client";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# The server build of the game (`horizon-client --no-default-features`): the game without sound,
|
||||
# controllers or Horizon's online extras, so it links neither ALSA nor udev. horizon-server runs it
|
||||
# headless as `horizon-engine` (docs/hosting.md, packaging/server/Dockerfile).
|
||||
{
|
||||
lib,
|
||||
rustPlatform,
|
||||
pkg-config,
|
||||
callPackage,
|
||||
version,
|
||||
}:
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "horizon-engine";
|
||||
inherit version;
|
||||
src = callPackage ./source.nix { };
|
||||
cargoLock.lockFile = ../Cargo.lock;
|
||||
|
||||
cargoBuildFlags = [
|
||||
"--package=horizon-client"
|
||||
"--bin=horizon-client"
|
||||
];
|
||||
buildNoDefaultFeatures = true;
|
||||
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
|
||||
# The game's tests read the prepared vanilla files in .local/, which a build never has.
|
||||
doCheck = false;
|
||||
|
||||
postInstall = ''
|
||||
mv $out/bin/horizon-client $out/bin/horizon-engine
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Horizon's headless game engine, run by horizon-server";
|
||||
homepage = "https://playhorizon.dev";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "horizon-engine";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# horizon-prepare: builds the game files Horizon reads from the player's own Minecraft 26.3 and the
|
||||
# server jar they downloaded themselves. Nothing of Mojang's is fetched or packaged here.
|
||||
#
|
||||
# It works in a "root": research-lock.json (the pinned jars' hashes), the exporters' Java sources it
|
||||
# compiles against those jars, and a writable .local/ for its scratch files. The store is read-only, so
|
||||
# the package keeps that root in share/horizon/prepare and bin/horizon-prepare (horizon-prepare.sh)
|
||||
# copies it somewhere writable unless --root is given. The exporters need a JDK of the pinned Java
|
||||
# version (25): the default JAVA_HOME.
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
rustPlatform,
|
||||
callPackage,
|
||||
coreutils,
|
||||
jdk25_headless,
|
||||
version,
|
||||
}:
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "horizon-prepare";
|
||||
inherit version;
|
||||
src = callPackage ./source.nix { };
|
||||
cargoLock.lockFile = ../Cargo.lock;
|
||||
|
||||
cargoBuildFlags = [
|
||||
"--package=horizon-prepare"
|
||||
"--bin=horizon-prepare"
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
postInstall = ''
|
||||
root=$out/share/horizon/prepare
|
||||
install -Dm444 research-lock.json -t $root
|
||||
install -Dm444 crates/horizon-prepare/src/exporters.rs -t $root/crates/horizon-prepare/src
|
||||
mkdir -p $root/mods/horizon/src/test
|
||||
cp -r mods/horizon/src/test/java $root/mods/horizon/src/test/
|
||||
|
||||
mkdir -p $out/libexec
|
||||
mv $out/bin/horizon-prepare $out/libexec/horizon-prepare
|
||||
substitute ${./horizon-prepare.sh} $out/bin/horizon-prepare \
|
||||
--subst-var out \
|
||||
--subst-var-by shell ${stdenv.shell} \
|
||||
--subst-var-by coreutils ${coreutils} \
|
||||
--subst-var-by java ${jdk25_headless}
|
||||
chmod +x $out/bin/horizon-prepare
|
||||
'';
|
||||
|
||||
passthru.java = jdk25_headless;
|
||||
|
||||
meta = {
|
||||
description = "Prepare Horizon's game files from your own copy of Minecraft: Java Edition 26.3";
|
||||
homepage = "https://playhorizon.dev";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "horizon-prepare";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!@shell@
|
||||
# horizon-prepare from Nix: the program wants a writable root (research-lock.json, the exporters'
|
||||
# sources, .local/ for its scratch files), which the store can't be. Unless --root is given, a copy of
|
||||
# the packaged root goes to $HORIZON_PREPARE_ROOT, by default ${XDG_DATA_HOME:-~/.local/share}/horizon-nix/prepare.
|
||||
set -eu
|
||||
export JAVA_HOME="${JAVA_HOME:-@java@}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--root | --root=*) exec @out@/libexec/horizon-prepare "$@" ;;
|
||||
esac
|
||||
done
|
||||
root="${HORIZON_PREPARE_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/horizon-nix/prepare}"
|
||||
@coreutils@/bin/mkdir -p "$root"
|
||||
@coreutils@/bin/cp -R --no-preserve=mode @out@/share/horizon/prepare/. "$root/"
|
||||
exec @out@/libexec/horizon-prepare --root "$root" "$@"
|
||||
@@ -0,0 +1,43 @@
|
||||
# horizon-server: server.toml, the CLI and the supervisor. It finds the game beside itself, so the
|
||||
# engine is linked into the same bin/ (crates/horizon-server/src/engine.rs `program_in`).
|
||||
{
|
||||
lib,
|
||||
rustPlatform,
|
||||
pkg-config,
|
||||
callPackage,
|
||||
horizon-engine,
|
||||
version,
|
||||
}:
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "horizon-server";
|
||||
inherit version;
|
||||
src = callPackage ./source.nix { };
|
||||
cargoLock.lockFile = ../Cargo.lock;
|
||||
|
||||
cargoBuildFlags = [
|
||||
"--package=horizon-server"
|
||||
"--bin=horizon-server"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
postInstall = ''
|
||||
ln -s ${lib.getExe horizon-engine} $out/bin/horizon-engine
|
||||
'';
|
||||
|
||||
passthru.engine = horizon-engine;
|
||||
|
||||
meta = {
|
||||
description = "Host a Minecraft: Java Edition 26.3 server with Horizon, the game rewritten in Rust";
|
||||
longDescription = ''
|
||||
Holds none of Mojang's files: the server reads the game files you prepare from your own copy of
|
||||
Minecraft 26.3 with horizon-prepare (docs/hosting.md).
|
||||
'';
|
||||
homepage = "https://playhorizon.dev";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "horizon-server";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# The game workspace's sources: everything a `cargo build` at the root reads, without the folders it never
|
||||
# does (the launcher is its own workspace), so editing those rebuilds nothing.
|
||||
{ lib }:
|
||||
let
|
||||
root = ../.;
|
||||
skipped = [
|
||||
".github"
|
||||
".githooks"
|
||||
".claude"
|
||||
".local"
|
||||
"benchmarks"
|
||||
"launcher"
|
||||
"nix"
|
||||
"target"
|
||||
"flake.nix"
|
||||
"flake.lock"
|
||||
];
|
||||
in
|
||||
lib.cleanSourceWith {
|
||||
name = "horizon-source";
|
||||
src = lib.cleanSource root;
|
||||
filter =
|
||||
path: _type:
|
||||
let
|
||||
relative = lib.removePrefix (toString root + "/") (toString path);
|
||||
in
|
||||
!(lib.any (top: relative == top || lib.hasPrefix (top + "/") relative) skipped)
|
||||
# The pages of docs/ (the code reads only some of its data files).
|
||||
&& !(lib.hasPrefix "docs/" relative && lib.hasSuffix ".md" relative);
|
||||
}
|
||||
@@ -6838,6 +6838,27 @@ def living_short_timers():
|
||||
return ops + setup('/tick unfreeze', f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=32,dz=16]',
|
||||
f'/forceload remove {x0} {z0}')
|
||||
|
||||
def command_block_last_output():
|
||||
"""#13077: `BaseCommandBlock` keeps the feedback it got as a component (stamped with the time:
|
||||
`Component.literal("[HH:mm:ss] ").append(message)`) and saves it with
|
||||
`ComponentSerialization.CODEC`: a failed command keeps its red, click-to-suggest error."""
|
||||
x0, y0, z0 = 3904, 260, 3648
|
||||
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
|
||||
cmd('/scoreboard objectives add cb_out dummy', False)]
|
||||
# Two failures, whose styled feedback (red, click to suggest the command) is what the save must keep.
|
||||
# A success's feedback names an entity, a new score holder or a bracketed value, whose components
|
||||
# the two engines build differently (or which change later groups' scoreboard lists).
|
||||
commands = ['say', 'scoreboard players get #nobody cb_out']
|
||||
at = [(x0 + 2 + 3 * n, y0, z0 + 4) for n in range(len(commands))]
|
||||
for command, (x, y, z) in zip(commands, at):
|
||||
ops.append(cmd(f'/setblock {x} {y} {z} minecraft:command_block{{Command:"{command}"}}', expect_result=1))
|
||||
ops.append(cmd(f'/setblock {x} {y + 1} {z} minecraft:redstone_block', expect_result=1))
|
||||
ops.append({'op': 'tick', 'n': 3})
|
||||
for x, y, z in at:
|
||||
ops.append(cmd(f'/data get block {x} {y} {z} LastOutput'))
|
||||
return ops + setup(f'/fill {x0} {y0} {z0} {x0 + 15} {y0 + 1} {z0 + 15} air',
|
||||
'/scoreboard objectives remove cb_out', f'/forceload remove {x0} {z0}')
|
||||
|
||||
def loaded_ticket_removal(blocks=False):
|
||||
"""Entity-ticking command visibility after forced tickets leave; FULL is separate."""
|
||||
targets = [(d, x, z) for d in range(2, 7)
|
||||
@@ -7421,6 +7442,7 @@ def groups():
|
||||
('piston entity tick order', piston_entity_tick_order()),
|
||||
('primed tnt box', primed_tnt_box()),
|
||||
('primed tnt fall', primed_tnt_fall()),
|
||||
('command block last output', command_block_last_output()),
|
||||
('living short timers', living_short_timers()),
|
||||
('projectile fall distance', projectile_fall_distance()),
|
||||
('item fall distance', item_fall_distance()),
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
"horizon-client -> horizon-shaders",
|
||||
"horizon-client -> horizon-telemetry",
|
||||
"horizon-client -> horizon-theme",
|
||||
"horizon-client-input -> horizon-theme",
|
||||
"horizon-client-lib -> horizon-anticheat",
|
||||
"horizon-client-lib -> horizon-bench",
|
||||
"horizon-client-lib -> horizon-chat-filter",
|
||||
|
||||
@@ -142,7 +142,7 @@ AbstractTextAreaWidget.extractBackground skip:ui frame drawn by the book screen
|
||||
AbstractTextAreaWidget.extractBorder skip:ui frame sprite; no behaviour
|
||||
AbstractTextAreaWidget.withinContentAreaTopBottom skip:ui visibility cull while drawing lines
|
||||
AbstractWidget.extractRenderState port crates/horizon-client-render/src/ui.rs::hovered gap: minor: the scissor test is not part of the hover check
|
||||
AbstractWidget.extractTooltipForNextRenderPass port crates/horizon-client-render/src/ui.rs::tooltip gap: minor: unsure: widget hover delay not checked
|
||||
AbstractWidget.extractTooltipForNextRenderPass port crates/horizon-client-render/src/ui.rs::tooltip widgets with a delay wait it out: the social rows' buttons use social_screen::TooltipDelay (PlayerEntry.TOOLTIP_DELAY, 500 ms); others have vanilla's zero delay
|
||||
AbstractWidget.extractScrollingStringOverContents port crates/horizon-client-render/src/ui.rs::scrolling_text ui::button and ui::slider (margin 2) draw it for the HudBatch widgets; screen_draw.rs::scrolling_string_over_contents for the screen_draw ones
|
||||
AbstractWidget.mouseClicked port crates/horizon-client/src/title.rs::Title::click gap: minor: click dispatch is per screen; the press sound is audio.rs Audio::play_button_click
|
||||
AbstractWidget.mouseReleased port crates/horizon-client-render/src/ui.rs::Scroll::release gap: minor: only the scroll release; widget onRelease is per screen
|
||||
|
||||
|
@@ -105,6 +105,7 @@ EditBox.setMaxLength port crates/horizon-client-render/src/ui.rs::Caret::sync ga
|
||||
EditBox.setFocused port crates/horizon-client-render/src/ui.rs::Caret::draw focus restarts the blink clock here; gap: minor: no IME or narration side effects
|
||||
EditBox.setEditable missing minor: unsure: no read-only flag on the box in ui.rs; a box vanilla locks would accept typing
|
||||
EditBox.setHighlightPos port crates/horizon-client-render/src/ui.rs::EditBox::set_cursor sets the highlight and scrolls
|
||||
EditBox.setCentered port crates/horizon-client-render/src/ui.rs::EditBox::text_start_x the text and a click start (width - text width) / 2 in; BookSignScreen's title box sets it
|
||||
EditBox.setHint inline crates/horizon-client/src/title/create_world.rs::Title::create_field the hint is passed when the box is made and drawn while it is empty and unfocused
|
||||
FittingMultiLineTextWidget.setWidth skip:ui layout: wrap width
|
||||
FittingMultiLineTextWidget.minimizeHeight skip:ui layout: height to content
|
||||
|
||||
|
@@ -2,7 +2,7 @@ WinScreen.wrapCreditsIO port crates/horizon-client/src/win_screen.rs::WinScreen:
|
||||
WinScreen.extractRenderState port crates/horizon-client/src/win_screen.rs::WinScreen::draw logo, lines scrolled up, last line stops mid-screen; background and vignette are drawn here too
|
||||
WinScreen.extractVignette inline crates/horizon-client/src/win_screen.rs::WinScreen::draw credits_vignette sprite stretched over the screen
|
||||
WinScreen.extractMenuBackground port crates/horizon-client/src/win_screen.rs::WinScreen::menu_background menu background tiled and scrolled at half the text speed
|
||||
WorldOptionsScreen.init port crates/horizon-client/src/game/lan_screen.rs::Game::open_world_options reads difficulty, lock, LAN port and command/force settings when opened; layout is WorldOptions::buttons
|
||||
WorldOptionsScreen.init port crates/horizon-client/src/game/lan_screen.rs::Game::open_world_options reads difficulty, lock, LAN port and command/force settings when opened; layout is WorldOptions::buttons; the port box's PORT_INFO_TEXT tooltip is WorldOptions::port_tooltip
|
||||
WorldOptionsScreen.createGameRulesButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click GameRules arm opens the rules view; enabled state is in WorldOptions::active
|
||||
WorldOptionsScreen.createGameModeButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click Default and Personal mode arms cycle the mode; enabled state is in WorldOptions::active
|
||||
WorldOptionsScreen.createAllowCommandsButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click Cheats arm toggles commands; the hardcore lock is in WorldOptions::active
|
||||
|
||||
|
@@ -59,7 +59,7 @@ ReportReasonSelectionScreen skip:service abuse report reason screen; Horizon ha
|
||||
ReportReasonSelectionScreen.ReasonSelectionList skip:service reason list of the report screen; Horizon has no reporting
|
||||
ReportReasonSelectionScreen.ReasonSelectionList.Entry skip:service reason row of the report screen; Horizon has no reporting
|
||||
SkinReportScreen skip:service skin abuse report screen; Horizon has no reporting
|
||||
PlayerEntry skip:service social list row with report, hide and status; Horizon has no social list
|
||||
PlayerEntry port crates/horizon-client/src/social_screen.rs::Entry the row and its report (disabled) and hide/show buttons; the list, pages and search are social_screen's SocialView/draw, the state is game/social.rs::Social; the buttons' 500 ms tooltip delay is social_screen::TooltipDelay
|
||||
PlayerSocialManager skip:service Mojang social manager: block, friends and message hiding; Horizon has none of it
|
||||
PresenceHandler skip:service online presence sharing with the service; Horizon has none
|
||||
RemoteFriendListUpdateHandler skip:service friend list polling and toasts from the service; Horizon has no friend list (its relay is separate)
|
||||
|
@@ -727,6 +727,8 @@ MASKS = [
|
||||
(re.compile(r'Average time per tick: [0-9.]+ms'), 'Average time per tick: <ms>'),
|
||||
(re.compile(r'P50: [0-9.]+ms P95: [0-9.]+ms P99: [0-9.]+ms'), 'P50: <ms> P95: <ms> P99: <ms>'),
|
||||
(re.compile(r'debug/client-[0-9_.-]+\.(jfr|json)'), 'debug/client-<time>'),
|
||||
# BaseCommandBlock.sendSystemMessage stamps the last output with the wall clock.
|
||||
(re.compile(r'\[\d\d:\d\d:\d\d\] '), '[<time>] '),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user