02ababa65f
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.
The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.
Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
7.8 KiB
Rust
176 lines
7.8 KiB
Rust
use std::fs::OpenOptions;
|
||
use std::io::Write;
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
use bevy::prelude::*;
|
||
use bevy::window::{MonitorSelection, PresentMode, 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, 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,
|
||
};
|
||
|
||
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). The window_geometry field is None on first run
|
||
// and after upgrading from a build that didn't persist geometry.
|
||
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)
|
||
.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)
|
||
.run();
|
||
}
|
||
|
||
/// 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);
|
||
}));
|
||
}
|