d1e87765af
Final runtime split for #118: the 2,536-line mod.rs becomes seven focused submodules along existing system boundaries — mod.rs 574 fan-step helpers, CardImageSet, markers, plugin build sync.rs 748 asset loading + card entity lifecycle (spawn/update/position) layout.rs 351 resize snapping, in-place resize, tableau fan spread stock.rs 291 empty-stock recycle hint + count badge highlights.rs 276 hint/right-click highlights, cursor hit-testing labels.rs 208 desktop text labels + Android corner labels anim.rs 200 flip animation, drag shadows Moved items are pub(super); code moved verbatim apart from relocating the two stock colour constants next to their consumers. No behaviour change; all 70 card tests pass unchanged. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 lines
10 KiB
Rust
277 lines
10 KiB
Rust
//! Hint and right-click highlights, plus cursor hit-testing helpers.
|
|
|
|
use super::*;
|
|
|
|
|
|
use bevy::color::Color;
|
|
use solitaire_core::Card;
|
|
use solitaire_core::game_state::GameState;
|
|
|
|
use crate::events::StateChangedEvent;
|
|
use crate::layout::{Layout, LayoutResource};
|
|
use crate::pause_plugin::PausedResource;
|
|
use crate::resources::{DragState, GameStateResource};
|
|
use crate::settings_plugin::SettingsResource;
|
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
|
|
|
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
|
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
|
|
/// resets the card sprite to its normal face-up colour.
|
|
pub(super) fn tick_hint_highlight(
|
|
time: Res<Time>,
|
|
mut commands: Commands,
|
|
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
|
game: Res<GameStateResource>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
card_images: Option<Res<CardImageSet>>,
|
|
) {
|
|
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
|
let use_images = card_images.is_some();
|
|
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
|
hint.remaining -= time.delta_secs();
|
|
if hint.remaining <= 0.0 {
|
|
// Restore the normal sprite colour.
|
|
// When image-based rendering is active, WHITE is the neutral tint;
|
|
// otherwise restore the solid colour appropriate to the card state.
|
|
sprite.color = if use_images {
|
|
Color::WHITE
|
|
} else {
|
|
let is_face_up = all_cards(&game.0)
|
|
.iter()
|
|
.find(|(c, _face_up)| *c == card_entity.card)
|
|
.is_some_and(|(_, face_up)| *face_up);
|
|
if is_face_up {
|
|
CARD_FACE_COLOUR
|
|
} else {
|
|
card_back_colour(back_idx)
|
|
}
|
|
};
|
|
commands
|
|
.entity(entity)
|
|
.remove::<HintHighlight>()
|
|
.remove::<HintHighlightTimer>();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #46 — Right-click legal destination highlights
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Lime tint applied to a `PileMarker` sprite when it is a legal
|
|
/// destination for the right-clicked card. Same RGB as the design-
|
|
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
|
|
/// because `Alpha::with_alpha` is not yet a `const` trait method on
|
|
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
|
|
/// so a palette swap can't drift the two apart silently.
|
|
pub(super) const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
|
|
|
|
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
|
|
/// when the timer expires.
|
|
///
|
|
/// This is a fallback expiry: highlights also clear immediately on
|
|
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
|
|
/// first. The 1.5 s timer ensures highlights always disappear even if the
|
|
/// player takes no further action.
|
|
pub(super) fn tick_right_click_highlights(
|
|
mut commands: Commands,
|
|
time: Res<Time>,
|
|
paused: Option<Res<PausedResource>>,
|
|
mut highlights: Query<
|
|
(Entity, &mut RightClickHighlightTimer, &mut Sprite),
|
|
With<RightClickHighlight>,
|
|
>,
|
|
) {
|
|
if paused.is_some_and(|p| p.0) {
|
|
return;
|
|
}
|
|
let dt = time.delta_secs();
|
|
for (entity, mut timer, mut sprite) in &mut highlights {
|
|
timer.0 -= dt;
|
|
if timer.0 <= 0.0 {
|
|
// Restore the pile marker to its default colour before removing
|
|
// the highlight marker component.
|
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
|
commands
|
|
.entity(entity)
|
|
.remove::<RightClickHighlight>()
|
|
.remove::<RightClickHighlightTimer>();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
|
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
|
///
|
|
/// Shared by the on-state-change and on-pause clear systems to avoid
|
|
/// duplicating the removal logic.
|
|
pub(super) fn clear_right_click_highlights(
|
|
commands: &mut Commands,
|
|
highlighted: &Query<Entity, With<RightClickHighlight>>,
|
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
|
) {
|
|
for entity in highlighted.iter() {
|
|
commands.entity(entity).remove::<RightClickHighlight>();
|
|
}
|
|
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
|
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clears all right-click destination highlights whenever any game-state
|
|
/// mutation succeeds (`StateChangedEvent` fires).
|
|
///
|
|
/// This ensures stale highlights do not linger after a card is moved.
|
|
pub(super) fn clear_right_click_highlights_on_state_change(
|
|
mut events: MessageReader<StateChangedEvent>,
|
|
mut commands: Commands,
|
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
|
) {
|
|
if events.read().next().is_none() {
|
|
return;
|
|
}
|
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
|
}
|
|
|
|
/// Clears all right-click destination highlights when the game is paused
|
|
/// (`PausedResource` changes to `true`).
|
|
///
|
|
/// Prevents highlighted pile markers from remaining visible behind the pause
|
|
/// overlay.
|
|
pub(super) fn clear_right_click_highlights_on_pause(
|
|
paused: Option<Res<PausedResource>>,
|
|
mut commands: Commands,
|
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
|
) {
|
|
let Some(paused) = paused else { return };
|
|
if paused.is_changed() && paused.0 {
|
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
|
}
|
|
}
|
|
|
|
/// Handles right-click: highlights legal destination piles for the clicked card,
|
|
/// and clears highlights on any subsequent right- or left-click.
|
|
///
|
|
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn handle_right_click(
|
|
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
|
paused: Option<Res<PausedResource>>,
|
|
drag: Res<DragState>,
|
|
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
|
layout: Option<Res<LayoutResource>>,
|
|
game: Res<GameStateResource>,
|
|
mut commands: Commands,
|
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
|
) {
|
|
if paused.is_some_and(|p| p.0) {
|
|
return;
|
|
}
|
|
|
|
let Some(buttons) = buttons else { return };
|
|
let left_pressed = buttons.just_pressed(MouseButton::Left);
|
|
let right_pressed = buttons.just_pressed(MouseButton::Right);
|
|
|
|
// Clear existing highlights on any click.
|
|
if left_pressed || right_pressed {
|
|
for entity in &highlighted {
|
|
commands.entity(entity).remove::<RightClickHighlight>();
|
|
}
|
|
for (_entity, _, mut sprite) in &mut pile_markers {
|
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only proceed for right-clicks while not dragging.
|
|
if !right_pressed || !drag.is_idle() {
|
|
return;
|
|
}
|
|
|
|
let Some(layout) = layout else { return };
|
|
|
|
// Convert cursor to world-space position.
|
|
let Some(world) = cursor_world_pos(&windows, &cameras) else {
|
|
return;
|
|
};
|
|
|
|
// Find the topmost face-up card under the cursor.
|
|
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
|
|
return;
|
|
};
|
|
|
|
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
|
|
return;
|
|
};
|
|
|
|
// Tint piles that legally accept the card.
|
|
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
|
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
|
|
if legal {
|
|
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
|
commands
|
|
.entity(entity)
|
|
.insert(RightClickHighlight)
|
|
.insert(RightClickHighlightTimer(1.5));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Converts cursor position to 2-D world coordinates.
|
|
pub(super) fn cursor_world_pos(
|
|
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
|
) -> Option<Vec2> {
|
|
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()
|
|
}
|
|
|
|
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
|
|
/// bounding rectangles of all card sprites, picking the highest Z.
|
|
pub(super) fn find_top_card_at(
|
|
cursor: Vec2,
|
|
game: &GameState,
|
|
layout: &Layout,
|
|
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
|
|
) -> Option<Card> {
|
|
let half = layout.card_size / 2.0;
|
|
let mut best: Option<(f32, Card)> = None;
|
|
|
|
for (_, card_entity, transform) in card_entities.iter() {
|
|
let pos = transform.translation.truncate();
|
|
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;
|
|
}
|
|
let found = all_cards(game)
|
|
.into_iter()
|
|
.find(|(c, face_up)| *c == card_entity.card && *face_up);
|
|
if let Some((card, _)) = found {
|
|
let z = transform.translation.z;
|
|
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
|
best = Some((z, card));
|
|
}
|
|
}
|
|
}
|
|
best.map(|(_, card)| card)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #28 — Stock-empty visual indicator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|