838342649c
Port the July 7 gate-extender branch onto current master (the You-hub, touch action bar, glass tab bar, Phase L, and hint-ghost work all landed since) and finish batch 3. cluster_app now includes the mode, replay, input, radial, tooltip, cursor, touch-selection, and safe-area plugins on top of batches 1-2; the 585 ambiguous pairs the expansion exposed are annotated down to zero and the gate stays assert_eq!(count, 0). New ordering machinery: - ReplayPlayback: playback driver chain on the pre-mutation spine (PointerInput < ReplayPlayback < overlay chain < GameMutation) - ModeStart: mode-start handlers between the stats abandon-recorder and GameMutation; ChallengeCompletion < DailyCompletion < WeeklyGoalsEval pinned inside ProgressUpdate so `.after(ProgressUpdate)` readers see settled progress - PointerInput: public set wrapping the input chain; AbandonRecord on the stats abandon-recorder; PendingHint around the async hint pipeline; HintGhostFx for the ghost chain (after the full visual spine) - Clubs: MoveRequestWriters, WarningToastWriters join the existing request/toast writer clubs; UiTextFx grows the overlay/tooltip/safe-area chrome painters; SettingsAccess grows the lag-tolerant settings readers Gate: cargo test --workspace green (993 engine tests), clippy --all-targets -D warnings clean, rustfmt applied. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
956 lines
37 KiB
Rust
956 lines
37 KiB
Rust
//! Renders the static table: felt background and empty pile markers.
|
||
//!
|
||
//! Pile markers are translucent rectangles that sit beneath any cards. They
|
||
//! remain visible only where a pile is empty, so the player can see where to
|
||
//! drop cards. All geometry comes from `LayoutResource`.
|
||
|
||
use bevy::prelude::*;
|
||
use bevy::window::WindowResized;
|
||
use solitaire_core::KlondikePile;
|
||
use solitaire_core::Suit;
|
||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||
|
||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||
use crate::game_plugin::GameMutation;
|
||
use crate::hud_plugin::HudVisibility;
|
||
use crate::layout::{
|
||
Layout, LayoutResource, LayoutSystem, TABLE_COLOUR, apply_dynamic_tableau_fan, compute_layout,
|
||
};
|
||
use crate::resources::GameStateResource;
|
||
use crate::safe_area::SafeAreaInsets;
|
||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||
use crate::ui_theme::TEXT_PRIMARY;
|
||
#[cfg(test)]
|
||
use solitaire_data::Theme;
|
||
|
||
/// Default tint applied to every empty-pile marker sprite. Pure white
|
||
/// at 15% alpha — soft enough that the marker reads as a "hint of a
|
||
/// slot" rather than a panel, but discernible even against a very dark
|
||
/// felt background under bright ambient light (the old 8% alpha vanished
|
||
/// on a #151515 felt during on-device Android testing).
|
||
///
|
||
/// Re-exported as the source of truth for `cursor_plugin::MARKER_DEFAULT`,
|
||
/// which used to duplicate the literal alongside a "kept in sync" doc
|
||
/// comment. Pulling both call sites through this const makes drift a
|
||
/// compile error instead of a stale comment.
|
||
pub const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.15);
|
||
|
||
/// Tint applied to the thin outline rectangle sitting behind every
|
||
/// empty-pile marker. A slightly brighter white at 28% alpha gives the
|
||
/// slot a defined edge — the standard solitaire "empty pile" affordance —
|
||
/// without competing with real cards. Rendered as a marginally larger
|
||
/// child rectangle one z-step behind the fill, so the fill overlaps it
|
||
/// and only a hairline frame remains visible.
|
||
const PILE_MARKER_OUTLINE_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.28);
|
||
|
||
/// Width in logical pixels of the visible outline frame around an empty
|
||
/// pile marker (the outline rect is this much larger on each side).
|
||
const PILE_MARKER_OUTLINE_WIDTH: f32 = 2.0;
|
||
|
||
/// Holds pre-loaded [`Handle<Image>`]s for the 5 selectable table backgrounds.
|
||
///
|
||
/// Loaded once at startup by [`load_background_images`]. Index 0 is the
|
||
/// default; indices 1–4 are unlockable.
|
||
#[derive(Resource)]
|
||
pub struct BackgroundImageSet {
|
||
/// One handle per background slot (indices 0–4).
|
||
pub handles: Vec<Handle<Image>>,
|
||
}
|
||
|
||
/// Z-depth used for the background — below everything.
|
||
const Z_BACKGROUND: f32 = -10.0;
|
||
/// Z-depth used for pile markers — below cards (which start at 0) but above
|
||
/// the background.
|
||
const Z_PILE_MARKER: f32 = -1.0;
|
||
|
||
/// Marker component for the table felt background.
|
||
#[derive(Component, Debug)]
|
||
pub struct TableBackground;
|
||
|
||
/// Marker component attached to each of the 13 empty-pile placeholders.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct PileMarker(pub KlondikePile);
|
||
|
||
/// Attached to a `PileMarker` entity when it has been temporarily tinted gold
|
||
/// as a hint destination. Stores the remaining countdown and the original sprite
|
||
/// colour so it can be restored when the timer expires.
|
||
#[derive(Component, Debug, Clone)]
|
||
pub struct HintPileHighlight {
|
||
/// Seconds remaining before the pile marker colour is restored.
|
||
pub timer: f32,
|
||
/// The sprite colour the marker had before the hint tint was applied.
|
||
pub original_color: Color,
|
||
}
|
||
|
||
/// Registers the table background and pile-marker rendering.
|
||
pub struct TablePlugin;
|
||
|
||
/// Set wrapping the pile-marker painter chain (theme, hint highlights,
|
||
/// visibility). Runs after [`crate::card_plugin::BoardVisuals`]; chrome-fx
|
||
/// systems that touch `Visibility` on UI entities declare themselves
|
||
/// ambiguous with it (#143).
|
||
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub struct MarkerVisuals;
|
||
|
||
impl Plugin for TablePlugin {
|
||
fn build(&self, app: &mut App) {
|
||
// Register WindowResized so the plugin works under MinimalPlugins in
|
||
// tests. Under DefaultPlugins, bevy_window has already registered it
|
||
// and this call is a no-op.
|
||
app.add_message::<WindowResized>()
|
||
.add_message::<SettingsChangedEvent>()
|
||
.add_message::<HintVisualEvent>()
|
||
.add_message::<StateChangedEvent>()
|
||
.add_systems(Startup, load_background_images.before(setup_table))
|
||
.add_systems(Startup, setup_table)
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
|
||
on_window_resized.in_set(LayoutSystem::UpdateOnResize),
|
||
// Marker painters: deterministic chain after the card
|
||
// paint pipeline — markers and cards share Sprite/
|
||
// Transform access (#143).
|
||
(
|
||
// Drains SettingsChangedEvent via cursor — a home
|
||
// deal-option edit (post-mutation SettingsAccess
|
||
// writer) surfacing one frame later repaints one
|
||
// frame later, which is invisible (#143).
|
||
apply_theme_on_settings_change
|
||
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||
apply_hint_pile_highlight,
|
||
tick_hint_pile_highlights,
|
||
sync_pile_marker_visibility.after(GameMutation),
|
||
)
|
||
.chain()
|
||
.in_set(MarkerVisuals)
|
||
.after(crate::card_plugin::BoardVisuals),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Loads the 5 background PNG files at startup via the Bevy `AssetServer` and
|
||
/// stores their [`Handle<Image>`]s in [`BackgroundImageSet`].
|
||
fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
|
||
let Some(asset_server) = asset_server else {
|
||
// AssetServer absent (e.g. MinimalPlugins in tests) — insert an
|
||
// empty set so setup_table can proceed using a default handle.
|
||
commands.insert_resource(BackgroundImageSet {
|
||
handles: Vec::new(),
|
||
});
|
||
return;
|
||
};
|
||
let handles = (0..5)
|
||
.map(|i| asset_server.load(format!("backgrounds/bg_{i}.png")))
|
||
.collect();
|
||
commands.insert_resource(BackgroundImageSet { handles });
|
||
}
|
||
|
||
/// Returns the felt colour for a given theme.
|
||
///
|
||
/// Only used in tests — the runtime path now picks a PNG image via
|
||
/// [`BackgroundImageSet`] rather than a solid colour.
|
||
#[cfg(test)]
|
||
fn theme_colour(theme: &Theme) -> Color {
|
||
match theme {
|
||
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
|
||
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
|
||
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
|
||
}
|
||
}
|
||
|
||
/// Effective table background colour: unlocked background index overrides the
|
||
/// Theme when `selected_background > 0`.
|
||
///
|
||
/// Only used in tests — the runtime path now picks a PNG image via
|
||
/// [`BackgroundImageSet`] rather than a solid colour.
|
||
#[cfg(test)]
|
||
fn effective_background_colour(theme: &Theme, selected_background: usize) -> Color {
|
||
match selected_background {
|
||
0 => theme_colour(theme),
|
||
1 => Color::srgb(0.25, 0.18, 0.10), // dark wood
|
||
2 => Color::srgb(0.05, 0.08, 0.22), // navy
|
||
3 => Color::srgb(0.30, 0.05, 0.08), // burgundy
|
||
_ => Color::srgb(0.12, 0.12, 0.14), // charcoal (4+)
|
||
}
|
||
}
|
||
|
||
fn default_window_size(window: &Window) -> Vec2 {
|
||
Vec2::new(window.resolution.width(), window.resolution.height())
|
||
}
|
||
|
||
fn setup_table(
|
||
mut commands: Commands,
|
||
windows: Query<&Window>,
|
||
existing_camera: Query<(), With<Camera>>,
|
||
settings: Option<Res<SettingsResource>>,
|
||
bg_images: Option<Res<BackgroundImageSet>>,
|
||
safe_area: Option<Res<SafeAreaInsets>>,
|
||
hud_vis: Option<Res<HudVisibility>>,
|
||
) {
|
||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||
// may have added one in tests). Use the felt-green clear colour so the
|
||
// background reads as green even before the background PNG finishes
|
||
// loading (which is asynchronous and can lag by several frames on
|
||
// Android).
|
||
if existing_camera.is_empty() {
|
||
commands.spawn((
|
||
Camera2d,
|
||
Camera {
|
||
clear_color: ClearColorConfig::Custom(Color::srgb(
|
||
TABLE_COLOUR[0],
|
||
TABLE_COLOUR[1],
|
||
TABLE_COLOUR[2],
|
||
)),
|
||
..default()
|
||
},
|
||
));
|
||
}
|
||
|
||
let (window_size, scale) = windows
|
||
.iter()
|
||
.next()
|
||
.map_or((Vec2::new(1280.0, 800.0), 1.0f32), |w| {
|
||
(default_window_size(w), w.scale_factor())
|
||
});
|
||
// Safe-area insets arrive from JNI asynchronously; they are almost always
|
||
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
|
||
// arrive and issues a synthetic WindowResized to re-snap all game objects.
|
||
let insets = safe_area.as_deref().copied().unwrap_or_default();
|
||
let safe_area_top = insets.top / scale;
|
||
let safe_area_bottom = insets.bottom / scale;
|
||
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
||
let layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
||
|
||
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
|
||
|
||
let image_handle = bg_images
|
||
.as_ref()
|
||
.and_then(|set| set.handles.get(selected_bg).cloned())
|
||
.unwrap_or_default();
|
||
|
||
spawn_background(&mut commands, window_size, image_handle);
|
||
spawn_pile_markers(&mut commands, &layout);
|
||
commands.insert_resource(LayoutResource(layout));
|
||
}
|
||
|
||
/// Spawns the felt background sprite using a PNG image handle.
|
||
///
|
||
/// The sprite covers the window at twice the window size so brief resize gaps
|
||
/// are never visible. The image is tinted `Color::WHITE` (no tint) so the PNG
|
||
/// pixel data is rendered as-is.
|
||
fn spawn_background(commands: &mut Commands, window_size: Vec2, image: Handle<Image>) {
|
||
// Spawn a sprite covering the window. We give it the window size plus
|
||
// headroom so resizing up doesn't expose edges before the resize handler
|
||
// runs.
|
||
commands.spawn((
|
||
Sprite {
|
||
image,
|
||
color: Color::WHITE,
|
||
custom_size: Some(window_size * 2.0),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(0.0, 0.0, Z_BACKGROUND),
|
||
TableBackground,
|
||
));
|
||
}
|
||
|
||
/// Reacts to settings changes by updating the background sprite's image handle.
|
||
///
|
||
/// When [`BackgroundImageSet`] is available the selected PNG handle is applied
|
||
/// directly (color is kept at `Color::WHITE` so the PNG pixel data shows
|
||
/// unmodified). If the resource is not yet ready the sprite is left unchanged.
|
||
fn apply_theme_on_settings_change(
|
||
mut events: MessageReader<SettingsChangedEvent>,
|
||
mut backgrounds: Query<&mut Sprite, With<TableBackground>>,
|
||
bg_images: Option<Res<BackgroundImageSet>>,
|
||
) {
|
||
let Some(ev) = events.read().last() else {
|
||
return;
|
||
};
|
||
let Some(set) = bg_images else {
|
||
// BackgroundImageSet not ready yet — leave sprite unchanged.
|
||
return;
|
||
};
|
||
let selected = ev.0.selected_background;
|
||
let Some(handle) = set.handles.get(selected).cloned() else {
|
||
return;
|
||
};
|
||
for mut sprite in &mut backgrounds {
|
||
sprite.image = handle.clone();
|
||
sprite.color = Color::WHITE;
|
||
}
|
||
}
|
||
|
||
/// 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) {
|
||
let marker_colour = PILE_MARKER_DEFAULT_COLOUR;
|
||
let marker_size = layout.card_size;
|
||
let font_size = layout.card_size.x * 0.28;
|
||
|
||
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
|
||
piles.push(KlondikePile::Stock);
|
||
for foundation in FOUNDATIONS {
|
||
piles.push(KlondikePile::Foundation(foundation));
|
||
}
|
||
for tableau in TABLEAUS {
|
||
piles.push(KlondikePile::Tableau(tableau));
|
||
}
|
||
|
||
for pile in piles {
|
||
let pos = layout.pile_positions[&pile];
|
||
let mut entity = commands.spawn((
|
||
Sprite {
|
||
color: marker_colour,
|
||
custom_size: Some(marker_size),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(pos.x, pos.y, Z_PILE_MARKER),
|
||
PileMarker(pile),
|
||
));
|
||
|
||
// Outline frame: a marginally larger rectangle sitting one z-step
|
||
// behind the fill. The fill overlaps its centre, leaving only a
|
||
// hairline border visible — a defined slot edge without an extra
|
||
// asset or 9-slice. Untagged so the `PileMarker` count is unchanged.
|
||
let outline_size = marker_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||
entity.with_children(|b| {
|
||
b.spawn((
|
||
Sprite {
|
||
color: PILE_MARKER_OUTLINE_COLOUR,
|
||
custom_size: Some(outline_size),
|
||
..default()
|
||
},
|
||
Transform::from_xyz(0.0, 0.0, -0.05),
|
||
));
|
||
});
|
||
|
||
// Tableau markers show "K" (only a King may start an empty column).
|
||
// Foundation markers show "A" (only an Ace may claim an empty slot).
|
||
// Neither label carries a suit because any suit may start any slot.
|
||
match &pile {
|
||
KlondikePile::Tableau(_) => {
|
||
entity.with_children(|b| {
|
||
b.spawn((
|
||
Text2d::new("K"),
|
||
TextFont {
|
||
font_size,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
|
||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||
));
|
||
});
|
||
}
|
||
KlondikePile::Foundation(_) => {
|
||
entity.with_children(|b| {
|
||
b.spawn((
|
||
Text2d::new("A"),
|
||
TextFont {
|
||
font_size,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
|
||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||
));
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||
fn on_window_resized(
|
||
mut events: MessageReader<WindowResized>,
|
||
safe_area: Option<Res<SafeAreaInsets>>,
|
||
windows: Query<&Window>,
|
||
hud_vis: Option<Res<HudVisibility>>,
|
||
game: Option<Res<GameStateResource>>,
|
||
mut layout_res: Option<ResMut<LayoutResource>>,
|
||
mut backgrounds: Query<
|
||
(&mut Sprite, &mut Transform),
|
||
(With<TableBackground>, Without<PileMarker>),
|
||
>,
|
||
mut markers: Query<
|
||
(&PileMarker, &mut Sprite, &mut Transform, Option<&Children>),
|
||
Without<TableBackground>,
|
||
>,
|
||
mut marker_outlines: Query<
|
||
&mut Sprite,
|
||
(
|
||
Without<PileMarker>,
|
||
Without<TableBackground>,
|
||
Without<Text2d>,
|
||
),
|
||
>,
|
||
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
|
||
) {
|
||
let Some(ev) = events.read().last() else {
|
||
return;
|
||
};
|
||
let window_size = Vec2::new(ev.width, ev.height);
|
||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||
let insets = safe_area.as_deref().copied().unwrap_or_default();
|
||
let safe_area_top = insets.top / scale;
|
||
let safe_area_bottom = insets.bottom / scale;
|
||
let hud_visible = hud_vis.as_deref().copied().unwrap_or_default() == HudVisibility::Visible;
|
||
// Android surface sizes after fold/unfold are unreliable (winit does not
|
||
// forward every content-rect change), so make each relayout observable
|
||
// in logcat — this is the primary evidence channel for foldable bugs.
|
||
#[cfg(target_os = "android")]
|
||
info!(
|
||
"layout: resize to {:.0}x{:.0} (insets top={:.0} bottom={:.0}, hud={hud_visible})",
|
||
window_size.x, window_size.y, insets.top, insets.bottom
|
||
);
|
||
let mut new_layout = compute_layout(window_size, safe_area_top, safe_area_bottom, hud_visible);
|
||
|
||
// compute_layout sizes the fan for a worst-case column; refine it to the
|
||
// current deal so a resize (incl. the Android safe-area-inset resize that
|
||
// fires in the first few frames, and fold/unfold on foldables) keeps the
|
||
// tableau filling the viewport instead of snapping back to the sparse
|
||
// worst-case fan.
|
||
if let Some(game) = game.as_ref() {
|
||
apply_dynamic_tableau_fan(&game.0, &mut new_layout);
|
||
}
|
||
|
||
if let Some(layout_res) = layout_res.as_deref_mut() {
|
||
layout_res.0 = new_layout.clone();
|
||
}
|
||
|
||
for (mut sprite, mut transform) in &mut backgrounds {
|
||
sprite.custom_size = Some(window_size * 2.0);
|
||
transform.translation.x = 0.0;
|
||
transform.translation.y = 0.0;
|
||
}
|
||
|
||
for (marker, mut sprite, mut transform, children) in &mut markers {
|
||
if let Some(pos) = new_layout.pile_positions.get(&marker.0) {
|
||
sprite.custom_size = Some(new_layout.card_size);
|
||
transform.translation.x = pos.x;
|
||
transform.translation.y = pos.y;
|
||
// Children carry the outline frame and the "A"/"K" watermark.
|
||
// They are sized at spawn from that layout's card size, so they
|
||
// must be re-derived here too or a resize (fold/unfold, rotation)
|
||
// leaves them at the stale size — visible as oversized grey
|
||
// frames on empty piles.
|
||
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||
let font_size = new_layout.card_size.x * 0.28;
|
||
for child in children.into_iter().flatten() {
|
||
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
|
||
outline.custom_size = Some(outline_size);
|
||
}
|
||
if let Ok(mut label) = marker_labels.get_mut(*child) {
|
||
label.font_size = font_size;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Card sprites are repositioned by `card_plugin::snap_cards_on_window_resize`
|
||
// running `.after(LayoutSystem::UpdateOnResize)` — that system snaps card
|
||
// transforms directly to the new layout instead of going through
|
||
// `StateChangedEvent → sync_cards → CardAnim` which would retarget the
|
||
// slide tween every frame during a corner drag (the visible "snap back
|
||
// and forth" jitter).
|
||
}
|
||
|
||
/// Bridges the asynchronous safe-area inset update into the synchronous
|
||
/// window-resize pipeline. When Android's JNI delivers the real inset values
|
||
/// (typically frame 2-3 of a fresh launch), this system writes a synthetic
|
||
/// `WindowResized` event carrying the current window size. `on_window_resized`
|
||
/// (which runs in `LayoutSystem::UpdateOnResize`) will then recompute the
|
||
/// layout with the correct `safe_area_top`, update `LayoutResource` and the
|
||
/// pile markers, and `snap_cards_on_window_resize` (running after the set)
|
||
/// will snap card sprites to the corrected positions.
|
||
fn on_safe_area_changed(
|
||
safe_area: Option<Res<SafeAreaInsets>>,
|
||
windows: Query<(Entity, &Window)>,
|
||
mut resize_events: MessageWriter<WindowResized>,
|
||
) {
|
||
let Some(safe_area) = safe_area else {
|
||
return;
|
||
};
|
||
if !safe_area.is_changed() {
|
||
return;
|
||
}
|
||
let Some((entity, window)) = windows.iter().next() else {
|
||
return;
|
||
};
|
||
resize_events.write(WindowResized {
|
||
window: entity,
|
||
width: window.resolution.width(),
|
||
height: window.resolution.height(),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task #6 — Hint pile-marker highlight
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Gold tint applied to a `PileMarker` sprite when it is the current
|
||
/// hint destination. Same RGB as the design-system [`STATE_WARNING`]
|
||
/// token (`#ddb26f`) so the in-game "look here" colour is the same hue
|
||
/// as every other warning/attention signal in the UI. Spelled as a
|
||
/// literal because `Alpha::with_alpha` is not yet a `const` trait
|
||
/// method on stable; the tracking test below pins the RGB to
|
||
/// `STATE_WARNING` so a future palette swap can't drift the two apart.
|
||
const HINT_PILE_HIGHLIGHT_COLOUR: Color = Color::srgb(0.867, 0.698, 0.435);
|
||
|
||
/// Listens for `HintVisualEvent` and tints the matching `PileMarker` entity
|
||
/// gold for 2 s, storing the original colour in `HintPileHighlight` so it can
|
||
/// be restored when the timer expires.
|
||
///
|
||
/// If the pile marker already has a `HintPileHighlight` from a previous hint
|
||
/// press, the timer is reset to 2 s without changing `original_color`.
|
||
fn apply_hint_pile_highlight(
|
||
mut events: MessageReader<HintVisualEvent>,
|
||
mut commands: Commands,
|
||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite, Option<&HintPileHighlight>)>,
|
||
) {
|
||
for ev in events.read() {
|
||
for (entity, pile_marker, mut sprite, existing) in pile_markers.iter_mut() {
|
||
if pile_marker.0 != ev.dest_pile {
|
||
continue;
|
||
}
|
||
let original_color = existing.map_or(sprite.color, |h| h.original_color);
|
||
sprite.color = HINT_PILE_HIGHLIGHT_COLOUR;
|
||
commands.entity(entity).insert(HintPileHighlight {
|
||
timer: 2.0,
|
||
original_color,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Counts down `HintPileHighlight::timer` each frame and restores the original
|
||
/// pile marker colour when the timer expires.
|
||
fn tick_hint_pile_highlights(
|
||
mut commands: Commands,
|
||
time: Res<Time>,
|
||
mut pile_markers: Query<(Entity, &mut Sprite, &mut HintPileHighlight)>,
|
||
) {
|
||
let dt = time.delta_secs();
|
||
for (entity, mut sprite, mut highlight) in pile_markers.iter_mut() {
|
||
highlight.timer -= dt;
|
||
if highlight.timer <= 0.0 {
|
||
sprite.color = highlight.original_color;
|
||
commands.entity(entity).remove::<HintPileHighlight>();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Hides pile-marker sprites for piles that have a card on top, shows them
|
||
/// for empty piles. Implements the "remain visible only where a pile is
|
||
/// empty" invariant declared in this module's top-level doc comment but
|
||
/// previously not enforced — markers always rendered, and the resulting
|
||
/// translucent rectangle bled through the rounded corners of any card sat
|
||
/// on top, producing visible "gray L" artifacts at each card corner.
|
||
///
|
||
/// Runs every Update tick guarded by `game.is_changed()` so the work is
|
||
/// skipped on idle frames. Bevy's resource change-detection sets the
|
||
/// changed flag on every state mutation (draw, move, undo, recycle, new
|
||
/// game), which covers every transition that flips a pile from
|
||
/// empty-to-occupied or vice versa.
|
||
fn sync_pile_marker_visibility(
|
||
game: Option<Res<GameStateResource>>,
|
||
mut markers: Query<(&PileMarker, &mut Visibility)>,
|
||
) {
|
||
let Some(game) = game else {
|
||
return;
|
||
};
|
||
if !game.is_changed() {
|
||
return;
|
||
}
|
||
for (pile_marker, mut visibility) in markers.iter_mut() {
|
||
let is_empty = pile_cards(&game.0, &pile_marker.0).is_empty();
|
||
*visibility = if is_empty {
|
||
Visibility::Inherited
|
||
} else {
|
||
Visibility::Hidden
|
||
};
|
||
}
|
||
}
|
||
|
||
fn pile_cards(
|
||
game: &solitaire_core::game_state::GameState,
|
||
pile: &KlondikePile,
|
||
) -> Vec<(solitaire_core::Card, bool)> {
|
||
match pile {
|
||
KlondikePile::Stock => {
|
||
let stock = game.stock_cards();
|
||
if stock.is_empty() {
|
||
game.waste_cards()
|
||
} else {
|
||
stock
|
||
}
|
||
}
|
||
_ => game.pile(*pile),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::game_plugin::GamePlugin;
|
||
use solitaire_core::{Foundation, Tableau};
|
||
|
||
/// Minimal headless app — omits windowing so pile markers are spawned with
|
||
/// the default 1280×800 layout and no camera is created.
|
||
fn headless_app() -> App {
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins)
|
||
.add_plugins(GamePlugin)
|
||
.add_plugins(TablePlugin);
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_spawns_twelve_pile_markers() {
|
||
let mut app = headless_app();
|
||
let count = app
|
||
.world_mut()
|
||
.query::<&PileMarker>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert_eq!(count, 12);
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_spawns_one_background() {
|
||
let mut app = headless_app();
|
||
let count = app
|
||
.world_mut()
|
||
.query::<&TableBackground>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn table_plugin_inserts_layout_resource() {
|
||
let app = headless_app();
|
||
assert!(app.world().get_resource::<LayoutResource>().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn every_pile_marker_has_unique_type() {
|
||
let mut app = headless_app();
|
||
let mut types: Vec<KlondikePile> = app
|
||
.world_mut()
|
||
.query::<&PileMarker>()
|
||
.iter(app.world())
|
||
.map(|m| m.0)
|
||
.collect();
|
||
types.sort_by_key(|p| format!("{p:?}"));
|
||
types.dedup();
|
||
assert_eq!(types.len(), 12);
|
||
}
|
||
|
||
#[test]
|
||
fn resize_updates_marker_outline_and_watermark_children() {
|
||
// Regression (found on Galaxy Fold 7, v0.41.0): `on_window_resized`
|
||
// resized the marker fill sprite but never its children, so after a
|
||
// fold/unfold the outline frame and "A"/"K" watermark stayed at
|
||
// spawn-time size — rendering as oversized grey slabs on empty piles.
|
||
let mut app = headless_app();
|
||
// Shrink the window well below the 1280×800 spawn default.
|
||
app.world_mut().write_message(WindowResized {
|
||
window: Entity::PLACEHOLDER,
|
||
width: 640.0,
|
||
height: 480.0,
|
||
});
|
||
app.update();
|
||
|
||
let card_size = app.world().resource::<LayoutResource>().0.card_size;
|
||
let expected_outline = card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||
let expected_font = card_size.x * 0.28;
|
||
|
||
let marker_children: Vec<Entity> = {
|
||
let mut q = app.world_mut().query::<(&PileMarker, &Children)>();
|
||
q.iter(app.world())
|
||
.flat_map(|(_, c)| c.iter().collect::<Vec<_>>())
|
||
.collect()
|
||
};
|
||
assert!(
|
||
!marker_children.is_empty(),
|
||
"pile markers must have outline/watermark children"
|
||
);
|
||
let mut outlines = 0;
|
||
let mut labels = 0;
|
||
for child in marker_children {
|
||
if let Some(sprite) = app.world().entity(child).get::<Sprite>() {
|
||
assert_eq!(
|
||
sprite.custom_size,
|
||
Some(expected_outline),
|
||
"outline child must track the resized card size",
|
||
);
|
||
outlines += 1;
|
||
}
|
||
if let Some(font) = app.world().entity(child).get::<TextFont>() {
|
||
assert!(
|
||
(font.font_size - expected_font).abs() < 0.01,
|
||
"watermark font must track the resized card size",
|
||
);
|
||
labels += 1;
|
||
}
|
||
}
|
||
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
|
||
assert!(
|
||
labels >= 11,
|
||
"tableau + foundation markers carry watermarks"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn pile_markers_hide_when_pile_is_occupied() {
|
||
// After a fresh deal: the 7 tableau piles + the stock pile are
|
||
// occupied; the 4 foundation piles are empty. The visibility-by-
|
||
// occupancy system must hide the first 8 markers and keep the
|
||
// last 4 visible. This implements
|
||
// the "remain visible only where a pile is empty" invariant
|
||
// in the module-level doc comment that was previously
|
||
// declared but not enforced — pile markers used to always
|
||
// render, and the resulting translucent rectangle bled through
|
||
// the rounded corners of any card sat on top.
|
||
let mut app = headless_app();
|
||
// headless_app() runs one tick; run another so
|
||
// sync_pile_marker_visibility has a chance to fire (it runs
|
||
// in Update, after Startup spawns the markers and the game
|
||
// state populates).
|
||
app.update();
|
||
|
||
let mut q = app.world_mut().query::<(&PileMarker, &Visibility)>();
|
||
let mut hidden_piles: Vec<KlondikePile> = Vec::new();
|
||
let mut visible_piles: Vec<KlondikePile> = Vec::new();
|
||
for (marker, visibility) in q.iter(app.world()) {
|
||
if matches!(visibility, Visibility::Hidden) {
|
||
hidden_piles.push(marker.0);
|
||
} else {
|
||
visible_piles.push(marker.0);
|
||
}
|
||
}
|
||
|
||
// 8 occupied piles after a fresh deal: stock + 7 tableau.
|
||
assert_eq!(
|
||
hidden_piles.len(),
|
||
8,
|
||
"stock + 7 tableau piles should hide their markers post-deal",
|
||
);
|
||
assert!(hidden_piles.contains(&KlondikePile::Stock));
|
||
for tableau in [
|
||
Tableau::Tableau1,
|
||
Tableau::Tableau2,
|
||
Tableau::Tableau3,
|
||
Tableau::Tableau4,
|
||
Tableau::Tableau5,
|
||
Tableau::Tableau6,
|
||
Tableau::Tableau7,
|
||
] {
|
||
assert!(
|
||
hidden_piles.contains(&KlondikePile::Tableau(tableau)),
|
||
"{tableau:?} marker should be hidden — it has cards",
|
||
);
|
||
}
|
||
|
||
// 4 empty piles: foundations only.
|
||
assert_eq!(visible_piles.len(), 4);
|
||
for foundation in [
|
||
Foundation::Foundation1,
|
||
Foundation::Foundation2,
|
||
Foundation::Foundation3,
|
||
Foundation::Foundation4,
|
||
] {
|
||
assert!(visible_piles.contains(&KlondikePile::Foundation(foundation)));
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Pure-function tests (no Bevy app required)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn all_three_themes_produce_distinct_colours() {
|
||
let green = theme_colour(&Theme::Green);
|
||
let blue = theme_colour(&Theme::Blue);
|
||
let dark = theme_colour(&Theme::Dark);
|
||
assert_ne!(green, blue, "Green and Blue must differ");
|
||
assert_ne!(green, dark, "Green and Dark must differ");
|
||
assert_ne!(blue, dark, "Blue and Dark must differ");
|
||
}
|
||
|
||
#[test]
|
||
fn effective_background_index_0_matches_theme_colour() {
|
||
for theme in [Theme::Green, Theme::Blue, Theme::Dark] {
|
||
let expected = theme_colour(&theme);
|
||
let actual = effective_background_colour(&theme, 0);
|
||
assert_eq!(
|
||
expected, actual,
|
||
"index 0 must always return the theme colour for {:?}",
|
||
theme
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn effective_background_indices_1_through_3_are_distinct_from_theme() {
|
||
// Non-zero indices override the theme with a fixed colour.
|
||
let theme_green = theme_colour(&Theme::Green);
|
||
for idx in 1..=3 {
|
||
let eff = effective_background_colour(&Theme::Green, idx);
|
||
assert_ne!(
|
||
eff, theme_green,
|
||
"index {idx} must override the theme colour"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn effective_background_index_4_falls_through_to_charcoal() {
|
||
// All indices ≥ 4 share the same charcoal fallback.
|
||
let c4 = effective_background_colour(&Theme::Green, 4);
|
||
let c5 = effective_background_colour(&Theme::Green, 5);
|
||
let c99 = effective_background_colour(&Theme::Green, 99);
|
||
assert_eq!(c4, c5, "indices 4 and 5 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");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Task #6 — HintPileHighlight timer and colour pure-function tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// The HINT_PILE_HIGHLIGHT_COLOUR constant must be visibly distinct from the
|
||
/// default pile marker colour so the player can see which pile is highlighted.
|
||
#[test]
|
||
fn hint_pile_highlight_colour_is_distinct_from_default() {
|
||
assert_ne!(
|
||
HINT_PILE_HIGHLIGHT_COLOUR, PILE_MARKER_DEFAULT_COLOUR,
|
||
"HINT_PILE_HIGHLIGHT_COLOUR must differ from the default pile marker colour"
|
||
);
|
||
}
|
||
|
||
/// `HINT_PILE_HIGHLIGHT_COLOUR` is spelled as a literal because
|
||
/// `Alpha::with_alpha` is not a `const` trait method on stable.
|
||
/// This test pins its RGB to the design-system `STATE_WARNING`
|
||
/// token so a future palette swap that updates the token but
|
||
/// forgets the hint highlight fails loudly here.
|
||
#[test]
|
||
fn hint_pile_highlight_rgb_tracks_state_warning_token() {
|
||
use crate::ui_theme::STATE_WARNING;
|
||
let hint = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
|
||
let warning = STATE_WARNING.to_srgba();
|
||
assert!((hint.red - warning.red).abs() < 1e-6);
|
||
assert!((hint.green - warning.green).abs() < 1e-6);
|
||
assert!((hint.blue - warning.blue).abs() < 1e-6);
|
||
}
|
||
|
||
/// A freshly-created HintPileHighlight has a positive timer countdown.
|
||
#[test]
|
||
fn hint_pile_highlight_timer_starts_positive() {
|
||
let h = HintPileHighlight {
|
||
timer: 2.0,
|
||
original_color: PILE_MARKER_DEFAULT_COLOUR,
|
||
};
|
||
assert!(
|
||
h.timer > 0.0,
|
||
"HintPileHighlight timer must start positive, got {}",
|
||
h.timer
|
||
);
|
||
}
|
||
|
||
/// Ticking the timer past its initial value results in a non-positive (expired)
|
||
/// countdown.
|
||
#[test]
|
||
fn hint_pile_highlight_timer_expires_after_full_duration() {
|
||
let mut remaining = 2.0_f32;
|
||
remaining -= 2.5; // 2.5 s elapsed on a 2.0 s timer
|
||
assert!(
|
||
remaining <= 0.0,
|
||
"timer must be expired after ticking past its initial value, got {}",
|
||
remaining
|
||
);
|
||
}
|
||
|
||
/// `original_color` is preserved through the highlight lifecycle so colour
|
||
/// can be correctly restored on expiry.
|
||
#[test]
|
||
fn hint_pile_highlight_preserves_original_color() {
|
||
let original = Color::srgb(0.1, 0.3, 0.5);
|
||
let h = HintPileHighlight {
|
||
timer: 2.0,
|
||
original_color: original,
|
||
};
|
||
assert_eq!(
|
||
h.original_color, original,
|
||
"original_color must be stored without modification"
|
||
);
|
||
}
|
||
|
||
/// The hint colour must read as "gold-ish" — red dominant, green
|
||
/// close behind, blue noticeably lower — so a player intuitively
|
||
/// associates the highlight with attention/warning. Bounds are
|
||
/// loose enough to accommodate the Terminal palette's muted gold
|
||
/// (`STATE_WARNING`, `#ddb26f`) while still rejecting a stray
|
||
/// red, green, or neutral grey if someone refactors badly.
|
||
/// Exact-RGB tracking lives in
|
||
/// `hint_pile_highlight_rgb_tracks_state_warning_token`.
|
||
#[test]
|
||
fn hint_pile_highlight_colour_is_gold() {
|
||
let Srgba {
|
||
red, green, blue, ..
|
||
} = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
|
||
assert!(
|
||
red >= 0.7,
|
||
"gold hint colour must have red ≥ 0.7, got {red}"
|
||
);
|
||
assert!(
|
||
green >= 0.5,
|
||
"gold hint colour must have green ≥ 0.5, got {green}"
|
||
);
|
||
assert!(
|
||
blue <= 0.6,
|
||
"gold hint colour must have blue ≤ 0.6, got {blue}"
|
||
);
|
||
assert!(
|
||
red > blue,
|
||
"gold hint colour must be warmer than cool, got r={red} b={blue}"
|
||
);
|
||
assert!(
|
||
green > blue,
|
||
"gold hint colour must be warmer than cool, got g={green} b={blue}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn suit_symbol_all_four_are_distinct() {
|
||
let symbols: Vec<&str> = Suit::SUITS.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");
|
||
}
|
||
}
|