feat(engine): cosmetic selectors applied, stats screen expanded, daily goals enforced
- Card backs: selected_card_back index maps to distinct Color values in card rendering - Backgrounds: selected_background index applied in TablePlugin alongside theme - Both re-render immediately on SettingsChangedEvent - Stats screen now shows Games Lost, Draw 1/3 Wins, and Lifetime Score - Daily challenge win no longer credited if server-supplied target_score or max_time_secs constraints are not met Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ use crate::events::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::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
/// Fraction of card height used as vertical offset between stacked tableau cards.
|
/// Fraction of card height used as vertical offset between stacked tableau cards.
|
||||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||||
@@ -36,10 +37,21 @@ const STACK_FAN_FRAC: f32 = 0.003;
|
|||||||
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);
|
const CARD_FACE_COLOUR: Color = Color::srgb(0.98, 0.98, 0.95);
|
||||||
const CARD_BACK_COLOUR: Color = Color::srgb(0.15, 0.30, 0.55);
|
|
||||||
const RED_SUIT_COLOUR: Color = Color::srgb(0.78, 0.12, 0.15);
|
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);
|
const BLACK_SUIT_COLOUR: Color = Color::srgb(0.08, 0.08, 0.08);
|
||||||
|
|
||||||
|
/// Returns the card back color for the given unlocked card-back index.
|
||||||
|
/// Index 0 = default blue; 1–4 are unlockable alternate designs.
|
||||||
|
fn card_back_colour(selected_card_back: usize) -> Color {
|
||||||
|
match selected_card_back {
|
||||||
|
0 => Color::srgb(0.15, 0.30, 0.55), // default blue
|
||||||
|
1 => Color::srgb(0.55, 0.10, 0.10), // deep red
|
||||||
|
2 => Color::srgb(0.05, 0.40, 0.10), // forest green
|
||||||
|
3 => Color::srgb(0.35, 0.08, 0.52), // purple
|
||||||
|
_ => Color::srgb(0.05, 0.40, 0.42), // teal (4+)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
|
/// Marker component linking a Bevy entity to a `solitaire_core::Card::id`.
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct CardEntity {
|
pub struct CardEntity {
|
||||||
@@ -57,8 +69,26 @@ 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_systems(PostStartup, sync_cards_startup)
|
app.add_event::<SettingsChangedEvent>()
|
||||||
.add_systems(Update, sync_cards_on_change.after(GameMutation));
|
.add_systems(PostStartup, sync_cards_startup)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
sync_cards_on_change.after(GameMutation),
|
||||||
|
resync_cards_on_settings_change.before(sync_cards_on_change),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When card-back selection changes in Settings, re-render all cards so the
|
||||||
|
/// new back colour is applied immediately (without waiting for a state change).
|
||||||
|
fn resync_cards_on_settings_change(
|
||||||
|
mut setting_events: EventReader<SettingsChangedEvent>,
|
||||||
|
mut state_events: EventWriter<StateChangedEvent>,
|
||||||
|
) {
|
||||||
|
if setting_events.read().next().is_some() {
|
||||||
|
state_events.send(StateChangedEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +100,15 @@ fn sync_cards_startup(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, &entities);
|
let back_colour = settings
|
||||||
|
.as_ref()
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +118,7 @@ fn sync_cards_on_change(
|
|||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform)>,
|
entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
@@ -91,7 +126,10 @@ fn sync_cards_on_change(
|
|||||||
}
|
}
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, &entities);
|
let back_colour = settings
|
||||||
|
.as_ref()
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +138,7 @@ fn sync_cards(
|
|||||||
game: &GameState,
|
game: &GameState,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
slide_secs: f32,
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
@@ -123,9 +162,9 @@ 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, cur)
|
update_card_entity(&mut commands, entity, &card, position, z, layout, slide_secs, back_colour, cur)
|
||||||
}
|
}
|
||||||
None => spawn_card_entity(&mut commands, &card, position, z, layout),
|
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +211,11 @@ 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) {
|
fn spawn_card_entity(commands: &mut Commands, card: &Card, pos: Vec2, z: f32, layout: &Layout, back_colour: Color) {
|
||||||
let body_colour = if card.face_up {
|
let body_colour = if card.face_up {
|
||||||
CARD_FACE_COLOUR
|
CARD_FACE_COLOUR
|
||||||
} else {
|
} else {
|
||||||
CARD_BACK_COLOUR
|
back_colour
|
||||||
};
|
};
|
||||||
|
|
||||||
commands
|
commands
|
||||||
@@ -216,12 +255,13 @@ fn update_card_entity(
|
|||||||
z: f32,
|
z: f32,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
slide_secs: f32,
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
cur: Vec3,
|
cur: Vec3,
|
||||||
) {
|
) {
|
||||||
let body_colour = if card.face_up {
|
let body_colour = if card.face_up {
|
||||||
CARD_FACE_COLOUR
|
CARD_FACE_COLOUR
|
||||||
} else {
|
} else {
|
||||||
CARD_BACK_COLOUR
|
back_colour
|
||||||
};
|
};
|
||||||
|
|
||||||
let target = Vec3::new(pos.x, pos.y, z);
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
|
|||||||
@@ -150,10 +150,21 @@ fn handle_daily_completion(
|
|||||||
path: Res<ProgressStoragePath>,
|
path: Res<ProgressStoragePath>,
|
||||||
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
||||||
) {
|
) {
|
||||||
for _ in wins.read() {
|
for ev in wins.read() {
|
||||||
if game.0.seed != daily.seed {
|
if game.0.seed != daily.seed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Enforce server-supplied goal constraints when present.
|
||||||
|
if let Some(target) = daily.target_score {
|
||||||
|
if ev.score < target {
|
||||||
|
continue; // score goal not met
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(max_secs) = daily.max_time_secs {
|
||||||
|
if ev.time_seconds > max_secs {
|
||||||
|
continue; // time limit exceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
if !progress.0.record_daily_completion(daily.date) {
|
if !progress.0.record_daily_completion(daily.date) {
|
||||||
// Already counted today — no-op.
|
// Already counted today — no-op.
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -171,12 +171,16 @@ fn spawn_stats_screen(
|
|||||||
"=== Statistics ===".to_string(),
|
"=== Statistics ===".to_string(),
|
||||||
format!("Games Played: {}", stats.games_played),
|
format!("Games Played: {}", stats.games_played),
|
||||||
format!("Games Won: {}", stats.games_won),
|
format!("Games Won: {}", stats.games_won),
|
||||||
|
format!("Games Lost: {}", stats.games_lost),
|
||||||
format!("Win Rate: {win_rate}"),
|
format!("Win Rate: {win_rate}"),
|
||||||
format!(
|
format!(
|
||||||
"Win Streak: {} (Best: {})",
|
"Win Streak: {} (Best: {})",
|
||||||
stats.win_streak_current, stats.win_streak_best
|
stats.win_streak_current, stats.win_streak_best
|
||||||
),
|
),
|
||||||
|
format!("Draw 1 Wins: {}", stats.draw_one_wins),
|
||||||
|
format!("Draw 3 Wins: {}", stats.draw_three_wins),
|
||||||
format!("Best Score: {}", stats.best_single_score),
|
format!("Best Score: {}", stats.best_single_score),
|
||||||
|
format!("Lifetime Score:{}", stats.lifetime_score),
|
||||||
format!("Fastest Win: {fastest}"),
|
format!("Fastest Win: {fastest}"),
|
||||||
format!("Avg Win Time: {avg}"),
|
format!("Avg Win Time: {avg}"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use solitaire_core::pile::PileType;
|
|||||||
use solitaire_data::settings::Theme;
|
use solitaire_data::settings::Theme;
|
||||||
|
|
||||||
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
use crate::layout::{compute_layout, Layout, LayoutResource, TABLE_COLOUR};
|
||||||
use crate::settings_plugin::SettingsChangedEvent;
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
/// Z-depth used for the background — below everything.
|
/// Z-depth used for the background — below everything.
|
||||||
const Z_BACKGROUND: f32 = -10.0;
|
const Z_BACKGROUND: f32 = -10.0;
|
||||||
@@ -51,6 +51,18 @@ fn theme_colour(theme: &Theme) -> Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Effective table background colour: unlocked background index overrides the
|
||||||
|
/// Theme when `selected_background > 0`.
|
||||||
|
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 {
|
fn default_window_size(window: &Window) -> Vec2 {
|
||||||
Vec2::new(window.resolution.width(), window.resolution.height())
|
Vec2::new(window.resolution.width(), window.resolution.height())
|
||||||
}
|
}
|
||||||
@@ -59,7 +71,7 @@ fn setup_table(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
existing_camera: Query<(), With<Camera>>,
|
existing_camera: Query<(), With<Camera>>,
|
||||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
) {
|
) {
|
||||||
// Only spawn a camera if one does not already exist (e.g. a parent app
|
// Only spawn a camera if one does not already exist (e.g. a parent app
|
||||||
// may have added one in tests).
|
// may have added one in tests).
|
||||||
@@ -76,7 +88,7 @@ fn setup_table(
|
|||||||
|
|
||||||
let initial_colour = settings
|
let initial_colour = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| theme_colour(&s.0.theme))
|
.map(|s| effective_background_colour(&s.0.theme, s.0.selected_background))
|
||||||
.unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]));
|
.unwrap_or_else(|| Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]));
|
||||||
|
|
||||||
spawn_background(&mut commands, window_size, initial_colour);
|
spawn_background(&mut commands, window_size, initial_colour);
|
||||||
@@ -106,7 +118,7 @@ fn apply_theme_on_settings_change(
|
|||||||
let Some(ev) = events.read().last() else {
|
let Some(ev) = events.read().last() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let colour = theme_colour(&ev.0.theme);
|
let colour = effective_background_colour(&ev.0.theme, ev.0.selected_background);
|
||||||
for mut sprite in &mut backgrounds {
|
for mut sprite in &mut backgrounds {
|
||||||
sprite.color = colour;
|
sprite.color = colour;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user