fix(engine): share Draw-Three waste fan step between renderer and hit-test
The waste fan x-offset was computed two ways: the renderer used tableau_col_step * 0.224 while the hit-test hard-coded card_size.x * 0.28. These coincide on desktop (col_step = 1.25*cw) but drift on Android, where tighter column spacing (H_GAP_DIVISOR=32, col_step ~= 1.03*cw) makes the renderer fan at ~0.231*cw. The top fanned waste card's sprite then sits ~14px left of its click target, so dragging the visible top card grabs the card beneath it. Extract waste_fan_step() and tableau_col_step() as the single source for both the renderer (card_plugin::card_positions) and the hit-test (input_plugin::card_position) so they can no longer diverge. Add a regression test that simulates Android-tight spacing and asserts the hit target tracks the renderer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||
use crate::card_plugin::{
|
||||
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
|
||||
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
|
||||
};
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
@@ -1175,12 +1175,15 @@ fn card_position(
|
||||
Vec2::new(base.x, base.y + y_offset)
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||
// card_plugin::card_positions(). Hit-testing must use the same offsets
|
||||
// so clicking the visually rightmost (top) card actually registers.
|
||||
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||
// so clicking the visually rightmost (top) card actually registers — a
|
||||
// fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
|
||||
// on Android (tighter column spacing), shifting the top card's hit target
|
||||
// onto the card beneath it.
|
||||
let pile_len = game.waste_cards().len();
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = stack_index.saturating_sub(visible_start) as f32;
|
||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||
Vec2::new(base.x + slot * waste_fan_step(layout), base.y)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
@@ -1948,6 +1951,52 @@ mod tests {
|
||||
assert_eq!(result.2, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||
// fanned card's click target lands on the card beneath it — so dragging
|
||||
// the visible top card plays the wrong one.
|
||||
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||
let cw = layout.card_size.x;
|
||||
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
layout.pile_positions.insert(
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||
);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||
];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||
let top_index = waste.len() - 1;
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
|
||||
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||
assert!(
|
||||
(pos.x - expected).abs() < 1e-3,
|
||||
"hit-test must use the shared waste fan step"
|
||||
);
|
||||
// The old fixed constant would have drifted from the renderer here.
|
||||
let old = base.x + 2.0 * cw * 0.28;
|
||||
assert!(
|
||||
(pos.x - old).abs() > 1.0,
|
||||
"shared step must differ from the old fixed step under tight spacing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_face_down_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
|
||||
Reference in New Issue
Block a user