From d48b592ddde908a6ccba643cc27d1411bbdebd95 Mon Sep 17 00:00:00 2001 From: martipops Date: Sat, 31 Oct 2026 23:14:48 -0400 Subject: [PATCH 0001/2557] Splitscreen: arrange players with a Setup map and Apply/Revert Combine draft arrangements and monitor-map projection with the usable Setup screen. Preserve current pause dialogs, HDR settings and keyboard navigation. Mouse dragging and controller pickup follow separately. Co-Authored-By: OpenAI Codex Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/lib.rs | 2 + .../horizon-client-render/src/options_ui.rs | 44 +- .../horizon-client-render/src/split_layout.rs | 512 +++++++++++++++- crates/horizon-client-render/src/split_map.rs | 166 +++++ crates/horizon-client-render/src/split_ui.rs | 578 ++++++++++++++++++ .../assets/horizon/lang/en_us.json | 4 +- crates/horizon-client/src/game/agent.rs | 11 +- crates/horizon-client/src/game/screens.rs | 18 +- crates/horizon-client/src/game/split.rs | 19 +- crates/horizon-client/src/game/split/hud.rs | 10 +- crates/horizon-client/src/game/split/setup.rs | 152 +++++ crates/horizon-client/src/lib.rs | 7 +- docs/extras.md | 7 +- tools/splitscreen_e2e.py | 92 +++ 14 files changed, 1559 insertions(+), 63 deletions(-) create mode 100644 crates/horizon-client-render/src/split_map.rs create mode 100644 crates/horizon-client-render/src/split_ui.rs create mode 100644 crates/horizon-client/src/game/split/setup.rs diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs index df02acc354..6316e8c447 100644 --- a/crates/horizon-client-render/src/lib.rs +++ b/crates/horizon-client-render/src/lib.rs @@ -83,7 +83,9 @@ pub mod sound_events; pub mod sound_preview; pub mod spans; pub mod split_layout; +pub mod split_map; pub mod split_profiles; +pub mod split_ui; mod sprite_cache; pub mod sprites; pub mod streaming; diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 7dbf11e94a..8c69e7c886 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -1263,7 +1263,7 @@ pub enum Page { Online, /// Splitscreen: the profile another player joins as (`split_profiles`). SplitProfiles, - /// Splitscreen: enable joining on this computer. + /// Splitscreen: the monitor map and draft window arrangement. SplitSetup, } #[derive(Clone, Debug, PartialEq)] @@ -1511,6 +1511,7 @@ pub struct OptionsUi { pub calibration: Option, /// Whether the window was fullscreen before calibration made it so (the game keeps it). pub calibration_fullscreen: Option, + pub split_setup: crate::split_ui::SplitUi, /// The AI Agent page's status lines (set by the game while it shows). pub agent_status: Vec, /// Keyboard focus: the interactive target currently focused. @@ -1527,8 +1528,6 @@ pub struct OptionsUi { /// The list's rows as last laid out: each row's top and bottom (GUI /// pixels), for the narration's row under the mouse. row_bands: Vec<[f32; 2]>, - /// Other local players are joined: leave before turning splitscreen off. - pub split_joined: bool, } impl Default for OptionsUi { fn default() -> Self { @@ -1567,6 +1566,7 @@ impl Default for OptionsUi { split_profiles: Default::default(), calibration: None, calibration_fullscreen: None, + split_setup: Default::default(), agent_status: Vec::new(), focus: None, can_change_value: false, @@ -1574,7 +1574,6 @@ impl Default for OptionsUi { framebuffer: [1280, 720], split_guest: false, row_bands: Vec::new(), - split_joined: false, } } } @@ -2088,6 +2087,9 @@ impl OptionsUi { ) { self.graphics.reset(); } + if page == Page::SplitSetup { + self.split_setup.reset(); + } if page == Page::SplitProfiles { self.split_profiles.reset(); } @@ -2220,14 +2222,7 @@ impl OptionsUi { fn list_rows(&self, s: &Settings) -> Vec { match self.page() { - Page::SplitSetup => vec![Row::Buttons( - if self.split_guest || self.split_joined { - inactive(self.extra_button(s, crate::extras::Item::Splitscreen)) - } else { - self.extra_button(s, crate::extras::Item::Splitscreen) - }, - None, - )], + Page::SplitSetup => Vec::new(), Page::Extras => { // Each section's switches two to a row, as vanilla's // OptionsList.addSmall lays out an options screen. @@ -3301,6 +3296,11 @@ impl OptionsUi { .draw(level, s, batch, atlas, cursor, self.in_world); return; } + if self.page() == Page::SplitSetup { + self.split_setup.on = s.extras.splitscreen; + self.split_setup.draw(batch, atlas, cursor, self.in_world); + return; + } if self.page() == Page::SplitProfiles { self.split_profiles .draw(batch, atlas, cursor, self.in_world, self.pad_style); @@ -3601,6 +3601,26 @@ impl OptionsUi { } return Vec::new(); } + if self.page() == Page::SplitSetup { + use crate::split_ui::Click; + if right { + return Vec::new(); + } + self.split_setup.on = s.extras.splitscreen; + return match self.split_setup.click((x, y), size, drag) { + Click::Nothing => Vec::new(), + Click::Pressed => vec![Event::ButtonClick], + Click::Switch => { + crate::extras::Item::Splitscreen.cycle(&mut s.extras, false); + vec![Event::ButtonClick, Event::Changed] + } + Click::Done => { + let mut events = vec![Event::ButtonClick]; + events.extend(self.back(s)); + events + } + }; + } if self.page() == Page::SplitProfiles { use crate::split_profiles::Click; if right || drag { diff --git a/crates/horizon-client-render/src/split_layout.rs b/crates/horizon-client-render/src/split_layout.rs index f1612ea329..6331d37cae 100644 --- a/crates/horizon-client-render/src/split_layout.rs +++ b/crates/horizon-client-render/src/split_layout.rs @@ -1,5 +1,6 @@ //! Splitscreen's layout, pure data tested without a window: where each player's view goes in -//! its window (`place`). +//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who +//! plays in each, before any of it is applied. use winit::dpi::PhysicalSize; /// Players at most: P1 and fifteen more. @@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [ &[4, 4, 4, 4], ]; -/// Where a window is. -#[derive(Clone, Debug, PartialEq)] -pub enum Placement { - /// A normal window: its corner on the desktop and its size in pixels (`None`: a new one, - /// where the system opens it). - Free(Option<([i32; 2], [u32; 2])>), - /// Borderless over the monitor of this name. - Fill(String), -} - -/// How far window `number` opens right and down of the centre, in logical pixels: Window 2 -/// centred, each next one a step further, as systems cascade new windows (back to the centre -/// after six), so new windows never open exactly on one another. -pub fn cascade(number: u8) -> f64 { - f64::from(number.saturating_sub(2) % 6) * 32.0 -} - -/// A window: its number (1 is the game's own) and where it is. -#[derive(Clone, Debug, PartialEq)] -pub struct Window { - pub number: u8, - pub placement: Placement, -} - /// `order` made a whole order of `n` players: those it names (once, if there are), then the /// others, P1 first. pub fn ordered(order: &[usize], n: usize) -> Vec { @@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] { } } +/// A screen as the system reports it: its name, where it is on the desktop and its size (both +/// physical pixels), and its scale. +#[derive(Clone, Debug, PartialEq)] +pub struct Monitor { + pub name: String, + pub at: [i32; 2], + pub size: [u32; 2], + pub scale: f64, +} + +impl Monitor { + /// Where it is on the desktop and its size, as windows are placed: macOS places them in + /// points, each monitor at its own scale; Windows and X11 in desktop pixels. + pub fn desktop(&self) -> [f64; 4] { + let k = if cfg!(target_os = "macos") { + self.scale + } else { + 1.0 + }; + [ + f64::from(self.at[0]) / k, + f64::from(self.at[1]) / k, + f64::from(self.size[0]) / k, + f64::from(self.size[1]) / k, + ] + } +} + +/// Where a window is. +#[derive(Clone, Debug, PartialEq)] +pub enum Placement { + /// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in + /// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big). + Free(Option<([i32; 2], [u32; 2])>), + /// Borderless over the monitor of this name. + Fill(String), +} + +/// A new free window's size, in logical pixels. +pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0]; + +/// How far window `number` opens right and down of the centre, in logical pixels: Window 2 +/// centred, each next one a step further, as systems cascade new windows (back to the centre +/// after six), so new windows never open exactly on one another. +pub fn cascade(number: u8) -> f64 { + f64::from(number.saturating_sub(2) % 6) * 32.0 +} + +/// A window: its number (1 is the game's own) and where it is. +#[derive(Clone, Debug, PartialEq)] +pub struct Window { + pub number: u8, + pub placement: Placement, +} + +/// Where a player can go: a window there is (by number), a new free one, or a new one filling a +/// monitor (by name). +#[derive(Clone, Debug, PartialEq)] +pub enum Spot { + Window(u8), + New, + Fill(String), +} + +/// Something to tell before Apply. +#[derive(Clone, Debug, PartialEq)] +pub enum Warning { + /// Nobody plays in this window: it won't open (Window 1 stays, blank). + Empty(u8), + /// This window fills a monitor that isn't there: its players play in Window 1. + Unplugged(u8, String), + /// This window fills the monitor Window 1 is on: its players play in Window 1. + OnWindow1(u8), +} + +/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1 +/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`). +#[derive(Clone, Debug, PartialEq)] +pub struct Draft { + pub windows: Vec, + pub players: Vec, + pub two: u8, + pub three: u8, + /// The players (indices) in the order their views fill a window shared with others: first + /// left or on top. Empty, or missing some: P1, P2... (`ordered`). + pub order: Vec, +} + +impl Draft { + /// Everyone in Window 1 (where it is now). + pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self { + Self { + windows: vec![Window { + number: 1, + placement: window1, + }], + players: vec![1; players], + two, + three, + order: Vec::new(), + } + } + + /// Players `i` and `j` trade places: their windows and their places in them. + pub fn swap(&mut self, i: usize, j: usize) { + let n = self.players.len(); + if i >= n || j >= n || i == j { + return; + } + self.players.swap(i, j); + let mut order = ordered(&self.order, n); + let (a, b) = ( + order.iter().position(|&p| p == i), + order.iter().position(|&p| p == j), + ); + if let (Some(a), Some(b)) = (a, b) { + order.swap(a, b); + } + self.order = order; + } + + /// Player `i` to place `k` among the others in its window (0: before them all; past the + /// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)". + pub fn put_at(&mut self, i: usize, k: usize) { + let n = self.players.len(); + if i >= n { + return; + } + let mine = self.players[i]; + let mut order: Vec = ordered(&self.order, n) + .into_iter() + .filter(|&p| p != i) + .collect(); + let others: Vec = order + .iter() + .copied() + .filter(|&p| self.players[p] == mine) + .collect(); + let at = match others.get(k) { + Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()), + None => others + .last() + .and_then(|j| order.iter().position(|p| p == j)) + .map_or(order.len(), |p| p + 1), + }; + order.insert(at, i); + self.order = order; + } + + /// The players but `i` in window `number`, in their order. + pub fn sharing(&self, i: usize, number: u8) -> Vec { + ordered(&self.order, self.players.len()) + .into_iter() + .filter(|&p| p != i && self.players[p] == number) + .collect() + } + + /// A new free window, numbered with the lowest number free; its number. + pub fn add_window(&mut self) -> u8 { + self.open(Placement::Free(None)) + } + + fn open(&mut self, placement: Placement) -> u8 { + let number = (2..=u8::MAX) + .find(|n| self.windows.iter().all(|w| w.number != *n)) + .unwrap_or(u8::MAX); + self.windows.push(Window { number, placement }); + number + } + + /// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the + /// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window + /// number the draft doesn't have is ignored. + pub fn place_player(&mut self, i: usize, spot: Spot) { + if i >= self.players.len() { + return; + } + let number = match spot { + Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n, + Spot::Window(_) => return, + Spot::New => self.add_window(), + Spot::Fill(name) => self.open(Placement::Fill(name)), + }; + self.players[i] = number; + } + + /// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of + /// them; while a draft is edited, the ones it added (the windows open now stay until Apply, + /// so their numbers hold still as players move). + pub fn drop_empty(&mut self, keep: &[u8]) { + let players = &self.players; + self.windows + .retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number)); + } + + /// Whether nobody plays in window `number`. + pub fn empty(&self, number: u8) -> bool { + !self.players.contains(&number) + } + + /// What to tell before Apply, in window order. + pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec { + let mut out = Vec::new(); + for w in &self.windows { + if let Placement::Fill(name) = &w.placement + && !monitors.iter().any(|m| &m.name == name) + { + out.push(Warning::Unplugged(w.number, name.clone())); + } else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) { + out.push(Warning::OnWindow1(w.number)); + } else if !self.players.contains(&w.number) { + out.push(Warning::Empty(w.number)); + } + } + out + } + + /// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its + /// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s + /// corner), the others over their monitor; a window over a missing monitor or Window 1's + /// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give + /// it its real rect (`Free(Some)`); else it is taken as a new window would open. + pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec> { + let home = monitors + .iter() + .find(|m| m.name == main) + .or(monitors.first()); + // Centred on Window 1's monitor, as the game opens a new window. + let new = |number: u8| { + let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| { + let at = m.desktop(); + (at, m.scale, at[2] / f64::from(m.size[0].max(1))) + }); + let size = NEW_WINDOW.map(|v| (v * scale).round() as u32); + let step = cascade(number) * scale * k; + let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step; + let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step; + ([x as i32, y as i32], size) + }; + self.windows + .iter() + .map(|w| { + let rect = match &w.placement { + Placement::Free(Some(rect)) => Some(*rect), + Placement::Free(None) => Some(new(w.number)), + Placement::Fill(name) if w.number != 1 && name == main => None, + Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| { + let at = m.desktop(); + ([at[0] as i32, at[1] as i32], m.size) + }), + }; + // Everyone with nowhere else plays in Window 1: it never has no size. + if w.number == 1 { + rect.or_else(|| Some(new(1))) + } else { + rect + } + }) + .collect() + } + + /// Every player's view as Apply would make it (`place`, as the game tiles them), with + /// `View`'s window an index into `windows`; players of a window over a missing monitor in + /// Window 1's. + pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec { + let rects = self.rects(monitors, main); + let index = |number: u8| self.windows.iter().position(|w| w.number == number); + let window1 = index(1).unwrap_or(0); + let windows: Vec = self + .players + .iter() + .map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1)) + .collect(); + let sizes: Vec> = rects + .iter() + .map(|r| { + r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| { + PhysicalSize::new(w, h) + }) + }) + .collect(); + place_ordered(&windows, &self.order, &sizes, self.two, self.three) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn window_1_always_has_a_size_and_unknown_windows_take_nobody() { + let monitors = [Monitor { + name: "Main".into(), + at: [0, 0], + size: [1920, 1080], + scale: 1.0, + }]; + let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0); + assert!(d.rects(&monitors, "Main")[0].is_some()); + assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1)); + d.place_player(1, Spot::Window(9)); + assert_eq!(d.players, [1, 1], "no Window 9 in the draft"); + } + fn size(w: u32, h: u32) -> PhysicalSize { PhysicalSize::new(w, h) } @@ -272,6 +549,97 @@ mod tests { ) ); } + let mut draft = here(16); + draft.place_player(15, Spot::New); + let views = draft.preview(&monitors(), "Built-in"); + assert_eq!(views.len(), 16); + assert_ne!(views[0].0, views[15].0); + } + + fn monitors() -> Vec { + vec![ + Monitor { + name: "Built-in".into(), + at: [0, 0], + size: [3024, 1964], + scale: 2.0, + }, + Monitor { + name: "TV".into(), + at: [3024, 0], + size: [3840, 2160], + scale: 1.0, + }, + ] + } + + /// P1 in Window 1, everyone else in Window 2 filling the TV. + trait DeskAndTv { + fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option; + } + impl DeskAndTv for Draft { + fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option { + let tv = monitors.iter().find(|m| m.name != main)?; + self.windows.push(Window { + number: 2, + placement: Placement::Fill(tv.name.clone()), + }); + for p in self.players.iter_mut().skip(1) { + *p = 2; + } + Some(self) + } + } + + fn here(players: usize) -> Draft { + Draft::everyone_here( + players, + Placement::Free(Some(([100, 100], [2000, 1200]))), + 0, + 0, + ) + } + + #[test] + fn presets() { + let m = monitors(); + // Everyone here: four quarters of Window 1. + let views = here(4).preview(&m, "Built-in"); + assert_eq!(views[3], (0, [1000, 600], size(1000, 600))); + // P1 alone, the others share the TV in rows. + let tv = here(4).desk_and_tv(&m, "Built-in").unwrap(); + assert_eq!(tv.players, [1, 2, 2, 2]); + assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into())); + let views = tv.preview(&m, "Built-in"); + assert_eq!(views[0], (0, [0, 0], size(2000, 1200))); + assert_eq!(views[1], (1, [0, 0], size(3840, 1080))); + } + + #[test] + fn new_windows_cascade_instead_of_stacking() { + let m = monitors(); + let mut d = here(3); + d.place_player(1, Spot::New); + d.place_player(2, Spot::New); + let rects = d.rects(&m, "Built-in"); + let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap()); + // Window 3 a step right and down of Window 2, on the same diagonal. + assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]); + assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0)); + } + + #[test] + fn put_at_a_place_among_a_window_s_players() { + let mut d = here(3); + d.put_at(2, 0); + assert_eq!(d.order, [2, 0, 1]); + d.put_at(2, 5); // past the last: after them + assert_eq!(d.order, [0, 1, 2]); + // Players in another window keep their places. + d.players = vec![1, 2, 1]; + d.put_at(2, 0); + assert_eq!(d.sharing(2, 1), [0]); + assert_eq!(d.order, [2, 0, 1]); } #[test] @@ -293,4 +661,98 @@ mod tests { let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0); assert_eq!(views[2].2, size(2000, 500)); } + + #[test] + fn two_players_swap_in_a_window_and_between_windows() { + let m = monitors(); + // In one window: P1 and P2 trade sides. + let mut d = here(2); + d.swap(0, 1); + assert_eq!(d.order, [1, 0]); + let views = d.preview(&m, "Built-in"); + assert!(views[1].1[0] < views[0].1[0], "P2 left of P1"); + // Between windows: P2 on the TV, P3 in Window 1; they trade windows. + let mut d = here(3); + d.place_player(1, Spot::Fill("TV".into())); + d.swap(1, 2); + assert_eq!(d.players, [1, 1, 2]); + } + + #[test] + fn players_placed() { + let m = monitors(); + let mut d = here(3); + d.place_player(1, Spot::Fill("TV".into())); + d.place_player(2, Spot::Window(2)); + assert_eq!(d.players, [1, 2, 2]); + d.place_player(2, Spot::New); + assert_eq!(d.players, [1, 2, 3]); + assert!(d.warnings(&m, "Built-in").is_empty()); + // The window left behind stays while the draft is edited (numbers hold still), and + // closes at Apply; Window 1 never. + d.place_player(1, Spot::Window(1)); + let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::>(); + assert_eq!(numbers(&d), [1, 2, 3]); + assert!(d.empty(2)); + d.drop_empty(&[]); + assert_eq!(numbers(&d), [1, 3]); + assert!(d.warnings(&m, "Built-in").is_empty()); + d.place_player(0, Spot::Window(3)); + d.place_player(1, Spot::Window(3)); + assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]); + } + + #[test] + fn windows_numbered_lowest_free() { + let mut d = here(3); + assert_eq!(d.add_window(), 2); + assert_eq!(d.add_window(), 3); + d.windows.retain(|w| w.number != 2); + assert_eq!(d.add_window(), 2); + // Nobody to place: no window opens for it. + d.place_player(9, Spot::New); + assert_eq!(d.windows.len(), 3); + } + + #[test] + fn a_window_over_window_1s_monitor() { + let m = monitors(); + let mut d = here(2); + d.place_player(1, Spot::Fill("Built-in".into())); + assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]); + // As the game does: P2 beside P1 in Window 1. + assert_eq!(d.preview(&m, "Built-in")[1].0, 0); + } + + #[test] + fn warnings_and_an_unplugged_monitor() { + let m = monitors(); + let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap(); + let three = d.add_window(); + assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]); + // The TV unplugged: its player plays in Window 1 beside P1. + assert_eq!( + d.warnings(&m[..1], "Built-in"), + [Warning::Unplugged(2, "TV".into()), Warning::Empty(three)] + ); + let views = d.preview(&m[..1], "Built-in"); + assert_eq!(views[1], (0, [1000, 0], size(1000, 1200))); + // P1 elsewhere: Window 1 is blank. + d.players = vec![2, 2]; + assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1)); + } + + #[test] + fn eight_players_two_windows() { + let m = monitors(); + let d = here(8).desk_and_tv(&m, "Built-in").unwrap(); + let views = d.preview(&m, "Built-in"); + // Seven on the TV: rows of 3, 2 and 2, the whole TV covered. + let area: u64 = views[1..] + .iter() + .map(|v| u64::from(v.2.width) * u64::from(v.2.height)) + .sum(); + assert_eq!(area, 3840 * 2160); + assert!(views[1..].iter().all(|v| v.0 == 1)); + } } diff --git a/crates/horizon-client-render/src/split_map.rs b/crates/horizon-client-render/src/split_map.rs new file mode 100644 index 0000000000..f958a0c233 --- /dev/null +++ b/crates/horizon-client-render/src/split_map.rs @@ -0,0 +1,166 @@ +//! Project a draft arrangement onto the Setup screen, without opening a window. +use crate::split_layout::{Draft, Monitor}; + +/// Map area: below the title and above the move row and footer. +pub const TOP: f32 = 30.0; +pub const BOTTOM: f32 = 80.0; +pub type Rect = [f32; 4]; +pub type Map = (Vec, Vec<(u8, Rect)>, Vec<(u8, Rect)>); + +/// Monitor, window and player rectangles in UI coordinates, in their drawing order. +pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map { + let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect(); + // A window's size on the desktop: its pixels at the scale of the monitor it is on. + let rects = draft.rects(monitors, main); + let on_desk: Vec<(u8, [f64; 4])> = draft + .windows + .iter() + .zip(&rects) + .filter_map(|(win, rect)| { + let ([x, y], [pw, ph]) = (*rect)?; + let (x, y) = (f64::from(x), f64::from(y)); + let scale = monitors + .iter() + .find(|m| { + let [mx, my, mw, mh] = m.desktop(); + x >= mx && x < mx + mw && y >= my && y < my + mh + }) + .or_else(|| monitors.iter().find(|m| m.name == main)) + .map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0])); + Some(( + win.number, + [x, y, f64::from(pw) * scale, f64::from(ph) * scale], + )) + }) + .collect(); + // The monitors, and windows past them (Wayland's, whose place is unknown) still shown. + let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d)); + let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min); + let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min); + let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max); + let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max); + if desk.is_empty() || x1 <= x0 || y1 <= y0 { + return Default::default(); + } + let (aw, ah) = ( + f64::from(w) - 40.0, + // Room under the monitors for their names, and always for the move row: the map + // doesn't jump when a player is picked (a drop back to where its tile was misses). + (f64::from(h - TOP - BOTTOM) - 18.0).max(10.0), + ); + let k = (aw / (x1 - x0)).min(ah / (y1 - y0)); + let (ox, oy) = ( + (f64::from(w) - (x1 - x0) * k) / 2.0, + f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0, + ); + let to_map = |[x, y, dw, dh]: [f64; 4]| { + [ + (ox + (x - x0) * k) as f32, + (oy + (y - y0) * k) as f32, + (dw * k) as f32, + (dh * k) as f32, + ] + }; + let mapped_monitors = desk.iter().copied().map(to_map).collect(); + let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect(); + // In their own order, emptied ones too: a window a player was just dragged out of stays + // where it was until Apply closes it, so the player can be dragged back into it. + let tiles = draft + .preview(monitors, main) + .iter() + .map(|&(i, [vx, vy], size)| { + let number = draft.windows.get(i).map_or(1, |w| w.number); + let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else { + return (number, [0.0; 4]); + }; + let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32); + ( + number, + [ + wx + vx as f32 / pw * ww, + wy + vy as f32 / ph * wh, + size.width as f32 / pw * ww, + size.height as f32 / ph * wh, + ], + ) + }) + .collect(); + (mapped_monitors, windows, tiles) +} + +/// The largest visible side of a tile after later windows cover it. +pub fn uncovered(mut r: [f32; 4], covers: impl Iterator) -> [f32; 4] { + for [cx, cy, cw, ch] in covers { + let [x, y, w, h] = r; + if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y { + continue; + } + let sides = [ + [x, y, cx - x, h], + [cx + cw, y, x + w - (cx + cw), h], + [x, y, w, cy - y], + [x, cy + ch, w, y + h - (cy + ch)], + ]; + r = sides + .into_iter() + .map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)]) + .max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3]))) + .unwrap_or([x, y, 0.0, 0.0]); + } + r +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::split_layout::{Placement, Spot}; + + #[test] + fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() { + let monitors = vec![ + Monitor { + name: "Desk".into(), + at: [0, 0], + size: [2000, 1200], + scale: 1.0, + }, + Monitor { + name: "TV".into(), + at: [-1920, 0], + size: [1920, 1080], + scale: 1.0, + }, + ]; + let mut draft = + Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0); + let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0); + assert_eq!(windows.len(), 1); + let (a, b) = (tiles[0].1, tiles[1].1); + assert!((a[2] - b[2]).abs() < 0.01); + assert!((a[0] + a[2] - b[0]).abs() < 0.01); + draft.place_player(1, Spot::Fill("TV".into())); + let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0); + assert!(screens[1][0] < screens[0][0]); + assert_eq!(windows.len(), 2); + assert_eq!((tiles[0].0, tiles[1].0), (1, 2)); + let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0); + assert_eq!(windows.len(), 1); + assert_eq!((tiles[0].0, tiles[1].0), (1, 1)); + assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty()); + } + + #[test] + fn covered_tiles_only_expose_a_visible_side_for_clicks() { + let tile = [0.0, 0.0, 100.0, 100.0]; + assert_eq!( + uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()), + [60.0, 0.0, 40.0, 100.0] + ); + let hidden = uncovered(tile, [tile].into_iter()); + assert_eq!(hidden[2] * hidden[3], 0.0); + assert_eq!( + uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()), + tile + ); + } +} diff --git a/crates/horizon-client-render/src/split_ui.rs b/crates/horizon-client-render/src/split_ui.rs new file mode 100644 index 0000000000..a3b19011bc --- /dev/null +++ b/crates/horizon-client-render/src/split_ui.rs @@ -0,0 +1,578 @@ +//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter. +use crate::{ + graphics_style::STYLE, + hud::{HudAtlas, HudBatch, rgba}, + split_layout::{Draft, Monitor, Placement, Spot, Warning, ordered}, + split_map::{self, BOTTOM, uncovered}, + text::Text, + ui, +}; + +#[derive(Default)] +pub struct SplitUi { + pub on: bool, + pub idle: bool, + pub live: Option, + pub monitors: Vec, + pub main: String, + pub names: Vec, + pub apply: Option, + edit: Option, + selected: Option, +} + +#[derive(Clone)] +enum Target { + Player(usize), + Move(Spot), + Revert, + Apply, + Done, + Switch, +} +type Button = ([f32; 4], String, Target, bool); +pub enum Click { + Nothing, + Pressed, + Done, + Switch, +} + +impl SplitUi { + pub fn reset(&mut self) { + self.edit = None; + self.selected = None; + self.apply = None; + } + fn draft(&self) -> Option<&Draft> { + self.edit.as_ref().or(self.live.as_ref()) + } + fn map(&self, w: f32, h: f32) -> split_map::Map { + if self.idle { + return Default::default(); + } + self.draft().map_or_else(Default::default, |draft| { + split_map::project(draft, &self.monitors, &self.main, w, h) + }) + } + fn editing(&mut self) -> Option<&mut Draft> { + if self.edit.is_none() { + self.edit = self.live.clone(); + } + self.edit.as_mut() + } + + fn monitor_name(&self, name: &str) -> String { + match self.monitors.iter().position(|m| m.name == name) { + Some(n) => format!("Monitor {}", n + 1), + None => "a monitor".to_owned(), + } + } + + /// Where the selected player can go: the other windows, a new one, a monitor no window fills. + fn spots(&self) -> Vec<(String, Spot)> { + let (Some(draft), Some(i)) = (self.draft(), self.selected) else { + return Vec::new(); + }; + let mine = draft.players.get(i).copied(); + let mut spots: Vec<(String, Spot)> = draft + .windows + .iter() + .filter(|w| Some(w.number) != mine) + .map(|w| (format!("Window {}", w.number), Spot::Window(w.number))) + .collect(); + spots.push(("New window".to_owned(), Spot::New)); + for m in &self.monitors { + let filled = draft + .windows + .iter() + .any(|w| w.placement == Placement::Fill(m.name.clone())); + if m.name != self.main && !filled { + spots.push(( + format!("Fill {}", self.monitor_name(&m.name)), + Spot::Fill(m.name.clone()), + )); + } + } + spots + } + + fn buttons(&self, w: f32, h: f32) -> Vec