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 solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
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::{
|
use crate::events::{
|
||||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
|
||||||
NewGameRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
|
.add_message::<HintVisualEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim.after(GameMutation),
|
||||||
start_foundation_flourish.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)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -955,4 +1113,147 @@ mod tests {
|
|||||||
"FoundationFlourish must not be inserted under reduce-motion"
|
"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