diff --git a/assets/graphics/cursor/cursor_default.png b/assets/graphics/cursor/cursor_default.png new file mode 100644 index 000000000..5f7840557 Binary files /dev/null and b/assets/graphics/cursor/cursor_default.png differ diff --git a/assets/graphics/cursor/cursor_hover.png b/assets/graphics/cursor/cursor_hover.png new file mode 100644 index 000000000..ef363f5a3 Binary files /dev/null and b/assets/graphics/cursor/cursor_hover.png differ diff --git a/assets/graphics/cursor/source/ATTRIBUTION.txt b/assets/graphics/cursor/source/ATTRIBUTION.txt new file mode 100644 index 000000000..2606757c5 --- /dev/null +++ b/assets/graphics/cursor/source/ATTRIBUTION.txt @@ -0,0 +1,12 @@ +DeadSync asset attributions +=========================== + +cursor.png +---------- +Original artwork: "Cursor icons created by zky.icon - Flaticon" +Source: https://www.flaticon.com/free-icons/cursor + +The PNG in this directory is a downsampled and recoloured derivative used +as the in-game mouse cursor. See `scripts/gen_cursors.py` for the +pipeline that produces `assets/graphics/cursor/cursor_default.png` and +`assets/graphics/cursor/cursor_hover.png` from this source. diff --git a/assets/graphics/cursor/source/cursor.png b/assets/graphics/cursor/source/cursor.png new file mode 100644 index 000000000..96e966c07 Binary files /dev/null and b/assets/graphics/cursor/source/cursor.png differ diff --git a/crates/deadsync-core/src/input.rs b/crates/deadsync-core/src/input.rs index 8fab49195..10d0718d7 100644 --- a/crates/deadsync-core/src/input.rs +++ b/crates/deadsync-core/src/input.rs @@ -25,6 +25,8 @@ impl Lane { pub enum InputSource { Keyboard, Gamepad, + /// Synthesized from a window-level mouse interaction. + Mouse, } #[cfg(test)] diff --git a/scripts/gen_cursors.py b/scripts/gen_cursors.py new file mode 100644 index 000000000..4c643736a --- /dev/null +++ b/scripts/gen_cursors.py @@ -0,0 +1,95 @@ +"""Build the DeadSync mouse cursor PNGs from the source reference at +`assets/graphics/cursor/source/cursor.png`. + +* `cursor_default.png` — the source silhouette with a 1-pixel white halo + so the (dark) cursor stays visible on dark backgrounds. +* `cursor_hover.png` — the same silhouette recoloured to the default + theme's decorative magenta (`#C1006F`) with a darker 1-pixel outline. + +Both outputs are 48x48 RGBA. Hotspot is auto-detected at the upper-left +opaque pixel of the downsampled image — the visible tip — and printed at +the end so the value in `src/app/graphics.rs` can be kept in sync. + +Run this whenever you tweak the source file or the magenta colour; the +script also drops the freshly-rendered PNGs into +`target//assets/graphics/cursor/` so a running game picks them +up without a cargo rebuild. +""" + +import os +import shutil +from PIL import Image, ImageFilter + +SRC = "assets/graphics/cursor/source/cursor.png" +# Output dimensions of the cursor sprite. 43 = roughly 90% of the +# original 48-pixel size; bump down for a smaller cursor, up for larger. +SIZE = 43 +OUT_DIR = "assets/graphics/cursor" + +MAGENTA_FILL = (0xC1, 0x00, 0x6F) +MAGENTA_OUTLINE = (0x55, 0x00, 0x30) + + +def add_halo(img, halo_rgb, thickness=1, alpha=255): + src_alpha = img.split()[-1] + dilated = src_alpha.filter(ImageFilter.MaxFilter(2 * thickness + 1)) + if alpha < 255: + dilated = dilated.point(lambda v: int(v * alpha / 255)) + halo_layer = Image.new("RGBA", img.size, (*halo_rgb, 0)) + halo_layer.putalpha(dilated) + return Image.alpha_composite(halo_layer, img) + + +def downsample_reference(): + src = Image.open(SRC).convert("RGBA") + return src.resize((SIZE, SIZE), Image.LANCZOS) + + +def recolour(img, rgb): + r, g, b, a = img.split() + r = Image.new("L", img.size, rgb[0]) + g = Image.new("L", img.size, rgb[1]) + b = Image.new("L", img.size, rgb[2]) + return Image.merge("RGBA", (r, g, b, a)) + + +def find_hotspot(img): + """Return the upper-left-most opaque pixel coordinates as the visible tip.""" + alpha = img.split()[-1] + px = alpha.load() + for y in range(img.size[1]): + for x in range(img.size[0]): + if px[x, y] > 128: + return (x, y) + return (0, 0) + + +def main(): + if not os.path.isfile(SRC): + raise SystemExit(f"missing source cursor: {SRC}") + os.makedirs(OUT_DIR, exist_ok=True) + shape = downsample_reference() + + default = add_halo(shape, (255, 255, 255), thickness=1) + default.save(f"{OUT_DIR}/cursor_default.png") + + hover = recolour(shape, MAGENTA_FILL) + hover = add_halo(hover, MAGENTA_OUTLINE, thickness=1) + hover.save(f"{OUT_DIR}/cursor_hover.png") + + hotspot = find_hotspot(default) + print(f"wrote {OUT_DIR}/cursor_default.png") + print(f"wrote {OUT_DIR}/cursor_hover.png") + print(f"hotspot: ({hotspot[0]}, {hotspot[1]}) (use this in src/app/graphics.rs)") + + for profile in ("debug", "release"): + t = f"target/{profile}/assets/graphics/cursor" + if not os.path.isdir(t): + continue + for name in ("cursor_default.png", "cursor_hover.png"): + shutil.copyfile(f"{OUT_DIR}/{name}", f"{t}/{name}") + print(f"synced -> {t}/{name}") + + +if __name__ == "__main__": + main() diff --git a/src/app/graphics.rs b/src/app/graphics.rs index bba6114f7..ba8238825 100644 --- a/src/app/graphics.rs +++ b/src/app/graphics.rs @@ -87,6 +87,29 @@ fn request_window_size(window: &Window, backend_type: BackendType, width: u32, h let _ = window.request_inner_size(size); } +fn load_custom_cursor_from_asset( + event_loop: &ActiveEventLoop, + rel_path: &str, + hotspot: (u16, u16), +) -> Option { + let dirs = dirs::app_dirs(); + let resolved = dirs.resolve_asset_path(rel_path); + let img = image::open(&resolved) + .map_err(|e| { + log::warn!("custom cursor load failed for {}: {}", resolved.display(), e); + }) + .ok()?; + let rgba = img.into_rgba8(); + let (width, height) = rgba.dimensions(); + let width = u16::try_from(width).ok()?; + let height = u16::try_from(height).ok()?; + let source = + winit::window::CustomCursor::from_rgba(rgba.into_raw(), width, height, hotspot.0, hotspot.1) + .map_err(|e| log::warn!("custom cursor build failed ({}): {}", rel_path, e)) + .ok()?; + Some(event_loop.create_custom_cursor(source)) +} + fn load_window_icon() -> Option { const WINDOW_ICON_PATHS: [&str; 2] = [ "assets/graphics/icon/icon-256.png", @@ -212,6 +235,26 @@ impl App { // Re-assert the opaque hint so compositors do not apply alpha-based blending. window.set_transparent(false); window.set_cursor_visible(!config::get().hide_mouse_cursor); + // Custom cursor theming: load default + hover variants once so we can + // swap them cheaply during pointer dispatch. The hotspot sits at + // the upper-left tip of the navigate shape so clicks register at + // the visual point. + self.cursor_default = load_custom_cursor_from_asset( + event_loop, + "assets/graphics/cursor/cursor_default.png", + (3, 2), + ); + self.cursor_hover = load_custom_cursor_from_asset( + event_loop, + "assets/graphics/cursor/cursor_hover.png", + (3, 2), + ); + self.cursor_is_hover = false; + if let Some(cursor) = self.cursor_default.clone() + && !config::get().hide_mouse_cursor + { + window.set_cursor(cursor); + } let high_dpi = config::get().high_dpi; let sz = window_render_size(&window, self.backend_type); self.state.shell.metrics = space::metrics_for_window(sz.width, sz.height); diff --git a/src/app/mod.rs b/src/app/mod.rs index 3370b37b1..48c113368 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1490,6 +1490,14 @@ pub struct ShellState { screenshot_request_side: Option, screenshot_flash_started_at: Option, screenshot_preview: Option, + /// Last known pointer position in logical (top-left origin) coords. + /// `None` when the cursor is outside the window, the surface is + /// inactive, or we haven't received a `CursorMoved` yet. + pointer_pos_logical: Option, + /// Last known raw pointer position in window pixels. Cached so we can + /// re-derive logical coords after a resize without waiting for the next + /// move. + pointer_pos_window: Option, } /// Hold-Tab fast-forward / hold-` slow-down multipliers, ITGmania parity. @@ -1671,6 +1679,8 @@ impl ShellState { screenshot_request_side: None, screenshot_flash_started_at: None, screenshot_preview: None, + pointer_pos_logical: None, + pointer_pos_window: None, } } @@ -3792,6 +3802,13 @@ pub struct App { state: AppState, software_renderer_threads: u8, gfx_debug_enabled: bool, + /// Lazily-created custom cursors. `None` if the asset PNGs were missing + /// or the platform refused to create them. + cursor_default: Option, + cursor_hover: Option, + /// Whether the cursor is currently the hover variant. Used to avoid + /// re-calling `set_cursor` on every mouse-move frame. + cursor_is_hover: bool, } impl App { @@ -3866,6 +3883,174 @@ impl App { } } + /// Re-project the cached raw pointer pixel position to logical coords. + /// Called whenever the window size or metrics change so that screens + /// querying `pointer_pos_logical` aren't using stale projections. + #[inline] + pub(super) fn refresh_pointer_logical_coords(&mut self) { + if let Some(window_pos) = self.state.shell.pointer_pos_window { + self.state.shell.pointer_pos_logical = window_pos.to_logical(); + } + } + + fn handle_pointer_moved(&mut self, event_loop: &ActiveEventLoop, px_x: f64, px_y: f64) { + let window_pos = space::WindowPos::new(px_x, px_y); + let logical = window_pos.to_logical(); + self.state.shell.pointer_pos_window = Some(window_pos); + self.state.shell.pointer_pos_logical = logical; + if !config::get().enable_mouse_input { + return; + } + let ev = input::PointerEvent { + pos: logical, + kind: input::PointerKind::Move, + timestamp: Instant::now(), + }; + self.dispatch_pointer_event(event_loop, &ev); + } + + fn handle_pointer_left(&mut self, event_loop: &ActiveEventLoop) { + self.state.shell.pointer_pos_window = None; + self.state.shell.pointer_pos_logical = None; + if !config::get().enable_mouse_input { + return; + } + let ev = input::PointerEvent { + pos: None, + kind: input::PointerKind::Leave, + timestamp: Instant::now(), + }; + self.dispatch_pointer_event(event_loop, &ev); + } + + fn handle_pointer_button( + &mut self, + event_loop: &ActiveEventLoop, + state: winit::event::ElementState, + button: winit::event::MouseButton, + ) { + if !config::get().enable_mouse_input { + return; + } + let mb = match button { + winit::event::MouseButton::Left => input::MouseButton::Left, + winit::event::MouseButton::Right => input::MouseButton::Right, + winit::event::MouseButton::Middle => input::MouseButton::Middle, + winit::event::MouseButton::Other(code) => input::MouseButton::Other(code), + // Back/Forward are not currently mapped to anything; treat as Other. + winit::event::MouseButton::Back => input::MouseButton::Other(u16::MAX - 1), + winit::event::MouseButton::Forward => input::MouseButton::Other(u16::MAX), + }; + let kind = match state { + winit::event::ElementState::Pressed => input::PointerKind::Down(mb), + winit::event::ElementState::Released => input::PointerKind::Up(mb), + }; + let ev = input::PointerEvent { + pos: self.state.shell.pointer_pos_logical, + kind, + timestamp: Instant::now(), + }; + self.dispatch_pointer_event(event_loop, &ev); + } + + fn handle_pointer_wheel( + &mut self, + event_loop: &ActiveEventLoop, + delta: winit::event::MouseScrollDelta, + ) { + if !config::get().enable_mouse_input { + return; + } + // Normalize line + pixel delta sources to "logical lines". One pixel + // line on most platforms is ~20 device pixels; this keeps wheel + // behaviour consistent across touchpads and discrete wheels. + const PIXEL_PER_LINE: f32 = 20.0; + let (dx, dy) = match delta { + winit::event::MouseScrollDelta::LineDelta(dx, dy) => (dx, dy), + winit::event::MouseScrollDelta::PixelDelta(p) => ( + (p.x as f32) / PIXEL_PER_LINE, + (p.y as f32) / PIXEL_PER_LINE, + ), + }; + let ev = input::PointerEvent { + pos: self.state.shell.pointer_pos_logical, + kind: input::PointerKind::Wheel { dx, dy }, + timestamp: Instant::now(), + }; + self.dispatch_pointer_event(event_loop, &ev); + } + + #[inline] + fn dispatch_pointer_event( + &mut self, + event_loop: &ActiveEventLoop, + ev: &input::PointerEvent, + ) { + trace!( + "pointer: kind={:?} pos={:?} screen={:?}", + ev.kind, ev.pos, self.state.screens.current_screen + ); + + // Route the event to the active screen. The match arm produces + // both the action to dispatch *and* the cursor-hover state in a + // single step, so the cursor logic can't drift from the screen's + // own notion of "is the pointer over something interactive". + let (action, hovers_interactive) = match self.state.screens.current_screen { + CurrentScreen::Menu => { + let s = &mut self.state.screens.menu_state; + let action = crate::screens::menu::handle_pointer(s, ev); + let hovers = crate::screens::menu::pointer_hovers_interactive(s); + (action, hovers) + } + _ => (ScreenAction::None, false), + }; + + // Apply the cursor change first so a click that navigates away + // still leaves the cursor in a consistent state. + self.apply_cursor_for_pointer(ev, hovers_interactive); + + if matches!(action, ScreenAction::None) { + return; + } + if let Err(e) = self.handle_action(action, event_loop) { + error!("pointer dispatch failed: {e}"); + } + } + + /// Decide which cursor variant to show based on this pointer event and + /// the hover-interactive flag reported by the active screen. Honours + /// the `hide_mouse_cursor` preference. + fn apply_cursor_for_pointer(&mut self, ev: &input::PointerEvent, hovers_interactive: bool) { + if config::get().hide_mouse_cursor { + return; + } + // A Leave event always restores the default cursor, even if the + // screen reported a stale hover state. + let hovered = !matches!(ev.kind, input::PointerKind::Leave) && hovers_interactive; + self.apply_cursor_hover(hovered); + } + + /// Apply the cached default/hover cursor to the window if the variant + /// changed since the last call. Cheap no-op when both cursors are absent + /// (e.g. asset load failed) or when the state is unchanged. + fn apply_cursor_hover(&mut self, hovered: bool) { + if hovered == self.cursor_is_hover { + return; + } + let Some(window) = self.window.as_ref() else { + return; + }; + let cursor = if hovered { + self.cursor_hover.clone() + } else { + self.cursor_default.clone() + }; + if let Some(cursor) = cursor { + window.set_cursor(cursor); + self.cursor_is_hover = hovered; + } + } + #[inline(always)] fn sync_input_fsr_view(&mut self) { let pending = input_screen::take_fsr_command(&mut self.state.screens.input_state) @@ -4586,6 +4771,9 @@ impl App { state, software_renderer_threads, gfx_debug_enabled, + cursor_default: None, + cursor_hover: None, + cursor_is_hover: false, } } @@ -4974,6 +5162,18 @@ impl App { ScreenAction::UpdateMouseCursorHidden(hidden) => { if let Some(window) = &self.window { window.set_cursor_visible(!hidden); + // Re-assert the themed cursor when re-showing it; the + // OS resets to its default while invisible. + if !hidden { + let cursor = if self.cursor_is_hover { + self.cursor_hover.clone() + } else { + self.cursor_default.clone() + }; + if let Some(cursor) = cursor { + window.set_cursor(cursor); + } + } } config::update_hide_mouse_cursor(hidden); options::sync_hide_mouse_cursor(&mut self.state.screens.options_state, hidden); @@ -9291,6 +9491,7 @@ impl ApplicationHandler for App { ); } self.sync_window_size(new_size); + self.refresh_pointer_logical_coords(); if surface_changed && self.state.shell.surface_active { self.request_redraw(&window, "surface_active"); } @@ -9328,6 +9529,18 @@ impl ApplicationHandler for App { #[cfg(any(target_os = "linux", target_os = "freebsd"))] self.handle_unix_window_keyboard_fallback(event_loop, &key_event); } + WindowEvent::CursorMoved { position, .. } => { + self.handle_pointer_moved(event_loop, position.x, position.y); + } + WindowEvent::CursorLeft { .. } => { + self.handle_pointer_left(event_loop); + } + WindowEvent::MouseInput { state, button, .. } => { + self.handle_pointer_button(event_loop, state, button); + } + WindowEvent::MouseWheel { delta, .. } => { + self.handle_pointer_wheel(event_loop, delta); + } WindowEvent::RedrawRequested => { let redraw_started = Instant::now(); let (request_to_redraw_us, redraw_request_reason) = diff --git a/src/config/load/options.rs b/src/config/load/options.rs index 352759d7b..18fc016ec 100644 --- a/src/config/load/options.rs +++ b/src/config/load/options.rs @@ -555,6 +555,10 @@ fn load_runtime_opts(conf: &SimpleIni, default: Config, cfg: &mut Config) { .get("Options", "OnlyDedicatedMenuButtons") .and_then(|v| v.parse::().ok()) .map_or(default.only_dedicated_menu_buttons, |v| v != 0); + cfg.enable_mouse_input = conf + .get("Options", "EnableMouseInput") + .and_then(|v| parse_loose_bool_str(&v)) + .unwrap_or(default.enable_mouse_input); cfg.theme_flag = conf .get("Options", "Theme") .and_then(|v| ThemeFlag::from_str(&v).ok()) diff --git a/src/config/mod.rs b/src/config/mod.rs index 56d97fc31..1fd338b3c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -324,6 +324,9 @@ pub struct Config { /// When true, gameplay arrow buttons (p*_up/down/left/right) are excluded from /// menu navigation. Only explicitly-bound menu buttons (p*_menu_*) work in menus. pub only_dedicated_menu_buttons: bool, + /// Master switch for mouse-driven menu navigation. Defaults off while the + /// feature is rolled out screen-by-screen — see plan.md for status. + pub enable_mouse_input: bool, } impl Default for Config { @@ -467,6 +470,7 @@ impl Default for Config { lights_simplify_bass: false, lights_com_port: SerialPortName::default(), only_dedicated_menu_buttons: false, + enable_mouse_input: false, } } } diff --git a/src/config/store/defaults.rs b/src/config/store/defaults.rs index 0fc563c28..80240bec7 100644 --- a/src/config/store/defaults.rs +++ b/src/config/store/defaults.rs @@ -311,6 +311,7 @@ fn push_default_options(content: &mut String, default: &Config) { "OnlyDedicatedMenuButtons", default.only_dedicated_menu_buttons, ); + push_bool(content, "EnableMouseInput", default.enable_mouse_input); push_line(content, "SongParsingThreads", default.song_parsing_threads); push_line( content, diff --git a/src/config/store/save.rs b/src/config/store/save.rs index 4fc6ba0c4..f730aa3f7 100644 --- a/src/config/store/save.rs +++ b/src/config/store/save.rs @@ -374,6 +374,7 @@ fn push_saved_options( "OnlyDedicatedMenuButtons", cfg.only_dedicated_menu_buttons, ); + push_bool(content, "EnableMouseInput", cfg.enable_mouse_input); push_line(content, "DisplayMonitor", cfg.display_monitor); push_line(content, "SongParsingThreads", cfg.song_parsing_threads); push_line( diff --git a/src/engine/input/mod.rs b/src/engine/input/mod.rs index 67aead67a..60a18d5e7 100644 --- a/src/engine/input/mod.rs +++ b/src/engine/input/mod.rs @@ -869,6 +869,43 @@ impl Keymap { // INI parsing and default emission moved to config.rs +/* ------------------------------ Pointer (mouse) events ------------------------------ */ + +/// Physical mouse button distinguished by the pointer event stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseButton { + Left, + Right, + Middle, + Other(u16), +} + +/// What happened on the pointer this tick. Position is in SM logical +/// (top-left origin) coordinates, or `None` when the cursor is outside the +/// window or its position is otherwise unknown. +#[derive(Clone, Copy, Debug)] +pub enum PointerKind { + /// The pointer moved (or first entered) the window. + Move, + /// A button transitioned to pressed. + Down(MouseButton), + /// A button transitioned to released. + Up(MouseButton), + /// The pointer left the window. + Leave, + /// A scroll-wheel notch in logical lines (positive = up / away from user). + Wheel { dx: f32, dy: f32 }, +} + +#[derive(Clone, Copy, Debug)] +pub struct PointerEvent { + /// Logical pointer position. `None` for `Leave`, or for `Move`/`Wheel` + /// events before the window has a valid surface size. + pub pos: Option, + pub kind: PointerKind, + pub timestamp: Instant, +} + #[inline(always)] fn collect_key_binding_from_compiled( km: &CompiledKeymap, diff --git a/src/engine/space.rs b/src/engine/space.rs index cf6235590..b36b62e1c 100644 --- a/src/engine/space.rs +++ b/src/engine/space.rs @@ -141,6 +141,66 @@ pub fn ortho_for_window(width: u32, height: u32) -> Matrix4 { Matrix4::orthographic_rh_gl(m.left, m.right, m.bottom, m.top, -1.0, 1.0) } +// ----------------------------------------------------------------------------- +// Position types: window-pixel vs. logical (top-left origin) space +// ----------------------------------------------------------------------------- + +/// A position in raw window pixels, matching winit's `PhysicalPosition`. +/// Use this for anything coming directly off the window or a mouse device. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WindowPos { + pub x: f64, + pub y: f64, +} + +impl WindowPos { + #[inline] + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + /// Project this window-pixel position into the logical SM-style coordinate + /// space used by screen layout code. Returns `None` when the cached + /// window size is degenerate (zero in any dimension) — that happens + /// briefly during init or minimize. + /// + /// The ortho projection (`ortho_for_window`) stretches the clamped + /// logical space to fill the entire window, so conversion is a simple + /// linear scale. + #[inline] + pub fn to_logical(self) -> Option { + let (px_w, px_h) = current_window_px(); + if px_w == 0 || px_h == 0 { + return None; + } + let m = CURRENT_METRICS.with(std::cell::Cell::get); + let sm_w = m.right - m.left; + let sm_h = m.top - m.bottom; + let nx = (self.x as f32) / (px_w as f32); + let ny = (self.y as f32) / (px_h as f32); + Some(LogicalPos { + x: nx * sm_w, + y: ny * sm_h, + }) + } +} + +/// A position in the logical, top-left origin coordinate space that screen +/// layout code uses (range `0..screen_width()` × `0..screen_height()`). +/// This is the coordinate system `screen_center_x()` and friends produce. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LogicalPos { + pub x: f32, + pub y: f32, +} + +impl LogicalPos { + #[inline] + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} + // ----------------------------------------------------------------------------- // Aspect helpers // ----------------------------------------------------------------------------- diff --git a/src/game/scores.rs b/src/game/scores.rs index a2c5b3f3e..b12517d92 100644 --- a/src/game/scores.rs +++ b/src/game/scores.rs @@ -1874,6 +1874,9 @@ fn replay_edges_for_player(gs: &gameplay::State, player: usize) -> Vec 0, InputSource::Gamepad => 1, + // Mouse never produces gameplay lane edges (notes are key/pad/gamepad). + // Encode as 0 so replays don't fail if a stray edge ever leaks in. + InputSource::Mouse => 0, }; out.push(LocalReplayEdgeV1 { event_music_time_ns: e.event_music_time_ns, diff --git a/src/screens/components/menu/menu_list.rs b/src/screens/components/menu/menu_list.rs index 2c0941074..70e90c3c3 100644 --- a/src/screens/components/menu/menu_list.rs +++ b/src/screens/components/menu/menu_list.rs @@ -1,6 +1,7 @@ use crate::act; use crate::engine::present::actors::Actor; use crate::engine::space::screen_center_x; +use crate::screens::components::shared::hitbox::HitRect; use std::sync::Arc; // --- CONSTANTS TO MATCH THE LUA SCRIPT'S STATIC STATE --- @@ -59,3 +60,67 @@ pub fn build_vertical_menu(p: MenuParams) -> Vec { } out } + +/// Compute mouse hit rectangles for the same rows `build_vertical_menu` +/// produces. Each rect is centred on `(screen_center_x(), start_center_y + +/// i*row_spacing)` with width `hit_width` and height `row_spacing`. Rect +/// `id` is the row index, so callers can recover it via `hit_test`. +/// +/// Kept here (next to the actor builder) so layout and hit math share a +/// source of truth — drift between the two would show up as clicks +/// missing visible rows. +pub fn item_rects( + count: usize, + start_center_y: f32, + row_spacing: f32, + hit_width: f32, +) -> Vec { + let mut rects = Vec::with_capacity(count); + let center_x = screen_center_x(); + for i in 0..count { + let center_y = (i as f32).mul_add(row_spacing, start_center_y); + rects.push(HitRect::from_center( + center_x, + center_y, + hit_width, + row_spacing, + i as u32, + )); + } + rects +} + +#[cfg(test)] +mod tests { + use super::item_rects; + use crate::engine::space::{LogicalPos, ortho_for_window}; + use crate::screens::components::shared::hitbox::hit_test; + + #[test] + fn item_rects_centered_on_screen_and_stacked_by_row_spacing() { + ortho_for_window(854, 480); + let rects = item_rects(3, 100.0, 28.0, 200.0); + assert_eq!(rects.len(), 3); + let cx = 0.5 * 854.0; + for (i, r) in rects.iter().enumerate() { + let expected_cy = (i as f32).mul_add(28.0, 100.0); + assert!((0.5 * (r.min.x + r.max.x) - cx).abs() < 1e-3); + assert!((0.5 * (r.min.y + r.max.y) - expected_cy).abs() < 1e-3); + assert!((r.max.x - r.min.x - 200.0).abs() < 1e-3); + assert!((r.max.y - r.min.y - 28.0).abs() < 1e-3); + assert_eq!(r.id, i as u32); + } + } + + #[test] + fn item_rects_hit_test_picks_correct_row() { + ortho_for_window(854, 480); + let rects = item_rects(3, 100.0, 28.0, 200.0); + let cx = 0.5 * 854.0; + for i in 0..3 { + let cy = (i as f32).mul_add(28.0, 100.0); + assert_eq!(hit_test(&rects, LogicalPos::new(cx, cy)), Some(i as u32)); + } + assert_eq!(hit_test(&rects, LogicalPos::new(0.0, 0.0)), None); + } +} diff --git a/src/screens/components/shared/hitbox.rs b/src/screens/components/shared/hitbox.rs new file mode 100644 index 000000000..d41fb01ef --- /dev/null +++ b/src/screens/components/shared/hitbox.rs @@ -0,0 +1,108 @@ +//! Lightweight axis-aligned hit-testing for mouse-driven UI. +//! +//! Each screen that opts in to mouse input rebuilds a small `Vec` +//! each frame (alongside its actor list) describing where its interactive +//! regions are in logical (top-left origin) coordinates. The pointer +//! plumbing in `app/mod.rs` queries `hit_test` to map pointer positions to +//! a screen-local id. +//! +//! Rectangles are simple AABBs. If a screen needs rotated or non-rectangular +//! buttons later it can layer that on top, but every interactive element in +//! the current codebase is rectangular. + +use crate::engine::space::LogicalPos; + +/// A screen-local interactive region. `id` is opaque to this module — screens +/// pick whatever discriminator is convenient (often a menu index or an enum +/// cast to u32). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HitRect { + pub min: LogicalPos, + pub max: LogicalPos, + pub id: u32, +} + +impl HitRect { + /// Build a `HitRect` from a center point and full width/height. Useful + /// for menu items laid out by `menu_list::build_vertical_menu`, where + /// each row is described by its center. + #[inline] + pub fn from_center(cx: f32, cy: f32, w: f32, h: f32, id: u32) -> Self { + let hw = 0.5 * w; + let hh = 0.5 * h; + Self { + min: LogicalPos::new(cx - hw, cy - hh), + max: LogicalPos::new(cx + hw, cy + hh), + id, + } + } + + #[inline] + pub fn contains(&self, p: LogicalPos) -> bool { + p.x >= self.min.x && p.x <= self.max.x && p.y >= self.min.y && p.y <= self.max.y + } +} + +/// Find the topmost (last-pushed) `HitRect` that contains `point` and return +/// its `id`. Iteration is reversed so later entries — which by convention +/// are drawn last and so appear "on top" — win ties. +#[inline] +pub fn hit_test(rects: &[HitRect], point: LogicalPos) -> Option { + for r in rects.iter().rev() { + if r.contains(point) { + return Some(r.id); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{HitRect, hit_test}; + use crate::engine::space::LogicalPos; + + #[test] + fn contains_includes_borders() { + let r = HitRect { + min: LogicalPos::new(10.0, 20.0), + max: LogicalPos::new(30.0, 40.0), + id: 0, + }; + assert!(r.contains(LogicalPos::new(10.0, 20.0))); + assert!(r.contains(LogicalPos::new(30.0, 40.0))); + assert!(r.contains(LogicalPos::new(20.0, 30.0))); + assert!(!r.contains(LogicalPos::new(9.9, 30.0))); + assert!(!r.contains(LogicalPos::new(30.1, 30.0))); + assert!(!r.contains(LogicalPos::new(20.0, 19.9))); + assert!(!r.contains(LogicalPos::new(20.0, 40.1))); + } + + #[test] + fn from_center_round_trips() { + let r = HitRect::from_center(100.0, 200.0, 50.0, 20.0, 7); + assert_eq!(r.min, LogicalPos::new(75.0, 190.0)); + assert_eq!(r.max, LogicalPos::new(125.0, 210.0)); + assert!(r.contains(LogicalPos::new(100.0, 200.0))); + assert!(!r.contains(LogicalPos::new(74.9, 200.0))); + } + + #[test] + fn hit_test_picks_topmost_overlap() { + let rects = vec![ + HitRect::from_center(50.0, 50.0, 40.0, 40.0, 1), + HitRect::from_center(55.0, 55.0, 40.0, 40.0, 2), + ]; + // Inside only the first rect. + assert_eq!(hit_test(&rects, LogicalPos::new(32.0, 32.0)), Some(1)); + // Inside both — topmost (id=2) wins. + assert_eq!(hit_test(&rects, LogicalPos::new(50.0, 50.0)), Some(2)); + // Outside both. + assert_eq!(hit_test(&rects, LogicalPos::new(100.0, 100.0)), None); + } + + #[test] + fn hit_test_empty_is_none() { + assert_eq!(hit_test(&[], LogicalPos::new(0.0, 0.0)), None); + } +} + diff --git a/src/screens/components/shared/menu_pointer.rs b/src/screens/components/shared/menu_pointer.rs new file mode 100644 index 000000000..65edc49b9 --- /dev/null +++ b/src/screens/components/shared/menu_pointer.rs @@ -0,0 +1,128 @@ +//! Reusable hover/click state for vertical list menus. +//! +//! Screens that present a simple list (main menu, select mode, select +//! color, etc.) all need the same three behaviours when mouse input is +//! enabled: +//! +//! * pointer Move sets the highlighted row to whichever item the cursor +//! is over (silently — no change sfx on every mouse pixel), +//! * pointer Leave clears the highlight, +//! * pointer Down(Left) returns the index of the clicked row so the +//! screen can launch its confirm action. +//! +//! `MenuPointer` is the bookkeeping shared by those screens. The screen +//! owns its `selected_index`, builds its `HitRect`s each frame via +//! `menu_list::item_rects`, and calls into `MenuPointer` from +//! `handle_pointer`. It also queries `hovered()` to decide whether the +//! mouse cursor should switch to its hover variant. + +use crate::engine::space::LogicalPos; +use crate::screens::components::shared::hitbox::{HitRect, hit_test}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct MenuPointer { + /// Index of the row currently under the pointer, or `None` if the + /// cursor is outside every row (or has left the window entirely). + hovered: Option, +} + +impl MenuPointer { + pub const fn new() -> Self { + Self { hovered: None } + } + + /// True if the pointer is currently over an interactive row. Used by + /// the cursor-swap logic in `app::dispatch_pointer_event`. + #[inline] + pub const fn hovers_interactive(&self) -> bool { + self.hovered.is_some() + } + + #[inline] + pub const fn hovered(&self) -> Option { + self.hovered + } + + /// Update the hovered row from a pointer Move event. Returns the new + /// index if the cursor is over one (so the caller can set its own + /// `selected_index`), or `None` if the cursor left the menu. + pub fn on_move(&mut self, pos: Option, rects: &[HitRect]) -> Option { + let new_hover = pos.and_then(|p| hit_test(rects, p)).map(|id| id as usize); + self.hovered = new_hover; + new_hover + } + + /// Clear the hover state, called from `PointerKind::Leave`. + #[inline] + pub fn on_leave(&mut self) { + self.hovered = None; + } + + /// Resolve a left-click into a row index. Returns the clicked row's + /// index, or `None` if the click missed every row. + pub fn on_click(&self, pos: Option, rects: &[HitRect]) -> Option { + pos.and_then(|p| hit_test(rects, p)).map(|id| id as usize) + } +} + +#[cfg(test)] +mod tests { + use super::MenuPointer; + use crate::engine::space::LogicalPos; + use crate::screens::components::shared::hitbox::HitRect; + + fn rects() -> Vec { + vec![ + HitRect::from_center(100.0, 100.0, 80.0, 20.0, 0), + HitRect::from_center(100.0, 130.0, 80.0, 20.0, 1), + HitRect::from_center(100.0, 160.0, 80.0, 20.0, 2), + ] + } + + #[test] + fn on_move_inside_a_row_returns_its_index() { + let mut mp = MenuPointer::new(); + let r = rects(); + assert_eq!(mp.on_move(Some(LogicalPos::new(100.0, 130.0)), &r), Some(1)); + assert!(mp.hovers_interactive()); + assert_eq!(mp.hovered(), Some(1)); + } + + #[test] + fn on_move_outside_returns_none_and_clears_hover() { + let mut mp = MenuPointer::new(); + let r = rects(); + // Hover row 0, then move off-row. + assert_eq!(mp.on_move(Some(LogicalPos::new(100.0, 100.0)), &r), Some(0)); + assert_eq!(mp.on_move(Some(LogicalPos::new(0.0, 0.0)), &r), None); + assert!(!mp.hovers_interactive()); + assert_eq!(mp.hovered(), None); + } + + #[test] + fn on_move_with_no_position_clears_hover() { + let mut mp = MenuPointer::new(); + let r = rects(); + assert_eq!(mp.on_move(Some(LogicalPos::new(100.0, 100.0)), &r), Some(0)); + assert_eq!(mp.on_move(None, &r), None); + assert!(!mp.hovers_interactive()); + } + + #[test] + fn on_leave_clears_hover() { + let mut mp = MenuPointer::new(); + let r = rects(); + mp.on_move(Some(LogicalPos::new(100.0, 100.0)), &r); + mp.on_leave(); + assert!(!mp.hovers_interactive()); + } + + #[test] + fn on_click_returns_clicked_row_index() { + let mp = MenuPointer::new(); + let r = rects(); + assert_eq!(mp.on_click(Some(LogicalPos::new(100.0, 160.0)), &r), Some(2)); + assert_eq!(mp.on_click(Some(LogicalPos::new(0.0, 0.0)), &r), None); + assert_eq!(mp.on_click(None, &r), None); + } +} diff --git a/src/screens/components/shared/mod.rs b/src/screens/components/shared/mod.rs index 0a6d09ed8..1701d9a37 100644 --- a/src/screens/components/shared/mod.rs +++ b/src/screens/components/shared/mod.rs @@ -1,8 +1,10 @@ pub mod banner; pub mod gamepad_overlay; pub mod gs_scorebox; +pub mod hitbox; pub mod loading_bar; pub mod lobby_hud; +pub mod menu_pointer; pub mod mode_pads; pub mod noteskin_model; pub mod pad_display; diff --git a/src/screens/menu.rs b/src/screens/menu.rs index 1f7cb12d6..66797221a 100644 --- a/src/screens/menu.rs +++ b/src/screens/menu.rs @@ -2,7 +2,7 @@ use crate::act; use crate::assets::i18n::{self, tr, tr_fmt}; use crate::assets::{FontRole, current_machine_font_key}; // Screen navigation is handled in app -use crate::engine::input::RawKeyboardEvent; +use crate::engine::input::{MouseButton, PointerEvent, PointerKind, RawKeyboardEvent}; use crate::engine::present::actors::{Actor, TextAlign}; use crate::engine::present::color; use crate::game::course::get_course_cache; @@ -11,6 +11,8 @@ use crate::game::song::get_song_cache; use crate::screens::components::menu::logo::{self, LogoParams}; use crate::screens::components::menu::menu_list::{self}; use crate::screens::components::menu::menu_splash; +use crate::screens::components::shared::hitbox::HitRect; +use crate::screens::components::shared::menu_pointer::MenuPointer; use crate::screens::components::shared::{screen_bar, transitions, visual_style_bg}; use crate::screens::input as screen_input; use crate::screens::{Screen, ScreenAction}; @@ -110,6 +112,7 @@ pub struct State { arrowcloud_text_cache: RefCell>>, menu_lr_chord: screen_input::MenuLrChordTracker, menu_lr_undo: [i8; 2], + pointer: MenuPointer, } pub fn init() -> State { @@ -125,6 +128,7 @@ pub fn init() -> State { arrowcloud_text_cache: RefCell::new(None), menu_lr_chord: screen_input::MenuLrChordTracker::default(), menu_lr_undo: [0; 2], + pointer: MenuPointer::new(), } } @@ -538,9 +542,14 @@ fn move_selection(state: &mut State, delta: isize) { #[inline(always)] fn start_selected(state: &mut State, started_by_p2: bool) -> ScreenAction { + start_index(state, state.selected_index, started_by_p2) +} + +#[inline(always)] +fn start_index(state: &mut State, index: usize, started_by_p2: bool) -> ScreenAction { crate::engine::audio::play_sfx("assets/sounds/start.ogg"); state.started_by_p2 = started_by_p2; - match state.selected_index { + match index { 0 => ScreenAction::Navigate(Screen::SelectProfile), 1 => ScreenAction::Navigate(Screen::Options), 2 => ScreenAction::Exit, @@ -548,6 +557,23 @@ fn start_selected(state: &mut State, started_by_p2: bool) -> ScreenAction { } } +/// Width of each menu-row hit target in logical units. Sized so the +/// hitbox hugs the menu text rather than spanning a quarter of the +/// screen — the labels ("Gameplay", "Options", "Exit") are short and +/// rendered at FOCUS_ZOOM × MENU_BASE_PX = 16 logical units tall, so a +/// 140-unit width gives generous click margin without bleeding into +/// neighbouring UI elements. +const MENU_HIT_WIDTH: f32 = 140.0; + +/// Build hit rects for the three main-menu rows. Layout mirrors what +/// `get_actors_with_alpha` passes into `menu_list::build_vertical_menu`, +/// so clicks always land on the visible label positions. +fn menu_item_rects() -> Vec { + let lp = LogoParams::default(); + let base_y = lp.top_margin + lp.target_h + MENU_BELOW_LOGO; + menu_list::item_rects(OPTION_COUNT, base_y, MENU_ROW_SPACING, MENU_HIT_WIDTH) +} + #[inline(always)] const fn menu_nav_delta(action: VirtualAction) -> Option { match action { @@ -621,9 +647,52 @@ pub fn handle_input(state: &mut State, ev: &InputEvent) -> ScreenAction { } } +/// True if the pointer is currently over an interactive menu row. +/// Consumed by `app::dispatch_pointer_event` to switch the cursor to its +/// hover variant. +#[inline] +pub fn pointer_hovers_interactive(state: &State) -> bool { + state.pointer.hovers_interactive() +} + +/// Pointer-driven navigation for the main menu. +/// +/// * Hover moves the highlight silently to the row under the cursor. +/// * Left-click on a row launches that row (same effect as `Start`). +/// * Right-click anywhere is a `back` (exit) consistent with keyboard. +pub fn handle_pointer(state: &mut State, ev: &PointerEvent) -> ScreenAction { + let rects = menu_item_rects(); + match ev.kind { + PointerKind::Move => { + if let Some(ix) = state.pointer.on_move(ev.pos, &rects) + && ix < OPTION_COUNT + { + state.selected_index = ix; + } + ScreenAction::None + } + PointerKind::Leave => { + state.pointer.on_leave(); + ScreenAction::None + } + PointerKind::Down(MouseButton::Left) => match state.pointer.on_click(ev.pos, &rects) { + Some(ix) => start_index(state, ix, false), + None => ScreenAction::None, + }, + PointerKind::Down(MouseButton::Right) => ScreenAction::Exit, + _ => ScreenAction::None, + } +} + #[cfg(test)] mod tests { - use super::menu_nav_delta; + use super::{ + MENU_BELOW_LOGO, MENU_HIT_WIDTH, MENU_ROW_SPACING, OPTION_COUNT, menu_item_rects, + menu_nav_delta, + }; + use crate::engine::space::LogicalPos; + use crate::screens::components::menu::logo::LogoParams; + use crate::screens::components::shared::hitbox::hit_test; use deadsync_input::VirtualAction; #[test] @@ -649,4 +718,38 @@ mod tests { assert_eq!(menu_nav_delta(VirtualAction::p2_down), Some(1)); assert_eq!(menu_nav_delta(VirtualAction::p2_menu_down), Some(1)); } + + #[test] + fn menu_item_rects_are_stacked_and_hit_test_per_row() { + // Pin the logical surface to a known 16:9 design size so + // screen_center_x() is stable across machines running the tests. + crate::engine::space::ortho_for_window(854, 480); + let rects = menu_item_rects(); + let lp = LogoParams::default(); + let base_y = lp.top_margin + lp.target_h + MENU_BELOW_LOGO; + let center_x = 0.5 * 854.0; + for (i, r) in rects.iter().enumerate() { + let expected_cy = (i as f32).mul_add(MENU_ROW_SPACING, base_y); + // Rect is centered on the row center. + assert!((0.5 * (r.min.x + r.max.x) - center_x).abs() < 1e-3); + assert!((0.5 * (r.min.y + r.max.y) - expected_cy).abs() < 1e-3); + assert!((r.max.x - r.min.x - MENU_HIT_WIDTH).abs() < 1e-3); + assert!((r.max.y - r.min.y - MENU_ROW_SPACING).abs() < 1e-3); + assert_eq!(r.id, i as u32); + } + + // Centerpoint of each row hits its own id, and falls into a single + // unique row. + for i in 0..OPTION_COUNT { + let center_y = (i as f32).mul_add(MENU_ROW_SPACING, base_y); + assert_eq!( + hit_test(&rects, LogicalPos::new(center_x, center_y)), + Some(i as u32) + ); + } + + // Far outside the menu region returns no hit. + assert_eq!(hit_test(&rects, LogicalPos::new(0.0, 0.0)), None); + assert_eq!(hit_test(&rects, LogicalPos::new(center_x, base_y - 50.0)), None); + } }