feat(app): Android build target — first working APK at 54 MB
Wires the workspace through `cargo apk build`. After this commit `cargo apk build -p solitaire_app --target x86_64-linux-android` produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk` containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable on the AVD or a physical x86_64 device. The five gating points discovered by iterating compile cycles: 1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib` to bundle as `libmain.so`; pure-bin crates panic with "Bin is not compatible with Cdylib". `src/lib.rs` carries the ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim that delegates for the desktop `cargo run` path. 2. `[package.metadata.android]` pins target SDK 34 / min SDK 26 so cargo-apk doesn't probe for whatever default it ships (which on this machine was an uninstalled API 30). `assets = "../assets"` lets the same asset directory feed both desktop and APK. 3. Workspace `bevy` features add `android-native-activity` (the Bevy-side glue that pairs with cargo-apk's NativeActivity wrapper). The feature is target-gated inside bevy_internal so desktop builds compile it out. 4. `arboard` (clipboard, used by Stats's "Copy share link") has no Android backend — `cargo apk build` fails with E0433 on `platform::Clipboard` if unconditional. Target-gated to `cfg(not(target_os = "android"))`; the system surfaces an informational toast on Android until JNI ClipboardManager is wired in the Phase-Android round. 5. `keyring` + `keyring-core` cannot compile for android — the transitive `rpassword` uses `libc::__errno_location` which bionic doesn't expose. Both crates target-gated; `auth_tokens` ships a stub on Android that returns `KeychainUnavailable` for every call, matching how callers already handle a Linux box without Secret Service. Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed when it tries to also wrap the bin target. The APK on disk is unaffected. Working around this with `cargo apk build --lib` is the next small step. What's verified: - Desktop `cargo build`, `cargo clippy --workspace --all-targets`, and `cargo test --workspace` all clean. - `cargo apk build -p solitaire_app --target x86_64-linux-android` produces 54 MB debug APK with libsolitaire_app.so + assets. What's NOT yet verified: - Whether the APK actually launches on the AVD / a phone (next step: `adb install` + `adb logcat` against the bevy_test AVD). - Whether `dirs::data_dir()` on Android returns a usable path (sync / persistence will surface this if not). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,68 @@ edition.workspace = true
|
||||
name = "solitaire_app"
|
||||
path = "src/main.rs"
|
||||
|
||||
# `cdylib` is what cargo-apk packages into `libsolitaire_app.so` for
|
||||
# Android — the activity dlopens the shared object and calls into it.
|
||||
# `rlib` lets the bin target above link the library normally on
|
||||
# desktop. Both produce the same code; only the linkage form differs.
|
||||
[lib]
|
||||
name = "solitaire_app"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
bevy = { workspace = true }
|
||||
solitaire_engine = { workspace = true }
|
||||
solitaire_data = { workspace = true }
|
||||
|
||||
# `keyring`'s default-store init only matters on platforms with a
|
||||
# real keychain backend (Linux Secret Service, macOS Keychain,
|
||||
# Windows Credential Store). The crate also pulls `rpassword`
|
||||
# transitively, which uses `libc::__errno_location` — a symbol
|
||||
# Android's bionic doesn't expose. Target-gating keeps
|
||||
# `cargo apk build` viable; the call site in `lib.rs` has its own
|
||||
# `cfg(not(target_os = "android"))` guard so the desktop init path
|
||||
# is unchanged.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring = { workspace = true }
|
||||
|
||||
# --- Android packaging metadata (read by `cargo-apk`) -------------------
|
||||
#
|
||||
# Pinning these values inside the repo means a contributor running
|
||||
# `cargo apk build -p solitaire_app --target x86_64-linux-android`
|
||||
# does not need to install whatever SDK version cargo-apk happens to
|
||||
# default to today. The numbers track the SDK we install in the dev
|
||||
# setup script: target SDK 34 (Android 14, current Play Store target),
|
||||
# min SDK 26 (Android 8, the lowest Bevy 0.18 supports cleanly with
|
||||
# the wgpu / GLES path).
|
||||
#
|
||||
# Asset path is `../assets` so the same directory the desktop build
|
||||
# already uses ships into the APK without copy-tree gymnastics.
|
||||
# `apk_name` keeps the output filename predictable across machines.
|
||||
[package.metadata.android]
|
||||
package = "com.solitairequest.app"
|
||||
apk_name = "solitaire-quest"
|
||||
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
||||
assets = "../assets"
|
||||
# No `runtime_libs` — we don't ship any precompiled .so files,
|
||||
# the entire app is pure Rust + Bevy. cargo-apk would try to
|
||||
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
|
||||
# arch directory under our package.
|
||||
strip = "strip"
|
||||
|
||||
[package.metadata.android.sdk]
|
||||
target_sdk_version = 34
|
||||
min_sdk_version = 26
|
||||
|
||||
[[package.metadata.android.uses_feature]]
|
||||
name = "android.hardware.touchscreen"
|
||||
required = true
|
||||
|
||||
[[package.metadata.android.uses_permission]]
|
||||
name = "android.permission.INTERNET"
|
||||
|
||||
[package.metadata.android.application]
|
||||
label = "Solitaire Quest"
|
||||
# `debuggable` defaults to false on release builds; cargo-apk flips it
|
||||
# automatically for debug profiles. Leaving the field unset keeps the
|
||||
# default behaviour.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Library entry point for `solitaire_app`.
|
||||
//!
|
||||
//! The app is a `cdylib + bin` hybrid: desktop builds run through the
|
||||
//! `bin` target's [`main`](crate::main_desktop) shim; Android builds
|
||||
//! load this `cdylib` via NativeActivity / GameActivity, which calls
|
||||
//! into the platform's own `main` glue. Both paths converge on
|
||||
//! [`run`], so the ECS bootstrap is single-sourced.
|
||||
//!
|
||||
//! Why split this out: cargo-apk requires the package to expose a
|
||||
//! `cdylib` library target — the Android activity dlopens
|
||||
//! `libsolitaire_app.so` and calls into it. A bin-only crate panics
|
||||
//! at build time with `Bin is not compatible with Cdylib`. The split
|
||||
//! keeps the desktop `cargo run -p solitaire_app` flow unchanged
|
||||
//! while making `cargo apk build -p solitaire_app` viable.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
/// App entry point — builds and runs the Bevy app.
|
||||
///
|
||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||
/// Android) the platform's NativeActivity / GameActivity glue.
|
||||
pub fn run() {
|
||||
// Install a panic hook that writes a crash log next to the save files
|
||||
// before re-running the default hook (so stderr still gets the message
|
||||
// and any debugger attached still sees the panic).
|
||||
install_crash_log_hook();
|
||||
|
||||
// Initialise the platform keyring store before any token operations.
|
||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||
// If the platform has no OS keyring (e.g. a headless CI box), keyring
|
||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||
//
|
||||
// Android: `keyring` isn't compiled in (its `rpassword` transitive
|
||||
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
|
||||
// ships an Android stub that returns KeychainUnavailable for every
|
||||
// call — the runtime behaviour is "session login required each launch"
|
||||
// until we wire Android Keystore via JNI in the Phase-Android round.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if let Err(e) = keyring::use_native_store(true) {
|
||||
eprintln!(
|
||||
"warn: could not initialise OS keyring ({e}); \
|
||||
server sync login will be unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
// Load settings before building the app so we can construct the right
|
||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||
let settings: Settings = settings_file_path()
|
||||
.map(|p| load_settings_from(&p))
|
||||
.unwrap_or_default();
|
||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||
|
||||
// Restore the previous window geometry if the player has one saved.
|
||||
// Otherwise open at the platform default (1280×800, centred on the
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// sessions don't end up with a comparatively tiny window.
|
||||
let had_saved_geometry = settings.window_geometry.is_some();
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
(geom.width, geom.height).into(),
|
||||
WindowPosition::At(IVec2::new(geom.x, geom.y)),
|
||||
),
|
||||
None => (
|
||||
(1280u32, 800u32).into(),
|
||||
WindowPosition::Centered(MonitorSelection::Primary),
|
||||
),
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
// The card-theme system's `themes://` asset source must be
|
||||
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
|
||||
// because that plugin freezes the asset-source list at build
|
||||
// time. The matching `AssetSourcesPlugin` (added below) finishes
|
||||
// the wiring after `DefaultPlugins` by populating the embedded
|
||||
// default theme into Bevy's `EmbeddedAssetRegistry`.
|
||||
register_theme_asset_sources(&mut app);
|
||||
|
||||
app
|
||||
.add_plugins(
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".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.
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
// The `assets/` directory lives at the workspace root, but
|
||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
||||
// Point one level up so `cargo run -p solitaire_app` finds
|
||||
// card faces, backs, backgrounds, and the UI font.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(AssetSourcesPlugin)
|
||||
.add_plugins(ThemePlugin)
|
||||
.add_plugins(ThemeRegistryPlugin)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.add_plugins(DiagnosticsHudPlugin);
|
||||
|
||||
// Smart default window sizing: when no saved geometry was loaded,
|
||||
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||
// monitor's logical size on the first frame. Without this, a 4K
|
||||
// monitor opens the same 1280×800 window that a 1080p monitor
|
||||
// does — visually tiny relative to screen. Skipped entirely when
|
||||
// saved geometry was applied; the player's preference always wins.
|
||||
//
|
||||
// Players who specifically want the literal 1280×800 baseline on
|
||||
// every fresh launch can flip `disable_smart_default_size` in
|
||||
// Settings to opt out. The flag is checked once at startup; a
|
||||
// mid-session change applies on the next launch.
|
||||
if !had_saved_geometry && !settings.disable_smart_default_size {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
/// One-shot Update system that runs only on launches without saved
|
||||
/// window geometry. Resizes the primary window to a fraction of the
|
||||
/// primary monitor's *logical* size — bigger monitors get bigger
|
||||
/// windows automatically. Logical size already accounts for the OS's
|
||||
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
|
||||
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
|
||||
/// target window — same physical inches as a 1920×1080 monitor with
|
||||
/// scale_factor 1.0 yielding 1344×756.
|
||||
///
|
||||
/// Uses `Local<bool>` to make itself one-shot rather than introducing
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
if *applied {
|
||||
return;
|
||||
}
|
||||
let Ok(monitor) = monitors.single() else {
|
||||
// Primary monitor not yet spawned by bevy_winit. Try again
|
||||
// next frame; the cost is one early-exit per tick until
|
||||
// monitors arrive (typically frame 1 or 2).
|
||||
return;
|
||||
};
|
||||
let Ok(mut window) = windows.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = monitor.scale_factor as f32;
|
||||
if scale <= 0.0 {
|
||||
// Defensive: a zero or negative scale factor would NaN the
|
||||
// arithmetic below. Bail and accept the default size.
|
||||
*applied = true;
|
||||
return;
|
||||
}
|
||||
let logical_w = monitor.physical_width as f32 / scale;
|
||||
let logical_h = monitor.physical_height as f32 / scale;
|
||||
|
||||
// Target 70 % of monitor in each dimension, clamped to the
|
||||
// existing 800×600 minimum and the monitor's own logical size
|
||||
// (so we never request a window larger than the screen).
|
||||
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
|
||||
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
|
||||
|
||||
// Resize only when the change is meaningful — at exactly 1280×800
|
||||
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
|
||||
// wider), worth the resize; at the same default on an 800×600
|
||||
// monitor the clamp pins us at 800×600 and we shouldn't resize.
|
||||
let curr_w = window.resolution.width();
|
||||
let curr_h = window.resolution.height();
|
||||
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
|
||||
window.resolution.set(target_w, target_h);
|
||||
}
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
/// unchanged. If the data directory is unavailable, the wrapper silently
|
||||
/// falls through — the default hook handles output either way.
|
||||
fn install_crash_log_hook() {
|
||||
let crash_log_path = settings_file_path().and_then(|p| {
|
||||
p.parent()
|
||||
.map(|parent| parent.join("crash.log"))
|
||||
});
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if let Some(path) = crash_log_path.as_ref()
|
||||
&& let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
// Plain unix-seconds timestamp keeps the format trivially
|
||||
// parseable and avoids pulling in chrono just for this.
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||
}
|
||||
default_hook(info);
|
||||
}));
|
||||
}
|
||||
+6
-252
@@ -1,255 +1,9 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{
|
||||
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||
};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
//! Desktop entry point for `solitaire_app`.
|
||||
//!
|
||||
//! The body of the app lives in `lib.rs` so cargo-apk can package the
|
||||
//! same code into an Android `cdylib`. This shim is the desktop /
|
||||
//! `cargo run` path — it just delegates to [`solitaire_app::run`].
|
||||
|
||||
fn main() {
|
||||
// Install a panic hook that writes a crash log next to the save files
|
||||
// before re-running the default hook (so stderr still gets the message
|
||||
// and any debugger attached still sees the panic).
|
||||
install_crash_log_hook();
|
||||
|
||||
// Initialise the platform keyring store before any token operations.
|
||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||
// If the platform has no OS keyring (e.g. a headless CI box), keyring
|
||||
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
||||
if let Err(e) = keyring::use_native_store(true) {
|
||||
eprintln!(
|
||||
"warn: could not initialise OS keyring ({e}); \
|
||||
server sync login will be unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
// Load settings before building the app so we can construct the right
|
||||
// sync provider. Falls back to defaults if no settings file exists yet.
|
||||
let settings: Settings = settings_file_path()
|
||||
.map(|p| load_settings_from(&p))
|
||||
.unwrap_or_default();
|
||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||
|
||||
// Restore the previous window geometry if the player has one saved.
|
||||
// Otherwise open at the platform default (1280×800, centred on the
|
||||
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||
// sessions don't end up with a comparatively tiny window.
|
||||
let had_saved_geometry = settings.window_geometry.is_some();
|
||||
let (window_resolution, window_position) = match settings.window_geometry {
|
||||
Some(geom) => (
|
||||
(geom.width, geom.height).into(),
|
||||
WindowPosition::At(IVec2::new(geom.x, geom.y)),
|
||||
),
|
||||
None => (
|
||||
(1280u32, 800u32).into(),
|
||||
WindowPosition::Centered(MonitorSelection::Primary),
|
||||
),
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
// The card-theme system's `themes://` asset source must be
|
||||
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
|
||||
// because that plugin freezes the asset-source list at build
|
||||
// time. The matching `AssetSourcesPlugin` (added below) finishes
|
||||
// the wiring after `DefaultPlugins` by populating the embedded
|
||||
// default theme into Bevy's `EmbeddedAssetRegistry`.
|
||||
register_theme_asset_sources(&mut app);
|
||||
|
||||
app
|
||||
.add_plugins(
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".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.
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
// The `assets/` directory lives at the workspace root, but
|
||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
||||
// Point one level up so `cargo run -p solitaire_app` finds
|
||||
// card faces, backs, backgrounds, and the UI font.
|
||||
.set(bevy::asset::AssetPlugin {
|
||||
file_path: "../assets".to_string(),
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(AssetSourcesPlugin)
|
||||
.add_plugins(ThemePlugin)
|
||||
.add_plugins(ThemeRegistryPlugin)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.add_plugins(ChallengePlugin)
|
||||
.add_plugins(TimeAttackPlugin)
|
||||
.add_plugins(HudPlugin)
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(LeaderboardPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
.add_plugins(UiTooltipPlugin)
|
||||
.add_plugins(SplashPlugin)
|
||||
.add_plugins(DiagnosticsHudPlugin);
|
||||
|
||||
// Smart default window sizing: when no saved geometry was loaded,
|
||||
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||
// monitor's logical size on the first frame. Without this, a 4K
|
||||
// monitor opens the same 1280×800 window that a 1080p monitor
|
||||
// does — visually tiny relative to screen. Skipped entirely when
|
||||
// saved geometry was applied; the player's preference always wins.
|
||||
//
|
||||
// Players who specifically want the literal 1280×800 baseline on
|
||||
// every fresh launch can flip `disable_smart_default_size` in
|
||||
// Settings to opt out. The flag is checked once at startup; a
|
||||
// mid-session change applies on the next launch.
|
||||
if !had_saved_geometry && !settings.disable_smart_default_size {
|
||||
app.add_systems(Update, apply_smart_default_window_size);
|
||||
}
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
/// One-shot Update system that runs only on launches without saved
|
||||
/// window geometry. Resizes the primary window to a fraction of the
|
||||
/// primary monitor's *logical* size — bigger monitors get bigger
|
||||
/// windows automatically. Logical size already accounts for the OS's
|
||||
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
|
||||
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
|
||||
/// target window — same physical inches as a 1920×1080 monitor with
|
||||
/// scale_factor 1.0 yielding 1344×756.
|
||||
///
|
||||
/// Uses `Local<bool>` to make itself one-shot rather than introducing
|
||||
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||
/// populates the `Monitor` entities asynchronously after winit's
|
||||
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||
fn apply_smart_default_window_size(
|
||||
mut applied: Local<bool>,
|
||||
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
if *applied {
|
||||
return;
|
||||
}
|
||||
let Ok(monitor) = monitors.single() else {
|
||||
// Primary monitor not yet spawned by bevy_winit. Try again
|
||||
// next frame; the cost is one early-exit per tick until
|
||||
// monitors arrive (typically frame 1 or 2).
|
||||
return;
|
||||
};
|
||||
let Ok(mut window) = windows.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = monitor.scale_factor as f32;
|
||||
if scale <= 0.0 {
|
||||
// Defensive: a zero or negative scale factor would NaN the
|
||||
// arithmetic below. Bail and accept the default size.
|
||||
*applied = true;
|
||||
return;
|
||||
}
|
||||
let logical_w = monitor.physical_width as f32 / scale;
|
||||
let logical_h = monitor.physical_height as f32 / scale;
|
||||
|
||||
// Target 70 % of monitor in each dimension, clamped to the
|
||||
// existing 800×600 minimum and the monitor's own logical size
|
||||
// (so we never request a window larger than the screen).
|
||||
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
|
||||
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
|
||||
|
||||
// Resize only when the change is meaningful — at exactly 1280×800
|
||||
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
|
||||
// wider), worth the resize; at the same default on an 800×600
|
||||
// monitor the clamp pins us at 800×600 and we shouldn't resize.
|
||||
let curr_w = window.resolution.width();
|
||||
let curr_h = window.resolution.height();
|
||||
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
|
||||
window.resolution.set(target_w, target_h);
|
||||
}
|
||||
*applied = true;
|
||||
}
|
||||
|
||||
/// Wraps the default panic hook with one that also appends a crash log
|
||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||
/// still runs afterwards, so stderr output and debugger integration are
|
||||
/// unchanged. If the data directory is unavailable, the wrapper silently
|
||||
/// falls through — the default hook handles output either way.
|
||||
fn install_crash_log_hook() {
|
||||
let crash_log_path = settings_file_path().and_then(|p| {
|
||||
p.parent()
|
||||
.map(|parent| parent.join("crash.log"))
|
||||
});
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if let Some(path) = crash_log_path.as_ref()
|
||||
&& let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
// Plain unix-seconds timestamp keeps the format trivially
|
||||
// parseable and avoids pulling in chrono just for this.
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||
}
|
||||
default_hook(info);
|
||||
}));
|
||||
solitaire_app::run();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user