mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edfb0e31d5 | ||
|
|
3828190801 | ||
|
|
866f9b4f12 | ||
|
|
5d040b68c6 | ||
|
|
591c7293d1 | ||
|
|
19ab314a7f | ||
|
|
477dcb8f3f | ||
|
|
d392a6a097 | ||
|
|
6adb9f5ed1 | ||
|
|
9edc21fbc4 | ||
|
|
d5e860248f | ||
|
|
71e0678303 | ||
|
|
dd130f1902 | ||
|
|
61713b8359 | ||
|
|
345f64e47c | ||
|
|
8f84c2cd48 | ||
|
|
e0a48b3f66 | ||
|
|
d35efa25ec | ||
|
|
b8b15b1682 | ||
|
|
4d0962e611 | ||
|
|
66b705cec3 | ||
|
|
e5ca012ed8 | ||
|
|
758d66bf44 | ||
|
|
b201dafa75 | ||
|
|
7c58bf004f | ||
|
|
4119284a4f | ||
|
|
36ebd0c545 |
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user