mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 10:31:29 -10:00
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:
commit
ada2c6b810
239 files changed
+11473
-1912
No files matched your search
Generated
+1
@@ -1318,6 +1318,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"dunce",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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, ¯os, &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, ¯os, &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.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¬ice.body, max - indent, width);
|
||||
let inner = lines
|
||||
.iter()
|
||||
.map(|l| width(l) + indent)
|
||||
.fold(width(¬ice.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(¬ice.title)) / 2.0).floor();
|
||||
batch.text(atlas, ¬ice.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());
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ice.body, max, width);
|
||||
let inner = lines
|
||||
.iter()
|
||||
.map(|l| width(l))
|
||||
.fold(width(¬ice.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(¬ice.title)) / 2.0).floor();
|
||||
batch.text(atlas, ¬ice.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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
pub mod account;
|
||||
mod fetch;
|
||||
mod item;
|
||||
pub mod mark;
|
||||
mod pose;
|
||||
pub mod prefs;
|
||||
pub mod preview;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user