//! Right-click radial menu for power-user quick-drops. //! //! Holding the right mouse button on a face-up draggable card pops up a //! small radial menu of icons, one per legal destination pile, arranged in //! a ring around the cursor. Releasing the button while the cursor is //! over an icon dispatches a [`MoveRequestEvent`] to that destination — //! the player skips the drag entirely. Releasing in empty space, or //! pressing `Esc`, cancels. //! //! # Relationship to [`crate::card_plugin::handle_right_click`] //! //! This plugin **augments** rather than replaces the legacy //! right-click-highlight tint. On the press frame `handle_right_click` //! still tints legal pile markers via [`RightClickHighlight`]; the radial //! overlay sits on top (Z = [`Z_RADIAL_MENU`]) and disappears with the //! release. The two paths read the same legal-destination set, so what //! the radial offers always matches what the highlights show. //! //! # State machine //! //! ```text //! ┌──────────────────┐ RMB press on face-up card //! │ Idle │ ──────────────────────────────────► Active //! └──────────────────┘ //! Esc OR RMB release outside any icon //! OR pause / state change //! ┌──────────────────┐ ◄──────────────────────────────────┐ //! │ Active │ │ //! │ source_pile │ RMB release while hovered_index │ //! │ count │ = Some(i) │ //! │ cards │ ─── fire MoveRequestEvent ─────────┘ //! │ destinations[] │ //! │ hovered_index │ //! └──────────────────┘ //! ``` //! //! # Tests //! //! Tests live alongside the implementation. The cursor-tracking and //! release-confirm systems take a [`RadialCursorOverride`] resource that //! lets tests inject a world-space cursor position without spinning up a //! real `PrimaryWindow` / camera, since `MinimalPlugins` provides //! neither. use bevy::input::touch::Touches; use bevy::input::ButtonInput; use bevy::math::Vec2; use bevy::prelude::*; use bevy::window::PrimaryWindow; use solitaire_core::card::Card; use solitaire_core::game_state::GameState; use solitaire_core::pile::PileType; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use crate::card_plugin::{TABLEAU_FACEDOWN_FAN_FRAC, TABLEAU_FAN_FRAC}; use crate::events::MoveRequestEvent; use crate::layout::{Layout, LayoutResource}; use crate::pause_plugin::PausedResource; use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS}; /// Seconds a finger must be held on a face-up card (without crossing the /// drag threshold) before the radial menu opens. Matches Android's long-press /// gesture recogniser default. const LONG_PRESS_SECS: f32 = 0.5; /// Sprite-space `Transform.z` for radial-menu overlay sprites. /// /// One rung above [`crate::ui_theme::Z_DROP_OVERLAY`] (`50.0`) so the radial icons render /// in front of any drop-target wash that might still be active from a /// concurrent drag, but well below the lifted card stack at `DRAG_Z`. pub const Z_RADIAL_MENU: f32 = 60.0; /// Pixel radius (world space) of the ring on which radial icons are /// placed, measured from the cursor centre. pub const RADIAL_RADIUS_PX: f32 = 80.0; /// Side length (world-space pixels) of each radial icon's hit-box. /// /// Sprites are rendered at this size; the cursor is considered "over" an /// icon when it lies within the axis-aligned square of this side length /// centred on the icon anchor. pub const RADIAL_ICON_SIZE_PX: f32 = 48.0; /// Scale factor applied to the focused (hovered) icon for emphasis. pub const RADIAL_HOVER_SCALE: f32 = 1.15; // --------------------------------------------------------------------------- // State resource // --------------------------------------------------------------------------- /// Right-click radial-menu state machine. /// /// `Idle` is the resting state. `Active` is entered when right-mouse is /// just-pressed on a face-up draggable card with at least one legal /// destination; it is exited on right-mouse release, on `Escape`, or on /// any external state change (game mutation, pause). #[derive(Resource, Debug, Default, Clone, PartialEq)] pub enum RightClickRadialState { /// Resting state — the radial is closed and no overlay sprites exist. #[default] Idle, /// Radial is open. The player is holding right-mouse on /// `source_pile` and the cursor is currently over icon /// `hovered_index` (or none). Active { /// Pile the right-clicked card came from. source_pile: PileType, /// Number of cards that would be moved (always `1` — only the /// top face-up card is ever offered for a quick-drop, since the /// radial is built around single-card foundation/tableau /// shortcuts and that matches the right-click highlight set). count: usize, /// Card ids that would be moved (bottom-to-top order). Length /// always equals `count`. Currently always one element. cards: Vec, /// Pre-computed `(destination, icon_anchor_world_pos)` pairs. /// /// Anchors are evenly spaced around a ring of radius /// [`RADIAL_RADIUS_PX`] centred on the press position. A single /// destination is placed directly above the cursor; multiple /// destinations span an arc. legal_destinations: Vec<(PileType, Vec2)>, /// Cursor position (world space) the radial was opened at — /// used as the centre of the ring for cursor-hover hit testing. centre: Vec2, /// Index into `legal_destinations` the cursor is currently /// hovering over, or `None` when the cursor is outside every /// icon's hit-box. hovered_index: Option, }, } impl RightClickRadialState { /// Returns `true` when the radial is currently open. pub fn is_active(&self) -> bool { matches!(self, Self::Active { .. }) } } /// Optional override resource for tests: when present and `Some`, every /// system that would normally read `Window::cursor_position()` reads this /// world-space coordinate instead. /// /// Tests insert this resource so the radial systems can run under /// `MinimalPlugins`, which has no `PrimaryWindow` and no `Camera`. /// Production builds never insert this resource. #[derive(Resource, Debug, Clone, Copy, Default)] pub struct RadialCursorOverride(pub Option); // --------------------------------------------------------------------------- // Visual marker components // --------------------------------------------------------------------------- /// Marker on a radial icon parent entity. Wraps the icon's index into /// [`RightClickRadialState::Active::legal_destinations`] so the /// hover-state system can find the right anchor / pile. #[derive(Component, Debug)] pub struct RadialIcon { /// Index into `RightClickRadialState::Active::legal_destinations`. pub index: usize, } /// Marker on the centre dot drawn at the cursor / source position. #[derive(Component, Debug)] pub struct RadialCentre; // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Registers [`RightClickRadialState`] and the systems that drive it. /// /// All systems run in the `Update` schedule. `RadialCursorOverride` is /// **not** registered by default — production never needs it; tests /// insert it manually. pub struct RadialMenuPlugin; impl Plugin for RadialMenuPlugin { fn build(&self, app: &mut App) { app.init_resource::() // Tests inject `RadialCursorOverride` themselves; production // never touches it. We do not `init_resource` here so the // cursor-from-window path is the default. .add_systems( Update, ( radial_open_on_right_click, radial_open_on_long_press, radial_track_cursor, radial_handle_release_or_cancel, radial_redraw_overlay, ) .chain(), ); } } // --------------------------------------------------------------------------- // Pure helpers (testable without a Bevy World) // --------------------------------------------------------------------------- /// Returns the world-space anchor for radial icon `index` of `count`, /// arranged on a ring of `radius` centred at `centre`. /// /// One destination places the icon directly above the cursor (12 o'clock). /// Multiple destinations spread evenly around a circle, with index 0 at /// 12 o'clock and remaining indices winding clockwise. pub fn radial_anchor_for_index(centre: Vec2, count: usize, index: usize, radius: f32) -> Vec2 { if count == 0 { return centre; } if count == 1 { // Single destination → straight above the cursor for maximum legibility. return centre + Vec2::new(0.0, radius); } // Spread evenly. Angle is measured from the +Y axis, clockwise, so // index 0 sits at 12 o'clock and increasing indices sweep right. let frac = (index as f32) / (count as f32); let angle = std::f32::consts::TAU * frac; Vec2::new(centre.x + radius * angle.sin(), centre.y + radius * angle.cos()) } /// Returns `(hit?, index)` — whether `cursor` falls within any icon's /// hit-box, and if so the index of the first match. Hit-boxes are /// axis-aligned squares of side [`RADIAL_ICON_SIZE_PX`] centred on each /// anchor. If multiple icons overlap (impossible at the default radius + /// icon size combination, but defensively checked) the lowest index wins. pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option { let half = RADIAL_ICON_SIZE_PX / 2.0; for (i, anchor) in anchors.iter().enumerate() { if (cursor.x - anchor.x).abs() <= half && (cursor.y - anchor.y).abs() <= half { return Some(i); } } None } /// Returns the legal destination piles for moving `card` from /// `source_pile` in `game`. /// /// Mirrors [`crate::card_plugin::handle_right_click`]'s decision logic /// exactly — only foundations that legally accept the card and tableaus /// that legally accept the card. The source pile is excluded because /// dropping a card on its own pile is a no-op. pub fn legal_destinations_for_card( card: &Card, source_pile: &PileType, game: &GameState, ) -> Vec { let mut out = Vec::new(); for slot in 0..4_u8 { let dest = PileType::Foundation(slot); if dest == *source_pile { continue; } if let Some(pile) = game.piles.get(&dest) && can_place_on_foundation(card, pile) { out.push(dest); } } for i in 0..7_usize { let dest = PileType::Tableau(i); if dest == *source_pile { continue; } if let Some(pile) = game.piles.get(&dest) && can_place_on_tableau(card, pile) { out.push(dest); } } out } /// Returns the topmost face-up draggable card under `cursor` (world /// space) along with its source pile. /// /// Reuses the same "topmost face-up card" semantics as /// [`crate::card_plugin::handle_right_click`]: tableau columns offer /// every face-up card, waste / foundations offer only their top card, /// and stock is never draggable. Returns `None` for face-down cards, /// empty piles, or clicks in dead space. pub fn find_top_face_up_card_at( cursor: Vec2, game: &GameState, layout: &Layout, ) -> Option<(PileType, Card)> { let piles = [ PileType::Waste, PileType::Foundation(0), PileType::Foundation(1), PileType::Foundation(2), PileType::Foundation(3), PileType::Tableau(0), PileType::Tableau(1), PileType::Tableau(2), PileType::Tableau(3), PileType::Tableau(4), PileType::Tableau(5), PileType::Tableau(6), ]; for pile in piles { let Some(pile_cards) = game.piles.get(&pile) else { continue; }; if pile_cards.cards.is_empty() { continue; } let is_tableau = matches!(pile, PileType::Tableau(_)); for i in (0..pile_cards.cards.len()).rev() { let card = &pile_cards.cards[i]; if !card.face_up { continue; } // Only the top card is draggable on non-tableau piles. if !is_tableau && i != pile_cards.cards.len() - 1 { continue; } let pos = card_position(game, layout, &pile, i); let half = layout.card_size / 2.0; if cursor.x < pos.x - half.x || cursor.x > pos.x + half.x || cursor.y < pos.y - half.y || cursor.y > pos.y + half.y { continue; } return Some((pile, card.clone())); } } None } /// Mirror of `input_plugin::card_position` — kept private to this /// module so the radial's hit-test geometry tracks renderer geometry /// without depending on `input_plugin` internals. fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 { let base = layout.pile_positions[pile]; if matches!(pile, PileType::Tableau(_)) { let mut y_offset = 0.0_f32; if let Some(pile_cards) = game.piles.get(pile) { for card in pile_cards.cards.iter().take(stack_index) { let step = if card.face_up { TABLEAU_FAN_FRAC } else { TABLEAU_FACEDOWN_FAN_FRAC }; y_offset -= layout.card_size.y * step; } } Vec2::new(base.x, base.y + y_offset) } else { base } } /// Builds the `(destination, anchor)` list for a fresh radial open. fn build_radial_destinations(centre: Vec2, dests: Vec) -> Vec<(PileType, Vec2)> { let count = dests.len(); dests .into_iter() .enumerate() .map(|(i, d)| (d, radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX))) .collect() } // --------------------------------------------------------------------------- // Cursor lookup — uses an override resource under MinimalPlugins, falls // back to the real Window/Camera otherwise. // --------------------------------------------------------------------------- /// Returns the world-space cursor position. Prefers /// [`RadialCursorOverride`] when present (test injection); otherwise /// reads the primary window's cursor position and projects it through /// the camera. fn cursor_world( override_res: Option<&Res>, windows: &Query<&Window, With>, cameras: &Query<(&Camera, &GlobalTransform)>, ) -> Option { if let Some(ovr) = override_res && let Some(pos) = ovr.0 { return Some(pos); } let window = windows.single().ok()?; let cursor = window.cursor_position()?; let (camera, camera_transform) = cameras.single().ok()?; camera.viewport_to_world_2d(camera_transform, cursor).ok() } // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- /// On `MouseButton::Right` `just_pressed`, attempts to open the radial /// menu over the card the cursor is on. Skips when a left-mouse drag is /// in progress, when the game is paused, or when the clicked card has no /// legal destinations. #[allow(clippy::too_many_arguments)] fn radial_open_on_right_click( buttons: Option>>, paused: Option>, drag: Res, cursor_override: Option>, windows: Query<&Window, With>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, game: Option>, mut state: ResMut, ) { if paused.is_some_and(|p| p.0) { return; } if !drag.is_idle() { return; } let Some(buttons) = buttons else { return }; if !buttons.just_pressed(MouseButton::Right) { return; } if state.is_active() { // Already active — ignore re-presses. return; } let Some(layout) = layout else { return }; let Some(game) = game else { return }; let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else { return; }; let Some((source_pile, card)) = find_top_face_up_card_at(world, &game.0, &layout.0) else { return; }; // Only single-card right-click for now: foundations require single // cards and the highlight tint shows the same set the radial offers. let dests = legal_destinations_for_card(&card, &source_pile, &game.0); if dests.is_empty() { return; } let legal_destinations = build_radial_destinations(world, dests); *state = RightClickRadialState::Active { source_pile, count: 1, cards: vec![card.id], legal_destinations, centre: world, hovered_index: None, }; } /// Opens the radial menu after a sustained touch hold on a face-up card. /// /// Counts up while the touch is down, the drag threshold has not been /// crossed, and the radial is not yet active. Fires after /// [`LONG_PRESS_SECS`] (0.5 s). The timer resets whenever these /// conditions are not met, so lifting, committing a drag, or the radial /// already being open all clear it cleanly. #[allow(clippy::too_many_arguments)] fn radial_open_on_long_press( time: Res