From e3188faddcb112d1154174e0220e2c6d969de3e7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 27 May 2026 13:17:28 -0700 Subject: [PATCH] =?UTF-8?q?fix(engine):=20foundation=E2=86=92tableau=20dra?= =?UTF-8?q?g=20hints,=20z-lift,=20and=20Android=20battery=20drain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- solitaire_app/src/lib.rs | 40 ++++++++++++++++++++---- solitaire_engine/src/animation_plugin.rs | 2 +- solitaire_engine/src/card_plugin.rs | 9 ++++-- solitaire_engine/src/input_plugin.rs | 38 +++++++++++++++++++--- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index 16512a5..840b445 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -19,6 +19,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use bevy::prelude::*; use bevy::window::{MonitorSelection, PresentMode, WindowPosition}; +#[cfg(target_os = "android")] +use bevy::winit::{UpdateMode, WinitSettings}; #[cfg(not(target_os = "android"))] use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow}; #[cfg(not(target_os = "android"))] @@ -112,12 +114,22 @@ pub fn run() { name: Some("ferrous-solitaire".into()), resolution: window_resolution, position: window_position, - // AutoNoVsync prefers Mailbox (triple-buffered) and - // falls back to Immediate, eliminating the vsync stall - // that AutoVsync produces during continuous window - // resize on X11 / Wayland. The game's frame budget is - // small enough that a few stray dropped frames from - // disabling vsync are imperceptible. + // On Android, AutoVsync caps the GPU at the display + // refresh rate (~60-90 fps). Without it the renderer + // spins as fast as the hardware allows, keeping the + // GPU fully loaded and draining the battery even when + // the game is completely idle. + // + // 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, // Android windows always fill the screen; max_width/max_height // default to 0.0, which panics Bevy's clamp when min > max. @@ -204,6 +216,22 @@ pub fn run() { .add_plugins(SplashPlugin) .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 // `Window::icon` field; the icon is set through the underlying // `winit::window::Window` via `WinitWindows`. Android draws its diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 1a80455..20f6bf5 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -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 /// `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). /// diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 9c90be6..c86728e 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -23,7 +23,7 @@ use solitaire_core::pile::PileType; 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::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::game_plugin::GameMutation; @@ -963,7 +963,12 @@ fn update_card_entity( if !has_card_animation { // Slide to the new position when it differs meaningfully; snap otherwise. 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 .entity(entity) .insert(Transform::from_translation(start)) diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 2d729db..468954e 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -734,8 +734,13 @@ fn end_drag( .is_some_and(|p| can_place_on_foundation(&bottom_card, p)) } PileType::Tableau(_) => { - game.0.piles.get(&target) - .is_some_and(|p| can_place_on_tableau(&bottom_card, p)) + // 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)) } _ => false, }; @@ -988,8 +993,13 @@ fn touch_end_drag( .is_some_and(|p| can_place_on_foundation(&bottom_card, p)) } PileType::Tableau(_) => { - game.0.piles.get(&target) - .is_some_and(|p| can_place_on_tableau(&bottom_card, p)) + // 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)) } _ => 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. if hints.is_empty() { let stock_non_empty = game.piles.get(&PileType::Stock)