fix(engine): eliminate waste-pile bleed-through on card draw

Only the top N waste cards are now passed through card_positions():
Draw-One renders 1 card, Draw-Three renders up to 3 fanned in X
(standard Klondike layout). Non-top waste card entities are despawned,
so nothing is visible at the waste position while the newly drawn card
slides in from the stock — the bleed-through bug is gone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 04:31:26 +00:00
parent 2a01ecdbfd
commit de01566e47
+92 -5
View File
@@ -16,7 +16,7 @@ use std::collections::{HashMap, HashSet};
use bevy::color::Color;
use bevy::prelude::*;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
@@ -199,15 +199,36 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
continue;
};
let is_tableau = matches!(pile_type, PileType::Tableau(_));
let is_waste = matches!(pile_type, PileType::Waste);
// Tableau uses a two-speed fan: face-down cards are packed tighter
// than face-up cards so the visible (playable) portion stands out.
// Non-tableau piles stack with a negligible offset.
//
// Waste pile: only the top N cards are rendered to prevent bleed-through
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
// shows up to 3 fanned in X (matching the standard Klondike presentation).
let cards = &pile.cards;
let render_start = if is_waste {
let visible = match game.draw_mode {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
cards.len().saturating_sub(visible)
} else {
0
};
let mut y_offset = 0.0_f32;
for (i, card) in cards.iter().enumerate() {
let pos = Vec2::new(base.x, base.y + y_offset);
let z = 1.0 + (i as f32) * STACK_FAN_FRAC;
for (slot, card) in cards[render_start..].iter().enumerate() {
let x_offset = if is_waste && matches!(game.draw_mode, DrawMode::DrawThree) {
// Fan left→right; top card (last slot) is rightmost and playable.
slot as f32 * layout.card_size.x * 0.28
} else {
0.0
};
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
out.push((card.clone(), pos, z));
if is_tableau {
let step = if card.face_up {
@@ -483,7 +504,8 @@ mod tests {
}
#[test]
fn card_positions_includes_all_52_cards() {
fn card_positions_includes_all_52_cards_at_game_start() {
// At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
@@ -491,6 +513,71 @@ mod tests {
assert_eq!(positions.len(), 52);
}
#[test]
fn waste_draw_one_only_renders_top_card() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawOne);
// Draw 3 cards so the waste pile has 3 cards.
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let positions = card_positions(&g, &layout);
// Filter rendered positions to only waste cards (by card ID).
let waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Draw-One: only 1 waste card should be rendered regardless of pile depth.
assert_eq!(waste_rendered.len(), 1);
// The single rendered card must be the top (last) waste card.
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
assert_eq!(waste_rendered[0].0.id, top_id);
}
#[test]
fn waste_draw_three_renders_up_to_three_fanned_cards() {
use solitaire_core::game_state::DrawMode;
let mut g = GameState::new(42, DrawMode::DrawThree);
// 5 draw() calls in Draw-Three mode accumulates multiple waste cards.
for _ in 0..5 {
let _ = g.draw();
}
let waste_pile = &g.piles[&PileType::Waste].cards;
assert!(waste_pile.len() >= 3, "need at least 3 waste cards for this test");
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0));
let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions
.iter()
.filter(|(card, _, _)| waste_ids.contains(&card.id))
.collect();
// Draw-Three: at most 3 waste cards rendered.
assert_eq!(waste_rendered.len(), 3);
// The three fanned cards must have strictly increasing X coordinates
// (left = oldest visible, right = top/playable).
waste_rendered.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
for w in waste_rendered.windows(2) {
assert!(w[1].1.x > w[0].1.x, "fanned waste cards must have distinct X positions");
}
// Top card (rightmost) must be the last card in the waste pile.
let top_id = waste_pile.last().unwrap().id;
assert_eq!(waste_rendered.last().unwrap().0.id, top_id);
}
#[test]
fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);