feat(engine): visible drop-target overlay during drag
The existing update_drop_highlights system tinted PileMarker sprites green for valid drops, but the marker is a card-sized rectangle that sits behind the stack. Once a tableau column had any cards on it the marker was occluded and the highlight effectively invisible — the handoff's "drops feel guess-y because there's no preview" point. A new update_drop_target_overlays system spawns an overlay above every legal target during drag: a soft DROP_TARGET_FILL rectangle sized to the pile's actual visible footprint (full fanned column for tableaux, card-sized for foundations and empty tableaux) plus four thin DROP_TARGET_OUTLINE edges forming a 3 px border. Z_DROP_OVERLAY = 50 sits above static cards (z ~1) but below the dragged stack (DRAG_Z = 500), so the overlay never occludes the card the player is holding. The valid-target enumeration mirrors update_drop_highlights exactly so the rules can't drift, and pile geometry mirrors input_plugin's pile_drop_rect. The original marker-tint system is untouched; it still does its job for empty-pile placeholders. The overlay layer is purely additive — running alongside, not replacing. Token values reuse the existing STATE_SUCCESS hue (#4ADE80) at 10% fill / 75% outline so the overlay green matches the rest of the success-signal palette (foundation completion, sync OK, etc.). Three headless tests pin the contract: overlay spawns for valid tableau drops, doesn't spawn for invalid destinations, and despawns the moment the drag ends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,17 @@
|
||||
//! - **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.
|
||||
//!
|
||||
//! **Drop-target overlays** (`update_drop_target_overlays`)
|
||||
//! Pile markers sit *behind* the card stack, so on a tableau column with
|
||||
//! any cards on it the green tint applied above is fully occluded. To
|
||||
//! make legal targets unmistakable mid-drag, this system spawns a
|
||||
//! translucent green rectangle plus four outline edges over every legal
|
||||
//! destination pile. For tableau columns the overlay covers the full
|
||||
//! visible fan (matching `input_plugin::pile_drop_rect`); for
|
||||
//! foundations and empty tableaux it is card-sized. Overlays are
|
||||
//! despawned the frame the drag ends or whenever the legal-target set
|
||||
//! changes.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
@@ -21,6 +32,9 @@ use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::ui_theme::{
|
||||
DROP_TARGET_FILL, DROP_TARGET_OUTLINE, DROP_TARGET_OUTLINE_PX, Z_DROP_OVERLAY,
|
||||
};
|
||||
|
||||
/// Semi-transparent white that `table_plugin` uses for idle pile markers.
|
||||
/// Kept in sync with the `marker_colour` constant there.
|
||||
@@ -29,12 +43,26 @@ 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);
|
||||
|
||||
/// Marker component on a parent entity that owns one drop-target overlay
|
||||
/// (a translucent fill plus four outline edges as children). The wrapped
|
||||
/// `PileType` identifies which pile this overlay highlights, so test
|
||||
/// queries and the despawn-on-target-change logic can filter by pile.
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DropTargetOverlay(pub PileType);
|
||||
|
||||
/// 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));
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_cursor_icon,
|
||||
update_drop_highlights,
|
||||
update_drop_target_overlays,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +203,213 @@ fn update_drop_highlights(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop-target overlay sprites — render in front of cards, unlike the pile
|
||||
// markers above which sit behind the stack.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawns / despawns translucent overlay sprites over every legal drop
|
||||
/// target while a drag is in progress.
|
||||
///
|
||||
/// The overlay is a parent `Sprite` (the soft fill) with four child
|
||||
/// `Sprite`s (top, bottom, left, right edges) that together form the
|
||||
/// outline. A new parent is spawned whenever a target appears in the
|
||||
/// valid set; a parent is despawned (with its children) whenever its
|
||||
/// pile leaves the valid set or the drag ends.
|
||||
///
|
||||
/// Geometry mirrors `input_plugin::pile_drop_rect` exactly so the
|
||||
/// highlighted region matches the actual drop hit-box.
|
||||
fn update_drop_target_overlays(
|
||||
mut commands: Commands,
|
||||
drag: Res<DragState>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
overlays: Query<(Entity, &DropTargetOverlay)>,
|
||||
) {
|
||||
// Drag idle → despawn every existing overlay and exit.
|
||||
if drag.is_idle() {
|
||||
for (entity, _) in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let (Some(game), Some(layout)) = (game, layout) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve the bottom card of the dragged stack — same logic as
|
||||
// `update_drop_highlights` so rules can't drift between the marker
|
||||
// tint and the overlay.
|
||||
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();
|
||||
|
||||
// Iterate the same pile list as `update_drop_highlights`. Stock and
|
||||
// Waste are excluded because they are never legal drop targets.
|
||||
let candidates = [
|
||||
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),
|
||||
];
|
||||
|
||||
// Compute the new set of valid piles for this frame.
|
||||
let mut valid: Vec<PileType> = Vec::new();
|
||||
for pile in &candidates {
|
||||
let is_valid = match pile {
|
||||
PileType::Foundation(_) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
game.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(_) => game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
|
||||
_ => false,
|
||||
};
|
||||
// Don't highlight the origin pile — dropping onto the source is
|
||||
// a no-op.
|
||||
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
|
||||
valid.push(pile.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Despawn overlays whose pile is no longer valid.
|
||||
for (entity, marker) in &overlays {
|
||||
if !valid.contains(&marker.0) {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn overlays for piles that are now valid but don't yet have one.
|
||||
let already_overlaid: Vec<PileType> = overlays
|
||||
.iter()
|
||||
.map(|(_, m)| m.0.clone())
|
||||
.filter(|p| valid.contains(p))
|
||||
.collect();
|
||||
|
||||
for pile in valid {
|
||||
if already_overlaid.contains(&pile) {
|
||||
continue;
|
||||
}
|
||||
spawn_drop_target_overlay(&mut commands, &pile, &layout.0, &game.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the `(centre, size)` of the drop-target overlay for a pile.
|
||||
///
|
||||
/// Mirrors `input_plugin::pile_drop_rect` — for tableau columns with two
|
||||
/// or more cards the rectangle extends downward to cover the full fan;
|
||||
/// for everything else it is card-sized. Replicated here rather than
|
||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||
/// this overlay is the only other consumer.
|
||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
|
||||
let centre = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||
let top_edge = centre.y + layout.card_size.y / 2.0;
|
||||
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
||||
let span_height = top_edge - bottom_edge;
|
||||
let new_centre_y = (top_edge + bottom_edge) / 2.0;
|
||||
return (
|
||||
Vec2::new(centre.x, new_centre_y),
|
||||
Vec2::new(layout.card_size.x, span_height),
|
||||
);
|
||||
}
|
||||
}
|
||||
(centre, layout.card_size)
|
||||
}
|
||||
|
||||
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at
|
||||
/// the appropriate world position for `pile`.
|
||||
fn spawn_drop_target_overlay(
|
||||
commands: &mut Commands,
|
||||
pile: &PileType,
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) {
|
||||
let (centre, size) = drop_overlay_rect(pile, layout, game);
|
||||
let edge = DROP_TARGET_OUTLINE_PX;
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_FILL,
|
||||
custom_size: Some(size),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(centre.x, centre.y, Z_DROP_OVERLAY),
|
||||
DropTargetOverlay(pile.clone()),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// Top edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(size.x, edge)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, size.y / 2.0 - edge / 2.0, 0.01),
|
||||
));
|
||||
// Bottom edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(size.x, edge)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, -size.y / 2.0 + edge / 2.0, 0.01),
|
||||
));
|
||||
// Left edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(edge, size.y)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(-size.x / 2.0 + edge / 2.0, 0.0, 0.01),
|
||||
));
|
||||
// Right edge.
|
||||
parent.spawn((
|
||||
Sprite {
|
||||
color: DROP_TARGET_OUTLINE,
|
||||
custom_size: Some(Vec2::new(edge, size.y)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(size.x / 2.0 - edge / 2.0, 0.0, 0.01),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -257,4 +492,159 @@ mod tests {
|
||||
// A cursor far off-screen should never hit anything.
|
||||
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drop-target overlay tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||
|
||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||
/// registered, plus the resources the system needs. Callers
|
||||
/// customise `GameStateResource` and `DragState` after construction.
|
||||
fn overlay_test_app(game: GameState) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.insert_resource(GameStateResource(game))
|
||||
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0))))
|
||||
.insert_resource(DragState::default())
|
||||
.add_systems(Update, update_drop_target_overlays);
|
||||
app
|
||||
}
|
||||
|
||||
/// Replaces the top card of a tableau pile with a fresh face-up
|
||||
/// card. Used to make a specific tableau column accept a chosen
|
||||
/// drag stack.
|
||||
fn set_tableau_top(game: &mut GameState, idx: usize, card: Card) {
|
||||
let pile = game
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(idx))
|
||||
.expect("tableau pile exists");
|
||||
pile.cards.clear();
|
||||
pile.cards.push(card);
|
||||
}
|
||||
|
||||
/// Inserts a single face-up dragged card into the waste pile and
|
||||
/// configures `DragState` so the overlay system treats it as the
|
||||
/// active drag.
|
||||
fn begin_drag_with(app: &mut App, dragged: Card) {
|
||||
// Place the dragged card on the waste pile (origin).
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let waste = game
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Waste)
|
||||
.expect("waste pile exists");
|
||||
waste.cards.clear();
|
||||
waste.cards.push(dragged.clone());
|
||||
}
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![dragged.id];
|
||||
drag.origin_pile = Some(PileType::Waste);
|
||||
drag.committed = true;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
|
||||
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
|
||||
// (black, rank 6) — alternating colour, one rank lower → legal.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9001, suit: Suit::Spades, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9002, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
overlays.contains(&PileType::Tableau(2)),
|
||||
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
// highlighted.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9101, suit: Suit::Clubs, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9102, suit: Suit::Spades, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!overlays.contains(&PileType::Tableau(2)),
|
||||
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlays_despawn_on_drag_end() {
|
||||
// Set up a scenario that produces at least one valid overlay,
|
||||
// confirm it spawns, then clear the drag and confirm every
|
||||
// overlay is despawned.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card { id: 9201, suit: Suit::Spades, rank: Rank::Six, face_up: true },
|
||||
);
|
||||
let dragged = Card { id: 9202, suit: Suit::Hearts, rank: Rank::Five, face_up: true };
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
app.update();
|
||||
|
||||
let count_during_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
count_during_drag >= 1,
|
||||
"expected ≥1 overlay during drag, got {count_during_drag}"
|
||||
);
|
||||
|
||||
// End the drag — every overlay should despawn next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
|
||||
let count_after_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count_after_drag, 0,
|
||||
"all overlays must despawn when the drag ends"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,30 @@ pub const STATE_DANGER: Color = Color::srgb(0.969, 0.447, 0.447);
|
||||
/// Info — daily-challenge constraint, draw-cycle indicator. `#6BBBFF`.
|
||||
pub const STATE_INFO: Color = Color::srgb(0.420, 0.733, 1.000);
|
||||
|
||||
/// Soft fill colour for the drop-target overlay shown over every legal
|
||||
/// destination pile while the player is dragging a card. Same green hue
|
||||
/// as `STATE_SUCCESS` (`#4ADE80`) so the visual language stays
|
||||
/// consistent, but at 10 % alpha so the underlying card faces remain
|
||||
/// fully readable through the wash.
|
||||
pub const DROP_TARGET_FILL: Color = Color::srgba(0.290, 0.871, 0.502, 0.10);
|
||||
|
||||
/// Outline colour for the drop-target overlay. Matches the
|
||||
/// `STATE_SUCCESS` hue at 75 % alpha so the rectangle border reads
|
||||
/// unmistakably against both the felt and stacked card faces without
|
||||
/// drowning the cards themselves.
|
||||
pub const DROP_TARGET_OUTLINE: Color = Color::srgba(0.290, 0.871, 0.502, 0.75);
|
||||
|
||||
/// Thickness of the drop-target outline edges, in world-space pixels.
|
||||
pub const DROP_TARGET_OUTLINE_PX: f32 = 3.0;
|
||||
|
||||
/// Sprite-space `Transform.z` for drop-target overlay entities. Sits
|
||||
/// well above any static card (top stack z is `~1.04`) but well below
|
||||
/// the lifted dragged stack (`DRAG_Z = 500.0` in `input_plugin`) so the
|
||||
/// overlay never occludes the card the player is holding. Distinct from
|
||||
/// the i32 `Z_*` UI-Node tokens above — those are `ZIndex` values for
|
||||
/// `bevy::ui`, while this is a 2D `Sprite` z coordinate.
|
||||
pub const Z_DROP_OVERLAY: f32 = 50.0;
|
||||
|
||||
/// Subtle border — default popover, card, and idle button outline.
|
||||
pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user