Compare commits

...
Author SHA1 Message Date
Ludovic Marie edfb0e31d5 Merge branch 'heads/pt3/3113' into union/pt3-1 2026-10-29 10:20:49 +01:00
Ludovic Marie 3828190801 Merge commit 'pt3/2713' into union/pt3-1 2026-10-29 10:18:54 +01:00
Ludovic Marie 866f9b4f12 Merge commit 'pt3/2712' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie 5d040b68c6 Merge commit 'pt3/2711' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie 591c7293d1 Merge commit 'pt3/3113' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie 19ab314a7f Merge commit 'pt3/3327' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie 477dcb8f3f Merge commit 'pt3/3295' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie d392a6a097 Merge commit 'pt3/2839' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie 6adb9f5ed1 Merge commit 'pt3/3054' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie 9edc21fbc4 Merge commit 'pt3/3049' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie d5e860248f Merge commit 'pt3/2533' into union/pt3-1 2026-10-29 10:18:53 +01:00
Ludovic Marie 71e0678303 Merge commit 'pt3/2383' into union/pt3-1 2026-10-29 10:18:52 +01:00
damnurmumandCodex dd130f1902 Render player heads and atlas sprites in text components
Parse Minecraft object components into inline spans and draw player heads and atlas sprites with the existing HUD atlas. Resolve head profiles through the skin cache and preserve fallback text for plain output.

Co-Authored-By: Codex <[email protected]>
2026-10-28 22:07:28 +03:00
RVZO6andCodex 61713b8359 Fix menu separator texture sampling
Use both rows of the separator textures when tiling headers and footers, including the create-world screen.

Co-Authored-By: Codex (GPT-6.1 Terra) <[email protected]>
2026-10-28 13:16:26 -04:00
Kacpherek 345f64e47c Import the Sounds trait for the elytra sound 2026-10-28 06:23:27 +01:00
Kacpherek 8f84c2cd48 Play the elytra's flying sound while gliding
Horizon had no ElytraOnPlayerSoundInstance, so gliding and diving were silent. The loop starts when the player starts gliding, fades in after a second, and follows the player's whole speed, so a straight dive is the loudest.
2026-10-28 06:16:05 +01:00
Kacpherek e0a48b3f66 Document the 2..128 render distance range 2026-10-28 05:56:49 +01:00
Kacpherek d35efa25ec Accept render distances up to 128 from the command line and horizon-ctl
--render-distance and horizon-ctl render-distance check against the same cap as the options.
2026-10-28 05:56:48 +01:00
Kacpherek b8b15b1682 Raise the render distance cap from 64 to 128 chunks
The options slider and options.txt now accept 2..128. 128 is where the server protocol's view distance (an i8) tops out.
2026-10-28 05:56:48 +01:00
Ludovic MarieandClaude Opus 5.5 4d0962e611 Merge main into fix/fog-lava-and-night-vision
Kept fog_night_vision next to main's level_flat/level_min_y and main's
flat-world dark disc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:38:12 +01:00
iLostName 66b705cec3 sounds: list each namespace's sound files once instead of a lookup per sound 2026-10-27 14:52:56 +01:00
Ludovic MarieandClaude Opus 5.5 e5ca012ed8 Merge main into fix/server-mob-animation-sync-2
Brings main in so the branch has one merge base with it (GitHub saw a
conflict through the criss-cross bases; the merge itself is clean).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:08:06 +01:00
iLostName 758d66bf44 remote: titles, tab list, scoreboard and effects don't carry over to the next server 2026-10-27 10:09:12 +01:00
iLostName b201dafa75 server list: the player count's tooltip lists the status sample 2026-10-27 10:07:15 +01:00
iLostName 7c58bf004f options: the slider test expects saving on release 2026-10-27 10:06:32 +01:00
iLostName 4119284a4f options: sliders apply while dragged and save on release 2026-10-27 09:55:35 +01:00
Kacpherek 36ebd0c545 Follow vanilla's lava fog for Fire Resistance and spectators, and brighten the fog colour with Night Vision 2026-10-26 19:34:12 +01:00
24 changed files with 756 additions and 97 deletions

No files matched your search

+65 -2
View File
@@ -1,6 +1,6 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use crate::text::{InlineObject, Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
@@ -263,6 +263,10 @@ pub fn to_text_with(
let style = object_style(o);
let content = if let Some(t) = contents(o) {
t
} else if let Some(object) = inline_object(o, resolve, contents) {
let mut text = Text::literal("\u{fffc}");
text.spans[0].style.inline_object = Some(object);
text
} else if let Some(t) = o.get("text") {
match t {
Value::String(s) => Text::literal(s.clone()),
@@ -299,12 +303,45 @@ pub fn to_text_with(
{
out.push(to_text_with(extra, resolve, contents).with_style(&style));
}
out
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
out.with_component(Value::Object(o.clone()))
} else {
out
}
}
Value::Null => Text::empty(),
}
}
fn inline_object(
object: &serde_json::Map<String, Value>,
resolve: &mut dyn FnMut(&str) -> Text,
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
) -> Option<InlineObject> {
let kind = object.get("object").and_then(Value::as_str);
let fallback = object
.get("fallback")
.map(|value| to_text_with(value, resolve, contents).plain())
.unwrap_or_default();
match kind {
Some("player") | None if object.contains_key("player") => Some(InlineObject::Player {
profile: object.get("player")?.clone(),
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
fallback,
}),
Some("atlas") | None if object.contains_key("sprite") => Some(InlineObject::Atlas {
atlas: object
.get("atlas")
.and_then(Value::as_str)
.unwrap_or("minecraft:blocks")
.to_owned(),
sprite: object.get("sprite")?.as_str()?.to_owned(),
fallback,
}),
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
@@ -318,6 +355,32 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
let component = serde_json::json!({
"text": "<",
"extra": [
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
"fallback": "[head]"},
{"text": " >", "color": "gold"}
]
});
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(text.plain(), "<[head] >");
assert!(matches!(
&text.spans[1].style.inline_object,
Some(InlineObject::Player { hat: false, .. })
));
assert!(text.spans[2].style.inline_object.is_none());
assert_eq!(text.component(), component);
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
let text = to_text(&sprite, &mut |_| Text::empty());
assert!(matches!(
&text.spans[0].style.inline_object,
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
));
}
#[test]
fn snbt_and_json_components() {
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
+31 -1
View File
@@ -58,6 +58,28 @@ pub enum ClickEvent {
OpenFile(String),
}
/// A non-character `minecraft:object` embedded in a text component.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InlineObject {
Player {
profile: Value,
hat: bool,
fallback: String,
},
Atlas {
atlas: String,
sprite: String,
fallback: String,
},
}
impl InlineObject {
pub fn fallback(&self) -> &str {
match self {
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
}
}
}
/// Text style; `None` fields inherit from the enclosing style.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Style {
@@ -77,6 +99,8 @@ pub struct Style {
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
/// Content metadata for a single U+FFFC object span; never inherited.
pub inline_object: Option<InlineObject>,
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
@@ -337,7 +361,13 @@ impl Text {
}
/// Component.getString: the plain characters.
pub fn plain(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
self.spans
.iter()
.map(|s| match &s.style.inline_object {
Some(object) if s.text == "\u{fffc}" => object.fallback(),
_ => s.text.as_str(),
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.spans.iter().all(|s| s.text.is_empty())
+25 -5
View File
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
pub fire_resistance: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
let brighten = if eye_in_water {
water_vision
} else {
input.night_vision
};
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
color = color.map(|c| c + (c * scale - c) * brighten);
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
};
match input.fog_type {
FogType::Lava => {
fog.environmental_start = 0.25;
fog.environmental_end = 1.0;
fog.sky_end = 1.0;
fog.cloud_end = 1.0;
// LavaFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else if input.fire_resistance {
(0.0, 5.0)
} else {
(0.25, 1.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
@@ -405,6 +422,9 @@ mod tests {
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
spectator: false,
fire_resistance: false,
night_vision: 0.0,
}
}
#[test]
+3
View File
@@ -896,6 +896,9 @@ impl Font {
std::mem::take(&mut self.cache.borrow_mut().region.pending)
}
pub fn advance(&self, c: char, style: &Style) -> f32 {
if c == '\u{fffc}' && style.inline_object.is_some() {
return 8.0;
}
let g = self.glyph_styled(c, style);
g.advance + if style.is_bold() { g.offset } else { 0.0 }
}
@@ -570,6 +570,7 @@ impl Item {
pub enum Action {
ButtonClick,
Changed,
SliderDrag,
OpenAdvanced,
OpenSimple,
Done,
@@ -800,7 +801,7 @@ impl GraphicsUi {
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
{
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
return vec![Action::Changed];
return vec![Action::SliderDrag];
}
return Vec::new();
}
@@ -822,11 +823,11 @@ impl GraphicsUi {
if item.slider(s).is_some() {
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
vec![Action::SliderDrag]
} else {
item.cycle(s, right, framebuffer);
return vec![Action::ButtonClick, Action::Changed];
vec![Action::ButtonClick, Action::Changed]
}
vec![Action::Changed]
}
};
}
@@ -897,7 +898,7 @@ mod tests {
false,
false
),
vec![Action::Changed]
vec![Action::SliderDrag]
);
assert_eq!(
ui.click(
@@ -909,7 +910,7 @@ mod tests {
true,
false
),
vec![Action::Changed]
vec![Action::SliderDrag]
);
assert!(ui.release());
assert!(!ui.release());
+185 -14
View File
@@ -59,6 +59,8 @@ pub struct HudAtlas {
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Resolved player-profile names and UUIDs for inline head components.
pub object_skins: HashMap<String, String>,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
@@ -252,6 +254,24 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
out.into_iter().map(|(_, image)| image).collect()
}
impl HudAtlas {
/// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry;
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
self.object_skins.clear();
}
match self.object_skins.entry(key) {
Entry::Occupied(entry)
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
}
Entry::Occupied(mut entry) => {
entry.insert(skin);
}
Entry::Vacant(entry) => {
entry.insert(skin);
}
}
}
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
let g = self.font.glyph(ch);
@@ -592,6 +612,7 @@ impl HudAtlas {
width,
height,
sprites,
object_skins: HashMap::new(),
translucent,
flags,
sprite_animations,
@@ -641,6 +662,8 @@ pub struct HudBatch {
pub glint_alpha: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
/// Player object profiles encountered while painting text this frame.
pub inline_profiles: Vec<serde_json::Value>,
}
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -692,6 +715,7 @@ impl HudBatch {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
}
}
/// Draw the vertices added since `start` with the inverting blend.
@@ -1012,17 +1036,38 @@ impl HudBatch {
let mut glyphs = Vec::new();
let mut cursor = x;
for (i, (ch, style)) in text.chars().enumerate() {
let object = (ch == '\u{fffc}')
.then_some(style.inline_object.as_ref())
.flatten();
if let Some(crate::text::InlineObject::Player { profile, .. }) = object
&& self.inline_profiles.len() < 256
&& !self.inline_profiles.contains(profile)
{
self.inline_profiles.push(profile.clone());
}
// The style's font (`Style.getFont`), the default one unless set.
let mut g = font.glyph_styled(ch, style);
let mut g = if object.is_some() {
crate::font::Glyph {
rect: None,
left: 0.0,
top: 0.0,
right: 0.0,
bottom: 8.0,
advance: 8.0,
offset: 0.0,
}
} else {
font.glyph_styled(ch, style)
};
// Font.StringRenderOutput.accept: obfuscated characters draw a
// random glyph of the same width each frame.
if style.obfuscated == Some(true) && ch != ' ' {
if object.is_none() && style.obfuscated == Some(true) && ch != ' ' {
g = font.random_glyph(style, g.advance);
}
let c = style.color.map_or(color, |rgb| {
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
});
let bold = style.is_bold();
let bold = object.is_none() && style.is_bold();
let advance = g.advance + if bold { g.offset } else { 0.0 };
glyphs.push((
cursor,
@@ -1034,6 +1079,7 @@ impl HudBatch {
style.strikethrough == Some(true),
i == 0,
advance,
object.cloned(),
));
cursor += advance;
}
@@ -1041,7 +1087,13 @@ impl HudBatch {
let white = font.white();
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
for &is_shadow in passes {
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
for (gx, g, c, bold, italic, underline, strike, first, advance, object) in &glyphs {
if let Some(object) = object {
if !is_shadow {
self.draw_inline_object(atlas, object, *gx, y, alpha);
}
continue;
}
let (dx, col) = if is_shadow {
let b = c.to_le_bytes();
(
@@ -1054,11 +1106,11 @@ impl HudBatch {
),
)
} else {
(0.0, c)
(0.0, *c)
};
let (px, py) = (gx + dx, y + dx);
let shear = |v: f32| if italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if bold { 0.1 } else { 0.0 };
let (px, py) = (*gx + dx, y + dx);
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if *bold { 0.1 } else { 0.0 };
let draw = |batch: &mut Self, ox: f32| match g.rect {
Some(r) => {
let (x0, x1, y0, y1) = (
@@ -1093,21 +1145,89 @@ impl HudBatch {
None => {}
};
draw(self, 0.0);
if bold {
if *bold {
draw(self, g.offset);
}
let x0 = if first { px - 1.0 } else { px };
if strike {
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
let x0 = if *first { px - 1.0 } else { px };
if *strike {
self.rect_uv(x0, py + 3.5, px + *advance - x0, 1.0, white, col);
}
if underline {
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
if *underline {
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
}
}
}
cursor - x
}
fn draw_inline_object(
&mut self,
atlas: &HudAtlas,
object: &crate::text::InlineObject,
x: f32,
y: f32,
alpha: u8,
) {
use crate::text::InlineObject;
let tint = rgba(255, 255, 255, alpha);
match object {
InlineObject::Player { profile, hat, .. } => {
let id = crate::skins::parse_profile(profile).id;
let name = inline_profile_name(profile);
let by_id = atlas.object_skins.get(&format!("id:{id:032x}"));
let by_name = name.and_then(|name| {
atlas
.object_skins
.get(&format!("name:{}", name.to_ascii_lowercase()))
});
let resolved = if profile.get("id").is_some() {
by_id.or(by_name)
} else {
by_name.or(by_id)
};
let default = crate::tab_list::default_skin(id);
let skin = resolved
.filter(|path| atlas.sprites.contains_key(*path))
.map_or(default, String::as_str);
let Some(rect) = atlas.sprites.get(skin).copied() else {
return;
};
for u in [8.0, 40.0].into_iter().take(if *hat { 2 } else { 1 }) {
let du = (rect.u1 - rect.u0) / 64.0;
let dv = (rect.v1 - rect.v0) / 64.0;
self.rect_uv(
x,
y,
8.0,
8.0,
Rect {
u0: rect.u0 + du * u,
v0: rect.v0 + dv * 8.0,
u1: rect.u0 + du * (u + 8.0),
v1: rect.v0 + dv * 16.0,
w: 8,
h: 8,
},
tint,
);
}
}
InlineObject::Atlas { sprite, .. } => {
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
if let Some(rect) = atlas.sprites.get(key).copied() {
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
}
}
}
}
}
fn inline_profile_name(profile: &serde_json::Value) -> Option<&str> {
profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str))
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct HudUniform {
@@ -1822,6 +1942,56 @@ mod tests {
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn player_object_draws_an_inline_head() {
let root = two_glyph_font("inline-head");
write_png(&root, "entity/player/slim/alex", 64, 64);
write_png(&root, "entity/player/wide/steve", 64, 64);
let mut atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"id":"00000000-0000-0000-0000-000000000000"}}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
assert_eq!(
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false),
8.0
);
assert_eq!(batch.vertices.len(), 12); // face and hat, six vertices each
assert_eq!(batch.inline_profiles.len(), 1);
atlas.set_object_skin("name:elyapluggin".into(), "entity/player/wide/steve".into());
let named = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"name":"elyapluggin"}}),
&mut |_| crate::text::Text::empty(),
);
batch.vertices.clear();
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
let skin = atlas.sprites["entity/player/wide/steve"];
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn atlas_object_draws_an_item_sprite_inline() {
let root = two_glyph_font("inline-item");
write_png(&root, "item/porkchop", 16, 16);
let atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({
"text": "a",
"extra": [{"object": "atlas", "sprite": "minecraft:item/porkchop"}]
}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
let width = batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
assert_eq!(width, atlas.font.glyph('a').advance + 8.0);
assert_eq!(batch.vertices.len(), 12); // glyph and one sprite
let sprite = atlas.sprites["item/porkchop"];
assert_eq!(batch.vertices[6].uv, [sprite.u0, sprite.v0]);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn effect_icons_are_painted_from_the_mob_effect_directory() {
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
@@ -2008,6 +2178,7 @@ mod tests {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
assert_eq!(
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
];
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
/// view distance tops out at 127 anyway.
pub const MAX_RENDER_DISTANCE: i32 = 128;
const INACTIVITY: &[&str] = &["minimized", "afk"];
const CLOUDS: &[&str] = &["false", "fast", "true"];
const MAIN_HAND: &[&str] = &["left", "right"];
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
b("reducedDebugInfo", false),
def("renderClouds", O(Clouds), V::Int(2)),
i("cloudRange", 2, 128, 128),
i("renderDistance", 2, 64, 12),
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
i("simulationDistance", 5, 32, 12),
u("screenEffectScale", 1.0),
def("soundDevice", O(AnyStr), V::Str(String::new())),
+25 -15
View File
@@ -239,7 +239,11 @@ impl Opt {
| Opt::GlintStrength
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
Opt::Fov => unit(s.fov, 30.0, 110.0),
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
Opt::RenderDistance => unit(
s.render_distance as f32,
2.0,
crate::options_file::MAX_RENDER_DISTANCE as f32,
),
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
Opt::Sensitivity => Kind::Slider(s.sensitivity),
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
@@ -586,7 +590,10 @@ impl Opt {
)
}
Opt::Fov => s.fov = step(30.0, 110.0),
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
Opt::RenderDistance => {
s.render_distance =
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
}
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
Opt::Sensitivity => s.sensitivity = v,
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
@@ -923,8 +930,10 @@ pub enum Event {
ButtonClick,
/// Close the options screens (back to the pause menu or title).
Close,
/// Settings changed: apply them.
/// Settings changed: apply and save them.
Changed,
/// A slider is being dragged: apply settings live but defer saving.
SliderDrag,
/// The language changed (reload translations).
Language(String),
/// The pack selection changed (reload resources).
@@ -2427,6 +2436,7 @@ impl OptionsUi {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenSimple => {
if self.stack.len() >= 2
@@ -2507,7 +2517,7 @@ impl OptionsUi {
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::Changed);
events.push(Event::SliderDrag);
}
return events;
}
@@ -2643,7 +2653,7 @@ impl OptionsUi {
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::Changed);
events.push(Event::SliderDrag);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
@@ -2676,7 +2686,7 @@ impl OptionsUi {
let slider = self.dragging.take().is_some();
let graphics_slider = self.graphics.release();
if slider || graphics_slider {
vec![Event::ButtonClick]
vec![Event::ButtonClick, Event::Changed]
} else {
Vec::new()
}
@@ -3052,7 +3062,7 @@ mod tests {
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
set_slider(Opt::RenderDistance, &mut s, position);
assert_eq!(s.render_distance, distance);
let reloaded = Settings::parse(&s.serialize());
@@ -3063,10 +3073,10 @@ mod tests {
);
}
let mut file = crate::options_file::OptionsFile::default();
file.set_text("renderDistance", "64").unwrap();
assert!(file.set_text("renderDistance", "65").is_err());
file.set_text("renderDistance", "128").unwrap();
assert!(file.set_text("renderDistance", "129").is_err());
assert!(file.set_text("renderDistance", "1").is_err());
assert_eq!(file.int("renderDistance"), 64);
assert_eq!(file.int("renderDistance"), 128);
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
@@ -3666,22 +3676,22 @@ mod tests {
let (x, y) = (slider.x + slider.w - 1.0, slider.y + 1.0);
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
assert_eq!(s.master_volume, 1.0);
assert_eq!(ui.release(), vec![Event::ButtonClick]);
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
assert!(ui.release().is_empty());
assert_eq!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
for cx in [-100.0, 250.0, 1000.0] {
assert_eq!(
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
vec![Event::Changed]
vec![Event::SliderDrag]
);
}
assert_eq!(ui.release(), vec![Event::ButtonClick]);
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
assert!(
ui.click(&mut s, &atlas, x, y, size, [854, 480], true, false)
.is_empty()
@@ -58,6 +58,8 @@ pub enum Ticking {
Bee { bee: u32, aggressive: bool },
/// `GuardianAttackSoundInstance`.
GuardianAttack { guardian: u32 },
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
Elytra { time: i32 },
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -95,6 +97,12 @@ pub struct TickContext {
pub mobs: Vec<MobSound>,
/// `!tickRateManager().runsNormally()`.
pub frozen: bool,
/// The player's position.
pub player_position: [f64; 3],
/// `getDeltaMovement().lengthSqr()` of the player.
pub player_speed_sqr: f64,
/// `isFallFlying()` of the player.
pub player_fall_flying: bool,
}
/// `AbstractSoundInstance` and its subclasses' fields.
#[derive(Clone, Debug)]
@@ -390,6 +398,24 @@ impl SoundInstance {
};
s
}
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
/// its first tick.
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
let mut s = Self::new(
"minecraft:item.elytra.flying",
PLAYERS,
0.1,
1.0,
random,
true,
0,
Attenuation::Linear,
position,
false,
);
s.ticking = Ticking::Elytra { time: 0 };
s
}
pub fn tickable(&self) -> bool {
self.ticking != Ticking::No
}
@@ -494,6 +520,37 @@ impl SoundInstance {
self.volume = 0.0;
}
}
Ticking::Elytra { time } => {
*time += 1;
let time = *time;
if ctx.player_removed || (time > 20 && !ctx.player_fall_flying) {
self.stop();
return;
}
let [x, y, z] = ctx.player_position;
self.position = [
f64::from(x as f32),
f64::from(y as f32),
f64::from(z as f32),
];
let speed = ctx.player_speed_sqr as f32;
self.volume = if speed >= 1.0e-7 {
(speed / 4.0).clamp(0.0, 1.0)
} else {
0.0
};
// Silent for a second, then a second's fade in.
if time < 20 {
self.volume = 0.0;
} else if time < 40 {
self.volume *= (time - 20) as f32 / 20.0;
}
self.pitch = if self.volume > 0.8 {
1.0 + (self.volume - 0.8)
} else {
1.0
};
}
Ticking::GuardianAttack { guardian } => {
let id = *guardian;
// The client's guardian never has a target of its own, so
@@ -6,7 +6,11 @@
//! instance's random.
use horizon_core::random::RandomSource;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
};
/// `SoundManager.EMPTY_SOUND_LOCATION`.
pub const EMPTY: &str = "minecraft:empty";
@@ -224,6 +228,34 @@ fn parse_sound(v: &Value) -> Option<Sound> {
sentinel: Sentinel::None,
})
}
/// The files under a namespace's `sounds/`, as `sounds/<path>` with `/`.
fn sound_files(namespace: &Path) -> HashSet<String> {
let mut files = HashSet::new();
let mut stack = vec![namespace.join("sounds")];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
// The listing's own type; a link is followed, as `is_file` does.
let Ok(kind) = e.file_type() else {
continue;
};
let (dir, file) = if kind.is_symlink() {
(path.is_dir(), path.is_file())
} else {
(kind.is_dir(), kind.is_file())
};
if dir {
stack.push(path);
} else if file && let Ok(rel) = path.strip_prefix(namespace) {
files.insert(rel.to_string_lossy().replace('\\', "/"));
}
}
}
files
}
impl SoundRegistry {
/// Every `assets/<namespace>/sounds.json` under a resource root, files
/// validated against `assets/<namespace>/sounds/`.
@@ -236,13 +268,19 @@ impl SoundRegistry {
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
.collect();
namespaces.sort();
// Each namespace's sound files are listed once, as the resource manager
// lists a pack, instead of one file lookup per sound (thousands).
let files: HashMap<&str, HashSet<String>> = namespaces
.iter()
.map(|ns| (ns.as_str(), sound_files(&assets.join(ns))))
.collect();
let exists = |ns: &str, path: &str| files.get(ns).is_some_and(|f| f.contains(path));
let mut registry = Self::default();
for namespace in namespaces {
let Ok(text) = fs::read(assets.join(&namespace).join("sounds.json")) else {
for namespace in &namespaces {
let Ok(text) = fs::read(assets.join(namespace).join("sounds.json")) else {
continue;
};
let exists = |ns: &str, path: &str| assets.join(ns).join(path).is_file();
registry.add_file(&namespace, &text, &exists);
registry.add_file(namespace, &text, &exists);
}
registry
}
@@ -369,6 +407,24 @@ mod tests {
use super::*;
use horizon_core::random::LegacyRandom;
#[test]
fn load_keeps_the_sound_files_a_root_holds() {
let root = std::env::temp_dir().join(format!("horizon-sounds-{}", std::process::id()));
let ns = root.join("assets/minecraft");
fs::create_dir_all(ns.join("sounds/block/note")).unwrap();
fs::write(ns.join("sounds/block/note/harp.ogg"), b"").unwrap();
fs::write(
ns.join("sounds.json"),
br#"{"note": {"sounds": ["block/note/harp", "block/note/gone"]}}"#,
)
.unwrap();
let r = SoundRegistry::load(&root);
let _ = fs::remove_dir_all(&root);
let sounds = &r.get("minecraft:note").unwrap().sounds;
let kept: Vec<&str> = sounds.iter().map(|s| s.location.as_str()).collect();
assert_eq!(kept, ["minecraft:block/note/harp"]);
}
#[test]
fn references_weigh_as_much_as_their_event() {
let mut r = SoundRegistry::default();
+43 -22
View File
@@ -227,28 +227,33 @@ pub fn tiled(
h: f32,
size: f32,
color: u32,
) {
tiled_sized(batch, atlas, sprite, x, y, w, h, [size, size], color);
}
/// Repeat a texture using its width and height in GUI pixels.
#[allow(clippy::too_many_arguments)]
pub fn tiled_sized(
batch: &mut HudBatch,
atlas: &HudAtlas,
sprite: &str,
x: f32,
y: f32,
w: f32,
h: f32,
size: [f32; 2],
color: u32,
) {
let Some(r) = atlas.sprites.get(sprite).copied() else {
return;
};
let mut yy = 0.0;
while yy < h {
let th = size.min(h - yy);
let mut xx = 0.0;
while xx < w {
let tw = size.min(w - xx);
batch.rect_uv(
x + xx,
y + yy,
tw,
th,
sub(r, [size, size], [0.0, 0.0, tw, th]),
color,
);
xx += size;
}
yy += size;
}
tile(
batch,
r,
size,
[0.0, 0.0, size[0], size[1]],
[x, y, w, h],
color,
);
}
/// Text drawn at `scale` times its size (a scaled pose).
pub fn text_scaled(
@@ -434,7 +439,7 @@ pub fn separators(
("gui/header_separator", "gui/footer_separator")
};
let w = batch.width;
tiled(
tiled_sized(
batch,
atlas,
header,
@@ -442,10 +447,10 @@ pub fn separators(
header_bottom - 2.0,
w,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
tiled(
tiled_sized(
batch,
atlas,
footer,
@@ -453,7 +458,7 @@ pub fn separators(
footer_top,
w,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
}
@@ -1291,6 +1296,22 @@ mod tests {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
let mut batch = HudBatch::new([640, 480]);
tile(
&mut batch,
sprite(32, 2),
[32.0, 2.0],
[0.0, 0.0, 32.0, 2.0],
[0.0, 5.0, 40.0, 2.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 2);
assert_quad(q[0], ([0.0, 5.0, 32.0, 7.0], [0.0, 0.0, 1.0, 1.0]));
assert_quad(q[1], ([32.0, 5.0, 40.0, 7.0], [0.0, 0.0, 0.25, 1.0]));
}
#[test]
fn a_high_resolution_button_slices_in_its_gui_units() {
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
let n = faithful(
@@ -425,7 +425,7 @@ pub const COMMANDS: &[Cmd] = &[
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
+8 -3
View File
@@ -394,7 +394,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -422,8 +422,9 @@ impl Options {
"--render-distance" | "--radius" => {
o.distance = value.parse()?;
o.distance_override = Some(o.distance);
if !(2..=64).contains(&o.distance) {
return Err("render distance must be 2..64".into());
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&o.distance) {
return Err(format!("render distance must be 2..{max}").into());
}
}
"--seed" => {
@@ -1563,6 +1564,8 @@ struct Game<'a> {
cart_sounds: HashSet<u32>,
/// The minecart the riding sounds were started for (`LocalPlayer.startRiding`).
riding_cart_sounds: Option<u32>,
/// `LocalPlayer.wasFallFlying` for the elytra's flying sound.
was_fall_flying: bool,
/// Bees whose buzz is playing, and whether it is the aggressive one.
bee_sounds: HashMap<u32, bool>,
/// Guardians whose attack hum is playing.
@@ -6068,6 +6071,7 @@ impl Game<'_> {
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
self.draw_pad_cursor(&mut batch);
self.resolve_inline_profiles(&batch.inline_profiles);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
@@ -14026,6 +14030,7 @@ impl<'a> Game<'a> {
sound_paused: false,
cart_sounds: HashSet::new(),
riding_cart_sounds: None,
was_fall_flying: false,
bee_sounds: HashMap::new(),
guardian_sounds: HashSet::new(),
dropped: Vec::new(),
+4 -3
View File
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
"tp X Y Z teleport the player",
"hotbar 1-9 select a hotbar slot",
"give ITEM [COUNT] add items to the inventory",
"render-distance N chunks, 2..64",
"render-distance N chunks, 2..128",
"lod-distance N far surface chunks, 0 disables, up to1024",
"time TICKS set the time of day",
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
@@ -516,8 +516,9 @@ impl Game<'_> {
}
"render-distance" => {
let n: i32 = arg(1)?.parse()?;
if !(2..=64).contains(&n) {
return Err("render distance must be 2..64".into());
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&n) {
return Err(format!("render distance must be 2..{max}").into());
}
self.settings.render_distance = n;
self.apply_settings();
+20 -1
View File
@@ -4,7 +4,7 @@ use super::Game;
use horizon_client::{
blocks::Fluid,
music::{BackgroundMusic, Music, MusicFrequency, Situation},
sound_engine::TickContext,
sound_engine::{SoundEngine, SoundInstance, Sounds, TickContext},
};
impl Game<'_> {
@@ -35,6 +35,22 @@ impl Game<'_> {
}
let (vehicle, carts) = self.start_cart_sounds();
let mobs = self.start_mob_sounds();
let (player_position, player_speed_sqr, player_fall_flying) =
self.player.as_ref().map_or(([0.0; 3], 0.0, false), |p| {
let at = p.position;
(
[at.x, at.y, at.z],
p.velocity.length_squared(),
p.fall_flying,
)
});
// LocalPlayer.onSyncedDataUpdated: starting to glide starts the
// elytra's flying sound.
if player_fall_flying && !self.was_fall_flying {
let instance = SoundInstance::elytra(player_position, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
self.was_fall_flying = player_fall_flying;
let ctx = TickContext {
player_underwater: self.ambience.was_underwater(),
player_removed: self.player.is_none(),
@@ -42,6 +58,9 @@ impl Game<'_> {
carts,
mobs,
frozen: !self.clock.runs_normally(),
player_position,
player_speed_sqr,
player_fall_flying,
};
self.audio.tick(self.pause, &ctx);
}
@@ -200,6 +200,9 @@ impl Game<'_> {
self.sync_agent_control();
self.ui.options.agent_status = self.agent_status_lines();
}
Event::SliderDrag => {
self.apply_settings();
}
Event::CopyAgentDetails => {
use horizon_client::clipboard::Clipboard;
let text = self.agent_details();
+64 -6
View File
@@ -547,10 +547,14 @@ impl Game<'_> {
self.extras_left();
}
self.clear_waypoints();
self.boss_overlay.reset();
self.hud_disconnected();
// Vanilla's Hud also ticks on the title and connect screens, so the
// action bar has run out by the next world; Horizon's ticks in one.
self.titles.overlay_time = 0;
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
self.gui.atlas.object_skins.clear();
self.server_packs_disconnect();
self.code_of_conduct = None;
self.close_dialogs();
@@ -566,8 +570,6 @@ impl Game<'_> {
self.clock = Default::default();
self.adv = Default::default();
self.stats = Default::default();
self.debug_charts.reset();
self.tab_list.reset();
self.score.board = Default::default();
self.streaming = Streaming::new(Arc::new(NoTerrain), self.pack.context.clone(), 1);
self.streaming.paused = true;
@@ -611,6 +613,17 @@ impl Game<'_> {
self.capture(false);
Ok(())
}
/// `Hud.onDisconnected`, from `Minecraft.disconnect` and `clearClientLevel`
/// (a proxy's reconfiguration too), but the chat: joining clears it and a
/// reconfiguration keeps it (`ChatComponent.storeState`).
fn hud_disconnected(&mut self) {
self.tab_list.reset();
self.boss_overlay.reset();
self.adv.toasts.clear();
self.debug_charts.reset();
self.titles.clear();
self.titles.reset_times();
}
pub(super) fn send(&self, packet: S) {
if let Some(r) = &self.remote {
@@ -902,7 +915,15 @@ impl Game<'_> {
}
}
Event::Reconfigured(registries) => {
self.boss_overlay.reset();
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard.
self.hud_disconnected();
self.score.board = Default::default();
if let Some(r) = self.remote.as_mut() {
r.players.clear();
r.tab_players.clear();
r.listed.clear();
}
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
@@ -1778,8 +1799,9 @@ impl Game<'_> {
C::Login(p) => {
self.level.hardcore = p.hardcore;
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0).
// new LocalPlayer (slot 0, no effects).
self.selected = 0;
self.potions.effects.clear();
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.online_mode = p.online_mode;
@@ -2917,7 +2939,10 @@ impl Game<'_> {
let text = |t: &Tag| {
let json = horizon_client::scoreboard::tag_to_json(t);
let text = horizon_client::scoreboard::comp_text(&json);
(!text.plain().is_empty()).then_some(text)
text.spans
.iter()
.any(|span| !span.text.is_empty())
.then_some(text)
};
self.tab_list.header = text(&p.header);
self.tab_list.footer = text(&p.footer);
@@ -3163,6 +3188,13 @@ impl Game<'_> {
.as_ref()
.and_then(|r| r.skins.get(&p.uuid).cloned())
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned());
self.gui
.atlas
.set_object_skin(format!("id:{:032x}", p.uuid), p.skin.clone());
self.gui.atlas.set_object_skin(
format!("name:{}", p.name.to_ascii_lowercase()),
p.skin.clone(),
);
}
let r = self.remote.as_ref()?;
let upside_down = players
@@ -3175,6 +3207,32 @@ impl Game<'_> {
.collect();
Some((players, r.online_mode, upside_down))
}
/// Resolve inline `object:player` profiles through the same skin cache as
/// player heads on items. The next frame uses the uploaded atlas texture.
pub(super) fn resolve_inline_profiles(&mut self, profiles: &[serde_json::Value]) {
use horizon_client::skins;
for profile in profiles {
let skin = skins::head_skin(
profile,
&mut self.skin_profiles.borrow_mut(),
&mut self.skin_downloads.borrow_mut(),
|location| self.gui.atlas.sprites.contains_key(location),
);
let id = skins::parse_profile(profile).id;
self.gui
.atlas
.set_object_skin(format!("id:{id:032x}"), skin.clone());
let name = profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str));
if let Some(name) = name {
self.gui
.atlas
.set_object_skin(format!("name:{}", name.to_ascii_lowercase()), skin);
}
}
}
/// The signed-in account's `textures`, once the services answered.
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
use horizon_client::skins::{self, NameOrId, Resolve};
@@ -118,9 +118,28 @@ impl Game<'_> {
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
spectator: self.spectator(),
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
night_vision: self.fog_night_vision(),
},
)
}
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
fn fog_night_vision(&self) -> f32 {
let partial = self.clock.fraction() as f32;
match self.potions.effects.get("night_vision") {
Some(e) if self.potions.effects.get("darkness").is_none() => {
// MobEffectInstance.endsWithin(200); -1 is infinite.
if e.duration < 0 || e.duration > 200 {
1.0
} else {
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
}
}
_ => 0.0,
}
}
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
fn level_flat(&self) -> bool {
match self.remote.as_ref() {
@@ -340,6 +340,9 @@ impl Loading {
self.save_menu_settings();
self.apply_menu_settings();
}
Event::SliderDrag => {
self.apply_menu_settings();
}
Event::Close if self.menu.options.resource_error.is_some() => {}
Event::Close => {
self.menu.options_open = false;
+121 -5
View File
@@ -29,6 +29,8 @@ pub enum Ping {
motd: Text,
/// Online and max players; None when the status has no `players`.
players: Option<(i64, i64)>,
/// `ServerData.playerList`: shown over the player count.
player_list: Vec<Text>,
ms: u128,
version: String,
},
@@ -51,6 +53,26 @@ impl Ping {
.with_color(DARK_GRAY)
}
}
/// ServerStatusPinger: each sample name as a literal line (its `§` codes
/// styled as the font does), then `multiplayer.status.and_more` for the
/// players the sample leaves out.
fn player_list(players: &serde_json::Value, online: i64) -> Vec<Text> {
let Some(sample) = players["sample"].as_array().filter(|s| !s.is_empty()) else {
return Vec::new();
};
let mut lines: Vec<Text> = sample
.iter()
.map(|p| Text::literal(p["name"].as_str().unwrap_or_default()).resolve_legacy())
.collect();
let shown = sample.len() as i64;
if shown < online {
lines.push(Text::translate(
"multiplayer.status.and_more",
&[Text::literal((online - shown).to_string())],
));
}
lines
}
type Pings = Arc<Mutex<HashMap<String, Ping>>>;
/// Server icons by address as 64x64 RGBA (FaviconTexture), from servers.dat
/// and then from each ping's favicon.
@@ -157,6 +179,9 @@ fn ping_all(servers: &[ServerEntry], pings: &Pings, icons: &Icons, file: &Path)
p.get("max").and_then(|n| n.as_i64()).unwrap_or(0),
)
});
let player_list = players
.map(|(online, _)| player_list(&json["players"], online))
.unwrap_or_default();
let version = json["version"]["name"].as_str().unwrap_or("?").to_owned();
// A new favicon replaces the saved one (an invalid one
// clears it) and servers.dat is saved again.
@@ -175,6 +200,7 @@ fn ping_all(servers: &[ServerEntry], pings: &Pings, icons: &Icons, file: &Path)
Ping::Ok {
motd,
players,
player_list,
ms,
version,
}
@@ -367,6 +393,37 @@ fn ping_tooltip(
)),
})
}
/// OnlineServerEntry's player list, over the player count drawn left of
/// the status icon; `width` measures that count.
fn player_list_tooltip(
servers: &[ServerEntry],
pings: &HashMap<String, Ping>,
size: (f32, f32),
scroll: f32,
cursor: (f32, f32),
width: impl Fn(&Text) -> f32,
) -> Option<Vec<Text>> {
let Row::Server(i) = row_at(servers.len(), 0, cursor, size, scroll)? else {
return None;
};
let Some(Ping::Ok {
players,
player_list,
..
}) = pings.get(&servers[i].ip)
else {
return None;
};
let (top, _, left) = list_area(size.0, size.1);
let icon_x = left + 2.0 + ROW_WIDTH - 4.0 - 15.0;
let y = top + 4.0 - scroll.trunc() + i as f32 * ROW_HEIGHT;
let w = width(&Ping::player_count(*players)).ceil();
let x = icon_x - w - 5.0;
if cursor.0 < x || cursor.0 >= x + w || cursor.1 < y || cursor.1 >= y + 8.0 {
return None;
}
(!player_list.is_empty()).then(|| player_list.clone())
}
impl Screen {
/// Draw after the list and footer so the tooltip is neither clipped nor covered.
@@ -389,11 +446,15 @@ impl Screen {
bottom,
content_height(servers.len(), lan.games().len()),
);
if let Some(text) =
ping_tooltip(servers, &pings.lock().unwrap(), size, scroll.amount, cursor)
{
crate::ui::tooltip(batch, atlas, &[text], cursor);
}
let pings = pings.lock().unwrap();
let lines = match ping_tooltip(servers, &pings, size, scroll.amount, cursor) {
Some(text) => vec![text],
None => player_list_tooltip(servers, &pings, size, scroll.amount, cursor, |t| {
atlas.font.width_text(t)
})
.unwrap_or_default(),
};
crate::ui::tooltip(batch, atlas, &lines, cursor);
}
pub fn list(saves: &Path) -> Self {
@@ -1441,6 +1502,7 @@ mod tests {
Ping::Ok {
motd: Text::empty(),
players: None,
player_list: Vec::new(),
ms: 42,
version: String::new(),
},
@@ -1477,6 +1539,60 @@ mod tests {
);
}
#[test]
fn player_list_styles_sample_names_and_counts_the_rest() {
let players = serde_json::json!({
"online": 5,
"sample": [
{"name": "\u{a7}b\u{a7}lBIT\u{a7}rMADE"},
{"name": ""},
{"name": "\u{a7}8\u{2022}\u{a7}r \u{a7}bLobby"},
]
});
let lines = player_list(&players, 5);
assert_eq!(lines.len(), 4);
let plain: Vec<String> = lines.iter().take(3).map(Text::plain).collect();
assert_eq!(plain, ["BITMADE", "", "\u{2022} Lobby"]);
assert_eq!(player_list(&players, 3).len(), 3);
assert!(player_list(&serde_json::json!({"online": 2}), 2).is_empty());
assert!(player_list(&serde_json::json!({"online": 2, "sample": []}), 2).is_empty());
}
#[test]
fn player_list_tooltip_covers_only_the_player_count() {
let servers = vec![ServerEntry::new("Test", "server0")];
let size = (427.0, 240.0);
let mut pings = HashMap::new();
let hover = |pings: &HashMap<String, Ping>, cursor| {
player_list_tooltip(&servers, pings, size, 0.0, cursor, |_| 20.0)
.map(|l| l.iter().map(Text::plain).collect::<Vec<_>>())
};
assert!(hover(&pings, (324.0, 37.0)).is_none());
let ok = |player_list| Ping::Ok {
motd: Text::empty(),
players: Some((0, 500)),
player_list,
ms: 1,
version: String::new(),
};
pings.insert("server0".into(), ok(vec![Text::literal("Lobby")]));
// The 20-wide count ends 5 pixels left of the bars at (349, 37).
for cursor in [(324.0, 37.0), (343.9, 44.9)] {
assert_eq!(hover(&pings, cursor), Some(vec!["Lobby".to_owned()]));
}
for cursor in [
(323.9, 37.0),
(344.0, 37.0),
(324.0, 36.9),
(324.0, 45.0),
(349.0, 37.0),
] {
assert!(hover(&pings, cursor).is_none(), "{cursor:?}");
}
pings.insert("server0".into(), ok(Vec::new()));
assert!(hover(&pings, (324.0, 37.0)).is_none());
}
#[test]
fn the_lan_header_follows_the_saved_servers() {
let all: Vec<Row> = rows(2, 1).collect();
@@ -358,7 +358,7 @@ impl Title {
let (x0, tw) = tabs(w);
let right = x0 + tw * TABS.len() as f32;
for (x, width) in [(0.0, x0), (right, w - right)] {
ui::tiled(
ui::tiled_sized(
batch,
atlas,
"gui/header_separator",
@@ -366,7 +366,7 @@ impl Title {
TAB_BAR - 2.0,
width,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
}
@@ -441,7 +441,7 @@ impl Title {
_ => {}
}
// The footer separator, drawn over the screen.
ui::tiled(
ui::tiled_sized(
batch,
atlas,
"gui/footer_separator",
@@ -449,7 +449,7 @@ impl Title {
h - FOOTER - 2.0,
w,
2.0,
32.0,
[32.0, 2.0],
u32::MAX,
);
}
+1 -1
View File
@@ -125,7 +125,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| `potion ITEM POTION` | `potion` | Give a potion item | `item` (potion, splash_potion, lingering_potion, tipped_arrow, required): which item<br>`potion` (string, required): potion id |
| `fill X1 Y1 Z1 X2 Y2 Z2 BLOCK` | `fill` (destructive) | Set a box of loaded blocks | `x1` (integer, required): corner X<br>`y1` (integer, required): corner Y<br>`z1` (integer, required): corner Z<br>`x2` (integer, required): opposite X<br>`y2` (integer, required): opposite Y<br>`z2` (integer, required): opposite Z<br>`block` (string, required): block state |
| `container X Y Z [ITEM COUNT]` | `container` | List a container's items, or add items to it | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`item` (string): item to add<br>`count` (integer): how many |
| `render-distance N` | CLI only | Chunks, 2..64 | `chunks` (integer, required): render distance |
| `render-distance N` | CLI only | Chunks, 2..128 | `chunks` (integer, required): render distance |
| `wait TICKS` | `wait` | Wait until TICKS game ticks have passed (20 per second) | `ticks` (integer, required): game ticks |
## Scripting and benchmarks
+1 -1
View File
@@ -48,7 +48,7 @@ Development builds that link quickly: `cargo build --profile fast -p horizon-cli
| `--world NAME` | World folder under `saves/` (default "New World"); reopened if it exists |
| `--seed N` | Seed for a new world (numbers or text, like vanilla) |
| `--creative` | Creative mode (instant breaking, pick block) |
| `--render-distance N` | Chunks, 2..64 (default 12) |
| `--render-distance N` | Chunks, 2..128 (default 12) |
| `--resources PATH` | Prepared resource directory |
| Control | Action |
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Compare options.txt loading and saving, and the key mapping table, with the
pinned client: every file is loaded through the real Options.load and written
back with Options.save. The Rust probe normalizes Horizon's intentional 33..64
back with Options.save. The Rust probe normalizes Horizon's intentional 33..128
render-distance extension to vanilla's default; a Rust slider/persistence
regression covers the extended range."""
from pathlib import Path
@@ -184,7 +184,7 @@ def main():
'fields, lenient Gson parsing and syntax-error aborts, key mappings (names, scancode-number forms, unbinding), '
'sound volumes, model parts, line endings, the OPTIONS data fixers across version boundaries, and the '
'KeyMapping table (order, defaults, categories, toggle mappings). Does not cover option screens or '
'runtime side effects of setting an option. Horizon render distances 33..64 are normalized to vanilla\'s '
'runtime side effects of setting an option. Horizon render distances 33..128 are normalized to vanilla\'s '
'default by options_probe; the slider/persistence unit regression checks that intentional extension.')
harness.differential(
'rust-options-parity', 'VanillaOptionsOracle', 'options_probe', make_cases, scope,