Files
Ferrous-Solitaire/solitaire_engine/src/avatar_plugin.rs
T
funman300 6e407a3ea7
Build and Deploy / build-and-push (push) Successful in 3m54s
fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:07:22 -07:00

116 lines
4.0 KiB
Rust

//! Downloads and caches the player's server avatar for display in the
//! profile modal.
//!
//! # Flow
//!
//! 1. After a successful login/register, `sync_setup_plugin` fires
//! [`AvatarFetchEvent`] with the server base URL and the relative
//! avatar path (e.g. `/avatars/{uuid}.png`).
//! 2. [`handle_avatar_fetch`] spawns an async task on the
//! [`AsyncComputeTaskPool`] that downloads the image bytes via
//! `reqwest` (reusing the same HTTP client pattern as the sync client).
//! 3. [`poll_avatar_task`] harvests the result, decodes the bytes into a
//! Bevy [`Image`] via `image::load_from_memory`, inserts it into
//! [`Assets<Image>`], and stores the [`Handle<Image>`] in
//! [`AvatarResource`].
//! 4. `profile_plugin` reads [`AvatarResource`] when the profile modal
//! opens and renders the avatar image (or an initials fallback when
//! `AvatarResource` is `None`).
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use crate::resources::TokioRuntimeResource;
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
/// has been fetched yet (new account, no internet, or fetch in progress).
#[derive(Resource, Default)]
pub struct AvatarResource(pub Option<Handle<Image>>);
/// Fired by `sync_setup_plugin` after a successful login or register when
/// the server reports that the user has a profile picture set.
#[derive(Debug, Clone)]
pub struct AvatarFetchEvent {
/// Full HTTP(S) URL to the avatar image (base_url + avatar_url path).
pub url: String,
}
impl bevy::prelude::Message for AvatarFetchEvent {}
/// In-flight avatar download task. Returns the raw image bytes on success,
/// or `None` on any network / decode error.
#[derive(Resource, Default)]
struct PendingAvatarTask(Option<Task<Option<Vec<u8>>>>);
pub struct AvatarPlugin;
impl Plugin for AvatarPlugin {
fn build(&self, app: &mut App) {
app.add_message::<AvatarFetchEvent>()
.init_resource::<AvatarResource>()
.init_resource::<PendingAvatarTask>()
.add_systems(Update, poll_avatar_task);
// Build the shared Tokio runtime; skip avatar download if the OS
// refuses to create threads (resource-limited / sandboxed environments).
match TokioRuntimeResource::new() {
Ok(rt) => {
app.insert_resource(rt)
.add_systems(Update, handle_avatar_fetch);
}
Err(e) => {
bevy::log::warn!(
"avatar_plugin: Tokio runtime unavailable — avatar fetch disabled: {e}"
);
}
}
}
}
fn handle_avatar_fetch(
mut events: MessageReader<AvatarFetchEvent>,
rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingAvatarTask>,
) {
for ev in events.read() {
// Cancel any in-flight task and restart with the new URL.
let url = ev.url.clone();
let rt = rt.0.clone();
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async move {
let client = reqwest::Client::new();
let bytes = client.get(&url).send().await.ok()?.bytes().await.ok()?;
Some(bytes.to_vec())
})
}));
}
}
fn poll_avatar_task(
mut pending: ResMut<PendingAvatarTask>,
mut avatar: ResMut<AvatarResource>,
mut images: ResMut<Assets<Image>>,
) {
let Some(task) = pending.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
pending.0 = None;
let Some(bytes) = result else { return };
match image::load_from_memory(&bytes) {
Ok(dyn_img) => {
let bevy_img = Image::from_dynamic(dyn_img, true, RenderAssetUsages::RENDER_WORLD);
let handle = images.add(bevy_img);
avatar.0 = Some(handle);
}
Err(e) => {
warn!("avatar_plugin: failed to decode avatar image: {e}");
}
}
}