ffc79447d4
P0 fixes: - Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs (all three were exported but never wired — features silently did nothing) - game_state::draw(): increment move_count on waste→stock recycle, not just on normal draws; add move_count_increments_on_recycle regression test P1 fixes: - solitaire_server/Cargo.toml: remove duplicate dev-dependencies (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections) P2 — input_plugin refactor: - Split 198-line handle_keyboard() into three focused systems under 110 lines each: handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G) - Introduce KeyboardConfirmState resource to share countdown timers across systems - Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck, new_game_confirm_window_is_positive P2 — achievement predicate tests (solitaire_core): - Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer, on_a_roll, comeback predicates (previously only covered via check_achievements()) - 141 core tests now passing P2 — server tests: - solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required) - solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order P3 — documentation: - Add struct-level /// to 12 Plugin structs (ChallengePlugin, CursorPlugin, AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin, HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin) - Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef - Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win card_animation module (new files from previous session): - chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs - Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants - Add handle_touch_stock_tap so touch users can draw from the stock pile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
8.8 KiB
Rust
262 lines
8.8 KiB
Rust
//! Cursor-icon feedback (#31) and drag drop-target highlighting (#32).
|
|
//!
|
|
//! **Cursor icons** (`update_cursor_icon`)
|
|
//! - Cards are being dragged → `Grabbing` (closed hand)
|
|
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
|
|
//! - Otherwise → `Default` (arrow)
|
|
//!
|
|
//! **Drop-target highlights** (`update_drop_highlights`)
|
|
//! While a drag is in progress every `PileMarker` sprite is tinted:
|
|
//! - **Green** if the dragged stack can legally land there.
|
|
//! - **Default** (nearly transparent white) otherwise.
|
|
//! The tint is cleared to default the frame the drag ends.
|
|
|
|
use bevy::prelude::*;
|
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
|
use solitaire_core::card::Suit;
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
use solitaire_core::pile::PileType;
|
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
|
|
|
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
|
use crate::layout::{Layout, LayoutResource};
|
|
use crate::resources::{DragState, GameStateResource};
|
|
use crate::table_plugin::PileMarker;
|
|
|
|
/// Semi-transparent white that `table_plugin` uses for idle pile markers.
|
|
/// Kept in sync with the `marker_colour` constant there.
|
|
const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
|
|
|
/// Green tint applied to pile markers that are valid drop targets during drag.
|
|
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
|
|
|
|
/// Renders a custom cursor sprite that follows the pointer and swaps to a grab-hand icon while a card drag is in progress.
|
|
pub struct CursorPlugin;
|
|
|
|
impl Plugin for CursorPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_systems(Update, (update_cursor_icon, update_drop_highlights));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #31 — Cursor icon
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Updates the primary-window cursor icon based on drag state and hover.
|
|
fn update_cursor_icon(
|
|
drag: Res<DragState>,
|
|
windows: Query<(Entity, &Window), With<PrimaryWindow>>,
|
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
game: Option<Res<GameStateResource>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let Ok((win_entity, window)) = windows.single() else { return };
|
|
|
|
if !drag.is_idle() {
|
|
commands
|
|
.entity(win_entity)
|
|
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
|
|
return;
|
|
}
|
|
|
|
let hovering = (|| {
|
|
let cursor = window.cursor_position()?;
|
|
let (camera, cam_xf) = cameras.single().ok()?;
|
|
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
|
let layout = layout.as_ref()?.0.clone();
|
|
let game = game.as_ref()?;
|
|
Some(cursor_over_draggable(world, &game.0, &layout))
|
|
})()
|
|
.unwrap_or(false);
|
|
|
|
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
|
|
SystemCursorIcon::Grab
|
|
} else {
|
|
SystemCursorIcon::Default
|
|
}));
|
|
}
|
|
|
|
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
|
fn cursor_over_draggable(cursor: Vec2, game: &GameState, layout: &Layout) -> bool {
|
|
let piles = [
|
|
PileType::Waste,
|
|
PileType::Foundation(Suit::Clubs),
|
|
PileType::Foundation(Suit::Diamonds),
|
|
PileType::Foundation(Suit::Hearts),
|
|
PileType::Foundation(Suit::Spades),
|
|
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;
|
|
};
|
|
let is_tableau = matches!(pile, PileType::Tableau(_));
|
|
let base = layout.pile_positions[&pile];
|
|
|
|
for (i, card) in pile_cards.cards.iter().enumerate().rev() {
|
|
if !card.face_up {
|
|
continue;
|
|
}
|
|
// Only the topmost card is draggable on non-tableau piles.
|
|
if !is_tableau && i != pile_cards.cards.len() - 1 {
|
|
continue;
|
|
}
|
|
let pos = tableau_or_stack_pos(game, layout, &pile, i, base, is_tableau);
|
|
if point_in_rect(cursor, pos, layout.card_size) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #32 — Drop-target highlighting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Tints pile-marker sprites green when they are valid drag destinations,
|
|
/// and restores the default colour when no drag is active.
|
|
/// Markers tagged with `RightClickHighlight` are skipped during the idle reset
|
|
/// so the right-click legal-destination highlight remains visible.
|
|
fn update_drop_highlights(
|
|
drag: Res<DragState>,
|
|
game: Option<Res<GameStateResource>>,
|
|
mut markers: Query<(&PileMarker, &mut Sprite, Option<&RightClickHighlight>)>,
|
|
) {
|
|
if drag.is_idle() {
|
|
// Drag ended — restore markers that are not right-click-highlighted.
|
|
for (_, mut sprite, rch) in &mut markers {
|
|
if rch.is_none() {
|
|
sprite.color = MARKER_DEFAULT;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let Some(game) = game else { return };
|
|
|
|
// The first element of drag.cards is the bottom card that lands on the target.
|
|
let Some(&bottom_id) = drag.cards.first() else { return };
|
|
let bottom_card = game
|
|
.0
|
|
.piles
|
|
.values()
|
|
.flat_map(|p| p.cards.iter())
|
|
.find(|c| c.id == bottom_id)
|
|
.cloned();
|
|
let Some(bottom_card) = bottom_card else { return };
|
|
let drag_count = drag.cards.len();
|
|
|
|
for (marker, mut sprite, _rch) in &mut markers {
|
|
let valid = match &marker.0 {
|
|
PileType::Foundation(suit) => {
|
|
if drag_count != 1 {
|
|
false
|
|
} else {
|
|
let pile = game.0.piles.get(&PileType::Foundation(*suit));
|
|
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p, *suit))
|
|
}
|
|
}
|
|
PileType::Tableau(idx) => {
|
|
let pile = game.0.piles.get(&PileType::Tableau(*idx));
|
|
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
|
}
|
|
_ => false,
|
|
};
|
|
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn tableau_or_stack_pos(
|
|
game: &GameState,
|
|
layout: &Layout,
|
|
pile: &PileType,
|
|
index: usize,
|
|
base: Vec2,
|
|
is_tableau: bool,
|
|
) -> Vec2 {
|
|
if is_tableau {
|
|
Vec2::new(
|
|
base.x,
|
|
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
|
|
)
|
|
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
|
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
|
let visible_start = pile_len.saturating_sub(3);
|
|
let slot = index.saturating_sub(visible_start) as f32;
|
|
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
|
} else {
|
|
base
|
|
}
|
|
}
|
|
|
|
fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
|
let half = size / 2.0;
|
|
point.x >= center.x - half.x
|
|
&& point.x <= center.x + half.x
|
|
&& point.y >= center.y - half.y
|
|
&& point.y <= center.y + half.y
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn point_in_rect_center_is_inside() {
|
|
assert!(point_in_rect(Vec2::ZERO, Vec2::ZERO, Vec2::new(10.0, 10.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn point_in_rect_edge_is_inside() {
|
|
assert!(point_in_rect(
|
|
Vec2::new(5.0, 5.0),
|
|
Vec2::ZERO,
|
|
Vec2::new(10.0, 10.0)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn point_in_rect_outside() {
|
|
assert!(!point_in_rect(
|
|
Vec2::new(6.0, 0.0),
|
|
Vec2::ZERO,
|
|
Vec2::new(10.0, 10.0)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn marker_valid_and_default_colours_are_distinct() {
|
|
// Regression guard — ensure these constants haven't been accidentally
|
|
// set to the same value.
|
|
assert_ne!(
|
|
format!("{MARKER_VALID:?}"),
|
|
format!("{MARKER_DEFAULT:?}")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_over_draggable_returns_false_for_empty_game() {
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
use crate::layout::compute_layout;
|
|
|
|
let game = GameState::new(42, DrawMode::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
|
// A cursor far off-screen should never hit anything.
|
|
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
|
}
|
|
}
|