diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index c2a8a0bd..07a4da74 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -76,6 +76,7 @@ mod probe_stone; mod probe_taxi; mod probe_vendor_swap; mod probes; +mod realm_list; mod scenarios; use crate::run_mode::CaptureMode; pub(crate) use depth_probe::DepthProbePlugin; @@ -138,7 +139,7 @@ pub(crate) use probe_pitch::ProbePitchPlugin; pub(crate) fn start_state() -> crate::char_select::ClientState { match glue_screen() { Some(GlueScreen::CharCreate) => crate::char_select::ClientState::CharCreate, - Some(GlueScreen::Login) => crate::char_select::ClientState::Login, + Some(GlueScreen::Login | GlueScreen::RealmList) => crate::char_select::ClientState::Login, None if crate::run_mode::scenario_active() => crate::char_select::ClientState::InWorld, None => crate::char_select::ClientState::Login, } @@ -673,6 +674,7 @@ impl Plugin for CapturePlugin { // Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier // frames, so the same frame's feeds and the one-shot `MERCHANT_SHOW` paint must see it. .add_systems(Update, seed_ui_fixture.before(crate::ui_unit::UnitFeed)) + .add_systems(Update, realm_list::seed_realm_list) .add_systems(Last, drive_capture); } } diff --git a/crates/benilla-app/src/capture/realm_list.rs b/crates/benilla-app/src/capture/realm_list.rs new file mode 100644 index 00000000..270314f4 --- /dev/null +++ b/crates/benilla-app/src/capture/realm_list.rs @@ -0,0 +1,57 @@ +//! `glue-realmlist`: the realm list over the login screen with realms in two categories, which the +//! live server cannot show (it lists one). The list is published as the realm park publishes one, +//! then the dialog is raised as Change Realm raises it; the tabs are named from the install's own +//! Region, so bytes 1 and 2 are "English" and "German" under Region 3 and 1 alone under Region 1. + +use bevy::prelude::*; + +use benilla_protocol::RealmInfo; + +use super::CaptureCtx; + +/// The scenario's name in [`super::scenarios::GLUE_SCENARIOS`]. +pub(super) const NAME: &str = "glue-realmlist"; + +/// Invented realms: three on category byte 1, two on byte 2, a mix of types, loads and counts. +fn fixture() -> Vec { + let realm = + |name: &str, category: u8, realm_type: u32, population: f32, characters: u8| RealmInfo { + name: name.into(), + address: "127.0.0.1:8085".into(), + population, + characters, + realm_type, + flags: 0, + category, + id: 0, + }; + vec![ + realm("Amberfield", 1, 0, 0.5, 2), + realm("Brightwater", 1, 1, 1.5, 0), + realm("Copperdale", 1, 6, 1.0, 0), + realm("Dunmoor", 2, 1, 0.8, 0), + realm("Eisenwald", 2, 0, 1.2, 0), + ] +} + +/// Publish the list, let the policy take it, then raise the dialog. +pub(super) fn seed_realm_list( + ctx: Res, + mut step: Local, + mut lists: MessageWriter, + mut realms: ResMut, + choice: Res, +) { + if ctx.name != NAME { + return; + } + match *step { + 0 => { + lists.write(crate::net::RealmListMessage { realms: fixture() }); + } + 2 => crate::realm_select::open_over_char_select(&mut realms, &choice), + 3.. => return, + _ => {} + } + *step += 1; +} diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 9df0a96c..e8b2290b 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -167,10 +167,13 @@ pub(crate) enum GlueScreen { /// The login screen, `UI_MainMenu` behind the account form. Its backdrop is the narrowest of /// the seven scenes, so a wide window (`WOW_WIN=2560x1440`) reaches its edges first. Login, + /// The realm list over the login screen, fed a list in two categories + /// (`super::realm_list`). + RealmList, } /// The glue scenarios: character creation as a human male warrior, the race the reference's own -/// screenshots use, and the login screen. +/// screenshots use, the login screen, and the realm list's category tabs over it. pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[ GlueScenario { name: "glue-charcreate", @@ -182,6 +185,11 @@ pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[ screen: GlueScreen::Login, pick: None, }, + GlueScenario { + name: super::realm_list::NAME, + screen: GlueScreen::RealmList, + pick: None, + }, ]; /// The golden baseline: two spots at noon and at night, Elwynn water and a Felwood hollow on diff --git a/crates/benilla-app/src/glue/art.rs b/crates/benilla-app/src/glue/art.rs index 8db69f07..8010d921 100644 --- a/crates/benilla-app/src/glue/art.rs +++ b/crates/benilla-app/src/glue/art.rs @@ -144,6 +144,10 @@ pub(crate) struct GlueArt { pub(crate) column_tabs: Option<(Handle, Vec2)>, pub(crate) tab_highlight: Option>, pub(crate) sort_arrow: Option<(Handle, Vec2)>, + /// The realm list's category tabs (`RealmListTabButtonTemplate`): the front tab's + /// `UI-Character-ActiveTab` and the others' `UI-Character-InActiveTab`. + pub(crate) tab_active: Option<(Handle, Vec2)>, + pub(crate) tab_inactive: Option<(Handle, Vec2)>, } /// `RealmSortButtonTemplate`'s `WhoFrame-ColumnTabs` slices, `[left, right, top, bottom]` each: @@ -155,6 +159,13 @@ pub(crate) const COLUMN_TAB_TC: [[f32; 4]; 3] = [ ]; /// `$parentArrow`'s texcoords into `UI-SortArrow`, the down-pointing half. pub(crate) const SORT_ARROW_TC: [f32; 4] = [0.0, 0.5625, 0.0, 1.0]; +/// `RealmListTabButtonTemplate`'s `UI-Character-(In)ActiveTab` slices, `[left, right, top, +/// bottom]` each: the left end (20 wide), the middle stretched to the label, the right end. +pub(crate) const REALM_TAB_TC: [[f32; 4]; 3] = [ + [0.0, 0.156_25, 0.0, 1.0], + [0.156_25, 0.843_75, 0.0, 1.0], + [0.843_75, 1.0, 0.0, 1.0], +]; /// The `HelpFrame-*` plate: six pieces tiling a 640×512 panel (TopLeft/Top 256², TopRight /// 128×256 on top; BotLeft/Bottom/BotRight below). @@ -427,10 +438,20 @@ impl GlueArt { add_mats, ); self.sort_arrow = sized(assets, "Interface\\Buttons\\UI-SortArrow", images); + self.tab_active = sized( + assets, + "Interface\\PaperDollInfoFrame\\UI-Character-ActiveTab", + images, + ); + self.tab_inactive = sized( + assets, + "Interface\\PaperDollInfoFrame\\UI-Character-InActiveTab", + images, + ); debug!( "glue art: addonlist set — helpframe {} header {} close {} droparrow {}/{} \ questhl {} titlehl {} greycheck {} tipborder {} scrolltrack {} \ - coltabs {} tabhl {} sortarrow {}", + coltabs {} tabhl {} sortarrow {} realmtabs {}/{}", self.help_frame.is_some(), self.dialog_header.is_some(), self.close_btn.is_some(), @@ -444,6 +465,8 @@ impl GlueArt { self.column_tabs.is_some(), self.tab_highlight.is_some(), self.sort_arrow.is_some(), + self.tab_active.is_some(), + self.tab_inactive.is_some(), ); debug!( "glue art: races {} classes {} gender {} factions {} banners {} hilight {} logo {} \ diff --git a/crates/benilla-app/src/realm_select/category.rs b/crates/benilla-app/src/realm_select/category.rs new file mode 100644 index 00000000..83b81fb6 --- /dev/null +++ b/crates/benilla-app/src/realm_select/category.rs @@ -0,0 +1,368 @@ +//! The realm categories behind the list's tabs. +//! +//! The category set is the client Region's `Cfg_Categories.dbc` rows, built once per process +//! (`0x46e300`). Each list rebuild (`0x46e510`) places every realm by its wire category byte: a 0 +//! byte takes the first category's id (`0x46e5b8`), then the byte joins every category whose id +//! equals it (`0x46e60b`), and a byte no category carries leaves the realm on no tab at all. +//! +//! A category the Lua names crosses as a 1-based ordinal over the non-empty categories only; the +//! map to the stored index (`0x46edf0`) has no sentinel, so an ordinal past the last one reads +//! the first stored category, empty or not. + +use bevy::prelude::*; + +use benilla_formats::RealmCategory; +use benilla_protocol::RealmInfo; + +use super::Realms; + +/// `MAX_REALM_CATEGORY_TABS` (`RealmList.lua:4`). +pub(super) const MAX_TABS: usize = 8; + +/// Every category's realms, as indices into the list in wire order. Before the category set is +/// read, or when the Region has none, there is one category holding every realm. +fn place(categories: Option<&[RealmCategory]>, realms: &[RealmInfo]) -> Vec> { + // Deviation: no Region rows faults the reference on a NULL category (`0x46e5b8`); list all. + let Some(categories) = categories.filter(|c| !c.is_empty()) else { + return vec![(0..realms.len()).collect()]; + }; + let mut buckets = vec![Vec::new(); categories.len()]; + for (i, realm) in realms.iter().enumerate() { + let byte = match realm.category { + 0 => match categories.first() { + Some(first) => first.id, + None => continue, + }, + b => u32::from(b), + }; + // No break: a realm joins every category carrying its id. + for (bucket, category) in buckets.iter_mut().zip(categories) { + if category.id == byte { + bucket.push(i); + } + } + } + buckets +} + +/// `0x46edf0`: the stored index of the `visible`-th non-empty category (1-based), or 0. +fn stored_index(buckets: &[Vec], visible: usize) -> usize { + buckets + .iter() + .enumerate() + .filter(|(_, b)| !b.is_empty()) + .nth(visible.wrapping_sub(1)) + .map_or(0, |(i, _)| i) +} + +impl Realms { + /// Every category's realms; see [`place`]. + fn buckets(&self) -> Vec> { + place(self.categories.as_deref(), &self.realms) + } + + /// The realms of the tab in front, unsorted: `GetNumRealms` and `GetRealmInfo` with + /// `RealmList.selectedCategory`. + pub(super) fn category_realms(&self) -> Vec { + let mut buckets = self.buckets(); + let at = stored_index(&buckets, self.category()); + if at < buckets.len() { + std::mem::take(&mut buckets[at]) + } else { + Vec::new() + } + } + + /// `RealmList.selectedCategory`: nil until the first `RealmListUpdate` makes it 1. + pub(super) fn category(&self) -> usize { + self.category.unwrap_or(1) + } + + /// The tab labels, `GetRealmCategories` (`0x46f1f0`): the names of the categories holding a + /// realm. `RealmList_UpdateTabs` hides the strip unless there are two or more, so the one name + /// the reference still pushes when none hold a realm (`0x46f255`) is left out. + pub(super) fn tabs(&self) -> Vec<&str> { + let Some(categories) = &self.categories else { + return Vec::new(); + }; + self.buckets() + .iter() + .zip(categories) + .filter(|(b, _)| !b.is_empty()) + .map(|(_, c)| c.name.as_str()) + .collect() + } + + /// `GetSelectedCategory` (`0x46f400`): the ordinal of the first non-empty category at or after + /// the stored one, else 1. + pub(super) fn selected_category(&self) -> usize { + let from = self.chosen_category.unwrap_or(0); + self.buckets() + .iter() + .enumerate() + .filter(|(_, b)| !b.is_empty()) + .enumerate() + .find(|(_, (i, _))| *i >= from) + .map_or(1, |(ordinal, _)| ordinal + 1) + } + + /// `RealmList_OnShow`: the tab `GetSelectedCategory` names comes to the front, when a tab + /// button carries that ordinal. + pub(super) fn front_selected_category(&mut self) { + let ordinal = self.selected_category(); + if ordinal <= MAX_TABS { + self.category = Some(ordinal); + } + } + + /// `RealmListTab_OnClick`: the tab comes to the front and the list redraws. The redraw has no + /// clicked name to keep (`RealmListUpdate` clears `selectedName`), so the highlight falls back + /// to the realm this session is on, when that realm is on the new tab. + pub(super) fn click_tab(&mut self, ordinal: usize) { + self.category = Some(ordinal); + self.refresh_in = super::REFRESH_SECS; + self.highlight_current(); + self.clamp_offset(); + } + + /// `ChangeRealm` stores the front tab's category (`0x46f188`), unconditionally, before it + /// dials; `GetSelectedCategory` reads it when the list next opens. + pub(super) fn store_front_category(&mut self) { + self.chosen_category = Some(stored_index(&self.buckets(), self.category())); + } + + /// The rebuild's tail (`0x46e6f0`): the category holding the realm this session names becomes + /// the stored one; with no such realm, or one on no tab, it is left as it was. + pub(super) fn store_current_category(&mut self) { + let (Some(categories), Some(want)) = (&self.categories, &self.current) else { + return; + }; + // The walk visits every realm, so the last one of the name decides. + let Some(realm) = self + .realms + .iter() + .rfind(|r| r.name.eq_ignore_ascii_case(want)) + else { + return; + }; + let byte = match realm.category { + 0 => match categories.first() { + Some(first) => first.id, + None => return, + }, + b => u32::from(b), + }; + if let Some(at) = categories.iter().position(|c| c.id == byte) { + self.chosen_category = Some(at); + } + } +} + +/// Read the client Region's category set once, after the archive chain opens. +pub(super) fn load_categories( + mut realms: ResMut, + assets: Option>, +) { + use benilla_assets::LockRecover; + let Some(assets) = assets else { + return; + }; + let mut chain = assets.chain.lock_recover(); + let region = benilla_formats::client_region(&chain); + match benilla_formats::load_realm_categories(&mut chain, region) { + Ok(categories) => { + info!( + "realm list: Region {region}, categories {:?}", + categories + .iter() + .map(|c| (c.id, c.name.as_str())) + .collect::>() + ); + if categories.is_empty() { + warn!( + "realm list: Region {region} names no Cfg_Categories.dbc row, so every realm \ + lists on one untabbed page (the reference's Region is Wow.ini's [WoW Config] \ + Region)" + ); + } + realms.categories = Some(categories); + } + Err(e) => warn!( + "realm list: Cfg_Categories.dbc failed to load, so every realm lists on one untabbed \ + page: {e:#}" + ), + } +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + fn cat(id: u32, name: &str) -> RealmCategory { + RealmCategory { + id, + name: name.into(), + } + } + + /// Region 1's rows, `Cfg_Categories.dbc` in file order. + pub(in crate::realm_select) fn us() -> Vec { + vec![cat(1, "United States"), cat(5, "Oceanic")] + } + + fn realm(name: &str, category: u8) -> RealmInfo { + RealmInfo { + name: name.into(), + address: "127.0.0.1:8085".into(), + population: 1.0, + characters: 0, + realm_type: 0, + flags: 0, + category, + id: 0, + } + } + + fn realms(list: &[(&str, u8)]) -> Realms { + Realms { + realms: list.iter().map(|&(n, c)| realm(n, c)).collect(), + categories: Some(us()), + ..Realms::default() + } + } + + fn names(r: &Realms) -> Vec { + r.rows().iter().map(|&i| r.realms[i].name.clone()).collect() + } + + /// The US client reads its two categories from the install's own `Cfg_Categories.dbc`. + #[test] + fn the_us_categories_come_from_the_install() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let loaded = benilla_formats::load_realm_categories(&mut chain, 1).expect("DBC"); + assert_eq!(loaded, us()); + } + + /// A category byte is an id, matched for equality: 1 and 5 are the two US tabs. + #[test] + fn a_realm_lands_on_the_tab_whose_id_is_its_category_byte() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5), ("Beta", 1)]); + assert_eq!(r.tabs(), ["United States", "Oceanic"]); + assert_eq!(names(&r), ["Alpha", "Beta"]); + r.category = Some(2); + assert_eq!(names(&r), ["Down Under"]); + } + + /// Byte 0 (a vmangos development-zone realm) folds into the first category. + #[test] + fn a_zero_byte_joins_the_first_category() { + let r = realms(&[("Dev", 0), ("Alpha", 1)]); + assert_eq!(r.tabs(), ["United States"], "one tab: the strip hides"); + assert_eq!(names(&r), ["Alpha", "Dev"]); + } + + /// A byte no category of the Region carries lists nowhere. + #[test] + fn a_byte_outside_the_region_is_on_no_tab() { + let r = realms(&[("Alpha", 1), ("Berlin", 2)]); + assert_eq!(r.tabs(), ["United States"]); + assert_eq!(names(&r), ["Alpha"]); + assert_eq!(r.category_realms(), [0]); + } + + /// Only a category holding a realm gets a tab, and ordinals count those alone. + #[test] + fn an_empty_category_has_no_tab_and_no_ordinal() { + let mut r = realms(&[("Down Under", 5)]); + assert_eq!(r.tabs(), ["Oceanic"], "United States holds nothing"); + assert_eq!( + names(&r), + ["Down Under"], + "ordinal 1 is the Oceanic category" + ); + // Past the last ordinal the map reads stored category 0, the empty United States. + r.category = Some(2); + assert!(names(&r).is_empty()); + } + + /// Before the category set is read every realm lists, untabbed. + #[test] + fn with_no_category_set_every_realm_lists_untabbed() { + let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]); + r.categories = None; + assert!(r.tabs().is_empty()); + assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]); + } + + /// A Region no row names (Region 0, no `Wow.ini` Region) lists every realm untabbed too, + /// where the reference faults; the list opens on it and a realm on it can be entered. + #[test] + fn a_region_with_no_categories_lists_every_realm_untabbed() { + let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]); + r.categories = Some(Vec::new()); + assert!(r.tabs().is_empty()); + assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]); + r.current = Some("Berlin".into()); + r.store_current_category(); + r.open(); + assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Berlin")); + assert!(r.can_enter()); + } + + /// A tab click fronts its category, and the highlight moves to the session's realm if it is + /// there, else nowhere, so Okay cannot enter a realm from another tab. + #[test] + fn a_tab_click_relists_and_resets_the_highlight() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + r.current = Some("Alpha".into()); + r.select("Alpha"); + r.click_tab(2); + assert_eq!(names(&r), ["Down Under"]); + assert!(r.selected().is_none() && !r.can_enter()); + r.click_tab(1); + assert_eq!(names(&r), ["Alpha"]); + assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Alpha")); + } + + /// A tab with fewer realms than the scroll offset shows from its top. + #[test] + fn a_tab_click_clamps_the_scroll_to_the_new_tab() { + let mut list: Vec<(String, u8)> = (0..30).map(|i| (format!("R{i:02}"), 1)).collect(); + list.push(("Down Under".into(), 5)); + let refs: Vec<(&str, u8)> = list.iter().map(|(n, c)| (n.as_str(), *c)).collect(); + let mut r = realms(&refs); + r.offset = 12; + r.click_tab(2); + assert_eq!(r.offset, 0); + } + + /// The list opens on the tab of the realm this session is on, and a realm entered from a tab + /// brings that tab back next time. + #[test] + fn the_list_opens_on_the_current_realms_tab() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + assert_eq!(r.selected_category(), 1, "nothing stored: the first tab"); + r.current = Some("down under".into()); + r.store_current_category(); + assert_eq!(r.selected_category(), 2); + r.front_selected_category(); + assert_eq!(names(&r), ["Down Under"]); + + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + r.category = Some(2); + r.store_front_category(); + r.category = Some(1); + r.front_selected_category(); + assert_eq!(r.category(), 2, "ChangeRealm's stored tab"); + } + + /// A stored category that has emptied gives the next non-empty one's ordinal. + #[test] + fn a_stored_category_that_emptied_reads_the_next_tab_on() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + r.chosen_category = Some(0); + r.realms.remove(0); + assert_eq!(r.selected_category(), 1, "Oceanic, now the only tab"); + } +} diff --git a/crates/benilla-app/src/realm_select/input.rs b/crates/benilla-app/src/realm_select/input.rs index 9b8dee46..eb49770c 100644 --- a/crates/benilla-app/src/realm_select/input.rs +++ b/crates/benilla-app/src/realm_select/input.rs @@ -15,8 +15,8 @@ use super::{is_down, Realms}; /// The double-click window, the interval the character select screen uses. const DOUBLE_CLICK_SECS: f32 = 0.4; -/// Clicks: a row selects (a second one enters), the column headers sort, Okay enters, Cancel and -/// the close X leave. +/// Clicks: a row selects (a second one enters), a tab fronts its category, the column headers +/// sort, Okay enters, Cancel and the close X leave. pub(super) fn clicks( buttons: Query<(Entity, &RealmAction)>, hits: Res, @@ -49,6 +49,9 @@ pub(super) fn clicks( enter = true; } } + // The front tab is `Disable()`d (`GlueTemplates_SelectTab`) and takes no click. + RealmAction::Tab(ordinal) if ordinal != realms.category() => realms.click_tab(ordinal), + RealmAction::Tab(_) => {} RealmAction::Ok => enter = true, RealmAction::Cancel => leave = Some(true), RealmAction::Close => leave = Some(false), @@ -177,6 +180,7 @@ fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWrit let name = realm.name.clone(); sounds.write(GlueSound("gsLoginChangeRealmOK")); realms.hide(); + realms.store_front_category(); realms.enter(choice, name); } diff --git a/crates/benilla-app/src/realm_select/mod.rs b/crates/benilla-app/src/realm_select/mod.rs index 9c9630a9..467b268c 100644 --- a/crates/benilla-app/src/realm_select/mod.rs +++ b/crates/benilla-app/src/realm_select/mod.rs @@ -19,6 +19,7 @@ //! names no screen; only `ConnectToRealm` (`0x46b210`) drops the world connection, once a realm is //! chosen. So the character park serves the list in place and Cancel there does nothing. +mod category; mod input; mod load; mod screen; @@ -29,6 +30,7 @@ pub(crate) use load::pvp_rp; use bevy::prelude::*; use crate::net::{RealmChoice, RealmListMessage, RealmRequest}; +use benilla_formats::RealmCategory; use benilla_protocol::RealmInfo; /// The persisted 1.12 CVar naming the last realm connected to (`0x83f2d0`); the SavedVariables @@ -46,8 +48,15 @@ pub(crate) struct Realms { pub(super) realms: Vec, /// The highlighted row by realm name, so a refresh that adds or drops a realm cannot move it. selected: Option, - /// The category tab in front (`RealmList.selectedCategory`), by the wire's category byte. - pub(super) category: Option, + /// The client Region's categories ([`category`]), read once at startup; `None` until then, + /// and every realm lists untabbed. + pub(super) categories: Option>, + /// The tab in front, `RealmList.selectedCategory`: a 1-based ordinal over the categories + /// holding a realm. + pub(super) category: Option, + /// `[0x837e98]`, the stored category `GetSelectedCategory` opens the list on, by index into + /// [`Self::categories`]; `None` is its `-1` load value. + chosen_category: Option, /// First visible row within the selected category (`RealmList.offset`). pub(super) offset: usize, /// Which column the list is sorted on, and which way. @@ -117,17 +126,11 @@ impl Sort { } impl Realms { - /// The rows the screen draws: indices into [`Self::realms`] for the selected category, sorted. + /// The rows the screen draws: indices into [`Self::realms`] for the tab in front, sorted per + /// category (`0x46e750`). pub(super) fn rows(&self) -> Vec { - let category = self.category; let (mean, stddev) = self.stats(); - let mut rows: Vec = self - .realms - .iter() - .enumerate() - .filter(|(_, r)| category.is_none_or(|c| r.category == c)) - .map(|(i, _)| i) - .collect(); + let mut rows = self.category_realms(); rows.sort_by(|&a, &b| { let (ra, rb) = (&self.realms[a], &self.realms[b]); for (key, descending) in self.sort.0 { @@ -153,15 +156,6 @@ impl Realms { rows } - /// The category bytes present, ascending, one tab each; `RealmList_UpdateTabs` hides the strip - /// when there is only one. - pub(super) fn categories(&self) -> Vec { - let mut cats: Vec = self.realms.iter().map(|r| r.category).collect(); - cats.sort_unstable(); - cats.dedup(); - cats - } - /// The load distribution, over every realm, not the selected category: `0x46e510` walks the /// flat all-categories array, so switching tabs moves no band. pub(super) fn stats(&self) -> (f32, f32) { @@ -186,22 +180,35 @@ impl Realms { self.selected().is_some_and(|r| !is_down(r)) } - /// `RealmList:Show()`: reset the scroll, highlight [`Self::current`], start the refresh timer. + /// `RealmList:Show()`: front the stored category's tab, reset the scroll, highlight + /// [`Self::current`], start the refresh timer. fn open(&mut self) { self.shown = true; + self.front_selected_category(); self.offset = 0; - self.selected = self - .current - .as_deref() - .and_then(|want| { - self.realms - .iter() - .find(|r| r.name.eq_ignore_ascii_case(want)) - }) - .map(|r| r.name.clone()); + self.highlight_current(); self.refresh_in = REFRESH_SECS; } + /// `RealmListUpdate` with no clicked name: the highlight is the row `GetRealmInfo` flags + /// `currentRealm`, [`Self::current`] case-folded, on the tab in front, or none. + fn highlight_current(&mut self) { + self.selected = self.current.as_deref().and_then(|want| { + self.rows() + .into_iter() + .map(|i| &self.realms[i]) + .find(|r| r.name.eq_ignore_ascii_case(want)) + .map(|r| r.name.clone()) + }); + } + + /// `GlueScrollFrame_Update` clamps the scroll bar to the tab's row count, and the bar's + /// change handler writes `RealmList.offset` back. + fn clamp_offset(&mut self) { + let max = self.rows().len().saturating_sub(screen::MAX_ROWS); + self.offset = self.offset.min(max); + } + /// Answer a park with a realm and remember it as [`Self::current`]. pub(super) fn enter(&mut self, choice: &RealmChoice, name: String) { self.current = Some(name.clone()); @@ -230,27 +237,32 @@ pub(crate) struct RealmSelectPlugin; impl Plugin for RealmSelectPlugin { fn build(&self, app: &mut App) { - app.init_resource::().add_systems( - Update, - ( - apply_realm_policy, - // Ungated by state: the dialog stands over login and character select alike. - screen::drive_screen, - // Input before the row refresh, so a click shows on the frame it landed. - smoke::debug_realm_smoke, + app.init_resource::() + .add_systems( + Startup, + category::load_categories.after(benilla_assets::AssetSet::Open), + ) + .add_systems( + Update, ( - input::clicks, - input::keys, - tick_refresh, - screen::refresh_rows, + apply_realm_policy, + // Ungated by state: the dialog stands over login and character select alike. + screen::drive_screen, + // Input before the row refresh, so a click shows on the frame it landed. + smoke::debug_realm_smoke, + ( + input::clicks, + input::keys, + tick_refresh, + screen::refresh_list, + ) + .chain() + .run_if(|realms: Res| realms.shown), ) .chain() - .run_if(|realms: Res| realms.shown), - ) - .chain() - .after(benilla_world::schedule::WorldStage::Net) - .before(crate::glue::GlueVisuals), - ); + .after(benilla_world::schedule::WorldStage::Net) + .before(crate::glue::GlueVisuals), + ); } } @@ -268,16 +280,6 @@ fn apply_realm_policy( for msg in msgs.read() { realms.realms = msg.realms.clone(); realms.refresh_in = REFRESH_SECS; - // Keep the tab across a refresh while it still exists, else the first. - let cats = realms.categories(); - if !realms.category.is_some_and(|c| cats.contains(&c)) { - realms.category = cats.first().copied(); - } - - // A list published while the dialog is up is a refresh, never a question. - if realms.shown { - continue; - } // The registered default is empty: a client that has never connected. let remembered = cvars .get(CVAR_REALM_NAME) @@ -286,6 +288,14 @@ fn apply_realm_policy( if realms.current.is_none() { realms.current = remembered.clone(); } + // The rebuild (`0x46e510`): the tab in front is kept as its ordinal, as the Lua keeps it. + realms.store_current_category(); + realms.clamp_offset(); + + // A list published while the dialog is up is a refresh, never a question. + if realms.shown { + continue; + } match auto_answer( &realms, remembered.as_deref(), @@ -383,11 +393,11 @@ mod tests { r.rows().iter().map(|&i| r.realms[i].name.clone()).collect() } + /// A list under the US client's categories. fn list(realms: Vec) -> Realms { - let category = realms.first().map(|r| r.category); Realms { realms, - category, + categories: Some(category::tests::us()), ..Realms::default() } } @@ -407,13 +417,15 @@ mod tests { assert!(!is_down(&sentinel) && !is_invalid(&sentinel)); } - /// The load distribution spans every realm, so switching tabs relabels no band. + /// The load distribution spans every realm, a realm on no tab included, so switching tabs + /// relabels no band. #[test] fn a_category_tab_scopes_the_rows_but_not_the_load_distribution() { let mut r = list(vec![ realm("Alpha", 1, 0, 0, 0, 1.0), realm("Beta", 1, 0, 0, 0, 1.0), - realm("Gamma", 2, 0, 0, 0, 400.0), + realm("Gamma", 5, 0, 0, 0, 400.0), + realm("Berlin", 2, 0, 0, 0, 7.0), ]); r.category = Some(1); assert_eq!(r.rows().len(), 2, "the tab scopes the rows"); @@ -426,8 +438,8 @@ mod tests { mean_on_tab_one, mean_on_tab_two, "the distribution is global — the tab must not move it" ); - // (1 + 1 + 400) / 3. - assert!((mean_on_tab_one - 134.0).abs() < 1e-3, "{mean_on_tab_one}"); + // (1 + 1 + 400 + 7) / 4: the category-2 realm is on no US tab and still counts. + assert!((mean_on_tab_one - 102.25).abs() < 1e-3, "{mean_on_tab_one}"); } /// The reference hides the tab strip for a single category. @@ -437,7 +449,7 @@ mod tests { realm("Alpha", 1, 0, 0, 0, 1.0), realm("Beta", 1, 0, 0, 0, 1.0), ]); - assert_eq!(r.categories(), vec![1]); + assert_eq!(r.tabs(), ["United States"]); } /// The default order is characters, load, name, mode, all ascending; more characters first. diff --git a/crates/benilla-app/src/realm_select/screen.rs b/crates/benilla-app/src/realm_select/screen.rs index ad980ccc..cbd6710e 100644 --- a/crates/benilla-app/src/realm_select/screen.rs +++ b/crates/benilla-app/src/realm_select/screen.rs @@ -1,21 +1,24 @@ //! The realm list's layout: `GlueXML/RealmList.xml` in Bevy UI, on the 1024×768 glue scale. //! //! A full-screen dim, then the 640×512 `HelpFrame` plate (the AddOns list's) with the title plate, -//! four sort headers, eighteen rows, the selection band, the close X, and Okay and Cancel. Rows are -//! spawned empty and [`refresh_rows`] writes them every frame from [`super::Realms`]. +//! four sort headers, the eight category tabs under the plate, eighteen rows, the selection band, +//! the close X, and Okay and Cancel. Rows and tabs are spawned empty and [`refresh_list`] writes +//! them every frame from [`super::Realms`]. //! -//! The category tab strip is not built: `RealmList_UpdateTabs` hides it for a single category, and -//! with several, only the first category's realms show. +//! The tab strip shows only with two or more categories holding a realm (`RealmList_UpdateTabs`), +//! one tab per category, and the rows are the front tab's. +use bevy::ecs::system::SystemParam; use bevy::prelude::*; use bevy::window::PrimaryWindow; -use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, SORT_ARROW_TC}; +use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, REALM_TAB_TC, SORT_ARROW_TC}; use crate::glue::widgets::{ - abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight, + abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueDisabled, GlueText, Hilight, }; use crate::glue_strings::GlueStrings; +use super::category::MAX_TABS; use super::load; use super::{Realms, SortKey}; @@ -67,6 +70,23 @@ const COL_TYPE: (f32, f32) = (235.0, 50.0); const COL_PLAYERS: (f32, f32) = (336.0, 32.0); const COL_LOAD: (f32, f32) = (418.0, 115.0); +/// `RealmListTab1`'s BOTTOMLEFT sits at the plate's BOTTOMLEFT (11, −15), so the 32-tall tabs hang +/// below the plate's lower edge. +const TAB_LEFT: f32 = 11.0; +const TAB_H: f32 = 32.0; +const TAB_TOP: f32 = BG_H + 15.0 - TAB_H; +/// Each next tab's LEFT at the one before's RIGHT −15. +const TAB_OVERLAP: f32 = 15.0; +/// `$parentLeft` and `$parentRight`, 20 wide; `GlueTemplates_TabResize(0)` makes the middle the +/// label's own width. +const TAB_END: f32 = 20.0; +/// `$parentLeftDisabled` at TOPLEFT (0, 3): the front tab's art stands 3 higher. +const TAB_FRONT_RISE: f32 = 3.0; +/// `$parentText` at CENTER (0, 2), and the highlight's two anchors at y 2. +const TAB_TEXT_RISE: f32 = 2.0; +/// `$parentHighlightTexture`, anchored 10 in from each side; its height is its art's, 32. +const TAB_HILIGHT_INSET: f32 = 10.0; + /// Root of the realm list; `with_art` and `s` trigger a rebuild when the art lands or the scale /// changes. #[derive(Component)] @@ -87,6 +107,8 @@ pub(super) enum RealmAction { /// declares no sound) where Cancel plays `gsLoginChangeRealmCancel`. Close, Sort(SortKey), + /// `RealmListTab`: the category tab with that 1-based ordinal. + Tab(usize), } /// The screen row of a row button and of each of its four texts. @@ -107,6 +129,14 @@ pub(super) struct RowHighlight; /// The Okay button, so the refresh can grey it out. #[derive(Component)] pub(super) struct OkButton; +/// A category tab's button, label and art pieces, by the tab's 1-based ordinal. +#[derive(Component, Clone, Copy)] +pub(super) struct TabOf(pub(super) usize); +/// One of a tab's six art pieces: the front tab's `ActiveTab` set, or the others' `InActiveTab`. +#[derive(Component, Clone, Copy)] +pub(super) struct TabArt { + front: bool, +} /// Spawn, rebuild and despawn the dialog as `shown` says, over whatever glue screen is current. pub(super) fn drive_screen( @@ -205,6 +235,7 @@ fn spawn_screen( spawn_header(b, art, &font, &text("SERVER_SELECTION"), s); spawn_close(b, art, &font, s); spawn_sort_headers(b, art, &font, strings, s); + spawn_tabs(b, art, &font, s); spawn_highlight(b, art, s); for row in 0..MAX_ROWS { spawn_row(b, &font, row, s); @@ -442,6 +473,108 @@ fn spawn_sort_headers( } } +/// `RealmListTab1`..`8` (`RealmListTabButtonTemplate`), spawned hidden in a row that chains each +/// tab 15 into the one before. A tab is its two 20-wide ends round a middle as wide as the label, +/// in both art sets; [`paint_tabs`] labels them and picks the set. +fn spawn_tabs(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle, s: f32) { + let px = |v: f32| Val::Px(v * s); + let mut strip = b.spawn(Node { + position_type: PositionType::Absolute, + left: px(TAB_LEFT), + top: px(TAB_TOP), + height: px(TAB_H), + flex_direction: FlexDirection::Row, + ..default() + }); + strip.with_children(|strip| { + for ordinal in 1..=MAX_TABS { + let overlap = if ordinal == 1 { 0.0 } else { -TAB_OVERLAP }; + let mut tab = strip.spawn(( + RealmAction::Tab(ordinal), + TabOf(ordinal), + Button, + GlueDisabled(false), + Visibility::Hidden, + Node { + height: px(TAB_H), + flex_shrink: 0.0, + margin: UiRect::left(px(overlap)), + padding: UiRect::horizontal(px(TAB_END)), + align_items: AlignItems::Center, + ..default() + }, + )); + tab.with_children(|t| { + for (front, sheet) in [(false, &art.tab_inactive), (true, &art.tab_active)] { + let Some((tex, size)) = sheet else { + continue; + }; + let top = if front { -TAB_FRONT_RISE } else { 0.0 }; + for (piece, tc) in REALM_TAB_TC.iter().enumerate() { + let (left, right, width) = match piece { + 0 => (px(0.0), Val::Auto, px(TAB_END)), + 1 => (px(TAB_END), px(TAB_END), Val::Auto), + _ => (Val::Auto, px(0.0), px(TAB_END)), + }; + t.spawn(( + TabOf(ordinal), + TabArt { front }, + Visibility::Hidden, + ImageNode { + image: tex.clone(), + rect: Some(tc_rect(*size, *tc)), + ..default() + }, + Node { + position_type: PositionType::Absolute, + left, + right, + width, + top: px(top), + height: px(TAB_H), + ..default() + }, + )); + } + } + if let Some(hi) = &art.tab_highlight { + t.spawn(( + Hilight, + Visibility::Hidden, + MaterialNode(hi.clone()), + Node { + position_type: PositionType::Absolute, + left: px(TAB_HILIGHT_INSET), + right: px(TAB_HILIGHT_INSET), + top: px(-TAB_TEXT_RISE), + height: px(TAB_H), + ..default() + }, + )); + } + let label = outlined_text( + t, + Node { + top: px(-TAB_TEXT_RISE), + ..default() + }, + (), + (), + GlueText { + text: "", + size: 12.0, // GlueFontNormalSmall + color: GOLD, + wrap: false, + }, + font, + s, + ); + t.commands().entity(label).insert(TabOf(ordinal)); + }); + } + }); +} + /// `RealmListHighlight`: one band, moved to the selected row and vertex-coloured to match it. fn spawn_highlight(b: &mut ChildSpawnerCommands, art: &GlueArt, s: f32) { let mut band = b.spawn(( @@ -503,10 +636,40 @@ fn spawn_row(b: &mut ChildSpawnerCommands, font: &Handle, row: usize, s: f }); } -/// `RealmListUpdate`: write every visible row, each frame, since any realm's population moves -/// every band. +/// The tab strip's buttons, art pieces and labels, kept apart from the rows' by their markers. +#[derive(SystemParam)] #[allow(clippy::type_complexity)] -pub(super) fn refresh_rows( +pub(super) struct TabNodes<'w, 's> { + tabs: Query< + 'w, + 's, + ( + &'static TabOf, + &'static Interaction, + &'static mut Visibility, + &'static mut GlueDisabled, + ), + ( + With, + Without, + Without, + Without, + ), + >, + arts: Query< + 'w, + 's, + (&'static TabOf, &'static TabArt, &'static mut Visibility), + (Without, Without), + >, + labels: + Query<'w, 's, (&'static TabOf, &'static mut Text, &'static mut TextColor), Without>, +} + +/// `RealmListUpdate`: the tab strip (`RealmList_UpdateTabs`), then every visible row, each frame, +/// since any realm's population moves every band. +#[allow(clippy::type_complexity)] +pub(super) fn refresh_list( realms: Res, strings: Option>, mut rows: Query< @@ -518,9 +681,11 @@ pub(super) fn refresh_rows( (&mut Node, &mut Visibility, Option<&mut ImageNode>), (With, Without), >, - mut ok: Query<&mut crate::glue::widgets::GlueDisabled, With>, + mut ok: Query<&mut GlueDisabled, With>, + mut tab_nodes: TabNodes, window: Query<&Window, With>, ) { + paint_tabs(&realms, &mut tab_nodes); let s = crate::glue::screen_scale(window.single().ok()); let visible = realms.rows(); let (mean, stddev) = realms.stats(); @@ -602,3 +767,202 @@ pub(super) fn refresh_rows( } } } + +/// `RealmList_UpdateTabs` and `GlueTemplates_UpdateTabs`: one category hides the strip; two to +/// eight show that many tabs, each labelled with its category's name. The front tab is +/// `Disable()`d on the `ActiveTab` art in its `DisabledFont` (`GlueFontHighlightSmall`, white); +/// the others stand on `InActiveTab` in gold, white with the highlight under the cursor. +fn paint_tabs(realms: &Realms, nodes: &mut TabNodes) { + let TabNodes { tabs, arts, labels } = nodes; + let names = realms.tabs(); + let shown = if names.len() >= 2 { names.len() } else { 0 }; + let front = realms.category(); + let mut hovered = None; + for (TabOf(ordinal), interaction, mut vis, mut disabled) in tabs.iter_mut() { + let want = if *ordinal <= shown { + Visibility::Inherited + } else { + Visibility::Hidden + }; + if *vis != want { + *vis = want; + } + let is_front = *ordinal == front; + if disabled.0 != is_front { + disabled.0 = is_front; + } + if !is_front && *interaction != Interaction::None { + hovered = Some(*ordinal); + } + } + for (TabOf(ordinal), art, mut vis) in arts.iter_mut() { + let want = if art.front == (*ordinal == front) { + Visibility::Inherited + } else { + Visibility::Hidden + }; + if *vis != want { + *vis = want; + } + } + for (TabOf(ordinal), mut text, mut color) in labels.iter_mut() { + let Some(name) = names.get(*ordinal - 1) else { + continue; + }; + if text.0 != *name { + text.0 = name.to_string(); + } + let c = if *ordinal == front || hovered == Some(*ordinal) { + load::HIGHLIGHT + } else { + GOLD + }; + if color.0 != c { + color.0 = c; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glue::{glue_clicks, GlueClicks}; + use benilla_protocol::RealmInfo; + + fn realm(name: &str, category: u8) -> RealmInfo { + RealmInfo { + name: name.into(), + address: "127.0.0.1:8085".into(), + population: 1.0, + characters: 0, + realm_type: 0, + flags: 0, + category, + id: 0, + } + } + + /// The tab strip as [`spawn_tabs`] builds it, both art sets stood in, under the US client's + /// categories, with the click and refresh passes the plugin runs. + fn tab_app(list: &[(&str, u8)]) -> App { + let mut app = App::new(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.init_resource::