Merge main into #9854: placement cases after the wake and used-stat cases

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-05 15:42:35 +01:00
commit ada2c6b810
239 files changed
+11473 -1912

No files matched your search

Generated
+1
View File
@@ -1318,6 +1318,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"dunce",
"flate2",
"fs4",
"gilrs",
+5
View File
@@ -184,6 +184,11 @@ ported to Rust; the pack_uniforms.rs functions drop JOML's property flags, and
`pack_projection` works the formula in doubles. The copyright line and license notice also head
both files.
`camera.rs` also adapts `Quaternionf.rotationX`, `rotationY` and `rotationZ`,
`Matrix4f.rotateAffine` and `Vector4f.mulAffine` (`joml::Quat::rotation_x`/`_y`/`_z`,
`joml::Mat4::rotate_affine`, `transform_affine`), for the sky's positions shader packs read.
Changes: ported to Rust; `rotate_affine` keeps only the affine case.
```
The MIT License
@@ -7,7 +7,7 @@
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{
Result,
bidi::visual_str,
bidi::visual,
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
@@ -639,7 +639,9 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
}
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour.
/// full-bright with an 8x outline in the dark colour. The font colours each
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
/// else the sign's. A line is one call per run of one colour.
fn sign_text_calls(
scene: &Scene,
data: &SignData,
@@ -662,25 +664,53 @@ fn sign_text_calls(
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).advance;
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
text.lines
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = visual_str(&shown).into_owned();
TextCall {
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
for i in 0..text.lines.len() {
// The line that fits, in the order it is drawn (getVisualOrder).
let line = text.line_text(i);
let line = line.resolved();
let flat: String = line.chars().map(|(c, _)| c).collect();
let fits = first_line(&flat, data.max_line_width(), &advance);
let shown = line.slice(0, fits.chars().count());
let shown = visual(&shown);
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
let x = -((width as i32) / 2) as f32;
let y = i as f32 * line_height - midpoint;
let first = calls.len();
let mut pen = 0.0;
for span in shown.spans.iter().filter(|s| !s.text.is_empty()) {
let run = span
.style
.color
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
match calls[first..].last_mut() {
Some(last) if last.color == run => last.text.push_str(&span.text),
_ => calls.push(TextCall {
matrix: m,
x: x + pen,
y,
text: span.text.clone(),
color: run,
outline,
light,
}),
}
pen += span.text.chars().map(advance).sum::<f32>();
}
if calls.len() == first {
// A line with no text is still a call, of no glyphs.
calls.push(TextCall {
matrix: m,
x: -((width(&shown) as i32) / 2) as f32,
y: i as f32 * line_height - midpoint,
text: shown,
x,
y,
text: String::new(),
color,
outline,
light,
}
})
.collect()
});
}
}
calls
}
/// How far the text of an outlined call sits in front of its outline, in
@@ -3467,6 +3497,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
mod tests {
use super::*;
use crate::assets::Assets;
use horizon_world::nbt::Tag;
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn entity_block_particles_and_breaking_use_exported_models() -> Result<()> {
@@ -3976,6 +4007,129 @@ mod tests {
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
/// The front text calls of a sign whose every glyph advances 6.
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
advance: 6.0,
..Glyph::default()
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
.into_iter()
.map(|c| (c.text, c.x, c.y, c.color, c.outline))
.collect()
}
fn sign_with(lines: [Tag; 4]) -> SignData {
let mut data = SignData::default();
data.decode(&Tag::compound([(
"front_text",
Tag::compound([("messages", Tag::List(lines.to_vec()))]),
)]));
data
}
fn colored(text: &str, color: &str) -> Tag {
Tag::compound([
("text", Tag::String(text.into())),
("color", Tag::String(color.into())),
])
}
/// `Font.StringRenderOutput`: a glyph takes its style's colour and the
/// sign's dark text colour only when the style has none (#9783). A line
/// of two colours is two calls, the second where the first ends, the
/// whole line centred.
#[test]
fn sign_text_takes_each_lines_own_colors() {
let mut two = colored("ab", "red");
let Tag::Compound(parts) = &mut two else {
unreachable!()
};
parts.insert("extra".into(), Tag::List(vec![colored("cd", "blue")]));
let data = sign_with([
Tag::String("plain".into()),
colored("Online", "green"),
two,
Tag::String("".into()),
]);
// Black dye: the dark text colour is black. Sign lines are 10 apart
// from 20 above the middle.
assert_eq!(
front_calls(&data, false),
[
("plain".to_owned(), -15.0, -20.0, 0xFF00_0000, 0),
("Online".to_owned(), -18.0, -10.0, 0xFF55_FF55, 0),
("ab".to_owned(), -12.0, 0.0, 0xFFFF_5555, 0),
("cd".to_owned(), 0.0, 0.0, 0xFF55_55FF, 0),
(String::new(), 0.0, 10.0, 0xFF00_0000, 0),
]
);
}
/// A glowing sign's own colour still wins over its dye, and the outline
/// stays the dye's dark colour. `§` codes in a line colour it too, and
/// `§r` goes back to the sign's colour.
#[test]
fn glowing_sign_text_keeps_its_outline_under_a_lines_own_colors() {
let mut data = sign_with([
Tag::String("plain".into()),
colored("Online", "green"),
Tag::String("§cRed§rX".into()),
Tag::String("".into()),
]);
data.front.color = 14;
data.front.glowing = true;
let (red, outline) = (0xFFFF_0000, 0xFF66_0000);
assert_eq!(
front_calls(&data, true),
[
("plain".to_owned(), -15.0, -20.0, red, outline),
("Online".to_owned(), -18.0, -10.0, 0xFF55_FF55, outline),
("Red".to_owned(), -12.0, 0.0, 0xFFFF_5555, outline),
("X".to_owned(), 6.0, 0.0, red, outline),
(String::new(), 0.0, 10.0, red, outline),
]
);
}
/// The text that fits the sign is drawn, in the colour it had.
#[test]
fn a_long_colored_line_is_cut_where_it_stops_fitting() {
// 90 wide: fifteen glyphs of 6.
let data = sign_with([
colored("abcdefghijklmnopqrst", "gold"),
Tag::String("".into()),
Tag::String("".into()),
Tag::String("".into()),
]);
assert_eq!(
front_calls(&data, false)[0],
("abcdefghijklmno".to_owned(), -45.0, -20.0, 0xFFFF_AA00, 0)
);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
+88 -2
View File
@@ -1,5 +1,6 @@
// `joml::Mat4::rotation` and `invert_orthonormal` in this file are adapted from JOML 1.10.9's
// `Matrix4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
// `joml::Mat4::rotation`, `rotate_affine`, `transform_affine` and `invert_orthonormal`, and
// `joml::Quat::rotation_x`/`_y`/`_z`, in this file are adapted from JOML 1.10.9's `Matrix4f`,
// `Quaternionf` and `Vector4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
//
// Copyright (c) 2015-2026 Richard Greenlees
//
@@ -142,6 +143,40 @@ pub mod joml {
w: w * cz + z * sz,
}
}
/// `Quaternionf.rotationX(angle)`.
pub fn rotation_x(angle: f32) -> Self {
let (x, w) = Self::half_turn(angle);
Self {
x,
y: 0.0,
z: 0.0,
w,
}
}
/// `Quaternionf.rotationY(angle)`.
pub fn rotation_y(angle: f32) -> Self {
let (y, w) = Self::half_turn(angle);
Self {
x: 0.0,
y,
z: 0.0,
w,
}
}
/// `Quaternionf.rotationZ(angle)`.
pub fn rotation_z(angle: f32) -> Self {
let (z, w) = Self::half_turn(angle);
Self {
x: 0.0,
y: 0.0,
z,
w,
}
}
fn half_turn(angle: f32) -> (f32, f32) {
let sin = sin(angle * 0.5);
(sin, cos_from_sin(sin, angle * 0.5))
}
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
let [x, y, z] = v;
@@ -336,6 +371,57 @@ pub mod joml {
self.set(2, 2, z2 - y2 - x2 + w2);
self.properties = AFFINE | ORTHONORMAL;
}
/// An affine matrix of no more specific kind, as JOML's property flags have it.
pub fn affine(m: glam::Mat4) -> Self {
Self {
m: m.to_cols_array(),
properties: AFFINE,
}
}
/// `Matrix4f.rotate(Quaternionfc)` of an affine matrix that is neither the identity nor
/// a pure translation (`rotateAffine`).
pub fn rotate_affine(&mut self, q: Quat) {
debug_assert!(
self.properties & AFFINE != 0 && self.properties & (IDENTITY | TRANSLATION) == 0
);
let (w2, x2, y2, z2) = (q.w * q.w, q.x * q.x, q.y * q.y, q.z * q.z);
let zw = q.z * q.w;
let dzw = zw + zw;
let xy = q.x * q.y;
let dxy = xy + xy;
let xz = q.x * q.z;
let dxz = xz + xz;
let yw = q.y * q.w;
let dyw = yw + yw;
let yz = q.y * q.z;
let dyz = yz + yz;
let xw = q.x * q.w;
let dxw = xw + xw;
let r = [
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
];
let s = *self;
for (column, r) in r.iter().enumerate() {
for row in 0..3 {
let v = s.get(0, row) * r[0] + s.get(1, row) * r[1] + s.get(2, row) * r[2];
self.set(column, row, v);
}
self.set(column, 3, 0.0);
}
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
}
/// `Vector4f.mul(Matrix4fc)` of an affine matrix (`mulAffine`, JOML's `Math.fma`
/// unfused): the matrix times `v`, `w` kept.
pub fn transform_affine(&self, v: [f32; 4]) -> [f32; 3] {
debug_assert!(self.properties & AFFINE != 0);
let [x, y, z, w] = v;
std::array::from_fn(|row| {
self.get(0, row) * x
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row) * w))
})
}
/// `Matrix4f.invert()` of a rotation and translation (`invertOrthonormal`; with the
/// identity and pure-translation shortcuts). `None` for any other matrix, which JOML
/// inverts by other code paths.
@@ -431,6 +431,16 @@ pub struct SignText {
pub glowing: bool,
/// The `click_event` in each line's component style, as saved.
pub click: [Option<Tag>; 4],
/// Each line's saved component when it is more than plain text (a colour,
/// siblings, a list), so its style survives loading, saving and the
/// network. See `SignText::message` for a line edited since.
pub saved: [Option<SavedLine>; 4],
}
/// A sign line's saved component and the text it showed when it was read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SavedLine {
pub text: String,
pub component: Tag,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
File diff suppressed because it is too large. Load diff
+57 -3
View File
@@ -166,9 +166,17 @@ pub struct Extras {
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_auras: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -225,7 +233,10 @@ impl Default for Extras {
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_auras: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -335,6 +346,9 @@ impl Extras {
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -345,7 +359,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\n{}:{}\n",
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
@@ -399,6 +413,11 @@ impl Extras {
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_auras),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -574,6 +593,9 @@ pub enum Item {
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
@@ -609,8 +631,11 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsAuras),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
@@ -642,6 +667,9 @@ impl Item {
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -671,6 +699,9 @@ impl Item {
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -709,6 +740,9 @@ impl Item {
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -724,7 +758,7 @@ impl Item {
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsAuras => {
| CosmeticsOthers | CosmeticsAuras | CosmeticsSolo | HorizonMarks | HorizonMe => {
return None;
}
})
@@ -789,6 +823,15 @@ impl Item {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -815,7 +858,12 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine | Item::CosmeticsOthers | Item::CosmeticsAuras => Some(Cost::Low),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsAuras
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -842,6 +890,9 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::CosmeticsAuras => e.cosmetics_auras,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -903,6 +954,9 @@ impl Item {
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
+111 -7
View File
@@ -7635,20 +7635,22 @@ impl Renderer {
shadow_visible.clear();
if let Some(v) = &shadow_view {
let radius = (frame.detail_distance / 16.0) as i32;
let radius = v
.distance
.map_or(radius, |d| radius.min((d / 16.0).ceil() as i32));
let light = ShadowCull {
light: Frustum {
planes: frustum(v.cull),
},
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
reach: v.distance,
};
crate::spans::time("shadow visibility", || {
// Within the unculled distance every section, then those in the light's box.
if let Some(d) = v.unculled {
let all = Frustum {
planes: [glam::Vec4::W; 6],
let all = ShadowCull {
light: Frustum {
planes: [glam::Vec4::W; 6],
},
casters: None,
reach: v.distance,
};
let near = radius.min((d / 16.0).ceil() as i32);
self.sections.visible(
@@ -11390,6 +11392,25 @@ impl Cull for Frusta {
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
@@ -11400,14 +11421,16 @@ const SHADOW_CASTER_MARGIN: f32 = 1.15;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.light.intersects(min, max)
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.light.contains(min, max)
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
@@ -12266,6 +12289,48 @@ mod tests {
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}: {e:?}", path.display()));
// A select between integer vectors on a vector of bools becomes GLSL's
// mix(uvec3, uvec3, bvec3) (GLSL 4.5 or EXT_shader_integer_mix). Old AMD OpenGL
// drivers (Radeon HD 7400M, driver 15.301) pick the float overload and refuse to
// compile the terrain shader (#9892). Scalar selects become `?:` and work everywhere.
let functions = module
.functions
.iter()
.map(|(handle, f)| (f, &info[handle]))
.chain(
module
.entry_points
.iter()
.enumerate()
.map(|(i, e)| (&e.function, info.get_entry_point(i))),
);
for (function, function_info) in functions {
for (handle, expression) in function.expressions.iter() {
let naga::Expression::Select {
condition, accept, ..
} = *expression
else {
continue;
};
let vector_of = |h: naga::Handle<naga::Expression>| match *function_info[h]
.ty
.inner_with(&module.types)
{
naga::TypeInner::Vector { scalar, .. } => Some(scalar.kind),
_ => None,
};
assert!(
vector_of(condition).is_none()
|| !matches!(
vector_of(accept),
Some(naga::ScalarKind::Uint | naga::ScalarKind::Sint)
),
"{} {:?}: select between integer vectors ({handle:?}): use scalar selects",
path.display(),
function.name
);
}
}
for entry in &module.entry_points {
let (module, info) = naga::back::pipeline_constants::process_overrides(
&module,
@@ -13352,6 +13417,7 @@ mod shadow_caster_tests {
planes: [Vec4::W; 6],
},
casters: Some(shadow_casters(camera, light)),
reach: None,
};
let min = Vec3::from(min);
cull.intersects(min, min + 16.0)
@@ -13398,6 +13464,7 @@ mod shadow_caster_tests {
planes: [Vec4::W; 6],
},
casters: Some(shadow_casters(camera, Vec3::Y)),
reach: None,
};
let min = Vec3::new(
column[0] * 16.0 - 13.5,
@@ -13412,6 +13479,42 @@ mod shadow_caster_tests {
assert!(!kept(7.5, [3.0, 0.0]));
}
/// With `shadowDistanceRenderMul`, our probe's Iris 1.11.7 maps keep the sections within
/// `shadowDistance × mul` of the camera along each axis, out to the cube's corners, and with
/// some slack (the camera at (13.5, 69.62, -13.5)):
/// - at 100 blocks, the column at chunk (7, 5), 98.5 and 93.5 blocks out (136 diagonally), and
/// the one at (-6, -7) are kept; the one at (8, -4), 114.5 out on x, is not;
/// - at 49.5 blocks, the column at (-2, -5), 50.5 out on z, is kept; at 49 it is not;
/// - at 50 blocks from a camera at y 151.62, the ground's sections 70 blocks below are not.
#[test]
fn the_shadow_distance_render_mul_keeps_a_cube() {
let kept = |camera: DVec3, reach: f32, keys: &[(i32, i32)]| {
let y = (64 - crate::chunk::MIN_Y) / 16;
let mut sections = resident::Resident::<()>::default();
for &(x, z) in keys {
sections.insert_with_bounds((x, y, z), (), None);
}
let cull = ShadowCull {
light: Frustum {
planes: [Vec4::W; 6],
},
casters: None,
reach: Some(reach),
};
let mut out = Vec::new();
sections.visible(camera, 12, &cull, |_| true, |_, _| false, &mut out);
let mut kept: Vec<_> = out.iter().map(|v| (v.key.0, v.key.2)).collect();
kept.sort_unstable();
kept
};
let ground = DVec3::new(13.5, 69.62, -13.5);
let columns = [(7, 5), (8, -4), (-6, -7)];
assert_eq!(kept(ground, 100.0, &columns), [(-6, -7), (7, 5)]);
assert_eq!(kept(ground, 49.5, &[(-2, -5)]), [(-2, -5)]);
assert_eq!(kept(ground, 49.0, &[(-2, -5)]), []);
assert_eq!(kept(DVec3::new(13.5, 151.62, -13.5), 50.0, &[(0, 0)]), []);
}
/// With the camera looking straight up under a noon sun, our probe's Iris 1.11.7 map holds
/// 7 columns there, which testing whole sections reproduces (a guess): the sections beside
/// the camera whose 16 blocks reach above it are kept, even where their terrain lies below
@@ -13439,6 +13542,7 @@ mod shadow_caster_tests {
let shadow = ShadowCull {
light: all,
casters: Some(shadow_casters(up, Vec3::Y)),
reach: None,
};
assert_eq!(
drawn(&|out| sections.visible(camera, 4, &shadow, |_| true, |_, _| false, out)),
@@ -111,6 +111,8 @@ enum SamplerKind {
Nearest,
Repeat,
NearestRepeat,
/// The block atlas: nearest texels, blended between the two nearest levels.
Atlas,
Comparison,
}
@@ -128,7 +130,10 @@ impl SamplerKind {
}
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
// Blocks look as the game draws them: nearest texels. Between levels, Iris 1.11.7
// blends `gtexture`'s two nearest (our probe packs, #9307: a read at level 1.25 is
// levels 1 and 2 mixed 3:1).
Source::Atlas if filtering => Self::Atlas,
Source::Atlas | Source::Normals | Source::Specular => Self::NearestRepeat,
Source::Image(_) if filtering => Self::Linear,
Source::Image(_) => Self::Nearest,
@@ -146,7 +151,7 @@ impl SamplerKind {
fn binding_type(self) -> wgpu::SamplerBindingType {
match self {
Self::Linear | Self::Repeat => wgpu::SamplerBindingType::Filtering,
Self::Linear | Self::Repeat | Self::Atlas => wgpu::SamplerBindingType::Filtering,
Self::Nearest | Self::NearestRepeat => wgpu::SamplerBindingType::NonFiltering,
Self::Comparison => wgpu::SamplerBindingType::Comparison,
}
@@ -647,6 +652,7 @@ pub struct PackPost {
nearest: wgpu::Sampler,
repeat: wgpu::Sampler,
nearest_repeat: wgpu::Sampler,
atlas_sampler: wgpu::Sampler,
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
/// nor buffer to clear.
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
@@ -2941,6 +2947,13 @@ impl PackPost {
),
repeat: sampler(device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat),
nearest_repeat: sampler(device, wgpu::FilterMode::Nearest, wgpu::AddressMode::Repeat),
atlas_sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack atlas"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
}),
blit,
copy,
uniforms: {
@@ -3000,7 +3013,15 @@ impl PackPost {
let n = head.len().min(pass.block.len());
pass.block[..n].copy_from_slice(&head[..n]);
for (index, ty, offset) in &pass.uniforms {
if let Some(v) = index.and_then(|i| self.uniforms.values.get(i)) {
let Some((i, v)) = index.and_then(|i| Some((i, self.uniforms.values.get(i)?))) else {
continue;
};
// A built-in of another type keeps its initial value (0); custom uniforms were
// checked when the pass was built.
if pack_uniforms::NAMES
.get(i)
.is_none_or(|name| v.read_as(name, ty))
{
v.write(ty, &mut pass.block, *offset as usize);
}
}
@@ -3144,6 +3165,7 @@ impl PackPost {
SamplerKind::Nearest => &self.nearest,
SamplerKind::Repeat => &self.repeat,
SamplerKind::NearestRepeat => &self.nearest_repeat,
SamplerKind::Atlas => &self.atlas_sampler,
SamplerKind::Comparison => shadow.map_or(&self.shadow.1, |t| &t.comparison),
};
entries.push(wgpu::BindGroupEntry {
@@ -5569,6 +5591,12 @@ mod const_offset_tests;
#[cfg(test)]
mod integer_colortex0_tests;
#[cfg(test)]
mod atlas_mip_tests;
#[cfg(test)]
mod wrong_type_uniform_tests;
#[cfg(test)]
mod atlas_name_post_tests;
@@ -0,0 +1,108 @@
//! The block atlas is read with nearest texels, blended between the two nearest levels: our
//! probe packs (#9307) read `gtexture` in gbuffers_terrain, and the atlas as a custom texture in
//! composite, at levels 0.5 and 1.25 in Iris 1.11.7, and got the two nearest levels mixed.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv = gl_MultiTexCoord0.xy; gl_Position = ftransform(); }\n";
/// The atlas as composite's `noisetex`, read at levels 0.5 (red) and 0.25 (green).
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders/shaders.properties",
"texture.composite.noisetex=minecraft:textures/atlas/blocks.png\n",
);
add("shaders/composite.vsh", VERTEX);
add(
"shaders/composite.fsh",
"#version 130\nuniform sampler2D noisetex;\nvoid main() {\n/* DRAWBUFFERS:0 */\n\
\x20 gl_FragData[0] = vec4(textureLod(noisetex, vec2(0.5), 0.5).r, textureLod(noisetex, vec2(0.5), 0.25).r, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-atlas-levels",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
/// A 4x4 atlas whose first level is black and second white.
fn atlas(gpu: &Fixture) -> wgpu::TextureView {
let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
label: Some("Original two-level atlas"),
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 2,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
for (level, side, value) in [(0u32, 4u32, 0u8), (1, 2, 255)] {
gpu.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: level,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&[value, value, value, 255].repeat((side * side) as usize),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(side * 4),
rows_per_image: None,
},
wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: 1,
},
);
}
texture.create_view(&Default::default())
}
#[test]
fn the_atlas_blends_its_two_nearest_levels() {
let Some(gpu) = Fixture::new() else { return };
// The pack reads no lightmap: any texture serves.
let lightmap = atlas(&gpu);
let mut post = PackPost::new(
&gpu.device,
&gpu.queue,
&gpu.adapter,
pack(),
wgpu::TextureFormat::Rgba8Unorm,
World {
atlas: Some((atlas(&gpu), [4, 4])),
material_maps: None,
lightmap,
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
)
.expect("the pack runs");
for p in gpu.render(&mut post, [4, 4]) {
// Half of white, and a quarter (within the blend's 8-bit weight).
assert!(p[0].abs_diff(128) <= 2 && p[1].abs_diff(64) <= 2, "{p:?}");
}
}
@@ -0,0 +1,56 @@
//! A built-in uniform declared with another GLSL type than the one Iris 1.11.7 gives is disabled
//! there ("Wrong uniform type ... Disabling that uniform") and reads 0; our probe packs (#9307)
//! read 0 for each such declaration. Declared with its own type, it keeps its value.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv = gl_MultiTexCoord0.xy; gl_Position = ftransform(); }\n";
/// composite declares two floats as `int` and `vec2` and an int as `float`, and writes what it
/// reads into colortex1; final declares the same names with their own types and adds them.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add("final.vsh", VERTEX);
add(
"composite.fsh",
"#version 130\nuniform int viewWidth;\nuniform vec2 viewHeight;\nuniform float fogShape;\n\
void main() {\n/* DRAWBUFFERS:1 */\n\
\x20 gl_FragData[0] = vec4(float(viewWidth), viewHeight.x, fogShape + 1.0, 255.0) / 255.0;\n}\n",
);
add(
"final.fsh",
"#version 130\nuniform sampler2D colortex1;\nuniform float viewWidth;\nuniform int fogShape;\n\
varying vec2 uv;\nvoid main() {\n\
\x20 vec4 c = texture(colortex1, uv);\n\
\x20 gl_FragColor = vec4(c.r, c.g + viewWidth * 4.0 / 255.0, c.b + float(fogShape + 2) * 10.0 / 255.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-wrong-uniform-types",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn built_ins_declared_with_another_type_read_zero() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// 8 pixels wide; post programs read `fogShape` -1. composite reads 0, 0 and 0 (+1 = 1);
// final reads 8 (green 32) and -1 (+2 = 1, blue 10).
for p in gpu.render(&mut post, [8, 8]) {
assert_eq!(p, [0, 32, 11, 255]);
}
}
@@ -155,6 +155,31 @@ impl Value {
Value::I(a)
}
/// Whether a program that declares the built-in `name` as `ty` reads this value. Iris 1.11.7
/// disables a built-in declared with another GLSL type than the one it gives ("Wrong uniform
/// type ... Disabling that uniform"), and the program reads 0. Our probe packs saw it for a
/// float declared `int` or `vec2`, an int `float`, an `ivec2` `vec2`, `int` or `ivec3`, a
/// `vec3` `vec2` or `vec4`, a `mat4` `mat3`, and any `uint`; an int or a bool built-in
/// declared `bool` or `int` was kept. A float declared `bool` is left as it was: Iris warned,
/// but our probe read true.
pub fn read_as(&self, name: &str, ty: &str) -> bool {
let int = matches!(self, Value::I(_));
let (declared_int, size) = match ty {
"float" => (false, 1),
"bool" if !int => return true,
"int" | "bool" => (true, 1),
"mat2" => (false, 4),
"mat3" => (false, 9),
"mat4" => (false, 16),
t if t.starts_with('u') => return false,
t if let Some(n) = t.strip_prefix("ivec") => (true, n.parse().unwrap_or(0)),
t if let Some(n) = t.strip_prefix("vec") => (false, n.parse().unwrap_or(0)),
_ => return true,
};
let name = name.strip_prefix("gbuffers.").unwrap_or(name);
declared_int == int && size == components(name)
}
/// Writes the value as the type the pack declared, at `offset` of a std140 block.
pub fn write(&self, ty: &str, out: &mut [u8], offset: usize) {
let ints = matches!(ty, "int" | "bool" | "uint") || ty.starts_with(['i', 'u']);
@@ -367,6 +392,25 @@ fn body(angle: f32, tilt: f32) -> Vec3 {
.transform_vector3(Vec3::new(0.0, 100.0, 0.0))
}
/// `sunPosition` and `moonPosition` (`angle`: the body's sky angle in degrees) and `upPosition`
/// (`angle` none) to the last bit, as Iris 1.11.7 gives them: our probes' readings (50: 25 ticks
/// of day and night, `sunPathRotation` 0 and 30, four view directions) fit `(0, 100, 0, 0)`
/// times `gbufferModelView` turned by JOML quaternions, in floats: `rotate(rotationY(-90°))`,
/// then for a body `rotate(rotationZ(sunPathRotation))` and `rotate(rotationX(angle))`. Turned
/// by matrices instead, as `body` and vanilla 26.3's sky do, 4e-6 stands where Iris has an exact
/// 0, and the rest differs by 1 to 2 ulps (#8464).
fn celestial(view: Mat4, tilt: f32, angle: Option<f32>) -> Vec3 {
use crate::camera::joml::{Mat4 as Joml, Quat};
use horizon_core::geometry::trig::DEG_TO_RAD;
let mut m = Joml::affine(view);
m.rotate_affine(Quat::rotation_y(-90.0 * DEG_TO_RAD));
if let Some(angle) = angle {
m.rotate_affine(Quat::rotation_z(tilt * DEG_TO_RAD));
m.rotate_affine(Quat::rotation_x(angle * DEG_TO_RAD));
}
Vec3::from_array(m.transform_affine([0.0, 100.0, 0.0, 0.0]))
}
/// `sunPathRotation`'s turn of the sky's bodies: `tilt` degrees about the world's X axis, so a
/// positive angle moves the noon sun toward -Z (north), as Iris does.
pub fn tilt_matrix(tilt: f32) -> Mat4 {
@@ -683,8 +727,8 @@ impl FrameUniforms {
let sun_angle = day_fraction(frame.sky.sun_angle);
let day = sun_angle < 0.5;
let tilt = self.shadow.sun_path_rotation;
let sun = to_view(body(frame.sky.sun_angle, tilt));
let moon = to_view(body(frame.sky.moon_angle, tilt));
let sun = celestial(view, tilt, Some(frame.sky.sun_angle));
let moon = celestial(view, tilt, Some(frame.sky.moon_angle));
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26), at
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
@@ -771,7 +815,7 @@ impl FrameUniforms {
"hasSkylight" => Value::i(&[i32::from(inputs.has_skylight)]),
"hasCeiling" => Value::i(&[i32::from(inputs.has_ceiling)]),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"upPosition" => v3(celestial(view, tilt, None)),
"sunPosition" => v3(sun),
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
"centerDepthSmooth" => Value::f(&[center_depth]),
@@ -1014,6 +1058,36 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// The declarations our probe packs (#9307) ran in Iris 1.11.7: kept, or read as 0.
#[test]
fn built_ins_are_read_only_as_iris_gives_them() {
let int = Value::i(&[1]);
let float = Value::f(&[0.25]);
let matrix = Value::f(&[1.0; 16]);
for (value, name, ty, kept) in [
(&int, "isSpectator", "bool", true),
(&int, "isSpectator", "int", true),
(&int, "worldTime", "bool", true),
(&int, "worldTime", "uint", false),
(&int, "worldTime", "float", false),
(&Value::i(&[2048, 2048]), "atlasSize", "ivec2", true),
(&Value::i(&[2048, 2048]), "atlasSize", "vec2", false),
(&Value::i(&[2048, 2048]), "atlasSize", "int", false),
(&Value::i(&[2048, 2048]), "atlasSize", "ivec3", false),
(&float, "sunAngle", "float", true),
(&float, "sunAngle", "vec2", false),
(&float, "viewWidth", "int", false),
(&Value::f(&[1.0, 2.0, 3.0]), "cameraPosition", "vec3", true),
(&Value::f(&[1.0, 2.0, 3.0]), "cameraPosition", "vec4", false),
(&Value::f(&[1.0, 2.0, 3.0]), "cameraPosition", "vec2", false),
(&matrix, "gbufferModelView", "mat4", true),
(&matrix, "gbufferModelView", "mat3", false),
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
] {
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
}
}
/// The load-time run of custom uniforms sees each built-in at its size: Iris 1.11.7 reads 2
/// for the first line of our probes K and X (#9086) and 0 for the others.
#[test]
@@ -1364,6 +1438,105 @@ mod initial_history_tests {
assert_eq!(float(&u, "sunAngle")[0].to_bits(), 0x3eb134f4);
}
/// `sunPosition`, `moonPosition`, `upPosition` and `shadowLightPosition` to the last bit, as
/// a probe pack of ours read them back in Iris 1.11.7: noon looking west, just after sunset
/// looking south (where Iris's sun is exactly 0 in z), and with `sunPathRotation = 30` at
/// 1000 ticks, at 13000 looking down 45° and at 12786 with yaw 200, pitch -30. The sky
/// angles are vanilla's timeline at those ticks.
#[test]
fn the_sun_moon_and_up_are_iris_s_to_the_last_bit() {
let hud = HudBatch::new([1280, 720]);
let mut f = frame(&hud);
let o = 0;
type Case = (f32, f32, f32, u32, u32, [[u32; 3]; 3], bool);
let cases: [Case; 5] = [
(
0.0,
90.0,
0.0,
0,
0x43340000,
[[o, 0x42c7fffe, o], [o, 0xc2c7fffe, o], [o, 0x42c7fffe, o]],
true,
),
(
0.0,
0.0,
0.0,
0x42b420b1,
0x4387082c,
[
[0x42c7fff7, 0xbde43dae, o],
[0xc2c7fff7, 0x3de4376e, o],
[o, 0x42c7ffff, o],
],
false,
),
(
30.0,
90.0,
0.0,
0x4394c5da,
0x43eec5da,
[
[0x41b8fb81, 0x42203312, 0x42b15411],
[0xc1b8fb8c, 0xc220331b, 0xc2b1540f],
[o, 0x42c7fffe, o],
],
true,
),
(
30.0,
0.0,
45.0,
0x42bb0aec,
0x4388c2bb,
[
[0x42c79f54, 0xbfcb7a52, 0xc0bdd8f1],
[0xc2c79f54, 0x3fcb7a25, 0x40bdd8c7],
[o, 0x428d6bdc, 0x428d6bde],
],
false,
),
(
30.0,
200.0,
-30.0,
0x42b420b1,
0x4387082c,
[
[0xc2bbe678, 0x41885944, 0x41edb502],
[0x42bbe677, 0xc1885947, 0xc1edb4fc],
[o, 0x42ad347e, 0xc247fffe],
],
false,
),
];
for (tilt, yaw, pitch, sun, moon, [sun_at, moon_at, up], day) in cases {
f.yaw = yaw;
f.pitch = pitch;
f.sky.sun_angle = f32::from_bits(sun);
f.sky.moon_angle = f32::from_bits(moon);
let mut u = FrameUniforms {
shadow: Shadow {
sun_path_rotation: tilt,
..Shadow::default()
},
..FrameUniforms::default()
};
u.update(&f, &PackInputs::default(), [1280, 720]);
let vector = |name: &str| -> [u32; 3] {
let v = bits(&u, name);
[v[0], v[1], v[2]]
};
assert_eq!(vector("sunPosition"), sun_at, "{sun:x}");
assert_eq!(vector("moonPosition"), moon_at, "{sun:x}");
assert_eq!(vector("upPosition"), up, "{sun:x}");
let light = if day { sun_at } else { moon_at };
assert_eq!(vector("shadowLightPosition"), light, "{sun:x}");
}
}
/// At night the moon casts the shadows, here with `sunPathRotation = 30`: Iris 1.11.7 at
/// 16000 ticks (the moon at 324.4897°), the camera at (-556.5, 76.62, 212.5), whose
/// negative x keeps its sign in the remainder.
+61 -518
View File
@@ -13,6 +13,7 @@ use crate::{
hud::{HudAtlas, HudBatch},
lang,
settings::{Opt, Settings},
settings_shell::{self as shell, Columns},
text::Text,
ui,
};
@@ -1207,23 +1208,7 @@ impl Item {
let Some(pos) = self.slider(s) else {
return;
};
let next_pos = if let Some(steps) = self.slider_steps() {
let steps = steps as f32;
let current = (pos * steps).round();
let next = if forward {
current + 1.0
} else {
current - 1.0
};
(next / steps).clamp(0.0, 1.0)
} else {
let delta = 1.0 / (width - 8.0).max(1.0);
if forward {
(pos + delta).min(1.0)
} else {
(pos - delta).max(0.0)
}
};
let next_pos = shell::slider_step(pos, self.slider_steps(), width, forward);
self.set_slider(s, next_pos);
self.touched(s);
}
@@ -1252,116 +1237,7 @@ pub enum Target {
Done,
}
/// A line of the list at `y` in its content: a page's name (only while
/// searching, which shows every page), a group's name with the height of the
/// panel its rows sit on, or a setting.
#[derive(Clone, Copy)]
struct Line {
y: f32,
page: Page,
group: Option<(&'static str, f32)>,
setting: Option<Setting>,
}
impl Line {
fn h(&self) -> f32 {
match (self.setting, self.group) {
(Some(_), _) => style::ROW,
(None, Some(_)) => style::GROUP_ROW,
(None, None) => style::PAGE_ROW,
}
}
}
/// The screen's parts in GUI pixels. Down the left: the mark, the search
/// field, the page list, and Undo and Done at the foot. On the right: the
/// plate with the open page's name and its list, and the strip under it that
/// describes the hovered setting. `list_x`, `list_w`, `top` and `bottom` are
/// the list's view.
#[derive(Clone, Copy, Debug)]
struct Columns {
side_x: f32,
side_y: f32,
plate: [f32; 4],
dock: [f32; 4],
list_x: f32,
list_w: f32,
top: f32,
bottom: f32,
}
impl Columns {
fn of(w: f32, h: f32) -> Self {
let side_w = style::CONTROL_W;
let gap = if w >= 560.0 { 16.0 } else { 8.0 };
let pw = (w - 16.0 - side_w - gap).min(432.0).floor();
let side_x = ((w - side_w - gap - pw) / 2.0).floor();
let px = side_x + side_w + gap;
// A short screen (a 720p window at scale 3) gives the margin to the list.
let side_y = if h >= style::TITLED { 24.0 } else { 8.0 };
let dock = [px, h - 8.0 - style::DOCK_H, pw, style::DOCK_H];
let plate = [px, side_y, pw, dock[1] - 4.0 - side_y];
let list_x = px + 8.0;
Self {
side_x,
side_y,
plate,
dock,
list_x,
list_w: pw - 16.0 - style::SCROLLER_W - 2.0,
top: side_y + 30.0,
bottom: plate[1] + plate[3] - 6.0,
}
}
/// The search field, under the mark.
fn search(&self) -> [f32; 4] {
[
self.side_x,
self.side_y + 26.0,
style::CONTROL_W,
style::SEARCH_H,
]
}
/// The search field's clear button, at its right end.
fn clear(&self) -> [f32; 4] {
let [x, y, w, h] = self.search();
[x + w - h, y, h, h]
}
/// Room for vanilla's scroller, whose thumb is 32 high and 8 short of its track.
fn scrolls(&self) -> bool {
self.bottom - self.top >= 40.0
}
fn bar_x(&self) -> f32 {
self.plate[0] + self.plate[2] - 8.0 - style::SCROLLER_W
}
/// A page in the page list, under the search field.
fn page(&self, i: usize) -> [f32; 4] {
[
self.side_x,
self.side_y + 56.0 + i as f32 * 18.0,
style::CONTROL_W,
16.0,
]
}
/// Done (`0`), and Undo over it (`1`): under the page list, level with the strip.
fn button(&self, i: usize) -> [f32; 4] {
let foot = self.dock[1] + self.dock[3] - style::BUTTON_H;
let y = if i == 0 {
foot
} else {
foot - 4.0 - style::BUTTON_H
};
[self.side_x, y, style::CONTROL_W, style::BUTTON_H]
}
/// A row's control column: its button, tick box or slider.
fn track(&self) -> (f32, f32) {
let x = self.list_x + self.list_w - style::INSET - style::CONTROL_W;
(x, style::CONTROL_W)
}
/// A slider's value for the pointer at `x`: the handle's middle follows it.
fn slider_at(&self, x: f32) -> f32 {
let (tx, tw) = self.track();
((x - tx - 5.0) / (tw - 10.0)).clamp(0.0, 1.0)
}
}
type Line = shell::Line<Page, Setting>;
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
fn shown(item: Item, s: &Settings) -> bool {
@@ -1376,18 +1252,7 @@ fn parts(item: Item, s: &Settings, binding: bool) -> (String, Option<String>) {
let key = if binding { format!("> {key} <") } else { key };
return (lang::get("Shortcut"), Some(key));
}
let label = item.label(s).plain();
// `options.generic_value` ("%s: %s") joins vanilla's; Horizon's use ": " too.
let format = lang::get("options.generic_value");
let sep = format
.split("%s")
.nth(1)
.filter(|sep| !sep.is_empty() && label.contains(*sep))
.unwrap_or(": ");
match label.split_once(sep) {
Some((name, value)) => (name.to_owned(), Some(value.to_owned())),
None => (label, None),
}
shell::name_and_value(&item.label(s).plain())
}
pub struct GraphicsUi {
@@ -1452,8 +1317,7 @@ impl GraphicsUi {
/// under the pointer.
fn lines(&self, s: &Settings) -> (Vec<Line>, f32) {
let query = self.query();
let mut lines = Vec::new();
let mut y: f32 = 0.0;
let mut lines = shell::Lines::new();
for (page, groups) in rows() {
if query.is_empty() && page != self.page {
continue;
@@ -1480,38 +1344,13 @@ impl GraphicsUi {
continue;
}
if !query.is_empty() {
if !lines.is_empty() {
y += style::PAGE_GAP;
}
lines.push(Line {
y,
page,
group: None,
setting: None,
});
y += style::PAGE_ROW;
lines.page(page);
}
for (name, rows) in groups {
lines.push(Line {
y,
page,
group: Some((name, rows.len() as f32 * style::ROW)),
setting: None,
});
y += style::GROUP_ROW;
for setting in rows {
lines.push(Line {
y,
page,
group: None,
setting: Some(setting),
});
y += style::ROW;
}
y += style::GROUP_GAP;
lines.group(page, name, rows);
}
}
(lines, (y - style::GROUP_GAP).max(0.0))
lines.finish()
}
/// The columns and the list, with the scroll kept within it.
fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Columns, Vec<Line>, f32) {
@@ -1619,99 +1458,44 @@ impl GraphicsUi {
self.keep(s);
let (w, h) = (batch.width, batch.height);
let (cols, lines, content) = self.layout(s, w, h);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
shell::backdrop(batch, atlas, in_world);
let current = self.current(&lines);
self.draw_side(batch, atlas, &cols, &lines, current, cursor);
self.draw_search(batch, atlas, &cols, cursor);
let focused = self.focus == Some(Target::Search);
shell::search_field(&mut self.search, batch, atlas, &cols, cursor, focused);
// The plate, with the open page's name (or the search's) over its list.
let [px, py, pw, _] = cols.plate;
style::panel(batch, atlas, cols.plate);
let query = self.search.value.trim();
let heading = if query.is_empty() {
lang::get(current.unwrap_or(self.page).name())
} else {
lang::get("Results for \"%s\"").replace("%s", query)
};
let heading = Text::literal(style::fit(atlas, &heading, pw - 24.0));
batch.text_styled(atlas, &heading, px + 12.0, py + 10.0, style::WHITE, true);
style::fill(
shell::plate(batch, atlas, &cols, &heading);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
shell::list(
batch,
atlas,
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
style::HEAD_RULE,
&cols,
&lines,
self.scroll.amount,
Page::name,
|batch, setting: Setting, y| {
let item = setting.item;
let rect = cols.row(y);
let hover = over_list && ui::hovered(cursor, rect[0], rect[1], rect[2], rect[3]);
let focused = self.focus == Some(Target::Item(item));
// The pointer's row wins over the keyboard's.
if hover || self.dragging == Some(item) || (focused && described.is_none()) {
described = Some(setting);
}
self.draw_row(batch, atlas, s, &cols, item, y, hover, focused);
},
);
// The list, clipped to its view: each group's panel, then its rows on it.
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let start = batch.vertices.len();
let mark = crate::widgets::targets_mark();
let mut described = None;
let shows = |y: f32, h: f32| y + h > cols.top && y < cols.bottom;
for line in &lines {
let y = self.row_y(&cols, line);
if let Some((_, panel)) = line.group
&& shows(y + style::GROUP_ROW, panel)
{
let rect = [cols.list_x, y + style::GROUP_ROW, cols.list_w, panel];
style::group(batch, atlas, rect);
}
}
// A group's first row, which has no rule over it.
let mut first = false;
for line in &lines {
let y = self.row_y(&cols, line);
let after_name = std::mem::replace(&mut first, line.group.is_some());
if !shows(y, line.h()) {
continue;
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some((name, _)) => (name, style::MUTED, 3.0),
None => (line.page.name(), style::ACCENT, 4.0),
};
let name = Text::literal(lang::get(name));
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
continue;
};
let item = setting.item;
if !after_name {
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
style::fill(batch, atlas, rule, style::ROW_RULE);
}
let rect = [cols.list_x, y, cols.list_w, style::ROW];
let hover = over_list && ui::hovered(cursor, rect[0], rect[1], rect[2], rect[3]);
let focused = self.focus == Some(Target::Item(item));
// The pointer's row wins over the keyboard's.
if hover || self.dragging == Some(item) || (focused && described.is_none()) {
described = Some(setting);
}
self.draw_row(batch, atlas, s, &cols, item, y, hover, focused);
}
if lines.is_empty() {
let none = Text::literal(lang::get("No settings match."));
batch.text_styled(
atlas,
&none,
cols.list_x + 2.0,
cols.top + 4.0,
style::MUTED,
true,
);
}
batch.clip_since(
start,
cols.list_x,
cols.top,
cols.list_x + cols.list_w,
cols.bottom,
);
crate::widgets::clip_targets(mark, cols.top, cols.bottom);
self.draw_scroller(batch, atlas, &cols, content);
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
self.draw_dock(batch, atlas, s, &cols, described, current);
let hovered = |[x, y, w, h]: [f32; 4]| ui::hovered(cursor, x, y, w, h);
let mut foot = vec![(cols.button(0), "Done", Target::Done)];
// Undo only once there is something to undo.
if self.changed {
@@ -1720,20 +1504,14 @@ impl GraphicsUi {
for (rect, label, target) in foot {
let label = Text::literal(lang::get(label));
let focused = self.focus == Some(target);
let hover = hovered(rect) || focused;
crate::widgets::record(&label.plain(), rect, true);
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(label.plain(), rect, true)
.hovered(hovered(rect))
.focused(focused)
});
crate::widgets::widget_cursor(hovered(rect), true);
crate::widgets::target(rect);
let primary = target == Target::Done;
if primary {
crate::widgets::declare_action(crate::widgets::Action::Done, rect, true);
}
style::button(batch, atlas, rect, &label, (hover, true, primary));
let declared = if primary {
crate::widgets::Action::Done
} else {
crate::widgets::Action::Apply
};
crate::widgets::declare_action(declared, rect, true);
shell::foot_button(batch, atlas, rect, &label, cursor, (focused, primary));
}
}
/// The mark over the search field, and the page list: the open page on a
@@ -1747,85 +1525,14 @@ impl GraphicsUi {
current: Option<Page>,
cursor: (f32, f32),
) {
let (x, y) = (cols.side_x, cols.side_y);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
let horizon = Text::literal("Horizon");
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
let title = Text::literal(lang::get("Graphics"));
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
shell::mark(batch, atlas, cols, &lang::get("Graphics"));
for (i, page) in Page::ALL.into_iter().enumerate() {
let rect = cols.page(i);
let [px, py, pw, ph] = rect;
let has = self.has(page, lines);
let open = current == Some(page);
let name = Text::literal(lang::get(page.name()));
crate::widgets::record(&name.plain(), rect, has);
let hover = has && ui::hovered(cursor, px, py, pw, ph);
let focused = self.focus == Some(Target::Page(page));
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(name.plain(), rect, has)
.hovered(hover)
.focused(focused)
});
crate::widgets::widget_cursor(hover, has);
if has {
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
}
if open {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
}
let color = match (open || hover, has) {
(true, _) => style::WHITE,
(false, true) => style::SOFT,
(false, false) => style::MUTED,
};
let ty = py + ((ph - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, &name.plain(), pw - 12.0));
batch.text_styled(atlas, &name, px + 8.0, ty, color, true);
if focused {
style::ring(batch, atlas, rect, style::WHITE);
}
}
}
fn draw_search(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
cursor: (f32, f32),
) {
let rect = cols.search();
style::well(batch, atlas, rect);
let focused = self.focus == Some(Target::Search);
self.search.focused = focused;
let [x, y, w, h] = rect;
let clear = cols.clear();
(self.search.x, self.search.y) = (x + 5.0, y + ((h - 8.0) / 2.0).floor());
self.search.width = clear[0] - self.search.x;
self.search.text_color = style::WHITE;
self.search.draw(batch, atlas, None);
if self.search.value.is_empty() && !focused {
let hint = Text::literal(self.search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, self.search.y, style::MUTED, true);
}
if !self.search.value.is_empty() {
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
crate::widgets::widget_cursor(hover, true);
let color = if hover { style::WHITE } else { style::MUTED };
let cy = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(
batch,
atlas,
&Text::literal("×"),
clear[0],
cy,
clear[2],
color,
);
}
if focused {
style::ring(batch, atlas, [x, y, w, h], style::WHITE);
let name = lang::get(page.name());
let flags = (has, open, focused);
shell::page_tab(batch, atlas, cols.page(i), &name, flags, cursor);
}
}
#[allow(clippy::too_many_arguments)]
@@ -1840,125 +1547,20 @@ impl GraphicsUi {
hover: bool,
focused: bool,
) {
let rect = [cols.list_x, y, cols.list_w, style::ROW];
let live = item.blocked(s).is_none();
let label = item.label(s).plain();
let (name, value) = parts(item, s, self.binding.is_some());
let (tx, tw) = cols.track();
let right = tx + tw;
let control = [
tx,
y + (style::ROW - style::CONTROL_H) / 2.0,
tw,
style::CONTROL_H,
];
let active = live && (hover || focused || self.dragging == Some(item));
if active {
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::HOVER);
}
crate::widgets::widget_cursor(hover, live);
let slider = item.slider(s);
let switch = item.switch(s);
// The control socket's widget tree, the narrator and a controller's focus.
let kind = match slider {
Some(value) => crate::widgets::Kind::Slider {
value,
track: [tx + 5.0, tw - 10.0],
},
None => crate::widgets::Kind::Button,
let view = shell::RowView {
label: &label,
name: &name,
value: value.as_deref(),
switch: item.switch(s),
slider: item.slider(s),
live: item.blocked(s).is_none(),
hover,
focused,
held: self.dragging == Some(item),
};
crate::widgets::record_as(&label, rect, live, kind);
crate::widgets::narratable(|| {
match (switch, slider) {
(Some(on), _) => crate::widgets::Narratable::checkbox(name.clone(), on, rect, live),
(_, Some(_)) => crate::widgets::Narratable::slider(label.clone(), rect, live),
_ => crate::widgets::Narratable::button(label.clone(), rect, live),
}
.hovered(hover)
.focused(focused)
});
if live {
crate::widgets::target_as(rect, kind);
}
let color = if live { style::WHITE } else { style::MUTED };
let soft = if live { style::SOFT } else { style::MUTED };
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let lit = live && (hover || self.dragging == Some(item));
let left = rect[0] + style::INSET;
// An action (Calibrate...) is a button across the row, as vanilla's lists have them.
if switch.is_none() && slider.is_none() && value.is_none() {
let wide = [left, control[1], right - left, style::CONTROL_H];
style::button(batch, atlas, wide, &Text::literal(name), (lit, live, false));
} else {
let value = value.unwrap_or_default();
let vw = atlas.font.width(&value);
// Where the control starts, and the value's text for a slider at rest.
let start = match (switch, slider) {
(Some(_), _) => right - style::CONTROL_H,
(None, Some(_)) if active => tx,
(None, Some(_)) => right - vw,
(None, None) => right - vw - 18.0,
};
match (switch, slider) {
(Some(on), _) => style::checkbox(batch, atlas, right, y, on, live),
// A slider shows its track, the value on it, while it is pointed at, focused or held.
(None, Some(position)) => {
let value = Text::literal(value);
if active {
style::slider(batch, atlas, control, &value, position, (lit, live));
} else {
batch.text_styled(atlas, &value, start, ty, soft, true);
}
}
(None, None) => {
let value = Text::literal(value);
style::cycler(
batch,
atlas,
[start, y + 3.0, vw + 18.0, 14.0],
&value,
(lit, live),
);
}
}
// The name, cut short before the control where a translation runs long.
let shown = Text::literal(style::fit(atlas, &name, start - 6.0 - left));
batch.text_styled(atlas, &shown, left, ty, color, true);
}
if focused {
style::ring(
batch,
atlas,
[rect[0] + 1.0, y, rect[2] - 2.0, style::ROW],
style::WHITE,
);
}
}
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
fn draw_scroller(&self, batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, content: f32) {
if !cols.scrolls() {
return;
}
let Some((y, bar)) =
ui::scrollbar_thumb(cols.top, cols.bottom, content, self.scroll.amount)
else {
return;
};
let x = cols.bar_x();
let height = cols.bottom - cols.top;
crate::widgets::scroll_area(
[
cols.list_x,
cols.top,
x + style::SCROLLER_W - cols.list_x,
height,
],
self.scroll.amount,
content - height,
);
let track = [x, cols.top, style::SCROLLER_W, height];
style::scroller(batch, atlas, track, [x, y, style::SCROLLER_W, bar]);
shell::row(batch, atlas, cols, y, &view);
}
/// The strip under the plate: the hovered or focused setting's name and
/// cost, then what it does (or why it can't be used now); with none, what
@@ -1999,10 +1601,7 @@ impl GraphicsUi {
(lang::get(page.about()), style::GREY)
}
};
let lines = atlas.font.split(&Text::literal(text), w - 24.0);
for (i, line) in lines.iter().take(2).enumerate() {
batch.text_styled(atlas, line, left, y + 20.0 + i as f32 * 10.0, color, true);
}
shell::dock_note(batch, atlas, cols, text, color);
}
/// A press (or a drag with `drag`) at `(x, y)`.
@@ -2188,10 +1787,7 @@ impl GraphicsUi {
self.scroll
.wheel(amount, 18.0, cols.top, cols.bottom, content);
}
/// What Tab steps through, in order, with where each is on screen. The
/// arrows (`near`) only reach the rows in view and the one next past either
/// edge, which scrolls in: a row farther out sits under the tabs or the
/// buttons and would take the step from them.
/// What Tab steps through, in order, with where each is on screen (`shell::reach`).
fn focusables(
&self,
s: &Settings,
@@ -2214,22 +1810,7 @@ impl GraphicsUi {
Some((setting.item, self.row_y(cols, line), live))
})
.collect();
// For the arrows, the rows in view and the next live one past either
// edge, over any greyed ones (HDR's page on a display without HDR).
let in_view = |y: f32| y + style::ROW > cols.top && y < cols.bottom;
let reach = match rows.iter().position(|r| in_view(r.1)) {
Some(first) if near => {
let last = rows.iter().rposition(|r| in_view(r.1)).unwrap_or(first);
let lo = rows[..first].iter().rposition(|r| r.2).unwrap_or(first);
let hi = rows[last + 1..]
.iter()
.position(|r| r.2)
.map_or(last, |i| last + 1 + i);
lo..hi + 1
}
None if near => 0..0,
_ => 0..rows.len(),
};
let reach = shell::reach(&rows, cols, near);
for &(item, y, live) in &rows[reach] {
if !live {
continue;
@@ -2258,14 +1839,8 @@ impl GraphicsUi {
self.can_change_value =
matches!(target, Target::Item(i) if i.slider(s).is_some() && i.blocked(s).is_none());
if let Target::Item(_) = target {
let [_, y, _, h] = rect;
if y < cols.top {
self.scroll.amount -= cols.top - y;
} else if y + h > cols.bottom {
self.scroll.amount += y + h - cols.bottom;
}
shell::scroll_into_view(&mut self.scroll, rect, cols, content);
self.picked = None;
self.scroll.clamp(cols.top, cols.bottom, content);
}
}
@@ -2310,13 +1885,7 @@ impl GraphicsUi {
.focus
.and_then(|f| focusables.iter().position(|&(t, _)| t == f));
if key == K::Tab {
let n = focusables.len();
let next = match (current, shift) {
(Some(i), false) => (i + 1) % n,
(Some(i), true) => (i + n - 1) % n,
(None, false) => 0,
(None, true) => n - 1,
};
let next = shell::tab_step(focusables.len(), current, shift);
let (target, rect) = focusables[next];
self.focus_on(s, target, rect, &cols, content);
return (Vec::new(), true);
@@ -2335,34 +1904,8 @@ impl GraphicsUi {
key,
K::ArrowUp | K::ArrowDown | K::ArrowLeft | K::ArrowRight
) {
use crate::gamepad::Button as B;
let dir = match key {
K::ArrowUp => B::DPadUp,
K::ArrowDown => B::DPadDown,
K::ArrowLeft => B::DPadLeft,
_ => B::DPadRight,
};
let next = match current {
Some(i) => {
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
crate::gamepad::focus_step_rect(focusables[i].1, dir, &rects)
}
// Nothing focused in view: going down, the first setting from the
// view's top on (the next past its bottom where greyed rows fill it);
// going up, the last one down to its bottom.
None => {
let mut rows = focusables
.iter()
.enumerate()
.filter(|(_, (t, _))| matches!(t, Target::Item(_)));
let pick = if key == K::ArrowUp {
rows.rfind(|(_, (_, [_, y, ..]))| *y + style::ROW <= cols.bottom)
} else {
rows.find(|(_, (_, [_, y, ..]))| *y >= cols.top)
};
pick.map(|(i, _)| i)
}
};
let is_row = |t: Target| matches!(t, Target::Item(_));
let next = shell::arrow_step(&focusables, current, key, &cols, is_row);
let Some(next) = next else {
return (Vec::new(), false);
};
+2
View File
@@ -32,6 +32,7 @@ pub mod collisions;
pub mod command_block;
pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod crash;
pub mod datapack;
pub mod dynamic_light;
@@ -91,6 +92,7 @@ pub mod sculk;
pub mod section_mask;
pub mod server_terrain;
pub mod settings;
pub mod settings_shell;
pub mod shader_options;
pub mod shader_pack_list;
pub mod signs;
+33 -16
View File
@@ -4025,9 +4025,12 @@ mod tests {
);
// A chunk covers only one quarter of this parent's 32² cell. Both
// opaque terrain and water must leave precisely that 16² footprint.
// Only active tiles enter the mesher snapshot, even when samples are ready.
lod.active.insert(key);
lod.covered.insert((-1, -2));
lod.covered_bounds = Some([-16, -32, 0, -16]);
let mesh = lod.mesh(key);
lod.active.remove(&key);
let top_area = |vertices: &[TerrainVertex], y: f32| {
vertices
.as_chunks::<4>()
@@ -4213,14 +4216,19 @@ mod tests {
&lod.reduced[&(-1, -1)].columns,
&lod.saved[&(-1, -1)]
));
assert!(
let reduced_spans = |lod: &Lod| {
lod.snapshot()
.visited_source(-16, -16, 2)
.unwrap()
.get()
.spans
.is_empty()
);
.iter()
.map(|span| (span.min, span.max, span.state))
.collect::<Vec<_>>()
};
// Reduction keeps a block held by any column of the footprint, so
// this single-column build survives the cached two-block reduction.
assert_eq!(reduced_spans(&lod), vec![(120, 121, stone)]);
let old = lod.saved[&(-1, -1)].clone();
lod.observe(Arc::new(chunk.clone()), &facts);
receive_observed(&mut lod);
@@ -4233,30 +4241,43 @@ mod tests {
&lod.reduced[&(-1, -1)].columns,
&lod.saved[&(-1, -1)]
));
assert_eq!(
reduced_spans(&lod),
vec![(120, 121, stone), (130, 131, stone)]
);
// A seed reduction arriving after the edit cannot restore old data.
lod.finish_detail((-1, -1), ReducedColumns::new(old));
assert_eq!(lod.ready[&fine][0].surface.unwrap().height, 131);
assert_eq!(
reduced_spans(&lod),
vec![(120, 121, stone), (130, 131, stone)]
);
// Exercise the actual worker/update seam: a parent stays displayed
// until all four child meshes can replace it in the same update.
// until all four child meshes have arrived, possibly over several updates.
let camera = [-256.0, 100.0, -256.0];
lod.area = Some(([-16, -16], 2, 64, 0, false, None));
lod.plan.roots = vec![key];
lod.plan.nodes.insert(key);
// As a replan does (`update` only selects after a change).
lod.reselect = true;
let await_meshes = |lod: &mut Lod| {
let await_meshes = |lod: &mut Lod, count: usize| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut uploaded = Vec::new();
loop {
let meshes = lod.update(camera, 2, 64, None, None);
if !meshes.is_empty() {
return meshes;
uploaded.extend(lod.update(camera, 2, 64, None, None));
if uploaded.len() >= count {
return uploaded;
}
assert!(
lod.removed().is_empty(),
"retired before replacement meshes"
);
assert!(std::time::Instant::now() < deadline, "mesh worker stalled");
std::thread::sleep(std::time::Duration::from_millis(1));
}
};
let uploaded = await_meshes(&mut lod);
let uploaded = await_meshes(&mut lod, 1);
assert_eq!(uploaded.len(), 1);
assert_eq!(uploaded[0].0, key);
assert!(lod.active.contains(&key));
@@ -4266,13 +4287,7 @@ mod tests {
lod.ready.insert(child, lod.ready[&key].clone());
}
lod.reselect = true;
assert!(lod.update(camera, 2, 64, None, None).is_empty());
assert!(
lod.active.contains(&key),
"parent retired before child meshes"
);
assert!(lod.removed().is_empty());
let uploaded = await_meshes(&mut lod);
let uploaded = await_meshes(&mut lod, 4);
assert_eq!(uploaded.len(), 4);
assert!(
key.children()
@@ -4376,6 +4391,7 @@ mod tests {
.insert(parent, Arc::new(vec![Column::default(); 256]));
}
lod.active = [near, far].into_iter().collect();
lod.wanted_stale = true;
lod.shared.jobs.lock().unwrap().clear();
lod.sample_jobs = SAMPLE_JOBS;
lod.pending = (0..SAMPLE_JOBS - 1)
@@ -4401,6 +4417,7 @@ mod tests {
lod.plan.split.clear();
lod.detail_masks.clear();
lod.covered = [(0, 0)].into_iter().collect();
lod.full = lod.covered.clone();
lod.detail_request_pending = false;
lod.detail_request_at = std::time::Instant::now() - std::time::Duration::from_secs(1);
lod.request_detail([0, 0]);
+92 -170
View File
@@ -1306,10 +1306,6 @@ enum Act {
Key(&'static str),
ResetKey(&'static str),
ResetKeys,
/// Controller binds: wait for a button, reset one, reset all.
PadKey(&'static str),
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// PvP & HUD's PvP preset (`pvp::apply_preset`).
PvpPreset,
@@ -1575,6 +1571,9 @@ pub struct OptionsUi {
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
/// Nintendo's: each part shows the letter of the button playing it.
pub pad_by_letter: bool,
/// The pad's focus drives the page: its hidden pointer sits mid-widget, so a tooltip goes
/// under the widget instead of beside the pointer.
pub pad_driving: bool,
pub languages: Vec<LanguageInfo>,
language_choice: Option<String>,
language_search: ui::EditBox,
@@ -1606,6 +1605,7 @@ pub struct OptionsUi {
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
pub controller: crate::controller_ui::ControllerUi,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
pub calibration: Option<crate::hdr_calibration::Calibration>,
@@ -1658,6 +1658,7 @@ impl Default for OptionsUi {
selected_pad_action: None,
pad_style: GlyphStyle::Xbox,
pad_by_letter: false,
pad_driving: false,
languages: Vec::new(),
language_choice: None,
language_search: search,
@@ -1676,6 +1677,7 @@ impl Default for OptionsUi {
minecart_improvements: false,
center_language: false,
graphics: Default::default(),
controller: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
@@ -2055,28 +2057,12 @@ fn narrate_widget(
active,
};
}
Act::PadKey(action) => {
let bound = s.controller.binding(action).is_some();
n.what = What::Named {
title: bind(tr(gamepad::action_caption(action)), bound),
active,
};
}
Act::ResetKey(name) => {
n.what = What::Named {
title: t("narrator.controls.reset", &[tr(name)]),
active,
};
}
Act::ResetPadKey(action) => {
n.what = What::Named {
title: t(
"narrator.controls.reset",
&[tr(gamepad::action_caption(action))],
),
active,
};
}
_ => {}
}
}
@@ -2168,6 +2154,7 @@ impl OptionsUi {
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => {
self.graphics.typing()
}
Page::Controller | Page::PadBinds => self.controller.typing(),
_ => self.settings_search_active(),
}
}
@@ -2287,6 +2274,9 @@ impl OptionsUi {
if let Some(at) = Self::graphics_page(page) {
self.graphics.open(at);
}
if let Some(at) = Self::controller_page(page) {
self.controller.open(at);
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
@@ -2320,6 +2310,37 @@ impl OptionsUi {
}
events
}
/// What the controller screen's actions do to these options.
fn controller_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::controller_ui::Action>,
) -> Vec<Event> {
use crate::controller_ui::Action;
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::BindPad(action) => self.selected_pad_action = Some(action),
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the controller screen opens for `page`, if it is one of its pages.
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
@@ -2359,8 +2380,7 @@ impl OptionsUi {
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Controller | Page::PadBinds => "horizon.pages.controller",
Page::Extras => "horizon.pages.extras",
Page::Pvp => "horizon.pages.pvp",
Page::Credits => "credits_and_attribution.screen.title",
@@ -2375,7 +2395,7 @@ impl OptionsUi {
fn scrollbar_x(&self, width: f32) -> f32 {
(width / 2.0).floor()
+ match self.page() {
Page::KeyBinds | Page::PadBinds => 178.0,
Page::KeyBinds => 178.0,
Page::Language => 118.0,
_ => 163.0,
}
@@ -2783,37 +2803,6 @@ impl OptionsUi {
));
self.filter_option_rows(rows)
}
Page::Controller => {
let mut rows = vec![Row::Buttons(
button(
0.0,
0.0,
310.0,
tr("horizon.pages.open.controller_binds"),
Act::Push(Page::PadBinds),
),
None,
)];
rows.extend(pairs(&[
Opt::PadEnabled,
Opt::PadVibration,
Opt::PadAdaptiveTriggers,
Opt::PadLightbar,
Opt::PadLookSensitivity,
Opt::PadToggleSneak,
Opt::PadInvertX,
Opt::PadInvertY,
Opt::PadMoveDeadzone,
Opt::PadLookDeadzone,
Opt::PadGlyphs,
Opt::PadKeyboard,
Opt::PadPointerSpeed,
Opt::PadScrollSpeed,
Opt::PadRepeatDelay,
Opt::PadRepeatInterval,
]));
rows
}
Page::Mouse => pairs(MOUSE_OPTIONS),
Page::Chat => pairs(&[
Opt::ChatVisibility,
@@ -3240,93 +3229,6 @@ impl OptionsUi {
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
}
Page::PadBinds => {
out.push(Widget {
x: 0.0,
y: 12.0,
w,
h: 9.0,
kind: WKind::Text {
text: tr(Self::title(page)),
color: u32::MAX,
centered: true,
},
list: false,
row: 0,
});
let row_left = cx - 170.0;
let scrollbar_x = row_left + 340.0 + 6.0 + 2.0;
let list_start = out.len();
let mut y = top + 2.0 - self.scroll;
let pad = &s.controller;
for (action, _) in gamepad::ACTIONS {
rows.begin(&out, y);
out.push(Widget {
x: row_left + 2.0,
y: y + 6.0,
w: 200.0,
h: 9.0,
kind: WKind::Text {
text: tr(gamepad::action_caption(action)),
color: u32::MAX,
centered: false,
},
list: false,
row: 0,
});
let mut message = match pad.binding(action) {
Some(b) => {
let b = if self.pad_by_letter { b.by_letter() } else { b };
let [r, g, bl] = b.color(self.pad_style);
Text::literal(b.label(self.pad_style))
.with_color(u32::from(r) << 16 | u32::from(g) << 8 | u32::from(bl))
}
None => tr("key.keyboard.unknown"),
};
if pad.conflicts(action) {
message = Text::literal("[ ")
.append(message)
.append(" ]")
.with_color(text::YELLOW);
}
if self.selected_pad_action == Some(*action) {
message = Text::literal("> ")
.append(message.with_style(&Style {
underlined: Some(true),
..Style::default()
}))
.append(" <")
.with_color(text::YELLOW);
}
let reset_x = scrollbar_x - 50.0 - 10.0;
let change_x = reset_x - 5.0 - 75.0;
out.push(button(change_x, y, 75.0, message, Act::PadKey(action)));
let reset = button(
reset_x,
y,
50.0,
tr("controls.reset"),
Act::ResetPadKey(action),
);
out.push(if pad.is_default(action) {
inactive(reset)
} else {
reset
});
y += 20.0;
rows.end(&mut out, y);
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
tr("controls.resetAll"),
Act::ResetPadKeys,
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
}
Page::Language => {
top = 36.0;
bottom = h - 53.0;
@@ -3582,7 +3484,7 @@ impl OptionsUi {
return;
}
// OptionsList rows are 310 wide, KeyBindsList's 340.
let half = if matches!(self.page(), Page::KeyBinds | Page::PadBinds) {
let half = if self.page() == Page::KeyBinds {
170.0
} else {
155.0
@@ -3669,6 +3571,11 @@ impl OptionsUi {
self.graphics.draw(s, batch, atlas, cursor, self.in_world);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.binding = self.selected_pad_action;
self.controller.draw(s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
// The controller picks a tile up between presses: the screen's size from the draw.
@@ -3779,7 +3686,8 @@ impl OptionsUi {
.collect();
if !lines.is_empty() {
tooltip = Some(lines);
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
tooltip_rect =
(!pointer_over || self.pad_driving).then_some([wd.x, wd.y, wd.w, wd.h]);
}
}
if tip_hover && let Some(tip) = tooltip_text(s, wd) {
@@ -3794,7 +3702,8 @@ impl OptionsUi {
.collect::<Vec<_>>(),
_ => atlas.font.split(&tip, 170.0),
});
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
tooltip_rect =
(!pointer_over || self.pad_driving).then_some([wd.x, wd.y, wd.w, wd.h]);
}
match &wd.kind {
WKind::Button { label, active, act } => {
@@ -4010,6 +3919,7 @@ impl OptionsUi {
}
if let Some(lines) = tooltip {
match tooltip_rect {
Some(rect) if self.pad_driving => ui::tooltip_under(batch, atlas, &lines, rect),
Some(rect) => ui::tooltip_menu(batch, atlas, &lines, cursor, rect),
None => ui::tooltip(batch, atlas, &lines, cursor),
}
@@ -4112,6 +4022,16 @@ impl OptionsUi {
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.graphics_events(s, actions);
}
if Self::controller_page(self.page()).is_some() {
// Waiting on a button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
return Vec::new();
}
let actions =
self.controller
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.controller_events(s, actions);
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag {
@@ -4221,10 +4141,6 @@ impl OptionsUi {
}
return events;
}
// Controller Binds: a click cancels the wait.
if self.selected_pad_action.take().is_some() {
return events;
}
// Key Binds: a mouse button pressed while waiting binds it.
if self.selected_key.is_some() {
let button = if right {
@@ -4343,16 +4259,6 @@ impl OptionsUi {
s.keys.reset_all();
events.push(Event::Changed);
}
Act::PadKey(action) => self.selected_pad_action = Some(action),
Act::ResetPadKey(action) => {
s.controller
.bind(action, gamepad::PadSettings::default_of(action));
events.push(Event::Changed);
}
Act::ResetPadKeys => {
s.controller.reset_bindings();
events.push(Event::Changed);
}
Act::ToggleMod(i) => {
s.mods.toggle(i);
events.push(Event::Changed);
@@ -4504,7 +4410,7 @@ impl OptionsUi {
let pack = self.pack_dragging.take();
let slider =
self.dragging.take().is_some() | self.shader_dragging.take().is_some() | pack.is_some();
let graphics_slider = self.graphics.release();
let graphics_slider = self.graphics.release() | self.controller.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
@@ -4592,6 +4498,10 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if Self::controller_page(self.page()).is_some() {
let actions = self.controller.wheel(s, size, cursor, framebuffer, amount);
return self.controller_events(s, actions);
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
@@ -4678,6 +4588,10 @@ impl OptionsUi {
self.graphics.scroll(s, size, amount);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.scroll(s, size, amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
@@ -4860,6 +4774,8 @@ impl OptionsUi {
self.split_profiles.type_text(text);
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
self.controller.type_text(text);
} else if self.settings_search_active() {
self.settings_search.insert_text(text);
self.scroll = 0.0;
@@ -4894,6 +4810,9 @@ impl OptionsUi {
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
if Self::controller_page(self.page()).is_some() {
return self.controller.shortcut(shortcut, clipboard);
}
if self.settings_search_active() {
if self.settings_search.shortcut(shortcut, clipboard) {
self.scroll = 0.0;
@@ -5018,6 +4937,16 @@ impl OptionsUi {
}
return events;
}
if Self::controller_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
let (actions, _) = self.controller.key_modifiers(s, key, text, shift, size, fb);
let mut events = self.controller_events(s, actions);
if key == K::Escape {
events.extend(self.back(s));
}
return events;
}
if key == K::Escape {
return self.back(s);
@@ -5290,16 +5219,6 @@ impl OptionsUi {
s.keys.reset_all();
events.push(Event::Changed);
}
Act::PadKey(action) => self.selected_pad_action = Some(action),
Act::ResetPadKey(action) => {
s.controller
.bind(action, gamepad::PadSettings::default_of(action));
events.push(Event::Changed);
}
Act::ResetPadKeys => {
s.controller.reset_bindings();
events.push(Event::Changed);
}
Act::ToggleMod(i) => {
s.mods.toggle(i);
events.push(Event::Changed);
@@ -6007,6 +5926,9 @@ mod tests {
_ => {}
}
}
if ui.controller_open() {
texts.extend(ui.controller.row_names(&s));
}
}
texts
})
@@ -7363,7 +7285,7 @@ mod tests {
fn classic_scrollbar_drag_is_bounded_silent_and_stops_on_release() {
let atlas = atlas();
let size = (480.0, 160.0);
for page in [Page::KeyBinds, Page::PadBinds, Page::Language, Page::Chat] {
for page in [Page::KeyBinds, Page::Language, Page::Chat] {
let mut s = Settings::default();
let mut ui = OptionsUi {
languages: languages(),
@@ -0,0 +1,676 @@
//! What Horizon's searchable settings screens share (`graphics_ui`, `controller_ui`): the frame's
//! geometry, its list of pages, groups and rows, and how the keyboard moves through them. Which
//! settings a screen has, and what they do, stay in that screen; the look comes from
//! `graphics_style`.
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
/// The screen's parts in GUI pixels. Down the left: the mark, the search
/// field, the page list, and Undo and Done at the foot. On the right: the
/// plate with the open page's name and its list, and the strip under it that
/// describes the hovered setting. `list_x`, `list_w`, `top` and `bottom` are
/// the list's view.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Columns {
pub(crate) side_x: f32,
pub(crate) side_y: f32,
pub(crate) plate: [f32; 4],
pub(crate) dock: [f32; 4],
pub(crate) list_x: f32,
pub(crate) list_w: f32,
pub(crate) top: f32,
pub(crate) bottom: f32,
}
impl Columns {
pub(crate) fn of(w: f32, h: f32) -> Self {
let side_w = style::CONTROL_W;
let gap = if w >= 560.0 { 16.0 } else { 8.0 };
let pw = (w - 16.0 - side_w - gap).min(432.0).floor();
let side_x = ((w - side_w - gap - pw) / 2.0).floor();
let px = side_x + side_w + gap;
// A short screen (a 720p window at scale 3) gives the margin to the list.
let side_y = if h >= style::TITLED { 24.0 } else { 8.0 };
let dock = [px, h - 8.0 - style::DOCK_H, pw, style::DOCK_H];
let plate = [px, side_y, pw, dock[1] - 4.0 - side_y];
let list_x = px + 8.0;
Self {
side_x,
side_y,
plate,
dock,
list_x,
list_w: pw - 16.0 - style::SCROLLER_W - 2.0,
top: side_y + 30.0,
bottom: plate[1] + plate[3] - 6.0,
}
}
/// The search field, under the mark.
pub(crate) fn search(&self) -> [f32; 4] {
[
self.side_x,
self.side_y + 26.0,
style::CONTROL_W,
style::SEARCH_H,
]
}
/// The search field's clear button, at its right end.
pub(crate) fn clear(&self) -> [f32; 4] {
let [x, y, w, h] = self.search();
[x + w - h, y, h, h]
}
/// Room for vanilla's scroller, whose thumb is 32 high and 8 short of its track.
pub(crate) fn scrolls(&self) -> bool {
self.bottom - self.top >= 40.0
}
pub(crate) fn bar_x(&self) -> f32 {
self.plate[0] + self.plate[2] - 8.0 - style::SCROLLER_W
}
/// A page in the page list, under the search field.
pub(crate) fn page(&self, i: usize) -> [f32; 4] {
[
self.side_x,
self.side_y + 56.0 + i as f32 * 18.0,
style::CONTROL_W,
16.0,
]
}
/// Done (`0`), and Undo over it (`1`): under the page list, level with the strip.
pub(crate) fn button(&self, i: usize) -> [f32; 4] {
let foot = self.dock[1] + self.dock[3] - style::BUTTON_H;
let y = if i == 0 {
foot
} else {
foot - 4.0 - style::BUTTON_H
};
[self.side_x, y, style::CONTROL_W, style::BUTTON_H]
}
/// A row's control column: its button, tick box or slider.
pub(crate) fn track(&self) -> (f32, f32) {
let x = self.list_x + self.list_w - style::INSET - style::CONTROL_W;
(x, style::CONTROL_W)
}
/// A slider's value for the pointer at `x`: the handle's middle follows it.
pub(crate) fn slider_at(&self, x: f32) -> f32 {
let (tx, tw) = self.track();
((x - tx - 5.0) / (tw - 10.0)).clamp(0.0, 1.0)
}
/// A row's whole width at `y`.
pub(crate) fn row(&self, y: f32) -> [f32; 4] {
[self.list_x, y, self.list_w, style::ROW]
}
}
/// A line of the list at `y` in its content: a page's name (only while
/// searching, which shows every page), a group's name with the height of the
/// panel its rows sit on, or a setting.
#[derive(Clone, Copy)]
pub(crate) struct Line<P, S> {
pub(crate) y: f32,
pub(crate) page: P,
pub(crate) group: Option<(&'static str, f32)>,
pub(crate) setting: Option<S>,
}
impl<P, S> Line<P, S> {
pub(crate) fn h(&self) -> f32 {
match (&self.setting, self.group) {
(Some(_), _) => style::ROW,
(None, Some(_)) => style::GROUP_ROW,
(None, None) => style::PAGE_ROW,
}
}
}
/// Lays lines out from the top.
pub(crate) struct Lines<P, S> {
lines: Vec<Line<P, S>>,
y: f32,
}
impl<P: Copy, S: Copy> Lines<P, S> {
pub(crate) fn new() -> Self {
Self {
lines: Vec::new(),
y: 0.0,
}
}
/// A page's name over its matches, while searching.
pub(crate) fn page(&mut self, page: P) {
if !self.lines.is_empty() {
self.y += style::PAGE_GAP;
}
self.lines.push(Line {
y: self.y,
page,
group: None,
setting: None,
});
self.y += style::PAGE_ROW;
}
/// A named group of rows.
pub(crate) fn group(&mut self, page: P, name: &'static str, rows: Vec<S>) {
self.lines.push(Line {
y: self.y,
page,
group: Some((name, rows.len() as f32 * style::ROW)),
setting: None,
});
self.y += style::GROUP_ROW;
for setting in rows {
self.lines.push(Line {
y: self.y,
page,
group: None,
setting: Some(setting),
});
self.y += style::ROW;
}
self.y += style::GROUP_GAP;
}
/// The lines and the content's height.
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
}
}
/// The world or the menu's background, veiled.
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
}
/// The mark and the screen's name over the search field.
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
let (x, y) = (cols.side_x, cols.side_y);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
let horizon = Text::literal("Horizon");
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
let title = Text::literal(title);
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
}
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
/// others bare text. `has`: something to show under it.
pub(crate) fn page_tab(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
name: &str,
(has, open, focused): (bool, bool, bool),
cursor: (f32, f32),
) {
let [px, py, pw, ph] = rect;
crate::widgets::record(name, rect, has);
let hover = has && ui::hovered(cursor, px, py, pw, ph);
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(name.to_owned(), rect, has)
.hovered(hover)
.focused(focused)
});
crate::widgets::widget_cursor(hover, has);
if has {
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
}
if open {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
}
let color = match (open || hover, has) {
(true, _) => style::WHITE,
(false, true) => style::SOFT,
(false, false) => style::MUTED,
};
let ty = py + ((ph - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
batch.text_styled(atlas, &name, px + 8.0, ty, color, true);
if focused {
style::ring(batch, atlas, rect, style::WHITE);
}
}
/// The search field, with its hint or clear button.
pub(crate) fn search_field(
search: &mut ui::EditBox,
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
cursor: (f32, f32),
focused: bool,
) {
let rect = cols.search();
style::well(batch, atlas, rect);
search.focused = focused;
let [x, y, _, h] = rect;
let clear = cols.clear();
(search.x, search.y) = (x + 5.0, y + ((h - 8.0) / 2.0).floor());
search.width = clear[0] - search.x;
search.text_color = style::WHITE;
// The clear button isn't the field's: a click at the field's end must not press it.
let frame = [x, y, clear[0] - x, h];
search.frame = Some(frame);
search.draw(batch, atlas, None);
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
}
if !search.value.is_empty() {
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
crate::widgets::widget_cursor(hover, true);
let color = if hover { style::WHITE } else { style::MUTED };
let cy = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(
batch,
atlas,
&Text::literal("×"),
clear[0],
cy,
clear[2],
color,
);
}
if focused {
style::ring(batch, atlas, frame, style::WHITE);
}
}
/// The plate, with the open page's name (or the search's) over its list.
pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, heading: &str) {
let [px, py, pw, _] = cols.plate;
style::panel(batch, atlas, cols.plate);
let heading = Text::literal(style::fit(atlas, heading, pw - 24.0));
batch.text_styled(atlas, &heading, px + 12.0, py + 10.0, style::WHITE, true);
style::fill(
batch,
atlas,
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
style::HEAD_RULE,
);
}
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
/// draws a setting at its `y`.
pub(crate) fn list<P: Copy, S: Copy>(
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
lines: &[Line<P, S>],
scroll: f32,
page_name: impl Fn(P) -> &'static str,
mut row: impl FnMut(&mut HudBatch, S, f32),
) {
let start = batch.vertices.len();
let mark = crate::widgets::targets_mark();
let row_y = |line: &Line<P, S>| cols.top + line.y - scroll;
let shows = |y: f32, h: f32| y + h > cols.top && y < cols.bottom;
for line in lines {
let y = row_y(line);
if let Some((_, panel)) = line.group
&& shows(y + style::GROUP_ROW, panel)
{
let rect = [cols.list_x, y + style::GROUP_ROW, cols.list_w, panel];
style::group(batch, atlas, rect);
}
}
// A group's first row, which has no rule over it.
let mut first = false;
for line in lines {
let y = row_y(line);
let after_name = std::mem::replace(&mut first, line.group.is_some());
if !shows(y, line.h()) {
continue;
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some((name, _)) => (name, style::MUTED, 3.0),
None => (page_name(line.page), style::ACCENT, 4.0),
};
let name = Text::literal(lang::get(name));
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
continue;
};
if !after_name {
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
style::fill(batch, atlas, rule, style::ROW_RULE);
}
row(batch, setting, y);
}
if lines.is_empty() {
let none = Text::literal(lang::get("No settings match."));
batch.text_styled(
atlas,
&none,
cols.list_x + 2.0,
cols.top + 4.0,
style::MUTED,
true,
);
}
batch.clip_since(
start,
cols.list_x,
cols.top,
cols.list_x + cols.list_w,
cols.bottom,
);
crate::widgets::clip_targets(mark, cols.top, cols.bottom);
}
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
pub(crate) fn name_and_value(label: &str) -> (String, Option<String>) {
// `options.generic_value` ("%s: %s") joins vanilla's; Horizon's use ": " too.
let format = lang::get("options.generic_value");
let sep = format
.split("%s")
.nth(1)
.filter(|sep| !sep.is_empty() && label.contains(*sep))
.unwrap_or(": ");
match label.split_once(sep) {
Some((name, value)) => (name.to_owned(), Some(value.to_owned())),
None => (label.to_owned(), None),
}
}
/// A slider's position one step on: a step of its own, or a pixel of its `width` wide track.
pub(crate) fn slider_step(pos: f32, steps: Option<u32>, width: f32, forward: bool) -> f32 {
if let Some(steps) = steps {
let steps = steps as f32;
let current = (pos * steps).round();
let next = if forward {
current + 1.0
} else {
current - 1.0
};
(next / steps).clamp(0.0, 1.0)
} else {
let delta = 1.0 / (width - 8.0).max(1.0);
if forward {
(pos + delta).min(1.0)
} else {
(pos - delta).max(0.0)
}
}
}
/// A setting's row as the screen reads it.
pub(crate) struct RowView<'a> {
/// The whole caption ("Render Distance: 12 chunks"), for the narrator.
pub(crate) label: &'a str,
pub(crate) name: &'a str,
pub(crate) value: Option<&'a str>,
pub(crate) switch: Option<bool>,
pub(crate) slider: Option<f32>,
/// Can be used now.
pub(crate) live: bool,
pub(crate) hover: bool,
pub(crate) focused: bool,
/// Its slider is being dragged.
pub(crate) held: bool,
}
/// A setting's row at `y`, its name on the left and its control on the right.
pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view: &RowView) {
let &RowView {
label,
name,
value,
switch,
slider,
live,
hover,
focused,
held,
} = view;
let rect = cols.row(y);
let (tx, tw) = cols.track();
let right = tx + tw;
let control = [
tx,
y + (style::ROW - style::CONTROL_H) / 2.0,
tw,
style::CONTROL_H,
];
let active = live && (hover || focused || held);
if active {
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::HOVER);
}
crate::widgets::widget_cursor(hover, live);
// The control socket's widget tree, the narrator and a controller's focus.
let kind = match slider {
Some(value) => crate::widgets::Kind::Slider {
value,
track: [tx + 5.0, tw - 10.0],
},
None => crate::widgets::Kind::Button,
};
crate::widgets::record_as(label, rect, live, kind);
crate::widgets::narratable(|| {
match (switch, slider) {
(Some(on), _) => crate::widgets::Narratable::checkbox(name.to_owned(), on, rect, live),
(_, Some(_)) => crate::widgets::Narratable::slider(label.to_owned(), rect, live),
_ => crate::widgets::Narratable::button(label.to_owned(), rect, live),
}
.hovered(hover)
.focused(focused)
});
if live {
crate::widgets::target_as(rect, kind);
}
let color = if live { style::WHITE } else { style::MUTED };
let soft = if live { style::SOFT } else { style::MUTED };
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let lit = live && (hover || held);
let left = rect[0] + style::INSET;
// An action (Calibrate...) is a button across the row, as vanilla's lists have them.
if switch.is_none() && slider.is_none() && value.is_none() {
let wide = [left, control[1], right - left, style::CONTROL_H];
style::button(batch, atlas, wide, &Text::literal(name), (lit, live, false));
} else {
let value = value.unwrap_or_default();
let vw = atlas.font.width(value);
// Where the control starts, and the value's text for a slider at rest.
let start = match (switch, slider) {
(Some(_), _) => right - style::CONTROL_H,
(None, Some(_)) if active => tx,
(None, Some(_)) => right - vw,
(None, None) => right - vw - 18.0,
};
match (switch, slider) {
(Some(on), _) => style::checkbox(batch, atlas, right, y, on, live),
// A slider shows its track, the value on it, while it is pointed at, focused or held.
(None, Some(position)) => {
let value = Text::literal(value);
if active {
style::slider(batch, atlas, control, &value, position, (lit, live));
} else {
batch.text_styled(atlas, &value, start, ty, soft, true);
}
}
(None, None) => {
let value = Text::literal(value);
style::cycler(
batch,
atlas,
[start, y + 3.0, vw + 18.0, 14.0],
&value,
(lit, live),
);
}
}
// The name, cut short before the control where a translation runs long.
let shown = Text::literal(style::fit(atlas, name, start - 6.0 - left));
batch.text_styled(atlas, &shown, left, ty, color, true);
}
if focused {
style::ring(
batch,
atlas,
[rect[0] + 1.0, y, rect[2] - 2.0, style::ROW],
style::WHITE,
);
}
}
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
pub(crate) fn scroller(
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
content: f32,
scroll: f32,
) {
if !cols.scrolls() {
return;
}
let Some((y, bar)) = ui::scrollbar_thumb(cols.top, cols.bottom, content, scroll) else {
return;
};
let x = cols.bar_x();
let height = cols.bottom - cols.top;
crate::widgets::scroll_area(
[
cols.list_x,
cols.top,
x + style::SCROLLER_W - cols.list_x,
height,
],
scroll,
content - height,
);
let track = [x, cols.top, style::SCROLLER_W, height];
style::scroller(batch, atlas, track, [x, y, style::SCROLLER_W, bar]);
}
/// The strip's text under its first line.
pub(crate) fn dock_note(
batch: &mut HudBatch,
atlas: &HudAtlas,
cols: &Columns,
text: String,
color: u32,
) {
let [x, y, w, _] = cols.dock;
let lines = atlas.font.split(&Text::literal(text), w - 24.0);
for (i, line) in lines.iter().take(2).enumerate() {
batch.text_styled(
atlas,
line,
x + 12.0,
y + 20.0 + i as f32 * 10.0,
color,
true,
);
}
}
/// Done or Undo at the foot of the page list.
pub(crate) fn foot_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
cursor: (f32, f32),
(focused, primary): (bool, bool),
) {
let [x, y, w, h] = rect;
let hovered = ui::hovered(cursor, x, y, w, h);
let hover = hovered || focused;
crate::widgets::record(&label.plain(), rect, true);
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(label.plain(), rect, true)
.hovered(hovered)
.focused(focused)
});
crate::widgets::widget_cursor(hovered, true);
crate::widgets::target(rect);
style::button(batch, atlas, rect, label, (hover, true, primary));
}
/// Of the rows (their target, `y` and whether they can take the focus), those the keys reach.
/// Tab reaches all. The arrows (`near`) only reach the rows in view and the next live one past
/// either edge, which scrolls in, over any greyed ones: a row farther out sits under the tabs or
/// the buttons and would take the step from them.
pub(crate) fn reach<T>(
rows: &[(T, f32, bool)],
cols: &Columns,
near: bool,
) -> std::ops::Range<usize> {
let in_view = |y: f32| y + style::ROW > cols.top && y < cols.bottom;
match rows.iter().position(|r| in_view(r.1)) {
Some(first) if near => {
let last = rows.iter().rposition(|r| in_view(r.1)).unwrap_or(first);
let lo = rows[..first].iter().rposition(|r| r.2).unwrap_or(first);
let hi = rows[last + 1..]
.iter()
.position(|r| r.2)
.map_or(last, |i| last + 1 + i);
lo..hi + 1
}
None if near => 0..0,
_ => 0..rows.len(),
}
}
/// Where Tab or Shift+Tab goes among `n` controls.
pub(crate) fn tab_step(n: usize, current: Option<usize>, shift: bool) -> usize {
match (current, shift) {
(Some(i), false) => (i + 1) % n,
(Some(i), true) => (i + n - 1) % n,
(None, false) => 0,
(None, true) => n - 1,
}
}
/// Where an arrow key goes among the controls. Nothing focused in view: going down, the first
/// setting from the view's top on (the next past its bottom where greyed rows fill it); going
/// up, the last one down to its bottom. `is_row`: a control that is a setting's row.
pub(crate) fn arrow_step<T: Copy>(
controls: &[(T, [f32; 4])],
current: Option<usize>,
key: winit::keyboard::KeyCode,
cols: &Columns,
is_row: impl Fn(T) -> bool,
) -> Option<usize> {
use crate::gamepad::Button as B;
use winit::keyboard::KeyCode as K;
let dir = match key {
K::ArrowUp => B::DPadUp,
K::ArrowDown => B::DPadDown,
K::ArrowLeft => B::DPadLeft,
_ => B::DPadRight,
};
match current {
Some(i) => {
let rects: Vec<[f32; 4]> = controls.iter().map(|f| f.1).collect();
crate::gamepad::focus_step_rect(controls[i].1, dir, &rects)
}
None => {
let mut rows = controls.iter().enumerate().filter(|(_, (t, _))| is_row(*t));
let pick = if key == K::ArrowUp {
rows.rfind(|(_, (_, [_, y, ..]))| *y + style::ROW <= cols.bottom)
} else {
rows.find(|(_, (_, [_, y, ..]))| *y >= cols.top)
};
pick.map(|(i, _)| i)
}
}
}
/// Scroll just enough to bring a row at `rect` into the list's view.
pub(crate) fn scroll_into_view(
scroll: &mut ui::Scroll,
[_, y, _, h]: [f32; 4],
cols: &Columns,
content: f32,
) {
if y < cols.top {
scroll.amount -= cols.top - y;
} else if y + h > cols.bottom {
scroll.amount += y + h - cols.bottom;
}
scroll.clamp(cols.top, cols.bottom, content);
}
+205 -23
View File
@@ -1,7 +1,8 @@
//! Sign block entities (`SignBlockEntity`, `SignText`): two four-line text
//! sides with a dye colour and glow, saved in vanilla's `front_text` /
//! `back_text` NBT layout.
pub use crate::chunk::block_entity::{SignData, SignText};
pub use crate::chunk::block_entity::{SavedLine, SignData, SignText};
use crate::{scoreboard, text::Text};
use horizon_world::nbt::Tag;
/// `DyeColor` names and `getTextColor` values by id.
@@ -32,6 +33,7 @@ impl Default for SignText {
color: BLACK,
glowing: false,
click: Default::default(),
saved: Default::default(),
}
}
}
@@ -96,6 +98,38 @@ impl SignText {
s(16) | s(8) | s(0)
}
}
/// The component a line is saved as (`SignText.CODEC`): the one it was
/// read as while its text is unchanged. A line edited since is a literal in
/// the old line's root style, as `SignBlockEntity.setMessages` makes it
/// (the style keeps its colour and click event, the siblings go).
pub fn message(&self, i: usize) -> Tag {
let line = &self.lines[i];
let saved = self.saved[i].as_ref();
if let Some(saved) = saved.filter(|s| s.text == *line) {
return saved.component.clone();
}
let mut style = match saved {
Some(saved) => root_style(&saved.component),
None => self.click[i]
.iter()
.map(|event| ("click_event", event.clone()))
.collect(),
};
if style.is_empty() {
return Tag::String(line.as_str().into());
}
style.insert(0, ("text", Tag::String(line.as_str().into())));
Tag::compound(style)
}
/// A line as the font draws it: its styled runs, `§` codes not yet applied.
pub fn line_text(&self, i: usize) -> Text {
// Signs are drawn every frame: a line read as plain text (most of them) skips the
// NBT -> JSON -> component round trip. Its click event does not change the drawing.
if self.saved[i].is_none() {
return Text::literal(self.lines[i].as_str());
}
scoreboard::comp_text(&scoreboard::tag_to_json(&self.message(i)))
}
}
impl SignData {
@@ -127,23 +161,7 @@ impl SignData {
Tag::compound([
(
"messages",
Tag::List(
t.lines
.iter()
.zip(&t.click)
.map(|(line, click)| {
let text = Tag::String(line.as_str().into());
match click {
// The line's style keeps its click event.
Some(event) => Tag::compound([
("text", text),
("click_event", event.clone()),
]),
None => text,
}
})
.collect(),
),
Tag::List((0..t.lines.len()).map(|i| t.message(i)).collect()),
),
(
"color",
@@ -168,9 +186,13 @@ impl SignData {
let mut out = SignText::default();
let Some(t) = t else { return out };
if let Some(Tag::List(messages)) = t.get("messages") {
for ((line, click), m) in out.lines.iter_mut().zip(&mut out.click).zip(messages) {
*line = component_text(m);
*click = click_event(m);
for (i, m) in messages.iter().take(4).enumerate() {
out.lines[i] = component_text(m);
out.click[i] = click_event(m);
out.saved[i] = is_styled(m).then(|| SavedLine {
text: out.lines[i].clone(),
component: m.clone(),
});
}
}
if let Some(Tag::String(c)) = t.get("color") {
@@ -202,6 +224,40 @@ fn click_event(tag: &Tag) -> Option<Tag> {
_ => None,
}
}
/// Whether a saved message is more than its text: a style, siblings, another
/// kind of content, or a list. A string and a lone `{text: ...}` are not.
fn is_styled(tag: &Tag) -> bool {
match tag {
Tag::List(_) => true,
Tag::Compound(c) => !(c.len() == 1 && matches!(tag.get("text"), Some(Tag::String(_)))),
_ => false,
}
}
/// The fields of a component's own style (`Style.CODEC`), the ones a literal
/// made from it keeps. A list's style is its first element's.
const STYLE_KEYS: [&str; 11] = [
"color",
"bold",
"italic",
"underlined",
"strikethrough",
"obfuscated",
"shadow_color",
"font",
"click_event",
"hover_event",
"insertion",
];
fn root_style(component: &Tag) -> Vec<(&'static str, Tag)> {
let root = match component {
Tag::List(list) => list.first(),
other => Some(other),
};
STYLE_KEYS
.iter()
.filter_map(|key| Some((*key, root?.get(key)?.clone())))
.collect()
}
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
fn component_text(tag: &Tag) -> String {
match tag {
@@ -216,8 +272,8 @@ fn component_text(tag: &Tag) -> String {
}
}
// Component.getString: translations and key binds resolved.
Tag::Compound(_) => {
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(tag)).plain()
Tag::Compound(_) | Tag::List(_) => {
scoreboard::comp_text(&scoreboard::tag_to_json(tag)).plain()
}
_ => String::new(),
}
@@ -267,6 +323,132 @@ mod tests {
decoded.decode(&tag);
assert_eq!(decoded, data);
}
/// A sign as a server sends it: a green "Online" and a plain line, the
/// way `ComponentSerialization.CODEC` writes them to NBT.
fn styled_sign() -> Tag {
let green = Tag::compound([
("text", Tag::String("Online".into())),
("color", Tag::String("green".into())),
]);
let front = Tag::compound([(
"messages",
Tag::List(vec![
Tag::String("[KnockIT-1]".into()),
green,
Tag::String("".into()),
Tag::String("0 / 20".into()),
]),
)]);
Tag::compound([("front_text", front)])
}
fn front_messages(tag: &Tag) -> &Vec<Tag> {
match tag.get("front_text").and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list,
other => panic!("no messages: {other:?}"),
}
}
fn run_colors(text: &SignText, line: usize) -> Vec<(String, Option<u32>)> {
text.line_text(line)
.spans
.into_iter()
.map(|s| (s.text, s.style.color))
.collect()
}
#[test]
fn a_lines_color_is_read_and_written_back() {
let saved = styled_sign();
let mut data = SignData::default();
data.decode(&saved);
assert_eq!(data.front.lines[1], "Online");
// Green is `ChatFormatting.GREEN`; the dye colour of the side is untouched.
assert_eq!(
run_colors(&data.front, 1),
[("Online".to_owned(), Some(0x55FF55))]
);
assert_eq!(
run_colors(&data.front, 0),
[("[KnockIT-1]".to_owned(), None)]
);
assert!(run_colors(&data.front, 2).is_empty());
// Saved and sent as it came: the coloured line keeps its component and
// the plain ones stay plain strings.
let encoded = Tag::compound(data.encode());
assert_eq!(front_messages(&encoded), front_messages(&saved));
}
#[test]
fn a_list_component_shows_all_its_parts_in_their_own_styles() {
let tag = Tag::compound([(
"front_text",
Tag::compound([(
"messages",
Tag::List(vec![Tag::List(vec![
Tag::compound([
("text", Tag::String("Hi ".into())),
("color", Tag::String("red".into())),
]),
Tag::compound([
("text", Tag::String("there".into())),
("color", Tag::String("#00FF00".into())),
]),
])]),
)]),
)]);
let mut data = SignData::default();
data.decode(&tag);
assert_eq!(data.front.lines[0], "Hi there");
// The list's later parts take the first one's style unless they set their own.
assert_eq!(
run_colors(&data.front, 0),
[
("Hi ".to_owned(), Some(0xFF5555)),
("there".to_owned(), Some(0x00FF00))
]
);
}
#[test]
fn an_edited_line_keeps_only_the_old_lines_style() {
let mut data = SignData::default();
data.decode(&styled_sign());
// `SignBlockEntity.setMessages`: a new literal with the old line's style.
data.front.lines[1] = "Full".into();
let encoded = Tag::compound(data.encode());
assert_eq!(
front_messages(&encoded),
&vec![
Tag::String("[KnockIT-1]".into()),
Tag::compound([
("text", Tag::String("Full".into())),
("color", Tag::String("green".into())),
]),
Tag::String("".into()),
Tag::String("0 / 20".into()),
]
);
assert_eq!(
run_colors(&data.front, 1),
[("Full".to_owned(), Some(0x55FF55))]
);
// A line that had no style stays plain.
data.front.lines[0] = "[KnockIT-2]".into();
assert!(matches!(data.front.message(0), Tag::String(_)));
}
#[test]
fn a_translated_line_is_not_flattened_by_a_round_trip() {
let tag = Tag::compound([(
"front_text",
Tag::compound([(
"messages",
Tag::List(vec![Tag::compound([(
"translate",
Tag::String("block.minecraft.stone".into()),
)])]),
)]),
)]);
let mut data = SignData::default();
data.decode(&tag);
let encoded = Tag::compound(data.encode());
assert_eq!(front_messages(&encoded)[0], front_messages(&tag)[0]);
}
#[test]
fn a_mods_sign_like_name_does_not_make_it_a_sign() {
assert_eq!(sign_kind("ruby:road_sign"), None);
@@ -328,6 +328,8 @@ pub struct TabInputs<'a> {
/// The player is in the level with an upside-down name
/// (`AvatarRenderer.isPlayerUpsideDown`).
pub upside_down: &'a dyn Fn(u128) -> bool,
/// The player plays Horizon: Horizon's mark after their head (`horizon_cosmetics::mark`).
pub horizon: &'a dyn Fn(u128) -> bool,
}
/// `PlayerTabOverlay`: the header, footer, visibility and heart states.
@@ -425,6 +427,10 @@ impl TabList {
};
let sw = i.screen_width;
let head = if show_head { 9 } else { 0 };
// Horizon: a column for the mark once anyone listed has it, so the names stay aligned.
let marked: Vec<bool> = players.iter().map(|p| (i.horizon)(p.uuid)).collect();
let mark = if marked.contains(&true) { 9 } else { 0 };
let head = head + mark;
let slot_width = (cols * (head + max_name + width_for_score + 13)).min(sw - 50) / cols;
let xxo = sw / 2 - (slot_width * cols + (cols - 1) * 5) / 2;
let mut yyo = 10;
@@ -491,6 +497,20 @@ impl TabList {
);
xo += 9;
}
if marked[n as usize] {
for (mx, my, w, h, color) in horizon_cosmetics::mark::MARK {
let (x, y) = (xo + i32::from(mx), yo + i32::from(my));
let (x1, y1) = (x + i32::from(w), y + i32::from(h));
out.push(GuiDraw::Fill {
x0: x,
y0: y,
x1,
y1,
color,
});
}
}
xo += mark;
let spectator = p.game_mode == SPECTATOR;
out.push(GuiDraw::Text {
text: entry.name.clone(),
+14 -3
View File
@@ -417,6 +417,11 @@ fn arena_vertex(v: u32) -> ArenaCorner {
let c = arena_page[v * 3u + 2u];
return ArenaCorner(vec3(a.x, a.y, b.x), vec3(b.y, c.x, c.y));
}
// The offset a block corner adds on one component: `side` on the face's axis (rel 0), `du`
// on the next axis (rel 1), `dw` on the one after (rel 2).
fn corner_offset(rel: u32, side: u32, du: u32, dw: u32) -> u32 {
return select(select(dw, du, rel == 1u), side, rel == 0u);
}
// `packed::packed_vertex`: corner `corner` of the record whose words are lo (0 to 2) and
// hi (3 to 5).
fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
@@ -430,9 +435,15 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let du = select(0u, 1u, step == 1u || step == 2u);
let dw = select(0u, 1u, step >= 2u);
// No dynamic component stores: FXC (DirectX 12) rejects them (X3500). A component's offset
// from the axis (0: the axis, 1: the next, 2: the one after) picks what it gets.
let rel = (vec3(0u, 1u, 2u) + vec3(3u - axis)) % vec3(3u);
let pos = block + select(select(vec3(dw), vec3(du), rel == vec3(1u)), vec3(side), rel == vec3(0u));
// from the axis (0: the axis, 1: the next, 2: the one after) picks what it gets. Scalar
// selects only: a vector select becomes GLSL's mix(uvec3, uvec3, bvec3), which old AMD
// OpenGL drivers (Radeon HD 7400M, driver 15.301) resolve to the float overload and reject.
let offset = vec3(
corner_offset((3u - axis) % 3u, side, du, dw),
corner_offset((4u - axis) % 3u, side, du, dw),
corner_offset((5u - axis) % 3u, side, du, dw),
);
let pos = block + offset;
let sel = lo.z >> 24u;
let us = (sel >> (2u * corner)) & 1u;
let vs = (sel >> (2u * corner + 1u)) & 1u;
+50 -4
View File
@@ -1013,6 +1013,31 @@ pub fn tooltip_menu(
);
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
}
/// A widget's tooltip while the controller's focus is on it (Horizon's: vanilla has no gamepad
/// input, and the hidden pointer mid-widget would put it over the widget).
pub fn tooltip_under(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
if lines.is_empty() {
return;
}
let (w, h) = tooltip_size(atlas, lines);
let (x, y) = tooltip_under_position((batch.width, batch.height), rect, (w, h));
tooltip_box(batch, atlas, lines, x, y, w, h);
}
/// Where `tooltip_under` puts lines of `size`: the frame's top left a pixel under the widget's
/// bottom left, over the widget when the screen ends first, and always on the screen.
fn tooltip_under_position(screen: (f32, f32), rect: [f32; 4], size: (f32, f32)) -> (f32, f32) {
// The frame reaches this far past the lines on every side.
const FRAME: f32 = 4.0;
let [x, y, _, height] = rect;
let right = screen.0 - size.0 - FRAME;
let below = y + height + 1.0 + FRAME;
let top = if below + size.1 + FRAME <= screen.1 {
below
} else {
y - 1.0 - FRAME - size.1
};
((x + FRAME).min(right).max(FRAME), top.max(FRAME))
}
/// `MenuTooltipPositioner.positionTooltip`: the tooltip's top left with the
/// pointer at `cursor`, the widget it belongs to at `rect`, and the screen.
/// `MARGIN` 5 and `MOUSE_OFFSET_X` 12 on the horizontal, and the
@@ -1123,6 +1148,9 @@ pub struct EditBox {
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
/// round the frame, not over the text.
pub frame: Option<[f32; 4]>,
}
/// The focused text field of a screen that keeps its values as `String`s
/// (the world list's search, world names, seeds, and the server screens): the `EditBox` state that
@@ -1385,6 +1413,7 @@ impl EditBox {
suggestion: None,
text_color: rgba(224, 224, 224, 255),
message: String::new(),
frame: None,
}
}
pub fn inner_width(&self) -> f32 {
@@ -1604,7 +1633,8 @@ impl EditBox {
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, format: Option<&Text>) {
self.layout(atlas);
crate::widgets::target_as(
[self.x, self.y, self.width, self.height],
self.frame
.unwrap_or([self.x, self.y, self.width, self.height]),
crate::widgets::Kind::Field,
);
crate::widgets::narratable(|| {
@@ -1816,9 +1846,6 @@ mod tests {
assert_eq!(scrolled_left(10.0, 40.0, 1_000), 10.0 - offset);
}
/// The label of a widget it does not fit is clipped to the widget's box
/// instead of being drawn past it (the bug: `so_so`'s 590 px Create World
/// label ran over the button beside it).
#[test]
fn a_glyph_leaves_a_label_that_fits_centred() {
let atlas = atlas();
@@ -2297,6 +2324,25 @@ mod tests {
assert!(!delete_words(&mut v, 1));
assert_eq!(v, "stone");
}
#[test]
fn a_pad_focused_tooltip_hangs_from_its_widgets_bottom_left() {
let screen = (427.0, 240.0);
let widget = [100.0, 100.0, 150.0, 20.0];
// Frame at 100, 121: lines 4 further in.
assert_eq!(
tooltip_under_position(screen, widget, (80.0, 10.0)),
(104.0, 125.0)
);
// The screen's right edge moves it left, its bottom puts it over the widget.
assert_eq!(
tooltip_under_position(screen, [400.0, 100.0, 20.0, 20.0], (80.0, 10.0)),
(343.0, 125.0)
);
assert_eq!(
tooltip_under_position(screen, [100.0, 215.0, 150.0, 20.0], (80.0, 10.0)),
(104.0, 200.0)
);
}
/// `MenuTooltipPositioner.positionTooltip`: the pointer's 12 px offset,
/// the 5 px screen margin, and the 3 to 5 px the tooltip keeps from the
/// widget's top or bottom edge.
+2
View File
@@ -83,6 +83,8 @@ fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Resolve virtualized folders to shell-compatible physical paths.
dunce = "1.0.5"
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
@@ -378,6 +378,12 @@
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
"horizon.extras.cosmetics_solo": "Im Einzelspieler",
"horizon.extras.cosmetics_solo.tooltip": "Deine Kosmetik und das Abzeichen deines Namens auch in deinen Einzelspielerwelten zeigen (deinen Namen mit „Eigenes Namensschild“). Aus: nur auf Servern.",
"horizon.extras.horizon_marks": "Horizon-Logos",
"horizon.extras.horizon_marks.tooltip": "Das Horizon-Logo neben Horizon-Spielern zeigen: ein kleines Logo vor den Namen anderer Spieler, die Horizon nutzen, über ihren Köpfen und in der Spielerliste. Nur Horizon-Spieler sehen es: Server erhalten nichts.",
"horizon.extras.horizon_me": "Mich als Horizon zeigen",
"horizon.extras.horizon_me.tooltip": "Mich als Horizon-Spieler zeigen: andere Horizon-Spieler sehen das Logo neben deinem Namen. An, sagt der Dienst jedem, der nach dir fragt, dass du in den letzten 3 Stunden Horizon gespielt hast; aus, meldet er dich nie.",
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
@@ -583,6 +583,12 @@
"horizon.options.repeat_delay": "Navigation Repeat Delay",
"horizon.options.repeat_interval": "Navigation Repeat Interval",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "In single player",
"horizon.extras.cosmetics_solo.tooltip": "Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers.",
"horizon.extras.horizon_marks": "Horizon logos",
"horizon.extras.horizon_marks.tooltip": "Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing.",
"horizon.extras.horizon_me": "Show me as Horizon",
"horizon.extras.horizon_me.tooltip": "Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you.",
"horizon.pages.pvp": "PvP & HUD",
"horizon.pages.pvp.tooltip": "Crosshair, HUD and PvP settings, all off by default. Nothing here shows what vanilla doesn't.",
"horizon.pages.open.pvp": "PvP & HUD...",
@@ -727,5 +733,52 @@
"horizon.pvp.chat_search": "Chat search",
"horizon.pvp.chat_search.tooltip": "Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.",
"horizon.pvp.preset": "PvP preset",
"horizon.pvp.preset.tooltip": "Turns on the custom crosshair, the armour and effects widgets, toggle sprint and sneak, lower fire, and the FPS counter and keystrokes mods if installed. Your settings for each stay."
"horizon.pvp.preset.tooltip": "Turns on the custom crosshair, the armour and effects widgets, toggle sprint and sneak, lower fire, and the FPS counter and keystrokes mods if installed. Your settings for each stay.",
"Bindings": "Bindings",
"Device": "Device",
"Look": "Look",
"Movement": "Movement",
"Menus": "Menus",
"Actions": "Actions",
"Reset": "Reset",
"Detected": "Detected",
"Glyphs": "Glyphs",
"Feedback": "Feedback",
"Controller": "Controller",
"How the sticks turn and move you, and how menus repeat and scroll.": "How the sticks turn and move you, and how menus repeat and scroll.",
"Which button does what. Each player has their own.": "Which button does what. Each player has their own.",
"The controller the game sees, its button glyphs and its feedback.": "The controller the game sees, its button glyphs and its feedback.",
"Press this row, then the button that should do it.": "Press this row, then the button that should do it.",
"Off, the game ignores every controller for this player.": "Off, the game ignores every controller for this player.",
"How fast the right stick turns the view.": "How fast the right stick turns the view.",
"Turn the view the other way, left and right.": "Turn the view the other way, left and right.",
"Turn the view the other way, up and down.": "Turn the view the other way, up and down.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "How far the right stick moves before the view turns. Raise it if the view drifts.",
"Hold the button to sneak, or press it once to start and again to stop.": "Hold the button to sneak, or press it once to start and again to stop.",
"How far the left stick moves before you walk. Raise it if you drift.": "How far the left stick moves before you walk. Raise it if you drift.",
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
"How long a held direction waits before it repeats.": "How long a held direction waits before it repeats.",
"How long a held direction takes between repeats.": "How long a held direction takes between repeats.",
"Which on-screen keyboard opens when a text field is pressed.": "Which on-screen keyboard opens when a text field is pressed.",
"Every action back on its default button.": "Every action back on its default button.",
"These settings belong to this player's profile.": "These settings belong to this player's profile.",
"The controller driving this player, or else the first one connected.": "The controller driving this player, or else the first one connected.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.",
"The glyph family the game picked from the controller's maker.": "The glyph family the game picked from the controller's maker.",
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
"The buttons menus show right now.": "The buttons menus show right now.",
"Rumble when something happens to you.": "Rumble when something happens to you.",
"A DualSense's triggers push back on bows and tools.": "A DualSense's triggers push back on bows and tools.",
"What a DualSense's light shows, over USB or Bluetooth.": "What a DualSense's light shows, over USB or Bluetooth.",
"Player profile": "Player profile",
"Vendor and product": "Vendor and product",
"Detected glyphs": "Detected glyphs",
"Glyphs in use": "Glyphs in use",
"Unknown": "Unknown",
"No controller": "No controller",
"Editing: %s": "Editing: %s",
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"horizon.pad.notice.connected.press": "Press to use it"
}
@@ -389,6 +389,12 @@
"horizon.extras.cosmetics.shown": "Affichés",
"horizon.extras.cosmetics.hidden": "Masqués",
"horizon.extras.cosmetics.name_tags": "Étiquettes seulement",
"horizon.extras.cosmetics_solo": "En solo",
"horizon.extras.cosmetics_solo.tooltip": "Afficher aussi vos cosmétiques et le badge de votre nom dans vos mondes solo (votre pseudo avec « Mon pseudo »). Désactivé : seulement sur les serveurs.",
"horizon.extras.horizon_marks": "Logos Horizon",
"horizon.extras.horizon_marks.tooltip": "Afficher le logo Horizon à côté des joueurs Horizon : un petit logo devant le nom des autres joueurs qui utilisent Horizon, au-dessus de leur tête et dans la liste des joueurs. Seuls les joueurs Horizon le voient : les serveurs ne reçoivent rien.",
"horizon.extras.horizon_me": "Me montrer Horizon",
"horizon.extras.horizon_me.tooltip": "Me montrer comme joueur Horizon : les autres joueurs Horizon voient le logo à côté de votre nom. Activé, le service répond à quiconque le demande que vous avez joué à Horizon dans les 3 dernières heures ; désactivé, il ne vous signale jamais.",
"horizon.split.leave": "Quitter",
"Upscaling": "Mise à l'échelle",
"Native": "Natif",
@@ -171,6 +171,8 @@ pub fn main() -> Result<()> {
gui_ticks: f["tick"].as_i64().unwrap_or(0),
background,
upside_down: &up,
// Vanilla has no Horizon mark.
horizon: &|_| false,
};
for d in list.layout(&inputs, font) {
draws.push(match d {
@@ -465,10 +465,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status | pad connection connected|disconnected", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
&[p("what", Ty::Str, "a button name, stick, trigger, release, status or connection"), opt("a", Ty::Str, "down|up|tap, left|right, or connected|disconnected"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
// Screens
+4 -2
View File
@@ -12,8 +12,8 @@ pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, block_sprites, bluemap, bluemap_detect, border_render,
breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint,
clouds, collisions, command_block, containers, crash, datapack, dynamic_light, effects,
environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
clouds, collisions, command_block, containers, controller_ui, crash, datapack, dynamic_light,
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
@@ -121,11 +121,13 @@ pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod controller_notice;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stand_up;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
@@ -0,0 +1,246 @@
//! A notice about a controller nobody has to answer: translated words, the controller's own glyph
//! if it helps, how long it stays. A view holds one at a time (`NoticeSlot`), so they never stack:
//! each player's game has its own slot, and what raises a notice (splitscreen's joins and
//! reconnects, a solo game's controller coming and going) decides that for itself.
use crate::gamepad::{Button, GlyphStyle};
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::toasts::Slide;
use std::time::{Duration, Instant};
/// What a notice is about: another with the same id takes its place at once, whatever its
/// priority (a controller reconnecting replaces its disconnect) and without sliding in again.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoticeId {
/// A controller connected or disconnected.
Connection,
/// A splitscreen player the server sent away.
PlayerLeft,
/// The stick stopped moving the player at its own angle on this server.
Movement,
}
/// Which of two notices about different things stays up.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
/// Good to know; it doesn't replace something that needs the player.
Info,
/// The player may have to do something.
Attention,
}
/// A button shown before the body, in the player's glyphs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Glyph {
pub button: Button,
pub style: GlyphStyle,
}
pub struct Notice {
id: NoticeId,
priority: Priority,
title: String,
body: String,
glyph: Option<Glyph>,
/// When it was raised: it goes that long after, drawn or not (a notice raised on the way
/// out of a world must not wait for the next one).
raised: Instant,
slide: Slide,
}
impl Notice {
pub fn new(
id: NoticeId,
priority: Priority,
title: impl Into<String>,
body: impl Into<String>,
) -> Self {
Self {
id,
priority,
title: title.into(),
body: body.into(),
glyph: None,
raised: Instant::now(),
slide: Slide::default(),
}
}
pub fn with_glyph(mut self, glyph: Glyph) -> Self {
self.glyph = Some(glyph);
self
}
}
/// The one notice over a player's view.
#[derive(Default)]
pub struct NoticeSlot {
current: Option<Notice>,
}
impl NoticeSlot {
/// `notice` shows here: in place of one about the same thing, and of anything that doesn't
/// outrank it. Otherwise it's dropped, not queued: by the time the other goes it is old news.
pub fn raise(&mut self, mut notice: Notice) {
match &mut self.current {
Some(old) if old.id == notice.id => {
// Already on screen: the words change and its time starts again.
notice.slide = std::mem::take(&mut old.slide);
*old = notice;
}
Some(old) if old.priority > notice.priority => {}
current => *current = Some(notice),
}
}
pub fn clear(&mut self) {
self.current = None;
}
/// What it shows now, for `pad status`.
pub fn showing(&self) -> Option<(&str, &str)> {
self.current
.as_ref()
.map(|n| (n.title.as_str(), n.body.as_str()))
}
/// Its time, after `shown_for`: it slides out (`ToastManager`'s slide); one whose time passed
/// while nothing drew is simply gone. Then whether anything is left to draw.
fn update(&mut self, shown_for: Duration) -> bool {
let Some(notice) = &mut self.current else {
return false;
};
let elapsed = notice.raised.elapsed();
let finished = if notice.slide.origin.is_none() && elapsed > shown_for {
true
} else {
notice.slide.update_now(|_| elapsed <= shown_for).1
};
if finished {
self.current = None;
}
!finished
}
/// The notice under `top` on a view: a strip in the middle, its words wrapped to the view (a
/// toast is as wide as its longest line: off a narrow view's edge), sliding down from the
/// view's edge.
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, top: f32, shown_for: Duration) {
if !self.update(shown_for) {
return;
}
let Some(notice) = &self.current else {
return;
};
let w = batch.width;
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let max = (w - 24.0).min(260.0);
let glyph = notice.glyph.map(|g| {
(
g.button.label(g.style),
width(g.button.label(g.style)) + 4.0,
)
});
let indent = glyph.map_or(0.0, |g| g.1);
let lines = crate::split_profiles::wrap(&notice.body, max - indent, width);
let inner = lines
.iter()
.map(|l| width(l) + indent)
.fold(width(&notice.title), f32::max);
let (pw, ph) = (inner.min(max) + 16.0, 20.0 + 10.0 * lines.len() as f32);
let px = ((w - pw) / 2.0).floor();
let py = (top - (1.0 - notice.slide.portion) * (top + ph)).floor();
panel(batch, atlas, [px, py, pw, ph]);
let white = rgba(255, 255, 255, 255);
let tx = (px + (pw - width(&notice.title)) / 2.0).floor();
batch.text(atlas, &notice.title, tx, py + 6.0, white, true);
for (k, line) in lines.iter().enumerate() {
let lx = (px + (pw - width(line) - indent) / 2.0).floor();
let y = py + 18.0 + 10.0 * k as f32;
if let (0, Some((label, _))) = (k, glyph) {
batch.text(atlas, label, lx, y, rgba(255, 255, 255, 255), false);
}
batch.text(atlas, line, lx + indent, y, rgba(200, 200, 200, 255), false);
}
}
}
/// The prompts' and notices' box: dark, a faint edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, [px, py, pw, ph]: [f32; 4]) {
batch.fill(atlas, px, py, pw, ph, rgba(0, 0, 0, 210));
let edge = rgba(255, 255, 255, 90);
for [x, y, fw, fh] in [
[px, py, pw, 1.0],
[px, py + ph - 1.0, pw, 1.0],
[px, py, 1.0, ph],
[px + pw - 1.0, py, 1.0, ph],
] {
batch.fill(atlas, x, y, fw, fh, edge);
}
}
/// A button as printed on the pad, in its colour, on a dark key `gw` wide.
pub fn key(
batch: &mut HudBatch,
atlas: &HudAtlas,
label: &str,
color: u32,
x: f32,
y: f32,
gw: f32,
) {
batch.fill(atlas, x - 1.0, y - 2.0, gw, 11.0, rgba(40, 40, 40, 255));
batch.text(atlas, label, x + 1.0, y, color, false);
}
#[cfg(test)]
mod tests {
use super::*;
fn notice(id: NoticeId, priority: Priority, title: &str) -> Notice {
Notice::new(id, priority, title, "")
}
#[test]
fn a_view_holds_one_notice_however_many_are_raised() {
let mut slot = NoticeSlot::default();
for i in 0..50 {
let title = i.to_string();
slot.raise(notice(NoticeId::Connection, Priority::Info, &title));
}
assert_eq!(slot.showing().map(|s| s.0), Some("49"));
}
#[test]
fn a_reconnect_replaces_its_disconnect_though_it_ranks_lower() {
let mut slot = NoticeSlot::default();
slot.raise(notice(NoticeId::Connection, Priority::Attention, "gone"));
slot.raise(notice(NoticeId::Connection, Priority::Info, "back"));
assert_eq!(slot.showing().map(|s| s.0), Some("back"));
}
#[test]
fn what_needs_the_player_stays_over_other_news() {
let mut slot = NoticeSlot::default();
slot.raise(notice(NoticeId::PlayerLeft, Priority::Attention, "left"));
slot.raise(notice(NoticeId::Connection, Priority::Info, "back"));
assert_eq!(slot.showing().map(|s| s.0), Some("left"));
// News of the same rank is newer.
slot.raise(notice(NoticeId::Connection, Priority::Attention, "gone"));
assert_eq!(slot.showing().map(|s| s.0), Some("gone"));
}
#[test]
fn a_notice_raised_while_nothing_draws_is_gone_when_its_time_has_passed() {
let mut slot = NoticeSlot::default();
slot.raise(notice(NoticeId::Connection, Priority::Info, "old"));
std::thread::sleep(Duration::from_millis(2));
assert!(!slot.update(Duration::ZERO));
assert_eq!(slot.showing(), None);
}
#[test]
fn a_notice_on_screen_stays_for_its_time_then_slides_out() {
let mut slot = NoticeSlot::default();
slot.raise(notice(NoticeId::Connection, Priority::Info, "new"));
assert!(slot.update(Duration::from_secs(60)));
std::thread::sleep(Duration::from_millis(2));
// Past its time it slides out rather than vanishing.
assert!(slot.update(Duration::ZERO));
assert!(slot.showing().is_some());
}
}
+62
View File
@@ -1483,6 +1483,68 @@ impl Experience {
sound_level
}
}
/// A player's `Entity.tickCount` and `Player.lastLevelUpTime`. This state belongs
/// to the player entity, not the level or the server's clock.
#[derive(Clone, Copy, Debug, Default)]
pub struct LevelUpSound {
ticks: i32,
last: i32,
}
impl LevelUpSound {
/// `Entity.commonTick`: called once for this player, including spectators.
pub fn tick(&mut self) {
self.ticks = self.ticks.wrapping_add(1);
}
/// `Player.giveExperienceLevels`: return the sound's volume and start its
/// cooldown when a positive change lands on a fifth level.
pub fn give_levels(&mut self, amount: i32, level: i32) -> Option<f32> {
if amount > 0 && level % 5 == 0 && (self.last as f32) < self.ticks as f32 - 100.0 {
self.last = self.ticks;
Some((if level > 30 { 1.0 } else { level as f32 / 30.0 }) * 0.75)
} else {
None
}
}
}
#[cfg(test)]
mod level_up_sound {
use super::LevelUpSound;
#[test]
fn players_have_independent_ages_and_cooldowns() {
let mut a = LevelUpSound::default();
let mut b = LevelUpSound::default();
for _ in 0..100 {
a.tick();
b.tick();
}
assert_eq!(a.give_levels(1, 5), None);
a.tick();
b.tick();
assert_eq!(a.give_levels(1, 5), Some(0.125));
assert_eq!(b.give_levels(1, 10), Some(0.25));
assert_eq!(a.give_levels(1, 10), None);
for _ in 0..100 {
a.tick();
}
assert_eq!(a.give_levels(1, 30), None);
a.tick();
assert_eq!(a.give_levels(1, 30), Some(0.75));
let mut respawned = LevelUpSound::default();
assert_eq!(respawned.give_levels(1, 35), None);
for _ in 0..101 {
respawned.tick();
}
assert_eq!(respawned.give_levels(-1, 35), None);
assert_eq!(respawned.give_levels(1, 34), None);
assert_eq!(respawned.give_levels(1, 35), Some(0.75));
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
/// 2D data value of the direction it faces, and its variant.
#[derive(Clone, Debug)]
+136
View File
@@ -713,6 +713,7 @@ pub fn mob_to_nbt(
);
}
if id == "minecraft:piglin" {
write_guarded_piglin_anger(m, map);
// InventoryCarrier.writeInventoryToTag.
map.insert(
"Inventory".into(),
@@ -4492,6 +4493,42 @@ mod tests {
}
}
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
let Some(actor) = m.extra.piglin.guarded_angry_at else {
return;
};
let brain = map
.entry("Brain".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(brain, Tag::Compound(_)) {
*brain = Tag::Compound(Compound::new());
}
let Tag::Compound(brain) = brain else { return };
let memories = brain
.entry("memories".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(memories, Tag::Compound(_)) {
*memories = Tag::Compound(Compound::new());
}
let Tag::Compound(memories) = memories else {
return;
};
if let Some(uuid) = actor.filter(|_| m.extra.anger > 0) {
// ExpirableValue.codec: the UUID value and optional remaining Long TTL.
memories.insert(
"minecraft:angry_at".into(),
Tag::compound([
("value", Tag::IntArray(uuid.to_vec())),
("ttl", Tag::Long(i64::from(m.extra.anger))),
]),
);
} else {
memories.remove(&"minecraft:angry_at".into());
}
}
fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
let brain = map
.entry("Brain".into())
@@ -6890,3 +6927,102 @@ mod generic_home_nbt_tests {
}
}
}
#[cfg(test)]
mod piglin_live_anger_memory_tests {
use super::*;
fn mob() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:piglin".into();
ty.species = crate::mobs::Species::Piglin;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
fn raw() -> Tag {
Tag::compound([(
"Brain",
Tag::compound([
("audit:root", Tag::Int(8)),
(
"memories",
Tag::compound([
("audit:keep", Tag::compound([("value", Tag::Int(7))])),
(
"minecraft:angry_at",
Tag::compound([
("value", Tag::IntArray(vec![90, 91, 92, 93])),
("ttl", Tag::Long(99)),
]),
),
]),
),
]),
)])
}
fn memory(tag: &Tag) -> Option<&Tag> {
tag.get("Brain")?.get("memories")?.get("minecraft:angry_at")
}
fn assert_unrelated(saved: &Tag) {
let brain = saved.get("Brain").unwrap();
assert_eq!(brain.get("audit:root"), Some(&Tag::Int(8)));
assert_eq!(
brain.get("memories").unwrap().get("audit:keep"),
Some(&Tag::compound([("value", Tag::Int(7))]))
);
}
#[test]
fn guarded_event_saves_actual_uuid_and_remaining_long_ttl() {
let raw = raw();
for actor in [[1, 2, 3, 4], [-5, 6, 7, 8]] {
let mut m = mob();
crate::mobs::piglin::set_guarded_anger_target(&mut m, Some(actor));
assert_eq!(m.extra.anger, 600);
assert_eq!(m.ai.target, Some(Who::Player));
for remaining in [600, 520] {
m.extra.anger = remaining;
for base in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [42, 43, 44, 45], base, None);
assert_eq!(
memory(&saved),
Some(&Tag::compound([
("value", Tag::IntArray(actor.to_vec())),
("ttl", Tag::Long(i64::from(remaining))),
]))
);
if base.is_some() {
assert_unrelated(&saved);
}
}
}
}
}
#[test]
fn expired_owned_memory_cannot_resurrect_stale_raw_anger() {
let mut m = mob();
crate::mobs::piglin::set_guarded_anger_target(&mut m, Some([1, 2, 3, 4]));
m.extra.anger = 0;
m.ai.target = None;
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw()), None);
assert!(memory(&saved).is_none());
assert_unrelated(&saved);
}
#[test]
fn calm_loaded_and_missing_identity_states_are_distinct() {
let mut m = mob();
assert!(memory(&mob_to_nbt(&m, [42, 43, 44, 45], None, None)).is_none());
let raw = raw();
// Loaded Brain semantics stay outside this live event projection.
let untouched = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
assert_eq!(memory(&untouched), memory(&raw));
crate::mobs::piglin::set_guarded_anger_target(&mut m, None);
assert_eq!(m.extra.anger, 600);
assert_eq!(m.ai.target, Some(Who::Player));
let saved = mob_to_nbt(&m, [42, 43, 44, 45], Some(&raw), None);
assert!(memory(&saved).is_none());
assert_unrelated(&saved);
}
}
+22 -11
View File
@@ -76,6 +76,7 @@ mod books;
mod bossbars;
mod breezes;
mod brush;
mod building_players;
mod bundles;
mod campfire;
mod campfire_douse;
@@ -960,6 +961,9 @@ struct GameLevel<'w> {
player_box: Option<([f64; 3], [f64; 3])>,
/// The LAN guests in this level who trigger blocks (not spectators).
guest_boxes: Vec<([f64; 3], [f64; 3])>,
/// Other players that block placement during a guest turn, whose hub is
/// borrowed out; None outside one, where `guest_boxes` are those players.
building_player_boxes: Option<Vec<([f64; 3], [f64; 3])>>,
player_collision: Option<PlayerCollision>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
@@ -1150,7 +1154,11 @@ impl GameLevel<'_> {
let b = self.entities;
self.player_box
.iter()
.chain(&self.guest_boxes)
.chain(
self.building_player_boxes
.as_ref()
.unwrap_or(&self.guest_boxes),
)
.chain(&self.cart_boxes)
.map(boxed)
.chain(b.mobs.iter().filter(|m| !m.removed).map(Mob::bounds))
@@ -2761,7 +2769,7 @@ struct Game<'a> {
orbs: horizon_client::join_seq::JoinList<ExperienceOrb>,
xp_delay: u32,
ordered: ordered_tick::OrderedTicks,
last_level_up: u64,
level_up_sound: horizon_client::entities::LevelUpSound,
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
@@ -3184,6 +3192,8 @@ struct Game<'a> {
/// have each chest open.
lid_openers: HashMap<(i32, i32, i32), i32>,
lan_acting: Option<u32>,
/// Other players in the current guest turn, while its hub is borrowed out.
lan_building_boxes: building_players::BuildingPlayers,
/// A guest's `/save-all`: saved once their turn ends (`run_guest_command`),
/// when their own state is back in their record.
save_after_guest_turn: bool,
@@ -4635,6 +4645,10 @@ impl Game<'_> {
})
.collect();
let guest_boxes = self.lan_block_trigger_boxes();
let building_player_boxes = self
.lan_acting
.is_some()
.then(|| self.lan_building_boxes.boxes(self.dim));
let dimension = match self.dim {
Dim::Overworld => LevelDimension::Overworld,
Dim::Nether => LevelDimension::Nether,
@@ -4717,6 +4731,7 @@ impl Game<'_> {
thundering: thunder > 0.9,
player_box,
guest_boxes,
building_player_boxes,
player_collision,
biomes: world_biomes!(self),
seed: self.level.seed,
@@ -8263,6 +8278,7 @@ impl Game<'_> {
fn pauses_game(&self) -> bool {
self.has_singleplayer_server()
&& !self.is_published()
&& !self.has_split_players()
&& self.title.is_none()
&& (matches!(
self.screen,
@@ -14242,19 +14258,13 @@ impl Game<'_> {
return;
};
let gained = gained.max(0);
let at = self.eye_position();
let old_level = self.experience.level;
let gave = self.experience.give(gained);
self.native_player_level_changed(old_level);
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = gave
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
self.audio
.play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0);
if let Some(level) = gave {
self.play_level_up(1, level);
}
}
/// BowItem.releaseUsing: power from charge time, arrows at power * 3.
@@ -19525,7 +19535,7 @@ impl<'a> Game<'a> {
orbs: Default::default(),
xp_delay: 0,
ordered: Default::default(),
last_level_up: 0,
level_up_sound: Default::default(),
swing: Default::default(),
swings_started: 0,
heights: Default::default(),
@@ -19759,6 +19769,7 @@ impl<'a> Game<'a> {
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
lan_building_boxes: Default::default(),
save_after_guest_turn: false,
player_attachments: Default::default(),
guest_attachments: HashMap::new(),
+43 -6
View File
@@ -10,6 +10,7 @@ use horizon_client::{
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::Clientbound as C;
use serde_json::Value;
use std::{
@@ -856,6 +857,20 @@ impl Game<'_> {
}
}
}
/// `AdvancementRewards.grant`'s pickup: volume 0.2 at the player's feet,
/// pitch `(nextFloat - nextFloat) * 0.7 + 1`, times 2. `playSound(null, …)`
/// so the player hears it. The draws are the level random, which is the
/// stream item pickup already uses for this pitch.
fn advancement_pickup_sound(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let pitch =
((self.world.random.next_float() - self.world.random.next_float()) * 0.7 + 1.0) * 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(at), 0.2, pitch);
}
/// `PlayerAdvancements.award` once done: `AdvancementRewards.grant`,
/// then toast and announcement.
fn award(&mut self, index: usize) {
@@ -868,7 +883,10 @@ impl Game<'_> {
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
self.experience.give(points);
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
@@ -886,10 +904,16 @@ impl Game<'_> {
for table in &a.rewards.loot {
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
if let Some(rest) = self.give(s)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0; 3], 0);
// Inventory.add returns false when anything is left, and
// AdvancementRewards then drops that remainder with no
// pickup sound. A stack the inventory takes whole plays
// entity.item.pickup at the player's feet.
if let Some(rest) = self.give(s) {
if let Some(eye) = self.eye_position() {
self.spawn_item(rest, eye, [0.0; 3], 0);
}
} else {
self.advancement_pickup_sound();
}
}
}
@@ -898,7 +922,11 @@ impl Game<'_> {
}
if let Some(d) = &a.display {
// A network player's client shows its own toast.
if d.show_toast && !self.adv.quiet && self.lan_acting.is_none() {
if d.show_toast
&& !self.adv.quiet
&& self.lan_acting.is_none()
&& self.toast_allowed("advancement", &a.id)
{
self.adv.toasts.push_back(Toast {
icon: d.icon.clone(),
frame: d.frame,
@@ -2036,6 +2064,14 @@ impl Game<'_> {
.chain(self.narrator_toast.as_ref().and_then(|t| t.slot))
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
}
/// `ToastManager.addToast`'s `ToastAddEvent`: whether a new toast of
/// `kind` (`id` within it) may be queued; false when a mod cancelled it.
/// Always asked: a bundle injected at the hook point is not in `mods()`.
pub(super) fn toast_allowed(&mut self, kind: &str, id: &str) -> bool {
let mut cancel = false;
self.native_toast_add(kind, id, &mut cancel);
!cancel
}
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
let t = &self.adv.toasts[index];
@@ -2291,6 +2327,7 @@ impl Game<'_> {
&& p.show
&& self.adv.progress.done(a)
&& let Some(d) = a.display.as_ref().filter(|d| d.show_toast)
&& self.toast_allowed("advancement", &a.id)
{
self.adv.toasts.push_back(Toast {
icon: d.icon.clone(),
@@ -0,0 +1,288 @@
//! Player candidates for EntityGetter.isUnobstructed while a guest owns the
//! game body. Identity keeps borrowed ancestor bodies distinct from hub copies.
use super::Dim;
use horizon_core::geometry::Aabb;
#[derive(Clone, Copy)]
pub(super) struct BuildingPlayer {
pub(super) conn: Option<u32>,
pub(super) dimension: Dim,
pub(super) bounds: Option<Aabb>,
pub(super) borrowed: bool,
}
#[derive(Default)]
pub(super) struct BuildingPlayers(Vec<BuildingPlayer>);
impl BuildingPlayers {
/// Refresh hub-owned bodies, preserve live ancestor bodies held outside
/// the hub, and omit the actor (GameLevel.player_box supplies that body).
pub(super) fn for_turn(
&self,
actor: u32,
outer: Option<BuildingPlayer>,
current: Vec<BuildingPlayer>,
) -> Self {
// The borrowed ancestors are the few nested turns (usually none or
// one): only they are searched, so a turn costs O(players), not
// O(players²), every guest's turn of every tick.
let mut players: Vec<_> = self
.0
.iter()
.copied()
.filter(|p| {
p.borrowed
&& p.conn != Some(actor)
&& (p.conn.is_none() || current.iter().any(|g| g.conn == p.conn))
})
.collect();
if let Some(outer) = outer {
players.retain(|p| p.conn != outer.conn);
if outer.conn != Some(actor)
&& (outer.conn.is_none() || current.iter().any(|g| g.conn == outer.conn))
{
players.push(outer);
}
}
let ancestors = players.len();
players.reserve(current.len());
for player in current {
// Hub conns are unique, so only the ancestors can already hold one.
if player.conn != Some(actor)
&& !players[..ancestors].iter().any(|p| p.conn == player.conn)
{
players.push(player);
}
}
Self(players)
}
pub(super) fn boxes(&self, dimension: Dim) -> Vec<([f64; 3], [f64; 3])> {
self.0
.iter()
.filter(|p| p.dimension == dimension)
.filter_map(|p| p.bounds)
.map(|bounds| {
(
bounds.min_position().components(),
bounds.max_position().components(),
)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::geometry::Vec3;
fn player(conn: Option<u32>, x: f64, dimension: Dim, borrowed: bool) -> BuildingPlayer {
BuildingPlayer {
conn,
dimension,
borrowed,
bounds: Some(Aabb::new(
Vec3::new(x, 0.0, 0.0),
Vec3::new(x + 0.6, 1.8, 0.6),
)),
}
}
fn xs(players: &BuildingPlayers, dim: Dim) -> Vec<f64> {
players.boxes(dim).iter().map(|(lo, _)| lo[0]).collect()
}
#[test]
fn nested_turns_preserve_host_and_live_ancestors_without_the_new_actor() {
let hub = || {
vec![
player(Some(1), 10.0, Dim::Overworld, false),
player(Some(2), 20.0, Dim::Overworld, false),
player(Some(3), 30.0, Dim::Nether, false),
]
};
let host = player(None, 0.0, Dim::Overworld, true);
let first = BuildingPlayers::default().for_turn(1, Some(host), hub());
let second = first.for_turn(2, Some(player(Some(1), 11.0, Dim::Overworld, true)), hub());
assert_eq!(xs(&second, Dim::Overworld), [0.0, 11.0]);
// A further turn in another level keeps each ancestor's own level.
let third = second.for_turn(3, Some(player(Some(2), 21.0, Dim::Nether, true)), hub());
assert_eq!(xs(&third, Dim::Overworld), [0.0, 11.0]);
assert_eq!(xs(&third, Dim::Nether), [21.0]);
}
#[test]
fn returning_inner_turn_refreshes_moved_and_inactive_hub_bodies() {
let host = player(None, 0.0, Dim::Overworld, true);
let first = BuildingPlayers::default().for_turn(
1,
Some(host),
vec![
player(Some(1), 10.0, Dim::Overworld, false),
player(Some(2), 20.0, Dim::Overworld, false),
],
);
// Restore the first turn after player 2 moved across dimensions.
let restored = first.for_turn(
1,
None,
vec![
player(Some(1), 10.0, Dim::Overworld, false),
player(Some(2), 25.0, Dim::Nether, false),
],
);
assert_eq!(xs(&restored, Dim::Overworld), [0.0]);
assert_eq!(xs(&restored, Dim::Nether), [25.0]);
// Death, spectator mode, and unloaded bodies provide no building box.
let mut inactive = player(Some(2), 25.0, Dim::Nether, false);
inactive.bounds = None;
let restored = restored.for_turn(1, None, vec![inactive]);
assert_eq!(xs(&restored, Dim::Overworld), [0.0]);
assert!(xs(&restored, Dim::Nether).is_empty());
}
#[test]
fn disconnected_ancestors_and_inactive_outer_bodies_do_not_linger() {
let first = BuildingPlayers::default().for_turn(
2,
Some(player(Some(1), 10.0, Dim::Overworld, true)),
vec![player(Some(1), 1.0, Dim::Overworld, false)],
);
let disconnected = first.for_turn(3, None, Vec::new());
assert!(xs(&disconnected, Dim::Overworld).is_empty());
let disconnected_outer = first.for_turn(
3,
Some(player(Some(1), 11.0, Dim::Overworld, true)),
Vec::new(),
);
assert!(xs(&disconnected_outer, Dim::Overworld).is_empty());
let mut outer = player(None, 0.0, Dim::Overworld, true);
let first = BuildingPlayers::default().for_turn(1, Some(outer), Vec::new());
outer.bounds = None;
let second = first.for_turn(2, Some(outer), Vec::new());
assert!(xs(&second, Dim::Overworld).is_empty());
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn for_player_keeps_the_outer_body_in_its_original_dimension() {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::{
lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig},
motion::PlayerMotion,
};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!(
"horizon-building-turn-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 0.5)).unwrap());
let original = game.player.as_ref().unwrap().bounds();
let net = NetConfig {
announce: false,
..NetConfig::lan(0, "building-test".into(), 2)
};
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
net,
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: Dim::Overworld.id().into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, _rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 2,
name: "NetherGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
},
2,
[20.5, 64.0, 0.5],
2,
);
guest.dimension = Dim::Nether.id().into();
guest.placed = true;
guest.loaded = true;
game.lan.as_mut().unwrap().hub.guests.push(guest);
let seen = game.for_player(super::super::commands::Target::Guest(2), |g| {
assert_eq!(g.dim, Dim::Nether);
// for_player already switched the level before entering the guest.
// The host's body must not become a phantom blocker in the Nether.
assert!(g.lan_building_boxes.boxes(Dim::Nether).is_empty());
assert_eq!(
g.lan_building_boxes.boxes(Dim::Overworld),
vec![(
original.min_position().components(),
original.max_position().components()
)]
);
g.player.as_ref().unwrap().position
});
assert_eq!(seen, Some(Vec3::new(20.5, 64.0, 0.5)));
assert_eq!(game.dim, Dim::Overworld);
assert_eq!(
game.player.as_ref().unwrap().position,
Vec3::new(10.5, 64.0, 0.5)
);
drop(game);
assert!(scratch.starts_with(std::env::temp_dir()));
std::fs::remove_dir_all(scratch).unwrap();
}
}
@@ -16,11 +16,19 @@ impl Game<'_> {
if self.serving.is_some() || self.options.dedicated.is_some() {
return;
}
// A window simulating the world already runs `WitherBoss.aiStep`.
// One following a server does not: `Level.addParticle` on the server
// draws nothing, so the smoke, the powered effect and the charge
// cloud have to be made here.
let following = self.remote.is_some();
let mut events = Vec::new();
let random = self.particles.level_random();
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
ambient::client_ai_step(m, &mut events);
ambient::client_particles(m, random, &mut events);
if following && m.species() == horizon_client::mobs::Species::Wither {
horizon_client::mobs::wither::head_particles(m, random, &mut events);
}
}
for event in events {
match event {
@@ -30,6 +38,22 @@ impl Game<'_> {
.add(&ParticleOptions::simple(ty), at, velocity);
}
}
MobEvent::Particles { kind, at, count: 0 } => {
if let Some(ty) = ParticleType::from_name(kind) {
self.particles
.add(&ParticleOptions::simple(ty), at, [0.0; 3]);
}
}
MobEvent::ParticleColored {
at,
color: [r, g, b],
} => {
self.particles.add(
&ParticleOptions::colored(ParticleType::EntityEffect, [r, g, b, 1.0]),
at,
[0.0; 3],
);
}
// Each guest of a LAN host makes its own.
MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -97,7 +97,7 @@ impl Game<'_> {
return Err(error);
}
let swap = self
.enter_guest(&mut host, conn)
.enter_guest(&mut host, conn, back)
.expect("the owner is not already acting");
self.lan = Some(host);
let result = f(self);
@@ -111,6 +111,32 @@ impl Game<'_> {
result
}
/// The connected owner's authoritative block/crafting menu, without
/// client prediction. Only its own slots are counted (not the inventory).
fn harness_owner_menu(&mut self) -> Result<(u32, usize, i32)> {
let owner = self.serving.as_ref().ok_or("menu state: no server")?.owner;
self.in_owner_level(|g| {
let host = g.lan.as_ref().ok_or("menu state: no hub")?;
let guest = host
.hub
.guests
.iter()
.find(|p| p.uuid == owner && p.placed)
.ok_or("menu state: owner is not connected")?;
if let Some(menu) = &guest.menu {
return Ok((guest.conn, menu.size, menu.window));
}
if let Some((window, _, menu, _)) = host
.play
.get(&guest.conn)
.and_then(|p| p.crafting.table.as_ref())
{
return Ok((guest.conn, menu.slot_count() - 36, *window));
}
Ok((guest.conn, 0, 0))
})
}
/// The player's portal process finished in the nether portal it stands
/// in (the oracle's player is not ticked, so neither is the wait).
fn harness_portal(&mut self) -> Result<()> {
@@ -583,6 +609,57 @@ impl Game<'_> {
Ok(())
})?;
}
"frame_map_state" => {
// Read saved-map association and live decorations, not network delivery.
let uuid = u128::from_str_radix(
&op["uuid"]
.as_str()
.ok_or("frame map: missing UUID")?
.replace('-', ""),
16,
)?;
let map_id = op["id"].as_i64().ok_or("frame map: missing map id")? as i32;
let state = self.harness_owner(|g| {
let Some(super::commands::Target::Frame(id)) = g.target_by_uuid(uuid) else {
return Err("frame map: UUID is not a live frame".into());
};
let actual = *g.persist.uuids.get(&id).ok_or("frame map: no UUID")?;
let frame = g.item_frames.iter().find(|f| f.id == id)
.ok_or("frame map: missing frame")?.clone();
if horizon_client::lan::playerdata::ints_uuid(actual) != uuid {
return Err("frame map: UUID mismatch".into());
}
let map = g.map(map_id).ok_or("frame map: no backed map")?;
let saved = map.to_nbt();
let data = saved.get("data").ok_or("frame map: no saved data compound")?;
let records = match data.get("frames") {
None => &[][..], // the CODEC omits an empty list
Some(horizon_world::nbt::Tag::List(list)) => list.as_slice(),
_ => return Err("frame map: frames is not a list".into()),
};
let record = records.iter().find(|r|
horizon_client::save::number(r.get("entity_id")) == Some(i64::from(id)));
let pos = record.map(|r| match r.get("pos") {
Some(horizon_world::nbt::Tag::IntArray(pos)) => Ok(pos.clone()),
_ => Err("frame map: invalid saved position"),
}).transpose()?;
let rotation = record.map(|r|
horizon_client::save::number(r.get("rotation"))
.ok_or("frame map: missing rotation")).transpose()?;
Ok(json!({"id":map_id,"uuid":actual,"kind":frame.kind(),"pos":frame.pos,
"center":map.center,"scale":map.scale,"dimension":map.dimension,
"saved":record.is_some(),"saved_pos":pos,"saved_rotation":rotation,
"decoration":map.decorations.iter().any(|d|d.key==super::maps::Key::Frame(id as i32)),
"frame_records":records.len(),
"frame_decorations":map.decorations.iter().filter(|d|d.sprite=="map/decorations/frame").count()}))
})?;
out.push_str(
&json!({"cmd":format!("#frame_map {}",op["label"].as_str().unwrap_or("")),
"messages":[],"results":[],"state":state})
.to_string(),
);
out.push('\n');
}
"map_state" => {
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
let value = self.harness_owner(|g| {
@@ -616,9 +693,34 @@ impl Game<'_> {
out.push_str(&record.to_string());
out.push('\n');
}
"menu_state" => {
let (_, own_slots, _) = self.harness_owner_menu()?;
let record = json!({"cmd": format!("#menu_state {}", op["label"].as_str().unwrap_or("")),
"messages": [], "results": [], "state": {"own_slots": own_slots}});
out.push_str(&record.to_string());
out.push('\n');
}
"close_menu" => {
let (conn, _, window) = self.harness_owner_menu()?;
self.in_owner_level(|g| -> Result<()> {
let mut host = g.lan.take().ok_or("close menu: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::ContainerClose(
horizon_protocol::packets::play::ContainerClose {
container: window,
},
),
);
g.lan = Some(host);
Ok(())
})?;
}
"use_block" => {
// Exercise the connected owner's normal server packet route.
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
let sequence = op["sequence"].as_i64().unwrap_or(0) as i32;
let face = op["face"].as_i64().unwrap_or(1) as i32;
let owner = self
.serving
@@ -649,11 +751,18 @@ impl Game<'_> {
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence: 0,
sequence,
},
),
);
let acknowledged = host
.hub
.guest(conn)
.is_some_and(|p| p.ack_up_to.get() >= sequence);
g.lan = Some(host);
if !acknowledged {
return Err("use block: owner packet was not acknowledged".into());
}
Ok(())
})?;
}
@@ -3055,6 +3055,7 @@ impl Game<'_> {
/// The caller reports the level change to the mods.
fn give_points(&mut self, amount: i32) {
let e = &mut self.experience;
let mut sound_level = None;
e.progress += amount as f32 / horizon_client::entities::Experience::needed(e.level) as f32;
e.total = e.total.saturating_add(amount).clamp(0, i32::MAX);
while e.progress < 0.0 {
@@ -3071,8 +3072,14 @@ impl Game<'_> {
e.progress =
(e.progress - 1.0) * horizon_client::entities::Experience::needed(e.level) as f32;
e.level += 1;
if e.level % 5 == 0 {
sound_level.get_or_insert(e.level);
}
e.progress /= horizon_client::entities::Experience::needed(e.level) as f32;
}
if let Some(level) = sound_level {
self.play_level_up(1, level);
}
}
/// AdvancementCommands.perform / performCriterion.
fn cmd_advancement(&mut self, ctx: &Context) -> R<i32> {
@@ -400,9 +400,23 @@ impl Game<'_> {
}
}
Target::Frame(id) => {
let new = entity_nbt::item_frame_from_nbt(&tag, id).ok_or_else(stale)?;
if let Some(slot) = self.item_frames.iter_mut().find(|f| f.id == id) {
*slot = new;
let mut new = entity_nbt::item_frame_from_nbt(&tag, id).ok_or_else(stale)?;
if let Some(i) = self.item_frames.iter().position(|f| f.id == id) {
let old = &self.item_frames[i];
// Entity.load cannot change the runtime type; saveWithoutId
// deliberately omitted the decoder's type discriminator.
new.glow = old.glow;
// ItemFrame.readAdditionalSaveData: ItemStack.matches includes
// count, item and components, before adopting the new stack.
if old.item.as_ref().is_some_and(|a| {
new.item
.as_ref()
.is_none_or(|b| a.count != b.count || !a.same_item_same_components(b))
}) {
let old = old.clone();
self.remove_framed_map(&old);
}
self.item_frames[i] = new;
}
}
Target::Boat(id) => {
@@ -191,7 +191,7 @@ impl Game<'_> {
{
log::error!("could not open {}: {e}", dim.id());
}
let Some(swap) = self.enter_guest(&mut host, conn) else {
let Some(swap) = self.enter_guest(&mut host, conn, back) else {
if self.dim != back
&& let Err(e) = self.enter_level(Some(&mut host), back)
{
+207 -38
View File
@@ -56,7 +56,9 @@ pub(super) struct Cosmetics {
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Show my cosmetics on its way to the service.
mine: MineSync,
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
}
@@ -114,13 +116,71 @@ fn http() -> horizon_cosmetics::Http {
horizon_cosmetics::Http::new(&api, &format!("Horizon/{}", env!("CARGO_PKG_VERSION")))
}
/// Sending Show my cosmetics: the wish it last looked at, the request on its way, and when to try
/// again after a failure (15 s, doubling to 10 minutes).
/// Sending one of the account's switches: the wish it last looked at, the request on its way,
/// when to try again after a failure (15 s, doubling to 10 minutes), and when to say an unchanged
/// wish again.
#[derive(Default)]
struct MineSync {
struct AccountSync {
wish: Option<bool>,
sending: Option<Receiver<Result<(), String>>>,
retry: Option<(Instant, Duration)>,
again: Option<Instant>,
}
impl AccountSync {
/// Takes the answer of the request on its way, if it came; `every`: how long a sent `true`
/// stands before it is sent again.
fn settle(&mut self, every: Option<Duration>) {
let Some(done) = self.sending.as_ref().and_then(|rx| rx.try_recv().ok()) else {
return;
};
self.sending = None;
match done {
Ok(()) => {
self.retry = None;
self.again = every
.filter(|_| self.wish == Some(true))
.map(|e| Instant::now() + e);
}
Err(_) => self.retry = backoff(self.retry),
}
}
/// Whether `wish` is to be sent now: it changed, a failure's wait is over, or `again` is due.
fn due(&self, wish: bool) -> bool {
let timer = self.retry.map(|(at, _)| at).or(self.again);
self.sending.is_none()
&& (self.wish != Some(wish) || timer.is_some_and(|at| Instant::now() >= at))
}
/// Sends `wish` on a thread.
fn start(&mut self, wish: bool, send: fn(bool) -> Result<(), String>) {
self.wish = Some(wish);
self.again = None;
let (tx, rx) = channel();
self.sending = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(send(wish));
})
.ok();
}
}
/// Tells the service whether the signed-in account may be shown as a Horizon player (listed: it
/// plays now). An offline account has nothing to send.
fn send_listed(listed: bool) -> Result<(), String> {
let Some(session) = horizon_client::auth::current() else {
return Ok(());
};
horizon_cosmetics::account::set_listed(
&http(),
&session.name,
|id| horizon_client::auth::join_server(&session, id).map_err(|e| format!("{e:?}")),
listed,
)
.map(|_| ())
}
/// Makes the service keep `wish` for the signed-in account, unless it already confirmed it
@@ -179,6 +239,23 @@ fn worn_of(
client.frame().worn(uuid()?)
}
/// The UUID the service knows the player's own model by. On a server, the profile it gave
/// (`profile`). In single player the window's own server (`solo`) names the player by their
/// offline profile, which the service doesn't know: the signed-in account's (`account`) then, only
/// with *Cosmetics in single player* on (`shown`); off, nothing is asked or drawn.
fn own_uuid(
profile: Option<u128>,
solo: bool,
shown: bool,
account: impl FnOnce() -> Option<u128>,
) -> Option<u128> {
match solo {
false => profile,
true if shown && profile.is_some() => account(),
true => None,
}
}
/// The game's model of an item's part.
fn part(p: &horizon_cosmetics::Part) -> Part {
Part {
@@ -259,7 +336,7 @@ impl Game<'_> {
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && c.mine.sending.is_none() {
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
@@ -273,27 +350,20 @@ impl Game<'_> {
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = c.owner.owns;
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
if !c.local {
let wish = self.settings.extras.cosmetics_mine;
let m = &mut c.mine;
if let Some(done) = m.sending.as_ref().and_then(|rx| rx.try_recv().ok()) {
m.sending = None;
m.retry = match done {
Ok(()) => None,
Err(_) => backoff(m.retry),
};
}
let due = m.wish != Some(wish) || m.retry.is_some_and(|(at, _)| Instant::now() >= at);
if due && m.sending.is_none() && c.owner.asking.is_none() {
m.wish = Some(wish);
let (tx, rx) = channel();
m.sending = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(send_mine(wish));
})
.ok();
let every = horizon_cosmetics::account::PRESENCE_EVERY;
c.mine.settle(None);
c.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy =
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
let extras = &self.settings.extras;
if busy {
} else if c.mine.due(extras.cosmetics_mine) {
c.mine.start(extras.cosmetics_mine, send_mine);
} else if c.listed.due(extras.horizon_listed) {
c.listed.start(extras.horizon_listed, send_listed);
}
}
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
@@ -310,10 +380,54 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// The name tag style `m` wears, if cosmetics are shown for it (name tags only counts): its
/// mark's place in the atlas and its line's colour. None until the mark is in the atlas.
pub(super) fn cosmetics_tag(&self, m: &Mob, own: bool) -> Option<super::names::TagStyle> {
let worn = self.worn(m, own)?;
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
pub(super) fn cosmetics_local(&self) -> bool {
self.cosmetics.local
}
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), and the
/// name tag style they wear if cosmetics are shown for them (name tags only counts). One
/// look in the cache for both.
pub(super) fn cosmetics_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
let mut marks = super::names::Marks::default();
let extras = &self.settings.extras;
let styles = if own {
extras.cosmetics_mine
} else {
extras.effective_others() != Others::Hidden
};
if !(styles || extras.horizon_marks) {
return marks;
}
let (Some(client), Some(uuid)) = (&self.cosmetics.client, self.cosmetics_uuid(m, own))
else {
return marks;
};
let mut frame = client.frame();
marks.horizon = extras.horizon_marks && frame.horizon(uuid);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
marks.style = worn.and_then(|w| self.cosmetics_tag(&w));
marks
}
/// Those of `uuids` who play Horizon, with the mark on (the player list): one look each.
pub(super) fn horizon_players(
&self,
uuids: impl Iterator<Item = u128>,
) -> std::collections::HashSet<u128> {
match &self.cosmetics.client {
Some(c) if self.settings.extras.horizon_marks => {
let mut frame = c.frame();
uuids.filter(|u| frame.horizon(*u)).collect()
}
_ => Default::default(),
}
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas.
fn cosmetics_tag(&self, worn: &horizon_cosmetics::Worn) -> Option<super::names::TagStyle> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let frame = self.cosmetics.frame.get();
@@ -342,18 +456,28 @@ impl Game<'_> {
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
let client = self.cosmetics.client.as_ref()?;
worn_of(client, &self.settings.extras, own, || {
if own {
self.remote.as_ref().map(|r| r.profile())
} else if &*m.kind.id == "minecraft:player" {
self.remote_player_uuid(m.id)
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
Some(u128::from(m.id))
} else {
None
}
self.cosmetics_uuid(m, own)
})
}
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
fn cosmetics_uuid(&self, m: &Mob, own: bool) -> Option<u128> {
if own {
own_uuid(
self.remote.as_ref().map(|r| r.profile()),
self.integrated.is_some(),
self.settings.extras.cosmetics_solo,
|| horizon_client::auth::current().map(|s| s.uuid),
)
} else if &*m.kind.id == "minecraft:player" {
self.remote_player_uuid(m.id)
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
Some(u128::from(m.id))
} else {
None
}
}
/// The cosmetics `m` wears, on its bones. `gpu`: posed on the GPU (the mob pass's skin
/// frame, begun); the bounds of what went there come back, for culling. Else the vertices
/// go to `out`.
@@ -561,4 +685,49 @@ mod tests {
let (worn, asked) = other_player(&hid, short);
assert!(worn.is_none() && asked.is_empty());
}
#[test]
fn single_player_shows_my_cosmetics_only_with_its_setting_and_asks_by_my_account() {
// The window's own server names the player by their offline profile.
const OFFLINE: u128 = 0x0ff1;
const ACCOUNT: u128 = 0xacc0;
let mine = |solo: bool, shown: bool, patience: Duration| {
let service = Service::default();
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
let extras = Extras {
cosmetics_solo: shown,
..Extras::default()
};
let uuid = || own_uuid(Some(OFFLINE), solo, extras.cosmetics_solo, || Some(ACCOUNT));
let deadline = Instant::now() + patience;
let worn = loop {
let worn = worn_of(&client, &extras, true, uuid);
if worn.is_some() || Instant::now() >= deadline {
break worn;
}
std::thread::sleep(Duration::from_millis(20));
};
let asked = service.0.lock().unwrap().clone();
(worn.is_some(), asked)
};
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
// Off by default: nothing drawn, nothing asked.
assert_eq!(mine(true, false, short), (false, vec![]));
// On: asked by the signed-in account, never by the offline profile.
let (shown, asked) = mine(true, true, long);
assert!(shown);
assert_eq!(
asked[0],
format!("POST /v1/worn {{\"players\":[\"{ACCOUNT:032x}\"]}}")
);
// On a server the setting changes nothing: the server's profile.
let (shown, asked) = mine(false, false, long);
assert!(shown);
assert_eq!(
asked[0],
format!("POST /v1/worn {{\"players\":[\"{OFFLINE:032x}\"]}}")
);
// An offline account in single player: nothing to ask about.
assert_eq!(own_uuid(Some(OFFLINE), true, true, || None), None);
}
}
+16 -12
View File
@@ -3436,8 +3436,7 @@ impl Game<'_> {
};
}
self.world.random = r;
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
return;
}
if p.species() == Species::Cat {
@@ -3463,8 +3462,7 @@ impl Game<'_> {
c.extra.color = mixed;
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
return;
}
if p.species() == Species::Wolf {
@@ -3491,23 +3489,20 @@ impl Game<'_> {
c.extra.color = mixed;
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
return;
}
// Panda.getBreedOffspring: genes from both parents.
if p.species() == Species::Panda {
horizon_client::mobs::panda::inherit(child, &p, q.as_ref().unwrap_or(&p));
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
return;
}
// Axolotl.getBreedOffspring: the drawn variant, and the baby is persistent.
if let Some(variant) = axolotl {
child.extra.variant = variant;
child.persistent = true;
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
return;
}
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
@@ -3543,8 +3538,17 @@ impl Game<'_> {
}
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
}
/// `Animal.finalizeSpawnChildFromBreeding`'s experience, from the breeding
/// parent's own random, and only when the `mob_drops` game rule is on.
fn breeding_orb(&mut self, parent: u32, at: [f64; 3]) {
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs(at, xp);
}
}
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
/// mining fatigue III for five minutes unless a stronger or longer one
@@ -3,8 +3,8 @@
//! (including their positions) and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
//! `player-expect <uuid> [seconds]` and `player-export <uuid> [freeze]` are a proxy's, for a player it moves
//! between two servers (transfer.rs).
//! `player-expect <uuid> [seconds]`, `player-export <uuid> [freeze]` and `player-release <uuid> ...` are a
//! proxy's, for a player it moves between two servers (transfer.rs).
use super::{ServerState, capture_log};
use crate::game::Game;
use serde_json::{Value, json};
@@ -1,6 +1,8 @@
//! A proxy's requests on the control endpoint, for a player it moves between two servers:
//! `player-expect <uuid> [seconds]` (the target is told who is coming) and
//! `player-export <uuid> [freeze]` (the source answers with the player's record). They run
//! `player-expect <uuid> [seconds]` (the target is told who is coming),
//! `player-export <uuid> [freeze]` (the source answers with the player's record) and
//! `player-release <uuid> <hand-over> moved [announce] | abort` (the source lets the player
//! go). They run
//! between two ticks, on the server thread, so the player is never caught half-way through a
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
//! `[transfer]` is off.
@@ -26,6 +28,10 @@ impl Game<'_> {
None => json!({ "error": "the server is not open to players" }),
},
"player-export" => self.player_export(args),
"player-release" => match self.lan.as_mut() {
Some(host) => arrivals::release_request(&mut host.hub, args),
None => json!({ "error": "the server is not open to players" }),
},
other => json!({ "error": format!("unknown request {other:?}") }),
}
}
@@ -337,6 +337,8 @@ impl Game<'_> {
),
None => "respawn none".to_owned(),
});
} else if let Some(op) = case.get("wake") {
used.push(self.fixture_wake(origin, op)?);
} else if let Some(op) = case.get("attack") {
used = self.fixture_attack(op, &targets)?;
} else if case.get("hopper").is_some() {
@@ -925,6 +927,37 @@ impl Game<'_> {
Ok(out)
}
/// A villager lying in the bed at `origin` with `yaw` wakes up
/// (`Villager.stopSleeping`): where it stands and the yaw it turns to.
fn fixture_wake(&mut self, origin: BlockPos, op: &Value) -> Result<String> {
let kind = self
.res
.mob_types()
.get("minecraft:villager")
.cloned()
.ok_or("no mob type minecraft:villager")?;
let bed = [origin.x, origin.y, origin.z];
let lying = horizon_core::geometry::Vec3::new(
f64::from(bed[0]) + 0.5,
f64::from(bed[1]) + 0.6875,
f64::from(bed[2]) + 0.5,
);
let yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
let mut villager = Mob::new(1, kind, lying, yaw);
horizon_client::mobs::villager::ext_mut(&mut villager).sleeping_pos = Some(bed);
let mut events = Vec::new();
let w = self.mob_world_base(&[]);
horizon_client::mobs::villager::stop_sleeping(&mut villager, &w, &mut events);
let p = villager.position;
Ok(format!(
"wake {} {} {} {}",
num(p.x),
num(p.y),
num(p.z),
villager.yaw.to_bits() as i32
))
}
/// `EntityType.create` and `snapTo` for one target: no spawn finalization.
fn fixture_target(&mut self, e: &Value) -> Result<Target> {
let kind = e["type"].as_str().unwrap_or("minecraft:pig");
+95 -7
View File
@@ -2,7 +2,9 @@
//! biting what stands on them for 6 damage, sinking back after 22 ticks.
use super::Game;
use horizon_client::mobs::{Part, Species, anim::Poses};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::geometry::Vec3;
use horizon_core::random::{LegacyRandom, RandomSource};
use std::sync::Arc;
#[derive(Clone, Debug)]
@@ -14,6 +16,24 @@ pub(super) struct Fangs {
pub life: i32,
pub owner: u32,
pub started: bool,
/// `Entity.random`. The attack sound's pitch is its first draw.
random: LegacyRandom,
}
/// `EvokerFangs.handleEntityEvent`: `nextFloat() * 0.2 + 0.85`.
pub(super) fn fang_attack_pitch(random: &mut impl RandomSource) -> f32 {
random.next_float() * 0.2 + 0.85
}
/// One of the twelve `crit` particles `EvokerFangs.tick` spawns on a client
/// when `lifeTicks` hits 14. The box is 0.5 wide (`getBbWidth() * 0.5`).
pub(super) fn fang_crit(random: &mut impl RandomSource, pos: [f64; 3]) -> ([f64; 3], [f64; 3]) {
let half = 0.5 * 0.5;
let x = pos[0] + (random.next_double() * 2.0 - 1.0) * half;
let y = pos[1] + 0.05 + random.next_double();
let z = pos[2] + (random.next_double() * 2.0 - 1.0) * half;
let xd = (random.next_double() * 2.0 - 1.0) * 0.3;
let yd = 0.3 + random.next_double() * 0.3;
let zd = (random.next_double() * 2.0 - 1.0) * 0.3;
([x, y + 1.0, z], [xd, yd, zd])
}
#[derive(Default)]
pub(super) struct FangState {
@@ -38,6 +58,7 @@ impl Game<'_> {
life: 22,
owner,
started: false,
random: horizon_client::mobs::entity_random(id, Vec3::new(at[0], at[1], at[2])),
});
}
pub(super) fn fangs_to_nbt(&self, f: &Fangs, uuid: [i32; 4]) -> horizon_world::nbt::Tag {
@@ -59,25 +80,52 @@ impl Game<'_> {
.unwrap_or(0)
}
/// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack.
/// `playLocalSound` at `nextFloat() * 0.2 + 0.85`. The fangs this client
/// tracks carry no Silent flag, so the sound always plays.
pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) {
if event == 4
&& let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id)
{
f.started = true;
let sound = self
.fangs
.list
.iter_mut()
.find(|f| f.id == id)
.and_then(|f| {
if event != 4 {
return None;
}
f.started = true;
let pitch = fang_attack_pitch(&mut f.random);
Some((f.pos, pitch))
});
if let Some((pos, pitch)) = sound {
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
Some(pos),
1.0,
pitch,
);
}
}
/// `EvokerFangs.tick`: clients only advance animation, never damage or discard.
/// At life 14, twelve `crit` particles (`ATTACK_TRIGGER_TICKS`).
pub(super) fn tick_remote_fangs(&mut self) {
let mut bursts = Vec::new();
for f in &mut self.fangs.list {
if f.started {
f.life -= 1;
if f.life == 14 {
let mut crits = Vec::with_capacity(12);
for _ in 0..12 {
crits.push(fang_crit(&mut f.random, f.pos));
}
bursts.push(crits);
}
}
}
let crit = ParticleOptions::simple(ParticleType::Crit);
for crits in bursts {
for (at, velocity) in crits {
self.particles.add(&crit, at, velocity);
}
}
}
@@ -96,11 +144,12 @@ impl Game<'_> {
if !f.started {
f.started = true;
self.lan_entity_event(f.id as i32, 4, f.pos);
let pitch = fang_attack_pitch(&mut f.random);
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
1.0,
pitch,
);
}
f.life -= 1;
@@ -247,3 +296,42 @@ impl Game<'_> {
fn p_zrot(p: &mut Poses, part: &str, v: f32) {
p.zrot(part, v);
}
#[cfg(test)]
mod tests {
use super::{fang_attack_pitch, fang_crit};
use horizon_core::random::LegacyRandom;
#[test]
fn fang_attack_pitch_matches_the_vanilla_span() {
// Java Random seed 1, first nextFloat() * 0.2 + 0.85, in f32.
let mut random = LegacyRandom::new(1);
let pitch = fang_attack_pitch(&mut random);
assert!((pitch - 0.996_175_65).abs() < 1.0e-6, "{pitch}");
assert!((0.85..1.05).contains(&pitch));
}
#[test]
fn fang_crit_sits_above_the_jaws() {
// Java Random seed 1, the first crit of the twelve. The y is
// getY() + 0.05 + nextDouble(), then addParticle adds 1.
let mut random = LegacyRandom::new(1);
let (at, velocity) = fang_crit(&mut random, [2.0, 70.0, -4.0]);
let expected_at = [
2.115_439_095_351_645_5,
71.460_080_811_492_2,
-4.146_142_579_345_142,
];
let expected_velocity = [
-0.100_369_766_424_293_3,
0.590_326_772_827_236_1,
-0.296_329_690_640_543_2,
];
for i in 0..3 {
assert!((at[i] - expected_at[i]).abs() < 1.0e-12, "{at:?}");
assert!(
(velocity[i] - expected_velocity[i]).abs() < 1.0e-12,
"{velocity:?}"
);
}
}
}
+1 -1
View File
@@ -233,7 +233,7 @@ impl Game<'_> {
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_folder(std::path::Path::new(url));
super::packs::open_uri(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
+13 -1
View File
@@ -139,6 +139,11 @@ impl Integrated {
let split = !self.flags.split_players.lock().unwrap().is_empty();
self.flags.paused.store(paused && !split, Ordering::Relaxed);
}
/// Whether splitscreen players are joined: while any is, the game runs even behind a
/// pause menu, so no window may stop ticking for one (`set_paused`, `pauses_game`).
pub fn has_split_players(&self) -> bool {
!self.flags.split_players.lock().unwrap().is_empty()
}
/// Splitscreen: `name` left; the game pauses again once every such player has. The owner's
/// permissions it was given go with it (a LAN player could otherwise join under that name).
pub fn leave_split(&self, name: &str) {
@@ -339,6 +344,13 @@ impl Game<'_> {
.as_ref()
.is_some_and(|s| s.published().is_some())
}
/// Splitscreen: players joined through memory play on while one opens a pause menu;
/// the world keeps ticking for them (`set_paused`), so this window must keep ticking too.
pub(super) fn has_split_players(&self) -> bool {
self.integrated
.as_ref()
.is_some_and(|s| s.has_split_players())
}
/// `Minecraft.doWorldLoad`: start the server thread on the world; the
/// window joins it through memory once it is ready (`poll_integrated_start`).
pub(super) fn open_integrated(
@@ -909,7 +921,7 @@ impl Game<'_> {
// Commands.performCommand runs in the player's level.
let (back, theirs) = (self.dim, super::Dim::from_id(&g.dimension));
let entered = theirs == back || self.enter_level(Some(&mut host), theirs).is_ok();
if entered && let Some(swap) = self.enter_guest(&mut host, conn) {
if entered && let Some(swap) = self.enter_guest(&mut host, conn, back) {
let command = command.trim_start_matches('/').to_owned();
// The hub is back in place while the command runs, as for a
// guest's own (`run_guest_commands`): selectors see every
+13 -7
View File
@@ -1536,7 +1536,9 @@ impl Game<'_> {
guest: &mut horizon_client::lan::guest::Guest,
) {
self.guest_attachments_into(guest);
if guest.placed {
// A player a proxy moved to another server (`player-release moved`) is saved
// there now, not here.
if guest.placed && !guest.released {
let _ = self.finish_player_autosaves(true);
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
@@ -1686,7 +1688,7 @@ impl Game<'_> {
host: &LanHost,
guest: &horizon_client::lan::guest::Guest,
) {
if !guest.placed || self.adv.tree.list.is_empty() {
if !guest.placed || guest.released || self.adv.tree.list.is_empty() {
return;
}
let Some(uuid) = self.records_uuid(host, guest.uuid) else {
@@ -2111,11 +2113,15 @@ impl Game<'_> {
"INFO",
&format!("{} lost connection: {}", guest.name, guest.left_reason),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} left the game", guest.name),
);
} else if self.player.is_some() && guest.placed {
// A player moved to another server without `announce` was not
// announced as leaving, so the line is not logged either.
if !guest.quiet_leave {
super::dedicated::log(
"INFO",
&format!("System chat: {} left the game", guest.name),
);
}
} else if self.player.is_some() && guest.placed && !guest.quiet_leave {
// ServerGamePacketListenerImpl.removePlayerFromWorld.
let who = self.player_text(&guest.name, guest.uuid);
self.system_message(
+86 -10
View File
@@ -6,8 +6,11 @@
//! host, and every world change they cause reaches all players through the
//! block log, the level events and the entity tracker.
use super::{
Dim, Game, GuestUse, Hit, InteractTarget, Mining, Screen, lan::LanHost,
lan_crafting::MenuRemoval, respawn_anchor::RespawnConfig,
Dim, Game, GuestUse, Hit, InteractTarget, Mining, Screen,
building_players::{BuildingPlayer, BuildingPlayers},
lan::LanHost,
lan_crafting::MenuRemoval,
respawn_anchor::RespawnConfig,
};
use horizon_client::{
command::vanilla,
@@ -118,6 +121,8 @@ pub(super) struct GuestPlay {
pub(super) respawn_requested: bool,
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
xp_delay: u32,
/// This player entity's age and level-up sound cooldown.
level_up_sound: horizon_client::entities::LevelUpSound,
/// What last killed the guest outright (`/kill`): the death message's key.
death_cause: Option<String>,
/// The equipment modifiers on the guest's attributes (`apply_equipment`)
@@ -166,6 +171,7 @@ pub(super) struct GuestSwap {
/// The host's command permission, swapped for the guest's for its turn.
permission: u8,
xp: Experience,
level_up_sound: horizon_client::entities::LevelUpSound,
host_keys: horizon_client::held::HeldKeys,
screen: Screen,
host_sign: Option<super::sign_edit::SignEditor>,
@@ -217,6 +223,8 @@ pub(super) struct GuestSwap {
Option<u8>,
Option<u32>,
),
/// That turn's other-player building boxes.
building_boxes: BuildingPlayers,
/// That turn's `known_movement`.
known_movement: Vec3,
/// The packets queued for that guest.
@@ -302,6 +310,21 @@ fn direction(face: i32) -> [i32; 3] {
}
}
/// The hub's current bodies, across dimensions. A borrowed ancestor's
/// live body takes precedence over this saved state until its turn returns.
fn building_players(host: &LanHost) -> Vec<BuildingPlayer> {
host.hub
.guests
.iter()
.map(|g| BuildingPlayer {
conn: Some(g.conn),
dimension: Dim::from_id(&g.dimension),
bounds: (g.placed && g.loaded && !g.dead && !g.spectator()).then(|| g.bounds()),
borrowed: false,
})
.collect()
}
impl Game<'_> {
/// Run `f` as the guest `conn`: the host's player state is swapped for theirs.
pub(super) fn as_guest<R>(
@@ -320,7 +343,7 @@ impl Game<'_> {
conn: u32,
f: impl FnOnce(&mut Self, &LanHost) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
let swap = self.enter_guest(host, conn, self.dim)?;
super::dedicated::crash_fixture("guest", || {
host.hub.guests.iter().any(|g| g.conn == conn && g.placed)
});
@@ -362,14 +385,37 @@ impl Game<'_> {
/// two the host may go back into `self.lan`, so that what the guest does
/// can reach the other players (their commands, `for_player`), and a
/// guest's turn can nest in another's: the acting guest and the packets
/// queued for them are kept and restored too.
pub(super) fn enter_guest(&mut self, host: &mut LanHost, conn: u32) -> Option<GuestSwap> {
/// queued for them are kept and restored too. `outer_dimension` belongs
/// to the body still in self.player: callers that enter another level
/// first must pass the dimension they left, not the new self.dim.
pub(super) fn enter_guest(
&mut self,
host: &mut LanHost,
conn: u32,
outer_dimension: Dim,
) -> Option<GuestSwap> {
// The guest whose turn it is is in place already (their record holds
// the game's state until their turn ends).
if self.lan_acting == Some(conn) {
return None;
}
let i = host.hub.guests.iter().position(|g| g.conn == conn)?;
// EntityGetter.isUnobstructed must still see other players while
// the hub is taken out of self.lan. The outer body is live here;
// its saved hub position (if any) belongs to a different turn.
let outer = BuildingPlayer {
conn: self.lan_acting,
dimension: outer_dimension,
bounds: self
.player
.as_ref()
.filter(|p| !self.spectator() && !p.health.dead_or_dying())
.map(PlayerMotion::bounds),
borrowed: true,
};
let other_boxes =
self.lan_building_boxes
.for_turn(conn, Some(outer), building_players(host));
// `ServerPlayer.permissions()`: the guest's own command level for its
// turn, so game-master checks see it (not the host's).
let permission = std::mem::replace(
@@ -457,7 +503,9 @@ impl Game<'_> {
motion.flying_speed = g.flying_speed;
// FireworkRocketItem.use boosts only a gliding player.
motion.fall_flying = g.fall_flying;
motion.invulnerable = motion.may_fly;
// A player frozen for a hand-over takes no damage (`Abilities.invulnerable`, which the
// void and `/kill` still pass), so its record stays true until the release.
motion.invulnerable = motion.may_fly || g.handover.is_some();
// `ServerPlayer.isInvulnerableTo`'s connection term, as of now.
motion.client_loaded = g.client_loaded();
let _ = motion.set_health(g.health, false);
@@ -486,6 +534,7 @@ impl Game<'_> {
total: g.xp_total,
},
);
let level_up_sound = std::mem::replace(&mut self.level_up_sound, play.level_up_sound);
// The host's held keys stay theirs: the guest holds only sneak, and a
// screen the guest opens (`keys.clear()`) leaves the host walking.
let host_keys = std::mem::take(&mut self.keys);
@@ -602,6 +651,7 @@ impl Game<'_> {
self.update_totem_ready();
self.entity_reach = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
self.block_reach = self.interaction_range("minecraft:block_interaction_range", 4.5, 5.0);
let building_boxes = std::mem::replace(&mut self.lan_building_boxes, other_boxes);
Some(GuestSwap {
conn,
i,
@@ -617,6 +667,7 @@ impl Game<'_> {
block_reach,
permission,
xp,
level_up_sound,
host_keys,
screen,
host_sign,
@@ -663,6 +714,7 @@ impl Game<'_> {
projectiles,
modifiers,
combat: host_combat,
building_boxes,
})
}
@@ -717,6 +769,7 @@ impl Game<'_> {
block_reach,
permission,
xp,
level_up_sound,
host_keys,
screen,
host_sign,
@@ -755,6 +808,7 @@ impl Game<'_> {
marks,
death_cause,
combat,
building_boxes,
acting,
known_movement,
packets,
@@ -853,6 +907,7 @@ impl Game<'_> {
self.lan_acting_entity,
) = acting;
self.known_movement = known_movement;
self.lan_building_boxes = building_boxes;
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
// An ender chest the guest opened: the menu shows their inventory
// itself (lan_open_container), not a stand-in block entity.
@@ -966,6 +1021,7 @@ impl Game<'_> {
.insert(key, BlockEntity::Chest(self.ender_items.clone()));
self.ender_open = Some(key);
}
play.level_up_sound = std::mem::replace(&mut self.level_up_sound, level_up_sound);
let new_xp = std::mem::replace(&mut self.experience, xp);
g.xp_level = new_xp.level;
g.xp_progress = new_xp.progress;
@@ -1108,6 +1164,14 @@ impl Game<'_> {
}
// The turn's teleports are in the hub now: the mods hear them.
self.flush_teleport_notes(host);
// An inner turn may move, kill or change a player's mode. Once its
// body is back in the hub, refresh the restored outer snapshot while
// keeping ancestor bodies that are still held by their GuestSwaps.
if let Some(actor) = self.lan_acting {
self.lan_building_boxes =
self.lan_building_boxes
.for_turn(actor, None, building_players(host));
}
}
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
@@ -3413,7 +3477,7 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return false;
};
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
let Some(swap) = self.enter_guest(&mut host, c.conn, self.dim) else {
self.lan = Some(host);
return false;
};
@@ -3635,6 +3699,7 @@ impl Game<'_> {
// empty: the mods hear each worn item again from empty (`native_players_equipment`).
if let Some(play) = host.play.get_mut(&conn) {
play.equipment_heard = None;
play.level_up_sound = Default::default();
}
// PlayerList.respawn: a server's player comes back in its respawn
// point's level: the Overworld, where the world spawn and beds are,
@@ -3798,6 +3863,13 @@ impl Game<'_> {
self.tick_player_menus(host);
self.native_players_equipment(host);
self.announce_guest_advancements(host);
// ServerLevel.tickNonPassenger calls Entity.commonTick for each player,
// including spectators and players whose client has not loaded yet.
for g in &host.hub.guests {
if host.hub.here(g) && !self.on_server_trip(g.conn) {
host.play.entry(g.conn).or_default().level_up_sound.tick();
}
}
let conns: Vec<u32> = host
.hub
.guests
@@ -3929,9 +4001,13 @@ impl Game<'_> {
self.lan_map_updates(host, conn);
host.hub.flush_progress(conn);
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
self.guest_arrows(host, gi);
self.guest_orbs(host, gi);
// A player frozen for a hand-over picks nothing up: what it took would be
// missing from its record, and lost when the release saves nothing.
if host.hub.guests[gi].handover.is_none() {
self.guest_pickups(host, gi);
self.guest_arrows(host, gi);
self.guest_orbs(host, gi);
}
self.guest_die(host, gi, conn);
}
}
@@ -181,10 +181,17 @@ impl Game<'_> {
return;
}
self.spawn_dispensed(pos, facing, rest, 6);
// CrafterRecipeCraftedTrigger for players within the 17-block box.
let near = self.eye_position().is_some_and(|e| {
// CrafterRecipeCraftedTrigger: every player whose box meets the 17-block
// box around the crafter's centre (8.5 from the centre to a face: the
// player's own width and height reach into it, not its eye point).
// `AABB.intersects` is strict: a box that only touches a face is out.
let near = self.player.as_ref().is_some_and(|p| {
let c = center(pos);
(0..3).all(|i| (e[i] - c[i]).abs() <= 8.5 + 1.0)
let b = p.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
(lo.x < c[0] + 8.5 && hi.x > c[0] - 8.5)
&& (lo.y < c[1] + 8.5 && hi.y > c[1] - 8.5)
&& (lo.z < c[2] + 8.5 && hi.z > c[2] - 8.5)
});
if near {
let id = horizon_client::crafting::qualified(recipe);
+3 -1
View File
@@ -549,7 +549,7 @@ impl MapData {
})
}
/// `MapItemSavedData.<clinit>`: the CODEC save arm omits optional defaults.
fn to_nbt(&self) -> Tag {
pub(super) fn to_nbt(&self) -> Tag {
let mut data = vec![
("dimension", Tag::String(self.dimension.as_str().into())),
("xCenter", Tag::Int(self.center[0])),
@@ -1898,6 +1898,8 @@ impl Game<'_> {
}
}
#[cfg(test)]
mod live_frame_tests;
#[cfg(test)]
mod save_tests;
@@ -0,0 +1,165 @@
use super::*;
use crate::game::{Audio, Game, Options, Progress, Settings, commands::Target, load_resources};
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn live_frame_item_adoption_detaches_only_a_changed_old_stack() {
frame_adoption(false);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn live_glow_frame_adoption_keeps_type_without_saved_id() {
frame_adoption(true);
}
fn frame_adoption(glow_first: bool) {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.player = Some(
horizon_client::motion::PlayerMotion::new(
game.motion,
horizon_core::geometry::Vec3::new(4.5, 100.0, 4.5),
)
.unwrap(),
);
game.creative = false;
let empty = ItemStack::new("minecraft:map", 1);
*game.held_mut() = Some(empty.clone());
assert!(game.use_empty_map(&empty));
let stack = game.held().unwrap().clone();
assert_eq!(map_id(&stack), Some(0));
for (n, kind) in [
(1, "minecraft:item_frame"),
(2, "minecraft:glow_item_frame"),
] {
let tag = Tag::compound([
("id", Tag::String(kind.into())),
("UUID", Tag::IntArray(vec![317, 1, 0, n])),
("block_pos", Tag::IntArray(vec![10 + n, 101, 5])),
("Facing", Tag::Byte(5)),
("ItemRotation", Tag::Byte(3)),
("Item", horizon_client::save::item_to_nbt(&stack)),
]);
game.load_item_frame(tag, Some([317, 1, 0, n]));
}
let mut ids: Vec<_> = game.item_frames.iter().map(|f| f.id).collect();
if glow_first {
ids.reverse();
}
for &id in &ids {
let frame = game
.item_frames
.iter()
.find(|f| f.id == id)
.unwrap()
.clone();
game.map(0).unwrap().tick_framed(
1,
0,
false,
false,
Framed {
pos: frame.pos,
facing: frame.facing,
id: id as i32,
},
);
}
assert_eq!(game.map(0).unwrap().frames.len(), 2);
for &id in &ids {
for change in ["rotation", "unchanged", "components", "count", "empty"] {
let frame = game
.item_frames
.iter()
.find(|f| f.id == id)
.unwrap()
.clone();
let mut tag = horizon_client::entity_nbt::item_frame_to_nbt(
&frame,
*game.persist.uuids.get(&id).unwrap(),
None,
);
let Tag::Compound(fields) = &mut tag else {
unreachable!()
};
// Live command data omits the entity id before adoption.
fields.remove(&"id".into());
match change {
"rotation" => {
fields.insert("ItemRotation".into(), Tag::Byte(4));
}
"components" | "count" => {
let mut next = stack.clone();
if change == "count" {
next.count = 2;
} else {
next.damage = 1;
}
fields.insert("Item".into(), horizon_client::save::item_to_nbt(&next));
}
"empty" => {
fields.remove(&"Item".into());
}
_ => {}
}
game.apply_entity_nbt(Target::Frame(id), tag).unwrap();
assert_eq!(
game.item_frames.iter().find(|f| f.id == id).unwrap().kind(),
frame.kind(),
"{change}: immutable frame type"
);
let retained = matches!(change, "unchanged" | "rotation");
let map = game.map(0).unwrap();
assert_eq!(
map.frames.iter().any(|f| f.entity_id == id as i32),
retained,
"{change}: old saved frame association"
);
assert_eq!(
map.decorations
.iter()
.any(|d| d.key == Key::Frame(id as i32)),
retained,
"{change}: old frame decoration"
);
let other = ids.iter().copied().find(|&other| other != id).unwrap();
assert!(map.frames.iter().any(|f| f.entity_id == other as i32));
// Re-establish the actual production map association for the next independent write.
let frame = game.item_frames.iter_mut().find(|f| f.id == id).unwrap();
frame.item = Some(stack.clone());
let f = Framed {
pos: frame.pos,
facing: frame.facing,
id: id as i32,
};
game.map(0).unwrap().tick_framed(1, 0, false, false, f);
}
}
}
+8 -1
View File
@@ -47,6 +47,10 @@ impl Game<'_> {
// MusicManager's showNowPlayingToast and hideNowPlayingToast calls.
let multiplier = f64::from(self.settings.notification_display_time);
for show in std::mem::take(&mut self.music.toast_requests) {
// Each showing is a new toast to mods (`ToastAddEvent`).
if show && self.now_playing.is_some() && !self.toast_allowed("now_playing", "") {
continue;
}
if let Some(toast) = &mut self.now_playing {
if show {
toast.show(multiplier);
@@ -77,7 +81,10 @@ impl Game<'_> {
0 => None,
_ => {
let mut toast = horizon_client::toasts::NowPlayingToast::default();
if state == 2 && self.final_music_volume() > 0.0 {
if state == 2
&& self.final_music_volume() > 0.0
&& self.toast_allowed("now_playing", "")
{
toast.show(f64::from(self.settings.notification_display_time));
}
Some(toast)
+56 -24
View File
@@ -246,7 +246,7 @@ impl Game<'_> {
lines: vec![(text, 0.0)],
light: l,
discrete,
style: None,
marks: Marks::default(),
});
}
continue;
@@ -290,18 +290,18 @@ impl Game<'_> {
(m.position.y + f64::from(m.height())).floor() as i32,
m.position.z.floor() as i32,
);
// A Horizon player's name tag style (cosmetics), beside the name.
let style = if player && horizon_cosmetics::enabled() {
self.cosmetics_tag(m, Some(m.id) == own)
// Horizon's mark before a Horizon player's name and their name tag style (cosmetics).
let marks = if player || self.cosmetics_local() {
self.cosmetics_marks(m, Some(m.id) == own)
} else {
None
Marks::default()
};
tags.push(NameTag {
at: p,
lines,
light: l,
discrete,
style,
marks,
});
}
drop(player_ids);
@@ -383,7 +383,7 @@ impl Game<'_> {
lines,
light: l,
discrete: false,
style: None,
marks: Marks::default(),
});
}
// ItemFrameRenderer.shouldShowName/getNameTag: only the targeted
@@ -446,7 +446,7 @@ impl Game<'_> {
center.z.floor() as i32,
),
discrete: false,
style: None,
marks: Marks::default(),
});
}
}
@@ -482,7 +482,7 @@ impl Game<'_> {
light,
NAME_TAG_UNIT,
out,
tag.style,
tag.marks,
));
continue;
}
@@ -499,7 +499,7 @@ impl Game<'_> {
light,
NAME_TAG_UNIT,
out,
tag.style,
tag.marks,
));
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
patches.extend(self.styled_name_tag_quads(
@@ -511,7 +511,7 @@ impl Game<'_> {
0,
NAME_TAG_UNIT,
see_through,
tag.style,
tag.marks,
));
}
self.map_patches.extend(patches);
@@ -533,12 +533,21 @@ impl Game<'_> {
out: &mut Vec<Vertex>,
) -> Vec<horizon_client::font::Patch> {
self.styled_name_tag_quads(
o, lines, text_alpha, background, text_layer, light, unit, out, None,
o,
lines,
text_alpha,
background,
text_layer,
light,
unit,
out,
Marks::default(),
)
}
/// [`Self::name_tag_quads`], with a cosmetic style on the last line (the name): its mark
/// before the text and its line under the background.
/// [`Self::name_tag_quads`], with Horizon's marks on the last line (the name): the Horizon
/// player's mark before the text, a name tag style's badge after it and its line under the
/// background.
#[allow(clippy::too_many_arguments)]
fn styled_name_tag_quads(
&self,
@@ -550,7 +559,7 @@ impl Game<'_> {
light: u32,
unit: f32,
out: &mut Vec<Vertex>,
style: Option<TagStyle>,
marks: Marks,
) -> Vec<horizon_client::font::Patch> {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
@@ -590,20 +599,36 @@ impl Game<'_> {
text_alpha << 24 | 0xFF_FFFF,
if background { BACKGROUND } else { 0 },
);
if let Some(style) = style.filter(|_| n == last) {
let x = -self.gui.atlas.font.width_text(text) / 2.0;
let alpha = text_alpha << 24;
// The mark, 8 text pixels square, a pixel left of the background.
batch.rect_uv(x - 10.0, y0, 8.0, 8.0, style.mark, alpha | 0xFF_FFFF);
// The line, a pixel tall, under the background (which runs from -1 to 9).
if n == last && marks.horizon {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
for (mx, my, w, h, argb) in horizon_cosmetics::mark::MARK {
let a = text_alpha * (argb >> 24) / 255;
batch.fill(
&self.gui.atlas,
x + f32::from(mx),
y0 + f32::from(my),
f32::from(w),
f32::from(h),
horizon_client::hud_layout::abgr(a << 24 | argb & 0xFF_FFFF),
);
}
}
if let Some(style) = marks.style.filter(|_| n == last) {
let width = self.gui.atlas.font.width_text(text);
let x = -width / 2.0;
let alpha = text_alpha << 24;
// The badge, 8 text pixels square, a pixel right of the background.
batch.rect_uv(x + width + 2.0, y0, 8.0, 8.0, style.mark, alpha | 0xFF_FFFF);
// The line, a pixel tall, under the background (which runs from -1 to 9).
batch.fill(
&self.gui.atlas,
x - 1.0,
y0 + 9.0,
width + 1.0,
1.0,
alpha | style.line,
// The batch takes RGBA: the line as ARGB, swapped.
horizon_client::hud_layout::abgr(alpha | style.line),
);
}
for (i, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
@@ -707,10 +732,17 @@ struct NameTag {
lines: Vec<(Text, f64)>,
light: u8,
discrete: bool,
style: Option<TagStyle>,
marks: Marks,
}
/// A cosmetic name tag style: a mark before the name and a line under it.
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
#[derive(Clone, Copy, Default)]
pub(super) struct Marks {
pub horizon: bool,
pub style: Option<TagStyle>,
}
/// A cosmetic name tag style: a badge after the name and a line under it.
#[derive(Clone, Copy)]
pub(super) struct TagStyle {
/// The mark's place in the GUI atlas.
@@ -200,7 +200,11 @@ impl Game<'_> {
}
/// The narrator option changed, on the options screen or by its hotkey.
pub(super) fn narrator_status_changed(&mut self) {
let new = self.narrator_toast.is_none();
update_status(self.settings.narrator(), &mut self.narrator_toast);
if new && !self.toast_allowed("system", "narrator_toggle") {
self.narrator_toast = None;
}
}
/// The narrator hotkey on a key press or repeat, before the screen or
/// the game takes the key (it doesn't consume it): the next status,
+20 -2
View File
@@ -6,6 +6,10 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 411 adds client Host services, GUI painting, model-head snapshots and client lifecycle notifications.
//!
//! API 410 adds a final foreground GUI overlay (`native_gui_render_post`) and the current frame's camera during GUI rendering.
//!
//! API 409 adds a client mod's play packet to the server, as it is (`native_send_packet`).
//!
//! API 408 adds reading the server's play packets on the client (`native_client_packet`).
@@ -1160,7 +1164,7 @@ pub(super) use horizon_world::level::flags;
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 409;
pub(crate) const NATIVE_HOOKS_API: u32 = 411;
impl GameLevel<'_> {
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232.
@@ -2510,7 +2514,7 @@ impl Game<'_> {
/// is in GUI pixels. `native_camera` is this frame's world camera (None on
/// the panorama). `gui_hidden` is the F1 setting; `focused` is window focus.
/// A mod decides whether its foreground indicator respects those flags.
/// Client only; the host is read-only. API @API:gui_render_post@.
/// Client only; the host is read-only. API 410.
pub(super) fn native_gui_render_post(
&self,
batch: &mut HudBatch,
@@ -6082,6 +6086,20 @@ impl Game<'_> {
);
}
/// NeoForge's `ToastAddEvent`: a new toast is about to be queued. `kind`
/// is `advancement`, `recipe`, `tutorial`, `system` or `now_playing`;
/// `id` is the advancement id, the first recipe id, the tutorial hint
/// (`move`, `look`, `find_tree`, `punch_tree`, `open_inventory`,
/// `craft_planks`, `socialInteractions`), the system toast's id (`narrator_toggle`,
/// `pack_load_failure`, `pack_download`, `low_disk_space`,
/// `chunk_load_failure`, `chunk_save_failure`), or empty for
/// `now_playing`. An update to a toast already queued or shown is not an
/// add. Set `cancel` and it isn't queued, shown or sounded. Title-screen
/// toasts are not told. Client-side. API @API:toast_add@.
pub(super) fn native_toast_add(&mut self, kind: &str, id: &str, cancel: &mut bool) {
mod_api::dispatch_toast_add(self, mod_api::mods(), kind, id, cancel);
}
/// The dedicated server's whitelist changed to `enabled`. 305.
pub(super) fn native_whitelist_state_update(&mut self, enabled: bool) {
mod_api::dispatch_whitelist_state_update(self, mod_api::mods(), enabled);
+58
View File
@@ -174,11 +174,31 @@ impl Game<'_> {
}
pub(super) fn draw_options(&mut self, batch: &mut HudBatch) {
self.watch_pack_folder();
let context = self.controller_settings_context();
let mut ui = std::mem::take(&mut self.ui.options);
if ui.controller_open() {
ui.controller.context = context;
}
ui.draw(&self.settings, batch, &self.gui.atlas, self.cursor);
self.ui.options = ui;
self.show_calibration(batch.height);
}
/// Whose controller settings these are and what this game sees (`controller_ui`): the
/// player's badge in splitscreen with its name, and its own controller.
fn controller_settings_context(&self) -> horizon_client::controller_ui::Context {
let name = self.options.player_name();
let player = match &self.split_hud.badge {
Some(badge) if *badge != name => format!("{badge} · {name}"),
_ => name,
};
horizon_client::controller_ui::Context {
player,
device: self.pad.device_or_virtual(),
detected: detected_controller_style(self.pad.device.as_ref(), self.pad.style),
style: self.ui.options.pad_style,
by_letter: self.ui.options.pad_by_letter,
}
}
/// HDR calibration's test picture to the renderer, full screen while it runs: the
/// window goes fullscreen for it and back after, if it wasn't already.
pub(super) fn show_calibration(&mut self, h: f32) {
@@ -660,6 +680,16 @@ impl Game<'_> {
}
}
/// The named device can be a connected pad that has not driven the game yet.
fn detected_controller_style(
device: Option<&horizon_client::controller_ui::Device>,
fallback: horizon_client::gamepad::GlyphStyle,
) -> horizon_client::gamepad::GlyphStyle {
device.map_or(fallback, |d| {
horizon_client::gamepad::GlyphStyle::of_vendor(d.vendor)
})
}
/// Out of fullscreen, a window that splitscreen opened to fill a monitor (no border, the
/// monitor's size) is a window again: its border back and, still covering the monitor, the game's
/// first size (854 x 480) in the middle of it, to be moved and sized.
@@ -684,3 +714,31 @@ fn windowed_again(w: &winit::window::Window) {
));
}
}
#[cfg(test)]
mod controller_context_tests {
use super::detected_controller_style;
use horizon_client::{controller_ui::Device, gamepad::GlyphStyle};
#[test]
fn detected_style_follows_the_named_device_even_before_it_is_used() {
let mut device = Device {
name: "pad".into(),
vendor: Some(0x054c),
product: None,
};
assert_eq!(
detected_controller_style(Some(&device), GlyphStyle::Xbox),
GlyphStyle::PlayStation
);
device.vendor = Some(0x057e);
assert_eq!(
detected_controller_style(Some(&device), GlyphStyle::PlayStation),
GlyphStyle::Nintendo
);
assert_eq!(
detected_controller_style(None, GlyphStyle::PlayStation),
GlyphStyle::PlayStation
);
}
}
+7 -10
View File
@@ -30,16 +30,11 @@ pub fn packs_dir() -> PathBuf {
.unwrap_or_default()
.join("resourcepacks")
}
mod folder;
/// `Util.getPlatform().openPath`: the system file manager on the folder.
pub fn open_folder(dir: &Path) {
let program = if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(program).arg(dir).spawn() {
if let Err(e) = folder::command(dir).and_then(|mut command| command.spawn()) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
@@ -251,8 +246,10 @@ impl Game<'_> {
match &mut self.server_packs.load_fail {
Some(toast) => toast.reset(title, None),
None => {
self.server_packs.load_fail =
Some(horizon_client::toasts::SystemToast::new(title, None));
if self.toast_allowed("system", "pack_load_failure") {
self.server_packs.load_fail =
Some(horizon_client::toasts::SystemToast::new(title, None));
}
}
}
return;
@@ -0,0 +1,84 @@
//! Resolve paths in the game's filesystem context before handing them to a
//! desktop process, which may not inherit Windows AppData virtualization.
use std::{io, path::Path, process::Command};
pub(super) fn command(dir: &Path) -> io::Result<Command> {
let program = if cfg!(windows) {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
// canonicalize opens the directory and resolves its physical location.
// dunce also removes verbatim syntax where safe: Explorer is not a Win32
// file API and does not handle every extended-length path form.
#[cfg(windows)]
let dir = dunce::canonicalize(dir)?;
let mut command = Command::new(program);
command.arg(dir);
Ok(command)
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"horizon-folder-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn shell_gets_the_existing_folder_as_one_physical_path() {
let scratch = Scratch::new();
let folder = scratch.0.join("packs & textures \u{2603}");
fs::create_dir(&folder).unwrap();
fs::write(folder.join("marker"), b"pack scan").unwrap();
let logical = folder.join("..").join("packs & textures \u{2603}");
let command = command(&logical).unwrap();
let args: Vec<_> = command.get_args().collect();
assert_eq!(args.len(), 1);
let target = Path::new(args[0]);
assert!(!target.as_os_str().to_string_lossy().starts_with(r"\\?\"));
assert!(
!target
.components()
.any(|component| component == std::path::Component::ParentDir)
);
assert_eq!(fs::read(target.join("marker")).unwrap(), b"pack scan");
assert_eq!(
fs::canonicalize(target).unwrap(),
fs::canonicalize(&folder).unwrap()
);
assert_eq!(command.get_program(), "explorer");
}
#[test]
fn missing_folder_is_not_sent_to_explorer() {
let scratch = Scratch::new();
assert_eq!(
command(&scratch.0.join("missing")).unwrap_err().kind(),
io::ErrorKind::NotFound
);
}
}
+87
View File
@@ -8,6 +8,8 @@ use super::{Game, Screen};
use horizon_client::held::Source;
use horizon_client::{
Result,
controller_notice::{Glyph, Notice, NoticeId, Priority},
controller_ui::Device,
gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Route, Rumble},
keymapping::SNEAK,
};
@@ -21,6 +23,7 @@ pub(super) const HELP: &[&str] = &[
"pad trigger left|right VALUE pull a virtual trigger (0..1)",
"pad release centre and release the virtual controller",
"pad status connected controllers, the active one and its state",
"pad connection connected|disconnected as if a controller came or went at this game's own reader",
];
/// The controllers and the state the game last acted on.
@@ -58,6 +61,10 @@ pub(super) struct Pads {
/// The pad drove the game last (the mouse and keyboard hand control back).
pub(super) in_use: bool,
pub(super) style: GlyphStyle,
/// The controller driving this player, or else the first one its own reader sees.
pub(super) device: Option<Device>,
/// Controllers that came and went at this game's own reader since the last poll.
connections: Vec<Connection>,
/// The cursor the pad drives in menus.
pub(super) menu: super::pad_menu::MenuCursor,
/// The last vibration asked for, for `pad status`.
@@ -82,6 +89,15 @@ mod hardware;
mod parity_tests;
pub(super) use hardware::{PadId, PadRouter};
/// A controller coming or going, for this player's notice (`pad_connection_notice`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Connection {
/// A controller of this glyph family connected.
Connected(GlyphStyle),
/// One disconnected; `active`: it was driving this player.
Disconnected { active: bool },
}
/// What a controller did since the last frame.
#[cfg_attr(not(feature = "controllers"), allow(dead_code))]
pub(in crate::game) enum PadEvent {
@@ -116,6 +132,12 @@ mod hardware {
pub(in crate::game) fn events(&mut self) -> Vec<super::PadEvent> {
Vec::new()
}
pub(in crate::game) fn device(
&self,
_: PadId,
) -> Option<horizon_client::controller_ui::Device> {
None
}
pub(in crate::game) fn lend_output(&mut self, _: &mut Pads, _: PadId) {}
pub(in crate::game) fn reclaim_output(&mut self, _: &mut Pads) {}
pub(in crate::game) fn connected(&self, _: PadId) -> bool {
@@ -174,6 +196,16 @@ impl Pads {
}
self.feed = Some(state);
}
/// The device the controller settings name: a real one, else `horizon-ctl`'s virtual pad.
pub(super) fn device_or_virtual(&self) -> Option<Device> {
self.device.clone().or_else(|| {
self.in_use.then(|| Device {
name: "Virtual controller".to_owned(),
vendor: None,
product: None,
})
})
}
/// A replay ended: let go of everything it held.
pub(super) fn clear_injected(&mut self) {
self.menu.armed = None;
@@ -254,11 +286,15 @@ impl Game<'_> {
},
);
}
for change in std::mem::take(&mut self.pad.connections) {
self.pad_connection_notice(change);
}
let style = self.settings.controller.glyphs.resolve(self.pad.style);
self.ui.options.pad_style = style;
// Nintendo glyphs on a pad going by its letters name the button playing each part.
self.ui.options.pad_by_letter =
style == GlyphStyle::Nintendo && self.pad.by_letter(&self.settings.controller);
self.ui.options.pad_driving = self.pad.in_use && self.pad_focus_mode();
let mut state = hardware.merge(&self.pad.injected);
let activity = self.pad.activity.observe(&state, &self.settings.controller);
for (i, (stick, deadzone)) in [
@@ -550,6 +586,40 @@ impl Game<'_> {
}
/// `horizon-ctl pad ...`.
/// A controller came or went with no splitscreen router: say so on this view, in its one
/// notice. Nothing here decides what the controller drives; a player who turned controllers
/// off isn't told about them.
fn pad_connection_notice(&mut self, change: Connection) {
if !self.settings.controller.enabled {
return;
}
let split =
|key: &str| horizon_client::lang::get(&format!("horizon.splitscreen.prompt.{key}"));
let notice = match change {
Connection::Connected(family) => Notice::new(
NoticeId::Connection,
Priority::Info,
split("controller_connected"),
horizon_client::lang::get("horizon.pad.notice.connected.press"),
)
.with_glyph(Glyph {
button: Button::South,
style: self.settings.controller.glyphs.resolve(family),
}),
Connection::Disconnected { active } => Notice::new(
NoticeId::Connection,
if active {
Priority::Attention
} else {
Priority::Info
},
split("controller_disconnected"),
split("controller_disconnected.hint"),
),
};
self.split_hud.notice.raise(notice);
}
pub(super) fn pad_command(&mut self, words: &[&str]) -> Result<Value> {
let arg = |i: usize| -> Result<&str> {
words
@@ -580,6 +650,9 @@ impl Game<'_> {
"reason": why, "strong": p.strong, "weak": p.weak, "ms": p.millis,
})),
"in_use": self.pad.in_use,
"notice": self.split_hud.notice.showing().map(|(title, body)| {
json!({ "title": title, "body": body })
}),
"menu": self.pad_menu_status(),
"held": held,
"left": s.left,
@@ -593,6 +666,20 @@ impl Game<'_> {
}
Ok(status)
}
"connection" => {
let change = match arg(2)? {
"connected" => Connection::Connected(GlyphStyle::PlayStation),
"disconnected" => Connection::Disconnected { active: true },
other => {
return Err(format!(
"connection must be connected or disconnected, not {other}"
)
.into());
}
};
self.pad.connections.push(change);
Ok(json!({ "ok": true }))
}
"release" => {
self.pad.menu.armed = None;
self.pad.injected = PadState::default();
+44 -4
View File
@@ -3,17 +3,22 @@
//! server build) has the stub in pad.rs instead, and only `horizon-ctl pad`'s virtual pad.
use super::{
super::{Game, Screen},
PadEvent, Pads,
Connection, PadEvent, Pads,
};
use horizon_client::controller_ui::Device;
use horizon_client::gamepad::{self, Button, GlyphStyle, PadState, Rumble, TriggerFeel};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
mod face_buttons;
/// The controllers gilrs sees and the DualSense's output.
#[derive(Default)]
pub(super) struct Devices {
gilrs: Option<gilrs::Gilrs>,
started: bool,
/// The pads seen so far, so one already there when gilrs opens isn't news.
known: Vec<gilrs::GamepadId>,
/// The pad last touched; only its input counts.
active: Option<gilrs::GamepadId>,
/// Vibrations playing (an effect stops when dropped) and when they end.
@@ -80,8 +85,9 @@ impl Pads {
.build()
{
Ok(g) => {
for (_, pad) in g.gamepads() {
for (id, pad) in g.gamepads() {
log::info!("Controller connected: {}", pad.name());
self.devices.known.push(id);
}
Some(g)
}
@@ -102,6 +108,8 @@ impl Pads {
self.devices.gilrs = None;
self.devices.started = false;
self.devices.active = None;
self.devices.known.clear();
self.device = None;
self.devices.rumbles.clear();
self.devices.dualsense = false;
self.devices.output = None;
@@ -120,12 +128,23 @@ impl Pads {
match event {
E::Connected => {
log::info!("Controller connected: {}", gilrs.gamepad(id).name());
// Some backends report the controllers already there as they open.
if !self.devices.known.contains(&id) {
self.devices.known.push(id);
let vendor = gilrs.gamepad(id).vendor_id();
self.connections
.push(Connection::Connected(GlyphStyle::of_vendor(vendor)));
}
if let Some(output) = &self.devices.output {
output.scan();
}
}
E::Disconnected => {
log::info!("Controller disconnected: {}", gilrs.gamepad(id).name());
self.devices.known.retain(|known| *known != id);
self.connections.push(Connection::Disconnected {
active: self.devices.active == Some(id),
});
if self.devices.active == Some(id) {
self.devices.active = None;
}
@@ -153,20 +172,33 @@ impl Pads {
else {
// Before any pad is touched (the virtual pad playing), a DualSense still shows it.
self.devices.dualsense = gilrs.gamepads().any(|(_, pad)| is_dualsense(&pad));
self.device = gilrs.gamepads().next().map(|(_, pad)| device_of(&pad));
return PadState::default();
};
self.device = Some(device_of(&pad));
self.style = GlyphStyle::of_vendor(pad.vendor_id());
self.devices.dualsense = is_dualsense(&pad);
state_of(&pad)
}
}
/// What the controller settings say about a pad.
fn device_of(pad: &gilrs::Gamepad) -> Device {
Device {
name: pad.name().to_owned(),
vendor: pad.vendor_id(),
product: pad.product_id(),
}
}
/// A pad's buttons, sticks and triggers now.
fn state_of(pad: &gilrs::Gamepad) -> PadState {
let mut state = PadState::default();
let crossed = face_buttons::crossed(pad);
for button in Button::ALL {
if !matches!(button, Button::LeftTrigger | Button::RightTrigger) {
state.set(button, pad.is_pressed(gilrs_button(button)));
let named = face_buttons::named(gilrs_button(button), crossed);
state.set(button, pad.is_pressed(named));
}
}
let axis = |a| pad.value(a);
@@ -249,9 +281,10 @@ impl PadRouter {
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
match event {
gilrs::EventType::ButtonPressed(pressed, _) => {
let crossed = face_buttons::crossed(&gilrs.gamepad(id));
if let Some(button) = Button::ALL
.into_iter()
.find(|&b| gilrs_button(b) == pressed)
.find(|&b| face_buttons::named(gilrs_button(b), crossed) == pressed)
{
events.push(PadEvent::Press(id, button));
}
@@ -299,6 +332,13 @@ impl PadRouter {
})
}
pub(in crate::game) fn device(&self, id: PadId) -> Option<Device> {
self.gilrs
.as_ref()
.and_then(|g| g.connected_gamepad(id))
.map(|pad| device_of(&pad))
}
pub(in crate::game) fn name(&self, id: PadId) -> String {
self.gilrs
.as_ref()
@@ -0,0 +1,95 @@
//! Linux's codes for X and Y don't say where those buttons sit.
use gilrs::{Button, Gamepad, MappingSource};
use horizon_client::gamepad::GlyphStyle;
/// Whether this pad's X and Y reach us crossed. The kernel defines `BTN_X` as `BTN_NORTH` and
/// `BTN_Y` as `BTN_WEST`, so an Xbox-style pad's X, on the west, sends "north". gilrs' default
/// mapping takes the names at their word; an SDL row names `x` and `y` itself and is right.
pub(super) fn crossed(pad: &Gamepad) -> bool {
cfg!(target_os = "linux")
&& crossed_layout(
pad.mapping_source(),
[
Button::South,
Button::East,
Button::West,
Button::North,
Button::C,
Button::Z,
]
.map(|button| pad.button_code(button).is_some()),
GlyphStyle::of_vendor(pad.vendor_id()),
)
}
fn crossed_layout(source: MappingSource, face_buttons: [bool; 6], style: GlyphStyle) -> bool {
source == MappingSource::Driver
// The Xbox-style layout has no C or Z.
&& face_buttons == [true, true, true, true, false, false]
// These drivers send the same codes for what is really on the north and the west.
&& !matches!(style, GlyphStyle::PlayStation | GlyphStyle::Nintendo)
}
/// What gilrs calls the button we know as `button`, on a pad that is `crossed` or not.
pub(super) fn named(button: Button, crossed: bool) -> Button {
match button {
Button::West if crossed => Button::North,
Button::North if crossed => Button::West,
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
const XBOX_BUTTONS: [bool; 6] = [true, true, true, true, false, false];
#[test]
fn an_unmapped_xbox_style_pad_is_crossed() {
// The GameSir Nova Lite 2 of #7323: `3537:1098`, no SDL row, X sends BTN_NORTH.
let style = GlyphStyle::of_vendor(Some(0x3537));
assert!(crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
}
#[test]
fn an_sdl_row_already_names_x_and_y() {
assert!(!crossed_layout(
MappingSource::SdlMappings,
XBOX_BUTTONS,
GlyphStyle::Xbox
));
}
#[test]
fn a_sony_or_nintendo_pad_means_what_its_codes_say() {
for vendor in [0x054c, 0x057e] {
let style = GlyphStyle::of_vendor(Some(vendor));
assert!(!crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
}
}
#[test]
fn a_pad_with_c_or_z_is_not_xbox_style() {
for different in [
[true, true, true, true, true, false],
[true, true, true, true, false, true],
] {
assert!(!crossed_layout(
MappingSource::Driver,
different,
GlyphStyle::Xbox
));
}
}
#[test]
fn only_x_and_y_change_places() {
assert_eq!(named(Button::West, true), Button::North);
assert_eq!(named(Button::North, true), Button::West);
assert_eq!(named(Button::South, true), Button::South);
assert_eq!(named(Button::East, true), Button::East);
assert_eq!(named(Button::West, false), Button::West);
assert_eq!(named(Button::North, false), Button::North);
}
}
@@ -66,8 +66,8 @@ fn draw_glyph(
) {
let (label, [r, g, b]) = (button.label(style), button.color(style));
let width = HudBatch::text_width(atlas, label).ceil() + 4.0;
batch.fill(atlas, x - 1.0, y - 2.0, width, 11.0, rgba(40, 40, 40, 255));
batch.text(atlas, label, x + 1.0, y, rgba(r, g, b, 255), false);
let color = rgba(r, g, b, 255);
horizon_client::controller_notice::key(batch, atlas, label, color, x, y, width);
}
impl Game<'_> {
+46 -3
View File
@@ -153,6 +153,8 @@ pub(super) struct MenuCursor {
/// The pad's focus on each screen it left (`Game::key_screen`), so coming
/// back lands where it was, as vanilla keeps a screen's focused widget.
remembered: std::collections::HashMap<u64, Rect>,
/// The frame drawn when the pad last left a screen.
left_screen: u64,
}
impl MenuCursor {
@@ -179,6 +181,23 @@ fn list_shift(was: Option<f32>, now: Option<f32>) -> Option<Option<f32>> {
Some((dy.abs() >= 0.5).then_some(dy))
}
/// The target nearest `rect` on its line, for a focus that lost it.
fn beside(rect: Rect, targets: &[Rect]) -> Option<usize> {
let middle = rect[1] + rect[3] / 2.0;
let centre = |r: &Rect| r[0] + r[2] / 2.0;
targets
.iter()
.enumerate()
.filter(|(_, r)| r[1] <= middle && middle < r[1] + r[3])
.min_by(|(_, a), (_, b)| {
let from = centre(&rect);
(centre(a) - from)
.abs()
.total_cmp(&(centre(b) - from).abs())
})
.map(|(i, _)| i)
}
/// Where letting go of A clicks: the armed button, if the focus is still on it.
fn armed_click(armed: Option<Rect>, focus: Option<Target>) -> Option<(f32, f32)> {
focus
@@ -602,7 +621,7 @@ impl Game<'_> {
self.pad.menu.keyboard = false;
// Controller Binds waiting for a button: this one (Start cancels).
if self.screen == Screen::Options
&& self.ui.options.page() == Page::PadBinds
&& self.ui.options.controller_open()
&& let Some(action) = self.ui.options.selected_pad_action.take()
{
if button != Button::Start {
@@ -960,6 +979,7 @@ impl Game<'_> {
/// comes back. Called before the new screen's targets are read, so the
/// focus is still the left screen's.
pub(super) fn pad_remember_focus(&mut self, screen: u64) {
self.pad.menu.left_screen = widgets::generation();
let Some(focus) = self.pad.menu.focus else {
return;
};
@@ -1432,8 +1452,18 @@ impl Game<'_> {
.or_else(|| gamepad::refind(focus.rect, &rects, 12.0))
.map(|i| targets[i]);
if self.pad.menu.focus.is_none() && self.pad.menu.pos.is_some() {
// The widget went away (a page changed): start again.
let first = self.pad_first_target(targets);
// The widget went away on this screen (a Reset or an Undo took itself away
// when pressed): what's beside it. Two frames on, as the first may have been
// drawn before the screen changed.
let next_to = (!self.pad.menu.keyboard
&& widgets::generation() > self.pad.menu.left_screen + 1)
.then(|| beside(focus.rect, &rects))
.flatten();
// Else a page changed: start again.
let first = match next_to {
Some(i) => Some(targets[i]),
None => self.pad_first_target(targets),
};
if self.pad.menu.keyboard
&& let Some(first) = first
{
@@ -2080,6 +2110,19 @@ fn field_at(targets: &[Target], at: (f32, f32)) -> bool {
mod tests {
use super::*;
#[test]
fn a_focus_that_lost_its_widget_goes_beside_it() {
let reset = [200.0, 50.0, 40.0, 20.0];
let targets = [
[0.0, 0.0, 100.0, 16.0],
[100.0, 50.0, 90.0, 20.0],
[0.0, 50.0, 40.0, 20.0],
[200.0, 80.0, 40.0, 20.0],
];
assert_eq!(beside(reset, &targets), Some(1));
assert_eq!(beside(reset, &targets[3..]), None);
}
#[test]
fn keyboard_auto_opens_for_pad_on_typing_screen() {
assert!(osk_auto_opens(false, false, true, 3, true));
+6 -3
View File
@@ -5,7 +5,7 @@ use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{
Mob, Species, Who,
Mob, Species,
finalize::{
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
set_slot_enchantments, slot_enchantments, stored_drop_chance,
@@ -392,6 +392,10 @@ impl Game<'_> {
}
let at = p.position;
let eye = [at.x, at.y + 1.62, at.z];
let player_uuid = self
.lan_acting_uuid
.map(horizon_client::lan::playerdata::uuid_ints)
.or(self.level.player_uuid);
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin
@@ -408,8 +412,7 @@ impl Game<'_> {
continue;
}
let m = &mut self.mobs[i];
m.extra.anger = 600;
m.ai.target = Some(Who::Player);
horizon_client::mobs::piglin::set_guarded_anger_target(m, player_uuid);
}
}
/// A clear line of sight between two points (blocks with collision stop it).
+10 -6
View File
@@ -224,6 +224,7 @@ impl Game<'_> {
// SmithingTrimDemoSlotDisplay draws from a random of its own.
let mut random = super::dispenser::entity_random();
let mut items = Vec::new();
let mut first = None;
for id in recipes {
let Some(station) = index.toast_station(id) else {
continue;
@@ -251,19 +252,22 @@ impl Game<'_> {
)
});
if let Some(result) = result {
first.get_or_insert(id);
items.push((station.to_owned(), result));
}
}
}
if items.is_empty() {
let Some(first) = first else {
return;
}
};
match self.adv.toasts.iter_mut().find(|t| t.is_recipe()) {
Some(toast) => toast.add_recipes(items),
None => self
.adv
.toasts
.push_back(super::advancements::Toast::recipe(items)),
None => {
if self.toast_allowed("recipe", first) {
let toast = super::advancements::Toast::recipe(items);
self.adv.toasts.push_back(toast);
}
}
}
}
@@ -771,6 +771,18 @@ impl Game<'_> {
))
.fluid
== horizon_client::blocks::Fluid::Water;
if water {
// applyInertia, before the move: four bubbles on the motion
// the fireball already has.
let (at, speed) = horizon_client::projectiles::water_bubble_at(
[at.x, at.y, at.z],
[b.velocity.x, b.velocity.y, b.velocity.z],
);
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for _ in 0..4 {
self.particles.add(&bubble, at, speed);
}
}
b.remote_step(water);
let p = b.position;
self.particles.add(&trail, [p.x, p.y + 0.5, p.z], [0.0; 3]);
+25 -159
View File
@@ -11,6 +11,7 @@ use horizon_client::{
collisions::{SceneCollisions, no_collision_empty},
items::ItemStack,
mobs::Who,
stand_up::{StandUpLevel, StandUpType, bed_forward, stand_up_fallback},
text::Text,
};
use horizon_core::{
@@ -346,23 +347,16 @@ impl Game<'_> {
// ---- respawning -------------------------------------------------------
/// `DismountHelper.nonClimbableShape`: the collision shape, empty for
/// climbable blocks and open trapdoors.
fn non_climbable_shape(&self, p: [i32; 3]) -> VoxelShape {
let state = self.world.block(p[0], p[1], p[2]);
let name = &self.res.facts().get(state).name;
let open_trapdoor = name.ends_with("_trapdoor")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("open"))
.is_some_and(|v| v == "true");
if self.block_in_tag("minecraft:climbable", name) || open_trapdoor {
return VoxelShape::empty();
/// This level as the stand-up search reads it (`crate::stand_up`).
fn stand_up_level(&self) -> StandUpLevel<'_, '_> {
StandUpLevel {
world: &self.world,
registry: self.pack.assets.registry,
facts: self.res.facts(),
collision: self.collision,
block_tags: &self.res.items().block_tags,
border: Some(self.border.geometry()),
}
self.block_collision_shape(p)
}
/// `BlockState.getCollisionShape(level, pos)`: the shape under the empty
/// collision context.
@@ -406,81 +400,10 @@ impl Game<'_> {
.get(tag)
.is_some_and(|s| s.contains(block))
}
/// `EntityType.isBlockDangerous` for the player.
fn dangerous_for_player(&self, p: [i32; 3]) -> bool {
let state = self.world.block(p[0], p[1], p[2]);
let name = &*self.res.facts().get(state).name;
let lit_campfire = (name == "minecraft:campfire" || name == "minecraft:soul_campfire")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true");
// NodeEvaluator.isBurningBlock, then the hurting plants and snow.
self.block_in_tag("minecraft:fire", name)
|| matches!(
name,
"minecraft:lava"
| "minecraft:magma_block"
| "minecraft:lava_cauldron"
| "minecraft:wither_rose"
| "minecraft:sweet_berry_bush"
| "minecraft:cactus"
| "minecraft:powder_snow"
)
|| lit_campfire
}
/// `DismountHelper.findSafeDismountLocation` for a standing player.
fn safe_dismount_location(&self, p: [i32; 3], check_dangerous: bool) -> Option<Vec3> {
if check_dangerous && self.dangerous_for_player(p) {
return None;
}
let shape = self.non_climbable_shape(p);
let below = self.non_climbable_shape([p[0], p[1] - 1, p[2]]);
let floor = horizon_core::geometry::block_floor_height(&shape, || &below);
// DismountHelper.isBlockFloorValid.
if floor.is_infinite() || floor >= 1.0 {
return None;
}
if check_dangerous && floor <= 0.0 && self.dangerous_for_player([p[0], p[1] - 1, p[2]]) {
return None;
}
let position = Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]) + floor,
f64::from(p[2]) + 0.5,
);
// EntityDimensions.makeBoundingBox for 0.6 by 1.8 (the half width is a float).
let half = f64::from(0.6f32 / 2.0);
let bounds = Aabb::new(
Vec3::new(position.x - half, position.y, position.z - half),
Vec3::new(
position.x + half,
position.y + f64::from(1.8f32),
position.z + half,
),
);
if !self.can_dismount_to(bounds) {
return None;
}
let name = |q: [i32; 3]| {
self.res
.facts()
.get(self.world.block(q[0], q[1], q[2]))
.name
.clone()
};
if self.block_in_tag("minecraft:invalid_spawn_inside", &name(p))
|| self.block_in_tag(
"minecraft:invalid_spawn_inside",
&name([p[0], p[1] + 1, p[2]]),
)
{
return None;
}
Some(position)
self.stand_up_level()
.safe_dismount_location(StandUpType::PLAYER, p, check_dangerous)
}
/// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners,
/// signs and pressure plates override it to always.
@@ -505,58 +428,11 @@ impl Game<'_> {
}
None
}
/// `AbstractBedBlock.findStandUpPosition` for a bed whose head is at
/// `pos`, facing `forward`, set from `yaw`: beside the bed on the side
/// the player faced away from, around it, then on it; a safe spot
/// first, else any spot (a bunk bed: around it, a block lower, then on
/// it).
/// `AbstractBedBlock.findStandUpPosition` for a player beside the bed
/// whose head is at `pos`, facing `forward`, set from `yaw`.
pub(super) fn bed_stand_up(&self, pos: [i32; 3], forward: [i32; 3], yaw: f32) -> Option<Vec3> {
// Direction.getClockWise.
let right = [-forward[2], 0, forward[0]];
// Direction.isFacingAngle.
let radians = yaw * 0.017_453_292;
let (dx, dz) = (
-horizon_core::geometry::trig::sin(f64::from(radians)),
horizon_core::geometry::trig::cos(f64::from(radians)),
);
let facing = right[0] as f32 * dx + right[2] as f32 * dz > 0.0;
let side = if facing {
[-right[0], 0, -right[2]]
} else {
right
};
let (f, s) = ([forward[0], forward[2]], [side[0], side[2]]);
// bedSurroundStandUpOffsets and bedAboveStandUpOffsets.
let surround = [
[s[0], s[1]],
[s[0] - f[0], s[1] - f[1]],
[s[0] - f[0] * 2, s[1] - f[1] * 2],
[-f[0] * 2, -f[1] * 2],
[-s[0] - f[0] * 2, -s[1] - f[1] * 2],
[-s[0] - f[0], -s[1] - f[1]],
[-s[0], -s[1]],
[-s[0] + f[0], -s[1] + f[1]],
[f[0], f[1]],
[s[0] + f[0], s[1] + f[1]],
];
let above = [[0, 0], [-f[0], -f[1]]];
let at = |base: [i32; 3], offsets: &[[i32; 2]], check: bool| {
offsets.iter().find_map(|o| {
self.safe_dismount_location([base[0] + o[0], base[1], base[2] + o[1]], check)
})
};
let below = [pos[0], pos[1] - 1, pos[2]];
// isBunkBed: a bed below.
if self.is_bed(self.world.block(below[0], below[1], below[2])) {
return at(pos, &surround, true)
.or_else(|| at(below, &surround, true))
.or_else(|| at(pos, &above, true))
.or_else(|| at(pos, &surround, false))
.or_else(|| at(below, &surround, false))
.or_else(|| at(pos, &above, false));
}
let all: Vec<[i32; 2]> = surround.into_iter().chain(above).collect();
at(pos, &all, true).or_else(|| at(pos, &all, false))
self.stand_up_level()
.bed_stand_up(StandUpType::PLAYER, pos, forward, yaw)
}
pub(super) fn is_bed(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).name.ends_with("_bed")
@@ -571,29 +447,19 @@ impl Game<'_> {
return None;
}
let facing = self.bed_forward(state);
let stand = self.bed_stand_up(head, facing, yaw).unwrap_or_else(|| {
Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1] + 1) + 0.1,
f64::from(head[2]) + 0.5,
)
});
let stand = self
.bed_stand_up(head, facing, yaw)
.unwrap_or_else(|| stand_up_fallback(head));
Some((stand, look_at_yaw(stand, head)))
}
/// The bed's `facing`, as a step.
pub(super) fn bed_forward(&self, state: BlockStateId) -> [i32; 3] {
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing").cloned());
match facing.as_deref() {
Some("south") => [0, 0, 1],
Some("west") => [-1, 0, 0],
Some("east") => [1, 0, 0],
_ => [0, 0, -1],
}
let facing = self.pack.assets.registry.state(state);
bed_forward(
facing
.and_then(|s| s.properties.get("facing"))
.map(String::as_str),
)
}
/// `BedRule.canSetSpawn` of the bed's rule attribute (`bed_rule`,
/// `straw_bed_rule`): only the Overworld's beds set the spawn, straw
@@ -859,6 +859,9 @@ impl Game<'_> {
let board = &self.score.board;
let objective = board.display_objective(sb::SLOT_LIST);
let up = |id: u128| upside_down.contains(&id);
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
let marked = |id: u128| horizon.contains(&id);
let inputs = TabInputs {
players: &players,
board,
@@ -872,6 +875,7 @@ impl Game<'_> {
f64::from(self.settings.text_background_opacity),
),
upside_down: &up,
horizon: &marked,
};
let draws = self.tab_list.layout(&inputs, &self.gui.atlas.font);
horizon_client::hud_layout::paint(batch, &self.gui.atlas, &draws);
+20 -6
View File
@@ -363,7 +363,19 @@ impl Game<'_> {
/// that finished, the ones to start, a resource reload when the active
/// packs change, and the statuses for the server.
pub(super) fn tick_server_packs(&mut self) {
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
// SystemToast.addOrUpdate's addToast: mods hear each new toast, so a
// cancelled one is asked again at the batch's next change.
let mut notifiers = std::mem::take(&mut self.server_packs.notifiers);
notifiers.retain_mut(|n| {
let new = n.toast.is_none();
let keep = n.poll();
if new && n.toast.is_some() && !self.toast_allowed("system", "pack_download") {
n.toast = None;
}
keep
});
notifiers.append(&mut self.server_packs.notifiers);
self.server_packs.notifiers = notifiers;
// A reload waits for the world's resources (`reload_packs`).
if self.rest_loader.is_some() {
return;
@@ -506,11 +518,13 @@ impl Game<'_> {
.save_failure
.map(|(x, z)| horizon_client::chunk_io::save_failure_toast(x, z)),
];
for (slot, new) in self.server_packs.chunk_io.iter_mut().zip(toasts) {
match (slot.as_mut(), new) {
(Some(shown), Some(new)) => shown.reset_from(new),
(None, Some(new)) => *slot = Some(new),
_ => {}
let ids = ["low_disk_space", "chunk_load_failure", "chunk_save_failure"];
for (i, (new, id)) in toasts.into_iter().zip(ids).enumerate() {
let Some(new) = new else { continue };
if let Some(shown) = self.server_packs.chunk_io[i].as_mut() {
shown.reset_from(new);
} else if self.toast_allowed("system", id) {
self.server_packs.chunk_io[i] = Some(new);
}
}
}
+6 -67
View File
@@ -1,6 +1,7 @@
//! What splitscreen draws over a player's view: its badge, questions answered with the pad and
//! notices.
use super::*;
use horizon_client::controller_notice::{NoticeSlot, key, panel};
use horizon_client::gamepad::Button;
use horizon_client::hud::{HudBatch, rgba};
use std::time::Duration;
@@ -10,47 +11,12 @@ use std::time::Duration;
#[derive(Default)]
pub(in crate::game) struct Hud {
pub(in crate::game) menu: bool,
pub(super) badge: Option<String>,
pub(in crate::game) badge: Option<String>,
/// The badge's name stays (P1's Setup, Names); off, it shows only with a place.
pub(super) names: bool,
pub(super) place: Option<(String, Instant)>,
pub(super) prompt: Option<Prompt>,
pub(super) notice: Option<Notice>,
}
/// A notice nobody answers: a strip at the top of the view in the prompts' style, its words
/// wrapped to the view (a toast is as wide as its longest line: off a narrow view's edge), gone
/// after the toasts' time (`notification_display_time`).
pub(super) struct Notice {
title: String,
body: String,
/// When it was raised: it goes that long after, drawn or not (a notice raised on the way
/// out of a world must not wait for the next one).
raised: Instant,
}
impl Notice {
pub(super) fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
Self {
title: title.into(),
body: body.into(),
raised: Instant::now(),
}
}
}
/// The prompts' and notices' box: dark, a faint edge.
fn panel(batch: &mut HudBatch, atlas: &horizon_client::hud::HudAtlas, [px, py, pw, ph]: [f32; 4]) {
batch.fill(atlas, px, py, pw, ph, rgba(0, 0, 0, 210));
let edge = rgba(255, 255, 255, 90);
for [x, y, fw, fh] in [
[px, py, pw, 1.0],
[px, py + ph - 1.0, pw, 1.0],
[px, py, 1.0, ph],
[px + pw - 1.0, py, 1.0, ph],
] {
batch.fill(atlas, x, y, fw, fh, edge);
}
pub(in crate::game) notice: NoticeSlot,
}
/// A question on a player's view, answered with its controller's buttons.
@@ -153,10 +119,8 @@ impl Game<'_> {
let mut x = (px + (pw - line) / 2.0).floor();
let y = py + 20.0;
for (glyph, [r, g, b], what) in glyphs {
// The button as printed on the pad, in its colour, on a dark key.
let gw = width(glyph) + 4.0;
batch.fill(atlas, x - 1.0, y - 2.0, gw, 11.0, rgba(40, 40, 40, 255));
batch.text(atlas, glyph, x + 1.0, y, rgba(r, g, b, 255), false);
key(batch, atlas, glyph, rgba(r, g, b, 255), x, y, gw);
x += gw + 4.0;
batch.text(atlas, what, x, y, rgba(220, 220, 220, 255), false);
x += width(what) + gap(what);
@@ -167,33 +131,8 @@ impl Game<'_> {
let below_strip = matches!(&self.split_hud.prompt, Some(p) if p.ask == Ask::Join);
let shown_for =
Duration::from_secs_f64(5.0 * f64::from(self.settings.notification_display_time));
let Some(notice) = &mut self.split_hud.notice else {
return;
};
if notice.raised.elapsed() > shown_for {
self.split_hud.notice = None;
return;
}
let max = (w - 24.0).min(260.0);
let lines = horizon_client::split_profiles::wrap(&notice.body, max, width);
let inner = lines
.iter()
.map(|l| width(l))
.fold(width(&notice.title), f32::max);
let (pw, ph) = (inner.min(max) + 16.0, 20.0 + 10.0 * lines.len() as f32);
let (px, py) = (
((w - pw) / 2.0).floor(),
if below_strip { 56.0 } else { 18.0 },
);
panel(batch, atlas, [px, py, pw, ph]);
let white = rgba(255, 255, 255, 255);
let tx = (px + (pw - width(&notice.title)) / 2.0).floor();
batch.text(atlas, &notice.title, tx, py + 6.0, white, true);
for (k, line) in lines.iter().enumerate() {
let lx = (px + (pw - width(line)) / 2.0).floor();
let y = py + 18.0 + 10.0 * k as f32;
batch.text(atlas, line, lx, y, rgba(200, 200, 200, 255), false);
}
let top = if below_strip { 56.0 } else { 18.0 };
self.split_hud.notice.draw(batch, atlas, top, shown_for);
}
}
+12 -2
View File
@@ -4,6 +4,7 @@
use super::hud::tr;
use super::*;
use crate::game::pad::{PadEvent, PadId};
use horizon_client::controller_notice::{Notice, NoticeId, Priority};
use horizon_client::gamepad::{Button, PadState};
/// How long a controller's offer to join stays up.
@@ -256,10 +257,12 @@ impl Split<'_> {
if let Some(id) = self.p1_pad.owner {
self.host.pad.style = self.pads.style(id);
}
self.host.pad.device = self.p1_pad.owner.and_then(|id| self.pads.device(id));
for g in &mut self.guests {
if let Some(id) = g.pad {
g.game.pad.style = self.pads.style(id);
}
g.game.pad.device = g.pad.and_then(|id| self.pads.device(id));
}
// P1 alone keeps its controller's output (rumble, the DualSense's triggers): the router
// lends it its reader for P1's turn (`lend_output`), so no second reader opens. Only
@@ -315,7 +318,9 @@ impl Split<'_> {
if owned {
if self.in_world() {
self.lost.push(Lost { seat: 1, id, uuid });
self.host.split_hud.notice = Some(Notice::new(
self.host.split_hud.notice.raise(Notice::new(
NoticeId::Connection,
Priority::Attention,
tr("controller_disconnected", &[]),
tr("controller_disconnected.hint", &[]),
));
@@ -355,7 +360,12 @@ impl Split<'_> {
if hud.prompt.as_ref().is_some_and(|p| p.ask == Ask::Lost) {
hud.prompt = None;
}
hud.notice = Some(Notice::new(tr("controller_connected", &[]), ""));
hud.notice.raise(Notice::new(
NoticeId::Connection,
Priority::Info,
tr("controller_connected", &[]),
"",
));
}
/// A button on a controller nobody has while players wait for one: true when it answered
@@ -1,7 +1,11 @@
//! Players coming and going: joining (P1's world through memory, or P1's server) as a profile
//! it picks (`split_profiles`), dropping out.
use super::*;
use horizon_client::{gamepad::PadState, options_ui::Page};
use horizon_client::{
controller_notice::{Notice, NoticeId, Priority},
gamepad::PadState,
options_ui::Page,
};
/// Whether `game` shows its profile page now.
fn on_profiles(game: &Game<'_>) -> bool {
@@ -508,7 +512,7 @@ impl Split<'_> {
|| (self.host.integrated.is_none() && self.host.remote.is_none());
let before = self.guests.len();
if p1_left {
self.host.split_hud.notice = None;
self.host.split_hud.notice.clear();
}
for k in (0..self.guests.len()).rev() {
let g = &mut self.guests[k];
@@ -526,8 +530,12 @@ impl Split<'_> {
.then(|| turned_away(&g.game))
.flatten();
if let (true, Some(name), Some(why)) = (g.entered, &g.name, &why) {
self.host.split_hud.notice =
Some(Notice::new(format!("{name} could not stay"), why.clone()));
self.host.split_hud.notice.raise(Notice::new(
NoticeId::PlayerLeft,
Priority::Attention,
format!("{name} could not stay"),
why.clone(),
));
} else if let Some(why) = why {
let old = self.guests[k].name.take();
if let (Some(server), Some(old)) = (&self.host.integrated, &old) {
@@ -999,8 +999,21 @@ impl Game<'_> {
xp.progress = 0.0;
xp.total = 0;
}
let level = xp.level;
self.play_level_up(levels, level);
self.native_player_level_changed(old_level);
}
/// `Player.giveExperienceLevels`: all players near its feet hear the sound.
pub(super) fn play_level_up(&mut self, amount: i32, level: i32) {
if let Some(volume) = self.level_up_sound.give_levels(amount, level) {
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.levelup", at, volume, 1.0);
}
}
/// Whether the result slot may be taken (`mayPickup`).
fn may_take_result(&self) -> bool {
let Some(s) = self.station.as_ref() else {
+11
View File
@@ -624,6 +624,17 @@ impl Game<'_> {
}
// ThrownTrident.tick: Loyalty needs its owner, who is in this level.
let owner_eye = catchers.iter().find(|c| c.guest == t.guest).map(|c| c.eye);
// `isAcceptibleReturnOwner` fails for a dead or spectating owner: the
// trident drops as an item when it can be picked up, then goes.
if t.loyalty > 0
&& (t.dealt_damage || t.no_physics)
&& catchers.iter().any(|c| c.guest == t.guest && !c.touches)
{
if !t.creative_only {
self.spawn_at_location(t.position, f64::from(0.1f32), t.stack.clone());
}
continue;
}
if t.loyalty > 0
&& (t.dealt_damage || t.no_physics)
&& let Some(owner_eye) = owner_eye
+27 -4
View File
@@ -34,6 +34,20 @@ pub(super) enum Hint {
CraftPlanks,
SocialInteractions,
}
impl Hint {
/// The hint's name, as in its `tutorial.<name>.title` key.
fn name(self) -> &'static str {
match self {
Hint::Move => "move",
Hint::Look => "look",
Hint::FindTree => "find_tree",
Hint::PunchTree => "punch_tree",
Hint::OpenInventory => "open_inventory",
Hint::CraftPlanks => "craft_planks",
Hint::SocialInteractions => "socialInteractions",
}
}
}
/// The observations needed on the first tick of each step.
#[derive(Default)]
@@ -261,8 +275,12 @@ impl super::Game<'_> {
if !self.settings.file.bool("joinedFirstServer")
&& (!self.has_singleplayer_server() || self.is_published())
{
let toast = self.tutorial_toast(Hint::SocialInteractions, Some(0.0));
self.tutorial_toasts.push(toast);
// ToastManager.addToast: a mod may cancel it; the hint is still
// spent (`joinedFirstServer`), as vanilla sets it either way.
if self.toast_allowed("tutorial", Hint::SocialInteractions.name()) {
let toast = self.tutorial_toast(Hint::SocialInteractions, Some(0.0));
self.tutorial_toasts.push(toast);
}
self.settings.file.set(
"joinedFirstServer",
horizon_client::options_file::V::Bool(true),
@@ -352,6 +370,9 @@ pub(super) struct HintToast {
slot: Option<usize>,
slots: usize,
slide: horizon_client::toasts::Slide,
/// A mod cancelled it (`ToastAddEvent`): it never takes a slot, and
/// stays only so the hint isn't offered again while it lasts.
cancelled: bool,
}
impl super::Game<'_> {
@@ -462,6 +483,7 @@ impl super::Game<'_> {
slot: None,
slots,
slide: Default::default(),
cancelled: false,
}
}
@@ -486,12 +508,13 @@ impl super::Game<'_> {
.iter()
.any(|t| t.wanted && t.hint == hint)
{
let cancelled = !self.toast_allowed("tutorial", hint.name());
let toast = self.tutorial_toast(hint, progress);
self.tutorial_toasts.push(toast);
self.tutorial_toasts.push(HintToast { cancelled, ..toast });
}
let mut used = self.toast_slots_used();
for t in &mut self.tutorial_toasts {
if t.slot.is_none() && t.wanted && t.slots <= Self::TOAST_SLOTS {
if t.slot.is_none() && t.wanted && !t.cancelled && t.slots <= Self::TOAST_SLOTS {
let mask = (1u32 << t.slots) - 1;
if let Some(slot) =
(0..=Self::TOAST_SLOTS - t.slots).find(|s| used & (mask << s) == 0)
+263 -2
View File
@@ -1,6 +1,7 @@
//! Players that are about to arrive from another server, and a connected
//! player read out of this one: the control endpoint's `player-expect` and
//! `player-export` (`game/dedicated/transfer.rs` runs them).
//! player read out of this one and sent away: the control endpoint's
//! `player-expect`, `player-export` and `player-release`
//! (`game/dedicated/transfer.rs` runs them).
//!
//! A proxy that moves a player between two servers first tells the target
//! server who is coming (`player-expect <uuid>`), and reads the player out of
@@ -408,6 +409,54 @@ pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Va
}
}
/// `player-release <uuid> <hand-over> moved [announce]` or `player-release <uuid> <hand-over>
/// abort`: ends the hand-over the export with `freeze` started. `moved` takes the player out of
/// the world, saving nothing and, unless `announce`, without the "left the game" line; `abort`
/// lets it go on as it was. Both name the hand-over, so a late or repeated request for one that
/// is over touches nothing.
pub fn release_request(hub: &mut Hub, args: &str) -> serde_json::Value {
let mut parts = args.split_whitespace();
let Some(uuid) = parts.next().and_then(parse_uuid) else {
return error("player-release needs the player's unique id");
};
let Some(id) = parts.next().and_then(parse_handover) else {
return error("player-release needs the hand-over id the export gave");
};
let how = parts.next();
let announce = match (how, parts.next()) {
(Some("moved"), None) => false,
(Some("moved"), Some("announce")) => true,
(Some("abort"), None) => false,
_ => {
return error(
"player-release <uuid> <hand-over> is followed by moved [announce] or abort",
);
}
};
if parts.next().is_some() {
return error(
"player-release takes a unique id, a hand-over id, moved or abort, and at most announce",
);
}
if how == Some("moved") {
return match hub.release_moved(uuid, id, announce) {
Ok(()) => serde_json::json!({
"ok": true,
"released": "moved",
"announced": announce,
}),
Err(why) => error(why),
};
}
match hub.handover_of(uuid, id) {
Ok(i) => {
hub.guests[i].handover = None;
serde_json::json!({ "ok": true, "released": "abort" })
}
Err(why) => error(why),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -572,6 +621,7 @@ mod hub_tests {
guest::Guest,
hub::{Hub, HubConfig, TickCtx},
};
use horizon_protocol::ids::play::clientbound as id;
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
@@ -642,6 +692,29 @@ mod hub_tests {
(g, rx)
}
/// The packets waiting in a channel, in order, each as its id and payload.
fn packets(rx: &Receiver<Out>) -> Vec<(i32, Vec<u8>)> {
let mut out = Vec::new();
let mut one = |body: &[u8]| {
let mut r = horizon_protocol::buf::Reader::new(body);
let id = r.varint().unwrap();
out.push((id, r.rest().to_vec()));
};
while let Ok(o) = rx.try_recv() {
match o {
Out::Packet(b) => one(&b),
Out::Packets(bs) => bs.iter().for_each(|b| one(b)),
_ => {}
}
}
out
}
/// The ids of the packets waiting in a channel, in order.
fn ids(rx: &Receiver<Out>) -> Vec<i32> {
packets(rx).into_iter().map(|(id, _)| id).collect()
}
fn place(hub: &mut Hub, conn: u32) {
let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0);
let border = crate::worldborder::WorldBorder::default();
@@ -1121,4 +1194,192 @@ mod hub_tests {
assert!(hub.guests[i].left_reason.is_empty(), "not kicked");
assert_eq!(hub.guests[i].last_seen.tracked_count(), 20);
}
// ---- player-release ------------------------------------------------------
/// A watcher and a placed player frozen for a hand-over, and the hand-over's id.
fn frozen_with_a_watcher() -> (Hub, Receiver<Out>, Receiver<Out>, String) {
let mut hub = hub();
let (mut watcher, seen) = guest(1, 1, "Watcher");
watcher.placed = true;
hub.guests.push(watcher);
let (mut mover, mine) = guest(2, UUID_N, "Mover");
mover.placed = true;
hub.guests.push(mover);
hub.ticks = 50;
let record = super::export_record(&mut hub, 1, true, Default::default()).unwrap();
let handover = record["handover"].as_str().unwrap().to_owned();
let _ = (ids(&seen), ids(&mine));
(hub, seen, mine, handover)
}
fn poll(hub: &mut Hub) -> Vec<crate::lan::hub::HostEvent> {
let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0);
let border = crate::worldborder::WorldBorder::default();
let mut next = 100;
let mut ctx = tick_context(&mut world, &border, &mut next);
hub.poll(&mut ctx)
}
fn left_events(hub: &mut Hub) -> Vec<Guest> {
poll(hub)
.into_iter()
.filter_map(|e| match e {
crate::lan::hub::HostEvent::Left(g) => Some(*g),
_ => None,
})
.collect()
}
/// Moved to another server: the others see it go, but nothing is said and nothing saved.
#[test]
fn a_moved_player_leaves_the_world_without_the_leave_line_and_is_not_saved() {
let (mut hub, seen, mine, h) = frozen_with_a_watcher();
let reply = super::release_request(&mut hub, &format!("{UUID} {h} moved"));
assert_eq!(
reply,
serde_json::json!({"ok": true, "released": "moved", "announced": false})
);
assert_eq!(hub.guests.len(), 1, "the mover is out of the hub");
let told = ids(&seen);
assert!(told.contains(&id::PLAYER_INFO_REMOVE), "{told:?}");
assert!(told.contains(&id::REMOVE_ENTITIES), "{told:?}");
assert!(!told.contains(&id::SYSTEM_CHAT), "no leave line: {told:?}");
assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_))));
// The game is told it left, released: it saves no file and no record.
let left = left_events(&mut hub);
assert_eq!(left.len(), 1);
assert!(left[0].released && left[0].placed && left[0].handover.is_none());
assert_eq!(left[0].left_reason, "moved to another server");
}
#[test]
fn a_moved_player_can_be_announced_as_leaving() {
let (mut hub, seen, _mine, h) = frozen_with_a_watcher();
let reply = super::release_request(&mut hub, &format!("{UUID} {h} moved announce"));
assert_eq!(reply["announced"], true);
assert!(ids(&seen).contains(&id::SYSTEM_CHAT));
}
#[test]
fn an_aborted_hand_over_lets_the_player_go_on_as_it_was() {
let (mut hub, seen, mine, h) = frozen_with_a_watcher();
let reply = super::release_request(&mut hub, &format!("{UUID} {h} abort"));
assert_eq!(reply, serde_json::json!({"ok": true, "released": "abort"}));
assert_eq!(hub.guests.len(), 2);
assert!(hub.guests[1].handover.is_none() && hub.guests[1].placed);
assert_eq!(ids(&seen), Vec::<i32>::new());
assert_eq!(ids(&mine), Vec::<i32>::new());
assert!(left_events(&mut hub).is_empty());
// The hand-over is over: a late `moved` for it is refused.
let reply = super::release_request(&mut hub, &format!("{UUID} {h} moved"));
assert!(reply["error"].is_string(), "{reply}");
assert_eq!(hub.guests.len(), 2);
}
/// A release names the hand-over: one for another (an older one, or one the proxy
/// mixed up) changes nothing.
#[test]
fn a_release_for_another_hand_over_is_refused_and_changes_nothing() {
let (mut hub, seen, _mine, h) = frozen_with_a_watcher();
let rest: String = h.chars().skip(1).collect();
let other = format!("{}{rest}", if h.starts_with('0') { '1' } else { '0' });
for how in ["moved", "abort"] {
let reply = super::release_request(&mut hub, &format!("{UUID} {other} {how}"));
assert!(
reply["error"]
.as_str()
.unwrap()
.contains("not the player's current hand-over"),
"{reply}"
);
}
assert_eq!(hub.guests.len(), 2);
assert!(hub.guests[1].handover.is_some(), "still frozen");
assert_eq!(ids(&seen), Vec::<i32>::new());
}
#[test]
fn a_release_is_refused_for_a_player_who_is_not_frozen_and_changes_nothing() {
let (mut hub, seen, _mine, h) = frozen_with_a_watcher();
hub.guests[1].handover = None;
for how in ["moved", "abort"] {
let reply = super::release_request(&mut hub, &format!("{UUID} {h} {how}"));
assert!(
reply["error"]
.as_str()
.unwrap()
.contains("not in a hand-over"),
"{reply}"
);
}
// Not connected at all.
let reply = super::release_request(
&mut hub,
&format!("00000000000000000000000000000001 {h} moved"),
);
assert!(reply["error"].is_string());
assert_eq!(hub.guests.len(), 2);
assert_eq!(ids(&seen), Vec::<i32>::new());
}
#[test]
fn a_release_must_say_which_hand_over_how_and_nothing_more() {
let (mut hub, _seen, _mine, h) = frozen_with_a_watcher();
for args in [
String::new(),
"nonsense moved".to_owned(),
UUID.to_owned(),
format!("{UUID} moved"),
format!("{UUID} {h}"),
format!("{UUID} not-an-id moved"),
format!("{UUID} {h} later"),
format!("{UUID} {h} moved quietly"),
format!("{UUID} {h} abort announce"),
format!("{UUID} {h} moved announce now"),
] {
let reply = super::release_request(&mut hub, &args);
assert!(reply["error"].is_string(), "{args:?} gave {reply}");
}
assert!(
hub.guests[1].handover.is_some(),
"no refusal touched the player"
);
}
/// Once the freeze ran out the player played on, and the record the other server has is
/// older than it: a late `moved` is refused, and the proxy fails the hand-over.
#[test]
fn a_release_that_comes_after_the_freeze_ran_out_is_refused() {
let (mut hub, seen, mine, h) = frozen_with_a_watcher();
let since = hub.guests[1].handover.as_ref().unwrap().since;
hub.ticks = since + super::Settings::default().max_freeze_ticks;
assert!(poll(&mut hub).iter().any(|e| is_warn(e, "frozen")));
assert!(hub.guests[1].handover.is_none());
let reply = super::release_request(&mut hub, &format!("{UUID} {h} moved"));
assert!(
reply["error"].as_str().unwrap().contains("freeze ran out"),
"{reply}"
);
assert_eq!(hub.guests.len(), 2, "the player stays");
assert!(!ids(&seen).contains(&id::PLAYER_INFO_REMOVE));
assert!(!matches!(mine.try_recv(), Ok(Out::Disconnect(_))));
}
/// A frozen player takes no damage, but the void and `/kill` still kill it: its record is
/// then not what is left, so `moved` is refused and the next tick ends the hand-over.
#[test]
fn a_player_who_died_while_frozen_cannot_be_released_as_moved() {
let (mut hub, _seen, _mine, h) = frozen_with_a_watcher();
hub.guests[1].dead = true;
let reply = super::release_request(&mut hub, &format!("{UUID} {h} moved"));
assert!(reply["error"].as_str().unwrap().contains("died"), "{reply}");
assert_eq!(hub.guests.len(), 2);
assert!(
poll(&mut hub)
.iter()
.any(|e| is_warn(e, "died while frozen"))
);
assert!(hub.guests[1].handover.is_none());
}
}
+9
View File
@@ -346,6 +346,13 @@ pub struct Guest {
/// Its login started while a proxy said it would arrive from another
/// server, and used that expectation up (`player-expect`).
pub expected: bool,
/// Leaving for another server (`player-release moved`) without the "left the
/// game" line.
pub quiet_leave: bool,
/// Taken out of the world because a proxy moved it to another server: the game
/// saves neither its player file nor its records, since the other server has the
/// player now.
pub released: bool,
/// Root command names the mods took out of this player's command tree
/// (`native_player_commands_sent`); their tree is sent without them.
pub hidden_commands: Vec<String>,
@@ -522,6 +529,8 @@ impl Guest {
placed: false,
handover: None,
expected: j.expected,
quiet_leave: false,
released: false,
hidden_commands: Vec::new(),
flying_speed: 0.05,
dimension: "minecraft:overworld".into(),
+57 -5
View File
@@ -1019,10 +1019,18 @@ impl Hub {
}
let max = transfers.settings.max_freeze_ticks;
for g in &mut self.guests {
if g.handover
.as_ref()
.is_some_and(|h| super::arrivals::frozen_too_long(h.since, self.ticks, max))
{
let Some(h) = &g.handover else {
continue;
};
// A frozen player takes no damage, but the void and `/kill` still kill: its
// record is then not what is left of it, and `player-release moved` must fail.
if g.dead {
g.handover = None;
events.push(HostEvent::Warn(format!(
"{} died while frozen for a hand-over: the hand-over is over",
g.name,
)));
} else if super::arrivals::frozen_too_long(h.since, self.ticks, max) {
g.handover = None;
events.push(HostEvent::Warn(format!(
"{} was frozen for a hand-over for {max} ticks and nothing finished it: let go",
@@ -2037,11 +2045,55 @@ impl Hub {
if o.tab_hidden.remove(&g.uuid) {
continue;
}
o.sender.send(left.clone());
if !g.quiet_leave {
o.sender.send(left.clone());
}
}
g
}
/// Takes the frozen player `uuid` out of the world because a proxy moved it to another
/// server, for the hand-over `id`: the others see it go (its entity and its player-list
/// entry), but nothing is saved (the other server has the player now) and, unless
/// `announce`, the "left the game" line is not sent. The game is told it left, marked
/// released, so it saves nothing.
///
/// Refused when the player is not in that hand-over: another one, or none because the
/// freeze ran out (the player has played on since, and the record the other server has is
/// older), or a player who died while frozen.
pub fn release_moved(
&mut self,
uuid: u128,
id: u128,
announce: bool,
) -> Result<(), &'static str> {
let i = self.handover_of(uuid, id)?;
let g = &mut self.guests[i];
g.quiet_leave = !announce;
g.left_reason = "moved to another server".to_owned();
g.sender
.disconnect(translatable("multiplayer.disconnect.generic", &[]));
let mut gone = self.remove_guest(i, "moved to another server");
gone.released = true;
gone.handover = None;
self.pending_kicks.push(HostEvent::Left(Box::new(gone)));
Ok(())
}
/// The guest index of the player `uuid` while it is frozen for the hand-over `id`.
pub fn handover_of(&self, uuid: u128, id: u128) -> Result<usize, &'static str> {
let Some(i) = self.guests.iter().position(|g| g.uuid == uuid) else {
return Err("that player is not connected to this server");
};
let g = &self.guests[i];
match &g.handover {
_ if g.dead => Err("that player died during the hand-over"),
None => Err("that player is not in a hand-over (or its freeze ran out)"),
Some(h) if h.id != id => Err("that is not the player's current hand-over"),
Some(_) => Ok(i),
}
}
/// `ServerTickRateManager.updateStepTicks` / `updateStateToClients`:
/// send clock changes before the block events caused by their ticks.
pub fn sync_ticking(&mut self, rate: f32, frozen: bool, steps: crate::clock::StepState) {
+1 -1
View File
@@ -1961,7 +1961,7 @@ fn species_ai_step(
Species::Guardian | Species::ElderGuardian => super::guardian::ai_step(m, w, events),
Species::Vex => super::vex::ai_step(m, w),
Species::Ravager => super::ravager::ai_step(m, w, events),
Species::Wither => super::wither::ai_step(m, w, events),
Species::Wither => super::wither::ai_step(m, w, level_random, events),
Species::Allay => super::allay::ai_step(m),
Species::Bee => super::bee::ai_step(m, w),
Species::PolarBear => super::polar_bear::ai_step(m, w),
@@ -1610,13 +1610,16 @@ impl B {
// Sniffer.spawnChildFromBreeding: the experience
// (finalizeSpawnChildFromBreeding), then the plop,
// both from the parent's random; the game lays the
// egg.
// egg. Vanilla makes the draw inside the `mob_drops`
// check, so it is skipped with the rule off.
let at = [m.position.x, m.position.y, m.position.z];
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0);
cx.events.push(MobEvent::Experience {
at,
amount: xp as u32,
});
if cx.w.mob_drops {
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0);
cx.events.push(MobEvent::Experience {
at,
amount: xp as u32,
});
}
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 0.5;
cx.events
.push(MobEvent::Sound("block.sniffer_egg.plop", at, 1.0, pitch));
+108 -10
View File
@@ -2254,14 +2254,17 @@ impl Goal for Breed {
self.love_time += 1;
if self.love_time >= reduced(60) && m.distance_sqr(p.position) < 9.0 {
// Llama.getBreedOffspring and finalizeSpawnChildFromBreeding's
// experience, from the parent's random.
// experience, from the parent's random: vanilla draws it only
// inside the `mob_drops` check.
let llama = super::llama::is_llama(m.species()).then(|| {
let o = super::llama::breed_offspring(m, &p);
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0);
cx.events.push(MobEvent::Experience {
at: [m.position.x, m.position.y, m.position.z],
amount: xp as u32,
});
if cx.w.mob_drops {
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0);
cx.events.push(MobEvent::Experience {
at: [m.position.x, m.position.y, m.position.z],
amount: xp as u32,
});
}
Box::new(o)
});
m.extra.in_love = 0;
@@ -2913,9 +2916,10 @@ mod eat_block_tests {
/// A sheep on a wide grass shelf, ticked long enough for its `EatBlockGoal`
/// (1 in 1000 a tick) to fire several times: every event it pushed, with
/// the `mob_griefing` game rule at `griefing`. `tufts` puts short grass at
/// the sheep's own feet, so the goal takes the `destroyBlock` half; without
/// it the sheep stands on the grass and takes the grass-to-dirt half.
/// the `mob_griefing` game rule at `griefing` and `mob_drops` on. `tufts`
/// puts short grass at the sheep's own feet, so the goal takes the
/// `destroyBlock` half; without it the sheep stands on the grass and takes
/// the grass-to-dirt half.
fn sheep_events(griefing: bool, tufts: bool) -> Option<Vec<MobEvent>> {
let mut out = None;
let build = |world: &mut crate::world::World,
@@ -2938,7 +2942,7 @@ mod eat_block_tests {
}
}
};
with_built_test_world_rules(&[], griefing, build, |w, types| {
with_built_test_world_rules(&[], griefing, true, build, |w, types| {
let kind = Arc::new(types.types["minecraft:sheep"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut random = LegacyRandom::new(0);
@@ -3221,3 +3225,97 @@ mod move_to_block_tests {
assert_eq!(goal.walk([5, 64, 5], |_| true), Some([5, 63, 5]));
}
}
#[cfg(test)]
mod breed_xp_tests {
use super::*;
/// Two llamas in love, two blocks apart on a flat grass shelf, ticked by
/// the AI until the first breeds: its own events, with the `mob_drops`
/// game rule at `drops`. `None` without the vanilla data.
fn llama_events(drops: bool) -> Option<Vec<MobEvent>> {
// The partner's `Seen` needs its kind, so two loads.
let mut kind = None;
crate::mobs::world::with_built_test_world_rules(&[], true, drops, shelf, |_, types| {
kind = Some(Arc::new(types.types["minecraft:llama"].clone()));
});
let kind = kind?;
let mut partner = Mob::new(2, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
// AbstractHorse.canParent: only tame llamas breed.
partner.extra.tame = true;
partner.extra.in_love = 600;
let seen = [partner.seen(None)];
let mut out = None;
crate::mobs::world::with_built_test_world_rules(&seen, true, drops, shelf, |w, _| {
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.in_love = 600;
m.extra.tame = true;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
for _ in 0..600 {
crate::mobs::ai::tick(&mut m, w, &mut lr, &mut events);
if events
.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. }))
{
break;
}
}
out = Some(events);
});
out
}
/// A flat grass shelf two llamas can stand on.
fn shelf(world: &mut crate::world::World, registry: &horizon_world::registry::BlockRegistry) {
let grass = registry.default_state("minecraft:grass_block").unwrap();
for cx in -3..3 {
for cz in -3..3 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -8..=8 {
for z in -8..=8 {
world.set(x, 63, z, grass).unwrap();
}
}
}
/// `finalizeSpawnChildFromBreeding` makes the experience draw only inside
/// the `mob_drops` check, from the breeding parent's own random: the
/// breed goal pushes one 1-7 orb with the rule on, none with it off, and
/// the pair breeds either way.
#[test]
fn the_breed_xp_follows_the_mob_drops_rule() {
let Some(on) = llama_events(true) else {
return;
};
let Some(off) = llama_events(false) else {
return;
};
let orb = |events: &[MobEvent]| {
events.iter().find_map(|e| match e {
MobEvent::Experience { amount, .. } => Some(*amount),
_ => None,
})
};
assert!(
orb(&on).is_some_and(|a| (1..=7).contains(&a)),
"mob_drops on: one orb of 1-7, got {:?}",
orb(&on)
);
assert!(orb(&off).is_none(), "mob_drops off: no experience draw");
assert!(
on.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
"mob_drops on: the pair breeds"
);
assert!(
off.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
"mob_drops off: the pair still breeds"
);
}
}
+14 -1
View File
@@ -55,9 +55,14 @@ pub struct RideTarget {
pub aimed: Option<[i32; 3]>,
}
/// What a piglin's or hoglin's brain remembers of its surroundings and of
/// what it retreats from. None of it is saved: these memories have no codec.
/// what it retreats from. Most have no codec; live guarded anger below is saved.
#[derive(Clone, Debug, Default)]
pub struct Memories {
/// The guarded-block event owns the saved ANGRY_AT entry once set.
/// Outer None leaves unmodeled loaded memory alone; Some(None) has no
/// authoritative actor identity and clears stale raw data instead of inventing one.
/// Its remaining TTL is the existing `Extra::anger` counter.
pub guarded_angry_at: Option<Option<[i32; 4]>>,
/// `AVOID_TARGET`.
pub avoid: Option<AvoidTarget>,
/// `RIDE_TARGET`.
@@ -105,6 +110,14 @@ pub struct Memories {
/// `ADMIRING_DISABLED`: the ticks left.
pub admire_disabled: i32,
}
/// Existing guarded-block anger, with the causal player's authoritative UUID.
/// This adds identity only; targeting and countdown keep their existing policy.
pub fn set_guarded_anger_target(m: &mut Mob, player: Option<[i32; 4]>) {
m.extra.anger = 600;
m.ai.target = Some(Who::Player);
m.extra.piglin.guarded_angry_at = Some(player);
}
impl Memories {
/// `StartAdmiringItemIfSeen.create(119)` (core activity) and the admire
/// activity's `StopAdmiringIfItemTooFarAway(9)` and
+15 -27
View File
@@ -116,9 +116,9 @@ pub fn start_sleeping(m: &mut Mob, p: [i32; 3], w: &MobWorld, events: &mut Vec<M
);
true
}
/// `LivingEntity.stopSleeping` with `BedBlock.findStandUpPosition`: the bed
/// is no longer `occupied`, and the sleeper stands beside it where there is
/// room, else on top of it.
/// `LivingEntity.stopSleeping`: the bed is no longer `occupied`, and the
/// sleeper stands at `AbstractBedBlock.findStandUpPosition` for its yaw,
/// else 0.1 above the block over the bed.
pub fn stop_sleeping(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.ints[1] = 0;
let bed = m
@@ -134,30 +134,18 @@ pub fn stop_sleeping(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
pos: p,
occupied: false,
});
// Keep the existing stand-up search and fallback position.
let mut stand = m.position;
stand.y = f64::from(p[1]) + 0.5625 + 0.125;
for (dx, dz) in [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(-1, -1),
(1, -1),
(-1, 1),
] {
let (x, y, z) = (p[0] + dx, p[1], p[2] + dz);
let free = |y| w.data.state(w.world.block(x, y, z)).top <= 0.0;
if free(y) && free(y + 1) && w.solid(x, y - 1, z) {
stand = horizon_core::geometry::Vec3::new(
f64::from(x) + 0.5,
f64::from(y),
f64::from(z) + 0.5,
);
break;
}
}
let level = crate::stand_up::StandUpLevel {
world: w.world,
registry: w.registry,
facts: w.facts,
collision: w.collision,
block_tags: w.block_tags,
border: w.border,
};
let forward = crate::stand_up::bed_forward(w.prop(p[0], p[1], p[2], "facing"));
let stand = level
.bed_stand_up(crate::stand_up::StandUpType::VILLAGER, p, forward, m.yaw)
.unwrap_or_else(|| crate::stand_up::stand_up_fallback(p));
set_wake_pose(m, p, stand);
}
/// Rotation part of `LivingEntity.stopSleeping`, after choosing where to stand.
@@ -89,6 +89,7 @@ fn mod_farmland_is_a_secondary_site_and_farming_target() {
resources(),
&[],
true,
true,
|world, reg| {
put(world, 0, 0, reg.default_state("fieldtest:soil").unwrap());
},
@@ -126,6 +127,7 @@ fn mod_crops_are_harvested_only_at_their_vanilla_templates_max_age() {
resources(),
&[],
true,
true,
|world, reg| {
for (x, block, age) in [
(0, "grain", "6"),
@@ -187,6 +189,7 @@ fn farming_uses_the_first_tagged_block_item_and_consumes_one_seed() {
resources(),
&[],
true,
true,
|world, reg| {
for x in 0..=1 {
put(world, x, 0, reg.default_state("fieldtest:soil").unwrap());
+145 -10
View File
@@ -222,23 +222,68 @@ pub fn client_tick(m: &mut Mob, find: impl Fn(i32) -> Option<([f64; 3], f64)>) {
m.anim.vo = m.anim.v;
turn_heads(m, find);
}
/// `WitherBoss.aiStep` after `super.aiStep()`: the side heads turn, smoke
/// rises from the heads (the particle offsets are drawn on the server too).
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
/// `WitherBoss.aiStep` after `super.aiStep()`: the side heads turn, then
/// smoke and (while powered) the dim entity-effect particles at each head.
pub fn ai_step(
m: &mut Mob,
w: &MobWorld,
level_random: &mut impl RandomSource,
events: &mut Vec<MobEvent>,
) {
turn_heads(m, |id| target_pos(w, id).map(|(p, eye, _)| (p, eye)));
head_particles(m, level_random, events);
}
/// `WitherBoss.getHeadX/Y/Z`: the centre head is 3 blocks up, the side heads
/// 2.2 up and 1.3 out, all scaled.
fn particle_head(m: &Mob, index: usize, scale: f64) -> [f64; 3] {
let p = m.position;
if index == 0 {
return [p.x, p.y + 3.0 * scale, p.z];
}
let a = (m.body_yaw + 180.0 * (index as f32 - 1.0)).to_radians();
[
p.x + f64::from(a.mcos()) * 1.3 * scale,
p.y + 2.2 * scale,
p.z + f64::from(a.msin()) * 1.3 * scale,
]
}
/// `WitherBoss.aiStep`'s head particles. Smoke is one per head, offset by
/// the wither's gaussian times `0.3 * getScale()`. While powered, the level
/// random picks one tick in four for an entity-effect particle `(0.7, 0.7,
/// 0.5)` at a fresh offset. A wither that is still charging then adds the
/// `(0.7, 0.7, 0.9)` cloud. `Level.addParticle` on the server draws nothing,
/// so a window following a server runs this on its own copy too.
pub fn head_particles(
m: &mut Mob,
level_random: &mut impl RandomSource,
events: &mut Vec<MobEvent>,
) {
let powered = powered(m);
let scale = super::riders::scale(m);
let radius = 0.3 * scale;
for i in 0..3 {
let h = head_pos(m, i);
for _ in 0..3 {
m.random.next_gaussian();
}
let h = particle_head(m, i, scale);
let at = [
h[0] + m.random.next_gaussian() * radius,
h[1] + m.random.next_gaussian() * radius,
h[2] + m.random.next_gaussian() * radius,
];
events.push(MobEvent::Particles {
kind: "smoke",
at: h,
at,
count: 0,
});
// The entity-effect particles roll the level random, not the wither's.
let _ = powered;
if powered && level_random.next_int_bound(4).unwrap_or(1) == 0 {
let at = [
h[0] + m.random.next_gaussian() * radius,
h[1] + m.random.next_gaussian() * radius,
h[2] + m.random.next_gaussian() * radius,
];
events.push(MobEvent::ParticleColored {
at,
color: [0.7, 0.7, 0.5],
});
}
}
if m.extra.counter > 0 {
charging_particles(m, events);
@@ -619,4 +664,94 @@ mod tests {
charging_particles(&mut m, &mut events);
assert!(events.is_empty());
}
/// `WitherBoss.aiStep`: a powered wither (at or below half health) adds an
/// entity-effect particle `(0.7, 0.7, 0.5)` at a head when the level
/// random's `nextInt(4)` is 0. A healthy wither does not roll that random.
#[test]
fn a_powered_wither_sheds_dim_effect_particles_one_tick_in_four() {
use crate::mobs::sensor_scan_tests::kind;
use horizon_core::geometry::trig::Mth;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:wither".into();
k.species = Species::Wither;
let mut healthy = Mob::new(1, kind.clone(), Vec3::new(4.0, 64.0, -2.0), 0.0);
healthy.attrs.max_health = 300.0;
healthy.health = 151.0;
let mut untouched = LegacyRandom::new(7);
let mut level = LegacyRandom::new(7);
let mut events = Vec::new();
head_particles(&mut healthy, &mut level, &mut events);
assert!(events.iter().all(|e| !matches!(
e,
MobEvent::ParticleColored { color, .. } if *color == [0.7, 0.7, 0.5]
)));
assert_eq!(level.next_int(), untouched.next_int());
let mut powered = Mob::new(1, kind, Vec3::new(4.0, 64.0, -2.0), 0.0);
powered.attrs.max_health = 300.0;
powered.health = 150.0;
powered.body_yaw = 0.0;
// The level random's three nextInt(4) draws, taken from a copy so the
// expected heads are not read back out of the wither.
let mut rolls_from = LegacyRandom::new(1);
let rolls = [
rolls_from.next_int_bound(4).unwrap(),
rolls_from.next_int_bound(4).unwrap(),
rolls_from.next_int_bound(4).unwrap(),
];
let mut level = LegacyRandom::new(1);
let mut probe = powered.random.clone();
let mut events = Vec::new();
head_particles(&mut powered, &mut level, &mut events);
let radius = 0.3;
let mut smoke = Vec::new();
let mut effect = Vec::new();
for i in 0..3 {
let h = if i == 0 {
[4.0, 67.0, -2.0]
} else {
let a = (180.0 * (i as f32 - 1.0)).to_radians();
[
4.0 + f64::from(a.mcos()) * 1.3,
64.0 + 2.2,
-2.0 + f64::from(a.msin()) * 1.3,
]
};
smoke.push([
h[0] + probe.next_gaussian() * radius,
h[1] + probe.next_gaussian() * radius,
h[2] + probe.next_gaussian() * radius,
]);
if rolls[i] == 0 {
effect.push([
h[0] + probe.next_gaussian() * radius,
h[1] + probe.next_gaussian() * radius,
h[2] + probe.next_gaussian() * radius,
]);
}
}
let got_smoke: Vec<[f64; 3]> = events
.iter()
.filter_map(|e| match e {
MobEvent::Particles { kind, at, count: 0 } if *kind == "smoke" => Some(*at),
_ => None,
})
.collect();
let got_effect: Vec<[f64; 3]> = events
.iter()
.filter_map(|e| match e {
MobEvent::ParticleColored { at, color } if *color == [0.7, 0.7, 0.5] => Some(*at),
_ => None,
})
.collect();
assert_eq!(got_smoke, smoke);
assert_eq!(got_effect, effect);
assert_eq!(
effect.len(),
rolls.iter().filter(|roll| **roll == 0).count()
);
}
}
+5 -3
View File
@@ -431,20 +431,21 @@ pub(crate) fn with_built_test_world(
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
) -> bool {
with_built_test_world_rules(others, true, build, f)
with_built_test_world_rules(others, true, true, build, f)
}
/// `with_built_test_world` with the `mob_griefing` game rule the world reports.
#[cfg(test)]
pub(crate) fn with_built_test_world_rules(
others: &[Seen],
mob_griefing: bool,
mob_drops: bool,
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
) -> bool {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
with_resource_test_world(&root, others, mob_griefing, build, f)
with_resource_test_world(&root, others, mob_griefing, mob_drops, build, f)
}
/// The same mob world loaded from a content mod's resource root.
#[cfg(test)]
@@ -452,6 +453,7 @@ pub(crate) fn with_resource_test_world(
root: &std::path::Path,
others: &[Seen],
mob_griefing: bool,
mob_drops: bool,
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
) -> bool {
@@ -510,7 +512,7 @@ pub(crate) fn with_resource_test_world(
biomes: &biomes,
mob_griefing,
universal_anger: false,
mob_drops: true,
mob_drops,
max_entity_cramming: 24,
difficulty: super::spawning::LocalDifficulty::new(2, 18000, 0, 0.0),
end: false,
+67 -16
View File
@@ -366,6 +366,18 @@ impl crate::entity_rules::EntityRules for Projectile {
}
}
}
/// `AbstractHurtingProjectile.applyInertia` / `ThrowableProjectile.applyInertia`:
/// four bubble particles at `position - movement * 0.25`, moving with `movement`.
pub fn water_bubble_at(position: [f64; 3], movement: [f64; 3]) -> ([f64; 3], [f64; 3]) {
(
[
position[0] - movement[0] * 0.25,
position[1] - movement[1] * 0.25,
position[2] - movement[2] * 0.25,
],
movement,
)
}
impl Projectile {
/// `Entity.getBoundingBox`: its type's square box standing on its position.
pub fn bounds(&self) -> Aabb {
@@ -627,6 +639,21 @@ impl Projectile {
self.position.y + self.kind.eye_height()
}
pub fn water_bubbles(&self) -> Option<([f64; 3], [f64; 3])> {
if !self.touching_water {
return None;
}
// ThrowableProjectile.tick applies gravity, then applyInertia reads
// that movement. AbstractHurtingProjectile.applyInertia reads the
// movement it already has: fireballs, wither skulls and wind charges
// have no gravity step before the bubbles.
let hurting = matches!(
self.kind,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. }
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
);
let throwable = matches!(
self.kind,
ProjectileKind::Snowball
@@ -636,22 +663,19 @@ impl Projectile {
| ProjectileKind::ExperienceBottle
| ProjectileKind::Thrown(_)
);
if !throwable || !self.touching_water {
return None;
}
let v = Vec3::new(
self.velocity.x,
self.velocity.y - self.get_gravity(),
self.velocity.z,
);
Some((
let movement = if hurting {
[self.velocity.x, self.velocity.y, self.velocity.z]
} else if throwable {
[
self.position.x - v.x * 0.25,
self.position.y - v.y * 0.25,
self.position.z - v.z * 0.25,
],
[v.x, v.y, v.z],
))
self.velocity.x,
self.velocity.y - self.get_gravity(),
self.velocity.z,
]
} else {
return None;
};
let at = [self.position.x, self.position.y, self.position.z];
Some(water_bubble_at(at, movement))
}
/// Projectile.shoot: normalized direction plus triangular noise, times speed.
pub fn shoot(
@@ -1418,7 +1442,6 @@ mod tests {
critical: false,
effect: None,
},
ProjectileKind::SmallFireball,
ProjectileKind::LlamaSpit,
] {
let mut other = Projectile::new(kind, Vec3::new(0.5, 60.0, 0.5), None);
@@ -1426,6 +1449,34 @@ mod tests {
assert_eq!(other.water_bubbles(), None);
}
}
/// `AbstractHurtingProjectile.applyInertia`: four bubbles on the motion
/// the projectile already has, with no gravity taken off first.
#[test]
fn hurting_projectiles_in_water_bubble_along_their_motion() {
let expected_at = [0.25, 59.875, 1.0];
let expected_speed = [1.0, 0.5, -2.0];
for kind in [
ProjectileKind::SmallFireball,
ProjectileKind::LargeFireball { power: 1 },
ProjectileKind::WitherSkull { dangerous: true },
ProjectileKind::WindCharge,
ProjectileKind::BreezeWindCharge,
] {
let label = format!("{kind:?}");
let mut p = Projectile::new(kind, Vec3::new(0.5, 60.0, 0.5), None);
p.velocity = Vec3::new(1.0, 0.5, -2.0);
assert_eq!(p.water_bubbles(), None, "{label} in air leaves no trail");
p.touching_water = true;
let (at, speed) = p.water_bubbles().expect("bubbles in water");
for i in 0..3 {
assert!((at[i] - expected_at[i]).abs() < 1e-12, "{label} {at:?}");
assert!(
(speed[i] - expected_speed[i]).abs() < 1e-12,
"{label} {speed:?}"
);
}
}
}
/// A wall of blocks across x = 1.
struct Wall(Vec<(Who, Aabb)>);
impl ProjectileWorld for Wall {
+492 -25
View File
@@ -12,7 +12,8 @@ pub mod client;
use crate::hud::{HudAtlas, HudBatch, rgba};
use crate::text::Text;
use client::{
PendingInviteDto, RealmAddress, RealmServer, RealmsClient, RealmsError, Subscription,
PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmsClient, RealmsError,
Subscription,
};
use horizon_client_render::auth::{self, AuthSession};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -32,6 +33,7 @@ const RED: u32 = 0xFF55_55FF;
const INFO: u32 = 0xFFBE_887D;
const REALMS_INVITE_CODE: crate::clipboard::FieldId = ("realms.invite_code", 0);
const REALMS_PLAYER_NAME: crate::clipboard::FieldId = ("realms.player_name", 0);
/// `CommonLinks.BUY_REALMS`.
const BUY_REALMS: &str = "https://aka.ms/BuyJavaRealms";
@@ -254,6 +256,11 @@ pub enum Popup {
ResourcePack { pack: RealmsPack },
/// `RealmsSubscriptionTab`'s delete question (only offered once the Realm has expired).
Delete(RealmServer),
/// `RealmsPlayersTab.PlayerEntry.uninvite`'s confirm (`RealmsConfirmScreen`).
Uninvite {
server: RealmServer,
player: PlayerInfo,
},
}
#[derive(Clone, Debug)]
@@ -274,12 +281,27 @@ pub enum SubScreen {
/// `RealmsClient.subscriptionFor` on a worker, then its answer (the subscription tab).
subscription: Option<Result<Subscription, RealmsError>>,
subscription_job: Option<Job<Result<Subscription, RealmsError>>>,
/// `RealmsClient.getOwnRealm` on a worker, then its answer (the slots and players).
detail: Option<Result<RealmServer, RealmsError>>,
detail_job: Option<Job<Result<RealmServer, RealmsError>>>,
/// `RealmsClient.deleteRealm` on a worker, from the subscription tab's question.
deleting: Option<Job<Result<(), RealmsError>>>,
/// `RealmsClient.op`/`deop`/`uninvite` on a worker, from the players tab.
member_job: Option<Job<Result<(), RealmsError>>>,
error: Option<String>,
/// `RealmsClient.open`/`close` on a worker.
job: Option<Job<Result<(), RealmsError>>>,
},
/// `RealmsInviteScreen`: a name to invite to the Realm.
Invite {
server: RealmServer,
/// The invited players as the tab had them (the duplicate check).
players: Vec<PlayerInfo>,
name: String,
/// `RealmsClient.invite` on a worker, then the players.
job: Option<Job<Result<Vec<PlayerInfo>, RealmsError>>>,
error: Option<String>,
},
AddRealm,
Popup(Popup),
/// `RealmsLongRunningMcoTaskScreen` running `GetServerDetailsTask`: the join request is on
@@ -412,6 +434,7 @@ impl Screen {
}
}
let mut refresh = false;
let mut refetch_detail = false;
match &mut self.subscreen {
SubScreen::JoinCode { error, job, .. } => {
if let Some(result) = job.as_ref().and_then(take) {
@@ -437,7 +460,10 @@ impl Screen {
server,
subscription,
subscription_job,
detail,
detail_job,
deleting,
member_job,
error,
job,
..
@@ -457,6 +483,18 @@ impl Screen {
*subscription_job = None;
*subscription = Some(result);
}
if let Some(result) = detail_job.as_ref().and_then(take) {
*detail_job = None;
*detail = Some(result);
}
if let Some(result) = member_job.as_ref().and_then(take) {
*member_job = None;
match result {
// `updateOps`: the list reads again with the new operators.
Ok(()) => refetch_detail = true,
Err(e) => *error = Some(e.text()),
}
}
if let Some(result) = deleting.as_ref().and_then(take) {
*deleting = None;
match result {
@@ -470,9 +508,41 @@ impl Screen {
}
}
}
SubScreen::Invite {
server, job, error, ..
} => {
if let Some(result) = job.as_ref().and_then(take) {
*job = None;
match result {
Ok(_players) => {
// `invitePlayer` succeeded: back to the players tab, which
// reads the list again.
let server = server.clone();
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Players,
subscription: None,
subscription_job: None,
detail: None,
detail_job: None,
deleting: None,
member_job: None,
error: None,
job: None,
};
self.start_configure_subscription();
self.start_configure_detail();
}
Err(e) => *error = Some(e.text()),
}
}
}
SubScreen::Connecting { .. } => return self.poll_join(),
_ => {}
}
if refetch_detail {
self.start_configure_detail();
}
if refresh {
self.refresh();
}
@@ -537,15 +607,18 @@ impl Screen {
}
pub fn focused_box(&self, (w, _h): (f32, f32)) -> Option<[f32; 4]> {
matches!(self.subscreen, SubScreen::JoinCode { .. })
.then(|| [(w / 2.0).floor() - 100.0, 64.0, 200.0, 20.0])
match &self.subscreen {
SubScreen::JoinCode { .. } => Some([(w / 2.0).floor() - 100.0, 64.0, 200.0, 20.0]),
SubScreen::Invite { .. } => Some([(w / 2.0).floor() - 100.0, 62.0, 200.0, 20.0]),
_ => None,
}
}
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
if let SubScreen::JoinCode { ref mut code, .. } = self.subscreen {
Some((REALMS_INVITE_CODE, code, 128))
} else {
None
match &mut self.subscreen {
SubScreen::JoinCode { code, .. } => Some((REALMS_INVITE_CODE, code, 128)),
SubScreen::Invite { name, .. } => Some((REALMS_PLAYER_NAME, name, 16)),
_ => None,
}
}
@@ -621,6 +694,84 @@ impl Screen {
}
}
/// `RealmsConfigureWorldScreen.init`'s `fetchServerData`: the slots and players, on a
/// worker.
fn start_configure_detail(&mut self) {
let SubScreen::Configure {
server, detail_job, ..
} = &mut self.subscreen
else {
return;
};
if detail_job.is_some() {
return;
}
if let Some(client) = client() {
let id = server.id;
*detail_job = Some(spawn(move || client.get_own_realm(id)));
}
}
/// `RealmsInviteScreen.onInvite`: a blank name or one already invited is refused
/// before any request; the rest goes to `RealmsClient.invite` on a worker.
fn invite_clicked(&mut self) {
let (name, id, already) = match &self.subscreen {
SubScreen::Invite {
server,
players,
name,
job,
..
} if job.is_none() => (
name.trim().to_owned(),
server.id,
players
.iter()
.any(|p| p.name.eq_ignore_ascii_case(name.trim())),
),
_ => return,
};
let refusal = if name.is_empty() {
Some("mco.configure.world.players.error")
} else if already {
Some("mco.configure.world.players.invite.duplicate")
} else {
None
};
if let SubScreen::Invite { job, error, .. } = &mut self.subscreen {
if let Some(key) = refusal {
*error = Some(crate::lang::get(key));
return;
}
if let Some(client) = client() {
*error = None;
*job = Some(spawn(move || client.invite_player(id, &name)));
}
}
}
/// `RealmsInviteScreen.onClose`: back to the players tab.
fn back_to_players(&mut self) {
let SubScreen::Invite { server, .. } = &self.subscreen else {
return;
};
let server = server.clone();
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Players,
subscription: None,
subscription_job: None,
detail: None,
detail_job: None,
deleting: None,
member_job: None,
error: None,
job: None,
};
self.start_configure_subscription();
self.start_configure_detail();
}
pub fn click(&mut self, hit: Option<u8>, x: f32, y: f32, (w, h): (f32, f32)) -> Outcome {
match hit {
Some(1) => self.play_selected(),
@@ -632,11 +783,15 @@ impl Screen {
tab: ConfigureTab::Worlds,
subscription: None,
subscription_job: None,
detail: None,
detail_job: None,
deleting: None,
member_job: None,
error: None,
job: None,
};
self.start_configure_subscription();
self.start_configure_detail();
}
Outcome::None
}
@@ -737,6 +892,36 @@ impl Screen {
}
Outcome::None
}
Some(67) => {
if let SubScreen::Configure { server, tab, .. } = &self.subscreen
&& *tab == ConfigureTab::Players
{
let players = match &self.subscreen {
SubScreen::Configure {
detail: Some(Ok(detail)),
..
} => detail.players.clone(),
_ => Vec::new(),
};
self.subscreen = SubScreen::Invite {
server: server.clone(),
players,
name: String::new(),
job: None,
error: None,
};
}
Outcome::None
}
// RealmsInviteScreen: Invite (80) and Back (81).
Some(80) => {
self.invite_clicked();
Outcome::None
}
Some(81) => {
self.back_to_players();
Outcome::None
}
Some(66) => match (&self.subscreen, session()) {
(SubScreen::Configure { server, .. }, Some(s)) => {
Outcome::OpenUri(extend_link(server, s.uuid))
@@ -746,22 +931,86 @@ impl Screen {
Some(40) => Outcome::OpenUri(BUY_REALMS.to_owned()),
Some(50) => self.popup_continue(),
Some(btn @ 100..=200) => {
if let SubScreen::PendingInvites { invites, job } = &mut self.subscreen
&& job.is_none()
&& let Some(Ok(list)) = invites
&& let Some(invite) = list.get(usize::from((btn - 100) / 2))
&& let Some(client) = client()
{
let accept = (btn - 100) % 2 == 0;
let id = invite.invitation_id.clone();
*job = Some(spawn(move || {
let done = if accept {
client.accept_invite(&id)
} else {
client.reject_invite(&id)
// The button's meaning: a pending invite's answer, or a player row's
// Op/Deop/Remove (`RealmsPlayersTab.PlayerEntry`).
enum Row {
Nothing,
Pending(bool, String),
Member(bool, i64, String),
Uninvite(RealmServer, PlayerInfo),
}
let row = match &self.subscreen {
SubScreen::PendingInvites { invites, job } if job.is_none() => {
match invites.as_ref().and_then(|i| i.as_ref().ok()) {
Some(list) => match list.get(usize::from((btn - 100) / 2)) {
Some(invite) => {
Row::Pending((btn - 100) % 2 == 0, invite.invitation_id.clone())
}
None => Row::Nothing,
},
None => Row::Nothing,
}
}
SubScreen::Configure {
server,
tab,
detail,
member_job,
..
} if *tab == ConfigureTab::Players && member_job.is_none() => {
let index = usize::from((btn - 100) / 3);
let which = (btn - 100) % 3;
let player = match detail {
Some(Ok(detail)) => detail.players.get(index).cloned(),
_ => None,
};
done.and_then(|()| client.pending_invites())
}));
match player {
Some(player) if which < 2 => match &player.uuid {
Some(uuid) if !uuid.is_empty() => {
Row::Member(which == 0, server.id, uuid.clone())
}
_ => Row::Nothing,
},
Some(player) => Row::Uninvite(server.clone(), player),
None => Row::Nothing,
}
}
_ => Row::Nothing,
};
match row {
Row::Nothing => {}
Row::Pending(accept, id) => {
if let SubScreen::PendingInvites { job, .. } = &mut self.subscreen
&& job.is_none()
&& let Some(client) = client()
{
*job = Some(spawn(move || {
let done = if accept {
client.accept_invite(&id)
} else {
client.reject_invite(&id)
};
done.and_then(|()| client.pending_invites())
}));
}
}
Row::Member(op, realm_id, uuid) => {
if let SubScreen::Configure { member_job, .. } = &mut self.subscreen
&& member_job.is_none()
&& let Some(client) = client()
{
*member_job = Some(spawn(move || {
if op {
client.op_player(realm_id, &uuid).map(drop)
} else {
client.deop_player(realm_id, &uuid).map(drop)
}
}));
}
}
Row::Uninvite(server, player) => {
self.subscreen = SubScreen::Popup(Popup::Uninvite { server, player });
}
}
Outcome::None
}
@@ -819,6 +1068,29 @@ impl Screen {
}
Outcome::Connect(address)
}
// RealmsPlayersTab.PlayerEntry.uninvite, answered: the player goes and the
// list reads again.
Popup::Uninvite { server, player } => {
let Some(client) = client() else {
return Outcome::None;
};
let (id, uuid) = (server.id, player.uuid.clone().unwrap_or_default());
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Players,
subscription: None,
subscription_job: None,
detail: None,
detail_job: None,
deleting: None,
member_job: Some(spawn(move || client.uninvite_player(id, &uuid))),
error: None,
job: None,
};
self.start_configure_subscription();
self.start_configure_detail();
Outcome::None
}
// RealmsSubscriptionTab.deleteRealm: the question was asked over the
// configuration screen, so it answers there; the list returns once the
// Realm is gone.
@@ -832,12 +1104,16 @@ impl Screen {
tab: ConfigureTab::Subscription,
subscription: None,
subscription_job: None,
detail: None,
detail_job: None,
deleting: Some(spawn(move || client.delete_realm(id))),
member_job: None,
error: None,
job: None,
};
// A failed delete stays here and the tab reads again.
self.start_configure_subscription();
self.start_configure_detail();
Outcome::None
}
}
@@ -939,7 +1215,9 @@ impl Screen {
server,
tab,
subscription,
detail,
deleting,
member_job,
job,
..
} => {
@@ -982,6 +1260,58 @@ impl Screen {
None,
));
}
// RealmsPlayersTab: a row's Op/Deop/Remove, then Invite Player.
if *tab == ConfigureTab::Players
&& let Some(Ok(detail)) = detail
{
let rx = ((w - ROW_WIDTH) / 2.0).floor();
for (i, player) in detail.players.iter().enumerate() {
let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT;
if row_y + ROW_HEIGHT > h - 48.0 {
break;
}
let id = 100 + i as u8 * 3;
let width = (ROW_WIDTH - 140.0).max(40.0) / 3.0;
// The vanilla buttons carry the sprite; these say what they do in
// the same three translations (`invites.*.tooltip`).
for (n, key) in [
(0u8, "mco.configure.world.invites.ops.tooltip"),
(1, "mco.configure.world.invites.normal.tooltip"),
(2, "mco.configure.world.invites.remove.tooltip"),
] {
buttons.push((
rx + ROW_WIDTH - 132.0 + n as f32 * width,
row_y + 8.0,
width - 2.0,
t(key),
player.uuid.is_some() && member_job.is_none(),
id + n,
None,
));
}
}
buttons.push((
cx - 100.0,
h - 60.0,
200.0,
t("mco.configure.world.buttons.invite"),
true,
67,
None,
));
}
}
SubScreen::Invite { job, .. } => {
buttons.push((
cx - 100.0,
mid + 10.0,
200.0,
t("mco.configure.world.buttons.invite"),
job.is_none(),
80,
None,
));
buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 81, None));
}
SubScreen::AddRealm => {
buttons.push((
@@ -1252,6 +1582,7 @@ impl Screen {
server,
tab,
subscription,
detail,
deleting,
error,
job,
@@ -1382,8 +1713,49 @@ impl Screen {
u32::MAX,
),
},
// The other tabs land in follow-ups; vanilla shows this while its own
// fetches run.
ConfigureTab::Players => match detail {
Some(Ok(detail)) => {
let invited = crate::lang::tr(
"mco.configure.world.invited.number",
&[&detail.players.len() as &dyn std::fmt::Display],
);
centered(batch, atlas, &invited, cx, 26.0, u32::MAX);
let rx = ((w - ROW_WIDTH) / 2.0).floor();
for (i, player) in detail.players.iter().enumerate() {
let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT;
if row_y + ROW_HEIGHT > h - 48.0 {
break;
}
let name = if player.operator {
format!(
"{} [{}]",
player.name,
t("mco.configure.world.invites.ops.tooltip")
)
} else {
player.name.clone()
};
batch.text(atlas, &name, rx + 4.0, row_y + 14.0, u32::MAX, true);
}
}
Some(Err(e)) => {
for (i, line) in
wrap_lines(&e.text(), atlas, w - 40.0).iter().enumerate()
{
centered(batch, atlas, line, cx, 60.0 + i as f32 * 10.0, RED);
}
}
None => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
},
// The Worlds and Settings tabs land in follow-ups; vanilla shows this
// while its own fetches run.
_ => centered(
batch,
atlas,
@@ -1413,6 +1785,46 @@ impl Screen {
}
}
}
SubScreen::Invite {
name, job, error, ..
} => {
// `RealmsInviteScreen`: the title, the name field and the message below
// the Invite button.
centered(
batch,
atlas,
&t("mco.configure.world.buttons.invite"),
cx,
20.0,
u32::MAX,
);
batch.text(
atlas,
&t("mco.configure.world.invite.profile.name"),
cx - 100.0,
50.0,
GREY,
true,
);
caret.draw(
batch,
atlas,
(REALMS_PLAYER_NAME, name, 16),
[cx - 100.0, 62.0, 200.0, 20.0],
true,
focused_at,
);
let status = if job.is_some() {
Some((t("mco.configure.world.players.inviting"), u32::MAX))
} else {
error.clone().map(|e| (e, RED))
};
if let Some((text, color)) = status {
for (i, line) in wrap_lines(&text, atlas, 200.0).iter().enumerate() {
centered(batch, atlas, line, cx, 88.0 + i as f32 * 10.0, color);
}
}
}
SubScreen::AddRealm => {
centered(batch, atlas, &t("mco.title"), cx, 24.0, u32::MAX);
let lines = wrap_lines(&t("mco.selectServer.popup"), atlas, CONTENT_WIDTH);
@@ -1433,10 +1845,18 @@ impl Screen {
Popup::Leave(_) => t("mco.configure.world.leave.question.line1"),
Popup::ResourcePack { .. } => t("mco.configure.world.resourcepack.question"),
Popup::Delete(_) => t("mco.configure.world.delete.question.line1"),
Popup::Uninvite { player, .. } => crate::lang::tr(
"mco.configure.world.uninvite.player",
&[&player.name as &dyn std::fmt::Display],
),
};
let lines = wrap_lines(&message, atlas, CONTENT_WIDTH);
let top = mid - 10.0 - 10.0 * lines.len() as f32;
centered(batch, atlas, &t("mco.info"), cx, top - 16.0, INFO);
let header = match popup {
Popup::Uninvite { .. } => t("mco.question"),
_ => t("mco.info"),
};
centered(batch, atlas, &header, cx, top - 16.0, INFO);
for (i, line) in lines.iter().enumerate() {
centered(batch, atlas, line, cx, top + i as f32 * 10.0, u32::MAX);
}
@@ -1815,6 +2235,53 @@ mod tests {
assert_eq!(enabled(15), Some(false), "invites");
}
/// `RealmsInviteScreen.onInvite`: a blank name and one already invited are refused
/// before any request.
#[test]
fn inviting_an_empty_or_duplicate_name_asks_nothing() {
let mut screen = Screen {
state: FetchState::Loaded(vec![server(7, None)]),
subscreen: SubScreen::Invite {
server: server(7, None),
players: vec![PlayerInfo {
name: "Alex".into(),
uuid: Some("01234567-89ab-cdef-0123-456789abcdef".into()),
..Default::default()
}],
name: " ".into(),
job: None,
error: None,
},
selected: Some(0),
joining: None,
leaving: None,
last_click: None,
};
screen.invite_clicked();
let SubScreen::Invite { error, job, .. } = &screen.subscreen else {
panic!("still the invite screen");
};
assert!(job.is_none(), "a blank name asks the service nothing");
assert_eq!(
error.as_deref(),
Some(crate::lang::get("mco.configure.world.players.error").as_str())
);
if let SubScreen::Invite { name, error, .. } = &mut screen.subscreen {
*name = "ALEX".into();
*error = None;
}
screen.invite_clicked();
let SubScreen::Invite { error, job, .. } = &screen.subscreen else {
panic!("still the invite screen");
};
assert!(job.is_none(), "a duplicate name asks the service nothing");
assert_eq!(
error.as_deref(),
Some(crate::lang::get("mco.configure.world.players.invite.duplicate").as_str())
);
}
#[test]
fn join_realm_button_navigates_to_join_code_screen() {
let mut screen = listed(vec![]);
@@ -64,6 +64,32 @@ pub struct RealmServer {
pub minigame_id: Option<i32>,
#[serde(default)]
pub minigame_image: Option<String>,
/// The invited players: only in the detail answer (`RealmsClient.getOwnRealm`).
#[serde(default, deserialize_with = "nullable")]
pub players: Vec<PlayerInfo>,
}
/// `PlayerInfo`: a Realm's invited player.
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlayerInfo {
#[serde(default, deserialize_with = "nullable")]
pub name: String,
#[serde(default)]
pub uuid: Option<String>,
#[serde(default, deserialize_with = "nullable")]
pub operator: bool,
#[serde(default, deserialize_with = "nullable")]
pub accepted: bool,
#[serde(default, deserialize_with = "nullable")]
pub online: bool,
}
/// `Ops`: the Realm's operators, by name (`RealmsClient.op`/`deop`).
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
pub struct Ops {
#[serde(default, deserialize_with = "nullable")]
pub ops: Vec<String>,
}
impl RealmServer {
@@ -427,6 +453,41 @@ impl RealmsClient {
Self::execute(self.request("DELETE", &url, READ_TIMEOUT), None).map(drop)
}
/// `RealmsClient.getOwnRealm`: the detail answer with the slots and the players.
pub fn get_own_realm(&self, realm_id: i64) -> Result<RealmServer, RealmsError> {
let url = self.url(&format!("worlds/{realm_id}"));
let json = Self::execute(self.request("GET", &url, READ_TIMEOUT), None)?;
serde_json::from_str(&json).map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.op`: `POST worlds/{id}/{uuid}` with an empty body, then the ops.
pub fn op_player(&self, realm_id: i64, uuid: &str) -> Result<Ops, RealmsError> {
let url = self.url(&format!("worlds/{realm_id}/{}", undashed(uuid)?));
let json = Self::execute(self.request("POST", &url, READ_TIMEOUT), Some(""))?;
serde_json::from_str(&json).map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.deop`: `DELETE worlds/{id}/{uuid}`, then the ops.
pub fn deop_player(&self, realm_id: i64, uuid: &str) -> Result<Ops, RealmsError> {
let url = self.url(&format!("worlds/{realm_id}/{}", undashed(uuid)?));
let json = Self::execute(self.request("DELETE", &url, READ_TIMEOUT), None)?;
serde_json::from_str(&json).map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.invite`: `POST worlds/{id}` with the player's name, then the players.
pub fn invite_player(&self, realm_id: i64, name: &str) -> Result<Vec<PlayerInfo>, RealmsError> {
let body = serde_json::json!({"name": name}).to_string();
let url = self.url(&format!("worlds/{realm_id}"));
let json = Self::execute(self.request("POST", &url, READ_TIMEOUT), Some(&body))?;
serde_json::from_str::<Vec<PlayerInfo>>(&json).map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.uninvite`: `DELETE worlds/{id}/invite/{uuid}`.
pub fn uninvite_player(&self, realm_id: i64, uuid: &str) -> Result<(), RealmsError> {
let url = self.url(&format!("worlds/{realm_id}/invite/{}", undashed(uuid)?));
Self::execute(self.request("DELETE", &url, READ_TIMEOUT), None).map(drop)
}
/// `RealmsClient.redeemInviteCode`: the invite-code service, with the token in
/// `x-mojangauth` as `frontendV2Request` sets it.
pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> {
@@ -458,6 +519,17 @@ fn path_segment(s: &str) -> Result<&str, RealmsError> {
}
}
/// `UndashedUuid.toString`: a player's UUID as 32 hex digits, the op/deop/uninvite path
/// segment. Anything else (a name with a `/`, a shortened id) would reach another path.
fn undashed(uuid: &str) -> Result<String, RealmsError> {
let hex: String = uuid.chars().filter(|c| *c != '-').collect();
if hex.len() == 32 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(hex)
} else {
Err(RealmsError::Parse)
}
}
#[cfg(test)]
mod tests {
use super::*;
+262
View File
@@ -0,0 +1,262 @@
//! Where an entity stands up beside a block: `AbstractBedBlock.findStandUpPosition`
//! and `DismountHelper.findSafeDismountLocation` for any entity type, shared by
//! players (waking up, respawning at a bed or anchor) and villagers waking up.
use std::collections::{HashMap, HashSet};
use crate::{
blocks::BlockFacts,
collisions::{SceneCollisions, no_collision_empty},
world::World,
};
use horizon_core::{
geometry::{Aabb, Axis, Vec3, VoxelShape, block_floor_height, border::BorderGeometry, trig},
position::BlockPos,
};
use horizon_world::{
block_collision::BlockCollisionRegistry,
collision_context::CollisionContext,
registry::{BlockRegistry, BlockStateId},
};
/// What `findSafeDismountLocation` reads of an `EntityType`: its
/// dimensions, whether it is the player, and `fireImmune`. No type that
/// stands up from a bed has an `immuneTo` block.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StandUpType {
pub width: f32,
pub height: f32,
pub player: bool,
pub fire_immune: bool,
}
impl StandUpType {
/// `EntityTypes.PLAYER`: 0.6 by 1.8.
pub const PLAYER: Self = Self {
width: 0.6,
height: 1.8,
player: true,
fire_immune: false,
};
/// `EntityTypes.VILLAGER`: 0.6 by 1.95.
pub const VILLAGER: Self = Self {
width: 0.6,
height: 1.95,
player: false,
fire_immune: false,
};
}
/// The level as the stand-up search reads it (a `CollisionGetter`).
pub struct StandUpLevel<'a, 'r> {
pub world: &'a World,
pub registry: &'a BlockRegistry,
pub facts: &'a BlockFacts,
pub collision: &'a BlockCollisionRegistry<'r>,
pub block_tags: &'a HashMap<String, HashSet<String>>,
/// The world border; None: none (every box is inside).
pub border: Option<&'a BorderGeometry>,
}
impl StandUpLevel<'_, '_> {
fn name(&self, p: [i32; 3]) -> &str {
&self.facts.get(self.world.block(p[0], p[1], p[2])).name
}
fn tagged(&self, tag: &str, block: &str) -> bool {
self.block_tags.get(tag).is_some_and(|s| s.contains(block))
}
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
self.registry
.state(state)
.and_then(|s| s.properties.get(key))
.map(String::as_str)
}
/// `BlockState.getCollisionShape(level, pos)` under the empty context.
fn collision_shape(&self, p: [i32; 3]) -> VoxelShape {
let mut scene = SceneCollisions {
world: self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape_with_context(
&mut scene,
self.world.block(p[0], p[1], p[2]),
BlockPos::new(p[0], p[1], p[2]),
&CollisionContext::empty(false),
)
.unwrap_or_else(|_| VoxelShape::empty())
}
/// `DismountHelper.nonClimbableShape`: no shape for climbable blocks
/// and open trapdoors.
pub fn non_climbable_shape(&self, p: [i32; 3]) -> VoxelShape {
let state = self.world.block(p[0], p[1], p[2]);
let name = self.name(p);
let open_trapdoor = name.ends_with("_trapdoor") && self.prop(state, "open") == Some("true");
if self.tagged("minecraft:climbable", name) || open_trapdoor {
return VoxelShape::empty();
}
self.collision_shape(p)
}
/// `EntityType.isBlockDangerous`: `NodeEvaluator.isBurningBlock` unless
/// fire immune, then the hurting plants and powder snow.
pub fn dangerous(&self, ty: StandUpType, p: [i32; 3]) -> bool {
let state = self.world.block(p[0], p[1], p[2]);
let name = self.name(p);
let burning = self.tagged("minecraft:fire", name)
|| matches!(
name,
"minecraft:lava" | "minecraft:magma_block" | "minecraft:lava_cauldron"
)
|| (matches!(name, "minecraft:campfire" | "minecraft:soul_campfire")
&& self.prop(state, "lit") == Some("true"));
(!ty.fire_immune && burning)
|| matches!(
name,
"minecraft:wither_rose"
| "minecraft:sweet_berry_bush"
| "minecraft:cactus"
| "minecraft:powder_snow"
)
}
/// `WorldBorder.isWithinBounds(AABB)`: the max corner less `1.0E-5F`.
fn within_border(&self, bounds: Aabb) -> bool {
let Some(border) = self.border else {
return true;
};
let inset = f64::from(1.0e-5f32);
let (lo_x, lo_z) = (bounds.min(Axis::X), bounds.min(Axis::Z));
let (hi_x, hi_z) = (bounds.max(Axis::X) - inset, bounds.max(Axis::Z) - inset);
border.is_within_bounds(lo_x, lo_z, 0.0) && border.is_within_bounds(hi_x, hi_z, 0.0)
}
/// `DismountHelper.findSafeDismountLocation`: standing in `p` on its
/// floor (or the block below's), with room for the type's box.
pub fn safe_dismount_location(
&self,
ty: StandUpType,
p: [i32; 3],
check_dangerous: bool,
) -> Option<Vec3> {
if check_dangerous && self.dangerous(ty, p) {
return None;
}
let below = [p[0], p[1] - 1, p[2]];
let shape = self.non_climbable_shape(p);
let below_shape = self.non_climbable_shape(below);
let floor = block_floor_height(&shape, || &below_shape);
// DismountHelper.isBlockFloorValid.
if floor.is_infinite() || floor >= 1.0 {
return None;
}
if check_dangerous && floor <= 0.0 && self.dangerous(ty, below) {
return None;
}
let position = Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]) + floor,
f64::from(p[2]) + 0.5,
);
// EntityDimensions.makeBoundingBox: the half width is a float.
let half = f64::from(ty.width / 2.0);
let bounds = Aabb::new(
Vec3::new(position.x - half, position.y, position.z - half),
Vec3::new(
position.x + half,
position.y + f64::from(ty.height),
position.z + half,
),
);
let mut scene = SceneCollisions {
world: self.world,
binding: self.collision,
subject: None,
};
if !no_collision_empty(&mut scene, bounds) {
return None;
}
let invalid = |q: [i32; 3]| self.tagged("minecraft:invalid_spawn_inside", self.name(q));
if ty.player && (invalid(p) || invalid([p[0], p[1] + 1, p[2]])) {
return None;
}
self.within_border(bounds).then_some(position)
}
/// `AbstractBedBlock.findStandUpPosition` for a bed whose block is at
/// `pos`, facing `forward` (a horizontal step), from `yaw`: beside the
/// bed on the side the sleeper faced away from, around it, then on it;
/// a safe spot first, else any spot (a bunk bed: around it, a block
/// lower, then on it).
pub fn bed_stand_up(
&self,
ty: StandUpType,
pos: [i32; 3],
forward: [i32; 3],
yaw: f32,
) -> Option<Vec3> {
// Direction.getClockWise.
let right = [-forward[2], 0, forward[0]];
// Direction.isFacingAngle.
let radians = yaw * 0.017_453_292;
let (dx, dz) = (
-trig::sin(f64::from(radians)),
trig::cos(f64::from(radians)),
);
let facing = right[0] as f32 * dx + right[2] as f32 * dz > 0.0;
let side = if facing {
[-right[0], 0, -right[2]]
} else {
right
};
let (f, s) = ([forward[0], forward[2]], [side[0], side[2]]);
// bedSurroundStandUpOffsets and bedAboveStandUpOffsets.
let surround = [
[s[0], s[1]],
[s[0] - f[0], s[1] - f[1]],
[s[0] - f[0] * 2, s[1] - f[1] * 2],
[-f[0] * 2, -f[1] * 2],
[-s[0] - f[0] * 2, -s[1] - f[1] * 2],
[-s[0] - f[0], -s[1] - f[1]],
[-s[0], -s[1]],
[-s[0] + f[0], -s[1] + f[1]],
[f[0], f[1]],
[s[0] + f[0], s[1] + f[1]],
];
let above = [[0, 0], [-f[0], -f[1]]];
// findStandUpPositionAtOffset.
let at = |base: [i32; 3], offsets: &[[i32; 2]], check: bool| {
offsets.iter().find_map(|o| {
self.safe_dismount_location(ty, [base[0] + o[0], base[1], base[2] + o[1]], check)
})
};
let below = [pos[0], pos[1] - 1, pos[2]];
// isBunkBed: a bed below.
if self.name(below).ends_with("_bed") {
return at(pos, &surround, true)
.or_else(|| at(below, &surround, true))
.or_else(|| at(pos, &above, true))
.or_else(|| at(pos, &surround, false))
.or_else(|| at(below, &surround, false))
.or_else(|| at(pos, &above, false));
}
let all: Vec<[i32; 2]> = surround.into_iter().chain(above).collect();
at(pos, &all, true).or_else(|| at(pos, &all, false))
}
}
/// `LivingEntity.stopSleeping`'s stand-up spot when `findStandUpPosition`
/// finds none: 0.1 above the block over the bed.
pub fn stand_up_fallback(bed: [i32; 3]) -> Vec3 {
Vec3::new(
f64::from(bed[0]) + 0.5,
f64::from(bed[1] + 1) + 0.1,
f64::from(bed[2]) + 0.5,
)
}
/// A bed's `facing` property as a step (north when unset).
pub fn bed_forward(facing: Option<&str>) -> [i32; 3] {
match facing {
Some("south") => [0, 0, 1],
Some("west") => [-1, 0, 0],
Some("east") => [1, 0, 0],
_ => [0, 0, -1],
}
}
+10 -4
View File
@@ -61,7 +61,7 @@ struct Builder {
threads: Vec<thread::JoinHandle<()>>,
}
impl Builder {
fn new(terrain: Arc<VanillaTerrain>) -> Self {
fn new(terrain: Arc<VanillaTerrain>, background: bool) -> Self {
let shared = Arc::new(Shared {
requests: Mutex::new(Requests::default()),
wake: Condvar::new(),
@@ -74,7 +74,7 @@ impl Builder {
// time while the far trees and structures came in).
let count = horizon_threads::worker_count().div_ceil(3).clamp(1, 12);
let threads = (0..count)
.map(|_| Self::spawn(terrain.clone(), shared.clone(), tx.clone()))
.map(|_| Self::spawn(terrain.clone(), shared.clone(), tx.clone(), background))
.collect();
Self {
shared,
@@ -86,11 +86,14 @@ impl Builder {
terrain: Arc<VanillaTerrain>,
worker: Arc<Shared>,
tx: mpsc::Sender<LodDetail>,
background: bool,
) -> thread::JoinHandle<()> {
thread::Builder::new()
.name("Far decoration".into())
.spawn(move || {
horizon_threads::idle_current();
if background {
horizon_threads::idle_current();
}
// A superflat world's far ring is its flat ground (`FlatTerrain::lod_surface`);
// a normal overworld rebuilt here would draw hills, oceans and trees.
if terrain.is_flat() {
@@ -509,7 +512,7 @@ pub(super) struct VanillaLodSampler {
impl VanillaLodSampler {
pub(super) fn new(generator: Arc<VanillaTerrain>) -> Self {
let worker = generator.generator.worker();
let builder = Some(Builder::new(generator.clone()));
let builder = Some(Builder::new(generator.clone(), true));
let vegetation = Arc::new(Vegetation::new(
generator.generator.data(),
&generator.biome_map,
@@ -830,6 +833,9 @@ mod tests {
1234,
)?);
let mut sampler = VanillaLodSampler::new(terrain.clone());
// This bounded test waits for decoration. Idle workers can be starved
// by other tests, so keep the real request/result path at normal priority.
sampler.builder = Some(Builder::new(terrain.clone(), false));
let mut fork = sampler.fork().expect("independent far sampler");
let direct = sampler.grid(-1024, 0, 32, 16);
assert_eq!(
+41 -1
View File
@@ -1,6 +1,7 @@
//! What the game asks the service about its own account: whether it owns a cosmetic (`GET
//! /v1/me`: no other player's cosmetics show for one that owns none, unless the player asks), and
//! the one setting it changes itself, "Show my cosmetics" (`PUT /v1/visible`).
//! the settings it changes itself, "Show my cosmetics" (`PUT /v1/visible`) and "Show me as a
//! Horizon player" (`POST /v1/presence`, also sent every `PRESENCE_EVERY` while on).
//!
//! The game signs in the way an online-mode server checks a player: the service hands out a
//! one-time server id, the game tells Mojang's session server it joins it (`join`, with the
@@ -39,6 +40,28 @@ impl Call for crate::Http {
}
}
/// How often a running game tells the service its player plays Horizon (the service's
/// `PRESENCE_EVERY`; it forgets them some hours after the last time).
pub const PRESENCE_EVERY: std::time::Duration = std::time::Duration::from_secs(3600);
/// "Show me as a Horizon player": listed, other Horizon players see the mark by `name`'s name for
/// a while (sent again every `PRESENCE_EVERY`); not listed, the service forgets it at once.
pub fn set_listed(
service: &impl Call,
name: &str,
join: impl FnOnce(&str) -> Result<(), String>,
listed: bool,
) -> Result<bool, String> {
let session = sign_in(service, name, join)?;
let answer = service.call(
"POST",
"/v1/presence",
Some(&session),
&json!({ "listed": listed }),
)?;
answer["listed"].as_bool().ok_or_else(|| "no answer".into())
}
/// A session of the service's own for the account `name`; `join` tells Mojang that account joins
/// the server id the service chose.
fn sign_in(
@@ -125,6 +148,7 @@ mod tests {
"/v1/auth/finish" => Err("HTTP 401".into()),
"/v1/visible" if s == Some("svc") => Ok(json!({"visible": body["visible"]})),
"/v1/me" if s == Some("svc") => Ok(json!({"owned": self.owned})),
"/v1/presence" if s == Some("svc") => Ok(json!({"listed": body["listed"]})),
_ => Err("HTTP 401".into()),
}
}
@@ -187,4 +211,20 @@ mod tests {
// A join Mojang never saw: the service refuses the id.
assert!(set_visible(&service, "Alex", |_| Ok(()), false).is_err());
}
#[test]
fn leaving_the_list_signs_in_the_same_way() {
let service = Service::default();
let join = |id: &str| {
*service.joined.borrow_mut() = Some(id.to_owned());
Ok(())
};
assert_eq!(set_listed(&service, "Alex", join, false), Ok(false));
let sent = service.sent.borrow();
assert_eq!(sent[2].1, "/v1/presence");
assert_eq!(
(sent[2].2.as_deref(), &sent[2].3),
(Some("svc"), &json!({"listed": false}))
);
}
}
+33 -7
View File
@@ -9,8 +9,9 @@
//!
//! Protocol (JSON over HTTPS):
//! - `POST /v1/worn` `{"players": ["<uuid hex>", …]}` (at most `BATCH`) answers
//! `{"ttl": seconds, "players": {"<uuid hex>": [{"id", "hash"}, …]}}`; a
//! player who wears nothing is left out.
//! `{"ttl": seconds, "players": {"<uuid hex>": [{"id", "hash"}, …]}, "horizon": ["<uuid hex>", …]}`;
//! a player who wears nothing is left out of `players`, and `horizon` lists the batch's players
//! who play Horizon (and let it say so).
//! - `GET /v1/items/<id>?hash=<hash>` answers the item file, which never
//! changes for a hash.
@@ -54,6 +55,8 @@ struct State {
struct Entry {
worn: Option<Worn>,
/// Plays Horizon, as the service last said.
horizon: bool,
expires: Instant,
seen: Instant,
}
@@ -163,6 +166,15 @@ impl Frame<'_> {
/// What `uuid` wears as last known (`None`: nothing, or not known yet).
/// A player not known, or known for longer than the TTL, is asked for.
pub fn worn(&mut self, uuid: u128) -> Option<Worn> {
self.look(uuid).and_then(|e| e.worn.clone())
}
/// Whether `uuid` plays Horizon, as last known (false until known); asked for as `worn`.
pub fn horizon(&mut self, uuid: u128) -> bool {
self.look(uuid).is_some_and(|e| e.horizon)
}
fn look(&mut self, uuid: u128) -> Option<&Entry> {
let now = self.now;
let state = &mut *self.state;
match state.players.get_mut(&uuid) {
@@ -171,7 +183,7 @@ impl Frame<'_> {
if entry.expires <= now && state.pending.insert(uuid) {
self.asked = true;
}
entry.worn.clone()
Some(entry)
}
None => {
self.asked |= state.pending.insert(uuid);
@@ -197,6 +209,9 @@ fn ready(s: &State, now: Instant) -> bool {
struct Answer {
ttl: u64,
players: HashMap<String, Vec<ItemRef>>,
/// Missing from services before it.
#[serde(default)]
horizon: Vec<String>,
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -277,6 +292,7 @@ fn work(shared: &Shared, transport: &dyn Transport, cache: &Option<PathBuf>, sav
// Shown until the service answers: asked again with the others.
s.players.entry(uuid).or_insert(Entry {
worn: Some(worn),
horizon: false,
expires: now,
seen: now,
});
@@ -288,6 +304,11 @@ fn work(shared: &Shared, transport: &dyn Transport, cache: &Option<PathBuf>, sav
return;
}
};
let horizon: HashSet<u128> = answer
.horizon
.iter()
.filter_map(|hex| u128::from_str_radix(hex, 16).ok())
.collect();
let mut worn: HashMap<u128, Worn> = HashMap::new();
// Players with an item the service didn't send: asked again soon.
let mut short = HashSet::new();
@@ -344,6 +365,7 @@ fn work(shared: &Shared, transport: &dyn Transport, cache: &Option<PathBuf>, sav
uuid,
Entry {
worn: worn.remove(&uuid),
horizon: horizon.contains(&uuid),
expires: now + ttl,
seen,
},
@@ -525,7 +547,8 @@ impl Transport for Http {
}
/// Item files in a folder instead of the service (`HORIZON_COSMETICS_LOCAL`): every player asked
/// for wears the first item of each slot found there, by file name. For artists and the bench.
/// for wears the first item of each slot found there, by file name, and plays Horizon. For artists
/// and the bench.
pub struct Local {
dir: PathBuf,
}
@@ -577,9 +600,12 @@ impl Transport for Local {
.into_iter()
.map(|p| (p, serde_json::Value::Array(refs.clone())))
.collect();
Ok(serde_json::json!({"ttl": 60, "players": players})
.to_string()
.into_bytes())
let horizon: Vec<&String> = players.keys().collect();
Ok(
serde_json::json!({"ttl": 60, "players": players, "horizon": horizon})
.to_string()
.into_bytes(),
)
}
fn get(&self, path: &str) -> Result<Vec<u8>, String> {
+44 -3
View File
@@ -7,6 +7,8 @@ struct Fake {
log: Arc<Mutex<Vec<String>>>,
offline: Arc<AtomicBool>,
wearing: Arc<Mutex<HashMap<u128, Vec<&'static str>>>>,
/// Who plays Horizon; `None`: a service from before the field.
horizon: Arc<Mutex<Option<HashSet<u128>>>>,
}
impl Fake {
@@ -49,9 +51,16 @@ impl Transport for Fake {
players.insert(uuid.to_owned(), serde_json::Value::Array(refs));
}
}
Ok(serde_json::json!({"ttl": 300, "players": players})
.to_string()
.into_bytes())
let mut answer = serde_json::json!({"ttl": 300, "players": players});
if let Some(horizon) = &*self.horizon.lock().unwrap() {
let listed: Vec<&str> = asked
.iter()
.filter_map(|u| u.as_str())
.filter(|u| horizon.contains(&u128::from_str_radix(u, 16).unwrap()))
.collect();
answer["horizon"] = serde_json::json!(listed);
}
Ok(answer.to_string().into_bytes())
}
fn get(&self, path: &str) -> Result<Vec<u8>, String> {
@@ -294,3 +303,35 @@ fn a_new_game_shows_the_last_outfits_while_the_service_is_down() {
drop(fake);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn horizon_players_come_with_the_same_request_as_what_they_wear() {
let (fake, mut c, time) = setup(None);
// 3 wears a hat and plays Horizon, 50 plays Horizon in nothing, 7 wears a hat elsewhere.
*fake.horizon.lock().unwrap() = Some(HashSet::from([3, 50]));
{
let mut frame = c.frame();
assert!(!frame.horizon(3) && !frame.horizon(50));
let _ = frame.worn(7);
}
run(&mut c);
assert_eq!(
fake.log().iter().filter(|l| l.starts_with("POST")).count(),
1
);
let mut frame = c.frame();
assert!(frame.horizon(3) && frame.horizon(50) && !frame.horizon(7));
assert_eq!(ids(frame.worn(3)), ["hat"]);
assert!(frame.worn(50).is_none());
drop(frame);
run(&mut c);
assert!(fake.log().is_empty());
// Stopped playing (or turned it off): gone after the TTL. An older service never says.
*fake.horizon.lock().unwrap() = None;
time.store(300, Ordering::SeqCst);
let _ = c.frame().horizon(3);
run(&mut c);
let mut frame = c.frame();
assert!(!frame.horizon(3));
assert_eq!(ids(frame.worn(3)), ["hat"]);
}
+1
View File
@@ -11,6 +11,7 @@
pub mod account;
mod fetch;
mod item;
pub mod mark;
mod pose;
pub mod prefs;
pub mod preview;
+25
View File
@@ -0,0 +1,25 @@
//! Horizon's mark as the game draws it before a Horizon player's name (and the launcher before
//! yours): two white bars over a violet line, their reflection fainter under it, 8×8.
/// The mark's rectangles in its 8×8 square: `(x, y, width, height, ARGB)`.
pub const MARK: [(u8, u8, u8, u8, u32); 5] = [
(2, 1, 1, 3, 0xFFFF_FFFF),
(5, 1, 1, 3, 0xFFFF_FFFF),
(0, 4, 8, 1, 0xFFA9_8FFF),
(2, 5, 1, 2, 0x66FF_FFFF),
(5, 5, 1, 2, 0x66FF_FFFF),
];
/// The mark as 8×8 RGBA texels, rows from the top.
pub fn texels() -> [[u8; 4]; 64] {
let mut out = [[0; 4]; 64];
for (x, y, w, h, argb) in MARK {
let [a, r, g, b] = argb.to_be_bytes();
for yy in y..y + h {
for xx in x..x + w {
out[usize::from(yy) * 8 + usize::from(xx)] = [r, g, b, a];
}
}
}
out
}
+14
View File
@@ -21,6 +21,11 @@ pub const OTHERS: &str = "cosmeticsOthers";
pub const AURAS: &str = "cosmeticsAuras";
/// Show my cosmetics: `true` (the default) or `false`.
pub const MINE: &str = "cosmeticsMine";
/// Horizon's mark by other Horizon players' names: `true` (the default) or `false`. The game's.
pub const MARKS: &str = "horizonMarks";
/// Show me as a Horizon player: `true` (the default) or `false`. The game's; the launcher reads it
/// to draw your name tag as others see it.
pub const LISTED: &str = "horizonListed";
/// What the service confirmed, a `<uuid hex>:<true|false>` line per account.
pub const CONFIRMED: &str = "cosmetics-visible.txt";
/// Whether the account owns a cosmetic, as the service last said: a `<uuid hex>:<true|false>`
@@ -141,6 +146,15 @@ impl Prefs {
}
}
/// Whether the game in `home` lets the service say its player plays Horizon (on unless off).
pub fn listed(home: &Path) -> bool {
fs::read_to_string(home.join(FILE)).map_or(true, |t| {
!t.lines()
.filter_map(|l| l.split_once(':'))
.any(|(k, v)| k.trim() == LISTED && v.trim() == "false")
})
}
/// Whether the service last confirmed `uuid`'s cosmetics shown; None: never told this game.
pub fn confirmed(home: &Path, uuid: &str) -> Option<bool> {
read_account(home, CONFIRMED, uuid)
+57 -2
View File
@@ -689,6 +689,27 @@ pub fn parse_stack(snbt: &str) -> Result<Option<ItemStack>, String> {
let (tag, _) = horizon_world::nbt::parse_snbt(snbt)?;
Ok(crate::item_nbt::item_from_nbt(&tag))
}
/// `MapDecorations.Entry.CODEC`: a bare type ID uses the default resource namespace.
/// Keep preserved entry keys and numeric tags intact; this is not registry validation.
fn canonical_other_component(id: &str, tag: &Tag) -> Tag {
let mut value = tag.clone();
if id == "minecraft:map_decorations"
&& let Tag::Compound(entries) = &mut value
{
for entry in entries.values_mut() {
if let Some(Tag::String(kind)) = entry.get_mut("type") {
let text = kind.to_string_lossy();
if !text.is_empty()
&& !text.contains(':')
&& let Ok(id) = horizon_world::identifier::Identifier::parse(&text)
{
*kind = id.to_string().as_str().into();
}
}
}
}
value
}
/// The saved `DataComponentPatch.CODEC` compound, or None when default.
/// A filled map's empty decorations equal its prototype (`Items.mapProperties`).
pub fn encode_components(stack: &ItemStack) -> Option<Tag> {
@@ -908,7 +929,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
if let Some(d) = data {
for (k, v) in &d.other {
out.entry(JavaString::from(k.as_str()))
.or_insert_with(|| v.clone());
.or_insert_with(|| canonical_other_component(k, v));
}
}
(!out.is_empty()).then_some(Tag::Compound(out))
@@ -1079,7 +1100,8 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
&& (matches!(key.as_str(), "minecraft:item_name" | "minecraft:rarity")
|| (key == "minecraft:tooltip_display" && v == &ominous_tooltip_display()));
if !MODELLED.contains(&key.as_str()) && !ominous_part {
data.other.push((key, v.clone()));
let value = canonical_other_component(&key, v);
data.other.push((key, value));
}
}
data.other.sort_by(|a, b| a.0.cmp(&b.0));
@@ -1119,6 +1141,39 @@ pub fn command_components(text: &str) -> Result<Tag, String> {
mod tests {
use super::*;
#[test]
fn map_decoration_types_round_trip_with_default_namespaces() {
let parse = |text| horizon_world::nbt::parse_snbt(text).unwrap().0;
let source = parse(
r#"{"minecraft:map_decorations":{a:{type:"minecraft:mansion",x:10.5d,z:-20.0d,rotation:90.0f},"a+b":{type:"red_x",x:-1.25d,z:2.75d,rotation:-45.5f}},"minecraft:custom_data":{keep:7}}"#,
);
let expected = parse(
r#"{"minecraft:map_decorations":{a:{type:"minecraft:mansion",x:10.5d,z:-20.0d,rotation:90.0f},"a+b":{type:"minecraft:red_x",x:-1.25d,z:2.75d,rotation:-45.5f}},"minecraft:custom_data":{keep:7}}"#,
);
assert!(decode_components(None).1.is_none());
for input in [&source, &expected] {
let (damage, extra) = decode_components(Some(input));
assert_eq!(
extra.as_ref().unwrap().other("minecraft:map_decorations"),
expected.get("minecraft:map_decorations")
);
let loaded = ItemStack {
damage,
extra,
..ItemStack::new("minecraft:filled_map", 1)
};
assert_eq!(encode_components(&loaded), Some(expected.clone()));
// A fresh model has no original NBT or retained component source.
let mut fresh = ItemStack::new("minecraft:filled_map", 1);
assert!(fresh.extra.is_none());
for id in ["minecraft:map_decorations", "minecraft:custom_data"] {
fresh.data_mut().set_other(id, input.get(id).cloned());
}
assert_eq!(encode_components(&fresh), Some(expected.clone()));
assert!(fresh.data().unwrap().map_id.is_none());
}
}
#[test]
fn empty_map_decoration_patch_uses_filled_map_prototype() {
let parse = |text| horizon_world::nbt::parse_snbt(text).unwrap().0;
+7 -14
View File
@@ -212,21 +212,14 @@ impl GitHub {
Ok(Some((entry, file["sha"].as_str().unwrap_or("").to_owned())))
}
/// A public repository `owner/name`: its GitHub JSON, None when it is not found (or private).
pub fn repository(&self, repo: &str) -> Result<Option<Value>> {
/// The blob sha of `path` on main, None when it isn't there: what replacing it needs.
pub fn blob(&self, path: &str) -> Result<Option<String>> {
match self
.agent
.get(&format!("{}/repos/{repo}", self.at.api))
.timeout(self.call_timeout)
.set("Authorization", &format!("Bearer {}", self.bot_token))
.set("Accept", "application/vnd.github+json")
.set("X-GitHub-Api-Version", "2022-11-28")
.call("GET", &format!("/contents/{path}"))
.query("ref", "main")
.call()
{
Ok(r) => {
let repo = read(r)?;
Ok((repo["private"] == false).then_some(repo))
}
Ok(r) => Ok(read(r)?["sha"].as_str().map(str::to_owned)),
Err(ureq::Error::Status(404, _)) => Ok(None),
Err(e) => Err(failure(e)),
}
@@ -336,14 +329,14 @@ impl GitHub {
pub fn put(
&self,
path: &str,
text: &str,
content: impl AsRef<[u8]>,
branch: &str,
sha: Option<&str>,
message: &str,
) -> Result<()> {
let mut body = json!({
"message": message,
"content": STANDARD.encode(text),
"content": STANDARD.encode(content),
"branch": branch,
});
if let Some(sha) = sha {
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more