From ddee605874bd6212798f301f453524d2405c43d0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 17:40:53 -0700 Subject: [PATCH] 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