mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Debug options screen (F3+F6): choose what the F3 overlay shows
Ports DebugOptionsScreen, DebugScreenEntries and DebugScreenEntryList from 26.3: every entry can be off, in the overlay or always shown, the default and performance profiles, debug-profile.json beside options.txt. The overlay, F3+B, F3+G and the 3D crosshair follow the entries, and the screen's GUI Scale button edits debugGuiScale. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
19021623be
commit
e47213a981
10 files changed
+1059
-77
No files matched your search
@@ -21,6 +21,7 @@
|
||||
.DS_Store
|
||||
# What the client writes to its working directory when run from the repo root.
|
||||
/options.txt
|
||||
/debug-profile.json
|
||||
/horizon.txt
|
||||
/horizon-graphics.txt
|
||||
/horizon-controller.txt
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
//! The debug screen's entries (26.3 `DebugScreenEntries`, `DebugScreenEntryList`):
|
||||
//! what each F3 line block and debug renderer is, whether it shows always,
|
||||
//! only in the overlay or never, the two built-in profiles, and
|
||||
//! `debug-profile.json` beside `options.txt`, which keeps them.
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `DebugScreenEntryStatus`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
AlwaysOn,
|
||||
InOverlay,
|
||||
Never,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// `getSerializedName`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Status::AlwaysOn => "alwaysOn",
|
||||
Status::InOverlay => "inOverlay",
|
||||
Status::Never => "never",
|
||||
}
|
||||
}
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
[Status::AlwaysOn, Status::InOverlay, Status::Never]
|
||||
.into_iter()
|
||||
.find(|s| s.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// `DebugScreenProfile`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Profile {
|
||||
Default,
|
||||
Performance,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Profile::Default => "default",
|
||||
Profile::Performance => "performance",
|
||||
}
|
||||
}
|
||||
pub fn translation_key(self) -> &'static str {
|
||||
match self {
|
||||
Profile::Default => "debug.options.profile.default",
|
||||
Profile::Performance => "debug.options.profile.performance",
|
||||
}
|
||||
}
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
[Profile::Default, Profile::Performance]
|
||||
.into_iter()
|
||||
.find(|p| p.name() == name)
|
||||
}
|
||||
/// `DebugScreenEntries.PROFILES`.
|
||||
fn statuses(self) -> &'static [(&'static str, Status)] {
|
||||
use Status::*;
|
||||
match self {
|
||||
Profile::Default => &[
|
||||
("3d_crosshair", InOverlay),
|
||||
("game_version", InOverlay),
|
||||
("tps", InOverlay),
|
||||
("fps", InOverlay),
|
||||
("memory", InOverlay),
|
||||
("system_specs", InOverlay),
|
||||
("player_position", InOverlay),
|
||||
("player_section_position", InOverlay),
|
||||
("simple_performance_impactors", InOverlay),
|
||||
],
|
||||
Profile::Performance => &[
|
||||
("tps", InOverlay),
|
||||
("fps", AlwaysOn),
|
||||
("gpu_utilization", InOverlay),
|
||||
("memory", InOverlay),
|
||||
("simple_performance_impactors", InOverlay),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DebugEntryCategory`: the screen's two sections, by `sortKey`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Category {
|
||||
/// `SCREEN_TEXT`, `debug.options.category.text`.
|
||||
Text,
|
||||
/// `RENDERER` (`DebugEntryNoop`), `debug.options.category.renderer`.
|
||||
Renderer,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub fn translation_key(self) -> &'static str {
|
||||
match self {
|
||||
Category::Text => "debug.options.category.text",
|
||||
Category::Renderer => "debug.options.category.renderer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One registered entry: its path in the `minecraft` namespace, its section,
|
||||
/// and whether it shows while debug info is reduced (`isAllowed(true)`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Entry {
|
||||
pub path: &'static str,
|
||||
pub category: Category,
|
||||
pub reduced: bool,
|
||||
}
|
||||
|
||||
const fn text(path: &'static str, reduced: bool) -> Entry {
|
||||
Entry {
|
||||
path,
|
||||
category: Category::Text,
|
||||
reduced,
|
||||
}
|
||||
}
|
||||
const fn renderer(path: &'static str) -> Entry {
|
||||
Entry {
|
||||
path,
|
||||
category: Category::Renderer,
|
||||
reduced: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DebugScreenEntries`' registrations, in their order.
|
||||
pub const ENTRIES: [Entry; 46] = [
|
||||
text("game_version", true),
|
||||
text("fps", true),
|
||||
text("tps", true),
|
||||
text("memory", true),
|
||||
text("detailed_memory", true),
|
||||
text("system_specs", true),
|
||||
text("looking_at_block_state", false),
|
||||
text("looking_at_block_tags", false),
|
||||
text("looking_at_fluid_state", false),
|
||||
text("looking_at_fluid_tags", false),
|
||||
text("looking_at_entity", false),
|
||||
text("looking_at_entity_tags", false),
|
||||
text("chunk_render_stats", true),
|
||||
text("chunk_generation_stats", false),
|
||||
text("entity_render_stats", true),
|
||||
text("particle_render_stats", false),
|
||||
text("chunk_source_stats", true),
|
||||
text("player_position", false),
|
||||
text("player_section_position", true),
|
||||
text("player_speed", false),
|
||||
text("light_levels", false),
|
||||
text("heightmap", false),
|
||||
text("biome", false),
|
||||
text("local_difficulty", false),
|
||||
text("day_count", false),
|
||||
text("entity_spawn_counts", false),
|
||||
text("sound_mood", false),
|
||||
text("sound_cache", true),
|
||||
text("post_effects", false),
|
||||
renderer("entity_hitboxes"),
|
||||
renderer("chunk_borders"),
|
||||
renderer("3d_crosshair"),
|
||||
renderer("chunk_section_paths"),
|
||||
text("gpu_utilization", true),
|
||||
text("simple_performance_impactors", true),
|
||||
renderer("chunk_section_octree"),
|
||||
renderer("visualize_water_levels"),
|
||||
renderer("visualize_heightmap"),
|
||||
renderer("visualize_collision_boxes"),
|
||||
renderer("visualize_entity_supporting_blocks"),
|
||||
renderer("visualize_block_light_levels"),
|
||||
renderer("visualize_sky_light_levels"),
|
||||
renderer("visualize_solid_faces"),
|
||||
renderer("visualize_chunks_on_server"),
|
||||
renderer("visualize_sky_light_sections"),
|
||||
renderer("chunk_section_visibility"),
|
||||
];
|
||||
|
||||
/// `DebugScreenEntries.getEntry` by path.
|
||||
pub fn entry(path: &str) -> Option<&'static Entry> {
|
||||
ENTRIES.iter().find(|e| e.path == path)
|
||||
}
|
||||
|
||||
/// `DebugOptionsScreen.OptionList.updateSearch`: the entries whose path
|
||||
/// contains `search`, by category then identifier.
|
||||
pub fn search(search: &str) -> Vec<&'static Entry> {
|
||||
let mut all: Vec<&Entry> = ENTRIES.iter().filter(|e| e.path.contains(search)).collect();
|
||||
all.sort_by(|a, b| a.category.cmp(&b.category).then(a.path.cmp(b.path)));
|
||||
all
|
||||
}
|
||||
|
||||
/// `DebugScreenEntryList`: every entry's status (by full identifier, as the
|
||||
/// file keeps them) and the profile they came from if unchanged. Whether the
|
||||
/// F3 overlay is open (`isOverlayVisible`) is the game's `debug`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DebugEntries {
|
||||
statuses: BTreeMap<String, Status>,
|
||||
profile: Option<Profile>,
|
||||
file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for DebugEntries {
|
||||
fn default() -> Self {
|
||||
let mut entries = Self {
|
||||
statuses: BTreeMap::new(),
|
||||
profile: None,
|
||||
file: None,
|
||||
};
|
||||
entries.reset_to_profile(Profile::Default);
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(path: &str) -> String {
|
||||
format!("minecraft:{path}")
|
||||
}
|
||||
|
||||
impl DebugEntries {
|
||||
/// `DebugScreenEntryList.load` from `debug-profile.json` in `dir`: the
|
||||
/// default profile without a file; a file that does not parse is reset
|
||||
/// to it and saved again.
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
let file = dir.join("debug-profile.json");
|
||||
let mut entries = Self {
|
||||
file: Some(file.clone()),
|
||||
..Self::default()
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&file) else {
|
||||
return entries;
|
||||
};
|
||||
match Self::parse(&text) {
|
||||
Some((Some(profile), _)) => entries.reset_to_profile(profile),
|
||||
Some((None, custom)) => {
|
||||
entries.statuses = custom;
|
||||
entries.profile = None;
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"Couldn't read debug profile file {}, resetting to default",
|
||||
file.display()
|
||||
);
|
||||
entries.save();
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
/// `SerializedOptions.CODEC`: an optional `profile` name and an optional
|
||||
/// `custom` map of identifiers to statuses.
|
||||
fn parse(text: &str) -> Option<(Option<Profile>, BTreeMap<String, Status>)> {
|
||||
let json: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let object = json.as_object()?;
|
||||
let profile = match object.get("profile") {
|
||||
Some(name) => Some(Profile::parse(name.as_str()?)?),
|
||||
None => None,
|
||||
};
|
||||
let mut custom = BTreeMap::new();
|
||||
if let Some(map) = object.get("custom") {
|
||||
for (key, status) in map.as_object()? {
|
||||
// Identifier.CODEC: a bare path is in the minecraft namespace.
|
||||
let key = if key.contains(':') {
|
||||
key.clone()
|
||||
} else {
|
||||
identifier(key)
|
||||
};
|
||||
custom.insert(key, Status::parse(status.as_str()?)?);
|
||||
}
|
||||
}
|
||||
Some((profile, custom))
|
||||
}
|
||||
/// `DebugScreenEntryList.save`: the profile's name while it is unchanged,
|
||||
/// else every status, with the data version the codec wraps it in.
|
||||
pub fn save(&self) {
|
||||
let Some(file) = &self.file else {
|
||||
return;
|
||||
};
|
||||
let mut json = serde_json::Map::new();
|
||||
json.insert(
|
||||
"DataVersion".into(),
|
||||
horizon_core::version::DATA_VERSION.into(),
|
||||
);
|
||||
match self.profile {
|
||||
Some(profile) => {
|
||||
json.insert("profile".into(), profile.name().into());
|
||||
}
|
||||
None => {
|
||||
let custom: serde_json::Map<String, serde_json::Value> = self
|
||||
.statuses
|
||||
.iter()
|
||||
.map(|(k, s)| (k.clone(), s.name().into()))
|
||||
.collect();
|
||||
json.insert("custom".into(), custom.into());
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(file, serde_json::Value::Object(json).to_string()) {
|
||||
log::error!("Failed to save debug profile file {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
fn reset_to_profile(&mut self, profile: Profile) {
|
||||
self.profile = Some(profile);
|
||||
self.statuses = profile
|
||||
.statuses()
|
||||
.iter()
|
||||
.map(|(path, status)| (identifier(path), *status))
|
||||
.collect();
|
||||
}
|
||||
/// `loadProfile` then `save` (the screen's profile buttons).
|
||||
pub fn load_profile(&mut self, profile: Profile) {
|
||||
self.reset_to_profile(profile);
|
||||
self.save();
|
||||
}
|
||||
pub fn is_using_profile(&self, profile: Profile) -> bool {
|
||||
self.profile == Some(profile)
|
||||
}
|
||||
/// `getStatus`: never for an entry the statuses leave out.
|
||||
pub fn status(&self, path: &str) -> Status {
|
||||
self.statuses
|
||||
.get(&identifier(path))
|
||||
.copied()
|
||||
.unwrap_or(Status::Never)
|
||||
}
|
||||
/// `setStatus`: the statuses are custom from now on, and saved.
|
||||
pub fn set_status(&mut self, path: &str, status: Status) {
|
||||
self.profile = None;
|
||||
self.statuses.insert(identifier(path), status);
|
||||
self.save();
|
||||
}
|
||||
/// `toggleStatus` (F3+B, F3+G) with the overlay open or not: whether the
|
||||
/// entry is on afterwards.
|
||||
pub fn toggle_status(&mut self, path: &str, overlay: bool) -> bool {
|
||||
let (status, on) = match self.statuses.get(&identifier(path)) {
|
||||
Some(Status::AlwaysOn) => (Status::Never, false),
|
||||
Some(Status::InOverlay) if overlay => (Status::Never, false),
|
||||
Some(Status::Never) if overlay => (Status::InOverlay, true),
|
||||
_ => (Status::AlwaysOn, true),
|
||||
};
|
||||
self.set_status(path, status);
|
||||
on
|
||||
}
|
||||
/// `isCurrentlyEnabled` (`rebuildCurrentList`): on always, or in the
|
||||
/// `overlay` while it is open, and allowed with the debug info `reduced`.
|
||||
pub fn enabled(&self, path: &str, overlay: bool, reduced: bool) -> bool {
|
||||
let shown = match self.status(path) {
|
||||
Status::AlwaysOn => true,
|
||||
Status::InOverlay => overlay,
|
||||
Status::Never => false,
|
||||
};
|
||||
shown && entry(path).is_some_and(|e| e.reduced || !reduced)
|
||||
}
|
||||
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw.
|
||||
pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.any(|e| self.enabled(e.path, overlay, reduced))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toggling_follows_the_overlay() {
|
||||
let mut e = DebugEntries::default();
|
||||
// Not in the default profile: on always while F3 is closed.
|
||||
assert!(e.toggle_status("entity_hitboxes", false));
|
||||
assert_eq!(e.status("entity_hitboxes"), Status::AlwaysOn);
|
||||
assert!(!e.toggle_status("entity_hitboxes", false));
|
||||
assert_eq!(e.status("entity_hitboxes"), Status::Never);
|
||||
// With F3 open, a hidden entry goes in the overlay only.
|
||||
assert!(e.toggle_status("chunk_borders", true));
|
||||
assert_eq!(e.status("chunk_borders"), Status::AlwaysOn);
|
||||
assert!(!e.toggle_status("chunk_borders", true));
|
||||
assert_eq!(e.status("chunk_borders"), Status::Never);
|
||||
assert!(e.toggle_status("chunk_borders", true));
|
||||
assert_eq!(e.status("chunk_borders"), Status::InOverlay);
|
||||
assert!(!e.toggle_status("chunk_borders", true));
|
||||
assert!(!e.is_using_profile(Profile::Default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_needs_the_overlay_and_full_debug_info() {
|
||||
let mut e = DebugEntries::default();
|
||||
assert!(!e.any_enabled(false, false));
|
||||
assert!(e.enabled("player_position", true, false));
|
||||
// Reduced debug info hides the position, not the section.
|
||||
assert!(!e.enabled("player_position", true, true));
|
||||
assert!(e.enabled("player_section_position", true, true));
|
||||
e.load_profile(Profile::Performance);
|
||||
assert!(e.enabled("fps", false, false));
|
||||
assert!(!e.enabled("memory", false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_keeps_a_profile_or_the_statuses() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-debug-profile-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut e = DebugEntries::load(&dir);
|
||||
assert!(e.is_using_profile(Profile::Default));
|
||||
e.load_profile(Profile::Performance);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("debug-profile.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(saved["profile"], "performance");
|
||||
assert!(DebugEntries::load(&dir).is_using_profile(Profile::Performance));
|
||||
e.set_status("biome", Status::AlwaysOn);
|
||||
let loaded = DebugEntries::load(&dir);
|
||||
assert_eq!(loaded.status("biome"), Status::AlwaysOn);
|
||||
assert_eq!(loaded.status("fps"), Status::AlwaysOn);
|
||||
assert!(!loaded.is_using_profile(Profile::Performance));
|
||||
// A broken file is reset to the default profile.
|
||||
std::fs::write(dir.join("debug-profile.json"), "{\"profile\":\"nope\"}").unwrap();
|
||||
assert!(DebugEntries::load(&dir).is_using_profile(Profile::Default));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_sorts_text_before_renderers() {
|
||||
let all = search("");
|
||||
assert_eq!(all.len(), 46);
|
||||
assert_eq!(all[0].path, "biome");
|
||||
let first_renderer = all
|
||||
.iter()
|
||||
.position(|e| e.category == Category::Renderer)
|
||||
.unwrap();
|
||||
assert_eq!(all[first_renderer].path, "3d_crosshair");
|
||||
assert!(
|
||||
all[first_renderer..]
|
||||
.iter()
|
||||
.all(|e| e.category == Category::Renderer)
|
||||
);
|
||||
assert_eq!(search("chunk_b").len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ mod creatures;
|
||||
mod crossbow;
|
||||
pub(crate) mod cushions;
|
||||
mod debug_keys;
|
||||
mod debug_options;
|
||||
mod debug_stick;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
@@ -703,6 +704,8 @@ enum Screen {
|
||||
DialogWarning,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// DebugOptionsScreen, F3+F6 (game/debug_options.rs).
|
||||
DebugOptions,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -2284,8 +2287,11 @@ struct Game<'a> {
|
||||
debug_keys: horizon_client::debug_keys::DebugKeys,
|
||||
/// The F3 overlay's charts (F3+2, F3+3).
|
||||
debug_charts: horizon_client::debug_chart::DebugCharts,
|
||||
show_hitboxes: bool,
|
||||
show_chunk_borders: bool,
|
||||
/// `Minecraft.debugEntries`: what the overlay and the debug renderers
|
||||
/// show (`debug-profile.json`, F3+F6).
|
||||
debug_entries: horizon_client::debug_entries::DebugEntries,
|
||||
/// `DebugOptionsScreen` while it is open (game/debug_options.rs).
|
||||
debug_options: Option<debug_options::DebugOptionsUi>,
|
||||
/// PauseScreen.showPauseMenu (false after F3+Escape).
|
||||
pause_menu: bool,
|
||||
/// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it).
|
||||
@@ -6707,6 +6713,7 @@ impl Game<'_> {
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
| Screen::DebugOptions
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
}
|
||||
@@ -7363,6 +7370,7 @@ impl Game<'_> {
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
Screen::CommandBlock => self.draw_command_block(&mut batch),
|
||||
Screen::BlockEditor => self.draw_block_editor(&mut batch),
|
||||
Screen::DebugOptions => self.draw_debug_options(&mut batch),
|
||||
Screen::Inventory => {
|
||||
self.draw_inventory(&mut batch);
|
||||
self.draw_mod_browser(&mut batch);
|
||||
@@ -13870,6 +13878,7 @@ impl Game<'_> {
|
||||
Screen::BlockEditor => self.close_block_editor(),
|
||||
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -14743,6 +14752,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::ServerLinks && d != 0.0 {
|
||||
self.server_links_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::DebugOptions && d != 0.0 {
|
||||
self.debug_options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
@@ -14940,6 +14952,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::ServerLinks if pressed && button == MouseButton::Left => {
|
||||
self.server_links_click()?
|
||||
}
|
||||
Screen::DebugOptions if pressed && button == MouseButton::Left => {
|
||||
self.debug_options_click()
|
||||
}
|
||||
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
|
||||
self.confirm_link_click()
|
||||
}
|
||||
@@ -15110,6 +15125,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
|
||||
self.command_block_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::DebugOptions => {
|
||||
self.debug_options_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::BlockEditor => {
|
||||
self.block_editor_key(&event)
|
||||
}
|
||||
@@ -15937,8 +15955,10 @@ impl<'a> Game<'a> {
|
||||
friends: None,
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
show_hitboxes: false,
|
||||
show_chunk_borders: false,
|
||||
debug_entries: horizon_client::debug_entries::DebugEntries::load(
|
||||
&std::env::current_dir().unwrap_or_default(),
|
||||
),
|
||||
debug_options: None,
|
||||
pause_menu: true,
|
||||
previous_mode: None,
|
||||
gamemode_switcher: None,
|
||||
|
||||
@@ -424,6 +424,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
|
||||
@@ -630,6 +630,7 @@ impl Game<'_> {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
@@ -909,6 +910,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,8 +1042,8 @@ impl Game<'_> {
|
||||
"screen": self.screen_name(),
|
||||
"debug": {
|
||||
"overlay": self.debug,
|
||||
"hitboxes": self.show_hitboxes,
|
||||
"chunk_borders": self.show_chunk_borders,
|
||||
"hitboxes": self.debug_entry("entity_hitboxes"),
|
||||
"chunk_borders": self.debug_entry("chunk_borders"),
|
||||
"pause_menu": self.pause_menu,
|
||||
"advanced_tooltips": self.settings.file.bool("advancedItemTooltips"),
|
||||
"pause_on_lost_focus": self.settings.file.bool("pauseOnLostFocus"),
|
||||
|
||||
@@ -278,14 +278,26 @@ impl Game<'_> {
|
||||
format!("To edit: press {}", key("key.debug.debugOptions")),
|
||||
]
|
||||
}
|
||||
/// `DebugScreenOverlay.extractRenderState` with the default profile's
|
||||
/// entries, over everything the frame drew (menus too), at the debug GUI
|
||||
/// scale. F1 hides it unless a screen is open.
|
||||
/// `Gui.extractRenderState`'s debug overlay, over everything the frame
|
||||
/// drew (menus too); the debug options screen draws it under itself.
|
||||
pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
if self.screen != Screen::DebugOptions {
|
||||
self.draw_debug_overlay_under(batch, size);
|
||||
}
|
||||
}
|
||||
/// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines
|
||||
/// (`debug-profile.json`), at the debug GUI scale, and the charts while
|
||||
/// the overlay is open. F1 hides it unless a screen is open.
|
||||
pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
let screen_open = self.screen != Screen::Playing;
|
||||
if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) {
|
||||
let reduced = self.reduced_debug_info();
|
||||
if self.title.is_some()
|
||||
|| !self.debug_entries.any_enabled(self.debug, reduced)
|
||||
|| (self.ui.hide_gui && !screen_open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let on = |path: &str| self.debug_entries.enabled(path, self.debug, reduced);
|
||||
let framebuffer = size.map(|v| v as i32);
|
||||
let (scale, [width, height]) = debug_overlay::scale(
|
||||
framebuffer,
|
||||
@@ -296,6 +308,83 @@ impl Game<'_> {
|
||||
);
|
||||
let mut d = debug_overlay::Displayer::default();
|
||||
// fps: DebugEntryFps.
|
||||
if on("fps") {
|
||||
self.debug_fps_line(&mut d);
|
||||
}
|
||||
// game_version: DebugEntryVersion (the launched version and brand).
|
||||
if on("game_version") {
|
||||
d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)"));
|
||||
}
|
||||
// player_position, player_section_position.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let mut lines = debug_overlay::position(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim.id(),
|
||||
self.streaming.forced.len(),
|
||||
);
|
||||
let section = lines.pop().unwrap_or_default();
|
||||
if on("player_position") {
|
||||
d.group("position", lines);
|
||||
}
|
||||
if on("player_section_position") {
|
||||
d.group("position", [section]);
|
||||
}
|
||||
}
|
||||
if on("simple_performance_impactors") {
|
||||
self.debug_performance_lines(&mut d);
|
||||
}
|
||||
if on("system_specs") {
|
||||
self.debug_system_lines(&mut d, framebuffer);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
});
|
||||
if self.debug && !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
if on("tps") {
|
||||
self.debug_tps_line(&mut d);
|
||||
}
|
||||
// The chart keys and the charts only while the overlay is open.
|
||||
let footer = if self.debug {
|
||||
self.debug_chart_lines().to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (left, right) = d.columns(&footer);
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width);
|
||||
if self.debug {
|
||||
let charts = self.debug_charts.draw(
|
||||
&atlas.font,
|
||||
width,
|
||||
height,
|
||||
self.settings.max_fps,
|
||||
1000.0 / self.clock.rate(),
|
||||
!self.has_singleplayer_server(),
|
||||
);
|
||||
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
|
||||
scale, scale,
|
||||
)]));
|
||||
draws.extend(charts);
|
||||
if self.debug_charts.lightmap
|
||||
&& let Some(r) = &self.renderer
|
||||
{
|
||||
draws.extend(horizon_client::debug_chart::lightmap(
|
||||
&r.lightmap_pixels,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
}
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
}
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
/// `DebugEntryFps`.
|
||||
fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
|
||||
let limit = self.framerate_limit();
|
||||
let present = self
|
||||
.renderer
|
||||
@@ -324,24 +413,9 @@ impl Game<'_> {
|
||||
limit.to_string()
|
||||
},
|
||||
));
|
||||
// game_version: DebugEntryVersion (the launched version and brand).
|
||||
d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)"));
|
||||
// player_position, player_section_position.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let mut lines = debug_overlay::position(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim.id(),
|
||||
self.streaming.forced.len(),
|
||||
);
|
||||
let section = lines.pop().unwrap_or_default();
|
||||
if !self.reduced_debug_info() {
|
||||
d.group("position", lines);
|
||||
}
|
||||
d.group("position", [section]);
|
||||
}
|
||||
// simple_performance_impactors.
|
||||
/// `DebugEntrySimplePerformanceImpactors`.
|
||||
fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) {
|
||||
let s = &self.settings;
|
||||
d.line(format!(
|
||||
"{}{}B: {}",
|
||||
@@ -366,7 +440,9 @@ impl Game<'_> {
|
||||
"naive"
|
||||
}
|
||||
));
|
||||
// system_specs (no Java line: Horizon has no JVM).
|
||||
}
|
||||
/// `DebugEntrySystemSpecs` (no Java line: Horizon has no JVM).
|
||||
fn debug_system_lines(&self, d: &mut debug_overlay::Displayer, framebuffer: [i32; 2]) {
|
||||
if let Some(r) = self.renderer.as_ref() {
|
||||
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
d.group(
|
||||
@@ -432,14 +508,9 @@ impl Game<'_> {
|
||||
{
|
||||
d.group("system", [r.hdr_line()]);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
});
|
||||
if !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// tps: DebugEntryTps for local and remote connections.
|
||||
/// `DebugEntryTps` for local and remote connections.
|
||||
fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) {
|
||||
if self.player.is_some() {
|
||||
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
|
||||
let (target, status) = if self.clock.sprinting() {
|
||||
@@ -471,32 +542,6 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
}
|
||||
let (left, right) = d.columns(&self.debug_chart_lines());
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width);
|
||||
let charts = self.debug_charts.draw(
|
||||
&atlas.font,
|
||||
width,
|
||||
height,
|
||||
self.settings.max_fps,
|
||||
1000.0 / self.clock.rate(),
|
||||
!self.has_singleplayer_server(),
|
||||
);
|
||||
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
|
||||
scale, scale,
|
||||
)]));
|
||||
draws.extend(charts);
|
||||
if self.debug_charts.lightmap
|
||||
&& let Some(r) = &self.renderer
|
||||
{
|
||||
draws.extend(horizon_client::debug_chart::lightmap(
|
||||
&r.lightmap_pixels,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
}
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
/// `KeyMapping.getTranslatedKeyMessage`.
|
||||
fn key_text(&self, mapping: &str) -> Text {
|
||||
@@ -525,13 +570,17 @@ impl Game<'_> {
|
||||
self.debug_feedback(Text::translate(key, &[]));
|
||||
}
|
||||
/// `LocalPlayer.isReducedDebugInfo`: the server's rule or the option.
|
||||
fn reduced_debug_info(&self) -> bool {
|
||||
pub(super) fn reduced_debug_info(&self) -> bool {
|
||||
self.settings.reduced_debug_info || self.ui.rules.bool("reduced_debug_info")
|
||||
}
|
||||
/// The default profile's `3d_crosshair` entry: on while the overlay is
|
||||
/// open, unless debug info is reduced.
|
||||
/// `DebugScreenEntryList.isCurrentlyEnabled` for an entry's path.
|
||||
pub(super) fn debug_entry(&self, path: &str) -> bool {
|
||||
self.debug_entries
|
||||
.enabled(path, self.debug, self.reduced_debug_info())
|
||||
}
|
||||
/// The `3d_crosshair` entry.
|
||||
pub(super) fn debug_crosshair(&self) -> bool {
|
||||
self.debug && !self.reduced_debug_info()
|
||||
self.debug_entry("3d_crosshair")
|
||||
}
|
||||
/// `Permissions.COMMANDS_GAMEMASTER` (`GameModeCommand.PERMISSION_CHECK`).
|
||||
pub(super) fn gamemaster(&self) -> bool {
|
||||
@@ -555,8 +604,10 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
DebugAction::ShowHitboxes if player => {
|
||||
self.show_hitboxes = !self.show_hitboxes;
|
||||
self.debug_translated(if self.show_hitboxes {
|
||||
let on = self
|
||||
.debug_entries
|
||||
.toggle_status("entity_hitboxes", self.debug);
|
||||
self.debug_translated(if on {
|
||||
"debug.show_hitboxes.on"
|
||||
} else {
|
||||
"debug.show_hitboxes.off"
|
||||
@@ -569,8 +620,10 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
DebugAction::ShowChunkBorders if player => {
|
||||
self.show_chunk_borders = !self.show_chunk_borders;
|
||||
self.debug_translated(if self.show_chunk_borders {
|
||||
let on = self
|
||||
.debug_entries
|
||||
.toggle_status("chunk_borders", self.debug);
|
||||
self.debug_translated(if on {
|
||||
"debug.chunk_boundaries.on"
|
||||
} else {
|
||||
"debug.chunk_boundaries.off"
|
||||
@@ -674,11 +727,13 @@ impl Game<'_> {
|
||||
self.debug_charts.toggle_lightmap(&mut self.debug);
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the debug
|
||||
// options screen, the profiler and its chart, the texture dump):
|
||||
// the keys are used up as in vanilla.
|
||||
DebugAction::DebugOptions
|
||||
| DebugAction::DumpDynamicTextures
|
||||
DebugAction::DebugOptions => {
|
||||
self.toggle_debug_options();
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the profiler and
|
||||
// its chart, the texture dump): the keys are used up as in vanilla.
|
||||
DebugAction::DumpDynamicTextures
|
||||
| DebugAction::Profiling
|
||||
| DebugAction::ProfilingChart => true,
|
||||
DebugAction::ShowHitboxes
|
||||
@@ -859,7 +914,7 @@ impl Game<'_> {
|
||||
pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos {
|
||||
let mut g = Gizmos::default();
|
||||
self.bounding_box_gizmos(camera, &mut g);
|
||||
if self.show_chunk_borders
|
||||
if self.debug_entry("chunk_borders")
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
let block = [
|
||||
@@ -870,7 +925,7 @@ impl Game<'_> {
|
||||
let min_y = self.world.bottom_y();
|
||||
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
|
||||
}
|
||||
if !self.show_hitboxes {
|
||||
if !self.debug_entry("entity_hitboxes") {
|
||||
return g;
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! `DebugOptionsScreen` (F3+F6): every debug entry with Off / In Overlay /
|
||||
//! Always buttons, a search box over them, the two built-in profiles and the
|
||||
//! debug overlay's GUI scale. The statuses live in `debug-profile.json`
|
||||
//! (`horizon_client::debug_entries`).
|
||||
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/debug/DebugOptionsScreen
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
debug_entries::{Category, Entry, Profile, Status, search},
|
||||
hud::HudBatch,
|
||||
options_file::V,
|
||||
text::Text,
|
||||
ui::{self, EditBox, Scroll},
|
||||
};
|
||||
use winit::event::{ElementState, KeyEvent};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
|
||||
/// `HeaderAndFooterLayout(this, 61, 33)`.
|
||||
const HEADER: f32 = 61.0;
|
||||
const FOOTER: f32 = 33.0;
|
||||
/// `OptionList.getRowWidth` and `ITEM_HEIGHT`.
|
||||
const ROW_WIDTH: f32 = 350.0;
|
||||
const ITEM: f32 = 20.0;
|
||||
/// `OptionEntry.BUTTON_WIDTH`, at the CycleButtons' 16 pixels high.
|
||||
const BUTTON: f32 = 60.0;
|
||||
/// The footer: two profile buttons, the GUI scale and Done, 4 pixels apart.
|
||||
const FOOTER_BUTTONS: [f32; 4] = [120.0, 120.0, 110.0, 60.0];
|
||||
/// `debug.options.warning`'s colour (-2142128) and the buttons' text colours.
|
||||
const WARNING: u32 = 0xDF_5050;
|
||||
const UNSELECTED: u32 = 0xBA_BABA;
|
||||
const OVERLAY: u32 = 0xFF_FF55;
|
||||
const NOT_ALLOWED: u32 = 0x80_8080;
|
||||
/// `EditBox.SEARCH_HINT_STYLE`: `ChatFormatting.GRAY`.
|
||||
const HINT: u32 = 0xAA_AAAA;
|
||||
|
||||
/// The open screen: its search box and the list's scroll.
|
||||
pub(super) struct DebugOptionsUi {
|
||||
search: EditBox,
|
||||
scroll: Scroll,
|
||||
}
|
||||
|
||||
impl DebugOptionsUi {
|
||||
pub(super) fn search_focused(&self) -> bool {
|
||||
self.search.focused
|
||||
}
|
||||
}
|
||||
|
||||
/// One list row (`AbstractOptionEntry`).
|
||||
enum Row {
|
||||
Category(Category),
|
||||
Option(&'static Entry),
|
||||
}
|
||||
|
||||
/// `OptionList.updateSearch`: a category row before each category's entries.
|
||||
fn rows(value: &str) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
let mut current = None;
|
||||
for entry in search(value) {
|
||||
if current != Some(entry.category) {
|
||||
rows.push(Row::Category(entry.category));
|
||||
current = Some(entry.category);
|
||||
}
|
||||
rows.push(Row::Option(entry));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// `AbstractSelectionList.contentHeight`.
|
||||
fn content_height(n: usize) -> f32 {
|
||||
n as f32 * ITEM + 4.0
|
||||
}
|
||||
/// `getRowLeft`: the list spans the screen, its rows centred.
|
||||
fn row_left(w: f32) -> f32 {
|
||||
(w / 2.0).floor() - ROW_WIDTH / 2.0
|
||||
}
|
||||
/// The three buttons' left edges (Off, In Overlay, Always) for a row whose
|
||||
/// content starts at `x` (`extractContent`).
|
||||
fn button_xs(content_x: f32) -> [f32; 3] {
|
||||
let start = content_x + ROW_WIDTH - 4.0 - 3.0 * BUTTON;
|
||||
[start, start + BUTTON, start + 2.0 * BUTTON]
|
||||
}
|
||||
/// The footer buttons' left edges and their row's top.
|
||||
fn footer(w: f32, h: f32) -> ([f32; 4], f32) {
|
||||
let total: f32 = FOOTER_BUTTONS.iter().sum::<f32>() + 3.0 * 4.0;
|
||||
let mut x = ((w - total) / 2.0).floor();
|
||||
let mut xs = [0.0; 4];
|
||||
for (slot, width) in xs.iter_mut().zip(FOOTER_BUTTONS) {
|
||||
*slot = x;
|
||||
x += width + 4.0;
|
||||
}
|
||||
(xs, h - FOOTER + ((FOOTER - 20.0) / 2.0).floor())
|
||||
}
|
||||
/// The three buttons' statuses and texts, in their order on the row.
|
||||
const BUTTONS: [(Status, &str, u32); 3] = [
|
||||
(Status::Never, "options.off", 0xFF_FFFF),
|
||||
(Status::InOverlay, "debug.entry.overlay", OVERLAY),
|
||||
(Status::AlwaysOn, "debug.entry.always", WARNING),
|
||||
];
|
||||
|
||||
impl Game<'_> {
|
||||
/// `KeyboardHandler`'s F3+F6: closes the screen if it is open, else opens
|
||||
/// it over the game or the pause menu (`canInterruptScreen`).
|
||||
pub(super) fn toggle_debug_options(&mut self) {
|
||||
match self.screen {
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Playing | Screen::Paused if !self.screen_over_playing() => {
|
||||
let mut search = EditBox::new(0.0, 0.0, (ROW_WIDTH / 3.0).floor(), 20.0);
|
||||
search.max_length = 32;
|
||||
self.debug_options = Some(DebugOptionsUi {
|
||||
search,
|
||||
scroll: Scroll::default(),
|
||||
});
|
||||
self.screen = Screen::DebugOptions;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
self.mining = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Screen.onClose`: back to the game.
|
||||
pub(super) fn close_debug_options(&mut self) {
|
||||
self.debug_options = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
/// The header's title row and warning (`init`'s vertical layout,
|
||||
/// centred in the header): the title's x, the row's top, the warning's
|
||||
/// lines with their y.
|
||||
fn debug_options_header(&self, w: f32) -> (f32, f32, Vec<(Text, f32)>) {
|
||||
let font = &self.gui.atlas.font;
|
||||
let title = Text::translate("debug.options.title", &[]);
|
||||
let warning = Text::translate("debug.options.warning", &[]).with_color(WARNING);
|
||||
let lines = font.split(&warning, ROW_WIDTH);
|
||||
let height = 20.0 + 8.0 + lines.len() as f32 * 9.0;
|
||||
let top = ((HEADER - height) / 2.0).floor();
|
||||
let third = (ROW_WIDTH / 3.0).floor();
|
||||
let tw = font.width_text(&title);
|
||||
let row = third + 8.0 + tw + 8.0 + third;
|
||||
let title_x = ((w - row) / 2.0).floor() + third + 8.0;
|
||||
let lines = lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| (line, top + 28.0 + i as f32 * 9.0))
|
||||
.collect();
|
||||
(title_x, top, lines)
|
||||
}
|
||||
/// `debugGuiScale`'s button: `GUI Scale: Unchanged`, `Auto` or the scale.
|
||||
fn debug_gui_scale_label(&self) -> Text {
|
||||
let value = match self.settings.file.int("debugGuiScale") {
|
||||
-1 => Text::translate("options.debugGuiScale.unchanged", &[]),
|
||||
0 => Text::translate("options.guiScale.auto", &[]),
|
||||
n => Text::literal(n.to_string()),
|
||||
};
|
||||
Text::translate(
|
||||
"options.generic_value",
|
||||
&[Text::translate("options.guiScale", &[]), value],
|
||||
)
|
||||
}
|
||||
/// `ClampingLazyMaxIntRange(-1, window.calculateScale(0, unicode))` as a
|
||||
/// CycleButton: the next value, or the previous one with Shift.
|
||||
fn cycle_debug_gui_scale(&mut self, back: bool) {
|
||||
let size = self.renderer.as_ref().map_or([1280, 720], |r| {
|
||||
[r.config.width as i32, r.config.height as i32]
|
||||
});
|
||||
let max = horizon_client::window::calculate_scale(size, 0, self.settings.force_unicode);
|
||||
let current = self.settings.file.int("debugGuiScale").clamp(-1, max);
|
||||
let next = match (back, current) {
|
||||
(false, c) if c >= max => -1,
|
||||
(false, c) => c + 1,
|
||||
(true, -1) => max,
|
||||
(true, c) => c - 1,
|
||||
};
|
||||
self.settings.file.set("debugGuiScale", V::Int(next));
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
pub(super) fn draw_debug_options(&mut self, batch: &mut HudBatch) {
|
||||
let Some(mut s) = self.debug_options.take() else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
// extractBlurredBackground: the debug overlay goes under the screen.
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or([w as u32, h as u32], |r| [r.config.width, r.config.height]);
|
||||
self.draw_debug_overlay_under(batch, size);
|
||||
let atlas = &self.gui.atlas;
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let reduced = self.reduced_debug_info();
|
||||
let rows = rows(&s.search.value);
|
||||
let (top, bottom) = (HEADER, h - FOOTER);
|
||||
let content = content_height(rows.len());
|
||||
s.scroll.clamp(top, bottom, content);
|
||||
// The list: background, rows clipped to it, separators, scrollbar.
|
||||
ui::list_background(batch, atlas, top, bottom, true);
|
||||
let first = batch.vertices.len();
|
||||
let left = row_left(w);
|
||||
let inside = self.cursor.1 >= top && self.cursor.1 < bottom;
|
||||
let mut tooltip = None;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let y = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM;
|
||||
if y + ITEM < top || y > bottom {
|
||||
continue;
|
||||
}
|
||||
let (cx, cy) = (left + 2.0, y + 2.0);
|
||||
match row {
|
||||
Row::Category(category) => {
|
||||
let label = Text::translate(category.translation_key(), &[])
|
||||
.bold()
|
||||
.underlined();
|
||||
let lw = atlas.font.width_text(&label);
|
||||
let lx = (cx + (ROW_WIDTH - 4.0) / 2.0 - (lw + 8.0) / 2.0).floor() + 4.0;
|
||||
batch.text_styled(atlas, &label, lx, cy + 4.0, u32::MAX, true);
|
||||
}
|
||||
Row::Option(entry) => {
|
||||
let allowed = entry.reduced || !reduced;
|
||||
let name = if allowed {
|
||||
Text::literal(entry.path)
|
||||
} else {
|
||||
Text::literal(entry.path).italic().with_color(NOT_ALLOWED)
|
||||
};
|
||||
batch.text_styled(atlas, &name, cx, cy + 5.0, u32::MAX, true);
|
||||
let xs = button_xs(cx);
|
||||
let status = self.debug_entries.status(entry.path);
|
||||
for ((status_of, key, color), x) in BUTTONS.into_iter().zip(xs) {
|
||||
let selected = status == status_of;
|
||||
let label = Text::translate(key, &[]).with_color(if selected {
|
||||
color
|
||||
} else {
|
||||
UNSELECTED
|
||||
});
|
||||
let hover = inside && ui::hovered(self.cursor, x, cy, BUTTON, 16.0);
|
||||
ui::button(batch, atlas, x, cy, BUTTON, 16.0, &label, !selected, hover);
|
||||
}
|
||||
if !allowed && inside && ui::hovered(self.cursor, cx, y, xs[0] - cx, ITEM) {
|
||||
tooltip = Some(Text::translate("debug.options.notAllowed.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
batch.clip_since(first, 0.0, top, w, bottom);
|
||||
ui::separators(batch, atlas, top, bottom, true);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
left + ROW_WIDTH + 8.0,
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
s.scroll.amount.trunc(),
|
||||
);
|
||||
// The header: title, search box, warning.
|
||||
let (title_x, header_top, lines) = self.debug_options_header(w);
|
||||
let title = Text::translate("debug.options.title", &[]);
|
||||
batch.text_styled(atlas, &title, title_x, header_top + 5.0, u32::MAX, true);
|
||||
s.search.x = title_x + atlas.font.width_text(&title) + 8.0;
|
||||
s.search.y = header_top;
|
||||
s.search.draw(batch, atlas, None);
|
||||
if s.search.value.is_empty() {
|
||||
let hint = Text::translate("debug.options.search", &[])
|
||||
.with_color(HINT)
|
||||
.italic();
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&hint,
|
||||
s.search.text_x(),
|
||||
s.search.text_y(),
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (line, y) in &lines {
|
||||
let lw = atlas.font.width_text(line);
|
||||
batch.text_styled(atlas, line, ((w - lw) / 2.0).floor(), *y, u32::MAX, true);
|
||||
}
|
||||
// The footer.
|
||||
let (xs, fy) = footer(w, h);
|
||||
let labels = [
|
||||
Text::translate(Profile::Default.translation_key(), &[]),
|
||||
Text::translate(Profile::Performance.translation_key(), &[]),
|
||||
self.debug_gui_scale_label(),
|
||||
Text::translate("gui.done", &[]),
|
||||
];
|
||||
let active = [
|
||||
!self.debug_entries.is_using_profile(Profile::Default),
|
||||
!self.debug_entries.is_using_profile(Profile::Performance),
|
||||
true,
|
||||
true,
|
||||
];
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let hover = ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
xs[i],
|
||||
fy,
|
||||
FOOTER_BUTTONS[i],
|
||||
20.0,
|
||||
label,
|
||||
active[i],
|
||||
hover,
|
||||
);
|
||||
if i == 2 && hover {
|
||||
tooltip = Some(Text::translate("options.debugGuiScale.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
if let Some(tooltip) = tooltip {
|
||||
let lines = atlas.font.split(&tooltip, 200.0);
|
||||
ui::tooltip(batch, atlas, &lines, self.cursor);
|
||||
}
|
||||
self.debug_options = Some(s);
|
||||
}
|
||||
/// A left click: a status button, the search box, a footer button.
|
||||
pub(super) fn debug_options_click(&mut self) {
|
||||
let Some(mut s) = self.debug_options.take() else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = self.gui_size();
|
||||
let (top, bottom) = (HEADER, h - FOOTER);
|
||||
let shift =
|
||||
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
|
||||
s.search.focused = ui::hovered(
|
||||
self.cursor,
|
||||
s.search.x,
|
||||
s.search.y,
|
||||
s.search.width,
|
||||
s.search.height,
|
||||
);
|
||||
if s.search.focused {
|
||||
s.search.click(&self.gui.atlas, self.cursor.0, shift, false);
|
||||
}
|
||||
let inside = self.cursor.1 >= top && self.cursor.1 < bottom;
|
||||
let rows = rows(&s.search.value);
|
||||
let left = row_left(w);
|
||||
let mut clicked = None;
|
||||
if inside {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let Row::Option(entry) = row else { continue };
|
||||
let cy = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM + 2.0;
|
||||
let status = self.debug_entries.status(entry.path);
|
||||
for ((status_of, _, _), x) in BUTTONS.into_iter().zip(button_xs(left + 2.0)) {
|
||||
if status != status_of && ui::hovered(self.cursor, x, cy, BUTTON, 16.0) {
|
||||
clicked = Some((entry.path, status_of));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.debug_options = Some(s);
|
||||
if let Some((path, status)) = clicked {
|
||||
// OptionEntry.setValue.
|
||||
self.audio.play_button_click();
|
||||
self.debug_entries.set_status(path, status);
|
||||
return;
|
||||
}
|
||||
let (xs, fy) = footer(w, h);
|
||||
let Some(i) =
|
||||
(0..4).find(|&i| ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match i {
|
||||
0 | 1 => {
|
||||
let profile = if i == 0 {
|
||||
Profile::Default
|
||||
} else {
|
||||
Profile::Performance
|
||||
};
|
||||
if self.debug_entries.is_using_profile(profile) {
|
||||
return;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
self.debug_entries.load_profile(profile);
|
||||
}
|
||||
2 => {
|
||||
self.audio.play_button_click();
|
||||
self.cycle_debug_gui_scale(shift);
|
||||
}
|
||||
_ => {
|
||||
self.audio.play_button_click();
|
||||
self.close_debug_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The mouse wheel over the list (`scrollRate`: half a row).
|
||||
pub(super) fn debug_options_scroll(&mut self, delta: f32) {
|
||||
let (_, h) = self.gui_size();
|
||||
if let Some(s) = &mut self.debug_options {
|
||||
let content = content_height(rows(&s.search.value).len());
|
||||
s.scroll
|
||||
.wheel(delta, ITEM / 2.0, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// A key the debug keys left: Escape closes, the rest edits the search.
|
||||
pub(super) fn debug_options_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
if key == KeyCode::Escape {
|
||||
self.close_debug_options();
|
||||
return;
|
||||
}
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let types_text = self.types_text();
|
||||
let Some(s) = &mut self.debug_options else {
|
||||
return;
|
||||
};
|
||||
if !s.search.focused {
|
||||
return;
|
||||
}
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.search.shortcut(shortcut, &mut self.clipboard);
|
||||
} else if !s.search.key(key, ctrl, shift)
|
||||
&& types_text
|
||||
&& let Some(text) = event.text.as_deref()
|
||||
{
|
||||
self.debug_options_type(text);
|
||||
}
|
||||
}
|
||||
/// Characters typed into the search box (`EditBox.charTyped`).
|
||||
pub(super) fn debug_options_type(&mut self, typed: &str) {
|
||||
let allowed: String = typed
|
||||
.chars()
|
||||
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
|
||||
.collect();
|
||||
if let Some(s) = &mut self.debug_options
|
||||
&& s.search.focused
|
||||
&& !allowed.is_empty()
|
||||
{
|
||||
s.search.insert_text(&allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_put_a_category_before_each_section() {
|
||||
let all = rows("");
|
||||
assert_eq!(all.len(), 48);
|
||||
assert!(matches!(all[0], Row::Category(Category::Text)));
|
||||
let renderer = all
|
||||
.iter()
|
||||
.position(|r| matches!(r, Row::Category(Category::Renderer)))
|
||||
.unwrap();
|
||||
assert!(matches!(all[renderer + 1], Row::Option(e) if e.path == "3d_crosshair"));
|
||||
// A search inside one section shows only its heading.
|
||||
let hitboxes = rows("hitbox");
|
||||
assert_eq!(hitboxes.len(), 2);
|
||||
assert!(matches!(hitboxes[0], Row::Category(Category::Renderer)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layout_matches_vanilla_at_854_by_480() {
|
||||
// 854x480 at GUI scale 2 is 427x240.
|
||||
assert_eq!(row_left(427.0), 38.0);
|
||||
assert_eq!(button_xs(40.0), [206.0, 266.0, 326.0]);
|
||||
assert_eq!(footer(427.0, 240.0), ([2.0, 126.0, 250.0, 364.0], 213.0));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ impl Game<'_> {
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat | Screen::CommandBlock => true,
|
||||
Screen::DebugOptions => self
|
||||
.debug_options
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.search_focused()),
|
||||
Screen::WorldOptions => self.world_options_typing(),
|
||||
Screen::Creative => self.creative_screen.tab().search,
|
||||
Screen::Inventory => {
|
||||
@@ -106,6 +110,7 @@ impl Game<'_> {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(&text),
|
||||
Screen::CommandBlock => self.command_block_type(&text),
|
||||
Screen::DebugOptions => self.debug_options_type(&text),
|
||||
Screen::WorldOptions => self.world_options_text(Some(&text)),
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
|
||||
@@ -22,7 +22,8 @@ impl Screen {
|
||||
| Self::Friends
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning => true,
|
||||
| Self::DialogWarning
|
||||
| Self::DebugOptions => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
| Self::Chat
|
||||
|
||||
@@ -42,6 +42,7 @@ pub mod creative;
|
||||
pub mod cushion;
|
||||
pub mod data_cmd;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_entries;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod dialog;
|
||||
|
||||
Reference in new issue
Block a user