mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Merge origin/main into shaderpacks/atlas-linear-mipmaps
Keeps atlas_mip_tests beside #9764's wrong_type_uniform_tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
b859bcf312
197 files changed
+9383
-1591
No files matched your search
@@ -242,6 +242,18 @@ jobs:
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
- name: Build a native-mod game with the launcher's managed toolchain
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
env:
|
||||
HORIZON_LAUNCHER_HOME: ${{ runner.temp }}\horizon-native-arm64-toolchain
|
||||
HORIZON_NATIVE_TARGET_DIR: ${{ runner.temp }}\horizon-native-arm64-toolchain\target
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
cargo test --release --locked --target aarch64-pc-windows-msvc --lib rust_toolchain::tests::managed_arm64_toolchain_builds_the_game -- --ignored --exact --nocapture
|
||||
$client = Join-Path $env:HORIZON_NATIVE_TARGET_DIR 'fast/horizon-client.exe'
|
||||
python ../tools/check_pe_arch.py arm64 $client
|
||||
python ../tools/check_imports.py $client
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
|
||||
@@ -174,12 +174,13 @@ BlueMap is licensed under the MIT license:
|
||||
## JOML
|
||||
|
||||
`crates/horizon-client-render/src/camera.rs` (`joml::Mat4::rotation`, `invert_orthonormal`) and
|
||||
`crates/horizon-client-render/src/gfx/pack_uniforms.rs` (`invert_generic`, the formula of
|
||||
`pack_projection`) adapt the float arithmetic of `Matrix4f.rotation(Quaternionfc)`,
|
||||
`invertOrthonormal`, `invertGenericNonThis` and `setPerspective` from
|
||||
`crates/horizon-client-render/src/gfx/pack_uniforms.rs` (`invert_generic`, `invert_affine`, the
|
||||
formulas of `pack_projection` and `shadow_ortho`) adapt the float arithmetic of
|
||||
`Matrix4f.rotation(Quaternionfc)`, `invertOrthonormal`, `invertGenericNonThis`, `invertAffine`,
|
||||
`setPerspective` and `setOrtho` from
|
||||
[JOML](https://github.com/JOML-CI/JOML/tree/faf9eef2511242122beb6055fe3da92daed214c5) 1.10.9, the
|
||||
library vanilla 26.3 uses, so that shader packs get the same matrices to the last bit. Changes:
|
||||
ported to Rust; `invert_generic` and `pack_projection` drop JOML's property flags, and
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -159,9 +159,9 @@ const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
|
||||
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
|
||||
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
|
||||
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
|
||||
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
|
||||
/// shadow, and ends past the build height and the slant across the map below the camera.
|
||||
/// the next cell. Its depth starts 356 blocks toward the light (256 further than `shaders/`
|
||||
/// packs' default), so the ground over a deep cave still casts its shadow, and ends past the
|
||||
/// build height and the slant across the map below the camera.
|
||||
pub fn camera(settings: &Settings) -> Shadow {
|
||||
Shadow {
|
||||
resolution: settings.resolution,
|
||||
@@ -169,8 +169,8 @@ pub fn camera(settings: &Settings) -> Shadow {
|
||||
render_mul: 1.0,
|
||||
interval: SNAP,
|
||||
euclidean_snap: true,
|
||||
near: 256.0,
|
||||
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
|
||||
near: Some(-356.0),
|
||||
far: Some(2.0 * settings.distance + 384.0),
|
||||
..Shadow::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ mod world_target_tests;
|
||||
#[cfg(test)]
|
||||
mod higher_shadowcolor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
@@ -613,6 +616,8 @@ pub struct PackPost {
|
||||
rgb: u16,
|
||||
rgb_unset: bool,
|
||||
used: u16,
|
||||
/// Buffers some pass draws into the "alt" side of or flips; the others' B is their A.
|
||||
doubled: u16,
|
||||
/// `size.buffer.*`.
|
||||
sizes: [Option<horizon_shaders::BufferSize>; BUFFERS],
|
||||
/// Buffers bound as images, and those also copied for a pass that samples them.
|
||||
@@ -2045,6 +2050,9 @@ impl PackPost {
|
||||
let mask =
|
||||
|flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
|
||||
let mut used = 1u16;
|
||||
// Buffers some pass draws into the "alt" side of, or flips: only these get a B texture.
|
||||
let mut doubled = mask(&post.deferred_pre) | mask(&post.composite_pre);
|
||||
let mut shadow_doubled = 0u16;
|
||||
let draws = DrawUniforms::new(device);
|
||||
// The shadow buffers' formats when the shadow pass runs; their programs see them as
|
||||
// buffers 0 to 7.
|
||||
@@ -2203,11 +2211,29 @@ impl PackPost {
|
||||
));
|
||||
return None;
|
||||
}
|
||||
// Composite-style programs draw into the "alt" side of what they write; gbuffers,
|
||||
// shadow and `final` programs write in place, compute programs through images.
|
||||
let alt = if geometry.is_none()
|
||||
&& group != Group::Final
|
||||
&& matches!(pass.pipeline, Pipeline::Render(_))
|
||||
{
|
||||
pass.written
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if in_shadow {
|
||||
shadow_doubled |= alt;
|
||||
} else {
|
||||
doubled |= alt;
|
||||
}
|
||||
for p in std::iter::once(&pass).chain(&pass.compute) {
|
||||
let (stage, flipped) = pack.released.get();
|
||||
pack.released.set((stage, flipped | p.flip));
|
||||
if !in_shadow {
|
||||
if in_shadow {
|
||||
shadow_doubled |= p.flip;
|
||||
} else {
|
||||
used |= p.reads | p.written | p.flip;
|
||||
doubled |= p.flip;
|
||||
}
|
||||
storage |= image_buffers(&p.images);
|
||||
image_copied |= p.image_copies;
|
||||
@@ -2587,18 +2613,24 @@ impl PackPost {
|
||||
&shadow_config,
|
||||
f,
|
||||
colors,
|
||||
shadow_doubled & colors,
|
||||
depth1,
|
||||
linear,
|
||||
);
|
||||
notes.push(format!(
|
||||
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b}{1}",
|
||||
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b} (two textures: {4:#b}){1}",
|
||||
t.size,
|
||||
if depth1 { ", shadowtex1" } else { "" },
|
||||
shadow_config.distance,
|
||||
shadow_config.sun_path_rotation,
|
||||
shadow_doubled & colors,
|
||||
));
|
||||
t
|
||||
});
|
||||
log::info!(
|
||||
"shader pack: colortex buffers {used:#b}, with two textures {:#b}",
|
||||
doubled & used
|
||||
);
|
||||
// Any program can leave pixels untouched. Composite-style passes also write the
|
||||
// alternate image, so their first write does not replace the main image's clear.
|
||||
let mut cleared = Vec::new();
|
||||
@@ -2808,6 +2840,7 @@ impl PackPost {
|
||||
rgb,
|
||||
rgb_unset: true,
|
||||
used,
|
||||
doubled: doubled & used,
|
||||
storage,
|
||||
copied,
|
||||
sizes,
|
||||
@@ -2980,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);
|
||||
}
|
||||
}
|
||||
@@ -4111,8 +4152,16 @@ impl PackPost {
|
||||
if i != 0 && self.clears[i].is_none() {
|
||||
attachment |= wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
|
||||
}
|
||||
let sides = [0, 1]
|
||||
.map(|_| texture_sized(&label, self.formats[i], attachment, levels, sizes[i]));
|
||||
let side = || texture_sized(&label, self.formats[i], attachment, levels, sizes[i]);
|
||||
let a = side();
|
||||
let sides = [
|
||||
a.clone(),
|
||||
if self.doubled & 1 << i != 0 {
|
||||
side()
|
||||
} else {
|
||||
a
|
||||
},
|
||||
];
|
||||
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
|
||||
mips[i] = Some(sides.each_ref().map(|t| {
|
||||
Mips {
|
||||
@@ -4357,8 +4406,8 @@ impl PackPost {
|
||||
shadow.interval.to_bits().hash(&mut hasher);
|
||||
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
|
||||
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
|
||||
shadow.near.to_bits().hash(&mut hasher);
|
||||
shadow.far.to_bits().hash(&mut hasher);
|
||||
shadow.near.map(f32::to_bits).hash(&mut hasher);
|
||||
shadow.far.map(f32::to_bits).hash(&mut hasher);
|
||||
for hf in &shadow.hardware_filtering {
|
||||
hf.hash(&mut hasher);
|
||||
}
|
||||
@@ -4548,7 +4597,10 @@ impl PackPost {
|
||||
store: wgpu::StoreOp::Store,
|
||||
};
|
||||
for b in (0..BUFFERS).filter(|&b| self.rgb & 1 << b != 0) {
|
||||
for alt in [false, true] {
|
||||
for alt in [false, true]
|
||||
.into_iter()
|
||||
.take(1 + usize::from(self.doubled & 1 << b != 0))
|
||||
{
|
||||
if let Some(v) = view(self.main, b, alt) {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack three-channel start"),
|
||||
@@ -4581,7 +4633,8 @@ impl PackPost {
|
||||
let max_attachments = (limits.max_color_attachments as usize).min(8);
|
||||
// The buffers the blit doesn't clear, and the other texture of every cleared buffer.
|
||||
let alt_clears = (0..BUFFERS)
|
||||
.filter(|&b| self.clears[b].is_some() && view(self.main, b, true).is_some())
|
||||
.filter(|&b| self.clears[b].is_some() && self.doubled & 1 << b != 0)
|
||||
.filter(|&b| view(self.main, b, true).is_some())
|
||||
.map(|b| (b, true));
|
||||
let mut by_size: Vec<([u32; 2], Vec<(usize, bool)>)> = Vec::new();
|
||||
for (b, alt) in self
|
||||
@@ -5541,6 +5594,9 @@ 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,93 @@
|
||||
//! A buffer gets its B texture only when a composite-style program draws into it or flips it:
|
||||
//! the others' B is a second view of A, and they read the same as before. The programs here
|
||||
//! are original and cut down.
|
||||
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` writes colortex1 from colortex2, which no program writes; colortex4 is only
|
||||
/// flipped by a directive. `shadowcomp` writes shadowcolor1; shadowcolor0 is only read.
|
||||
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("shadowcomp.vsh", VERTEX);
|
||||
add(
|
||||
"shadowcomp.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1 */\nvoid main() { gl_FragData[0]=vec4(0,0.5,0,1); }\n",
|
||||
);
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS: 1 */\n\
|
||||
void main() { gl_FragData[0]=vec4(texture2D(colortex2,uv).rgb*0.5,1); }\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows colortex1, colortex4, shadowcolor0 and shadowcolor1 in turn.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nconst int shadowMapResolution=16;\n\
|
||||
const vec4 colortex2ClearColor=vec4(1,1,1,1);\n\
|
||||
const vec4 colortex4ClearColor=vec4(0,0,0.75,1);\n\
|
||||
const vec4 shadowcolor0ClearColor=vec4(0.25,0,0,1);\n\
|
||||
uniform sampler2D colortex1;\nuniform sampler2D colortex4;\n\
|
||||
uniform sampler2D shadowcolor0;\nuniform sampler2D shadowcolor1;\nin vec2 uv;\n\
|
||||
void main() {\n vec4 c[4] = vec4[4](texture(colortex1,uv), texture(colortex4,uv),\n\
|
||||
texture(shadowcolor0,uv), texture(shadowcolor1,uv));\n\
|
||||
gl_FragColor = c[int(gl_FragCoord.x) % 4];\n}\n",
|
||||
);
|
||||
add(
|
||||
"shaders.properties",
|
||||
"shadow.enabled=true\nflip.composite.colortex4=true\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-single-side",
|
||||
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 only_buffers_drawn_into_or_flipped_get_a_second_texture() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
// Two frames: the second starts from the first one's buffers.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
let expected = [
|
||||
[128, 128, 128, 255],
|
||||
[0, 0, 191, 255],
|
||||
[64, 0, 0, 255],
|
||||
[0, 128, 0, 255],
|
||||
];
|
||||
for (x, want) in expected.iter().enumerate() {
|
||||
let got = pixels[x];
|
||||
assert!(
|
||||
got.iter().zip(want).all(|(g, w)| g.abs_diff(*w) <= 1),
|
||||
"frame {frame}, column {x}: {got:?}, expected {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let shared = |sides: &[wgpu::TextureView; 2]| sides[0].texture() == sides[1].texture();
|
||||
let targets = post.targets.as_ref().expect("rendered");
|
||||
for (b, single) in [(1, false), (2, true), (4, false)] {
|
||||
let sides = targets.color[b].as_ref().expect("used buffer");
|
||||
assert_eq!(shared(sides), single, "colortex{b}");
|
||||
}
|
||||
let shadow = post.shadow_targets.as_ref().expect("shadow pass");
|
||||
for (n, single) in [(0, true), (1, false)] {
|
||||
let sides = shadow.color[n].as_ref().expect("used buffer");
|
||||
assert_eq!(shared(sides), single, "shadowcolor{n}");
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ pub(super) struct ShadowTargets {
|
||||
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
|
||||
/// and their stride in bytes.
|
||||
staging: Option<(wgpu::Buffer, u32)>,
|
||||
/// Both sides of each shadowcolor buffer used.
|
||||
/// Both sides of each shadowcolor buffer used; one texture for both when no `shadowcomp`
|
||||
/// program draws into or flips the buffer.
|
||||
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
|
||||
/// The clear colour of each buffer cleared every frame.
|
||||
pub clears: [Option<[f32; 4]>; SHADOW_COLORS],
|
||||
@@ -85,12 +86,14 @@ fn target(
|
||||
impl ShadowTargets {
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
|
||||
/// sampler). The resolution is kept within the device's limit.
|
||||
/// sampler), `doubled` (bit n: shadowcolor n needs a B texture). The resolution is kept
|
||||
/// within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
doubled: u16,
|
||||
depth1: bool,
|
||||
linear: u8,
|
||||
) -> Self {
|
||||
@@ -101,8 +104,8 @@ impl ShadowTargets {
|
||||
let depth = wgpu::TextureFormat::Depth32Float;
|
||||
let color = std::array::from_fn(|n| {
|
||||
(colors & 1 << n != 0).then(|| {
|
||||
[0, 1].map(|side| {
|
||||
let label = format!("Shader pack shadowcolor{n} {}", ["A", "B"][side]);
|
||||
let side = |side: &str| {
|
||||
let label = format!("Shader pack shadowcolor{n} {side}");
|
||||
target(
|
||||
device,
|
||||
&label,
|
||||
@@ -111,7 +114,14 @@ impl ShadowTargets {
|
||||
wgpu::TextureUsages::empty(),
|
||||
)
|
||||
.1
|
||||
})
|
||||
};
|
||||
let a = side("A");
|
||||
let b = if doubled & 1 << n != 0 {
|
||||
side("B")
|
||||
} else {
|
||||
a.clone()
|
||||
};
|
||||
[a, b]
|
||||
})
|
||||
});
|
||||
let linear = [0, 1].map(|n| {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// `invert_generic` and the formula of `pack_projection` in this file are adapted from JOML
|
||||
// 1.10.9's `Matrix4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
|
||||
// `invert_generic`, `invert_affine` and the formulas of `pack_projection` and `shadow_ortho` in
|
||||
// this file are adapted from JOML 1.10.9's `Matrix4f` (https://github.com/JOML-CI/JOML, MIT; see
|
||||
// THIRD_PARTY_NOTICES.md).
|
||||
//
|
||||
// Copyright (c) 2015-2026 Richard Greenlees
|
||||
//
|
||||
@@ -154,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']);
|
||||
@@ -521,18 +547,80 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
depth * gl * view
|
||||
}
|
||||
|
||||
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
|
||||
/// not of `shadowModelView` (Iris's matrices, measured).
|
||||
pub const SHADOW_EYE: f32 = 100.0;
|
||||
/// How far toward the light a `shadowMapFov` projection starts, in blocks.
|
||||
const SHADOW_EYE: f32 = 100.0;
|
||||
|
||||
/// The orthographic shadow projection's planes when the pack sets none, as JOML's `zNear` and
|
||||
/// `zFar`: Iris 1.11.7's, measured with a probe pack (each unset plane on its own).
|
||||
const SHADOW_NEAR: f32 = -100.05;
|
||||
const SHADOW_FAR: f32 = 156.0;
|
||||
|
||||
/// `shadowProjection` and its inverse to the last bit, as Iris 1.11.7 gives them: our probes'
|
||||
/// readings (13 settings of `shadowDistance`, `shadowNearPlane` and `shadowFarPlane`, each
|
||||
/// plane set or not) fit JOML's `setOrtho` over `shadowDistance` each side with `zZeroToOne`,
|
||||
/// its depth terms then taken to GL's -1..1 (`m22 * 2`, `m32 * 2 - 1`), in floats; and JOML's
|
||||
/// `invertAffine` of that. The planes are the pack's, or `SHADOW_NEAR`/`SHADOW_FAR`: a pack
|
||||
/// that sets one gets it as it is, with no 100-block offset (#8464).
|
||||
fn shadow_ortho(shadow: &Shadow) -> (Mat4, Mat4) {
|
||||
let d = shadow.distance;
|
||||
let near = shadow.near.unwrap_or(SHADOW_NEAR);
|
||||
let far = shadow.far.unwrap_or(SHADOW_FAR);
|
||||
let side = 2.0 / (d - -d);
|
||||
let projection = Mat4::from_cols(
|
||||
Vec4::new(side, 0.0, 0.0, 0.0),
|
||||
Vec4::new(0.0, side, 0.0, 0.0),
|
||||
Vec4::new(0.0, 0.0, 1.0 / (near - far) * 2.0, 0.0),
|
||||
Vec4::new(0.0, 0.0, near / (near - far) * 2.0 - 1.0, 1.0),
|
||||
);
|
||||
(projection, invert_affine(projection))
|
||||
}
|
||||
|
||||
/// JOML's `Matrix4f.invertAffine()`, operation by operation.
|
||||
fn invert_affine(m: Mat4) -> Mat4 {
|
||||
let g = |c: usize, r: usize| m.col(c)[r];
|
||||
let (m00, m01, m02) = (g(0, 0), g(0, 1), g(0, 2));
|
||||
let (m10, m11, m12) = (g(1, 0), g(1, 1), g(1, 2));
|
||||
let (m20, m21, m22) = (g(2, 0), g(2, 1), g(2, 2));
|
||||
let (m30, m31, m32) = (g(3, 0), g(3, 1), g(3, 2));
|
||||
let (m11m00, m10m01, m10m02) = (m00 * m11, m01 * m10, m02 * m10);
|
||||
let (m12m00, m12m01, m11m02) = (m00 * m12, m01 * m12, m02 * m11);
|
||||
let det = (m11m00 - m10m01) * m22 + (m10m02 - m12m00) * m21 + (m12m01 - m11m02) * m20;
|
||||
let s = 1.0 / det;
|
||||
let (m10m22, m10m21, m11m22) = (m10 * m22, m10 * m21, m11 * m22);
|
||||
let (m11m20, m12m21, m12m20) = (m11 * m20, m12 * m21, m12 * m20);
|
||||
let (m20m02, m20m01, m21m02) = (m20 * m02, m20 * m01, m21 * m02);
|
||||
let (m21m00, m22m01, m22m00) = (m21 * m00, m22 * m01, m22 * m00);
|
||||
Mat4::from_cols_array(&[
|
||||
(m11m22 - m12m21) * s,
|
||||
(m21m02 - m22m01) * s,
|
||||
(m12m01 - m11m02) * s,
|
||||
0.0,
|
||||
(m12m20 - m10m22) * s,
|
||||
(m22m00 - m20m02) * s,
|
||||
(m10m02 - m12m00) * s,
|
||||
0.0,
|
||||
(m10m21 - m11m20) * s,
|
||||
(m20m01 - m21m00) * s,
|
||||
(m11m00 - m10m01) * s,
|
||||
0.0,
|
||||
(m10m22 * m31 - m10m21 * m32 + m11m20 * m32 - m11m22 * m30 + m12m21 * m30 - m12m20 * m31)
|
||||
* s,
|
||||
(m20m02 * m31 - m20m01 * m32 + m21m00 * m32 - m21m02 * m30 + m22m01 * m30 - m22m00 * m31)
|
||||
* s,
|
||||
(m11m02 * m30 - m12m01 * m30 + m12m00 * m31 - m10m02 * m31 + m10m01 * m32 - m11m00 * m32)
|
||||
* s,
|
||||
1.0,
|
||||
])
|
||||
}
|
||||
|
||||
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
|
||||
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
|
||||
/// light's path about Z by the light's angle from the zenith, then X by 90 degrees, so the
|
||||
/// light lies on the view's +Z. Its origin is `shadowIntervalSize / 2 - camera % interval`
|
||||
/// (the remainder takes the camera's sign) from the player on each world axis, so the map moves
|
||||
/// in whole steps. The orthographic projection covers `shadowDistance` each side and depths
|
||||
/// from `-near` to `far`, counted from `SHADOW_EYE` blocks toward the light; or perspective
|
||||
/// with `shadowMapFov`.
|
||||
/// in whole steps. The orthographic projection is `shadow_ortho`'s; or perspective with
|
||||
/// `shadowMapFov`, from `shadowNearPlane` (0.05) to `shadowFarPlane` (256) counted from
|
||||
/// `SHADOW_EYE` blocks toward the light.
|
||||
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
|
||||
let tilt = f64::from(shadow.sun_path_rotation).to_radians();
|
||||
let untilted = DMat4::from_rotation_x(tilt).transform_vector3(light.as_dvec3());
|
||||
@@ -552,14 +640,13 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
|
||||
DVec3::ZERO
|
||||
};
|
||||
let view = (rotation * DMat4::from_translation(-origin)).as_mat4();
|
||||
let (near, far) = (shadow.near, shadow.far);
|
||||
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
|
||||
let projection = match shadow.fov {
|
||||
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye,
|
||||
None => {
|
||||
let d = shadow.distance;
|
||||
Mat4::orthographic_rh_gl(-d, d, -d, d, -near, far) * eye
|
||||
Some(fov) => {
|
||||
let (near, far) = (shadow.near.unwrap_or(0.05), shadow.far.unwrap_or(256.0));
|
||||
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
|
||||
Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye
|
||||
}
|
||||
None => shadow_ortho(shadow).0,
|
||||
};
|
||||
(view, projection)
|
||||
}
|
||||
@@ -663,7 +750,13 @@ impl FrameUniforms {
|
||||
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
|
||||
};
|
||||
let light = shadow_light(frame, &self.shadow);
|
||||
let (_, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
|
||||
let (shadow_projection, shadow_projection_inverse) = match self.shadow.fov {
|
||||
Some(_) => {
|
||||
let (_, projection) = shadow_camera(light, frame.camera, &self.shadow);
|
||||
(projection, projection.inverse())
|
||||
}
|
||||
None => shadow_ortho(&self.shadow),
|
||||
};
|
||||
let (shadow_view, shadow_view_inverse) = shadow_model_view(frame, &self.shadow);
|
||||
let day_time = inputs.day_time.rem_euclid(24000) as i32;
|
||||
let world_day = inputs.day_time.div_euclid(24000);
|
||||
@@ -718,7 +811,7 @@ impl FrameUniforms {
|
||||
"shadowModelView" => m4(shadow_view),
|
||||
"shadowModelViewInverse" => m4(shadow_view_inverse),
|
||||
"shadowProjection" => m4(shadow_projection),
|
||||
"shadowProjectionInverse" => m4(shadow_projection.inverse()),
|
||||
"shadowProjectionInverse" => m4(shadow_projection_inverse),
|
||||
"atlasSize" => Value::i(&[self.atlas[0] as i32, self.atlas[1] as i32]),
|
||||
"frameTime" => Value::f(&[frame_time]),
|
||||
"frameTimeCounter" => Value::f(&[seconds]),
|
||||
@@ -946,6 +1039,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]
|
||||
@@ -1139,6 +1262,80 @@ mod initial_history_tests {
|
||||
float(u, name).map(f32::to_bits)
|
||||
}
|
||||
|
||||
/// `shadowProjection` and its inverse to the last bit, as a probe pack of ours read them
|
||||
/// back in Iris 1.11.7, for `shadowDistance`, `shadowNearPlane` and `shadowFarPlane` (none
|
||||
/// meaning the pack doesn't set it): a plane the pack sets is taken as it is, with no
|
||||
/// 100-block offset; one it doesn't set is -100.05 (near) or 156 (far).
|
||||
#[test]
|
||||
fn the_shadow_projection_is_iris_s_for_the_pack_s_planes() {
|
||||
let hud = HudBatch::new([1280, 720]);
|
||||
let f = frame(&hud);
|
||||
let (o, z, one) = (0, 0x80000000, 0x3f800000);
|
||||
type Case = (f32, Option<f32>, Option<f32>, [u32; 4], [u32; 4]);
|
||||
// (distance, near, far, [side, m22, m32, -], [inverse side, m22, m32, -])
|
||||
let cases: [Case; 5] = [
|
||||
(
|
||||
100.0,
|
||||
None,
|
||||
None,
|
||||
[0x3c23d70a, 0xbbfff335, 0xbe5fc198, 0],
|
||||
[0x42c80001, 0xc3000666, 0xc1dfccc8, 0],
|
||||
),
|
||||
(
|
||||
100.0,
|
||||
Some(0.5),
|
||||
Some(128.0),
|
||||
[0x3c23d70a, 0xbc808081, 0xbf810101, 0],
|
||||
[0x42c80000, 0xc27f0000, 0xc2808000, 0],
|
||||
),
|
||||
(
|
||||
100.0,
|
||||
None,
|
||||
Some(128.0),
|
||||
[0x3c23d70a, 0xbc0fb013, 0xbdfb0128, 0],
|
||||
[0x42c80000, 0xc2e40ccc, 0xc15f9994, 0],
|
||||
),
|
||||
(
|
||||
100.0,
|
||||
Some(0.05),
|
||||
None,
|
||||
[0x3c23d70a, 0xbc521e5f, 0xbf801503, 0],
|
||||
[0x42c80000, 0xc29bf333, 0xc29c0ccd, 0],
|
||||
),
|
||||
(
|
||||
127.3,
|
||||
Some(1.7),
|
||||
Some(333.3),
|
||||
[0x3c00b42f, 0xbbc5a2bf, 0xbf814ffb, 0],
|
||||
[0x42fe9999, 0xc325cccc, 0xc3277fff, 0],
|
||||
),
|
||||
];
|
||||
for (distance, near, far, [side, m22, m32, _], [i_side, i22, i32, _]) in cases {
|
||||
let mut u = FrameUniforms {
|
||||
shadow: Shadow {
|
||||
distance,
|
||||
near,
|
||||
far,
|
||||
..Shadow::default()
|
||||
},
|
||||
..FrameUniforms::default()
|
||||
};
|
||||
u.update(&f, &PackInputs::default(), [1280, 720]);
|
||||
assert_eq!(
|
||||
bits(&u, "shadowProjection"),
|
||||
[side, o, o, o, o, side, o, o, o, o, m22, o, o, o, m32, one],
|
||||
"{near:?} {far:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
bits(&u, "shadowProjectionInverse"),
|
||||
[
|
||||
i_side, z, z, o, z, i_side, z, o, z, z, i22, o, z, z, i32, one
|
||||
],
|
||||
"{near:?} {far:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris 1.11.7's camera and shadow matrices to the last bit, read back from its uniforms by
|
||||
/// a probe pack of ours: on a texel boundary of the shadow map the last bit decides which
|
||||
/// texel a fragment reads (#8464). Noon at 6000 ticks, looking west (yaw 90) with vanilla's
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -709,24 +709,19 @@ pub fn lookup(
|
||||
];
|
||||
for (kind, url) in urls {
|
||||
let Some(url) = url else { continue };
|
||||
// registerTexture computes the hash first. A cape or elytra URL the
|
||||
// hash cannot take is that texture's problem alone: vanilla's lookup
|
||||
// goes on to the next, and only the skin texture decides the default.
|
||||
let Some(hash) = texture_hash(url) else {
|
||||
if kind == Kind::Skin {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// registerTexture computes the hash first: a bad URL, even a cape's,
|
||||
// fails the whole lookup.
|
||||
let hash = texture_hash(url)?;
|
||||
if kind == Kind::Skin {
|
||||
skin = Some(skin_location(&hash));
|
||||
}
|
||||
match download(kind, url) {
|
||||
// A cape or elytra that cannot be fetched is simply not drawn; only
|
||||
// the skin itself falls back to the default one (`SkinManager.get`).
|
||||
Download::Failed if kind == Kind::Skin => return None,
|
||||
Download::Failed | Download::Ready => {}
|
||||
// `SkinManager.registerTextures` joins the skin, cape and elytra
|
||||
// futures with `allOf`: any one failing fails the profile's
|
||||
// `PlayerSkin`, and `createLookup` then shows the default skin.
|
||||
Download::Failed => return None,
|
||||
Download::Pending => state = Download::Pending,
|
||||
Download::Ready => {}
|
||||
}
|
||||
}
|
||||
if state != Download::Ready || require_secure && !textures.signed {
|
||||
@@ -1562,35 +1557,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A cape or elytra that cannot be fetched takes only itself out of the
|
||||
/// picture: the player's skin still shows. Only the skin texture's own
|
||||
/// failure is the default skin.
|
||||
/// `SkinManager.registerTextures` waits on the skin, cape and elytra
|
||||
/// together (`CompletableFuture.allOf`): a cape or elytra that cannot be
|
||||
/// fetched, or whose URL has no hash, fails the profile, and the player
|
||||
/// shows the default skin, as the skin's own failure does.
|
||||
#[test]
|
||||
fn a_failed_cape_does_not_hide_the_skin() {
|
||||
fn a_failed_cape_or_elytra_shows_the_default_skin() {
|
||||
let textures = super::Textures {
|
||||
skin: Some("https://textures.minecraft.net/texture/skin1".into()),
|
||||
cape: Some("https://textures.minecraft.net/texture/cape1".into()),
|
||||
elytra: None,
|
||||
elytra: Some("https://textures.minecraft.net/texture/elytra1".into()),
|
||||
signed: true,
|
||||
slim: false,
|
||||
};
|
||||
let skin = super::lookup(&textures, true, |kind, _| match kind {
|
||||
super::Kind::Skin => super::Download::Ready,
|
||||
_ => super::Download::Failed,
|
||||
});
|
||||
assert_eq!(skin, Some(super::skin_location("skin1")));
|
||||
// A cape URL the hash cannot take is the same: the skin shows.
|
||||
let bad_url = super::Textures {
|
||||
let all_ready = super::lookup(&textures, true, |_, _| super::Download::Ready);
|
||||
assert_eq!(all_ready, Some(super::skin_location("skin1")));
|
||||
for failing in [super::Kind::Skin, super::Kind::Cape, super::Kind::Elytra] {
|
||||
let skin = super::lookup(&textures, true, |kind, _| {
|
||||
if kind == failing {
|
||||
super::Download::Failed
|
||||
} else {
|
||||
super::Download::Ready
|
||||
}
|
||||
});
|
||||
assert_eq!(skin, None, "{failing:?} failed");
|
||||
}
|
||||
let bad_cape_url = super::Textures {
|
||||
cape: Some("no-scheme".into()),
|
||||
..textures.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
super::lookup(&bad_url, true, |_, _| super::Download::Ready),
|
||||
Some(super::skin_location("skin1"))
|
||||
);
|
||||
// The skin itself failing is still the default skin.
|
||||
assert_eq!(
|
||||
super::lookup(&textures, true, |_, _| super::Download::Failed),
|
||||
super::lookup(&bad_cape_url, true, |_, _| super::Download::Ready),
|
||||
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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,6 +121,7 @@ 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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -707,14 +707,13 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
|
||||
if id == "minecraft:hoglin" {
|
||||
// Hoglin.addAdditionalSaveData writes it only when set.
|
||||
if m.extra.cannot_be_hunted {
|
||||
map.insert("CannotBeHunted".into(), Tag::Byte(1));
|
||||
} else {
|
||||
map.remove(&"CannotBeHunted".into());
|
||||
}
|
||||
map.insert(
|
||||
"CannotBeHunted".into(),
|
||||
Tag::Byte(m.extra.cannot_be_hunted as i8),
|
||||
);
|
||||
}
|
||||
if id == "minecraft:piglin" {
|
||||
write_guarded_piglin_anger(m, map);
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
@@ -4494,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())
|
||||
@@ -5168,8 +5203,8 @@ mod sheep_nbt_tests {
|
||||
}
|
||||
|
||||
/// `Hoglin.readAdditionalSaveData` and `addAdditionalSaveData`:
|
||||
/// `CannotBeHunted` (a bastion's hoglins) stays with the hoglin, and such a
|
||||
/// hoglin is no piglin's prey (`Hoglin.canBeHunted`).
|
||||
/// `CannotBeHunted` (a bastion's hoglins) stays with the hoglin and is always
|
||||
/// written, and such a hoglin is no piglin's prey (`Hoglin.canBeHunted`).
|
||||
#[test]
|
||||
fn a_hoglin_keeps_cannot_be_hunted_and_is_no_prey() {
|
||||
use crate::mobs::Species;
|
||||
@@ -5199,7 +5234,7 @@ mod sheep_nbt_tests {
|
||||
assert!(!prey.extra.cannot_be_hunted);
|
||||
assert!(prey.seen(None).huntable);
|
||||
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
|
||||
assert_eq!(saved.get("CannotBeHunted"), None);
|
||||
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6892,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);
|
||||
}
|
||||
}
|
||||
@@ -6839,7 +6839,12 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if block_hit.is_some() && (self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)?) {
|
||||
// TntBlock.useItemOn is the block's own: a sneak with something in hand skips it, and
|
||||
// the flint and steel or fire charge lights a fire on the TNT instead.
|
||||
if block_hit.is_some()
|
||||
&& (self.use_bone_meal(&stack)?
|
||||
|| (!(sneaking && hands_full) && self.ignite_tnt(&stack)?))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
// ShearsItem.useOn: shears crop a growing plant's head to its top age.
|
||||
@@ -6910,6 +6915,16 @@ impl Game<'_> {
|
||||
self.use_spyglass();
|
||||
return Ok(true);
|
||||
}
|
||||
// FoodOnAStickItem.use with no boost (not riding its mount, or the
|
||||
// mount refused): PASS, but counted as a use of the stick.
|
||||
if !on_block
|
||||
&& matches!(
|
||||
&*stack.item,
|
||||
"minecraft:carrot_on_a_stick" | "minecraft:warped_fungus_on_a_stick"
|
||||
)
|
||||
{
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
// BundleItem.use: startUsingItem, then onUseTick empties it.
|
||||
if !on_block && info.bundle {
|
||||
self.start_using_item();
|
||||
@@ -13846,6 +13861,15 @@ impl Game<'_> {
|
||||
&mut self.world.random,
|
||||
),
|
||||
);
|
||||
// FlintAndSteelItem / FireChargeItem.useOn: BLOCK_CHANGE at the lit block,
|
||||
// and ItemStack.useOn's used statistic.
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
hit.pos,
|
||||
self.sculk_actor(),
|
||||
None,
|
||||
);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
if item == "fire_charge" {
|
||||
self.consume_held();
|
||||
} else {
|
||||
@@ -13868,6 +13892,20 @@ impl Game<'_> {
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.audio.play(sound, Some(at(target)), 1.0, pitch);
|
||||
// BLOCK_PLACE: flint and steel names the clicked block, a fire charge the
|
||||
// fire. Then ItemStack.useOn's used statistic.
|
||||
let placed_at = if item == "fire_charge" {
|
||||
target
|
||||
} else {
|
||||
hit.pos
|
||||
};
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_place",
|
||||
placed_at,
|
||||
self.sculk_actor(),
|
||||
None,
|
||||
);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
if item == "fire_charge" {
|
||||
self.consume_held();
|
||||
} else {
|
||||
@@ -14290,6 +14328,7 @@ impl Game<'_> {
|
||||
.map_or(eye, |pl| [pl.position.x, pl.position.y, pl.position.z]);
|
||||
self.audio
|
||||
.play("minecraft:entity.arrow.shoot", Some(feet), 1.0, pitch);
|
||||
self.award_stat("used", "minecraft:bow", 1);
|
||||
if !self.creative
|
||||
&& !infinity
|
||||
&& let Some(i) = arrow_slot
|
||||
|
||||
@@ -1481,6 +1481,10 @@ impl Game<'_> {
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
// Counted on the server only, before the swap.
|
||||
if self.remote.is_none() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
let swap = swap_with_equipment_slot(stack, current, self.creative);
|
||||
self.armor[index] = Some(swap.worn.clone());
|
||||
// Player.setItemSlot: onEquipItem plays the equip sound.
|
||||
|
||||
@@ -1240,9 +1240,19 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// BoatItem.use: ENTITY_PLACE at the hit (the boat's position), by the player.
|
||||
let at = self
|
||||
.boats
|
||||
.last()
|
||||
.map(|b| [b.position.x, b.position.y, b.position.z]);
|
||||
if let Some(at) = at {
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event("minecraft:entity_place", at, ctx);
|
||||
}
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -200,6 +200,8 @@ impl Game<'_> {
|
||||
} else if let Some(screen) = self.book_screen(stack) {
|
||||
self.open_book(screen);
|
||||
}
|
||||
// WritableBookItem.use / WrittenBookItem.use count every open.
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
@@ -616,9 +642,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 +700,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(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,7 @@ impl Game<'_> {
|
||||
// is an empty stack (no match) when the shots broke it.
|
||||
if let Some(stack) = self.held().cloned() {
|
||||
self.trigger_item("minecraft:shot_crossbow", &stack);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +551,8 @@ impl Game<'_> {
|
||||
self.keys.insert(KeyCode::ShiftLeft);
|
||||
}
|
||||
self.using = None;
|
||||
// The oracle's player wears nothing: no armor left on from an earlier case.
|
||||
self.armor = Default::default();
|
||||
self.inventory[self.selected] = stack.clone();
|
||||
self.offhand = parse_stack(op["offhand"].as_str().unwrap_or("-"))?;
|
||||
// The used statistic of each item held, once.
|
||||
|
||||
@@ -1222,6 +1222,7 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_eye.launch", Some(at), 1.0, pitch);
|
||||
self.consume_held();
|
||||
self.award_stat("used", "minecraft:ender_eye", 1);
|
||||
self.swing_arm();
|
||||
}
|
||||
|
||||
|
||||
@@ -368,6 +368,7 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.award_stat("used", "minecraft:firework_rocket", 1);
|
||||
self.swing_arm();
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_elytra_boost(player, &name);
|
||||
|
||||
@@ -194,10 +194,6 @@ fn bite_impulse(r: &mut impl RandomSource) -> f64 {
|
||||
// Mth.nextFloat(random, min, max): `random.nextFloat() * (max - min) + min`.
|
||||
f64::from(-0.4f32 * (r.next_float() * (1.0f32 - 0.6f32) + 0.6f32))
|
||||
}
|
||||
/// RandomSource.triangle(mode, deviation).
|
||||
fn triangle(r: &mut impl RandomSource, mode: f64, deviation: f64) -> f64 {
|
||||
mode + deviation * (f64::from(r.next_float()) - f64::from(r.next_float()))
|
||||
}
|
||||
/// `FishingHook.onHitBlock`: `getDeltaMovement().normalize().scale(hit.distanceTo(this))`,
|
||||
/// where `HitResult.distanceTo` is the *squared* distance to the hook: the bobber
|
||||
/// springs back a little off a wall instead of stopping dead.
|
||||
@@ -269,11 +265,11 @@ impl Game<'_> {
|
||||
);
|
||||
let mut v = Vec3::new(f64::from(-y_sin), f64::from(dir_y), f64::from(-y_cos));
|
||||
let len = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
|
||||
let r = &mut self.world.random;
|
||||
let r = &mut LegacyRandom::new(horizon_client::sound_engine::unique_seed());
|
||||
v = Vec3::new(
|
||||
v.x * (0.6 / len + triangle(r, 0.5, 0.0103365)),
|
||||
v.y * (0.6 / len + triangle(r, 0.5, 0.0103365)),
|
||||
v.z * (0.6 / len + triangle(r, 0.5, 0.0103365)),
|
||||
v.x * (0.6 / len + r.triangle(0.5, 0.0103365)),
|
||||
v.y * (0.6 / len + r.triangle(0.5, 0.0103365)),
|
||||
v.z * (0.6 / len + r.triangle(0.5, 0.0103365)),
|
||||
);
|
||||
self.next_entity += 1;
|
||||
self.hook = Some(Hook {
|
||||
@@ -297,6 +293,8 @@ impl Game<'_> {
|
||||
lure,
|
||||
lerp: HookLerp::default(),
|
||||
});
|
||||
// FishingRodItem.use counts a cast, not a reel-in.
|
||||
self.award_stat("used", "minecraft:fishing_rod", 1);
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
@@ -550,7 +548,7 @@ impl Game<'_> {
|
||||
if h.time_until_hooked > 0 {
|
||||
// The fish swimming up: a bubble now and then, and a fishing
|
||||
// particle to each side of its path.
|
||||
h.fish_angle += triangle(&mut self.world.random, 0.0, 9.188) as f32;
|
||||
h.fish_angle += self.world.random.triangle(0.0, 9.188) as f32;
|
||||
let angle = h.fish_angle * (std::f64::consts::PI / 180.0) as f32;
|
||||
let (sin, cos) = (trig::sin(f64::from(angle)), trig::cos(f64::from(angle)));
|
||||
let distance = h.time_until_hooked as f32 * 0.1;
|
||||
|
||||
@@ -231,8 +231,13 @@ impl Game<'_> {
|
||||
/// `SpyglassItem.use`: the sound, then start scoping (a mod's `Start`
|
||||
/// may refuse that, not the sound).
|
||||
pub(super) fn use_spyglass(&mut self) {
|
||||
let at = self.eye_position();
|
||||
// Player.playSound: at the feet (getY), not the eyes.
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
|
||||
self.award_stat("used", "minecraft:spyglass", 1);
|
||||
self.start_using_item();
|
||||
}
|
||||
/// `SpyglassItem.releaseUsing` / `finishUsingItem` (after 1200 ticks).
|
||||
|
||||
@@ -1140,6 +1140,8 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
self.give_or_drop_stack(filled);
|
||||
}
|
||||
// EmptyMapItem.use: consumed, then counted.
|
||||
self.award_stat("used", "minecraft:map", 1);
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_map_filled(player, &name, id);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,8 +3,9 @@
|
||||
//! 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};
|
||||
@@ -14,6 +15,8 @@ use std::time::{Duration, Instant};
|
||||
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 +83,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 +106,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 +126,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,14 +170,25 @@ 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();
|
||||
@@ -299,6 +327,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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -6179,7 +6179,7 @@ impl Game<'_> {
|
||||
// reaches its own write when this window is a server client.
|
||||
self.rope_rot_old = (self.yaw, self.pitch);
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys, sprint_window)?;
|
||||
// The server sends no passenger's position: they sit where their
|
||||
// vehicles are now.
|
||||
self.position_riders();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart
|
||||
//! moves on the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, ray_box};
|
||||
use crate::Result;
|
||||
use horizon_client::mobs::{Mob, equine, happy_ghast};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
@@ -243,10 +244,18 @@ impl Game<'_> {
|
||||
|
||||
/// The local player's tick in its vehicle, in place of its own movement:
|
||||
/// false when it rides nothing.
|
||||
pub(super) fn remote_ride(&mut self, keys: Keys) -> bool {
|
||||
pub(super) fn remote_ride(&mut self, keys: Keys, sprint_window: i32) -> Result<bool> {
|
||||
let Some(id) = self.riding else {
|
||||
return false;
|
||||
return Ok(false);
|
||||
};
|
||||
// LocalPlayer.aiStep runs its sprint decisions in a vehicle too: `canSprint` and
|
||||
// this client's authority stand in for food (`isSprintingPossible`).
|
||||
let (can_sprint, local) = self
|
||||
.driven_mount()
|
||||
.map_or((false, false), |m| (equine::can_sprint(m.species()), true));
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.sprint_input_tick_riding(keys, sprint_window, can_sprint, local)?;
|
||||
}
|
||||
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
// LocalPlayer.aiStep: the jump bar charges while jump is held.
|
||||
let jump = self.charge_mount_jump(i, keys);
|
||||
@@ -256,7 +265,7 @@ impl Game<'_> {
|
||||
// Another animal moves on the server with the rider's input
|
||||
// (PlayerInput); the rider sits where `positionRider` puts it.
|
||||
self.position_rider();
|
||||
return true;
|
||||
return Ok(true);
|
||||
}
|
||||
let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
|
||||
// The seat does not move; the rider sits on the cushion.
|
||||
@@ -297,7 +306,7 @@ impl Game<'_> {
|
||||
// riding it. Nothing with the id is in the level any more (it
|
||||
// died, or the world it rode in was left), so the seat goes.
|
||||
self.riding = None;
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
cart
|
||||
};
|
||||
@@ -317,7 +326,7 @@ impl Game<'_> {
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
true
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `LocalPlayer.tick` for a passenger: the boat it controls goes to the
|
||||
@@ -451,6 +460,10 @@ impl Game<'_> {
|
||||
}
|
||||
// playerJumpPendingScale waits for the ground.
|
||||
let pending = m.rider.map_or(0.0, |r| r.jump);
|
||||
// The rider's sprint state, not the key: Camel.getRiddenSpeed reads
|
||||
// `controller.isSprinting()`, which keeps the double-tapped sprint
|
||||
// after the key is let go (`shouldStopRunSprinting` decides the end).
|
||||
let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting);
|
||||
m.rider = Some(equine::Rider {
|
||||
strafe,
|
||||
forward,
|
||||
@@ -458,7 +471,7 @@ impl Game<'_> {
|
||||
pitch: self.pitch,
|
||||
jump: if jump > 0.0 { jump } else { pending },
|
||||
steer: false,
|
||||
sprint: keys.sprint,
|
||||
sprint: sprinting,
|
||||
jumping: keys.jump,
|
||||
client: false,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -350,6 +350,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.consume_held();
|
||||
}
|
||||
self.award_stat("used", item, 1);
|
||||
Ok(true)
|
||||
}
|
||||
/// `TntRenderer`: the block centred on the entity, swelling over the last
|
||||
|
||||
@@ -371,6 +371,10 @@ impl Game<'_> {
|
||||
if stack.damage >= self.trident_max_damage(&stack).saturating_sub(1) {
|
||||
return;
|
||||
}
|
||||
// Counted (on the server) before the throw or the spin.
|
||||
if self.remote.is_none() {
|
||||
self.award_stat("used", "minecraft:trident", 1);
|
||||
}
|
||||
if riptide > 0 {
|
||||
// `hurtWithoutBreaking` and the sound belong to the server; a server's
|
||||
// client only launches.
|
||||
|
||||
@@ -1238,7 +1238,7 @@ impl Hub {
|
||||
let joined = C::SystemChat(play::SystemChat {
|
||||
content: yellow_names(
|
||||
"multiplayer.player.joined",
|
||||
vec![display_name(self.guests[i].uuid, &name)],
|
||||
vec![self.player_display_name(self.guests[i].uuid, &name)],
|
||||
),
|
||||
overlay: false,
|
||||
})
|
||||
@@ -2028,7 +2028,7 @@ impl Hub {
|
||||
let left = C::SystemChat(play::SystemChat {
|
||||
content: yellow_names(
|
||||
"multiplayer.player.left",
|
||||
vec![display_name(g.uuid, &g.name)],
|
||||
vec![self.player_display_name(g.uuid, &g.name)],
|
||||
),
|
||||
overlay: false,
|
||||
})
|
||||
|
||||
@@ -80,6 +80,11 @@ pub fn is_equine(s: Species) -> bool {
|
||||
| Species::TraderLlama
|
||||
)
|
||||
}
|
||||
/// `Entity.canSprint`: false everywhere but the camel (`Camel.canSprint`). A
|
||||
/// rider's `LocalPlayer.isSprintingPossible` asks its vehicle.
|
||||
pub fn can_sprint(s: Species) -> bool {
|
||||
matches!(s, Species::Camel | Species::CamelHusk)
|
||||
}
|
||||
/// `AbstractHorse.getMaxTemper`: 100, 30 for llamas.
|
||||
pub fn max_temper(s: Species) -> i32 {
|
||||
if super::llama::is_llama(s) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,7 +155,7 @@ fn baby_turtle_on_land(s: &super::Seen) -> bool {
|
||||
/// `universal_anger` rule on and no anger target left, any player. Creative and
|
||||
/// spectator players are dropped first (`isValidPlayerTarget`), by the goal's
|
||||
/// `can_target_player`.
|
||||
fn angry_at_player(m: &Mob, w: &MobWorld) -> bool {
|
||||
pub(super) fn angry_at_player(m: &Mob, w: &MobWorld) -> bool {
|
||||
m.extra.angry_at == Some(Who::Player)
|
||||
|| (w.universal_anger && m.extra.anger > 0 && m.extra.angry_at.is_none())
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn register(m: &mut Mob) {
|
||||
2,
|
||||
NearestTarget::player(true)
|
||||
.interval(10)
|
||||
.when(|m, _| m.extra.anger > 0),
|
||||
.when(super::tame::angry_at_player),
|
||||
);
|
||||
}
|
||||
S::Drowned => {
|
||||
@@ -479,6 +479,12 @@ fn zombified_piglin_step(m: &mut Mob, cx: &mut Cx) {
|
||||
} else if m.ai.target.is_none() {
|
||||
m.extra.flag2 = false;
|
||||
}
|
||||
// The anger's target is whoever the piglin holds as its target, so a
|
||||
// piglin hit by another mob is angry at that mob and not at the player
|
||||
// (`NearestAttackableTargetGoal` tests `isAngryAt`).
|
||||
if m.ai.target.is_some() {
|
||||
m.extra.angry_at = m.ai.target;
|
||||
}
|
||||
if m.extra.anger > 0 {
|
||||
m.extra.anger -= 1;
|
||||
if !m.is_baby() {
|
||||
@@ -500,6 +506,7 @@ fn zombified_piglin_step(m: &mut Mob, cx: &mut Cx) {
|
||||
}
|
||||
if m.extra.anger == 0 {
|
||||
m.ai.target = None;
|
||||
m.extra.angry_at = None;
|
||||
m.extra.flag2 = false;
|
||||
}
|
||||
} else {
|
||||
@@ -1572,4 +1579,36 @@ mod wither_arrow_tests {
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
|
||||
/// #9808: a zombified piglin angry at another mob (a skeleton's arrow
|
||||
/// hit it) is not angry at the player (`NeutralMob.isAngryAt`).
|
||||
#[test]
|
||||
fn a_piglin_angry_at_a_mob_does_not_target_the_player() {
|
||||
with_test_world(&[], |w, types| {
|
||||
let mut w = w.clone();
|
||||
w.player = Some(test_player(Vec3::new(3.5, 64.0, 0.5)));
|
||||
let kind = Arc::new(types.types["minecraft:zombified_piglin"].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
m.extra.anger = 300;
|
||||
m.extra.angry_at = Some(Who::Mob(99));
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
for _ in 0..40 {
|
||||
let mut events = Vec::new();
|
||||
m.tick(&w, &mut lr, &mut events);
|
||||
assert_ne!(m.ai.target, Some(Who::Player));
|
||||
}
|
||||
// Fresh piglin, angry at the player: it does take the player.
|
||||
let kind = Arc::new(types.types["minecraft:zombified_piglin"].clone());
|
||||
let mut m = Mob::new(2, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
m.extra.anger = 300;
|
||||
m.extra.angry_at = Some(Who::Player);
|
||||
let mut hit = false;
|
||||
for _ in 0..60 {
|
||||
let mut events = Vec::new();
|
||||
m.tick(&w, &mut lr, &mut events);
|
||||
hit |= m.ai.target == Some(Who::Player);
|
||||
}
|
||||
assert!(hit, "angry at the player targets the player");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -932,8 +932,29 @@ impl PlayerMotion {
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
let conditions = self.sprint_conditions();
|
||||
self.run_sprint_tick(keys, conditions, sprint_window)
|
||||
}
|
||||
/// The same phase for a passenger: `LocalPlayer.isSprintingPossible` asks the
|
||||
/// vehicle's `canSprint` and this client's authority there, not food.
|
||||
pub fn sprint_input_tick_riding(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
vehicle_can_sprint: bool,
|
||||
vehicle_local: bool,
|
||||
) -> Result<()> {
|
||||
let conditions = crate::sprint::Conditions {
|
||||
passenger: true,
|
||||
vehicle_can_sprint,
|
||||
vehicle_local,
|
||||
..self.sprint_conditions()
|
||||
};
|
||||
self.run_sprint_tick(keys, conditions, sprint_window)
|
||||
}
|
||||
fn sprint_conditions(&self) -> crate::sprint::Conditions {
|
||||
crate::sprint::Conditions {
|
||||
sprinting: self.sprinting,
|
||||
mobility_restricted: self.mobility_restricted,
|
||||
enough_food: self.health.food.has_enough_food(),
|
||||
@@ -951,7 +972,14 @@ impl PlayerMotion {
|
||||
under_water: self.is_under_water(),
|
||||
swimming: self.swimming,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
fn run_sprint_tick(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
conditions: crate::sprint::Conditions,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
// The sprint setter edits attributes and flags, independent of local
|
||||
// keyboard history. Retain the input prefix even if that setter fails.
|
||||
let mut state = self.sprint_input;
|
||||
@@ -2856,7 +2884,7 @@ impl LivingAirWorld for MotionWorld<'_, '_, '_> {
|
||||
Ok(self.player.status.levitation)
|
||||
}
|
||||
fn is_client_side(&mut self) -> Result<bool> {
|
||||
Ok(false)
|
||||
Ok(self.player.client_side)
|
||||
}
|
||||
fn has_chunk_at(&mut self, p: BlockPos) -> Result<bool> {
|
||||
Ok(self.scene.chunks.contains_key(&(p.x >> 4, p.z >> 4)))
|
||||
@@ -2868,7 +2896,7 @@ impl LivingAirWorld for MotionWorld<'_, '_, '_> {
|
||||
self.player.position.y
|
||||
}
|
||||
fn minimum_y(&mut self) -> Result<i32> {
|
||||
Ok(0)
|
||||
Ok(self.scene.shape.min_y)
|
||||
}
|
||||
fn discard_friction(&mut self) -> Result<bool> {
|
||||
Ok(false)
|
||||
@@ -5835,4 +5863,43 @@ mod tests {
|
||||
"the flight speed follows the sprint state, not the sprint key"
|
||||
);
|
||||
}
|
||||
/// #9817: `LivingEntity.travelInAir`: a client (`level.isClientSide()`) whose
|
||||
/// chunk below is not loaded skips gravity and falls at 0.1 a tick
|
||||
/// (above `getMinY`), before the 0.98 vertical drag.
|
||||
#[test]
|
||||
fn a_client_over_an_unloaded_chunk_falls_at_a_tenth() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
for (client_side, want) in [
|
||||
(true, -0.1 * f64::from(0.98_f32)),
|
||||
(false, -0.0784000015258789),
|
||||
] {
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
// Far outside the test scene's chunks.
|
||||
let at = Vec3::new(10_000.5, 20.0, 10_000.5);
|
||||
assert!(!scene.chunks.contains_key(&(10_000 >> 4, 10_000 >> 4)));
|
||||
let mut player = PlayerMotion::new(&resources, at).unwrap();
|
||||
player.client_side = client_side;
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
health: &resources.health,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
assert_eq!(player.velocity.y, want, "client_side={client_side}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! are signed cookies, and GitHub holds everything else).
|
||||
mod archive;
|
||||
pub mod github;
|
||||
pub mod page;
|
||||
pub mod ratings;
|
||||
|
||||
use axum::{
|
||||
@@ -45,8 +46,8 @@ const PER_ACCOUNT: usize = 6;
|
||||
const PER_HOUR: usize = 60;
|
||||
/// The page's JSON.
|
||||
const MAX_FORM: usize = 64 << 10;
|
||||
/// A submission's whole body: the file, the page and the form's framing.
|
||||
const MAX_BODY: usize = index::MAX_FILE as usize + MAX_FORM * 2;
|
||||
/// A submission's whole body: the file, the page, its images and the form's framing.
|
||||
const MAX_BODY: usize = index::MAX_FILE as usize + page::MAX_IMAGES + MAX_FORM * 2;
|
||||
/// Ratings set per account per hour, and by everyone.
|
||||
const RATINGS_PER_ACCOUNT: usize = 30;
|
||||
const RATINGS_PER_HOUR: usize = 600;
|
||||
@@ -498,16 +499,31 @@ pub struct Form {
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
pub license: String,
|
||||
/// The code's address (a file upload's); a repository listing's is the repository.
|
||||
/// The code's address.
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
/// `owner/name` on GitHub: lists the project by its repository, whose releases are its
|
||||
/// versions. No file is sent.
|
||||
/// Refused: the registry no longer follows repositories (every version is a submission).
|
||||
/// Kept so an old form's listing gets a clear answer.
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
/// `https://` only; an icon in the repository is a pull request by hand.
|
||||
/// Refused: icons are uploaded (the `icon` part) and kept in the registry.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// 1 to 3 of the kind's categories.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// The project's screenshots to keep, in order (`screenshots/<id>/<name>` of its entry);
|
||||
/// uploaded ones come after them. Absent: all of them.
|
||||
#[serde(default)]
|
||||
pub screenshots: Option<Vec<String>>,
|
||||
/// A `shaderpack`'s submitter says they made it and its licence lets the registry hand it out.
|
||||
#[serde(default)]
|
||||
pub own_work: bool,
|
||||
/// Changes the page only, with no file ("Edit project").
|
||||
#[serde(default)]
|
||||
pub edit: bool,
|
||||
/// A new project's minisign public key.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
@@ -665,11 +681,11 @@ async fn submit(
|
||||
drain_form(&mut multipart).await;
|
||||
}
|
||||
let result = match result {
|
||||
Ok((form, file)) => {
|
||||
Ok((form, file, images)) => {
|
||||
let inner = inner.clone();
|
||||
tokio::task::spawn_blocking(move || match &file {
|
||||
Some(file) => publish(&inner.config, &login, user_id, &form, file),
|
||||
None => list(&inner.config, &login, user_id, &form),
|
||||
Some(file) => publish(&inner.config, &login, user_id, &form, file, &images),
|
||||
None => page::edit(&inner.config, &login, user_id, &form, &images),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -685,15 +701,29 @@ async fn submit(
|
||||
result.map(|url| Json(json!({"pull_request": url})))
|
||||
}
|
||||
|
||||
/// Reads the form's two parts: `entry` (JSON) and `file`, streamed to `dir`.
|
||||
/// Reads the form's parts: `entry` (JSON), `file`, streamed to `dir`, and the images, `icon`
|
||||
/// and up to 8 `screenshot`s.
|
||||
async fn receive(
|
||||
multipart: &mut Multipart,
|
||||
dir: &Path,
|
||||
) -> Result<(Form, Option<PathBuf>), Refusal> {
|
||||
) -> Result<(Form, Option<PathBuf>, page::Uploads), Refusal> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let (mut form, mut file) = (None, None);
|
||||
let mut images = page::Uploads::default();
|
||||
while let Some(mut field) = multipart.next_field().await.map_err(torn)? {
|
||||
match field.name() {
|
||||
Some(part @ ("icon" | "screenshot")) => {
|
||||
let part = part.to_owned();
|
||||
let name = field.file_name().unwrap_or("").to_owned();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = field.chunk().await.map_err(torn)? {
|
||||
bytes.extend_from_slice(&chunk);
|
||||
if bytes.len() > page::MAX_IMAGE {
|
||||
return Err(bad(format!("{part} {name:?} is too big")));
|
||||
}
|
||||
}
|
||||
images.add(&part, &name, bytes).map_err(bad)?;
|
||||
}
|
||||
Some("entry") => {
|
||||
let text = field.text().await.map_err(torn)?;
|
||||
if text.len() > MAX_FORM {
|
||||
@@ -723,11 +753,11 @@ async fn receive(
|
||||
}
|
||||
}
|
||||
match (form, file) {
|
||||
(Some(form), None) if form.repo.is_some() => Ok((form, None)),
|
||||
(Some(form), Some(_)) if form.repo.is_some() => Err(bad(
|
||||
"a project listed by its repository sends no file: publish a GitHub release in it",
|
||||
(Some(form), _) if form.repo.is_some() => Err(bad(
|
||||
"the registry doesn't follow repositories: submit each version's file here, so it is checked and reviewed",
|
||||
)),
|
||||
(Some(form), Some(file)) => Ok((form, Some(file))),
|
||||
(Some(form), None) if form.edit => Ok((form, None, images)),
|
||||
(Some(form), Some(file)) if !form.edit => Ok((form, Some(file), images)),
|
||||
_ => Err(bad("send the page and the file")),
|
||||
}
|
||||
}
|
||||
@@ -841,6 +871,7 @@ pub fn publish(
|
||||
user_id: u64,
|
||||
form: &Form,
|
||||
file: &Path,
|
||||
images: &page::Uploads,
|
||||
) -> Result<String, Refusal> {
|
||||
let gh = &config.github;
|
||||
let kind = Kind::from(form.kind.clone());
|
||||
@@ -848,13 +879,7 @@ pub fn publish(
|
||||
if !index::valid_id(&form.id) {
|
||||
return Err(bad("an id is 1 to 64 of a-z, 0-9 and _"));
|
||||
}
|
||||
if form
|
||||
.icon
|
||||
.as_ref()
|
||||
.is_some_and(|i| !i.starts_with("https://"))
|
||||
{
|
||||
return Err(bad("the icon must be an https:// address"));
|
||||
}
|
||||
page::check_form(form, &kind).map_err(bad)?;
|
||||
if !form.source.trim().starts_with("https://") {
|
||||
return Err(bad("the source must be an https:// address of the code"));
|
||||
}
|
||||
@@ -890,6 +915,9 @@ pub fn publish(
|
||||
};
|
||||
let existing = gh.entry(&form.id).map_err(github_down)?;
|
||||
let is_new = existing.is_none();
|
||||
if is_new {
|
||||
page::check_new(form, images).map_err(bad)?;
|
||||
}
|
||||
let turns_native =
|
||||
kind == Kind::NativeMod && existing.as_ref().is_some_and(|(e, _)| e.kind == Kind::Mod);
|
||||
// An owner update: an owner's new version and nothing else of the entry
|
||||
@@ -910,9 +938,9 @@ pub fn publish(
|
||||
owner_ids: vec![user_id],
|
||||
license: form.license.trim().to_owned(),
|
||||
source: form.source.trim().to_owned(),
|
||||
icon: form.icon.clone(),
|
||||
categories: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
icon: None,
|
||||
categories: form.categories.clone(),
|
||||
tags: form.tags.clone(),
|
||||
screenshots: Vec::new(),
|
||||
key: form.key.clone().filter(|k| !k.trim().is_empty()),
|
||||
versions: vec![version.clone()],
|
||||
@@ -975,9 +1003,8 @@ pub fn publish(
|
||||
entry.authors = form.authors.clone();
|
||||
entry.license = form.license.trim().to_owned();
|
||||
entry.source = form.source.trim().to_owned();
|
||||
if form.icon.is_some() {
|
||||
entry.icon = form.icon.clone();
|
||||
}
|
||||
entry.categories = form.categories.clone();
|
||||
entry.tags = form.tags.clone();
|
||||
entry.versions.insert(0, version.clone());
|
||||
let (mut a, mut b) = (entry.clone(), before);
|
||||
a.versions.clear();
|
||||
@@ -987,6 +1014,7 @@ pub fn publish(
|
||||
// could be someone who claimed the login after a rename.
|
||||
owner_update = matched == Ownership::ById
|
||||
&& a == b
|
||||
&& images.is_empty()
|
||||
&& !turns_native
|
||||
&& (entry.key.is_none() || version.signature.is_some())
|
||||
&& version
|
||||
@@ -996,19 +1024,17 @@ pub fn publish(
|
||||
(entry, Some(blob))
|
||||
}
|
||||
};
|
||||
let mut entry = entry;
|
||||
let writes = page::place(&mut entry, form, images).map_err(bad)?;
|
||||
// Every check CI runs but the trial run: the file as its kind, its id and
|
||||
// version, its signature, the entry's fields. CI runs it all again.
|
||||
let files = file.parent().unwrap_or(Path::new("."));
|
||||
std::fs::copy(file, files.join(&sha256)).map_err(|e| bad(e.to_string()))?;
|
||||
let mut checked = entry.clone();
|
||||
checked.versions.truncate(1);
|
||||
if checked
|
||||
.icon
|
||||
.as_ref()
|
||||
.is_some_and(|i| !i.starts_with("https://"))
|
||||
{
|
||||
checked.icon = None;
|
||||
}
|
||||
// The images were checked by `page`; the registry's own files aren't in this scratch folder.
|
||||
checked.icon = None;
|
||||
checked.screenshots.clear();
|
||||
let project = registry::project(
|
||||
&checked,
|
||||
&Sources {
|
||||
@@ -1031,7 +1057,7 @@ pub fn publish(
|
||||
// The branch is there: a pull request, someone else's submission, or
|
||||
// this submitter's own that GitHub broke off (a timeout past the
|
||||
// branch's creation). Only the last is taken up again, from main: the
|
||||
// branch holds no commit yet, or the one this form wrote for them.
|
||||
// branch holds no commit yet, or only those this form wrote for them.
|
||||
if let Some(url) = gh.open_pull(&branch).map_err(github_down)? {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::CONFLICT,
|
||||
@@ -1042,14 +1068,15 @@ pub fn publish(
|
||||
));
|
||||
}
|
||||
let commits = gh.commits(&branch).map_err(github_down)?;
|
||||
let theirs = commits.len() <= 1
|
||||
&& commits.iter().all(|m| {
|
||||
m.lines().any(|line| {
|
||||
line.trim()
|
||||
.strip_prefix("Submission:")
|
||||
.is_some_and(|tag| submission_tag_valid(config, &branch, login, tag.trim()))
|
||||
})
|
||||
});
|
||||
// Every commit the form wrote (the entry, then its images) carries the tag; one without
|
||||
// it is someone else's.
|
||||
let theirs = commits.iter().all(|m| {
|
||||
m.lines().any(|line| {
|
||||
line.trim()
|
||||
.strip_prefix("Submission:")
|
||||
.is_some_and(|tag| submission_tag_valid(config, &branch, login, tag.trim()))
|
||||
})
|
||||
});
|
||||
if !theirs {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::CONFLICT,
|
||||
@@ -1074,6 +1101,7 @@ pub fn publish(
|
||||
&message,
|
||||
)
|
||||
.map_err(github_down)?;
|
||||
page::write(gh, &writes, &branch, &message)?;
|
||||
let v = &project.versions[0];
|
||||
let capabilities: Vec<String> = v
|
||||
.capabilities
|
||||
@@ -1161,113 +1189,6 @@ pub fn publish(
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// A new project listed by its repository: the entry (a link and a page) on a new branch and a
|
||||
/// pull request opened. Its versions are the repository's GitHub releases, read by the registry
|
||||
/// without a pull request (docs/api/mod-index.md); a reviewer reads the repository now, once.
|
||||
pub fn list(config: &Config, login: &str, user_id: u64, form: &Form) -> Result<String, Refusal> {
|
||||
let gh = &config.github;
|
||||
let kind = Kind::from(form.kind.clone());
|
||||
let repo = form.repo.as_deref().unwrap_or("").trim().to_owned();
|
||||
if form
|
||||
.icon
|
||||
.as_ref()
|
||||
.is_some_and(|i| !i.starts_with("https://"))
|
||||
{
|
||||
return Err(bad("the icon must be an https:// address"));
|
||||
}
|
||||
let entry = Entry {
|
||||
id: form.id.clone(),
|
||||
kind: kind.clone(),
|
||||
repo: Some(repo.clone()),
|
||||
name: form.name.trim().to_owned(),
|
||||
summary: form.summary.trim().to_owned(),
|
||||
description: form.description.clone(),
|
||||
authors: form.authors.clone(),
|
||||
owners: vec![login.to_owned()],
|
||||
owner_ids: vec![user_id],
|
||||
license: form.license.trim().to_owned(),
|
||||
source: String::new(),
|
||||
icon: form.icon.clone(),
|
||||
categories: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
screenshots: Vec::new(),
|
||||
key: form.key.clone().filter(|k| !k.trim().is_empty()),
|
||||
versions: Vec::new(),
|
||||
};
|
||||
// The form asks for the page; the repository's horizon-registry.toml may change it later.
|
||||
let problems = entry.resolved(®istry::Page::default()).check_fields();
|
||||
if !problems.is_empty() {
|
||||
return Err(bad(problems.join("; ")));
|
||||
}
|
||||
let Some(info) = gh.repository(&repo).map_err(github_down)? else {
|
||||
return Err(bad(format!(
|
||||
"https://github.com/{repo} was not found, or is private"
|
||||
)));
|
||||
};
|
||||
if gh.entry(&form.id).map_err(github_down)?.is_some() {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"{} is already listed: publish a GitHub release in its repository",
|
||||
form.id
|
||||
),
|
||||
));
|
||||
}
|
||||
let text = entry.to_toml().map_err(bad)?;
|
||||
let branch = format!("list/{}", form.id);
|
||||
if !gh.branch(&branch).map_err(github_down)? {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"{} was already submitted: its pull request is open",
|
||||
form.id
|
||||
),
|
||||
));
|
||||
}
|
||||
let message = format!(
|
||||
"List {}\n\nSubmitted by @{login} through the form.\n\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>",
|
||||
form.id
|
||||
);
|
||||
gh.put(
|
||||
&format!("mods/{}.toml", form.id),
|
||||
&text,
|
||||
&branch,
|
||||
None,
|
||||
&message,
|
||||
)
|
||||
.map_err(github_down)?;
|
||||
let owner = info["owner"]["login"].as_str().unwrap_or("?");
|
||||
let body = format!(
|
||||
"<!-- submitted-by: {login} -->\n🤖 Submitted through [{site}]({public}/mods/submit) by @{login}. The form opens the pull request with \
|
||||
the registry's token: neither Ludovic nor a reviewer wrote it.\n\n\
|
||||
| | |\n|---|---|\n| Project | `{id}` ({kind}, new) |\n| Repository | https://github.com/{repo} (owned by `{owner}`: {same}) |\n| Signed | {signed} |\n\n\
|
||||
**Reviewers: this is the only review.** Releases published in that repository later are listed after the registry's automatic checks, with no pull request. \
|
||||
Read the repository and its source, check that the submitter is allowed to speak for it, and that the page is accurate. \
|
||||
The checks below trial the repository's existing releases.",
|
||||
public = config.public_url,
|
||||
site = config
|
||||
.public_url
|
||||
.split_once("://")
|
||||
.map_or(config.public_url.as_str(), |(_, host)| host),
|
||||
id = form.id,
|
||||
kind = kind.name(),
|
||||
same = if owner.eq_ignore_ascii_case(login) {
|
||||
"the submitter"
|
||||
} else {
|
||||
"NOT the submitter: check they act for it"
|
||||
},
|
||||
signed = if entry.key.is_some() {
|
||||
"releases must be signed with the key"
|
||||
} else {
|
||||
"no key: releases are unsigned"
|
||||
},
|
||||
);
|
||||
let (url, _) = gh
|
||||
.pull(&format!("List {}", form.id), &branch, &body)
|
||||
.map_err(github_down)?;
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! A project's page in a submission: its categories, tags and description
|
||||
//! (`horizon_mods::index::page`), and its images, uploaded with the form and
|
||||
//! written into the registry (`icons/<id>.png`, `screenshots/<id>/<n>.<ext>`)
|
||||
//! so the site and the launcher load them only from the registry's own host.
|
||||
//! Also "Edit project": an owner changes the page with no new version.
|
||||
use crate::{Config, Form, Ownership, Refusal, bad, github::GitHub, github_down, now, ownership};
|
||||
use axum::http::StatusCode;
|
||||
use horizon_mods::index::{Kind, page as rules, registry::Entry};
|
||||
|
||||
/// The biggest image part: a screenshot (an icon is smaller, checked when added).
|
||||
pub const MAX_IMAGE: usize = rules::MAX_SCREENSHOT as usize;
|
||||
/// All of a submission's images together.
|
||||
pub const MAX_IMAGES: usize = MAX_IMAGE * rules::MAX_SCREENSHOTS + MAX_ICON;
|
||||
const MAX_ICON: usize = 256 << 10;
|
||||
|
||||
/// The images a submission uploads, checked.
|
||||
#[derive(Default)]
|
||||
pub struct Uploads {
|
||||
icon: Option<Vec<u8>>,
|
||||
/// Extension and bytes, in the order sent.
|
||||
screenshots: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl Uploads {
|
||||
/// Takes one image part (`icon` or `screenshot`) named `name` by the browser.
|
||||
pub fn add(&mut self, part: &str, name: &str, bytes: Vec<u8>) -> Result<(), String> {
|
||||
if part == "icon" {
|
||||
if bytes.len() > MAX_ICON {
|
||||
return Err(format!("the icon is over {} KiB", MAX_ICON >> 10));
|
||||
}
|
||||
rules::check_icon(&bytes)?;
|
||||
self.icon = Some(bytes);
|
||||
return Ok(());
|
||||
}
|
||||
if self.screenshots.len() == rules::MAX_SCREENSHOTS {
|
||||
return Err(format!("at most {} screenshots", rules::MAX_SCREENSHOTS));
|
||||
}
|
||||
rules::check_screenshot(name, &bytes)?;
|
||||
let (format, ..) = rules::image_size(&bytes).ok_or("not an image")?;
|
||||
self.screenshots
|
||||
.push((format.extensions()[0].to_owned(), bytes));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.icon.is_none() && self.screenshots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's own rules, for any submission: categories of the kind, plain tags, no icon address,
|
||||
/// and a `shaderpack`'s statement that it is the submitter's to hand out.
|
||||
pub fn check_form(form: &Form, kind: &Kind) -> Result<(), String> {
|
||||
let mut errors = rules::check(kind, &form.description, &form.categories, &form.tags);
|
||||
if form.icon.is_some() {
|
||||
errors.push("upload the icon (a square PNG) instead of giving its address".into());
|
||||
}
|
||||
if *kind == Kind::ShaderPack && !form.own_work {
|
||||
errors.push(
|
||||
"a shaders/-folder pack is listed only by its author, under a licence that lets the registry hand it out: \
|
||||
confirm both"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a new project must have: a description, a category and an icon.
|
||||
pub fn check_new(form: &Form, images: &Uploads) -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
let length = form.description.trim().chars().count();
|
||||
if length < rules::MIN_DESCRIPTION {
|
||||
errors.push(format!(
|
||||
"the description is {length} characters: write at least {} (what it does, how to use it)",
|
||||
rules::MIN_DESCRIPTION
|
||||
));
|
||||
}
|
||||
if form.categories.is_empty() {
|
||||
errors.push(format!("pick 1 to {} categories", rules::MAX_CATEGORIES));
|
||||
}
|
||||
if images.icon.is_none() {
|
||||
errors.push("upload an icon: a square PNG, 64 to 1024 pixels".into());
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the uploaded images in `entry` and returns the files to write. The form's `screenshots`
|
||||
/// keeps those of the entry it lists, in its order (absent: all of them); uploads come after.
|
||||
pub fn place(
|
||||
entry: &mut Entry,
|
||||
form: &Form,
|
||||
images: &Uploads,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let id = entry.id.clone();
|
||||
let mut writes = Vec::new();
|
||||
if let Some(icon) = &images.icon {
|
||||
let path = format!("icons/{id}.png");
|
||||
entry.icon = Some(path.clone());
|
||||
writes.push((path, icon.clone()));
|
||||
}
|
||||
let mut kept = match &form.screenshots {
|
||||
None => entry.screenshots.clone(),
|
||||
Some(list) => {
|
||||
if let Some(stranger) = list.iter().find(|s| !entry.screenshots.contains(s)) {
|
||||
return Err(format!("{stranger:?} is not one of {id}'s screenshots"));
|
||||
}
|
||||
list.clone()
|
||||
}
|
||||
};
|
||||
if kept.len() + images.screenshots.len() > rules::MAX_SCREENSHOTS {
|
||||
return Err(format!("at most {} screenshots", rules::MAX_SCREENSHOTS));
|
||||
}
|
||||
// New names never reuse one the entry has had, so a page cached elsewhere never shows the wrong image.
|
||||
let taken: Vec<String> = entry
|
||||
.screenshots
|
||||
.iter()
|
||||
.filter_map(|s| s.rsplit('/').next()?.split('.').next().map(str::to_owned))
|
||||
.collect();
|
||||
let mut n = 1;
|
||||
for (extension, bytes) in &images.screenshots {
|
||||
while taken.contains(&n.to_string()) {
|
||||
n += 1;
|
||||
}
|
||||
let path = format!("screenshots/{id}/{n}.{extension}");
|
||||
kept.push(path.clone());
|
||||
writes.push((path, bytes.clone()));
|
||||
n += 1;
|
||||
}
|
||||
entry.screenshots = kept;
|
||||
Ok(writes)
|
||||
}
|
||||
|
||||
/// Writes the images on `branch`, each replacing what main has at its path.
|
||||
pub fn write(
|
||||
gh: &GitHub,
|
||||
writes: &[(String, Vec<u8>)],
|
||||
branch: &str,
|
||||
message: &str,
|
||||
) -> Result<(), Refusal> {
|
||||
for (path, bytes) in writes {
|
||||
let blob = gh.blob(path).map_err(github_down)?;
|
||||
gh.put(path, bytes, branch, blob.as_deref(), message)
|
||||
.map_err(github_down)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// "Edit project": an owner (by GitHub ID) changes the page of `form.id`, no version added. Opens
|
||||
/// a pull request labelled `owner-edit`; returns its address.
|
||||
pub fn edit(
|
||||
config: &Config,
|
||||
login: &str,
|
||||
user_id: u64,
|
||||
form: &Form,
|
||||
images: &Uploads,
|
||||
) -> Result<String, Refusal> {
|
||||
let gh = &config.github;
|
||||
let Some((before, blob)) = gh.entry(&form.id).map_err(github_down)? else {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("{} is not in the registry", form.id),
|
||||
));
|
||||
};
|
||||
if ownership(&before, login, user_id) != Ownership::ById {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
format!(
|
||||
"{} is {}'s: only its owners edit its page",
|
||||
form.id,
|
||||
before.owners.join(", ")
|
||||
),
|
||||
));
|
||||
}
|
||||
check_form(form, &before.kind).map_err(bad)?;
|
||||
let mut entry = before.clone();
|
||||
entry.name = form.name.trim().to_owned();
|
||||
entry.summary = form.summary.trim().to_owned();
|
||||
entry.description = form.description.clone();
|
||||
entry.authors = form.authors.clone();
|
||||
entry.license = form.license.trim().to_owned();
|
||||
entry.source = form.source.trim().to_owned();
|
||||
entry.categories = form.categories.clone();
|
||||
entry.tags = form.tags.clone();
|
||||
let writes = place(&mut entry, form, images).map_err(bad)?;
|
||||
let problems = entry.check_fields();
|
||||
if !problems.is_empty() {
|
||||
return Err(bad(problems.join("; ")));
|
||||
}
|
||||
if entry == before && writes.is_empty() {
|
||||
return Err(bad("nothing changed"));
|
||||
}
|
||||
let changed: Vec<&str> = [
|
||||
("name", entry.name != before.name),
|
||||
("summary", entry.summary != before.summary),
|
||||
("description", entry.description != before.description),
|
||||
("authors", entry.authors != before.authors),
|
||||
("licence", entry.license != before.license),
|
||||
("source", entry.source != before.source),
|
||||
("categories", entry.categories != before.categories),
|
||||
("tags", entry.tags != before.tags),
|
||||
("icon", images.icon.is_some()),
|
||||
("screenshots", entry.screenshots != before.screenshots),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(field, changed)| changed.then_some(field))
|
||||
.collect();
|
||||
let branch = format!("edit/{}-{}", form.id, now());
|
||||
if !gh.branch(&branch).map_err(github_down)? {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::CONFLICT,
|
||||
"an edit of this project was just sent: wait a second and send it again",
|
||||
));
|
||||
}
|
||||
let message = format!(
|
||||
"Edit {}'s page\n\nSubmitted by @{login} through the form.\n\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>",
|
||||
form.id
|
||||
);
|
||||
let text = entry.to_toml().map_err(bad)?;
|
||||
gh.put(
|
||||
&format!("mods/{}.toml", form.id),
|
||||
&text,
|
||||
&branch,
|
||||
Some(&blob),
|
||||
&message,
|
||||
)
|
||||
.map_err(github_down)?;
|
||||
write(gh, &writes, &branch, &message)?;
|
||||
let body = format!(
|
||||
"<!-- submitted-by: {login} -->\n🤖 Page edit sent through the form by @{login}, an owner of `{id}` (GitHub ID \
|
||||
{user_id}). The form opens the pull request with the registry's token: neither Ludovic nor a reviewer wrote it.\n\n\
|
||||
No new version: it changes the {fields}. Reviewers: read the new page text and look at the images.",
|
||||
id = form.id,
|
||||
fields = changed.join(", "),
|
||||
);
|
||||
let (url, number) = gh
|
||||
.pull(&format!("Edit {}'s page", form.id), &branch, &body)
|
||||
.map_err(github_down)?;
|
||||
if let Err(e) = gh.label(number, "owner-edit") {
|
||||
eprintln!("labelling {url}: {} {}", e.status, e.message);
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
@@ -69,34 +69,27 @@ async fn mock_github() -> (String, Shared) {
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/contents/mods/{file}",
|
||||
"/repos/o/r/contents/{*path}",
|
||||
get(
|
||||
|State(s): State<Shared>, UrlPath(file): UrlPath<String>| async move {
|
||||
|State(s): State<Shared>, UrlPath(path): UrlPath<String>| async move {
|
||||
let s = s.lock().unwrap();
|
||||
match s.main.get(&format!("mods/{file}")) {
|
||||
match s.main.get(&path) {
|
||||
Some(text) => Ok(Json(json!({"content": STANDARD.encode(text), "sha": "blob1"}))),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
},
|
||||
)
|
||||
.put(
|
||||
|State(s): State<Shared>, UrlPath(file): UrlPath<String>, Json(b): Json<Value>| async move {
|
||||
let text = String::from_utf8(STANDARD.decode(b["content"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let key = (b["branch"].as_str().unwrap().to_owned(), format!("mods/{file}"));
|
||||
|State(s): State<Shared>, UrlPath(path): UrlPath<String>, Json(b): Json<Value>| async move {
|
||||
let bytes = STANDARD.decode(b["content"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let key = (b["branch"].as_str().unwrap().to_owned(), path);
|
||||
let value = (text, b["message"].as_str().unwrap().to_owned(), b["sha"].as_str().map(str::to_owned));
|
||||
s.lock().unwrap().commits.insert(key, value);
|
||||
Json(json!({}))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/repos/alice/coords",
|
||||
get(|| async { Json(json!({"private": false, "owner": {"login": "alice"}})) }),
|
||||
)
|
||||
.route(
|
||||
"/repos/alice/secret",
|
||||
get(|| async { Json(json!({"private": true, "owner": {"login": "alice"}})) }),
|
||||
)
|
||||
.route(
|
||||
"/repos/o/r/releases/tags/staging",
|
||||
get(|State(s): State<Shared>| async move {
|
||||
@@ -205,8 +198,23 @@ fn fixtures() -> PathBuf {
|
||||
struct Client {
|
||||
app: Router,
|
||||
cookies: HashMap<String, String>,
|
||||
/// Image parts sent with every submission: (part, file name, bytes). A new project's icon
|
||||
/// by default.
|
||||
images: Vec<(&'static str, String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// The header of a PNG of `w`×`h` pixels: what the service reads of an image.
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
b.extend(w.to_be_bytes());
|
||||
b.extend(h.to_be_bytes());
|
||||
b.extend([8, 6, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
const DESCRIPTION: &str =
|
||||
"Shows your coordinates, facing and the time of day in a corner of the screen.";
|
||||
|
||||
impl Client {
|
||||
async fn send(&mut self, request: Request<Body>) -> (StatusCode, Value, Option<String>) {
|
||||
let response = self.app.clone().oneshot(request).await.unwrap();
|
||||
@@ -307,6 +315,11 @@ impl Client {
|
||||
body.extend_from_slice(file);
|
||||
body.extend_from_slice(b"\r\n");
|
||||
}
|
||||
for (part, name, bytes) in &self.images {
|
||||
body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{part}\"; filename=\"{name}\"\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes());
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(b"\r\n");
|
||||
}
|
||||
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
||||
let request = Request::post("/api/mods/submit")
|
||||
.header(header::COOKIE, self.cookie_header())
|
||||
@@ -330,6 +343,8 @@ fn form() -> Value {
|
||||
"kind": "mod",
|
||||
"name": "Coordinates HUD",
|
||||
"summary": "Your coordinates in a corner.",
|
||||
"description": DESCRIPTION,
|
||||
"categories": ["utility"],
|
||||
"authors": ["Alice"],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/alice/coords",
|
||||
@@ -380,6 +395,7 @@ async fn setup_keyed(
|
||||
let client = Client {
|
||||
app: router(App::new(config).unwrap()),
|
||||
cookies: HashMap::new(),
|
||||
images: vec![("icon", "icon.png".into(), png(128, 128))],
|
||||
};
|
||||
(client, mock)
|
||||
}
|
||||
@@ -458,7 +474,7 @@ fn published_with_ids(owner: &str, key: bool, ids: &[u64]) -> String {
|
||||
),
|
||||
};
|
||||
let text = format!(
|
||||
"id = \"coords_hud\"\nkind = \"mod\"\nname = \"Coordinates HUD\"\nsummary = \"Your coordinates in a corner.\"\nauthors = [\"Alice\"]\nowners = [\"{owner}\"]\n{ids}license = \"MIT\"\nsource = \"https://github.com/alice/coords\"\n{}\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"{}\"\n",
|
||||
"id = \"coords_hud\"\nkind = \"mod\"\nname = \"Coordinates HUD\"\nsummary = \"Your coordinates in a corner.\"\ndescription = \"{DESCRIPTION}\"\ncategories = [\"utility\"]\nauthors = [\"Alice\"]\nowners = [\"{owner}\"]\n{ids}license = \"MIT\"\nsource = \"https://github.com/alice/coords\"\n{}\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"{}\"\n",
|
||||
if key {
|
||||
format!("key = {}\n", f["key"])
|
||||
} else {
|
||||
@@ -483,6 +499,8 @@ async fn update(
|
||||
.insert("mods/coords_hud.toml".into(), main);
|
||||
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
|
||||
let csrf = client.sign_in().await;
|
||||
// An update uploads no image: an owner's version alone.
|
||||
client.images.clear();
|
||||
let mut f = form();
|
||||
change(&mut f);
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
@@ -545,10 +563,10 @@ async fn everything_else_waits_for_a_reviewer() {
|
||||
)
|
||||
.await;
|
||||
unsigned(&body, &labels);
|
||||
// A new icon.
|
||||
// Another category.
|
||||
let (body, labels) = update(
|
||||
published("alice", true),
|
||||
|f| f["icon"] = json!("https://x.test/i.png"),
|
||||
|f| f["categories"] = json!(["utility", "mobs"]),
|
||||
Some(UPDATE_KEY),
|
||||
)
|
||||
.await;
|
||||
@@ -644,6 +662,7 @@ async fn what_the_form_refuses() {
|
||||
// A shader pack runs the game's own check: a mod's file isn't one.
|
||||
let mut f = form();
|
||||
f["kind"] = json!("shader");
|
||||
f["categories"] = json!(["bloom"]);
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
|
||||
refused(status, &json, "horizon-shaders.toml: missing");
|
||||
@@ -838,6 +857,7 @@ async fn ratings() {
|
||||
let mut other = Client {
|
||||
app: client.app.clone(),
|
||||
cookies: HashMap::new(),
|
||||
images: Vec::new(),
|
||||
};
|
||||
mock.lock().unwrap().login = "carol".into();
|
||||
let carol = other.sign_in().await;
|
||||
@@ -1088,50 +1108,25 @@ async fn a_mod_turns_native_once() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_project_is_listed_by_its_repository_and_never_updated_by_the_form() {
|
||||
async fn the_registry_follows_no_repository() {
|
||||
let (mut client, mock) = setup().await;
|
||||
let csrf = client.sign_in().await;
|
||||
let listing = json!({
|
||||
"id": "coords_hud", "kind": "native-mod", "repo": "alice/coords", "name": "Coordinates HUD",
|
||||
"id": "other", "kind": "native-mod", "repo": "alice/coords", "name": "Coordinates HUD",
|
||||
"summary": "Your coordinates in a corner.", "license": "MIT",
|
||||
});
|
||||
let (status, json) = client.submit_with(&csrf, &listing, None).await;
|
||||
assert_eq!(status, StatusCode::OK, "{json}");
|
||||
{
|
||||
let m = mock.lock().unwrap();
|
||||
assert!(m.assets.is_empty(), "no file is uploaded");
|
||||
let (text, _, blob) = &m.commits[&(
|
||||
"list/coords_hud".to_owned(),
|
||||
"mods/coords_hud.toml".to_owned(),
|
||||
)];
|
||||
assert!(blob.is_none());
|
||||
let entry: Entry = toml::from_str(text).unwrap();
|
||||
assert_eq!(entry.repo.as_deref(), Some("alice/coords"));
|
||||
assert_eq!(entry.owners, ["alice"]);
|
||||
assert!(entry.versions.is_empty());
|
||||
let body = m.pulls[0]["body"].as_str().unwrap();
|
||||
assert!(
|
||||
body.contains("the submitter") && !body.contains("owner-update"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(m.labels.is_empty());
|
||||
}
|
||||
// A repository that is not found or private, a missing licence, a file beside a repository.
|
||||
let mut other = listing.clone();
|
||||
other["id"] = json!("other");
|
||||
for (field, value) in [
|
||||
("repo", json!("alice/secret")),
|
||||
("repo", json!("alice/nothing")),
|
||||
("license", json!("")),
|
||||
] {
|
||||
let mut bad = other.clone();
|
||||
bad[field] = value;
|
||||
let (status, json) = client.submit_with(&csrf, &bad, None).await;
|
||||
for file in [None, Some(&b"x"[..])] {
|
||||
let (status, json) = client.submit_with(&csrf, &listing, file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("doesn't follow repositories"),
|
||||
"{json}"
|
||||
);
|
||||
}
|
||||
let (status, _) = client.submit_with(&csrf, &other, Some(b"x")).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
// An update of a project listed by its repository: publish a release instead.
|
||||
// An entry from when it did: an update through the form is still refused.
|
||||
let main = published("alice", false).replacen(
|
||||
"kind = \"mod\"\n",
|
||||
"kind = \"mod\"\nrepo = \"alice/coords\"\n",
|
||||
@@ -1144,8 +1139,189 @@ async fn a_project_is_listed_by_its_repository_and_never_updated_by_the_form() {
|
||||
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
|
||||
let (status, json) = client.submit(&csrf, &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::CONFLICT, "{json}");
|
||||
assert!(mock.lock().unwrap().pulls.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_new_project_has_a_page_and_its_images_go_in_the_registry() {
|
||||
let (mut client, mock) = setup().await;
|
||||
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
|
||||
let csrf = client.sign_in().await;
|
||||
// What a new project lacks, each refused before anything reaches GitHub.
|
||||
let refusals = [
|
||||
("description", json!("Too short.")),
|
||||
("categories", json!([])),
|
||||
("categories", json!(["bloom"])),
|
||||
("tags", json!(["Casino"])),
|
||||
("icon", json!("https://x.test/i.png")),
|
||||
];
|
||||
for (field, value) in refusals {
|
||||
let mut f = form();
|
||||
f[field] = value.clone();
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{field} {value}: {json}");
|
||||
}
|
||||
assert!(
|
||||
json["error"].as_str().unwrap().contains("GitHub release"),
|
||||
mock.lock().unwrap().pulls.is_empty(),
|
||||
"nothing reached GitHub"
|
||||
);
|
||||
// Submissions per account per hour are limited: the rest on a fresh service.
|
||||
let (mut client, mock) = setup().await;
|
||||
let csrf = client.sign_in().await;
|
||||
client.images.clear();
|
||||
let (status, json) = client.submit(&csrf, &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
json["error"].as_str().unwrap().contains("upload an icon"),
|
||||
"{json}"
|
||||
);
|
||||
// A JPEG icon, a screenshot too small.
|
||||
for (part, name, bytes) in [
|
||||
("icon", "i.png", vec![0xff, 0xd8, 0xff]),
|
||||
("screenshot", "s.png", png(100, 100)),
|
||||
] {
|
||||
client.images = vec![
|
||||
("icon", "icon.png".into(), png(128, 128)),
|
||||
(part, name.into(), bytes),
|
||||
];
|
||||
let (status, json) = client.submit(&csrf, &form(), &file).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{part}: {json}");
|
||||
}
|
||||
assert!(
|
||||
mock.lock().unwrap().pulls.is_empty(),
|
||||
"nothing reached GitHub"
|
||||
);
|
||||
|
||||
client.images = vec![
|
||||
("icon", "icon.png".into(), png(128, 128)),
|
||||
("screenshot", "a.png".into(), png(1280, 720)),
|
||||
("screenshot", "b.png".into(), png(1920, 1080)),
|
||||
];
|
||||
let mut f = form();
|
||||
f["tags"] = json!(["hud"]);
|
||||
let (status, json) = client.submit(&csrf, &f, &file).await;
|
||||
assert_eq!(status, StatusCode::OK, "{json}");
|
||||
let m = mock.lock().unwrap();
|
||||
let branch = "submit/coords_hud-0.1.0".to_owned();
|
||||
let entry: Entry =
|
||||
toml::from_str(&m.commits[&(branch.clone(), "mods/coords_hud.toml".into())].0).unwrap();
|
||||
assert_eq!(entry.icon.as_deref(), Some("icons/coords_hud.png"));
|
||||
assert_eq!(
|
||||
entry.screenshots,
|
||||
[
|
||||
"screenshots/coords_hud/1.png",
|
||||
"screenshots/coords_hud/2.png"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
(entry.categories.as_slice(), entry.tags.as_slice()),
|
||||
(&["utility".to_owned()][..], &["hud".to_owned()][..])
|
||||
);
|
||||
for path in [
|
||||
"icons/coords_hud.png",
|
||||
"screenshots/coords_hud/1.png",
|
||||
"screenshots/coords_hud/2.png",
|
||||
] {
|
||||
assert!(
|
||||
m.commits.contains_key(&(branch.clone(), path.to_owned())),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `alice`'s project on main with two screenshots.
|
||||
fn with_screenshots() -> String {
|
||||
published("alice", false).replacen(
|
||||
"license = ",
|
||||
"icon = \"icons/coords_hud.png\"\nscreenshots = [\"screenshots/coords_hud/1.png\", \"screenshots/coords_hud/2.png\"]\nlicense = ",
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
async fn edit(
|
||||
client: &mut Client,
|
||||
csrf: &str,
|
||||
change: impl FnOnce(&mut Value),
|
||||
) -> (StatusCode, Value) {
|
||||
let mut f = form();
|
||||
for field in ["key", "signature", "version", "game"] {
|
||||
f.as_object_mut().unwrap().remove(field);
|
||||
}
|
||||
f["edit"] = json!(true);
|
||||
change(&mut f);
|
||||
client.submit_with(csrf, &f, None).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_owner_edits_the_page_without_a_version() {
|
||||
let (mut client, mock) = setup().await;
|
||||
mock.lock()
|
||||
.unwrap()
|
||||
.main
|
||||
.insert("mods/coords_hud.toml".into(), with_screenshots());
|
||||
let csrf = client.sign_in().await;
|
||||
client.images.clear();
|
||||
// Nothing changed; a screenshot that isn't the project's; a file sent with an edit.
|
||||
let (status, json) = edit(&mut client, &csrf, |_| {}).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
json["error"].as_str().unwrap().contains("nothing changed"),
|
||||
"{json}"
|
||||
);
|
||||
let (status, _) = edit(&mut client, &csrf, |f| {
|
||||
f["screenshots"] = json!(["screenshots/other/1.png"])
|
||||
})
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
let mut f = form();
|
||||
f["edit"] = json!(true);
|
||||
let (status, _) = client.submit(&csrf, &f, b"x").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Drops the first screenshot, adds one, changes the categories and tags.
|
||||
client.images = vec![("screenshot", "new.jpg".into(), png(1280, 720))];
|
||||
let (status, _) = edit(&mut client, &csrf, |f| {
|
||||
f["screenshots"] = json!(["screenshots/coords_hud/2.png"])
|
||||
})
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "a PNG named .jpg");
|
||||
client.images = vec![("screenshot", "new.png".into(), png(1280, 720))];
|
||||
let (status, json) = edit(&mut client, &csrf, |f| {
|
||||
f["screenshots"] = json!(["screenshots/coords_hud/2.png"]);
|
||||
f["categories"] = json!(["utility", "management"]);
|
||||
f["tags"] = json!(["hud"]);
|
||||
})
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{json}");
|
||||
{
|
||||
let m = mock.lock().unwrap();
|
||||
let branch = m.branches.last().unwrap().clone();
|
||||
assert!(branch.starts_with("edit/coords_hud-"), "{branch}");
|
||||
let (text, _, blob) = &m.commits[&(branch.clone(), "mods/coords_hud.toml".into())];
|
||||
assert_eq!(blob.as_deref(), Some("blob1"));
|
||||
let entry: Entry = toml::from_str(text).unwrap();
|
||||
// A new name never reuses one the entry had.
|
||||
assert_eq!(
|
||||
entry.screenshots,
|
||||
[
|
||||
"screenshots/coords_hud/2.png",
|
||||
"screenshots/coords_hud/3.png"
|
||||
]
|
||||
);
|
||||
assert_eq!(entry.categories, ["utility", "management"]);
|
||||
assert_eq!(entry.versions.len(), 1, "no version added");
|
||||
assert!(
|
||||
m.commits
|
||||
.contains_key(&(branch, "screenshots/coords_hud/3.png".into()))
|
||||
);
|
||||
assert!(m.assets.is_empty());
|
||||
let body = m.pulls[0]["body"].as_str().unwrap();
|
||||
assert!(body.contains("categories, tags, screenshots"), "{body}");
|
||||
assert_eq!(m.labels, [(1, json!(["owner-edit"]))]);
|
||||
}
|
||||
// Someone else: not an owner by ID.
|
||||
mock.lock().unwrap().login = "bob".into();
|
||||
let csrf = client.sign_in().await;
|
||||
let (status, json) = edit(&mut client, &csrf, |f| f["tags"] = json!(["x"])).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{json}");
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! the submission service and the index's readers.
|
||||
use super::Kind;
|
||||
|
||||
/// At most this many categories, at least one once [`REQUIRED`].
|
||||
/// At most this many categories, at least one ([`check_required`]).
|
||||
pub const MAX_CATEGORIES: usize = 3;
|
||||
pub const MAX_TAGS: usize = 10;
|
||||
pub const MAX_TAG: usize = 24;
|
||||
@@ -17,9 +17,6 @@ pub const SCREENSHOT_MIN: (u32, u32) = (320, 180);
|
||||
pub const SCREENSHOT_MAX: (u32, u32) = (3840, 2160);
|
||||
/// An icon is square, this many pixels a side.
|
||||
pub const ICON_SIDE: std::ops::RangeInclusive<u32> = 64..=1024;
|
||||
/// Whether a page must have a description, a category and an icon. Off until
|
||||
/// every listed project has them (the rollout's step 4).
|
||||
pub const REQUIRED: bool = false;
|
||||
|
||||
const MODS: &[&str] = &[
|
||||
"adventure",
|
||||
@@ -154,15 +151,8 @@ pub fn check(
|
||||
errors.push(format!(
|
||||
"the description is {length} characters: at most {MAX_DESCRIPTION}"
|
||||
));
|
||||
} else if REQUIRED && length < MIN_DESCRIPTION {
|
||||
errors.push(format!(
|
||||
"the description is {length} characters: write at least {MIN_DESCRIPTION} (what it does, how to use it)"
|
||||
));
|
||||
}
|
||||
let known = self::categories(kind);
|
||||
if REQUIRED && categories.is_empty() {
|
||||
errors.push(format!("pick 1 to {MAX_CATEGORIES} categories"));
|
||||
}
|
||||
if categories.len() > MAX_CATEGORIES {
|
||||
errors.push(format!(
|
||||
"{} categories: pick at most {MAX_CATEGORIES}",
|
||||
@@ -199,6 +189,44 @@ pub fn check(
|
||||
errors
|
||||
}
|
||||
|
||||
/// What every listed page must have: a description of [`MIN_DESCRIPTION`] characters or more, a
|
||||
/// category, and an icon kept in the registry (`icons/`), screenshots too: the site and the launcher
|
||||
/// load images only from the registry's own host.
|
||||
pub fn check_required(
|
||||
id: &str,
|
||||
description: &str,
|
||||
categories: &[String],
|
||||
icon: Option<&str>,
|
||||
screenshots: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
let length = description.trim().chars().count();
|
||||
if length < MIN_DESCRIPTION {
|
||||
errors.push(format!(
|
||||
"the description is {length} characters: write at least {MIN_DESCRIPTION} (what it does, how to use it)"
|
||||
));
|
||||
}
|
||||
if categories.is_empty() {
|
||||
errors.push(format!("pick 1 to {MAX_CATEGORIES} categories"));
|
||||
}
|
||||
match icon {
|
||||
None => errors.push("has no icon: icons/<id>.png, a square PNG".into()),
|
||||
Some(icon) if icon != format!("icons/{id}.png") => errors.push(format!(
|
||||
"its icon {icon:?} must be kept in the registry as icons/{id}.png"
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
for s in screenshots
|
||||
.iter()
|
||||
.filter(|s| !s.starts_with("screenshots/"))
|
||||
{
|
||||
errors.push(format!(
|
||||
"screenshot {s:?} must be kept in the registry, screenshots/<id>/"
|
||||
));
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// An image's format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Image {
|
||||
@@ -339,6 +367,31 @@ mod tests {
|
||||
assert_eq!(check(&Kind::Mod, "", &[], &eleven), ["11 tags: at most 10"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listed_page_is_complete_and_its_images_are_ours() {
|
||||
let text = "x".repeat(MIN_DESCRIPTION);
|
||||
let ok = check_required(
|
||||
"a",
|
||||
&text,
|
||||
&strings(&["mobs"]),
|
||||
Some("icons/a.png"),
|
||||
&strings(&["screenshots/a/1.png"]),
|
||||
);
|
||||
assert!(ok.is_empty(), "{ok:?}");
|
||||
let all = check_required("a", "short", &[], None, &[]);
|
||||
assert_eq!(all.len(), 3, "{all:?}");
|
||||
let far = check_required(
|
||||
"a",
|
||||
&text,
|
||||
&strings(&["mobs"]),
|
||||
Some("https://x.test/i.png"),
|
||||
&strings(&["https://x.test/1.png"]),
|
||||
);
|
||||
assert_eq!(far.len(), 2, "{far:?}");
|
||||
let other_icon = check_required("a", &text, &strings(&["mobs"]), Some("icons/b.png"), &[]);
|
||||
assert_eq!(other_icon.len(), 1, "{other_icon:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_description_has_a_ceiling() {
|
||||
let long = "x".repeat(MAX_DESCRIPTION + 1);
|
||||
|
||||
@@ -212,9 +212,6 @@ impl Entry {
|
||||
for e in rules::check(&self.kind, &self.description, &self.categories, &self.tags) {
|
||||
errors.push(format!("{id}: {e}"));
|
||||
}
|
||||
if rules::REQUIRED && self.icon.is_none() {
|
||||
errors.push(format!("{id}: has no icon (icons/{id}.png, a square PNG)"));
|
||||
}
|
||||
if self.screenshots.len() > rules::MAX_SCREENSHOTS {
|
||||
errors.push(format!(
|
||||
"{id}: {} screenshots, at most {}",
|
||||
@@ -641,6 +638,25 @@ pub fn build(src: &Sources) -> Result<Index, Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
let mut projects = Vec::new();
|
||||
for entry in entries.iter().filter(|e| listed(e, src)) {
|
||||
// A listed project's page is complete (registry v2's rollout, step 4).
|
||||
let page = match &entry.repo {
|
||||
Some(_) => Cache::read(src.registry, &entry.id)
|
||||
.map(|c| c.page)
|
||||
.unwrap_or_default(),
|
||||
None => Page::default(),
|
||||
};
|
||||
let e = entry.resolved(&page);
|
||||
errors.extend(
|
||||
rules::check_required(
|
||||
&e.id,
|
||||
&e.description,
|
||||
&e.categories,
|
||||
e.icon.as_deref(),
|
||||
&e.screenshots,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|problem| format!("{}: {problem}", e.id)),
|
||||
);
|
||||
match project(entry, src) {
|
||||
Ok(p) => projects.push(p),
|
||||
Err(e) => errors.extend(e),
|
||||
@@ -760,6 +776,14 @@ pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
|
||||
}
|
||||
}
|
||||
for new in head {
|
||||
// Every version is a submission, checked and reviewed: the registry no longer follows a
|
||||
// repository's releases (entries from before keep theirs until they are pinned).
|
||||
if new.repo.is_some() && base.iter().all(|e| e.id != new.id || e.repo.is_none()) {
|
||||
r.errors.push(format!(
|
||||
"{}: the registry doesn't follow repositories any more: list each version's file (the form, or [[versions]])",
|
||||
new.id
|
||||
));
|
||||
}
|
||||
if !base.iter().any(|e| e.id == new.id) {
|
||||
r.notes.push(format!(
|
||||
"{}: a new {} by {}{}",
|
||||
|
||||
@@ -32,6 +32,7 @@ owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/horizon-rust/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud"
|
||||
icon = "icons/coords_hud.png"
|
||||
categories = ["utility"]
|
||||
key = "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb"
|
||||
|
||||
[[versions]]
|
||||
@@ -49,6 +50,9 @@ const COZY: &str = r#"id = "cozy"
|
||||
kind = "modpack"
|
||||
name = "Cozy"
|
||||
summary = "Coordinates and a calm render distance."
|
||||
description = "Coordinates HUD with a calm render distance, played together as one profile."
|
||||
categories = ["lightweight"]
|
||||
icon = "icons/cozy.png"
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
@@ -66,6 +70,9 @@ const GLOW: &str = r#"id = "glow"
|
||||
kind = "shader"
|
||||
name = "Soft Glow"
|
||||
summary = "A post-processing pass over the world."
|
||||
description = "A soft bloom drawn over the world after it renders, in Horizon's own shader format."
|
||||
categories = ["bloom"]
|
||||
icon = "icons/glow.png"
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
@@ -111,7 +118,9 @@ impl Scratch {
|
||||
s.write("mods/cozy.toml", COZY);
|
||||
s.write("mods/glow.toml", GLOW);
|
||||
s.write("revoked.toml", REVOKED_TOML);
|
||||
s.write_bytes("icons/coords_hud.png", &png(128, 128));
|
||||
for icon in ["coords_hud", "cozy", "glow"] {
|
||||
s.write_bytes(&format!("icons/{icon}.png"), &png(128, 128));
|
||||
}
|
||||
for (name, hash) in [
|
||||
("coords_hud-0.1.0.hmod", HMOD),
|
||||
("cozy-1.0.0.hpack", HPACK),
|
||||
@@ -201,7 +210,17 @@ fn lists_every_problem_of_a_registry() {
|
||||
has("other 0.1.0: the file's id is \"coords_hud\""),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert_eq!(errors.len(), 4, "{errors:?}");
|
||||
// The modpack's category, on what is now a shader.
|
||||
assert!(
|
||||
has("\"lightweight\" is not a category of a shader"),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert!(has("glow: its icon \"icons/cozy.png\""), "{errors:?}");
|
||||
assert!(
|
||||
has("other: its icon \"icons/coords_hud.png\""),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert_eq!(errors.len(), 7, "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,6 +324,34 @@ fn a_review_protects_published_files() {
|
||||
assert_eq!(r.notes, ["cozy: a new modpack by ludovic111"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_review_refuses_following_a_repository() {
|
||||
let pinned: Entry = toml::from_str(COZY).unwrap();
|
||||
let follows: Entry = toml::from_str(COZY_REPO).unwrap();
|
||||
// A new entry that follows a repository, and a pinned entry turned into one.
|
||||
for base in [vec![], vec![pinned.clone()]] {
|
||||
let r = review(&base, std::slice::from_ref(&follows), "acme");
|
||||
assert!(
|
||||
r.errors
|
||||
.iter()
|
||||
.any(|e| e.contains("doesn't follow repositories")),
|
||||
"{r:?}"
|
||||
);
|
||||
}
|
||||
// An entry from before keeps its repository until it is pinned, and may drop it.
|
||||
let r = review(
|
||||
std::slice::from_ref(&follows),
|
||||
std::slice::from_ref(&follows),
|
||||
"acme",
|
||||
);
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
let r = review(&[follows], &[pinned], "acme");
|
||||
assert!(
|
||||
!r.errors.iter().any(|e| e.contains("repositories")),
|
||||
"{r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_may_turn_native_once() {
|
||||
let base: Entry = toml::from_str(&hud_entry()).unwrap();
|
||||
@@ -353,8 +400,8 @@ fn a_mod_may_turn_native_once() {
|
||||
#[test]
|
||||
fn page_fields_and_screenshots_reach_the_index() {
|
||||
let s = Scratch::new("page");
|
||||
let page = "categories = [\"utility\"]\ntags = [\"hud\", \"coordinates\"]\n\
|
||||
screenshots = [\"screenshots/coords_hud/1.png\", \"https://x.test/2.jpg\"]\n";
|
||||
let page = "tags = [\"hud\", \"coordinates\"]\n\
|
||||
screenshots = [\"screenshots/coords_hud/1.png\"]\n";
|
||||
s.write("mods/coords_hud.toml", &format!("{page}{}", hud_entry()));
|
||||
s.write_bytes("screenshots/coords_hud/1.png", &png(1280, 720));
|
||||
let built = s.build().unwrap();
|
||||
@@ -363,18 +410,16 @@ fn page_fields_and_screenshots_reach_the_index() {
|
||||
assert_eq!(p.tags, ["hud", "coordinates"]);
|
||||
assert_eq!(
|
||||
p.screenshots,
|
||||
[
|
||||
"https://horizon-rust.github.io/horizon-mods/screenshots/coords_hud/1.png",
|
||||
"https://x.test/2.jpg"
|
||||
]
|
||||
["https://horizon-rust.github.io/horizon-mods/screenshots/coords_hud/1.png"]
|
||||
);
|
||||
Index::parse(&serde_json::to_string(&built).unwrap()).unwrap();
|
||||
|
||||
// A category of another kind, a screenshot too small, one in another project's folder, and an
|
||||
// icon that isn't square.
|
||||
let bad = "categories = [\"bloom\"]\n\
|
||||
screenshots = [\"screenshots/coords_hud/1.png\", \"screenshots/other/2.png\"]\n";
|
||||
s.write("mods/coords_hud.toml", &format!("{bad}{}", hud_entry()));
|
||||
// A category of another kind, a screenshot too small, one in another project's folder, one on
|
||||
// another host, and an icon that isn't square.
|
||||
let bad = "screenshots = [\"screenshots/coords_hud/1.png\", \"screenshots/other/2.png\", \
|
||||
\"https://x.test/3.jpg\"]\n";
|
||||
let entry = hud_entry().replace("[\"utility\"]", "[\"bloom\"]");
|
||||
s.write("mods/coords_hud.toml", &format!("{bad}{entry}"));
|
||||
s.write_bytes("screenshots/coords_hud/1.png", &png(100, 100));
|
||||
s.write_bytes("icons/coords_hud.png", &png(128, 64));
|
||||
let errors = s.build().unwrap_err();
|
||||
@@ -386,7 +431,11 @@ fn page_fields_and_screenshots_reach_the_index() {
|
||||
"{errors:?}"
|
||||
);
|
||||
assert!(has("the icon is 128×64"), "{errors:?}");
|
||||
assert_eq!(errors.len(), 4, "{errors:?}");
|
||||
assert!(
|
||||
has("\"https://x.test/3.jpg\" must be kept in the registry"),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert_eq!(errors.len(), 5, "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -414,6 +463,9 @@ const COZY_REPO: &str = r#"id = "cozy"
|
||||
kind = "modpack"
|
||||
repo = "acme/cozy-pack"
|
||||
owners = ["acme"]
|
||||
description = "Coordinates HUD with a calm render distance, played together as one profile."
|
||||
categories = ["lightweight"]
|
||||
icon = "icons/cozy.png"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/horizon-rust/horizon/tree/main/crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"icon": "https://horizon-rust.github.io/horizon-mods/icons/coords_hud.png",
|
||||
"categories": ["utility"],
|
||||
"key": "RWTVtuGb/GRXcBf3Re5hzM07EzoStr2sLTJjghJia0xX15ULoZRFFZDb",
|
||||
"versions": [
|
||||
{
|
||||
@@ -46,6 +47,7 @@
|
||||
"kind": "modpack",
|
||||
"name": "Cozy",
|
||||
"summary": "Coordinates and a calm render distance.",
|
||||
"description": "Coordinates HUD with a calm render distance, played together as one profile.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
@@ -54,6 +56,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/horizon-rust/horizon-mods",
|
||||
"icon": "https://horizon-rust.github.io/horizon-mods/icons/cozy.png",
|
||||
"categories": ["lightweight"],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
@@ -81,6 +85,7 @@
|
||||
"kind": "shader",
|
||||
"name": "Soft Glow",
|
||||
"summary": "A post-processing pass over the world.",
|
||||
"description": "A soft bloom drawn over the world after it renders, in Horizon's own shader format.",
|
||||
"authors": [
|
||||
"Horizon"
|
||||
],
|
||||
@@ -89,6 +94,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"source": "https://github.com/horizon-rust/horizon-mods",
|
||||
"icon": "https://horizon-rust.github.io/horizon-mods/icons/glow.png",
|
||||
"categories": ["bloom"],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -144,9 +144,11 @@ pub struct Shadow {
|
||||
pub sun_path_rotation: f32,
|
||||
/// `shadowMapFov`: a perspective projection of this many degrees instead of orthographic.
|
||||
pub fov: Option<f32>,
|
||||
/// `shadowNearPlane`, `shadowFarPlane`.
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
/// `shadowNearPlane`, `shadowFarPlane`: the orthographic projection's planes, as JOML's
|
||||
/// `zNear`/`zFar`. None when the pack doesn't set one: Iris 1.11.7 then uses -100.05 and
|
||||
/// 156 (measured with a probe pack), each on its own.
|
||||
pub near: Option<f32>,
|
||||
pub far: Option<f32>,
|
||||
/// `shadowHardwareFiltering(0|1)`: shadowtex0/1 sampled with depth comparison.
|
||||
pub hardware_filtering: [bool; 2],
|
||||
/// `shadowtex0Nearest` (or `shadowtexNearest`, `shadow0MinMagNearest`) and
|
||||
@@ -173,8 +175,8 @@ impl Default for Shadow {
|
||||
euclidean_snap: false,
|
||||
sun_path_rotation: 0.0,
|
||||
fov: None,
|
||||
near: 0.05,
|
||||
far: 256.0,
|
||||
near: None,
|
||||
far: None,
|
||||
hardware_filtering: [false; 2],
|
||||
nearest: [false; 2],
|
||||
colors: std::array::from_fn(|_| white.clone()),
|
||||
@@ -362,8 +364,8 @@ impl Config {
|
||||
"voxelDistance" => float(&mut s.voxel_distance),
|
||||
"shadowIntervalSize" => float(&mut s.interval),
|
||||
"sunPathRotation" => float(&mut s.sun_path_rotation),
|
||||
"shadowNearPlane" => float(&mut s.near),
|
||||
"shadowFarPlane" => float(&mut s.far),
|
||||
"shadowNearPlane" => s.near = number(value).or(s.near),
|
||||
"shadowFarPlane" => s.far = number(value).or(s.far),
|
||||
"shadowMapFov" => s.fov = number(value).filter(|f| *f > 0.0),
|
||||
"shadowHardwareFiltering" => {
|
||||
if let Some(b) = boolean(value) {
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
|
||||
- [Opt-in data collection](telemetry.md): the consent, exactly what is sent, how it is stored and sent.
|
||||
- [Benchmark report format](benchmark-report.md): the versioned JSON every benchmark writes (game, launcher, `tools/perf_bench.py`), the local log and, with consent, the upload.
|
||||
- [Horizon APIs](api/README.md): every surface other programs build on (mods, shaders, agent control, servers, the launcher, data formats), each with its guide, generated reference, stability and changelog, and the [stability and versioning policy](api/stability.md).
|
||||
- Menus: every screen is vanilla's (title, world list, create world, multiplayer, options, pause), except Horizon's graphics settings, drawn in the design system's Blocks palette. Horizon's own settings hang off vanilla's Options the way mods add theirs: **Horizon Settings...** and **Mods...** in the last two slots of the Options grid (Horizon Settings opens the graphics settings screen, at General or Advanced, and Controller Settings, Extras and Mods, each a vanilla options list), with Horizon Graphics and Advanced Graphics also at the top of Video Settings and Controller Settings at the bottom of Controls. The title screen's and pause menu's Friends buttons open Join a Friend and Play with Friends ([play-with-friends.md](play-with-friends.md)).
|
||||
- Menus: every screen is vanilla's (title, world list, create world, multiplayer, options, pause), except Horizon's graphics settings, drawn in the design system's Blocks palette. Horizon's own settings hang off vanilla's Options the way mods add theirs: **Horizon Settings...** and **Mods...** in the last two slots of the Options grid (Horizon Settings opens the graphics settings screen, at General or Advanced, the controller settings screen in the same style, and Extras and Mods, each a vanilla options list), with Horizon Graphics and Advanced Graphics also at the top of Video Settings and Controller Settings at the bottom of Controls. The title screen's and pause menu's Friends buttons open Join a Friend and Play with Friends ([play-with-friends.md](play-with-friends.md)).
|
||||
|
||||
## Parity
|
||||
|
||||
|
||||
+12
-9
@@ -42,7 +42,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
| `owners` | GitHub logins allowed to publish updates. |
|
||||
| `license`, `source` | An SPDX expression and an `https://` address of the source code: both required. |
|
||||
| `icon` | `https://`, optional. |
|
||||
| `categories` | 1 to 3 of the kind's list (`horizon_mods::index::page::categories`, [the lists](../design/registry-v2.md#categories)). Optional until every listed project has them. |
|
||||
| `categories` | 1 to 3 of the kind's list (`horizon_mods::index::page::categories`, [the lists](../design/registry-v2.md#categories)). |
|
||||
| `tags` | Free-form: at most 10, each `[a-z0-9][a-z0-9-]{0,23}`, nothing about gambling. Optional. |
|
||||
| `screenshots` | `https://` addresses of PNG or JPEG images, in order, at most 8. Optional. |
|
||||
| `key` | The author's [minisign](https://jedisct1.github.io/minisign/) public key. When set, every version must be signed with it; an update signed with another key is refused. Absent: the project is unsigned and the launcher says so. |
|
||||
@@ -102,8 +102,9 @@ them into the profile's game folder.
|
||||
## The registry repository
|
||||
|
||||
[horizon-rust/horizon-mods](https://github.com/horizon-rust/horizon-mods) holds one `mods/<id>.toml` per project and
|
||||
`revoked.toml`. An entry is **a link and a page**: the project's GitHub repository and what the listing says about it.
|
||||
Its versions are the repository's GitHub releases. What an entry can't vouch for is read from the file by the kind's own
|
||||
`revoked.toml`. An entry is **a page and its pinned versions**: every version is a file submitted through the form (or a
|
||||
pull request), checked, reviewed and pinned by SHA-256 in `[[versions]]`. Entries listed before 2026-11-05 may still
|
||||
name a `repo` whose releases are read as below; new ones may not (`registry review` refuses them). What an entry can't vouch for is read from the file by the kind's own
|
||||
check (`index::inspect`): a mod's API, side and capabilities, a modpack's mods, the size. Types and rules:
|
||||
`horizon_mods::index::registry`.
|
||||
|
||||
@@ -114,10 +115,10 @@ repo = "horizon-rust/coords-hud" # GitHub owner/name: its releases are its versi
|
||||
owners = ["ludovic111"] # GitHub logins responsible for the project
|
||||
owner_ids = [12345678] # their numeric GitHub IDs: these decide who an owner is (a login can change hands)
|
||||
key = "RWT..." # optional: the minisign public key every version is signed with
|
||||
icon = "icons/coords_hud.png" # optional: a square PNG, 64 to 1024 px, at most 256 KiB in the repository, or https://
|
||||
categories = ["utility"] # 1 to 3 of the kind's list
|
||||
icon = "icons/coords_hud.png" # required: a square PNG, 64 to 1024 px, at most 256 KiB, in the repository
|
||||
categories = ["utility"] # required: 1 to 3 of the kind's list
|
||||
tags = ["hud", "coordinates"] # optional, at most 10, lowercase
|
||||
screenshots = ["screenshots/coords_hud/1.png"] # optional, at most 8: PNG or JPEG, 320x180 to 3840x2160, at most 1 MiB, or https://
|
||||
screenshots = ["screenshots/coords_hud/1.png"] # optional, at most 8: PNG or JPEG, 320x180 to 3840x2160, at most 1 MiB, in the repository
|
||||
# The page. Each field may instead come from horizon-registry.toml in the repo (below); these win.
|
||||
name = "Coordinates HUD"
|
||||
summary = "One line, at most 160 characters."
|
||||
@@ -127,7 +128,7 @@ license = "MIT"
|
||||
# source defaults to https://github.com/<repo>
|
||||
```
|
||||
|
||||
**Versions come from the releases.** For each entry with a `repo`, the registry's CI lists its GitHub releases (drafts
|
||||
**Legacy: versions from releases.** For each entry with a `repo`, the registry's CI lists its GitHub releases (drafts
|
||||
and pre-releases are skipped) and, for each release it has not read yet, downloads the asset with the kind's extension
|
||||
(and its `<name>.minisig` when the project has a key), runs the checks of a submission (below), and if they pass
|
||||
adds the version. The version, the game and the pins are the file's own manifest (`horizon-mod.toml`,
|
||||
@@ -144,8 +145,8 @@ does: a release deleted or an asset replaced later does not change the index (th
|
||||
`description`, `authors` and `license` (unknown keys, and anything about owners, key, kind or icon, are refused): they fill the
|
||||
page fields the entry leaves out. A listing that is incomplete without it is refused until it is.
|
||||
|
||||
**`[[versions]]` in an entry are optional** and kept for projects listed before releases were read, and for Horizon's own
|
||||
mods built in the registry (`native/<name>/`):
|
||||
**`[[versions]]`** hold every version (the form writes them; Horizon's own mods are built in the registry,
|
||||
`native/<name>/`):
|
||||
|
||||
```toml
|
||||
[[versions]] # newest first
|
||||
@@ -222,6 +223,8 @@ API line are its facts. A new kind gets its own check before it opens.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-11-05: every listed project needs a description of 50 characters or more, 1 to 3 categories and an icon kept in the registry (`icons/<id>.png`); screenshots are kept in the registry too (`registry build` refuses https:// images in entries).
|
||||
- 2026-11-05: the registry follows no repository any more: `registry review` refuses a new entry with `repo` (and an entry turning into one); entries from before keep theirs until they are pinned. The form uploads the icon and up to 8 screenshots into the registry and takes "Edit project" (an owner's page change, no version: pull request `edit/<id>-<time>`, label `owner-edit`).
|
||||
- 2026-11-05: kinds `shaderpack` and `datapack`; optional `categories`, `tags` and `screenshots` (entries and index). The index's schema is unchanged: readers from before keep the new kinds as unknown and ignore the new fields.
|
||||
- 2026-10-30: an entry names its GitHub repository (`repo`) and its versions are the repository's releases, read by the registry's CI; `file.url` may be a release asset of that repository. The index's schema is unchanged.
|
||||
- 2026-10-26: `projects[].downloads`, GitHub's download counts of a project's files, refreshed every hour.
|
||||
|
||||
+6
-1
@@ -174,6 +174,8 @@ the crafting result, armour to the player's grid.
|
||||
| LB / RB | previous / next tab (Create World, Player Social, the graphics settings' sidebar) |
|
||||
| Right stick | scroll the list, wherever the focus is: the focus stays on its row (when the row leaves the view, on the nearest one still in view), no value changes, and nothing scrolls under the on-screen keyboard |
|
||||
|
||||
- **Tooltips**: an options page's tooltip hangs from the focused widget's bottom left while the pad's focus drives
|
||||
the page, since the pad's hidden pointer sits mid-widget. The mouse and the keyboard keep vanilla's positioners.
|
||||
- **Declared buttons**: a button that says what it's for gets its controller
|
||||
button and a glyph on it while the pad drives; see "Adding a screen".
|
||||
- **Sliders**: left and right move the value (one pixel per press like
|
||||
@@ -339,7 +341,10 @@ hints, "Press X to dismount") only while the pad is the last-used input.
|
||||
|
||||
Controller settings are Horizon's own: **Options → Controls → Controller
|
||||
Settings...** (or **Options → Horizon Settings... → Controller Settings...**),
|
||||
a vanilla options list with **Controller Binds...** at the top. They live in
|
||||
a screen in the graphics settings' style with a search field and General, Bindings and Device pages. It
|
||||
names the player whose profile it edits and the controller the game sees; Device also shows its vendor and
|
||||
product ids, the detected glyph family and the override. Changes apply at once; **Undo** (X) is there only
|
||||
while something differs from when the screen opened, and **Done** (B) leaves. They live in
|
||||
`horizon-controller.txt` next to `options.txt`, like `horizon-graphics.txt`: `options.txt` stays
|
||||
exactly vanilla's, so a vanilla client can share the game folder.
|
||||
|
||||
|
||||
+46
-12
@@ -56,9 +56,9 @@ y points down, as in vanilla's `ModelPart`s and Blockbench's "Modded Entity" mod
|
||||
`phase` periods in. `swing` turns the part `amplitude` degrees (at most 45) about the axis, `bob`
|
||||
moves it `amplitude` pixels (at most 4), `spin` turns it all the way round once per period.
|
||||
|
||||
A **name tag style** (`"slot": "name_tag"`) has no model: its texture is a square mark of at most
|
||||
16×16 drawn before the player's name, and `"tag": {"line": "#rrggbb"}` the colour of a line under
|
||||
it. `parts` stays empty and there are no animations; no other slot has a `tag`.
|
||||
A **name tag style** (`"slot": "name_tag"`) has no model: its texture is a square badge of at most
|
||||
16×16 drawn after the player's name (before it sits Horizon's own mark, for Horizon players), and
|
||||
`"tag": {"line": "#rrggbb"}` the colour of a line under it. `parts` stays empty and there are no animations; no other slot has a `tag`.
|
||||
|
||||
```json
|
||||
{ "format": 1, "id": "example_tag", "slot": "name_tag", "texture": { "png": "<base64 8×8 PNG>" },
|
||||
@@ -84,8 +84,9 @@ does the asking.
|
||||
|
||||
- **One request for the players in view**: `POST /v1/worn` with up to 64 UUIDs
|
||||
(`{"players": ["<uuid hex>", …]}`) answers `{"ttl": seconds, "players": {"<uuid hex>": [{"id",
|
||||
"hash"}, …]}}`; a player who wears nothing is left out. Only the UUIDs of players the game draws
|
||||
are sent, nothing else about them or the server.
|
||||
"hash"}, …]}, "horizon": ["<uuid hex>", …]}`; a player who wears nothing is left out of
|
||||
`players`, and `horizon` lists the batch's Horizon players (below). Only the UUIDs of players the
|
||||
game draws or lists are sent, nothing else about them or the server.
|
||||
- **Cached for the service's TTL** (kept within 1 minute to 1 hour). Past it the last answer stays
|
||||
drawn while the player is asked for again. The 4096 players seen most recently are remembered.
|
||||
- **Item files** come from `GET /v1/items/<id>?hash=<hash>` once and are kept on disk under their
|
||||
@@ -96,6 +97,31 @@ does the asking.
|
||||
ids and hashes, written only when an answer changes them). They are read only when the service doesn't
|
||||
answer, so a game started while it is down still shows what players last wore, until it answers.
|
||||
|
||||
## Horizon players' mark
|
||||
|
||||
Like Lunar Client's: Horizon players see Horizon's mark (two white bars over a violet line,
|
||||
`horizon_cosmetics::mark`) before the names of other Horizon players, above their heads (both
|
||||
views, and your own with *Own name tag*) and in the player list, after the head. Nothing changes on
|
||||
the wire to Minecraft servers: which players use Horizon comes from the cosmetics service, in the
|
||||
same cached `/v1/worn` answers as what they wear (no request of its own, nothing per frame).
|
||||
|
||||
- **Who is reported**: a player whose game said "I play Horizon" lately. While *Show me as
|
||||
Horizon* is on, the game signs in (Mojang's `join`, as for *My cosmetics*) and sends
|
||||
`POST /v1/presence {"listed": true}` at start, then every hour (`account::PRESENCE_EVERY`); the
|
||||
service reports them for 3 hours after the last time (`COSMETICS_PRESENCE_WINDOW`). Off, the game
|
||||
sends `{"listed": false}` and the service forgets them at once: it never reports them again until
|
||||
they turn it back on. An offline account sends nothing and is never reported.
|
||||
- **Anyone can ask**: `/v1/worn` takes no sign-in (as for what players wear), so anyone, a server
|
||||
included, can learn whether a UUID played Horizon in the last 3 hours while that player shows
|
||||
themselves. *Show me as Horizon*'s tooltip says so.
|
||||
- **Settings** (Extras › Cosmetics, both on by default): *Horizon logos* ("Show the Horizon logo
|
||||
next to Horizon players"; off: the game draws no mark, and asks for nothing more than cosmetics
|
||||
need) and *Show me as Horizon* ("Show me as a Horizon player").
|
||||
- **The player list** keeps a 9-pixel column for the mark once anyone listed has it, so names stay
|
||||
aligned.
|
||||
- **Cost**: in a name tag, the mark is five filled rectangles beside the text, looked up under the
|
||||
same lock as the name tag style; in the player list, one look per listed player while it shows.
|
||||
|
||||
## In the game
|
||||
|
||||
- **The flag**: `horizon_cosmetics::ON_BY_DEFAULT` is `true`. `HORIZON_COSMETICS=0` turns
|
||||
@@ -114,19 +140,26 @@ does the asking.
|
||||
about them; name tags only, their name tag styles show and nothing on their bodies. Until the
|
||||
player presses it there is no `cosmeticsOthers` line, and it follows ownership
|
||||
(`prefs::Others::effective`): *Shown* if the signed-in account owns a cosmetic, else *Hidden*.
|
||||
The button shows that value; pressing it writes the line, which then wins.
|
||||
The button shows that value; pressing it writes the line, which then wins. Hidden, nothing of
|
||||
theirs is drawn, yet with *Horizon logos* on the game still asks the same `/v1/worn` batch who
|
||||
plays Horizon.
|
||||
- **Ownership** (`horizon_cosmetics::account::owns`): once a run, in a world, the game signs in
|
||||
as for *My cosmetics* and reads `GET /v1/me`; an account whose `owned` list has anything owns
|
||||
one. The answer is kept per account in `cosmetics-owned.txt` (beside `cosmetics-visible.txt`;
|
||||
the launcher's Wardrobe writes it too) and used at the next start until the service answers.
|
||||
Not known yet, failed (retried after 15 s, doubling to 10 minutes) or offline: not owning. An
|
||||
answer that says owns shows others' at once, without a restart. Only one of the two sign-ins
|
||||
runs at a time (Mojang keeps one joined server id per account). `HORIZON_COSMETICS_LOCAL`
|
||||
counts as owning.
|
||||
answer that says owns shows others' at once, without a restart. Only one of the sign-ins (this,
|
||||
*My cosmetics*, *Show me as Horizon*) runs at a time (Mojang keeps one joined server id per
|
||||
account). `HORIZON_COSMETICS_LOCAL` counts as owning.
|
||||
- *Others' auras and trails*, off by default (no item of that kind exists yet).
|
||||
- **Name tag styles** (`game/names.rs`): the mark, 8 text pixels square, before the name and a
|
||||
- *In single player*, off by default, the game's only (`cosmeticsSolo`). The
|
||||
window's own server names the player by their offline profile, which the service doesn't
|
||||
know, so in single player (open to LAN too) the game draws and asks nothing for the player's
|
||||
own model. On, it asks by the signed-in account's UUID: their cosmetics, their name tag style
|
||||
(with *Own name tag*) and Horizon's mark show in their own worlds. Offline accounts: nothing.
|
||||
- **Name tag styles** (`game/names.rs`): the badge, 8 text pixels square, after the name and a
|
||||
one-pixel line in the style's colour under the name's background, in the name tag's own pass
|
||||
(the mark is in the atlas like the other items' textures).
|
||||
(the badge is in the atlas like the other items' textures). Horizon's mark goes before the name.
|
||||
- **Drawing** (`game/cosmetics.rs`): an item's texture takes one of the skin atlas's 64×64 slots
|
||||
and its boxes are posed on the wearer's bone each frame, through the same GPU skin path as the
|
||||
player model (the CPU path where that is off), lit and culled with the player. A helmet hides a
|
||||
@@ -183,7 +216,8 @@ page stays hidden.
|
||||
|
||||
- **A row per slot** (head, back, shirt, shoulder pet, name tag) with what it wears,
|
||||
"Nothing equipped" or "None owned yet"; **your player** in the middle, on your own skin, wearing
|
||||
it, with your name tag style over the head (`horizon_cosmetics::preview`); **what you own for the
|
||||
it, with your name tag over the head as others see it (Horizon's mark unless the game's *Show me
|
||||
as Horizon* is off, your style's badge after the name) (`horizon_cosmetics::preview`); **what you own for the
|
||||
chosen slot**, "Nothing" first. Equipping shows at once.
|
||||
- **The shop is on the website**: an *Open the shop* button by the page's title opens
|
||||
`https://playhorizon.dev/shop` in the browser (browsing, prices, the founder claim, purchases through
|
||||
|
||||
@@ -72,7 +72,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
|
||||
| `key KEY down\|up\|tap\|repeat` | `key` | Press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...) | `key` (string, required): key name<br>`action` (down, up, tap, repeat, required): what to do |
|
||||
| `mouse left\|right\|middle down\|up\|click` | `mouse` | Press or release a mouse button in the world | `button` (left, right, middle, required): button<br>`action` (down, up, click, required): what to do |
|
||||
| `interact-entity ID` | `interact_entity` | Right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep | `id` (integer, required): entity id from entities |
|
||||
| `pad BUTTON down\|up\|tap \| pad stick left\|right X Y \| pad trigger left\|right V \| pad release \| pad status` | `pad` | A virtual controller: buttons, sticks and triggers as a real pad would send them | `what` (string, required): a button name, stick, trigger, release or status<br>`a` (string): down\|up\|tap, or left\|right<br>`b` (number): stick X or trigger value<br>`c` (number): stick Y |
|
||||
| `pad BUTTON down\|up\|tap \| pad stick left\|right X Y \| pad trigger left\|right V \| pad release \| pad status \| pad connection connected\|disconnected` | `pad` | A virtual controller: buttons, sticks and triggers as a real pad would send them | `what` (string, required): a button name, stick, trigger, release, status or connection<br>`a` (string): down\|up\|tap, left\|right, or connected\|disconnected<br>`b` (number): stick X or trigger value<br>`c` (number): stick Y |
|
||||
| `release` | `release` | Release every key and mouse button | none |
|
||||
| `stop` | `stop` | Cancel the running action (goto, break...) and release every key | none |
|
||||
|
||||
|
||||
+23
-8
@@ -16,8 +16,8 @@ mods, `.hnmod`) aren't on Modrinth or CurseForge yet (Modrinth has no `horizon`
|
||||
| Java mods (Fabric, NeoForge, Forge, Quilt) | ported into `horizon-mods/<mod id>/` and turned on | Checked from its jar before install: **Works**, **Partly** or **Doesn't run** ([below](#java-mods)) |
|
||||
| Resource packs | `resourcepacks/` beside `options.txt` | **Works** when made for 26.3 ([resource-packs.md](resource-packs.md) lists what Horizon reads). Older packs are **Partly**: they load with vanilla's compatibility warning |
|
||||
| Modpacks (`.mrpack`, CurseForge zip) | the Modpacks page's import | **Partly**: Horizon runs the mods it can port, from the player's own downloads ([modpacks.md](modpacks.md)) |
|
||||
| Shader packs (`.hshader`, Horizon's registry only) | `shaderpacks/` beside `options.txt` | **Works** when made for 26.3: checked by the game's own check before install ([shaders.md](shaders.md)). The `shaders/`-format packs on Modrinth and CurseForge are not offered |
|
||||
| Data packs (Modrinth) | `datapacks/` of the world picked on the **Data packs** tab (`mods install <pack> --world <world>`); not in the Installed list: each world's list on Home has them | **Partly**: Horizon runs a data pack's functions, function tags, predicates and item modifiers, not yet its recipes, loot tables, advancements, other tags or worldgen. The install reads the zip and says what of it runs; a pack with none of those (worldgen only, such as Tectonic) is refused |
|
||||
| Shader packs (`.hshader` and `shaders/`-format zips, Horizon's registry only) | `shaderpacks/` beside `options.txt` | **Works** when made for 26.3: checked against the registry's index before install (a `.hshader` by the game's own check, [shaders.md](shaders.md)). The `shaders/`-format packs on Modrinth and CurseForge are not offered |
|
||||
| Data packs (Horizon's registry, Modrinth) | `datapacks/` of the world picked on the **Data packs** tab (`mods install <pack> --world <world>`); not in the Installed list: each world's list on Home has them | **Partly**: Horizon runs a data pack's functions, function tags, predicates and item modifiers, not yet its recipes, loot tables, advancements, other tags or worldgen. The install reads the zip and says what of it runs; a pack with none of those (worldgen only, such as Tectonic) is refused |
|
||||
|
||||
Mods, resource packs and shader packs go into the game folder of the instance selected in the sidebar, and the
|
||||
Installed list, updates and Remove are that instance's: picking another instance reads its list. The page says which
|
||||
@@ -82,10 +82,12 @@ start); the hedge places and its recipe crafts 4.
|
||||
## The Mods page
|
||||
|
||||
The launcher's **Mods** page has the five kinds as tabs: **Mods**, **Modpacks**, **Resource packs**, **Shader packs**
|
||||
and **Data packs**, each with a search field (Enter searches). Mods, Modpacks and Resource packs pick their source:
|
||||
Horizon's registry or Modrinth, with **CurseForge (coming soon)** beside them, disabled. Mods start on Horizon's
|
||||
registry; **Shader packs** search it only (Horizon's own `.hshader` packs), **Data packs** search Modrinth only and
|
||||
install into the world picked under the search. A Java instance's tabs always search Modrinth for its version and
|
||||
and **Data packs**, each with a search field (Enter searches). Mods, Modpacks, Resource packs and Data packs pick their
|
||||
source: Horizon's registry or Modrinth, with **CurseForge (coming soon)** beside them, disabled. Mods start on
|
||||
Horizon's registry; **Shader packs** search it only (Horizon's own packs); data packs install into the world picked
|
||||
under the search. On Horizon's registry, **Filters** lists the kind's categories (`horizon_mods::index::page::categories`):
|
||||
a result has every category picked; the search words also match a project's tags. A project's page shows its
|
||||
categories. A Java instance's tabs always search Modrinth for its version and
|
||||
loader, with the version and loader filters. A Horizon instance searching Modrinth's Java mods sees them all, each
|
||||
marked **Works**, **Partly** or **Doesn't run in Horizon** once its jar is checked; **Show everything** adds what was
|
||||
made for other Minecraft versions. Each result is an index row: its summary, author,
|
||||
@@ -96,14 +98,27 @@ file and the checksum they were checked against, with **Update to …** when a n
|
||||
(asked twice). **Update all (N)** above the list updates every pack a newer version was found for, of every kind,
|
||||
one after the other; one that fails is named and doesn't stop the others.
|
||||
|
||||
**Automatic updates** (Settings > Game > **Update registry mods automatically**, on by default): when the launcher
|
||||
starts (every Horizon instance) and before Play (the instance it starts), what was installed from Horizon's registry
|
||||
moves to its newest version for the Minecraft that version is for and the Horizon that plays it
|
||||
(`Index::installable`), checked as an install is (`browser::horizon::auto`). Native mods update at the start only, and
|
||||
the next Play first builds the game again with them (the Mods page's Build, `build_native`), then plays it. Never
|
||||
updated automatically: a modpack's profile (its `.hpack` pins each mod by SHA-256; the pack updates as a whole when the
|
||||
player asks), native mods of an instance that keeps a Nightly or a native build made on one, data packs (they live in a
|
||||
world), a version for another Minecraft, and anything older than what is installed. A content mod (`.hmod`) whose new version no longer has a block, item, entity, effect, menu or creative tab
|
||||
of the installed one waits for the player, and the page names what it would drop. The file an update replaces is kept
|
||||
in `mod-browser/replaced/` until the next start, which puts it back if the update was cut short.
|
||||
|
||||
### Project pages, filters and icons
|
||||
|
||||
A result's title opens its page inside the launcher, not in a web browser: its icon, authors, downloads, followers,
|
||||
last update, licence and source, how well it runs (for a Java mod, what ports and what doesn't, from its check) with
|
||||
**Install** or **Import**, the project's links (its page on the source, source code, issues, wiki, Discord,
|
||||
donations; `https://` only, opened in the system browser), then three tabs: **Description** (the project's Markdown,
|
||||
drawn by the launcher's renderer), **Gallery** and **Versions** (each version's date, loaders, game versions, file
|
||||
and size, and the verdict of any version already checked). The data is `browser::details` (`Source::details`; on
|
||||
drawn by the launcher's renderer), **Gallery** (one image large, a click shows the next; the arrows and the strip of
|
||||
thumbnails under it pick another; Modrinth's gallery, or the screenshots a registry project lists, which the registry
|
||||
mirrors beside its icons; no tab when there are none) and **Versions** (each version's date, loaders, game versions,
|
||||
file and size, and the verdict of any version already checked). The data is `browser::details` (`Source::details`; on
|
||||
Modrinth `GET /project/{id}` and `/project/{id}/members`, then `/project/{id}/version`).
|
||||
|
||||
**Filters** sit above the results: the order (best match, relevance, downloads, follows, newest, updated) and, under
|
||||
|
||||
+31
-16
@@ -1736,22 +1736,37 @@ trusting native code is the player's decision, never an agent's.
|
||||
official `rustc`, `cargo` and `rust-std` (and `rust-mingw` on Windows), each pinned by SHA-256, downloaded once
|
||||
from static.rust-lang.org into `<launcher data>/rust/`, with its own Cargo home `<launcher data>/cargo/`. No
|
||||
rustup, nothing on the system; the player's own Rust is never used. It ships for macOS on Apple Silicon, Linux
|
||||
x86-64 and Windows x64. The Windows ARM64 launcher has no bundled native-mod compiler yet. An x64 launcher
|
||||
also refuses to build native mods when the selected game is ARM64: its compiler only produces x64 binaries.
|
||||
Choosing x64 from the ARM64 launcher does not provide an x64 compiler. An Intel
|
||||
Mac has none either, so native builds there stop with "the launcher has none for macos x86_64". Windows uses the
|
||||
`windows-gnu` host, which links without Visual Studio. `rust-mingw` only links, so on Windows the toolchain also brings MinGW-w64
|
||||
(`rust_toolchain::MINGW`: WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT like `rust-mingw`, one zip pinned by
|
||||
SHA-256, its `mingw64/` without the documentation) on the build's `PATH`: `ring` and `hidapi` compile C,
|
||||
and `windows-sys`' `raw-dylib` imports need `dlltool` and the assembler it runs. The linker is the system's on
|
||||
macOS (the Command Line Tools: the launcher asks for `xcode-select --install`) and Linux (`cc`). On Linux the
|
||||
game also builds against the system's ALSA (`alsa-sys`) and libudev (`libudev-sys`, `hidapi`), found with
|
||||
`pkg-config`, whose development files desktop distributions don't install. Before downloading anything the
|
||||
launcher checks for `cc`, the C library's headers (`cc` compiles an include of `stdint.h`), `pkg-config`,
|
||||
`alsa` and `libudev` and names what is missing, with the install command for the distribution in
|
||||
`/etc/os-release` (Debian, Ubuntu and Mint:
|
||||
`sudo apt install build-essential pkg-config libasound2-dev libudev-dev`). SteamOS has GCC without the C
|
||||
library's headers, and a read-only system: native builds don't work there yet.
|
||||
x86-64, Windows x64 and Windows ARM64. The managed compiler follows the launcher's process architecture. An x64
|
||||
launcher running under ARM64 emulation keeps the existing x64 Rust and MinGW toolchain. The game architecture
|
||||
setting does not switch the compiler or enable cross-compilation. Windows x64 uses `x86_64-pc-windows-gnu` and
|
||||
WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0 (MSVCRT), with its SHA-pinned `mingw64/` folder on `PATH`.
|
||||
|
||||
On Windows ARM64, Rust 1.99.1's native host tools use `aarch64-pc-windows-gnullvm`; the launcher also downloads
|
||||
a SHA-pinned LLVM-MinGW 23.1.5 toolchain with UCRT. Rust's `aarch64-pc-windows-msvc` release target cannot be
|
||||
reused as a self-contained player toolchain: it needs the MSVC linker and C libraries, while the launcher must
|
||||
bring its own C compiler and linker for dependencies such as `ring` and `hidapi` and for Windows imports. The
|
||||
selected game's source is rebuilt in full for `aarch64-pc-windows-gnullvm`; the existing MSVC executable is never
|
||||
linked into the output. Rust and C code therefore use one Windows ARM64 calling convention and ABI. The
|
||||
executable's PE machine is checked before installation. LLVM-MinGW's ARM64 `windres` and archive tools are
|
||||
selected for Windows icon resources; the generic resource compiler can emit x64 objects on an ARM64 host.
|
||||
Prebuilt MSVC static libraries in a native mod are not compatible. The output uses UCRT, which is included with
|
||||
Windows 10 and later. Rust's unwinder is statically linked because Windows does not include LLVM-MinGW's
|
||||
`libunwind.dll`; the built game imports only Windows system DLLs.
|
||||
|
||||
Before compiling, the launcher checks that the selected game's installed executable matches its managed compiler.
|
||||
The Windows ARM64 launcher builds a selected ARM64 game, and the x64 launcher builds a selected x64 game,
|
||||
including when it runs under ARM64 emulation. A selection that does not match the launcher's process architecture
|
||||
is rejected, and an ARM64 selection that has only installed the x64 fallback is rejected by the PE check. Install
|
||||
a matching game build before building native mods. ARM64EC executables are unsupported. ARM64 Cargo artifacts
|
||||
and build records have their own cache namespace; the existing x64 cache layout is unchanged. An Intel Mac has
|
||||
no bundled compiler either, so native builds there stop with "the launcher has none for macos x86_64". The linker
|
||||
is the system's on macOS (the Command Line Tools: the launcher asks for `xcode-select --install`) and Linux (`cc`).
|
||||
On Linux the game also builds against the system's ALSA (`alsa-sys`) and libudev (`libudev-sys`, `hidapi`), found
|
||||
with `pkg-config`, whose development files desktop distributions don't install. Before downloading anything the
|
||||
launcher checks for `cc`, the C library's headers (`cc` compiles an include of `stdint.h`), `pkg-config`, `alsa`
|
||||
and `libudev` and names what is missing, with the install command for the distribution in `/etc/os-release`
|
||||
(Debian, Ubuntu and Mint: `sudo apt install build-essential pkg-config libasound2-dev libudev-dev`). SteamOS has
|
||||
GCC without the C library's headers, and a read-only system: native builds don't work there yet.
|
||||
`horizon-launcher rust status|install|remove`.
|
||||
2. **horizon-mixins**, at the commit the mods name (their `.hnmod`'s `mixins`), else `native_mods::MIXINS_REV`,
|
||||
built once with that cargo (`cargo install --locked --git … --rev …`) into `native/tools/<commit>/`.
|
||||
|
||||
+4
-3
@@ -55,9 +55,10 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
starts the game. With no install on the computer the Play page offers the download alone and the
|
||||
Minecraft folder row says none is needed. Without an owner,
|
||||
the Play page asks to sign in first. `horizon-launcher prepare --download`.
|
||||
- **Mods**: tabs for Mods, Modpacks, Resource packs, Shader packs and Data packs. Mods, Modpacks
|
||||
and Resource packs search Horizon's own registry or Modrinth (CurseForge support is coming soon);
|
||||
Shader packs search the registry, Data packs Modrinth (into the world picked there). A Java
|
||||
- **Mods**: tabs for Mods, Modpacks, Resource packs, Shader packs and Data packs. Mods, Modpacks,
|
||||
Resource packs and Data packs (into the world picked there) search Horizon's own registry, with
|
||||
its categories as filters, or Modrinth (CurseForge support is coming soon); Shader packs search
|
||||
the registry. A Java
|
||||
instance's files come from Modrinth for its version and loader; on a Horizon instance, Modrinth's
|
||||
Java mods are listed too, each marked Works, Partly or Doesn't run in Horizon. Install puts a file in
|
||||
the game folder only after its SHA-512 matches, and a
|
||||
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "تتطلب التعديلات الأصلية تطابق معماريات Windows المدعومة. اللعبة: {game_arch}؛ سلسلة أدوات البناء: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
@@ -1168,5 +1168,11 @@
|
||||
"{file} unpacks to more than {limit}: it isn't imported.": "{file} unpacks to more than {limit}: it isn't imported.",
|
||||
"Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.": "Horizon's Vulkan renderer crashed while starting on this graphics card, so the game now uses {api}. Press Play to start it again. Video Settings > Graphics API switches back.",
|
||||
"This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.": "This Nightly's title ({title}) doesn't name its date and commit as Horizon signs them, so it can't be checked: it was not installed.",
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it."
|
||||
"{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.": "{tag} is older than {newest}, which is installed: installing it as a rollback. The selection only moves to it when you pick it.",
|
||||
"Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.": "Native mods require supported Windows architectures that match. Game: {game_arch}; build toolchain: {toolchain_arch}.",
|
||||
"Update registry mods automatically": "Update registry mods automatically",
|
||||
"At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.": "At start and before you play, what you installed from Horizon's registry moves to its newest version for this game. Never inside a modpack, and an update that would remove blocks or items waits for you.",
|
||||
"Updated automatically: {list}.": "Updated automatically: {list}.",
|
||||
"{title} wasn't updated: {error}": "{title} wasn't updated: {error}",
|
||||
"{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready.": "{title} {version} waits for you: it no longer has {ids}, which your worlds may use. Update it on the Mods page when you're ready."
|
||||
}
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user