From ae8118d7df4af556f9eaf5de24904a1d117d7afb Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 17:26:47 -0700 Subject: [PATCH 1/2] feat(engine): theme-store preview thumbnails (Phase H part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live catalog has advertised preview_url per theme since the store shipped, but the client never fetched it — rows were text-only. The modal now downloads each advertised preview PNG off the main thread (same AsyncComputeTaskPool + Tokio pattern as the catalog fetch and avatar image), decodes it into an Image asset, and renders a thumbnail sized by the theme's own card_aspect at the head of the row. Previews pop in as they arrive and are cached for the session, so reopening the store is instant. Failures log and leave the row text-only; the decode path is skipped entirely under MinimalPlugins (no Assets). solitaire_data grows ThemeStoreClient::fetch_preview with a 512 KB cap (previews carry no checksum — decorative only). Co-Authored-By: Claude Fable 5 --- solitaire_data/src/theme_store_client.rs | 40 +++ solitaire_engine/src/theme_store_plugin.rs | 284 +++++++++++++++++++-- 2 files changed, 300 insertions(+), 24 deletions(-) diff --git a/solitaire_data/src/theme_store_client.rs b/solitaire_data/src/theme_store_client.rs index a84e15d..a518968 100644 --- a/solitaire_data/src/theme_store_client.rs +++ b/solitaire_data/src/theme_store_client.rs @@ -19,6 +19,10 @@ use thiserror::Error; /// so downloading it is pure waste. pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024; +/// Hard cap on a preview PNG download — previews are small decorative +/// thumbnails; anything past this is a misconfigured server. +pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024; + /// Errors surfaced by [`ThemeStoreClient`]. #[derive(Debug, Error)] pub enum ThemeStoreError { @@ -110,6 +114,42 @@ impl ThemeStoreClient { verify_archive(&bytes, entry)?; Ok(bytes.to_vec()) } + + /// Fetch the preview PNG the catalog advertises for `entry`. + /// + /// Returns `Http(404)` when the entry carries no `preview_url` — + /// the same shape the server answers with when the file is absent, + /// so callers only handle one "no preview" case. Previews are + /// decorative; unlike archives they carry no checksum, only the + /// [`MAX_PREVIEW_BYTES`] size cap. + pub async fn fetch_preview( + &self, + entry: &ThemeCatalogEntry, + ) -> Result, ThemeStoreError> { + let Some(path) = entry.preview_url.as_deref() else { + return Err(ThemeStoreError::Http(404)); + }; + let resp = self + .client + .get(format!("{}{}", self.base_url, path)) + .send() + .await + .map_err(|e| ThemeStoreError::Network(e.to_string()))?; + if !resp.status().is_success() { + return Err(ThemeStoreError::Http(resp.status().as_u16())); + } + let bytes = resp + .bytes() + .await + .map_err(|e| ThemeStoreError::Network(e.to_string()))?; + if bytes.len() as u64 > MAX_PREVIEW_BYTES { + return Err(ThemeStoreError::Oversized { + expected: MAX_PREVIEW_BYTES, + got: bytes.len() as u64, + }); + } + Ok(bytes.to_vec()) + } } /// Checks downloaded `bytes` against the catalog `entry`'s declared diff --git a/solitaire_engine/src/theme_store_plugin.rs b/solitaire_engine/src/theme_store_plugin.rs index 49645c5..a635854 100644 --- a/solitaire_engine/src/theme_store_plugin.rs +++ b/solitaire_engine/src/theme_store_plugin.rs @@ -14,6 +14,9 @@ //! filesystem-based; the plugin is gated out on wasm32 alongside //! `SyncPlugin`. +use std::collections::HashMap; + +use bevy::asset::RenderAssetUsages; use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use thiserror::Error; @@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task)>); #[derive(Resource, Default)] struct StoreBaseUrl(Option); +/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime: +/// previews survive closing and reopening the store, so a revisit +/// renders instantly without refetching. +#[derive(Resource, Default)] +struct PreviewCache(HashMap>); + +/// One in-flight preview download: raw PNG bytes or the fetch error. +type PreviewFetch = Task, ThemeStoreError>>; + +/// In-flight preview downloads, one per catalog entry that advertises +/// a `preview_url` not already in [`PreviewCache`]. `String` is the +/// theme id the bytes belong to. +#[derive(Resource, Default)] +struct PreviewTasks(Vec<(String, PreviewFetch)>); + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -118,6 +136,8 @@ impl Plugin for ThemeStorePlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() // Esc-close reads keyboard input; register defensively so // the plugin works under MinimalPlugins in tests. .init_resource::>() @@ -129,6 +149,7 @@ impl Plugin for ThemeStorePlugin { ( handle_open_request, poll_catalog_task, + poll_preview_tasks, handle_install_buttons, poll_install_task, handle_close_button, @@ -176,6 +197,7 @@ fn handle_open_request( mut catalog_task: ResMut, mut store_base: ResMut, mut warning_toast: MessageWriter, + previews: Res, mut commands: Commands, font_res: Option>, ) { @@ -207,16 +229,28 @@ fn handle_open_request( rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await }) })); - spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref()); + spawn_store_modal( + &mut commands, + &catalog_state, + None, + &previews, + font_res.as_deref(), + ); } -/// Polls the catalog fetch; on completion updates [`CatalogState`] and +/// Polls the catalog fetch; on completion updates [`CatalogState`], +/// kicks off preview downloads for entries not yet in the cache, and /// rebuilds the modal if it is still open. +#[allow(clippy::too_many_arguments)] fn poll_catalog_task( mut catalog_task: ResMut, mut catalog_state: ResMut, screens: Query>, registry: Option>, + rt: Option>, + store_base: Res, + previews: Res, + mut preview_tasks: ResMut, mut commands: Commands, font_res: Option>, ) { @@ -229,7 +263,29 @@ fn poll_catalog_task( catalog_task.0 = None; *catalog_state = match result { - Ok(entries) => CatalogState::Loaded(entries), + Ok(entries) => { + // Fetch previews for anything new. Decorative — failures + // just leave the row text-only, so errors only log. + if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) { + for entry in entries + .iter() + .filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id)) + { + let rt = rt.0.clone(); + let base_url = base_url.to_owned(); + let entry = entry.clone(); + preview_tasks.0.push(( + entry.id.clone(), + AsyncComputeTaskPool::get().spawn(async move { + rt.block_on(async { + ThemeStoreClient::new(base_url).fetch_preview(&entry).await + }) + }), + )); + } + } + CatalogState::Loaded(entries) + } Err(e) => { warn!("theme store: catalog fetch failed: {e}"); CatalogState::Error(e.to_string()) @@ -241,10 +297,69 @@ fn poll_catalog_task( &mut commands, &catalog_state, registry.as_deref(), + &previews, font_res.as_deref(), ); } +/// Polls in-flight preview downloads. Each finished PNG is decoded into +/// an [`Image`] asset and cached by theme id; the open modal rebuilds +/// once per frame that added at least one preview, so thumbnails pop in +/// as they arrive. Failures log and leave the row text-only. Skipped +/// entirely when `Assets` is absent (`MinimalPlugins` tests). +#[allow(clippy::too_many_arguments)] +fn poll_preview_tasks( + mut preview_tasks: ResMut, + mut previews: ResMut, + images: Option>>, + catalog_state: Res, + screens: Query>, + registry: Option>, + mut commands: Commands, + font_res: Option>, +) { + if preview_tasks.0.is_empty() { + return; + } + let Some(mut images) = images else { + return; + }; + + let mut added = false; + preview_tasks.0.retain_mut(|(id, task)| { + let Some(result) = future::block_on(future::poll_once(task)) else { + return true; // still downloading + }; + match result { + Ok(bytes) => match image::load_from_memory(&bytes) { + Ok(dyn_img) => { + let handle = images.add(Image::from_dynamic( + dyn_img, + true, + RenderAssetUsages::RENDER_WORLD, + )); + previews.0.insert(id.clone(), handle); + added = true; + } + Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"), + }, + Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"), + } + false + }); + + if added { + rebuild_open_modal( + &screens, + &mut commands, + &catalog_state, + registry.as_deref(), + &previews, + font_res.as_deref(), + ); + } +} + /// Starts a download + import task when an Install button is pressed. /// One install at a time; repeat clicks while busy are ignored. fn handle_install_buttons( @@ -308,6 +423,7 @@ fn poll_install_task( screens: Query>, mut info_toast: MessageWriter, mut warning_toast: MessageWriter, + previews: Res, mut commands: Commands, font_res: Option>, ) { @@ -343,6 +459,7 @@ fn poll_install_task( &mut commands, &catalog_state, registry.as_deref(), + &previews, font_res.as_deref(), ); } @@ -378,11 +495,12 @@ fn rebuild_open_modal( commands: &mut Commands, catalog_state: &CatalogState, registry: Option<&ThemeRegistry>, + previews: &PreviewCache, font_res: Option<&FontResource>, ) { for entity in screens { commands.entity(entity).despawn(); - spawn_store_modal(commands, catalog_state, registry, font_res); + spawn_store_modal(commands, catalog_state, registry, previews, font_res); } } @@ -391,6 +509,7 @@ fn spawn_store_modal( commands: &mut Commands, catalog_state: &CatalogState, registry: Option<&ThemeRegistry>, + previews: &PreviewCache, font_res: Option<&FontResource>, ) { let body_font = TextFont { @@ -433,7 +552,15 @@ fn spawn_store_modal( for entry in entries { let installed = registry.is_some_and(|registry| registry.find(&entry.id).is_some()); - spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res); + spawn_store_row( + card, + entry, + installed, + previews.0.get(&entry.id), + &body_font, + &caption_font, + font_res, + ); } } } @@ -452,12 +579,20 @@ fn spawn_store_modal( commands.entity(scrim).insert(ScrimDismissible); } -/// One catalog row: name + author/size caption on the left, Install -/// button (or "Installed" caption) on the right. +/// Height of a row's preview thumbnail in logical pixels; the width +/// follows the theme's own `card_aspect` so a preview is never +/// stretched. +const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0; + +/// One catalog row: preview thumbnail (when downloaded) + name + +/// author/size caption on the left, Install button (or "Installed" +/// caption) on the right. +#[allow(clippy::too_many_arguments)] fn spawn_store_row( parent: &mut ChildSpawnerCommands, entry: &ThemeCatalogEntry, installed: bool, + preview: Option<&Handle>, body_font: &TextFont, caption_font: &TextFont, font_res: Option<&FontResource>, @@ -477,25 +612,45 @@ fn spawn_store_row( )) .with_children(|row| { row.spawn(Node { - flex_direction: FlexDirection::Column, - row_gap: VAL_SPACE_2, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, ..default() }) - .with_children(|col| { - col.spawn(( - Text::new(entry.name.clone()), - body_font.clone(), - TextColor(TEXT_PRIMARY), - )); - col.spawn(( - Text::new(format!( - "by {} — {} KB", - entry.author, - entry.size_bytes.div_ceil(1024) - )), - caption_font.clone(), - TextColor(TEXT_SECONDARY), - )); + .with_children(|left| { + if let Some(handle) = preview { + let (aw, ah) = entry.card_aspect; + let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32; + left.spawn(( + ImageNode::new(handle.clone()), + Node { + width: Val::Px(width), + height: Val::Px(PREVIEW_THUMB_HEIGHT_PX), + ..default() + }, + )); + } + left.spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|col| { + col.spawn(( + Text::new(entry.name.clone()), + body_font.clone(), + TextColor(TEXT_PRIMARY), + )); + col.spawn(( + Text::new(format!( + "by {} — {} KB", + entry.author, + entry.size_bytes.div_ceil(1024) + )), + caption_font.clone(), + TextColor(TEXT_SECONDARY), + )); + }); }); if installed { row.spawn(( @@ -661,4 +816,85 @@ mod tests { )); assert_eq!(screen_count(&mut app), 1); } + + // ----------------------------------------------------------------------- + // Phase H part 2: preview thumbnails + // ----------------------------------------------------------------------- + + fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry { + ThemeCatalogEntry { + id: id.into(), + name: id.into(), + author: "Test".into(), + version: "1.0.0".into(), + card_aspect: (2, 3), + size_bytes: 1024, + sha256: "00".into(), + download_url: format!("/api/themes/{id}/download"), + preview_url: preview.then(|| format!("/api/themes/{id}/preview")), + } + } + + /// A row whose theme id is in the [`PreviewCache`] renders an + /// `ImageNode` thumbnail; a row without one stays text-only. + #[test] + fn loaded_row_shows_preview_thumbnail_only_when_cached() { + let mut app = headless_app(); + let cached = catalog_entry("with-preview", true); + let uncached = catalog_entry("without-preview", false); + + let mut cache = PreviewCache::default(); + cache + .0 + .insert(cached.id.clone(), Handle::::default()); + let state = CatalogState::Loaded(vec![cached, uncached]); + + { + let world = app.world_mut(); + let mut commands = world.commands(); + spawn_store_modal(&mut commands, &state, None, &cache, None); + } + app.update(); + + let thumbs = app + .world_mut() + .query::<&ImageNode>() + .iter(app.world()) + .count(); + assert_eq!( + thumbs, 1, + "exactly the cached entry must render a preview thumbnail" + ); + } + + /// The thumbnail width follows the theme's own card aspect so a + /// preview never renders stretched. + #[test] + fn preview_thumbnail_width_follows_card_aspect() { + let mut app = headless_app(); + let mut entry = catalog_entry("wide", true); + entry.card_aspect = (1, 1); // square theme art + let mut cache = PreviewCache::default(); + cache.0.insert(entry.id.clone(), Handle::::default()); + let state = CatalogState::Loaded(vec![entry]); + + { + let world = app.world_mut(); + let mut commands = world.commands(); + spawn_store_modal(&mut commands, &state, None, &cache, None); + } + app.update(); + + let node = app + .world_mut() + .query_filtered::<&Node, With>() + .single(app.world()) + .expect("thumbnail node must exist"); + assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX)); + assert_eq!( + node.width, + Val::Px(PREVIEW_THUMB_HEIGHT_PX), + "a 1:1 aspect must yield a square thumbnail" + ); + } } From ddee605874bd6212798f301f453524d2405c43d0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 17:40:53 -0700 Subject: [PATCH 2/2] feat(engine): hint ghost-motion preview (Phase H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hint now spawns a translucent copy of the hinted card that glides to the suggested destination twice (0.7s per pass, SmoothSnap easing, tail fade so the loop reads as a repeat), alongside the existing static source/destination highlights. Auto-disabled under reduce-motion — the static highlights remain the whole story. The ghost despawns on timer, on a fresh hint, or the moment the board changes (a preview of a stale board is worse than none). Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/feedback_anim_plugin.rs | 307 ++++++++++++++++++- 1 file changed, 304 insertions(+), 3 deletions(-) diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index a364e14..fa6cda4 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot; use solitaire_data::AnimSpeed; use crate::animation_plugin::CardAnim; -use crate::card_plugin::CardEntity; +use crate::card_animation::{MotionCurve, sample_curve}; +use crate::card_plugin::{CardEntity, CardEntityIndex}; use crate::events::{ - DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent, - NewGameRequestEvent, + DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent, + MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, }; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; @@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() + .add_message::() .add_message::() .add_systems( Update, @@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin { start_deal_anim.after(GameMutation), start_foundation_flourish.after(GameMutation), ), + ) + // Hint ghost (Phase H): the spawn reads card Transform/Sprite, + // so it orders after the board painters; the tick only touches + // ghost entities (Without) and stays conflict-free. + .add_systems( + Update, + ( + spawn_hint_ghost + .after(GameMutation) + .after(crate::card_plugin::BoardVisuals), + tick_hint_ghosts, + despawn_hint_ghosts_on_state_change.after(GameMutation), + ) + .chain(), ); } } @@ -664,6 +681,147 @@ fn pile_cards( } } +// --------------------------------------------------------------------------- +// Phase H — hint ghost-motion preview +// --------------------------------------------------------------------------- + +/// Duration of one ghost glide from the hinted card to its destination. +const HINT_GHOST_PASS_SECS: f32 = 0.7; +/// How many glides one hint plays before the ghost despawns. Two reads +/// as "this move, over there" without outstaying the 2 s static +/// highlight it accompanies. +const HINT_GHOST_PASSES: f32 = 2.0; +/// Ghost translucency — clearly a projection, never mistakable for the +/// real card. +const HINT_GHOST_ALPHA: f32 = 0.45; +/// Ghost render depth: above every settled pile (~1.04 max) and the +/// in-flight `CardAnim` lift (50), below a dragged card (500). +const HINT_GHOST_Z: f32 = 400.0; + +/// A translucent copy of the hinted card gliding to the suggested +/// destination (Phase H). Purely decorative — despawned by timer, by a +/// newer hint, or by any state change. +#[derive(Component, Debug)] +pub struct HintGhost { + start: Vec3, + target: Vec3, + elapsed: f32, +} + +/// Normalised progress of the current glide pass, restarting from the +/// source each pass. Pure for unit testing. +fn hint_ghost_pass_t(elapsed: f32) -> f32 { + (elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS +} + +/// Ghost alpha at `pass_t` — full strength for most of the glide, then +/// fading over the last 20 % so the loop restart reads as a repeat +/// rather than a teleport. Pure for unit testing. +fn hint_ghost_alpha(pass_t: f32) -> f32 { + let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0); + HINT_GHOST_ALPHA * (1.0 - fade_in_tail) +} + +/// Spawns the ghost when a hint fires. The static highlights (source +/// card + gold destination pile) still spawn regardless; under +/// reduce-motion they are the whole story and no ghost appears +/// (`design-system.md` §Accessibility). +#[allow(clippy::too_many_arguments)] +fn spawn_hint_ghost( + mut events: MessageReader, + settings: Option>, + index: Option>, + layout: Option>, + cards: Query<(&Transform, &Sprite), With>, + existing: Query>, + mut commands: Commands, +) { + if events.is_empty() { + return; + } + if settings.is_some_and(|s| s.0.reduce_motion_mode) { + events.clear(); + return; + } + let (Some(index), Some(layout)) = (index, layout) else { + events.clear(); + return; + }; + for ev in events.read() { + // A fresh hint replaces any ghost still in flight. + for entity in &existing { + commands.entity(entity).despawn(); + } + let Some(card_entity) = index.get(&ev.source_card) else { + continue; + }; + let Ok((transform, sprite)) = cards.get(card_entity) else { + continue; + }; + let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else { + continue; + }; + let start = transform.translation.truncate().extend(HINT_GHOST_Z); + let mut ghost_sprite = sprite.clone(); + ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA); + commands.spawn(( + HintGhost { + start, + target: dest.extend(HINT_GHOST_Z), + elapsed: 0.0, + }, + ghost_sprite, + Transform::from_translation(start), + )); + } +} + +/// Advances every ghost: eased glide per pass, tail fade, despawn after +/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other +/// decorative animation. +#[allow(clippy::type_complexity)] +fn tick_hint_ghosts( + time: Res