Merge pull request 'fix(engine): share Draw-Three waste fan step between renderer and hit-test' (#106) from fix/draw-three-waste-fan-hittest into master
This commit was merged in pull request #106.
This commit is contained in:
@@ -63,6 +63,36 @@ pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
|||||||
// foundation piles bleeding through when a 2 sits on an Ace.
|
// foundation piles bleeding through when a 2 sits on an Ace.
|
||||||
pub const STACK_FAN_FRAC: f32 = 0.025;
|
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||||||
|
|
||||||
|
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
|
||||||
|
///
|
||||||
|
/// Derived from the actual tableau column spacing (`Tableau2.x − Tableau1.x`)
|
||||||
|
/// rather than a fixed fraction of card width, so the fan scales with the
|
||||||
|
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
|
||||||
|
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
|
||||||
|
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
|
||||||
|
/// land on the card beneath it.
|
||||||
|
pub fn waste_fan_step(layout: &Layout) -> f32 {
|
||||||
|
tableau_col_step(layout) * 0.224
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontal distance between adjacent tableau columns (`Tableau2.x −
|
||||||
|
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
|
||||||
|
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
|
||||||
|
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
|
||||||
|
pub fn tableau_col_step(layout: &Layout) -> f32 {
|
||||||
|
let t1 = layout
|
||||||
|
.pile_positions
|
||||||
|
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let t2 = layout
|
||||||
|
.pile_positions
|
||||||
|
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
(t2.x - t1.x).abs()
|
||||||
|
}
|
||||||
|
|
||||||
/// Font size as a fraction of card width.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
|
|
||||||
@@ -908,34 +938,18 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
|
|||||||
(KlondikePile::Tableau(Tableau::Tableau7), false),
|
(KlondikePile::Tableau(Tableau::Tableau7), false),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compute the Draw-Three waste fan step proportional to the column spacing
|
// Draw-Three waste fan step, proportional to the column spacing so it scales
|
||||||
// (waste_x − stock_x = card_width + h_gap) rather than a fixed fraction of
|
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
|
||||||
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
|
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
|
||||||
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
|
// click target on the card beneath it).
|
||||||
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
|
let waste_fan_step = waste_fan_step(layout);
|
||||||
// the top fanned card's centre within the waste column's own horizontal
|
|
||||||
// footprint instead of spilling into the adjacent gap.
|
|
||||||
let tableau_col_step = {
|
|
||||||
let t1 = layout
|
|
||||||
.pile_positions
|
|
||||||
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let t2 = layout
|
|
||||||
.pile_positions
|
|
||||||
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_default();
|
|
||||||
(t2.x - t1.x).abs()
|
|
||||||
};
|
|
||||||
let waste_fan_step = tableau_col_step * 0.224;
|
|
||||||
|
|
||||||
for (pile_type, is_stock_area) in piles {
|
for (pile_type, is_stock_area) in piles {
|
||||||
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
|
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
|
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
|
||||||
base.x -= tableau_col_step;
|
base.x -= tableau_col_step(layout);
|
||||||
}
|
}
|
||||||
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
|
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
|
||||||
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
|
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
|||||||
use crate::card_animation::tuning::AnimationTuning;
|
use crate::card_animation::tuning::AnimationTuning;
|
||||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||||
use crate::card_plugin::{
|
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::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
@@ -1175,12 +1175,15 @@ fn card_position(
|
|||||||
Vec2::new(base.x, base.y + y_offset)
|
Vec2::new(base.x, base.y + y_offset)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
} 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
|
// 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
|
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||||
// so clicking the visually rightmost (top) card actually registers.
|
// 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 pile_len = game.waste_cards().len();
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = stack_index.saturating_sub(visible_start) as f32;
|
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 {
|
} else {
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
@@ -1948,6 +1951,52 @@ mod tests {
|
|||||||
assert_eq!(result.2, vec![card]);
|
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]
|
#[test]
|
||||||
fn find_draggable_skips_face_down_cards() {
|
fn find_draggable_skips_face_down_cards() {
|
||||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
|||||||
Reference in New Issue
Block a user