fix(engine): foundation→tableau drag hints, z-lift, and Android battery drain
Fixes #34, #35, #36 - all_hints: add Foundation as source for Tableau hints (guarded by take_from_foundation); previously H key never suggested Foundation→Tableau - end_drag / touch_end_drag: enforce take_from_foundation at input layer so a rejected-by-core MoveRequestEvent is never fired - animation_plugin: pub CARD_ANIM_Z_LIFT so card_plugin can consume it - update_card_entity: set CardAnim start.z = z + CARD_ANIM_Z_LIFT to eliminate 1-frame z artifact where animated card appeared behind resting cards - solitaire_app: use AutoVsync on Android (caps GPU at display Hz vs spinning at 200+ fps); add WinitSettings unfocused reactive_low_power so app draws ~1fps when backgrounded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -19,6 +19,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use bevy::winit::{UpdateMode, WinitSettings};
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
|
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -112,12 +114,22 @@ pub fn run() {
|
|||||||
name: Some("ferrous-solitaire".into()),
|
name: Some("ferrous-solitaire".into()),
|
||||||
resolution: window_resolution,
|
resolution: window_resolution,
|
||||||
position: window_position,
|
position: window_position,
|
||||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
// On Android, AutoVsync caps the GPU at the display
|
||||||
// falls back to Immediate, eliminating the vsync stall
|
// refresh rate (~60-90 fps). Without it the renderer
|
||||||
// that AutoVsync produces during continuous window
|
// spins as fast as the hardware allows, keeping the
|
||||||
// resize on X11 / Wayland. The game's frame budget is
|
// GPU fully loaded and draining the battery even when
|
||||||
// small enough that a few stray dropped frames from
|
// the game is completely idle.
|
||||||
// disabling vsync are imperceptible.
|
//
|
||||||
|
// On desktop (X11 / Wayland) AutoNoVsync prefers
|
||||||
|
// Mailbox (triple-buffered) and falls back to
|
||||||
|
// Immediate, eliminating the vsync stall that
|
||||||
|
// AutoVsync produces during continuous window resize.
|
||||||
|
// The game's frame budget is small enough that a few
|
||||||
|
// stray dropped frames from disabling vsync are
|
||||||
|
// imperceptible on desktop.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
present_mode: PresentMode::AutoVsync,
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
present_mode: PresentMode::AutoNoVsync,
|
present_mode: PresentMode::AutoNoVsync,
|
||||||
// Android windows always fill the screen; max_width/max_height
|
// Android windows always fill the screen; max_width/max_height
|
||||||
// default to 0.0, which panics Bevy's clamp when min > max.
|
// default to 0.0, which panics Bevy's clamp when min > max.
|
||||||
@@ -204,6 +216,22 @@ pub fn run() {
|
|||||||
.add_plugins(SplashPlugin)
|
.add_plugins(SplashPlugin)
|
||||||
.add_plugins(DiagnosticsHudPlugin);
|
.add_plugins(DiagnosticsHudPlugin);
|
||||||
|
|
||||||
|
// On Android the default WinitSettings use UpdateMode::Continuous for
|
||||||
|
// the focused window, which means Bevy renders as fast as possible even
|
||||||
|
// when the game is completely idle. Switching to reactive_low_power with
|
||||||
|
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
||||||
|
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
||||||
|
//
|
||||||
|
// The focused mode stays Continuous so that card-slide animations remain
|
||||||
|
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
|
||||||
|
// display refresh rate (~60 Hz) when foregrounded, which already prevents
|
||||||
|
// the GPU from spinning at 200+ fps between vsync intervals.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
app.insert_resource(WinitSettings {
|
||||||
|
focused_mode: UpdateMode::Continuous,
|
||||||
|
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
||||||
|
});
|
||||||
|
|
||||||
// Wire the runtime window icon. Bevy 0.18 has no first-class
|
// Wire the runtime window icon. Bevy 0.18 has no first-class
|
||||||
// `Window::icon` field; the icon is set through the underlying
|
// `Window::icon` field; the icon is set through the underlying
|
||||||
// `winit::window::Window` via `WinitWindows`. Android draws its
|
// `winit::window::Window` via `WinitWindows`. Android draws its
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const VOLUME_TOAST_SECS: f32 = 1.4;
|
|||||||
///
|
///
|
||||||
/// 50.0 sits comfortably above the highest pile depth (~1.04) and well below
|
/// 50.0 sits comfortably above the highest pile depth (~1.04) and well below
|
||||||
/// `DRAG_Z` (500), so a dragged card always renders above an animated one.
|
/// `DRAG_Z` (500), so a dragged card always renders above an animated one.
|
||||||
const CARD_ANIM_Z_LIFT: f32 = 50.0;
|
pub const CARD_ANIM_Z_LIFT: f32 = 50.0;
|
||||||
|
|
||||||
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use solitaire_core::pile::PileType;
|
|||||||
|
|
||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration, CARD_ANIM_Z_LIFT};
|
||||||
use crate::card_animation::CardAnimation;
|
use crate::card_animation::CardAnimation;
|
||||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
@@ -963,7 +963,12 @@ fn update_card_entity(
|
|||||||
if !has_card_animation {
|
if !has_card_animation {
|
||||||
// Slide to the new position when it differs meaningfully; snap otherwise.
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||||
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
|
// Lift the card immediately on the first frame of the animation so
|
||||||
|
// it never appears behind a card that is already resting at the
|
||||||
|
// destination slot. `advance_card_anims` will maintain this lift
|
||||||
|
// throughout the tween and snap to `target` (without lift) on
|
||||||
|
// completion.
|
||||||
|
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.insert(Transform::from_translation(start))
|
.insert(Transform::from_translation(start))
|
||||||
|
|||||||
@@ -734,7 +734,12 @@ fn end_drag(
|
|||||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => {
|
||||||
game.0.piles.get(&target)
|
// Enforce the take-from-foundation rule at the input layer so the
|
||||||
|
// engine never fires a MoveRequestEvent that game_state would reject.
|
||||||
|
let foundation_allowed = !matches!(&origin, PileType::Foundation(_))
|
||||||
|
|| game.0.take_from_foundation;
|
||||||
|
foundation_allowed
|
||||||
|
&& game.0.piles.get(&target)
|
||||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -988,7 +993,12 @@ fn touch_end_drag(
|
|||||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => {
|
||||||
game.0.piles.get(&target)
|
// Enforce the take-from-foundation rule at the input layer so the
|
||||||
|
// engine never fires a MoveRequestEvent that game_state would reject.
|
||||||
|
let foundation_allowed = !matches!(&origin, PileType::Foundation(_))
|
||||||
|
|| game.0.take_from_foundation;
|
||||||
|
foundation_allowed
|
||||||
|
&& game.0.piles.get(&target)
|
||||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -1591,6 +1601,26 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass 2b — Foundation → Tableau moves (only when the rule allows it).
|
||||||
|
// Foundation piles are excluded from Pass 1 & 2's source list because they
|
||||||
|
// should never hint Foundation→Foundation. Here we handle the return path
|
||||||
|
// separately so the guarded `take_from_foundation` rule is respected.
|
||||||
|
if game.take_from_foundation {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
let from = PileType::Foundation(slot);
|
||||||
|
let Some(from_pile) = game.piles.get(&from) else { continue };
|
||||||
|
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
|
||||||
|
for i in 0..7_usize {
|
||||||
|
let dest = PileType::Tableau(i);
|
||||||
|
if let Some(dest_pile) = game.piles.get(&dest)
|
||||||
|
&& can_place_on_tableau(card, dest_pile) {
|
||||||
|
hints.push((from.clone(), dest, 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pass 3 — suggest drawing from the stock when no other hint was found.
|
// Pass 3 — suggest drawing from the stock when no other hint was found.
|
||||||
if hints.is_empty() {
|
if hints.is_empty() {
|
||||||
let stock_non_empty = game.piles.get(&PileType::Stock)
|
let stock_non_empty = game.piles.get(&PileType::Stock)
|
||||||
|
|||||||
Reference in New Issue
Block a user