feat(engine): card visual improvements — flip animation, foundation/tableau placeholders, drag shadow
Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed. Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation markers so the suit is visible while the pile is empty. Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land on empty columns. Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card. Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin, cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,11 @@ pub struct Settings {
|
|||||||
/// Set to `true` once the player has dismissed the first-run banner.
|
/// Set to `true` once the player has dismissed the first-run banner.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub first_run_complete: bool,
|
pub first_run_complete: bool,
|
||||||
|
/// When `true`, red-suit card faces use a blue tint instead of the default
|
||||||
|
/// cream so they are distinguishable from black-suit cards without relying
|
||||||
|
/// solely on colour.
|
||||||
|
#[serde(default)]
|
||||||
|
pub color_blind_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -121,6 +126,7 @@ impl Default for Settings {
|
|||||||
selected_card_back: 0,
|
selected_card_back: 0,
|
||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
|
color_blind_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,12 +283,30 @@ mod tests {
|
|||||||
selected_card_back: 0,
|
selected_card_back: 0,
|
||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: true,
|
first_run_complete: true,
|
||||||
|
color_blind_mode: false,
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
assert_eq!(loaded, s);
|
assert_eq!(loaded, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_preserves_non_default_cosmetic_selections() {
|
||||||
|
// selected_card_back and selected_background must survive save→load with
|
||||||
|
// non-zero values — zero is the default and not a meaningful regression check.
|
||||||
|
let path = tmp_path("cosmetic_selections");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
selected_card_back: 3,
|
||||||
|
selected_background: 2,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert_eq!(loaded.selected_card_back, 3);
|
||||||
|
assert_eq!(loaded.selected_background, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_missing_file_returns_default() {
|
fn load_from_missing_file_returns_default() {
|
||||||
let path = tmp_path("missing_xyz");
|
let path = tmp_path("missing_xyz");
|
||||||
@@ -318,5 +342,30 @@ mod tests {
|
|||||||
assert_eq!(s.theme, Theme::Green);
|
assert_eq!(s.theme, Theme::Green);
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
||||||
|
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
|
||||||
|
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
|
||||||
|
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_blind_mode_defaults_to_false_when_field_absent() {
|
||||||
|
// Simulate a JSON file that has no color_blind_mode field.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7 }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_blind_mode_round_trips() {
|
||||||
|
let path = tmp_path("color_blind");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
color_blind_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ use solitaire_core::card::{Card, Rank, Suit};
|
|||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::{CardFlippedEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::resources::GameStateResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
use crate::table_plugin::PileMarker;
|
||||||
|
|
||||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||||
@@ -39,9 +43,13 @@ const STACK_FAN_FRAC: f32 = 0.003;
|
|||||||
/// Font size as a fraction of card width.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
|
|
||||||
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||||
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
||||||
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||||
|
|
||||||
|
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
|
||||||
|
/// blue wash that distinguishes them from black-suit cards without colour alone.
|
||||||
|
const CARD_FACE_COLOUR_RED_CBM: Color = Color::srgba(0.85, 0.92, 1.0, 1.0);
|
||||||
|
|
||||||
/// Returns the card back color for the given unlocked card-back index.
|
/// Returns the card back color for the given unlocked card-back index.
|
||||||
/// Index 0 = default blue; 1–4 are unlockable alternate designs.
|
/// Index 0 = default blue; 1–4 are unlockable alternate designs.
|
||||||
@@ -65,6 +73,56 @@ pub struct CardEntity {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct CardLabel;
|
pub struct CardLabel;
|
||||||
|
|
||||||
|
/// Marker component indicating the card is currently highlighted as a hint.
|
||||||
|
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||||
|
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct HintHighlight {
|
||||||
|
/// Seconds remaining before the highlight is cleared.
|
||||||
|
pub remaining: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
|
||||||
|
/// card can legally be placed there.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RightClickHighlight;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #34 — Card-flip animation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Phase of the two-stage flip animation.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum FlipPhase {
|
||||||
|
/// Scale X from 1.0 → 0.0 (hiding the back face).
|
||||||
|
ScalingDown,
|
||||||
|
/// Scale X from 0.0 → 1.0 (revealing the front face).
|
||||||
|
ScalingUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
|
||||||
|
///
|
||||||
|
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
|
||||||
|
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct CardFlipAnim {
|
||||||
|
/// Seconds elapsed in the current phase.
|
||||||
|
pub timer: f32,
|
||||||
|
/// Which half of the flip we are in.
|
||||||
|
pub phase: FlipPhase,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration of each half of the flip animation (scale-down or scale-up).
|
||||||
|
const FLIP_HALF_SECS: f32 = 0.08;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #38 — Drag-elevation shadow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker component for the semi-transparent shadow sprite shown while dragging.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ShadowEntity;
|
||||||
|
|
||||||
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||||||
pub struct CardPlugin;
|
pub struct CardPlugin;
|
||||||
|
|
||||||
@@ -72,13 +130,24 @@ impl Plugin for CardPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// PostStartup ensures TablePlugin's Startup system has inserted
|
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||||
// LayoutResource before we try to read it.
|
// LayoutResource before we try to read it.
|
||||||
app.add_event::<SettingsChangedEvent>()
|
//
|
||||||
|
// `handle_right_click` reads `ButtonInput<MouseButton>`. Under
|
||||||
|
// `MinimalPlugins` (tests) this resource is absent by default, so we
|
||||||
|
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
||||||
|
app.init_resource::<ButtonInput<MouseButton>>()
|
||||||
|
.add_event::<SettingsChangedEvent>()
|
||||||
|
.add_event::<CardFlippedEvent>()
|
||||||
.add_systems(PostStartup, sync_cards_startup)
|
.add_systems(PostStartup, sync_cards_startup)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
sync_cards_on_change.after(GameMutation),
|
sync_cards_on_change.after(GameMutation),
|
||||||
resync_cards_on_settings_change.before(sync_cards_on_change),
|
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||||
|
start_flip_anim.after(GameMutation),
|
||||||
|
tick_flip_anim,
|
||||||
|
update_drag_shadow,
|
||||||
|
tick_hint_highlight,
|
||||||
|
handle_right_click,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -111,7 +180,8 @@ fn sync_cards_startup(
|
|||||||
let back_colour = settings
|
let back_colour = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities);
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +202,8 @@ fn sync_cards_on_change(
|
|||||||
let back_colour = settings
|
let back_colour = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
.map_or_else(|| card_back_colour(0), |s| card_back_colour(s.0.selected_card_back));
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, &entities);
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +213,7 @@ fn sync_cards(
|
|||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
slide_secs: f32,
|
slide_secs: f32,
|
||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
@@ -165,9 +237,12 @@ fn sync_cards(
|
|||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
match existing.get(&card.id) {
|
match existing.get(&card.id) {
|
||||||
Some(&(entity, cur)) => {
|
Some(&(entity, cur)) => {
|
||||||
update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, back_colour, cur)
|
update_card_entity(
|
||||||
|
&mut commands, entity, &card, position, z, layout,
|
||||||
|
slide_secs, back_colour, color_blind, cur,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour),
|
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour, color_blind),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,9 +318,22 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color) {
|
/// Returns the appropriate face-up body colour for a card.
|
||||||
let body_colour = if card.face_up {
|
///
|
||||||
|
/// In color-blind mode, red-suit cards receive a subtle blue tint
|
||||||
|
/// (`CARD_FACE_COLOUR_RED_CBM`) so they are distinguishable from black-suit
|
||||||
|
/// cards without relying on the text colour alone.
|
||||||
|
fn face_colour(card: &Card, color_blind: bool) -> Color {
|
||||||
|
if color_blind && card.suit.is_red() {
|
||||||
|
CARD_FACE_COLOUR_RED_CBM
|
||||||
|
} else {
|
||||||
CARD_FACE_COLOUR
|
CARD_FACE_COLOUR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color, color_blind: bool) {
|
||||||
|
let body_colour = if card.face_up {
|
||||||
|
face_colour(card, color_blind)
|
||||||
} else {
|
} else {
|
||||||
back_colour
|
back_colour
|
||||||
};
|
};
|
||||||
@@ -288,10 +376,11 @@ fn update_card_entity(
|
|||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
slide_secs: f32,
|
slide_secs: f32,
|
||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
cur: Vec3,
|
cur: Vec3,
|
||||||
) {
|
) {
|
||||||
let body_colour = if card.face_up {
|
let body_colour = if card.face_up {
|
||||||
CARD_FACE_COLOUR
|
face_colour(card, color_blind)
|
||||||
} else {
|
} else {
|
||||||
back_colour
|
back_colour
|
||||||
};
|
};
|
||||||
@@ -384,6 +473,299 @@ fn label_visibility(card: &Card) -> Visibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #34 — Card-flip animation systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||||
|
///
|
||||||
|
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
||||||
|
fn start_flip_anim(
|
||||||
|
mut events: EventReader<CardFlippedEvent>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
|
) {
|
||||||
|
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
|
||||||
|
// Instant animation speed — skip the flip effect entirely.
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for CardFlippedEvent(card_id) in events.read() {
|
||||||
|
for (entity, marker) in &card_entities {
|
||||||
|
if marker.card_id == *card_id {
|
||||||
|
commands.entity(entity).insert(CardFlipAnim {
|
||||||
|
timer: 0.0,
|
||||||
|
phase: FlipPhase::ScalingDown,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||||
|
///
|
||||||
|
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - At the midpoint the phase switches to `ScalingUp` and scale.x resets to 0.
|
||||||
|
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||||
|
fn tick_flip_anim(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut anims: Query<(Entity, &mut Transform, &mut CardFlipAnim)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut transform, mut anim) in &mut anims {
|
||||||
|
anim.timer += dt;
|
||||||
|
match anim.phase {
|
||||||
|
FlipPhase::ScalingDown => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = 1.0 - t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
anim.phase = FlipPhase::ScalingUp;
|
||||||
|
anim.timer = 0.0;
|
||||||
|
transform.scale.x = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlipPhase::ScalingUp => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale.x = 1.0;
|
||||||
|
commands.entity(entity).remove::<CardFlipAnim>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #38 — Drag-elevation shadow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maintains a single `ShadowEntity` while cards are being dragged.
|
||||||
|
///
|
||||||
|
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
|
||||||
|
/// sprite behind the top dragged card.
|
||||||
|
/// - If no drag is active, despawns the shadow entity.
|
||||||
|
fn update_drag_shadow(
|
||||||
|
mut commands: Commands,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
card_entities: Query<(&CardEntity, &Transform)>,
|
||||||
|
mut shadow: Local<Option<Entity>>,
|
||||||
|
) {
|
||||||
|
if drag.is_idle() {
|
||||||
|
// No drag in progress — remove shadow if it exists.
|
||||||
|
if let Some(e) = shadow.take() {
|
||||||
|
commands.entity(e).despawn_recursive();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let card_w = layout.0.card_size.x;
|
||||||
|
let card_h = layout.0.card_size.y;
|
||||||
|
|
||||||
|
// Find the world position of the first (top) dragged card.
|
||||||
|
let first_id = drag.cards.first().copied();
|
||||||
|
let top_pos = first_id.and_then(|id| {
|
||||||
|
card_entities
|
||||||
|
.iter()
|
||||||
|
.find(|(marker, _)| marker.card_id == id)
|
||||||
|
.map(|(_, t)| t.translation)
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(top_pos) = top_pos else { return };
|
||||||
|
|
||||||
|
// Shadow is slightly larger, offset behind-and-below, at a z slightly
|
||||||
|
// below the dragged cards.
|
||||||
|
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
|
||||||
|
|
||||||
|
match *shadow {
|
||||||
|
Some(e) => {
|
||||||
|
// Reposition the existing shadow.
|
||||||
|
commands.entity(e).insert(Transform::from_translation(shadow_pos));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Spawn a new shadow sprite.
|
||||||
|
let e = commands
|
||||||
|
.spawn((
|
||||||
|
ShadowEntity,
|
||||||
|
Sprite {
|
||||||
|
color: Color::srgba(0.0, 0.0, 0.0, 0.35),
|
||||||
|
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_translation(shadow_pos),
|
||||||
|
Visibility::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
*shadow = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #28 — Hint highlight tick system
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
||||||
|
/// removes the component and resets the card sprite to its normal face-up colour.
|
||||||
|
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>>,
|
||||||
|
) {
|
||||||
|
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||||
|
hint.remaining -= time.delta_secs();
|
||||||
|
if hint.remaining <= 0.0 {
|
||||||
|
// Restore normal face-up colour.
|
||||||
|
let is_face_up = game.0.piles.values()
|
||||||
|
.flat_map(|p| p.cards.iter())
|
||||||
|
.find(|c| c.id == card_entity.card_id)
|
||||||
|
.is_some_and(|c| c.face_up);
|
||||||
|
sprite.color = if is_face_up {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
} else {
|
||||||
|
card_back_colour(back_idx)
|
||||||
|
};
|
||||||
|
commands.entity(entity).remove::<HintHighlight>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #46 — Right-click legal destination highlights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Color applied to a `PileMarker` sprite when it is a legal destination for
|
||||||
|
/// the right-clicked card.
|
||||||
|
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
|
||||||
|
/// Restored color for `PileMarker` sprites when the highlight is cleared.
|
||||||
|
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
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 };
|
||||||
|
|
||||||
|
// Tint piles that legally accept the card.
|
||||||
|
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||||
|
let pile_type = &pile_marker.0;
|
||||||
|
let Some(pile) = game.0.piles.get(pile_type) else { continue };
|
||||||
|
let legal = match pile_type {
|
||||||
|
PileType::Foundation(suit) => {
|
||||||
|
can_place_on_foundation(&card, pile, *suit)
|
||||||
|
}
|
||||||
|
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if legal {
|
||||||
|
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||||
|
commands.entity(entity).insert(RightClickHighlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts cursor position to 2-D world coordinates.
|
||||||
|
fn cursor_world_pos(
|
||||||
|
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
|
) -> Option<Vec2> {
|
||||||
|
let window = windows.get_single().ok()?;
|
||||||
|
let cursor = window.cursor_position()?;
|
||||||
|
let (camera, camera_transform) = cameras.get_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.
|
||||||
|
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 card = game
|
||||||
|
.piles
|
||||||
|
.values()
|
||||||
|
.flat_map(|p| p.cards.iter())
|
||||||
|
.find(|c| c.id == card_entity.card_id && c.face_up)
|
||||||
|
.cloned();
|
||||||
|
if let Some(card) = card {
|
||||||
|
let z = transform.translation.z;
|
||||||
|
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||||
|
best = Some((z, card));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, card)| card)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -600,6 +982,69 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_back_colour_known_indices_are_distinct() {
|
||||||
|
// Indices 0–3 must each produce a unique colour.
|
||||||
|
let colours: Vec<_> = (0..4).map(card_back_colour).collect();
|
||||||
|
for i in 0..colours.len() {
|
||||||
|
for j in (i + 1)..colours.len() {
|
||||||
|
assert_ne!(colours[i], colours[j], "indices {i} and {j} must be distinct");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_back_colour_out_of_range_does_not_panic() {
|
||||||
|
// Indices >= 4 are beyond the defined set; the wildcard arm must handle them
|
||||||
|
// without panicking and return the same teal fallback for all.
|
||||||
|
let c4 = card_back_colour(4);
|
||||||
|
let c5 = card_back_colour(5);
|
||||||
|
let c99 = card_back_colour(99);
|
||||||
|
assert_eq!(c4, c5, "out-of-range indices must share the fallback colour");
|
||||||
|
assert_eq!(c4, c99, "index 99 must share the fallback colour");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Task #34 pure-function / phase-transition tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flip_phase_scaling_down_starts_at_one() {
|
||||||
|
// A brand-new flip anim in ScalingDown at timer=0 should produce scale 1.0
|
||||||
|
// (no time has elapsed yet).
|
||||||
|
let t = 0.0_f32 / FLIP_HALF_SECS;
|
||||||
|
let scale_x = 1.0 - t.min(1.0);
|
||||||
|
assert!((scale_x - 1.0).abs() < 1e-6, "scale_x at timer=0 must be 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flip_phase_scaling_down_reaches_zero_at_half_secs() {
|
||||||
|
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
|
||||||
|
let scale_x = 1.0 - t;
|
||||||
|
assert!(scale_x.abs() < 1e-6, "scale_x must reach 0.0 after one half-period");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flip_phase_scaling_up_starts_at_zero() {
|
||||||
|
let t = 0.0_f32 / FLIP_HALF_SECS;
|
||||||
|
let scale_x = t.min(1.0);
|
||||||
|
assert!(scale_x.abs() < 1e-6, "scale_x at start of ScalingUp must be 0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flip_phase_scaling_up_reaches_one_at_half_secs() {
|
||||||
|
let t = (FLIP_HALF_SECS / FLIP_HALF_SECS).min(1.0);
|
||||||
|
let scale_x = t;
|
||||||
|
assert!((scale_x - 1.0).abs() < 1e-6, "scale_x must reach 1.0 after second half-period");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flip_phase_enum_equality() {
|
||||||
|
assert_eq!(FlipPhase::ScalingDown, FlipPhase::ScalingDown);
|
||||||
|
assert_eq!(FlipPhase::ScalingUp, FlipPhase::ScalingUp);
|
||||||
|
assert_ne!(FlipPhase::ScalingDown, FlipPhase::ScalingUp);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! 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::{PrimaryWindow, SystemCursorIcon};
|
||||||
|
use bevy::winit::cursor::CursorIcon;
|
||||||
|
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);
|
||||||
|
|
||||||
|
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.get_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.get_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::*;
|
||||||
|
use solitaire_core::card::{Card, Rank};
|
||||||
|
|
||||||
|
fn face_up(suit: Suit, rank: Rank) -> Card {
|
||||||
|
Card { id: 0, suit, rank, face_up: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Persistent in-game HUD: score, move count, elapsed time, and mode badge.
|
//! Persistent in-game HUD: score, move count, elapsed time, mode badge,
|
||||||
|
//! daily-challenge constraint, and undo count.
|
||||||
//!
|
//!
|
||||||
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
|
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
|
||||||
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
||||||
@@ -8,25 +9,40 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
|
||||||
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
|
|
||||||
/// Marker on the score text node.
|
/// Marker on the score text node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct HudScore;
|
pub struct HudScore;
|
||||||
|
|
||||||
/// Marker on the move-count text node.
|
/// Marker on the move-count text node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct HudMoves;
|
pub struct HudMoves;
|
||||||
|
|
||||||
/// Marker on the elapsed-time text node.
|
/// Marker on the elapsed-time text node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct HudTime;
|
pub struct HudTime;
|
||||||
|
|
||||||
/// Marker on the mode badge text node.
|
/// Marker on the mode badge text node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct HudMode;
|
pub struct HudMode;
|
||||||
|
|
||||||
|
/// Marker on the daily-challenge constraint text node.
|
||||||
|
///
|
||||||
|
/// Displays the active goal (time limit or score target) when a daily challenge
|
||||||
|
/// is in progress. Empty string when no challenge is active or the game is won.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudChallenge;
|
||||||
|
|
||||||
|
/// Marker on the undo-count text node.
|
||||||
|
///
|
||||||
|
/// Shows how many undos have been used this game. Displayed in amber when
|
||||||
|
/// `undo_count > 0` because using undo blocks the no-undo achievement.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudUndos;
|
||||||
|
|
||||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||||
const Z_HUD: i32 = 50;
|
const Z_HUD: i32 = 50;
|
||||||
@@ -59,28 +75,114 @@ fn spawn_hud(mut commands: Commands) {
|
|||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
|
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
|
||||||
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
|
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
|
||||||
b.spawn((HudTime, Text::new("0:00"), font, white));
|
b.spawn((HudTime, Text::new("0:00"), font.clone(), white));
|
||||||
b.spawn((
|
b.spawn((
|
||||||
HudMode,
|
HudMode,
|
||||||
Text::new(""),
|
Text::new(""),
|
||||||
TextFont { font_size: 17.0, ..default() },
|
TextFont { font_size: 17.0, ..default() },
|
||||||
TextColor(Color::srgb(1.0, 0.85, 0.25)),
|
TextColor(Color::srgb(1.0, 0.85, 0.25)),
|
||||||
));
|
));
|
||||||
|
// Daily-challenge constraint (hidden until a challenge is active).
|
||||||
|
b.spawn((
|
||||||
|
HudChallenge,
|
||||||
|
Text::new(""),
|
||||||
|
TextFont { font_size: 17.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.4, 0.9, 1.0)),
|
||||||
|
));
|
||||||
|
// Undo counter (white by default; turns amber when undos are used).
|
||||||
|
b.spawn((
|
||||||
|
HudUndos,
|
||||||
|
Text::new(""),
|
||||||
|
font,
|
||||||
|
white,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
|
||||||
|
///
|
||||||
|
/// For example `format_time_limit(300)` returns `"5:00"`.
|
||||||
|
pub fn format_time_limit(secs: u64) -> String {
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
fn update_hud(
|
fn update_hud(
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
time_attack: Option<Res<TimeAttackResource>>,
|
time_attack: Option<Res<TimeAttackResource>>,
|
||||||
mut score_q: Query<&mut Text, (With<HudScore>, Without<HudMoves>, Without<HudTime>, Without<HudMode>)>,
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
mut moves_q: Query<&mut Text, (With<HudMoves>, Without<HudScore>, Without<HudTime>, Without<HudMode>)>,
|
mut score_q: Query<
|
||||||
mut time_q: Query<&mut Text, (With<HudTime>, Without<HudScore>, Without<HudMoves>, Without<HudMode>)>,
|
&mut Text,
|
||||||
mut mode_q: Query<&mut Text, (With<HudMode>, Without<HudScore>, Without<HudMoves>, Without<HudTime>)>,
|
(
|
||||||
|
With<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut moves_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMoves>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut time_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudTime>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut mode_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMode>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut challenge_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudChallenge>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut undos_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudUndos>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
||||||
|
|
||||||
// Score, moves, and mode only need updating when the game state changes.
|
// Score, moves, mode, challenge, and undos only need updating when game state changes.
|
||||||
if game.is_changed() {
|
if game.is_changed() {
|
||||||
let g = &game.0;
|
let g = &game.0;
|
||||||
let is_zen = g.mode == GameMode::Zen;
|
let is_zen = g.mode == GameMode::Zen;
|
||||||
@@ -106,6 +208,31 @@ fn update_hud(
|
|||||||
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Daily challenge constraint ---
|
||||||
|
if let Ok((mut t, _)) = challenge_q.get_single_mut() {
|
||||||
|
**t = if g.is_won {
|
||||||
|
// Hide constraint once the game is over.
|
||||||
|
String::new()
|
||||||
|
} else if let Some(dc) = daily.as_deref() {
|
||||||
|
challenge_hud_text(dc)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Undo count ---
|
||||||
|
if let Ok((mut t, mut color)) = undos_q.get_single_mut() {
|
||||||
|
let count = g.undo_count;
|
||||||
|
if count == 0 {
|
||||||
|
**t = String::new();
|
||||||
|
*color = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
|
||||||
|
} else {
|
||||||
|
**t = format!("Undos: {count}");
|
||||||
|
// Amber warning: using undo blocks the no-undo achievement.
|
||||||
|
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time display: show Time Attack countdown every frame when active;
|
// Time display: show Time Attack countdown every frame when active;
|
||||||
@@ -135,11 +262,27 @@ fn update_hud(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the HUD text for the active daily challenge constraints.
|
||||||
|
///
|
||||||
|
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
|
||||||
|
/// score target is set, or an empty string when the challenge has no extra
|
||||||
|
/// constraints.
|
||||||
|
fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||||
|
if let Some(secs) = dc.max_time_secs {
|
||||||
|
format!("Limit: {}", format_time_limit(secs))
|
||||||
|
} else if let Some(score) = dc.target_score {
|
||||||
|
format!("Goal: {score} pts")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use chrono::Local;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
@@ -220,4 +363,141 @@ mod tests {
|
|||||||
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
// 125 seconds = 2 minutes 5 seconds → "2:05"
|
||||||
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// format_time_limit (pure function)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_time_limit_300_is_5_00() {
|
||||||
|
assert_eq!(format_time_limit(300), "5:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_time_limit_zero() {
|
||||||
|
assert_eq!(format_time_limit(0), "0:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_time_limit_pads_seconds() {
|
||||||
|
assert_eq!(format_time_limit(65), "1:05");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// challenge_hud_text (pure function)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_text_shows_time_limit() {
|
||||||
|
let dc = DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 1,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(300),
|
||||||
|
};
|
||||||
|
assert_eq!(challenge_hud_text(&dc), "Limit: 5:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_text_shows_score_goal() {
|
||||||
|
let dc = DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 1,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: Some(4000),
|
||||||
|
max_time_secs: None,
|
||||||
|
};
|
||||||
|
assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_text_empty_when_no_constraints() {
|
||||||
|
let dc = DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 1,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: None,
|
||||||
|
};
|
||||||
|
assert_eq!(challenge_hud_text(&dc), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HudChallenge in-app tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_empty_when_no_daily_resource() {
|
||||||
|
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().insert_resource(DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 42,
|
||||||
|
goal_description: Some("Win fast".to_string()),
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(300),
|
||||||
|
});
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 1; // force change
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().insert_resource(DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 42,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: Some(4000),
|
||||||
|
max_time_secs: None,
|
||||||
|
});
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 1;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_hud_clears_on_win() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().insert_resource(DailyChallengeResource {
|
||||||
|
date: Local::now().date_naive(),
|
||||||
|
seed: 42,
|
||||||
|
goal_description: None,
|
||||||
|
target_score: None,
|
||||||
|
max_time_secs: Some(300),
|
||||||
|
});
|
||||||
|
// Mark the game as won — HudChallenge should be empty.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.is_won = true;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HudUndos in-app tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undos_hud_empty_at_game_start() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudUndos>(&mut app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undos_hud_shows_count_after_undo() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.undo_count = 3;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod auto_complete_plugin;
|
|||||||
pub mod audio_plugin;
|
pub mod audio_plugin;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
|
pub mod cursor_plugin;
|
||||||
pub mod daily_challenge_plugin;
|
pub mod daily_challenge_plugin;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
@@ -24,6 +25,7 @@ pub mod sync_plugin;
|
|||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod weekly_goals_plugin;
|
pub mod weekly_goals_plugin;
|
||||||
|
pub mod win_summary_plugin;
|
||||||
|
|
||||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||||
pub use challenge_plugin::{
|
pub use challenge_plugin::{
|
||||||
@@ -37,11 +39,12 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
|||||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||||
pub use auto_complete_plugin::AutoCompletePlugin;
|
pub use auto_complete_plugin::AutoCompletePlugin;
|
||||||
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
|
||||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin, HintHighlight, RightClickHighlight};
|
||||||
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use events::{
|
pub use events::{
|
||||||
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent,
|
AchievementUnlockedEvent, CardFlippedEvent, DrawRequestEvent, ForfeitEvent, GameWonEvent,
|
||||||
ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
|
InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
NewGameConfirmEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
|
pub use game_plugin::{GameMutation, GamePlugin, GameStatePath};
|
||||||
pub use help_plugin::{HelpPlugin, HelpScreen};
|
pub use help_plugin::{HelpPlugin, HelpScreen};
|
||||||
@@ -61,3 +64,6 @@ pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
|||||||
pub use time_attack_plugin::{
|
pub use time_attack_plugin::{
|
||||||
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS,
|
||||||
};
|
};
|
||||||
|
pub use win_summary_plugin::{
|
||||||
|
format_win_time, ScreenShakeResource, WinSummaryPending, WinSummaryPlugin,
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ use bevy::prelude::*;
|
|||||||
use solitaire_data::save_game_state_to;
|
use solitaire_data::save_game_state_to;
|
||||||
|
|
||||||
use crate::game_plugin::GameStatePath;
|
use crate::game_plugin::GameStatePath;
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::stats_plugin::StatsResource;
|
||||||
|
|
||||||
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Default)]
|
||||||
@@ -33,6 +35,7 @@ impl Plugin for PausePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn toggle_pause(
|
fn toggle_pause(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
@@ -40,6 +43,8 @@ fn toggle_pause(
|
|||||||
screens: Query<Entity, With<PauseScreen>>,
|
screens: Query<Entity, With<PauseScreen>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
path: Option<Res<GameStatePath>>,
|
path: Option<Res<GameStatePath>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
stats: Option<Res<StatsResource>>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::Escape) {
|
if !keys.just_pressed(KeyCode::Escape) {
|
||||||
return;
|
return;
|
||||||
@@ -48,7 +53,10 @@ fn toggle_pause(
|
|||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn_recursive();
|
||||||
paused.0 = false;
|
paused.0 = false;
|
||||||
} else {
|
} else {
|
||||||
spawn_pause_screen(&mut commands);
|
// Snapshot current level and streak at pause time.
|
||||||
|
let level = progress.as_deref().map(|p| p.0.level);
|
||||||
|
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
||||||
|
spawn_pause_screen(&mut commands, level, streak);
|
||||||
paused.0 = true;
|
paused.0 = true;
|
||||||
// Persist the current game state whenever the player opens the pause
|
// Persist the current game state whenever the player opens the pause
|
||||||
// overlay so an OS-level kill still leaves a resumable save.
|
// overlay so an OS-level kill still leaves a resumable save.
|
||||||
@@ -62,7 +70,12 @@ fn toggle_pause(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_pause_screen(commands: &mut Commands) {
|
/// Spawns the full-screen pause overlay.
|
||||||
|
///
|
||||||
|
/// `level` and `streak` are optional snapshots taken at pause time. When
|
||||||
|
/// `ProgressResource` or `StatsResource` is not installed (e.g. in headless
|
||||||
|
/// tests), those lines are omitted from the overlay.
|
||||||
|
fn spawn_pause_screen(commands: &mut Commands, level: Option<u32>, streak: Option<u32>) {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
PauseScreen,
|
PauseScreen,
|
||||||
@@ -90,6 +103,18 @@ fn spawn_pause_screen(commands: &mut Commands) {
|
|||||||
},
|
},
|
||||||
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||||
));
|
));
|
||||||
|
// Level and streak line — only shown when the resources are present.
|
||||||
|
if level.is_some() || streak.is_some() {
|
||||||
|
let info = build_level_streak_line(level, streak);
|
||||||
|
b.spawn((
|
||||||
|
Text::new(info),
|
||||||
|
TextFont {
|
||||||
|
font_size: 22.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(Color::srgb(0.75, 0.95, 0.75)),
|
||||||
|
));
|
||||||
|
}
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new("Press Esc to resume"),
|
Text::new("Press Esc to resume"),
|
||||||
TextFont {
|
TextFont {
|
||||||
@@ -101,6 +126,19 @@ fn spawn_pause_screen(commands: &mut Commands) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats the level / win-streak summary line for the pause overlay.
|
||||||
|
///
|
||||||
|
/// Both values are optional because either resource may be absent in
|
||||||
|
/// headless or partially-configured app contexts.
|
||||||
|
fn build_level_streak_line(level: Option<u32>, streak: Option<u32>) -> String {
|
||||||
|
match (level, streak) {
|
||||||
|
(Some(l), Some(s)) => format!("Level {l} Win streak: {s}"),
|
||||||
|
(Some(l), None) => format!("Level {l}"),
|
||||||
|
(None, Some(s)) => format!("Win streak: {s}"),
|
||||||
|
(None, None) => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -175,4 +213,74 @@ mod tests {
|
|||||||
"third Esc must re-spawn PauseScreen"
|
"third Esc must re-spawn PauseScreen"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// build_level_streak_line (pure function)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_streak_both_present() {
|
||||||
|
assert_eq!(
|
||||||
|
build_level_streak_line(Some(7), Some(3)),
|
||||||
|
"Level 7 Win streak: 3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_streak_only_level() {
|
||||||
|
assert_eq!(build_level_streak_line(Some(5), None), "Level 5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_streak_only_streak() {
|
||||||
|
assert_eq!(build_level_streak_line(None, Some(4)), "Win streak: 4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_streak_neither() {
|
||||||
|
assert_eq!(build_level_streak_line(None, None), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Pause screen with progress / stats resources present
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_screen_spawns_with_level_and_streak_when_resources_present() {
|
||||||
|
use crate::progress_plugin::{ProgressPlugin, ProgressResource};
|
||||||
|
use crate::stats_plugin::{StatsPlugin, StatsResource};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(crate::game_plugin::GamePlugin)
|
||||||
|
.add_plugins(crate::table_plugin::TablePlugin)
|
||||||
|
.add_plugins(ProgressPlugin::headless())
|
||||||
|
.add_plugins(StatsPlugin::headless())
|
||||||
|
.add_plugins(PausePlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Set known values.
|
||||||
|
app.world_mut().resource_mut::<ProgressResource>().0.level = 7;
|
||||||
|
app.world_mut().resource_mut::<StatsResource>().0.win_streak_current = 3;
|
||||||
|
|
||||||
|
press_esc(&mut app);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Verify the screen was spawned.
|
||||||
|
assert!(app.world().resource::<PausedResource>().0);
|
||||||
|
|
||||||
|
// Find the text nodes on the PauseScreen children and check one contains
|
||||||
|
// the expected level/streak string.
|
||||||
|
let texts: Vec<String> = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&Text>()
|
||||||
|
.iter(app.world())
|
||||||
|
.map(|t| t.0.clone())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
texts.iter().any(|t| t == "Level 7 Win streak: 3"),
|
||||||
|
"expected level/streak line in pause screen texts, got: {texts:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
||||||
@@ -74,6 +75,14 @@ struct AnimSpeedText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct BackgroundText;
|
struct BackgroundText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current color-blind mode state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ColorBlindText;
|
||||||
|
|
||||||
|
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SettingsPanelScrollable;
|
||||||
|
|
||||||
/// Tags interactive buttons inside the Settings panel.
|
/// Tags interactive buttons inside the Settings panel.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
enum SettingsButton {
|
enum SettingsButton {
|
||||||
@@ -86,6 +95,7 @@ enum SettingsButton {
|
|||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
CycleCardBack,
|
CycleCardBack,
|
||||||
CycleBackground,
|
CycleBackground,
|
||||||
|
ToggleColorBlind,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
Done,
|
Done,
|
||||||
}
|
}
|
||||||
@@ -129,7 +139,8 @@ impl Plugin for SettingsPlugin {
|
|||||||
.init_resource::<SettingsScreen>()
|
.init_resource::<SettingsScreen>()
|
||||||
.add_event::<SettingsChangedEvent>()
|
.add_event::<SettingsChangedEvent>()
|
||||||
.add_event::<ManualSyncRequestEvent>()
|
.add_event::<ManualSyncRequestEvent>()
|
||||||
.add_systems(Update, (handle_volume_keys, toggle_settings_screen));
|
.add_event::<bevy::input::mouse::MouseWheel>()
|
||||||
|
.add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel));
|
||||||
|
|
||||||
if self.ui_enabled {
|
if self.ui_enabled {
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
@@ -141,6 +152,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_card_back_text,
|
update_card_back_text,
|
||||||
update_background_text,
|
update_background_text,
|
||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
|
update_color_blind_text,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -301,6 +313,18 @@ fn update_anim_speed_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_color_blind_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = color_blind_label(settings.0.color_blind_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn card_back_label(idx: usize) -> String {
|
fn card_back_label(idx: usize) -> String {
|
||||||
if idx == 0 {
|
if idx == 0 {
|
||||||
"Default".to_string()
|
"Default".to_string()
|
||||||
@@ -346,11 +370,12 @@ fn handle_settings_buttons(
|
|||||||
mut changed: EventWriter<SettingsChangedEvent>,
|
mut changed: EventWriter<SettingsChangedEvent>,
|
||||||
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
|
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||||
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||||
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||||
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>)>,
|
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||||||
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
|
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<ColorBlindText>)>,
|
||||||
|
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||||||
) {
|
) {
|
||||||
for (interaction, button) in &interaction_query {
|
for (interaction, button) in &interaction_query {
|
||||||
if *interaction != Interaction::Pressed {
|
if *interaction != Interaction::Pressed {
|
||||||
@@ -456,6 +481,14 @@ fn handle_settings_buttons(
|
|||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.send(SettingsChangedEvent(settings.0.clone()));
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
}
|
}
|
||||||
|
SettingsButton::ToggleColorBlind => {
|
||||||
|
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.send(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = color_blind_text.get_single_mut() {
|
||||||
|
**t = color_blind_label(settings.0.color_blind_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsButton::SyncNow => {
|
SettingsButton::SyncNow => {
|
||||||
manual_sync.send(ManualSyncRequestEvent);
|
manual_sync.send(ManualSyncRequestEvent);
|
||||||
}
|
}
|
||||||
@@ -489,6 +522,39 @@ fn theme_label(theme: &Theme) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn color_blind_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||||
|
///
|
||||||
|
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||||
|
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||||||
|
/// scrolls past the top.
|
||||||
|
fn scroll_settings_panel(
|
||||||
|
mut scroll_evr: EventReader<MouseWheel>,
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||||||
|
) {
|
||||||
|
if !screen.0 {
|
||||||
|
scroll_evr.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta_y: f32 = scroll_evr
|
||||||
|
.read()
|
||||||
|
.map(|ev| match ev.unit {
|
||||||
|
MouseScrollUnit::Line => ev.y * 50.0,
|
||||||
|
MouseScrollUnit::Pixel => ev.y,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if delta_y == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut sp in scrollables.iter_mut() {
|
||||||
|
sp.offset_y = (sp.offset_y - delta_y).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -518,15 +584,18 @@ fn spawn_settings_panel(
|
|||||||
ZIndex(200),
|
ZIndex(200),
|
||||||
))
|
))
|
||||||
.with_children(|root| {
|
.with_children(|root| {
|
||||||
// Inner card — max_height + clip_y keeps it on-screen on small windows.
|
// Inner card — max_height + scroll_y lets the player reach all rows
|
||||||
|
// on small windows by scrolling with the mouse wheel.
|
||||||
root.spawn((
|
root.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
ScrollPosition::default(),
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
padding: UiRect::all(Val::Px(28.0)),
|
padding: UiRect::all(Val::Px(28.0)),
|
||||||
row_gap: Val::Px(14.0),
|
row_gap: Val::Px(14.0),
|
||||||
min_width: Val::Px(340.0),
|
min_width: Val::Px(340.0),
|
||||||
max_height: Val::Percent(88.0),
|
max_height: Val::Percent(88.0),
|
||||||
overflow: Overflow::clip_y(),
|
overflow: Overflow::scroll_y(),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
|
||||||
@@ -626,6 +695,28 @@ fn spawn_settings_panel(
|
|||||||
icon_button(row, "⇄", SettingsButton::ToggleTheme);
|
icon_button(row, "⇄", SettingsButton::ToggleTheme);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Color-blind mode row
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: Val::Px(8.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Color-blind Mode"),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.85, 0.85, 0.80)),
|
||||||
|
));
|
||||||
|
row.spawn((
|
||||||
|
ColorBlindText,
|
||||||
|
Text::new(color_blind_label(settings.color_blind_mode)),
|
||||||
|
TextFont { font_size: 18.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
icon_button(row, "⇄", SettingsButton::ToggleColorBlind);
|
||||||
|
});
|
||||||
|
|
||||||
// Card back row — only shown when the player has unlocked more than one.
|
// Card back row — only shown when the player has unlocked more than one.
|
||||||
if unlocked_card_backs.len() > 1 {
|
if unlocked_card_backs.len() > 1 {
|
||||||
card.spawn(Node {
|
card.spawn(Node {
|
||||||
@@ -942,4 +1033,92 @@ mod tests {
|
|||||||
fn cycle_unlocked_empty_returns_zero() {
|
fn cycle_unlocked_empty_returns_zero() {
|
||||||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_is_noop_when_settings_panel_closed() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||||||
|
.id();
|
||||||
|
// Send a downward scroll event while the panel is closed.
|
||||||
|
app.world_mut().send_event(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: -3.0,
|
||||||
|
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
// ScrollPosition must remain at 0.0 — panel was closed.
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.offset_y;
|
||||||
|
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_moves_offset_when_panel_open() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Open the panel.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
ScrollPosition { offset_y: 100.0, ..default() },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||||||
|
app.world_mut().send_event(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: -2.0,
|
||||||
|
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.offset_y;
|
||||||
|
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_clamps_offset_to_zero_at_top() {
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
// Entity starts at 10 px offset.
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
SettingsPanelScrollable,
|
||||||
|
ScrollPosition { offset_y: 10.0, ..default() },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||||||
|
app.world_mut().send_event(MouseWheel {
|
||||||
|
unit: MouseScrollUnit::Line,
|
||||||
|
x: 0.0,
|
||||||
|
y: 5.0,
|
||||||
|
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
let offset = app
|
||||||
|
.world()
|
||||||
|
.entity(entity)
|
||||||
|
.get::<ScrollPosition>()
|
||||||
|
.unwrap()
|
||||||
|
.offset_y;
|
||||||
|
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use solitaire_data::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::challenge_plugin::challenge_progress_label;
|
use crate::challenge_plugin::challenge_progress_label;
|
||||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
use crate::events::{ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -72,6 +72,8 @@ impl Plugin for StatsPlugin {
|
|||||||
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
.add_event::<NewGameRequestEvent>()
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
.add_event::<ForfeitEvent>()
|
||||||
|
.add_event::<InfoToastEvent>()
|
||||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||||
// clobbers it with a fresh game.
|
// clobbers it with a fresh game.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -84,6 +86,10 @@ impl Plugin for StatsPlugin {
|
|||||||
Update,
|
Update,
|
||||||
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
|
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
|
||||||
)
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_forfeit.before(GameMutation).in_set(StatsUpdate),
|
||||||
|
)
|
||||||
.add_systems(Update, toggle_stats_screen.after(GameMutation));
|
.add_systems(Update, toggle_stats_screen.after(GameMutation));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +131,26 @@ fn update_stats_on_new_game(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When the player presses G to forfeit, record the game as abandoned, save
|
||||||
|
/// stats, fire an informational toast, and start a new game.
|
||||||
|
fn handle_forfeit(
|
||||||
|
mut events: EventReader<ForfeitEvent>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut stats: ResMut<StatsResource>,
|
||||||
|
path: Res<StatsStoragePath>,
|
||||||
|
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||||
|
mut toast: EventWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
for _ in events.read() {
|
||||||
|
if game.0.move_count > 0 && !game.0.is_won {
|
||||||
|
stats.0.record_abandoned();
|
||||||
|
persist(&path, &stats.0, "forfeit");
|
||||||
|
}
|
||||||
|
toast.send(InfoToastEvent("Game forfeited".to_string()));
|
||||||
|
new_game.send(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle_stats_screen(
|
fn toggle_stats_screen(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
@@ -361,6 +387,25 @@ mod tests {
|
|||||||
assert_eq!(stats.games_played, 1);
|
assert_eq!(stats.games_played, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_win_increments_draw_three_wins_only() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<crate::resources::GameStateResource>()
|
||||||
|
.0
|
||||||
|
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
||||||
|
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 200,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let stats = &app.world().resource::<StatsResource>().0;
|
||||||
|
assert_eq!(stats.draw_three_wins, 1, "draw_three_wins must increment for DrawThree mode");
|
||||||
|
assert_eq!(stats.draw_one_wins, 0, "draw_one_wins must not increment for DrawThree mode");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_game_after_moves_records_abandoned() {
|
fn new_game_after_moves_records_abandoned() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|||||||
@@ -124,9 +124,22 @@ fn apply_theme_on_settings_change(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the single-letter suit symbol used on empty foundation markers.
|
||||||
|
///
|
||||||
|
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
|
||||||
|
pub fn suit_symbol(suit: &Suit) -> &'static str {
|
||||||
|
match suit {
|
||||||
|
Suit::Spades => "S",
|
||||||
|
Suit::Hearts => "H",
|
||||||
|
Suit::Diamonds => "D",
|
||||||
|
Suit::Clubs => "C",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
||||||
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
let marker_colour = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||||
let marker_size = layout.card_size;
|
let marker_size = layout.card_size;
|
||||||
|
let font_size = layout.card_size.x * 0.28;
|
||||||
|
|
||||||
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
let mut piles: Vec<PileType> = Vec::with_capacity(13);
|
||||||
piles.push(PileType::Stock);
|
piles.push(PileType::Stock);
|
||||||
@@ -140,15 +153,40 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
|
|||||||
|
|
||||||
for pile in piles {
|
for pile in piles {
|
||||||
let pos = layout.pile_positions[&pile];
|
let pos = layout.pile_positions[&pile];
|
||||||
commands.spawn((
|
let mut entity = commands.spawn((
|
||||||
Sprite {
|
Sprite {
|
||||||
color: marker_colour,
|
color: marker_colour,
|
||||||
custom_size: Some(marker_size),
|
custom_size: Some(marker_size),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
||||||
PileMarker(pile),
|
PileMarker(pile.clone()),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Task #35 — suit symbol on empty foundation placeholders.
|
||||||
|
if let PileType::Foundation(suit) = &pile {
|
||||||
|
let symbol = suit_symbol(suit).to_string();
|
||||||
|
entity.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text2d::new(symbol),
|
||||||
|
TextFont { font_size, ..default() },
|
||||||
|
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.45)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task #43 — King indicator on empty tableau placeholders.
|
||||||
|
if let PileType::Tableau(_) = &pile {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text2d::new("K"),
|
||||||
|
TextFont { font_size, ..default() },
|
||||||
|
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.35)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,4 +329,26 @@ mod tests {
|
|||||||
assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
|
assert_eq!(c4, c5, "indices 4 and 5 must share the charcoal fallback");
|
||||||
assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
|
assert_eq!(c4, c99, "index 99 must share the charcoal fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// suit_symbol pure-function tests (Task #35)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suit_symbol_returns_correct_letters() {
|
||||||
|
assert_eq!(suit_symbol(&Suit::Spades), "S");
|
||||||
|
assert_eq!(suit_symbol(&Suit::Hearts), "H");
|
||||||
|
assert_eq!(suit_symbol(&Suit::Diamonds), "D");
|
||||||
|
assert_eq!(suit_symbol(&Suit::Clubs), "C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suit_symbol_all_four_are_distinct() {
|
||||||
|
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs]
|
||||||
|
.iter()
|
||||||
|
.map(suit_symbol)
|
||||||
|
.collect();
|
||||||
|
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
||||||
|
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
//! Win summary modal overlay and screen-shake effect.
|
||||||
|
//!
|
||||||
|
//! # Task #33 — Win summary screen
|
||||||
|
//! On `GameWonEvent`, after a 0.5 s delay (so the cascade animation has
|
||||||
|
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
||||||
|
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
||||||
|
//!
|
||||||
|
//! # Task #47 — Win fanfare screen-shake
|
||||||
|
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
||||||
|
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
||||||
|
//! shake duration elapses.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::events::{GameWonEvent, NewGameRequestEvent, XpAwardedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Delay after `GameWonEvent` before the win-summary modal is spawned.
|
||||||
|
/// Chosen so the cascade animation has a moment to start first.
|
||||||
|
const WIN_SUMMARY_DELAY_SECS: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Duration of the screen-shake in seconds.
|
||||||
|
const SHAKE_DURATION_SECS: f32 = 0.6;
|
||||||
|
/// Maximum camera displacement in world-space pixels at the start of the shake.
|
||||||
|
const SHAKE_INTENSITY: f32 = 8.0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Accumulates win data while waiting for `XpAwardedEvent` to arrive.
|
||||||
|
///
|
||||||
|
/// The XP event fires shortly after `GameWonEvent`. We store both pieces of
|
||||||
|
/// data here so the modal can show the complete picture.
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct WinSummaryPending {
|
||||||
|
/// Score from the most recent `GameWonEvent`.
|
||||||
|
pub score: i32,
|
||||||
|
/// Elapsed game time (seconds) from the most recent `GameWonEvent`.
|
||||||
|
pub time_seconds: u64,
|
||||||
|
/// XP awarded from the most recent `XpAwardedEvent` (0 until that event fires).
|
||||||
|
pub xp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the camera shake effect after a win.
|
||||||
|
///
|
||||||
|
/// While `remaining > 0` a system applies a decaying sinusoidal offset to the
|
||||||
|
/// main camera's `Transform`. The system resets the camera to the origin when
|
||||||
|
/// `remaining` reaches zero.
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct ScreenShakeResource {
|
||||||
|
/// Seconds of shake remaining.
|
||||||
|
pub remaining: f32,
|
||||||
|
/// Peak displacement in world-space pixels (decays to zero over `remaining`).
|
||||||
|
pub intensity: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on the win-summary modal root entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct WinSummaryOverlay;
|
||||||
|
|
||||||
|
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
enum WinSummaryButton {
|
||||||
|
PlayAgain,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registers the win-summary modal and screen-shake systems.
|
||||||
|
pub struct WinSummaryPlugin;
|
||||||
|
|
||||||
|
impl Plugin for WinSummaryPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<WinSummaryPending>()
|
||||||
|
.init_resource::<ScreenShakeResource>()
|
||||||
|
.add_event::<GameWonEvent>()
|
||||||
|
.add_event::<XpAwardedEvent>()
|
||||||
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
cache_win_data,
|
||||||
|
spawn_win_summary_after_delay,
|
||||||
|
handle_win_summary_buttons,
|
||||||
|
apply_screen_shake,
|
||||||
|
)
|
||||||
|
.after(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Formats `seconds` as `m:ss`.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use solitaire_engine::win_summary_plugin::format_win_time;
|
||||||
|
/// assert_eq!(format_win_time(0), "0:00");
|
||||||
|
/// assert_eq!(format_win_time(65), "1:05");
|
||||||
|
/// assert_eq!(format_win_time(3661), "61:01");
|
||||||
|
/// ```
|
||||||
|
pub fn format_win_time(seconds: u64) -> String {
|
||||||
|
let m = seconds / 60;
|
||||||
|
let s = seconds % 60;
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Caches score/time from `GameWonEvent` and XP from `XpAwardedEvent` into
|
||||||
|
/// `WinSummaryPending` so they are available when the modal spawns.
|
||||||
|
fn cache_win_data(
|
||||||
|
mut won: EventReader<GameWonEvent>,
|
||||||
|
mut xp: EventReader<XpAwardedEvent>,
|
||||||
|
mut pending: ResMut<WinSummaryPending>,
|
||||||
|
) {
|
||||||
|
for ev in won.read() {
|
||||||
|
pending.score = ev.score;
|
||||||
|
pending.time_seconds = ev.time_seconds;
|
||||||
|
pending.xp = 0; // reset; XP event follows
|
||||||
|
}
|
||||||
|
for ev in xp.read() {
|
||||||
|
pending.xp = ev.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After `GameWonEvent`, arms the screen-shake resource.
|
||||||
|
///
|
||||||
|
/// This system shares the `GameWonEvent` stream with `cache_win_data` through
|
||||||
|
/// the delay timer stored in `Local` — the shake fires immediately, while the
|
||||||
|
/// modal waits 0.5 s.
|
||||||
|
fn spawn_win_summary_after_delay(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut won: EventReader<GameWonEvent>,
|
||||||
|
mut shake: ResMut<ScreenShakeResource>,
|
||||||
|
pending: Res<WinSummaryPending>,
|
||||||
|
time: Res<Time>,
|
||||||
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
|
mut delay: Local<Option<f32>>,
|
||||||
|
) {
|
||||||
|
// Process new win events.
|
||||||
|
for _ in won.read() {
|
||||||
|
// Arm the screen shake immediately.
|
||||||
|
shake.remaining = SHAKE_DURATION_SECS;
|
||||||
|
shake.intensity = SHAKE_INTENSITY;
|
||||||
|
// Start the delay timer (overwrite if a second win arrives).
|
||||||
|
*delay = Some(WIN_SUMMARY_DELAY_SECS);
|
||||||
|
// Clear any stale overlay from a previous win.
|
||||||
|
for entity in &overlays {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick the delay timer.
|
||||||
|
if let Some(remaining) = delay.as_mut() {
|
||||||
|
*remaining -= time.delta_secs();
|
||||||
|
if *remaining <= 0.0 {
|
||||||
|
*delay = None;
|
||||||
|
// Only spawn if there is no overlay already.
|
||||||
|
if overlays.is_empty() {
|
||||||
|
spawn_overlay(&mut commands, &pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the win-summary modal and fires `NewGameRequestEvent` when
|
||||||
|
/// the player presses "Play Again".
|
||||||
|
fn handle_win_summary_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
||||||
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
WinSummaryButton::PlayAgain => {
|
||||||
|
// Despawn the modal.
|
||||||
|
for entity in &overlays {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
}
|
||||||
|
new_game.send(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a decaying sinusoidal offset to the main `Camera2d` each frame
|
||||||
|
/// while `ScreenShakeResource::remaining > 0`.
|
||||||
|
///
|
||||||
|
/// Uses a deterministic oscillation (`sin`/`cos` of total elapsed time) to
|
||||||
|
/// avoid a dependency on a random-number crate in this crate.
|
||||||
|
fn apply_screen_shake(
|
||||||
|
mut shake: ResMut<ScreenShakeResource>,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut cameras: Query<&mut Transform, With<Camera2d>>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
if shake.remaining <= 0.0 {
|
||||||
|
// Ensure the camera is back at origin whenever shake is idle.
|
||||||
|
for mut t in &mut cameras {
|
||||||
|
t.translation.x = 0.0;
|
||||||
|
t.translation.y = 0.0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shake.remaining = (shake.remaining - dt).max(0.0);
|
||||||
|
// Decay factor: 1.0 at start, 0.0 at end.
|
||||||
|
let decay = shake.remaining / SHAKE_DURATION_SECS;
|
||||||
|
let elapsed = time.elapsed_secs();
|
||||||
|
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
|
||||||
|
let offset_y = (elapsed * 31.0).cos() * shake.intensity * decay;
|
||||||
|
|
||||||
|
for mut t in &mut cameras {
|
||||||
|
t.translation.x = offset_x;
|
||||||
|
t.translation.y = offset_y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI construction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn spawn_overlay(commands: &mut Commands, pending: &WinSummaryPending) {
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
WinSummaryOverlay,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Percent(0.0),
|
||||||
|
top: Val::Percent(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.70)),
|
||||||
|
ZIndex(300),
|
||||||
|
))
|
||||||
|
.with_children(|root| {
|
||||||
|
root.spawn((
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
padding: UiRect::all(Val::Px(36.0)),
|
||||||
|
row_gap: Val::Px(18.0),
|
||||||
|
min_width: Val::Px(320.0),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.10, 0.12, 0.10)),
|
||||||
|
BorderRadius::all(Val::Px(12.0)),
|
||||||
|
))
|
||||||
|
.with_children(|card| {
|
||||||
|
// Heading
|
||||||
|
card.spawn((
|
||||||
|
Text::new("You Won!"),
|
||||||
|
TextFont { font_size: 42.0, ..default() },
|
||||||
|
TextColor(Color::srgb(1.0, 0.87, 0.0)),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Score
|
||||||
|
card.spawn((
|
||||||
|
Text::new(format!("Score: {}", pending.score)),
|
||||||
|
TextFont { font_size: 26.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Time
|
||||||
|
card.spawn((
|
||||||
|
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||||
|
TextFont { font_size: 26.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
|
||||||
|
// XP
|
||||||
|
card.spawn((
|
||||||
|
Text::new(format!("XP earned: +{}", pending.xp)),
|
||||||
|
TextFont { font_size: 22.0, ..default() },
|
||||||
|
TextColor(Color::srgb(0.4, 1.0, 0.4)),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Play Again button
|
||||||
|
card.spawn((
|
||||||
|
WinSummaryButton::PlayAgain,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(28.0), Val::Px(12.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
margin: UiRect::top(Val::Px(8.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
|
||||||
|
BorderRadius::all(Val::Px(6.0)),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Play Again"),
|
||||||
|
TextFont { font_size: 22.0, ..default() },
|
||||||
|
TextColor(Color::WHITE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_win_time_zero() {
|
||||||
|
assert_eq!(format_win_time(0), "0:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_win_time_one_minute_five_seconds() {
|
||||||
|
assert_eq!(format_win_time(65), "1:05");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_win_time_exact_minute() {
|
||||||
|
assert_eq!(format_win_time(120), "2:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_win_time_large() {
|
||||||
|
// 3661 s = 61 min 1 s
|
||||||
|
assert_eq!(format_win_time(3661), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_win_time_59_seconds() {
|
||||||
|
assert_eq!(format_win_time(59), "0:59");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_shake_resource_default_is_idle() {
|
||||||
|
let shake = ScreenShakeResource::default();
|
||||||
|
assert!(shake.remaining <= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_summary_pending_default_is_zeroed() {
|
||||||
|
let p = WinSummaryPending::default();
|
||||||
|
assert_eq!(p.score, 0);
|
||||||
|
assert_eq!(p.time_seconds, 0);
|
||||||
|
assert_eq!(p.xp, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn win_summary_plugin_inserts_resources() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(WinSummaryPlugin);
|
||||||
|
app.update();
|
||||||
|
assert!(app.world().get_resource::<WinSummaryPending>().is_some());
|
||||||
|
assert!(app.world().get_resource::<ScreenShakeResource>().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_win_data_sets_score_and_time() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(WinSummaryPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.send_event(GameWonEvent { score: 1234, time_seconds: 90 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
|
assert_eq!(pending.score, 1234);
|
||||||
|
assert_eq!(pending.time_seconds, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_win_data_sets_xp_from_xp_awarded_event() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(WinSummaryPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut().send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.world_mut().send_event(XpAwardedEvent { amount: 75 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let pending = app.world().resource::<WinSummaryPending>();
|
||||||
|
assert_eq!(pending.xp, 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_won_event_arms_screen_shake() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(WinSummaryPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.send_event(GameWonEvent { score: 0, time_seconds: 0 });
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let shake = app.world().resource::<ScreenShakeResource>();
|
||||||
|
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user