feat(engine): hint ghost-motion preview (Phase H)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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::<MoveRejectedEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<HintVisualEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_message::<RequestRedraw>()
|
||||
.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<CardEntity>) 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<HintVisualEvent>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
index: Option<Res<CardEntityIndex>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
|
||||
existing: Query<Entity, With<HintGhost>>,
|
||||
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<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut ghosts: Query<
|
||||
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
|
||||
(Without<CardEntity>, Without<PileMarker>),
|
||||
>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
|
||||
ghost.elapsed += dt;
|
||||
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
|
||||
commands.entity(entity).despawn();
|
||||
continue;
|
||||
}
|
||||
let pass_t = hint_ghost_pass_t(ghost.elapsed);
|
||||
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
|
||||
transform.translation = ghost.start.lerp(ghost.target, eased);
|
||||
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
|
||||
}
|
||||
}
|
||||
|
||||
/// A ghost previews a move against the *current* board; the moment the
|
||||
/// board changes (move, undo, draw, new game) it is stale and vanishes.
|
||||
fn despawn_hint_ghosts_on_state_change(
|
||||
mut events: MessageReader<StateChangedEvent>,
|
||||
ghosts: Query<Entity, With<HintGhost>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
events.clear();
|
||||
for entity in &ghosts {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests (pure functions only — no Bevy world required)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -955,4 +1113,147 @@ mod tests {
|
||||
"FoundationFlourish must not be inserted under reduce-motion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase H — hint ghost-motion preview
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hint_ghost_pass_t_restarts_each_pass() {
|
||||
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
|
||||
// Just past one full pass, progress wraps back near zero.
|
||||
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_ghost_alpha_fades_only_in_the_tail() {
|
||||
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
|
||||
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
|
||||
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
|
||||
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// App with one indexed card entity and a real layout, ready to
|
||||
/// receive `HintVisualEvent`s.
|
||||
fn ghost_app(reduce_motion: bool) -> (App, Card) {
|
||||
use solitaire_core::{Deck, Rank, Suit};
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
// Sibling systems in the plugin take GameStateResource
|
||||
// non-optionally; give them a fresh deal.
|
||||
app.insert_resource(GameStateResource(
|
||||
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
|
||||
));
|
||||
app.insert_resource(SettingsResource(solitaire_data::Settings {
|
||||
reduce_motion_mode: reduce_motion,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
CardEntity { card: card.clone() },
|
||||
Sprite::default(),
|
||||
Transform::from_xyz(-100.0, 40.0, 1.0),
|
||||
))
|
||||
.id();
|
||||
let mut index = CardEntityIndex::default();
|
||||
index.0.insert(card.clone(), entity);
|
||||
app.insert_resource(index);
|
||||
app.insert_resource(LayoutResource(crate::layout::compute_layout(
|
||||
Vec2::new(1280.0, 720.0),
|
||||
0.0,
|
||||
0.0,
|
||||
true,
|
||||
)));
|
||||
app.update();
|
||||
(app, card)
|
||||
}
|
||||
|
||||
fn fire_hint(app: &mut App, card: Card) {
|
||||
use solitaire_core::Tableau;
|
||||
app.world_mut().write_message(HintVisualEvent {
|
||||
source_card: card,
|
||||
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
|
||||
});
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn ghost_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&HintGhost>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_event_spawns_one_ghost_at_the_source_card() {
|
||||
let (mut app, card) = ghost_app(false);
|
||||
fire_hint(&mut app, card);
|
||||
|
||||
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
|
||||
let transform = app
|
||||
.world_mut()
|
||||
.query_filtered::<&Transform, With<HintGhost>>()
|
||||
.single(app.world())
|
||||
.expect("ghost transform");
|
||||
// The tick system may have advanced the ghost by one real-time
|
||||
// frame already; assert it is still essentially at the source.
|
||||
assert!(
|
||||
transform
|
||||
.translation
|
||||
.truncate()
|
||||
.distance(Vec2::new(-100.0, 40.0))
|
||||
< 5.0,
|
||||
"ghost must start at the hinted card, got {:?}",
|
||||
transform.translation
|
||||
);
|
||||
assert_eq!(transform.translation.z, HINT_GHOST_Z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_ghost_suppressed_under_reduce_motion() {
|
||||
let (mut app, card) = ghost_app(true);
|
||||
fire_hint(&mut app, card);
|
||||
|
||||
assert_eq!(
|
||||
ghost_count(&mut app),
|
||||
0,
|
||||
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_change_despawns_the_ghost() {
|
||||
let (mut app, card) = ghost_app(false);
|
||||
fire_hint(&mut app, card);
|
||||
assert_eq!(ghost_count(&mut app), 1);
|
||||
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
ghost_count(&mut app),
|
||||
0,
|
||||
"a board change makes the previewed move stale — the ghost must vanish"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_hint_replaces_the_first_ghost() {
|
||||
let (mut app, card) = ghost_app(false);
|
||||
fire_hint(&mut app, card.clone());
|
||||
fire_hint(&mut app, card);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
ghost_count(&mut app),
|
||||
1,
|
||||
"re-hinting must replace the in-flight ghost, not stack a second"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user