mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
9
Commits
main
...
union/cs2-b9-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d161337d0 | ||
|
|
52011a52f0 | ||
|
|
66fa994370 | ||
|
|
e3791b7931 | ||
|
|
ca7692cf15 | ||
|
|
a1542cb263 | ||
|
|
8b94f011c6 | ||
|
|
4564f7f0e6 | ||
|
|
380d5ebe9f |
No files matched your search
Generated
+2
-2
@@ -1378,7 +1378,6 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"flate2",
|
||||
"gilrs",
|
||||
"glam",
|
||||
@@ -1413,7 +1412,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
@@ -1449,6 +1447,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"flate2",
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
@@ -1475,6 +1474,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
|
||||
@@ -7,6 +7,9 @@ rust-version = "1.90"
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
@@ -51,6 +54,8 @@ wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
# Sound output (horizon-client's `audio` feature).
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
|
||||
type Stream = std::convert::Infallible;
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
@@ -543,15 +544,47 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
/// Available audio output device names.
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
if output_disabled(|name| std::env::var_os(name)).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut names = Vec::new();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for device in devices {
|
||||
if let Ok(name) = device.name()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
Self::with_device(root, "")
|
||||
}
|
||||
pub fn with_device(root: &Path, device_name: &str) -> Self {
|
||||
Self::with_output(root, true, device_name)
|
||||
}
|
||||
/// No output device: the dedicated server.
|
||||
pub fn silent(root: &Path) -> Self {
|
||||
Self::with_output(root, false)
|
||||
Self::with_output(root, false, "")
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
/// Active audio device name, or empty for system default.
|
||||
pub fn device(&self) -> &str {
|
||||
&self.device_name
|
||||
}
|
||||
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
if output && registry.is_empty() {
|
||||
// The game then plays nothing on a working output (#1035, #1076).
|
||||
@@ -564,23 +597,24 @@ impl Audio {
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let opened = if output {
|
||||
Self::open(mixer.clone())
|
||||
Self::open_named(mixer.clone(), target_device)
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
let (stream, live) = match opened {
|
||||
Ok(v) => (Some(v), true),
|
||||
let (stream, live, actual_device) = match opened {
|
||||
Ok((v, dev)) => (Some(v), true, dev),
|
||||
Err(e) => {
|
||||
if output {
|
||||
log::warn!("Horizon: no sound: {e}");
|
||||
}
|
||||
(None, false)
|
||||
(None, false, String::new())
|
||||
}
|
||||
};
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: actual_device,
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
@@ -602,6 +636,63 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<(Stream, String)> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<(Stream, String)> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
if !target.is_empty()
|
||||
&& let Ok(devices) = host.output_devices()
|
||||
{
|
||||
for device in devices {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if name == target {
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok((stream, target.to_owned()));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: could not open audio device '{target}': {e}; falling back to default"
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let stream = Self::open(mixer)?;
|
||||
Ok((stream, String::new()))
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
|
||||
self.device_name = name.to_owned();
|
||||
return Ok(());
|
||||
}
|
||||
let (stream, actual) = Self::open_named(self.backend.mixer.clone(), name)?;
|
||||
self._stream = Some(stream);
|
||||
self.device_name = actual;
|
||||
self.backend.live = true;
|
||||
self.engine.loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
self.device_name = name.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
@@ -574,6 +574,9 @@ pub struct Renderer {
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Write the next frame's centre square as the world's 64x64 `icon.png`
|
||||
/// (GameRenderer.takeAutoScreenshot).
|
||||
pub world_icon: Option<PathBuf>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
pub gamma: f32,
|
||||
/// Optional full-light lightmap (Horizon Extras).
|
||||
@@ -1128,7 +1131,9 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
let output = hdr::output(&caps);
|
||||
crate::graphics::offer_hdr(output.is_some());
|
||||
(config, caps.present_modes.clone(), output)
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -2669,6 +2674,7 @@ impl Renderer {
|
||||
started: Instant::now(),
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
world_icon: None,
|
||||
gamma: 0.5,
|
||||
fullbright: false,
|
||||
animated_textures: true,
|
||||
@@ -5113,6 +5119,18 @@ impl Renderer {
|
||||
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
// GameRenderer.tryTakeScreenshotIfNeeded runs after the level (hand and
|
||||
// screen effects included) and before the entity outline and the post
|
||||
// chain: the icon is the plain scene, with no GUI either.
|
||||
let eight_bit = self.config.format.block_copy_size(None) == Some(4);
|
||||
let icon = self.world_icon.take().and_then(|path| {
|
||||
if eight_bit {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
log::warn!("Couldn't save auto screenshot: no 32-bit surface format");
|
||||
None
|
||||
}
|
||||
});
|
||||
// GameRenderer.render3dHud: the debug crosshair after the hand and
|
||||
// the screen effects.
|
||||
if crosshair > 0 {
|
||||
@@ -5192,7 +5210,7 @@ impl Renderer {
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
if eight_bit {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
@@ -5233,6 +5251,11 @@ impl Renderer {
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some((path, buffer)) = icon
|
||||
&& let Err(e) = self.write_world_icon(&path, &buffer)
|
||||
{
|
||||
log::warn!("Couldn't save auto screenshot: {e}");
|
||||
}
|
||||
let present = Instant::now();
|
||||
if let Some(surface_frame) = surface_frame {
|
||||
// On Wayland this ties RedrawRequested to the compositor's frame
|
||||
@@ -5313,6 +5336,16 @@ impl Renderer {
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
let (width, height, pixels) = self.read_frame(buffer)?;
|
||||
write_png(path, width, height, &pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// GameRenderer.takeAutoScreenshot: the centre square, scaled to 64x64.
|
||||
fn write_world_icon(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let (width, height, pixels) = self.read_frame(buffer)?;
|
||||
write_png(path, 64, 64, &world_icon_pixels(&pixels, width, height))
|
||||
}
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<(u32, u32, Vec<u8>)> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
}
|
||||
@@ -5350,17 +5383,51 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
Ok((width, height, pixels))
|
||||
}
|
||||
}
|
||||
fn write_png(path: &std::path::Path, width: u32, height: u32, pixels: &[u8]) -> Result<()> {
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
/// `NativeImage.resizeSubRectTo` from the screen's centre to 64x64. Vanilla
|
||||
/// resamples with STB; this averages each destination texel's source block,
|
||||
/// which differs by a texel where a block does not divide evenly.
|
||||
fn world_icon_pixels(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
|
||||
let side = width.min(height) as usize;
|
||||
if side == 0 {
|
||||
return vec![0; 64 * 64 * 4];
|
||||
}
|
||||
let (left, top) = ((width as usize - side) / 2, (height as usize - side) / 2);
|
||||
let mut out = Vec::with_capacity(64 * 64 * 4);
|
||||
for y in 0..64 {
|
||||
let y0 = y * side / 64;
|
||||
let y1 = ((y + 1) * side).div_ceil(64).max(y0 + 1).min(side);
|
||||
for x in 0..64 {
|
||||
let x0 = x * side / 64;
|
||||
let x1 = ((x + 1) * side).div_ceil(64).max(x0 + 1).min(side);
|
||||
let mut sum = [0u32; 4];
|
||||
for sy in y0..y1 {
|
||||
for sx in x0..x1 {
|
||||
let i = ((top + sy) * width as usize + left + sx) * 4;
|
||||
for c in 0..4 {
|
||||
sum[c] += u32::from(pixels[i + c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = ((y1 - y0) * (x1 - x0)) as u32;
|
||||
out.extend(sum.map(|c| (c / n) as u8));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -5452,6 +5519,32 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn world_icon_crops_the_centre_and_scales_to_64() {
|
||||
use super::world_icon_pixels;
|
||||
// 128x64, red left half and blue right half: the centre square is
|
||||
// 64x64 at x=32, so it keeps half of each colour.
|
||||
let mut pixels = Vec::with_capacity(128 * 64 * 4);
|
||||
for _ in 0..128 * 64 {
|
||||
pixels.extend_from_slice(&[255, 0, 0, 255]);
|
||||
}
|
||||
for y in 0..64 {
|
||||
for x in 64..128 {
|
||||
let i = (y * 128 + x) * 4;
|
||||
pixels[i..i + 4].copy_from_slice(&[0, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
let icon = world_icon_pixels(&pixels, 128, 64);
|
||||
assert_eq!(icon.len(), 64 * 64 * 4);
|
||||
assert_eq!(&icon[..4], [255, 0, 0, 255]);
|
||||
let last = (63 * 64 + 63) * 4;
|
||||
assert_eq!(&icon[last..last + 4], [0, 0, 255, 255]);
|
||||
// A source smaller than the icon repeats its texels rather than
|
||||
// indexing past them.
|
||||
let tiny = world_icon_pixels(&[1, 2, 3, 255], 1, 1);
|
||||
assert!(tiny.as_chunks::<4>().0.iter().all(|p| p == &[1, 2, 3, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
|
||||
@@ -210,6 +210,16 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
pub fn offer_hdr(on: bool) {
|
||||
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
|
||||
@@ -130,8 +130,9 @@ impl Style {
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's graphics settings screens, Simple and Advanced: which settings
|
||||
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
@@ -14,6 +14,8 @@ use crate::{
|
||||
pub enum Level {
|
||||
Simple,
|
||||
Advanced,
|
||||
/// HDR output's own settings, opened from Advanced while HDR is on.
|
||||
Hdr,
|
||||
}
|
||||
|
||||
/// Horizon's own settings (`graphics::Graphics`).
|
||||
@@ -37,6 +39,8 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrPaperWhite,
|
||||
HdrPeak,
|
||||
BatteryMode,
|
||||
@@ -119,6 +123,24 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Hdr => vec![
|
||||
Row::Header("HDR"),
|
||||
h(
|
||||
H::Hdr,
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"How bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -224,20 +246,10 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"FXAA smooths jagged edges of the world; slightly softer image.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
H::HdrSettings,
|
||||
None,
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -385,6 +397,7 @@ impl H {
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
@@ -471,6 +484,25 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's screen opens only where
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(H::HdrPaperWhite | H::HdrPeak) if !s.horizon.hdr => {
|
||||
Some("Turn HDR on to use it.")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
@@ -572,6 +604,7 @@ pub enum Action {
|
||||
Changed,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -649,6 +682,17 @@ impl GraphicsUi {
|
||||
let other = match level {
|
||||
Level::Simple => Action::OpenAdvanced,
|
||||
Level::Advanced => Action::OpenSimple,
|
||||
// One Done, back to Advanced.
|
||||
Level::Hdr => {
|
||||
widgets.push(Widget {
|
||||
x: cx - 100.0,
|
||||
y: by,
|
||||
w: 200.0,
|
||||
target: Target::Button(Action::Done),
|
||||
list: false,
|
||||
});
|
||||
return (widgets, headers, content);
|
||||
}
|
||||
};
|
||||
widgets.push(Widget {
|
||||
x: cx - 155.0,
|
||||
@@ -710,12 +754,10 @@ impl GraphicsUi {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let live = item.available();
|
||||
if hover && !live {
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -725,6 +767,7 @@ impl GraphicsUi {
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -774,6 +817,7 @@ impl GraphicsUi {
|
||||
let title = match level {
|
||||
Level::Simple => "Graphics",
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
if let Some(lines) = tooltip {
|
||||
@@ -817,7 +861,10 @@ impl GraphicsUi {
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if !item.available() => Vec::new(),
|
||||
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -847,6 +894,47 @@ impl GraphicsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_settings_open_where_hdr_is_offered() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 2000.0);
|
||||
let at = |ui: &mut GraphicsUi, level, item| {
|
||||
let (widgets, _, _) = ui.layout(level, size.0, size.1);
|
||||
let w = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
(w.x + 1.0, w.y + 1.0)
|
||||
};
|
||||
let button = at(&mut ui, Level::Advanced, Item::Horizon(H::HdrSettings));
|
||||
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
|
||||
ui.click(level, s, point, size, [480, 2000], false, false)
|
||||
};
|
||||
crate::graphics::offer_hdr(false);
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
// Not even with HDR saved on, from another computer.
|
||||
s.horizon.hdr = true;
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
s.horizon.hdr = false;
|
||||
crate::graphics::offer_hdr(true);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Advanced, button),
|
||||
[Action::ButtonClick, Action::OpenHdr]
|
||||
);
|
||||
// On HDR's screen, the sliders wait for the switch above them.
|
||||
let peak = at(&mut ui, Level::Hdr, Item::Horizon(H::HdrPeak));
|
||||
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
|
||||
s.horizon.hdr = true;
|
||||
assert_eq!(click(&mut ui, &mut s, Level::Hdr, peak), [Action::Changed]);
|
||||
let on = |level, item| {
|
||||
extra_rows(level)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
|
||||
};
|
||||
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
|
||||
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
|
||||
}
|
||||
#[test]
|
||||
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -1107,6 +1107,33 @@ impl HudBatch {
|
||||
}
|
||||
cursor - x
|
||||
}
|
||||
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
|
||||
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
|
||||
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
|
||||
/// shadow), over `background` (RGBA) unless its alpha is 0. Returns how
|
||||
/// many vertices at the start of the batch are the background.
|
||||
pub fn name_tag(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
y: f32,
|
||||
color: u32,
|
||||
background: u32,
|
||||
) -> usize {
|
||||
let x = -atlas.font.width_text(text) / 2.0;
|
||||
let start = self.vertices.len();
|
||||
let advance = self.text_styled(atlas, text, x, y, color, false);
|
||||
if background >> 24 == 0 {
|
||||
return 0;
|
||||
}
|
||||
// PreparedTextBuilder.markBackground: one pixel round the advances,
|
||||
// from a pixel above the line to 9 below its top.
|
||||
let glyphs = self.vertices.split_off(start);
|
||||
self.fill(atlas, x - 1.0, y - 1.0, advance + 1.0, 10.0, background);
|
||||
let behind = self.vertices.len() - start;
|
||||
self.vertices.extend(glyphs);
|
||||
behind
|
||||
}
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -1822,6 +1849,65 @@ mod tests {
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
|
||||
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
|
||||
// 'й' (height 12, ascent 10: the breve above the line), 7 wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-name-tag-{}", std::process::id()));
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
|
||||
fs::create_dir_all(&textures).unwrap();
|
||||
fs::write(
|
||||
root.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[
|
||||
{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["a"]},
|
||||
{"type":"bitmap","file":"minecraft:font/tall.png","height":12,"ascent":10,"chars":["й"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (name, (w, h), used) in [("a", (8, 8), 4), ("tall", (9, 12), 7)] {
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| [255, 255, 255, if i % w < used { 255 } else { 0 }])
|
||||
.collect();
|
||||
let file = fs::File::create(textures.join(format!("{name}.png"))).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let text = crate::text::Text::literal("aй").bold();
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let behind = batch.name_tag(&atlas, &text, 0.0, u32::MAX, 0x3F00_0000);
|
||||
// Each quad's box: x0, y0, x1, y1.
|
||||
let boxes: Vec<[f32; 4]> = batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| [q[0].pos[0], q[0].pos[1], q[2].pos[0], q[2].pos[1]])
|
||||
.collect();
|
||||
// Advances 5 and 8, plus 1 each for bold: Font.width 15, centred.
|
||||
let x = -7.5;
|
||||
// Each glyph twice (bold), the copy a pixel right; 'й' in its 9x12
|
||||
// box from 3 pixels above the line, each bold quad 0.1 larger.
|
||||
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
|
||||
let expected = [
|
||||
[x - 0.1, -0.1, x + 8.1, 8.1],
|
||||
[x + 0.9, -0.1, x + 9.1, 8.1],
|
||||
[x + 5.9, -3.1, x + 15.1, 9.1],
|
||||
[x + 6.9, -3.1, x + 16.1, 9.1],
|
||||
];
|
||||
for (got, want) in boxes[1..].iter().zip(expected) {
|
||||
assert!(near(*got, want), "{got:?} != {want:?}");
|
||||
}
|
||||
assert_eq!(boxes.len(), 1 + expected.len());
|
||||
// The background first: a pixel round the advances, 10 pixels tall.
|
||||
assert_eq!(behind, 6);
|
||||
assert_eq!(boxes[0], [x - 1.0, -1.0, x + 15.0, 9.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod audio;
|
||||
pub mod auth;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
@@ -65,6 +66,7 @@ pub mod settings;
|
||||
pub mod shader_pack_list;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
pub mod sprites;
|
||||
|
||||
@@ -128,6 +128,7 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::SoundDevice => "soundDevice",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -144,6 +145,7 @@ impl Opt {
|
||||
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
|
||||
Opt::AllowCursorChanges => "options.allowCursorChanges",
|
||||
Opt::Volume(_) => "soundCategory.master",
|
||||
Opt::SoundDevice => "options.audioDevice",
|
||||
Opt::GuiScale => "options.guiScale",
|
||||
Opt::ViewBobbing => "options.viewBobbing",
|
||||
Opt::Gamma => "options.gamma",
|
||||
@@ -527,6 +529,13 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
|
||||
Opt::HighContrast => on_off(s.high_contrast),
|
||||
Opt::ShowSubtitles => on_off(s.show_subtitles),
|
||||
Opt::SoundDevice => {
|
||||
if s.sound_device.is_empty() {
|
||||
tr("options.audioDevice.default")
|
||||
} else {
|
||||
Text::literal(&s.sound_device)
|
||||
}
|
||||
}
|
||||
Opt::MusicFrequency => tr([
|
||||
"options.music_frequency.default",
|
||||
"options.music_frequency.frequent",
|
||||
@@ -713,6 +722,23 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast = !s.high_contrast,
|
||||
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
|
||||
Opt::SoundDevice => {
|
||||
let devices = crate::audio::output_devices();
|
||||
let mut all = vec![String::new()];
|
||||
for d in devices {
|
||||
if !all.contains(&d) {
|
||||
all.push(d);
|
||||
}
|
||||
}
|
||||
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
|
||||
let count = all.len();
|
||||
let next_idx = if back {
|
||||
(current_idx + count - 1) % count
|
||||
} else {
|
||||
(current_idx + 1) % count
|
||||
};
|
||||
s.sound_device = all[next_idx].clone();
|
||||
}
|
||||
Opt::MusicFrequency => {
|
||||
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
@@ -844,6 +870,7 @@ pub enum Page {
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -1156,7 +1183,10 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
|
||||
if matches!(
|
||||
page,
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
@@ -1165,6 +1195,7 @@ impl OptionsUi {
|
||||
match page {
|
||||
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
|
||||
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
|
||||
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1184,7 +1215,7 @@ impl OptionsUi {
|
||||
Page::Mods => "Mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
@@ -1439,6 +1470,7 @@ impl OptionsUi {
|
||||
// SoundOptionsScreen: Music Frequency starts the next row (with
|
||||
// Music Toast, not in Horizon yet).
|
||||
rows.extend(pairs(&[Opt::MusicFrequency]));
|
||||
rows.push(Row::Big(Opt::SoundDevice));
|
||||
rows
|
||||
}
|
||||
Page::Controls => {
|
||||
@@ -2428,6 +2460,7 @@ impl OptionsUi {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -4272,4 +4305,29 @@ mod tests {
|
||||
ui.stack = vec![Page::Main, Page::Controls];
|
||||
assert!(pushes(&mut ui).contains(&Page::Controller));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_device_button_in_sound_page_and_cycles() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let rows = ui.list_rows(&s);
|
||||
assert!(
|
||||
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
|
||||
"Sound page should contain Row::Big(Opt::SoundDevice)"
|
||||
);
|
||||
assert_eq!(s.sound_device, "");
|
||||
let devices = crate::audio::output_devices();
|
||||
let count = 1 + devices.len();
|
||||
// Cycling forward 'count' times wraps around back to the initial selection ("")
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
// Cycling backward 'count' times also wraps around back to ""
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,8 @@ pub struct Settings {
|
||||
pub master_volume: f32,
|
||||
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
|
||||
pub volumes: [f32; 11],
|
||||
/// Empty for system default, or a specific audio device name.
|
||||
pub sound_device: String,
|
||||
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
|
||||
pub gui_scale: u32,
|
||||
pub view_bobbing: bool,
|
||||
@@ -194,6 +196,7 @@ impl Default for Settings {
|
||||
sensitivity: 0.5,
|
||||
master_volume: 1.0,
|
||||
volumes: [1.0; 11],
|
||||
sound_device: String::new(),
|
||||
gui_scale: 0,
|
||||
view_bobbing: true,
|
||||
gamma: 0.5,
|
||||
@@ -382,6 +385,7 @@ impl Settings {
|
||||
sensitivity: f.double("mouseSensitivity") as f32,
|
||||
master_volume: volumes[0],
|
||||
volumes,
|
||||
sound_device: f.string("soundDevice"),
|
||||
gui_scale: f.int("guiScale").max(0) as u32,
|
||||
view_bobbing: f.bool("bobView"),
|
||||
gamma: f.double("gamma") as f32,
|
||||
@@ -464,6 +468,7 @@ impl Settings {
|
||||
};
|
||||
put_f(&mut f, &format!("soundCategory_{source}"), v);
|
||||
}
|
||||
put_s(&mut f, "soundDevice", &self.sound_device);
|
||||
put_i(&mut f, "guiScale", self.gui_scale as i32);
|
||||
put_b(&mut f, "bobView", self.view_bobbing);
|
||||
put_f(&mut f, "gamma", self.gamma);
|
||||
@@ -626,6 +631,7 @@ pub enum Opt {
|
||||
InvertY,
|
||||
AllowCursorChanges,
|
||||
Volume(usize),
|
||||
SoundDevice,
|
||||
GuiScale,
|
||||
ViewBobbing,
|
||||
Gamma,
|
||||
@@ -800,4 +806,15 @@ mod tests {
|
||||
1.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sound_device_option() {
|
||||
let s = Settings::parse("soundDevice:\"Headphones\"\n");
|
||||
assert_eq!(s.sound_device, "Headphones");
|
||||
let serialized = s.serialize();
|
||||
assert!(serialized.contains("soundDevice:\"Headphones\""));
|
||||
let again = Settings::parse(&serialized);
|
||||
assert_eq!(again.sound_device, "Headphones");
|
||||
let empty = Settings::parse("");
|
||||
assert_eq!(empty.sound_device, "");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1231,7 +1231,7 @@ impl<B: Backend> Sounds for Bound<'_, B> {
|
||||
.update_category_volume(source, gain, self.backend);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_backend {
|
||||
use super::{Backend, ChannelId};
|
||||
use crate::sound_events::Sound;
|
||||
@@ -44,9 +44,6 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
@@ -91,7 +88,7 @@ winresource = "0.1"
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["dep:cpal"]
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"options.audioDevice": "Output Device",
|
||||
"options.audioDevice.default": "System Default"
|
||||
}
|
||||
@@ -11,5 +11,7 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"options.audioDevice": "Устройство вывода",
|
||||
"options.audioDevice.default": "По умолчанию"
|
||||
}
|
||||
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"graphics-hdr",
|
||||
"extras",
|
||||
"horizon",
|
||||
"agent",
|
||||
|
||||
@@ -1677,6 +1677,11 @@ struct Game<'a> {
|
||||
sleep_seen: (i64, i64),
|
||||
dim: Dim,
|
||||
world_root: PathBuf,
|
||||
/// The world's auto `icon.png` until it exists (GameRenderer's
|
||||
/// tryTakeScreenshotIfNeeded). Single player only.
|
||||
world_icon: Option<PathBuf>,
|
||||
/// When a world icon was last attempted; at most once a second.
|
||||
world_icon_attempt: Option<Instant>,
|
||||
/// The world is superflat (`ServerLevel.isFlat`, for every dimension).
|
||||
world_flat: bool,
|
||||
/// A player carried through a portal, re-placed once the destination loads.
|
||||
@@ -9034,6 +9039,13 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
// A single-player world takes its icon from its first good frame.
|
||||
let icon = std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(folder)
|
||||
.join("icon.png");
|
||||
self.world_icon = (!icon.is_file()).then_some(icon);
|
||||
self.world_icon_attempt = None;
|
||||
if self
|
||||
.options
|
||||
.execution_role
|
||||
@@ -9498,6 +9510,8 @@ impl Game<'_> {
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
self.player = None;
|
||||
self.world_icon = None;
|
||||
self.world_icon_attempt = None;
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
self.clear_entities();
|
||||
@@ -10824,6 +10838,9 @@ impl Game<'_> {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.audio.master = self.settings.master_volume;
|
||||
if self.settings.sound_device != self.audio.device() {
|
||||
let _ = self.audio.set_device(&self.settings.sound_device);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(
|
||||
self.settings.gui_scale,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
@@ -11582,6 +11599,36 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `GameRenderer.tryTakeScreenshotIfNeeded`: the first frame drawn in a
|
||||
/// single-player world after its sections are meshed becomes `icon.png`.
|
||||
fn try_take_world_icon(&mut self) {
|
||||
let Some(path) = self.world_icon.clone() else {
|
||||
return;
|
||||
};
|
||||
if self.title.is_some() || self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.world_icon_attempt
|
||||
.is_some_and(|last| now.duration_since(last) < Duration::from_secs(1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.world_icon_attempt = Some(now);
|
||||
if path.is_file() {
|
||||
self.world_icon = None;
|
||||
return;
|
||||
}
|
||||
let busy = self.streaming.busy();
|
||||
let Some(renderer) = &mut self.renderer else {
|
||||
return;
|
||||
};
|
||||
if renderer.world_icon.is_some() || renderer.drawn_sections <= 10 || busy {
|
||||
return;
|
||||
}
|
||||
renderer.world_icon = Some(path);
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
@@ -11656,6 +11703,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.try_take_world_icon();
|
||||
let alpha = self.clock.fraction();
|
||||
// Camera.getCameraEntityPartialTicks: the view, the hand and the
|
||||
// player's own model stay smooth while the level is frozen.
|
||||
@@ -13987,6 +14035,8 @@ impl<'a> Game<'a> {
|
||||
sleep_seen: (0, 0),
|
||||
dim: Dim::Overworld,
|
||||
world_root: PathBuf::new(),
|
||||
world_icon: None,
|
||||
world_icon_attempt: None,
|
||||
world_flat: false,
|
||||
travelling: None,
|
||||
server_trips: Vec::new(),
|
||||
@@ -15323,7 +15373,7 @@ impl Loading {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
Audio::with_device(&options.resources, &self.settings.sound_device)
|
||||
};
|
||||
let pack_error = loaded.pack_error.or_else(|| self.pack_error.take());
|
||||
// The game borrows its shared resources for the process's lifetime,
|
||||
@@ -15574,7 +15624,7 @@ pub fn run() -> Result<()> {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
Audio::with_device(&options.resources, &early_settings.sound_device)
|
||||
};
|
||||
let resources = loaded.resources;
|
||||
let game = start_game(
|
||||
|
||||
@@ -34,7 +34,7 @@ const HELP: &[&str] = &[
|
||||
"chat TEXT send a chat line or /command as if typed",
|
||||
"type TEXT type into the open chat box (key enter tap sends)",
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
|
||||
@@ -677,6 +677,7 @@ impl Game<'_> {
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"graphics-hdr" => Page::GraphicsHdr,
|
||||
"extras" => Page::Extras,
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows.
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light, mesher::Vertex, mobs::Mob, save::uuid_text,
|
||||
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
|
||||
scoreboard as sb, text::Text,
|
||||
};
|
||||
|
||||
@@ -289,7 +289,7 @@ impl Game<'_> {
|
||||
/// faded, on its background; any other in full over the world, its faded
|
||||
/// copy and background in `see_through`, over everything.
|
||||
pub(super) fn name_tag_geometry(
|
||||
&self,
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
@@ -298,26 +298,31 @@ impl Game<'_> {
|
||||
// The text alpha that goes with getBackgroundOpacity(0.25):
|
||||
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
|
||||
const FADED: u32 = 0x7F;
|
||||
let mut patches = Vec::new();
|
||||
for tag in self.name_tags(camera, alpha) {
|
||||
let o = tag.at - camera;
|
||||
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
|
||||
// background, blends.
|
||||
if tag.discrete {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
|
||||
self.name_tag_quads(o, &tag.lines, FADED, true, 0, vertex_light(tag.light), out);
|
||||
let light = vertex_light(tag.light);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 0, light, out));
|
||||
continue;
|
||||
}
|
||||
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
|
||||
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
|
||||
self.mark_entity(out.len(), "minecraft:name_tag");
|
||||
self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, vertex_light(lit), out);
|
||||
let light = vertex_light(lit);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, light, out));
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
|
||||
self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through));
|
||||
}
|
||||
self.map_patches.extend(patches);
|
||||
}
|
||||
/// The lines of one name tag, `o` from the camera: the text at
|
||||
/// `text_alpha` in its layer, on the translucent black background
|
||||
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
|
||||
/// Returns the glyph pixels the atlas still needs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn name_tag_quads(
|
||||
&self,
|
||||
@@ -328,7 +333,7 @@ impl Game<'_> {
|
||||
text_layer: u32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
) -> Vec<horizon_client::font::Patch> {
|
||||
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
|
||||
const BACKGROUND: u32 = 0x3F00_0000;
|
||||
// The camera's facing (Camera.rotation()), turned round in third-person
|
||||
@@ -342,85 +347,50 @@ impl Game<'_> {
|
||||
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
|
||||
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let white = self.gui.atlas.font.white();
|
||||
let away = forward.as_vec3() * 0.002;
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
for (text, lift) in lines {
|
||||
let o = (o + glam::DVec3::new(0.0, *lift, 0.0)).as_vec3();
|
||||
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
|
||||
// Text space: x right, y down (scale 0.025, -0.025).
|
||||
let at = |x: f32, y: f32| o + r * x - u * y;
|
||||
// Component.getVisualOrderText.
|
||||
let shown = horizon_client::bidi::visual(text);
|
||||
let chars: Vec<(char, u32)> = shown
|
||||
.spans
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
let rgb = s.style.color.unwrap_or(0xFF_FF_FF);
|
||||
// RGBA8, alpha in the top byte.
|
||||
let color = text_alpha << 24
|
||||
| ((rgb & 0xFF) << 16)
|
||||
| (rgb & 0xFF00)
|
||||
| ((rgb >> 16) & 0xFF);
|
||||
s.text.chars().map(move |c| (c, color))
|
||||
})
|
||||
.collect();
|
||||
let width: f32 = chars
|
||||
.iter()
|
||||
.map(|(c, _)| self.gui.atlas.glyph(*c).map_or(6.0, |g| g.1))
|
||||
.sum();
|
||||
let x0 = -(width / 2.0).trunc();
|
||||
let y0 = if text.plain() == "deadmau5" {
|
||||
-10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut quad = |a: [f32; 4], uv: [f32; 4], color: u32, behind: bool, layer: u32| {
|
||||
let corners = [(a[0], a[3]), (a[2], a[3]), (a[2], a[1]), (a[0], a[1])];
|
||||
let uvs = [
|
||||
[uv[0], uv[3]],
|
||||
[uv[2], uv[3]],
|
||||
[uv[2], uv[1]],
|
||||
[uv[0], uv[1]],
|
||||
];
|
||||
for ((x, y), uv) in corners.into_iter().zip(uvs) {
|
||||
batch.vertices.clear();
|
||||
// Font.prepareText, as any text: bold, italic, fonts and each
|
||||
// glyph's own box (accented.png's are 12 pixels tall).
|
||||
let behind = batch.name_tag(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha << 24 | 0xFF_FFFF,
|
||||
if background { BACKGROUND } else { 0 },
|
||||
);
|
||||
for (i, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
|
||||
let shift = if i * 6 < behind {
|
||||
away
|
||||
} else {
|
||||
glam::Vec3::ZERO
|
||||
};
|
||||
// The corners from the bottom left, as the world draws quads.
|
||||
for v in [quad[5], quad[2], quad[1], quad[0]] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (at(x, y) + if behind { away } else { glam::Vec3::ZERO }).to_array(),
|
||||
uv,
|
||||
layer,
|
||||
color,
|
||||
pos: (at(v.pos[0], v.pos[1]) + shift).to_array(),
|
||||
uv: v.uv,
|
||||
layer: text_layer,
|
||||
color: v.color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
if background {
|
||||
quad(
|
||||
[x0 - 1.0, y0 - 1.0, x0 + width, y0 + 8.0],
|
||||
[white.u0, white.v0, white.u1, white.v1],
|
||||
BACKGROUND,
|
||||
true,
|
||||
text_layer,
|
||||
);
|
||||
}
|
||||
let mut x = x0;
|
||||
for (ch, color) in chars {
|
||||
let Some((g, advance)) = self.gui.atlas.glyph(ch) else {
|
||||
x += 6.0;
|
||||
continue;
|
||||
};
|
||||
if ch != ' ' {
|
||||
quad(
|
||||
[x, y0, x + 8.0, y0 + 8.0],
|
||||
[g.u0, g.v0, g.u1, g.v1],
|
||||
color,
|
||||
false,
|
||||
text_layer,
|
||||
);
|
||||
}
|
||||
x += advance;
|
||||
}
|
||||
}
|
||||
// Glyphs rasterized on first use (Unifont's) upload with the frame.
|
||||
batch.patches
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -353,6 +353,9 @@ impl Game<'_> {
|
||||
let lang = Arc::new(horizon_client::net::load_language(&root));
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
if local.is_none() {
|
||||
// Only a single-player world gets an auto icon.
|
||||
self.world_icon = None;
|
||||
self.world_icon_attempt = None;
|
||||
horizon_client::crash::set_detail(
|
||||
"Server",
|
||||
horizon_client::net::shown_address(address),
|
||||
@@ -572,6 +575,8 @@ impl Game<'_> {
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
self.player = None;
|
||||
self.world_icon = None;
|
||||
self.world_icon_attempt = None;
|
||||
self.mobs.clear();
|
||||
self.clear_remote_objects();
|
||||
self.clear_remote_projectiles();
|
||||
|
||||
@@ -6,20 +6,19 @@ pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, auth, bidi, biome, block_entities, block_entity_anim,
|
||||
assets, atlas_sources, audio, auth, bidi, biome, block_entities, block_entity_anim,
|
||||
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
|
||||
collisions, command_block, containers, crash, dynamic_light, effects, environment, extras,
|
||||
fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hud, hud_layout,
|
||||
hud_overlays, inventory_effects, java_text, level_loading, light, lod, logging, menu_blur,
|
||||
mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, providers, screen_draw,
|
||||
screen_effects, sculk, settings, shader_pack_list, signs, skins, sound_events, spans, sprites,
|
||||
screen_effects, sculk, settings, shader_pack_list, signs, skins, sound_engine, sound_events, spans, sprites,
|
||||
streaming, tab_list, telemetry, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
pub mod audio;
|
||||
pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
@@ -101,7 +100,6 @@ pub mod screen_layout;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod shadows;
|
||||
pub mod sound_engine;
|
||||
pub mod spear;
|
||||
pub mod spectator_menu;
|
||||
pub mod sprint;
|
||||
|
||||
+3
-2
@@ -21,7 +21,8 @@ Every row has a one-line description and its cost in frame time (none, low, medi
|
||||
| Quality | smooth lighting, mipmap levels, biome blend, clouds, cloud distance, weather radius, particles, entity shadows, cutout leaves | `options.txt` |
|
||||
| | sky, fog, weather, animated textures | `horizon-graphics.txt` |
|
||||
| Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` |
|
||||
| | resolution scale, anti-aliasing (FXAA), HDR, paper white, peak brightness, present mode, frame pacing | `horizon-graphics.txt` |
|
||||
| | resolution scale, anti-aliasing (FXAA), present mode, frame pacing | `horizon-graphics.txt` |
|
||||
| HDR (its own screen, from Display) | HDR, paper white, peak brightness | `horizon-graphics.txt` |
|
||||
| Performance | battery mode, worker threads, upload budget per frame | `horizon-graphics.txt` |
|
||||
| Overlay | performance overlay: FPS, frame/CPU/GPU times, frame-time graph | `horizon-graphics.txt` |
|
||||
|
||||
@@ -29,7 +30,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
|
||||
|
||||
**Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are.
|
||||
|
||||
**HDR** (Display, off by default) presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
|
||||
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
|
||||
|
||||
Which settings each screen shows, their descriptions and costs are in `crates/horizon-client-render/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them.
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
|
||||
| `respawn` | `respawn` | Respawn from the death screen | none |
|
||||
| `pause` | `pause` | Open the pause menu | none |
|
||||
| `resume` | `resume` | Leave the pause menu | none |
|
||||
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, shader-packs, graphics, graphics-advanced, extras, horizon, agent): the page (default: the main options) |
|
||||
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, shader-packs, graphics, graphics-advanced, graphics-hdr, extras, horizon, agent): the page (default: the main options) |
|
||||
| `book [next\|back\|take\|done]` | `book` | The open book or lectern: its page text, or press one of its buttons | `action` (next, back, take, done): a button to press |
|
||||
| `signedit X Y Z [front\|back]` | `signedit` | Open the sign editor of the sign at X Y Z (then type and close) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`side` (front, back): which side |
|
||||
| `mount-menu` | `mount_menu` | Open the inventory of the horse, donkey, mule or llama being ridden | none |
|
||||
|
||||
Reference in new issue
Block a user