Files
Sprimo/crates/sprimo-app/src/main.rs
2026-02-13 21:54:30 +08:00

1251 lines
39 KiB
Rust

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use bevy::prelude::*;
use bevy::render::render_asset::RenderAssetUsages;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use bevy::window::{PrimaryWindow, WindowLevel, WindowMode, WindowPlugin, WindowPosition};
use image::{DynamicImage, GenericImageView, Rgba};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use sprimo_platform::{create_adapter, PlatformAdapter};
use sprimo_protocol::v1::{CommandEnvelope, FrontendCommand, FrontendState, FrontendStateSnapshot};
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
use sprimo_sprite::{
load_manifest, resolve_pack_path, AnimationDefinition, SpriteError, SpritePackManifest,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use thiserror::Error;
use tokio::runtime::Runtime;
use tracing::{info, warn};
const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default";
const WINDOW_PADDING: f32 = 16.0;
const STARTUP_WINDOW_SIZE: f32 = 416.0;
const MAGENTA_KEY: [u8; 3] = [255, 0, 255];
const CHROMA_KEY_TOLERANCE: u8 = 24;
const CHROMA_KEY_FORCE_RATIO: f32 = 0.15;
const WINDOWS_COLOR_KEY: [u8; 3] = [255, 0, 255];
#[derive(Debug, Error)]
enum AppError {
#[error("{0}")]
RuntimeCore(#[from] RuntimeCoreError),
}
#[derive(Resource, Clone)]
struct SharedSnapshot(Arc<RwLock<FrontendStateSnapshot>>);
#[derive(Resource, Clone)]
struct PlatformResource(Arc<dyn PlatformAdapter>);
#[derive(Resource)]
struct RuntimeCoreResource(Arc<RuntimeCore>);
#[derive(Resource, Copy, Clone)]
struct DurableState {
state: FrontendState,
}
#[derive(Resource, Clone)]
struct PendingStateReset {
timer: Option<Timer>,
revert_to: FrontendState,
}
#[derive(Resource)]
struct SpritePackRoot {
asset_root: PathBuf,
}
#[derive(Debug, Clone)]
struct AnimationClip {
fps: u16,
frames: Vec<u32>,
one_shot: bool,
}
#[derive(Debug, Clone)]
struct PackRuntime {
pack_id: String,
clips: HashMap<String, AnimationClip>,
texture_handle: Handle<Image>,
layout_handle: Handle<TextureAtlasLayout>,
idle_frame: u32,
frame_width: u32,
frame_height: u32,
}
#[derive(Debug)]
struct LoadedManifest {
pack_id: String,
pack_path: PathBuf,
manifest: SpritePackManifest,
}
#[derive(Debug, Clone, Copy)]
enum AlphaMode {
NativeAlpha,
ChromaKey,
}
#[derive(Resource, Clone)]
struct CurrentPackResource {
runtime: PackRuntime,
}
#[derive(Resource)]
struct AnimationResource {
current: String,
timer: Timer,
frame_cursor: usize,
one_shot_return: Option<String>,
}
#[derive(Component)]
struct PetSprite;
struct CommandIngress(Receiver<CommandEnvelope>);
struct HotkeyIngress(Receiver<()>);
#[cfg(target_os = "windows")]
#[allow(unsafe_code)]
mod windows_hotkey {
use std::ffi::c_void;
use std::sync::mpsc::Sender;
use std::thread;
use tracing::{info, warn};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Input::KeyboardAndMouse::{RegisterHotKey, MOD_ALT, MOD_CONTROL};
use windows::Win32::UI::WindowsAndMessaging::{
DispatchMessageW, GetMessageW, TranslateMessage, MSG, WM_HOTKEY,
};
pub fn spawn_recovery_hotkey(tx: Sender<()>, configured: &str) {
let hotkey = configured.trim().to_ascii_uppercase();
let (modifiers, key) = if hotkey == "CTRL+ALT+P" {
(MOD_CONTROL | MOD_ALT, u32::from(b'P'))
} else {
warn!(hotkey = configured, "unsupported hotkey config, falling back to Ctrl+Alt+P");
(MOD_CONTROL | MOD_ALT, u32::from(b'P'))
};
thread::spawn(move || {
// SAFETY: Registering a process-wide hotkey with a null HWND is valid.
let result = unsafe {
RegisterHotKey(HWND(std::ptr::null_mut::<c_void>()), 1, modifiers, key)
};
if result.is_err() {
warn!("failed to register global recovery hotkey");
return;
}
info!("global recovery hotkey registered");
let mut msg = MSG::default();
loop {
// SAFETY: `msg` points to initialized storage for the thread message queue.
let code = unsafe {
GetMessageW(&mut msg, HWND(std::ptr::null_mut::<c_void>()), 0, 0)
};
if code.0 <= 0 {
break;
}
if msg.message == WM_HOTKEY {
let _ = tx.send(());
}
// SAFETY: `msg` was produced by `GetMessageW`; dispatch is valid for this thread.
unsafe {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
});
}
}
fn main() -> Result<(), AppError> {
tracing_subscriber::fmt()
.with_env_filter("sprimo=info")
.with_target(false)
.compact()
.init();
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
let runtime_core = Arc::new(RuntimeCore::new(APP_NAME, platform.capabilities())?);
let shared_snapshot = runtime_core.snapshot();
let config = runtime_core
.config()
.read()
.expect("runtime core config lock poisoned")
.clone();
let runtime = Runtime::new().expect("tokio runtime");
let (bevy_command_tx, bevy_command_rx) = mpsc::channel();
let (hotkey_tx, hotkey_rx) = mpsc::channel();
runtime_core.spawn_api(&runtime);
let command_rx = runtime_core.command_receiver();
runtime.spawn(async move {
loop {
let next = {
let mut receiver = command_rx.lock().await;
receiver.recv().await
};
let Some(command) = next else {
break;
};
if bevy_command_tx.send(command).is_err() {
break;
}
}
});
if config.controls.hotkey_enabled {
#[cfg(target_os = "windows")]
windows_hotkey::spawn_recovery_hotkey(hotkey_tx, &config.controls.recovery_hotkey);
}
let mut app = App::new();
app.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(bevy::window::Window {
title: "sprimo-overlay".to_string(),
mode: WindowMode::Windowed,
transparent: window_transparent_mode(),
decorations: false,
resizable: false,
window_level: WindowLevel::AlwaysOnTop,
resolution: bevy::window::WindowResolution::new(
STARTUP_WINDOW_SIZE,
STARTUP_WINDOW_SIZE,
),
position: WindowPosition::At(IVec2::new(
config.window.x.round() as i32,
config.window.y.round() as i32,
)),
..default()
}),
..default()
}));
app.insert_resource(ClearColor(window_clear_color()));
app.insert_resource(SharedSnapshot(shared_snapshot));
app.insert_resource(RuntimeCoreResource(Arc::clone(&runtime_core)));
app.insert_resource(PlatformResource(Arc::clone(&platform)));
app.insert_resource(DurableState {
state: FrontendState::Idle,
});
app.insert_resource(PendingStateReset {
timer: None,
revert_to: FrontendState::Idle,
});
app.insert_resource(SpritePackRoot {
asset_root: default_asset_root(),
});
app.insert_non_send_resource(CommandIngress(bevy_command_rx));
app.insert_non_send_resource(HotkeyIngress(hotkey_rx));
app.add_systems(Startup, setup_scene);
app.add_systems(
Update,
(
attach_window_handle_once,
poll_backend_commands,
poll_hotkey_recovery,
tick_state_reset,
animate_sprite,
),
);
info!("sprimo frontend starting Bevy runtime");
app.run();
Ok(())
}
fn default_asset_root() -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("assets")
.join("sprite-packs")
}
fn setup_scene(
mut commands: Commands,
runtime_core: Res<RuntimeCoreResource>,
root: Res<SpritePackRoot>,
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
mut images: ResMut<Assets<Image>>,
asset_server: Res<AssetServer>,
mut window_query: Query<&mut Window, With<PrimaryWindow>>,
snapshot: Res<SharedSnapshot>,
) {
commands.spawn(Camera2dBundle {
camera: Camera {
clear_color: bevy::render::camera::ClearColorConfig::Custom(window_clear_color()),
..default()
},
..default()
});
let selected = runtime_core
.0
.config()
.read()
.expect("runtime core config lock poisoned")
.sprite
.selected_pack
.clone();
let runtime = match load_pack_runtime(
&root.asset_root,
&selected,
true,
&asset_server,
&mut images,
&mut texture_layouts,
) {
Ok(runtime) => runtime,
Err(err) => {
warn!(%err, "failed to load selected sprite pack, falling back to default");
load_pack_runtime(
&root.asset_root,
DEFAULT_PACK,
false,
&asset_server,
&mut images,
&mut texture_layouts,
)
.expect("embedded default sprite pack must load")
}
};
let (scale, visible) = {
let guard = snapshot
.0
.read()
.expect("frontend snapshot lock poisoned");
(guard.scale, guard.flags.visible)
};
let mut entity = commands.spawn((
SpriteBundle {
texture: runtime.texture_handle.clone(),
transform: Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(scale)),
..default()
},
TextureAtlas {
layout: runtime.layout_handle.clone(),
index: runtime.idle_frame as usize,
},
PetSprite,
));
if !visible {
entity.insert(Visibility::Hidden);
}
if let Ok(mut window) = window_query.get_single_mut() {
let size = window_size_for_pack(&runtime, scale);
window.resolution.set(size.x, size.y);
}
commands.insert_resource(CurrentPackResource {
runtime: runtime.clone(),
});
commands.insert_resource(AnimationResource {
current: "idle".to_string(),
timer: Timer::from_seconds(0.125, TimerMode::Repeating),
frame_cursor: 0,
one_shot_return: None,
});
}
#[cfg(target_os = "windows")]
fn window_clear_color() -> Color {
Color::srgb_u8(
WINDOWS_COLOR_KEY[0],
WINDOWS_COLOR_KEY[1],
WINDOWS_COLOR_KEY[2],
)
}
#[cfg(not(target_os = "windows"))]
fn window_clear_color() -> Color {
Color::srgba(0.0, 0.0, 0.0, 0.0)
}
#[cfg(target_os = "windows")]
fn window_transparent_mode() -> bool {
false
}
#[cfg(not(target_os = "windows"))]
fn window_transparent_mode() -> bool {
true
}
#[derive(Debug, Error)]
enum PackLoadError {
#[error("{0}")]
Sprite(#[from] SpriteError),
#[error("sprite pack path must be under asset root: {0}")]
ExternalPackPath(String),
#[error("sprite pack has no `idle` animation: {0}")]
MissingIdleAnimation(String),
#[error("sprite pack identifier must be a directory name: {0}")]
InvalidPackName(String),
#[error("failed to read image metadata for {path}: {source}")]
Image {
path: String,
source: image::ImageError,
},
#[error("invalid frame grid for {pack_id}: image={image_width}x{image_height}, frame={frame_width}x{frame_height}")]
InvalidFrameGrid {
pack_id: String,
image_width: u32,
image_height: u32,
frame_width: u32,
frame_height: u32,
},
#[error("frame index {frame} out of bounds for {pack_id}; total frames: {total_frames}")]
FrameOutOfRange {
pack_id: String,
frame: u32,
total_frames: u32,
},
#[error("invalid chroma key value `{value}`; expected `#RRGGBB`")]
InvalidChromaKey { value: String },
}
fn load_pack_runtime(
asset_root: &Path,
selected: &str,
allow_default_fallback: bool,
asset_server: &AssetServer,
images: &mut Assets<Image>,
texture_layouts: &mut Assets<TextureAtlasLayout>,
) -> Result<PackRuntime, PackLoadError> {
let loaded = if allow_default_fallback {
match load_pack_manifest_from_selector(asset_root, selected) {
Ok(v) => v,
Err(_) => load_pack_manifest_from_selector(asset_root, DEFAULT_PACK)?,
}
} else {
load_pack_manifest_from_selector(asset_root, selected)?
};
let clips = to_clip_map(&loaded.manifest, &loaded.pack_id)?;
let image_path = loaded.pack_path.join(&loaded.manifest.image);
let source_image = image::open(&image_path).map_err(|source| PackLoadError::Image {
path: image_path.display().to_string(),
source,
})?;
let (image_width, image_height) = source_image.dimensions();
let (columns, rows, total_frames) = compute_grid(
&loaded.pack_id,
image_width,
image_height,
loaded.manifest.frame_width,
loaded.manifest.frame_height,
)?;
validate_frames(&loaded.pack_id, &clips, total_frames)?;
let layout_handle = texture_layouts.add(TextureAtlasLayout::from_grid(
UVec2::new(loaded.manifest.frame_width, loaded.manifest.frame_height),
columns,
rows,
None,
None,
));
let (texture_handle, alpha_mode) = create_texture_handle(
&loaded.pack_id,
&loaded.manifest,
source_image,
&image_path,
asset_server,
images,
)?;
let alpha_mode_label = match alpha_mode {
AlphaMode::NativeAlpha => "native_alpha",
AlphaMode::ChromaKey => "chroma_key",
};
info!(
pack_id = loaded.pack_id,
image_width,
image_height,
frame_width = loaded.manifest.frame_width,
frame_height = loaded.manifest.frame_height,
columns,
rows,
alpha_mode = alpha_mode_label,
"sprite pack loaded"
);
let idle_frame = clips
.get("idle")
.and_then(|clip| clip.frames.first())
.copied()
.unwrap_or(0);
Ok(PackRuntime {
pack_id: loaded.pack_id,
clips,
texture_handle,
layout_handle,
idle_frame,
frame_width: loaded.manifest.frame_width,
frame_height: loaded.manifest.frame_height,
})
}
fn load_pack_manifest_from_selector(
asset_root: &Path,
selected: &str,
) -> Result<LoadedManifest, PackLoadError> {
let pack_path = resolve_pack_path(asset_root, selected)?;
if pack_path.strip_prefix(asset_root).is_err() {
return Err(PackLoadError::ExternalPackPath(pack_path.display().to_string()));
}
let pack_id = pack_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| PackLoadError::InvalidPackName(pack_path.display().to_string()))?
.to_string();
let manifest = load_manifest(&pack_path)?;
Ok(LoadedManifest {
pack_id,
pack_path,
manifest,
})
}
fn to_clip_map(
manifest: &SpritePackManifest,
pack_id: &str,
) -> Result<HashMap<String, AnimationClip>, PackLoadError> {
let mut clips = HashMap::new();
for animation in &manifest.animations {
clips.insert(animation.name.clone(), to_clip(animation.clone()));
}
if !clips.contains_key("idle") {
return Err(PackLoadError::MissingIdleAnimation(pack_id.to_string()));
}
Ok(clips)
}
fn create_texture_handle(
pack_id: &str,
manifest: &SpritePackManifest,
source_image: DynamicImage,
image_path: &Path,
asset_server: &AssetServer,
images: &mut Assets<Image>,
) -> Result<(Handle<Image>, AlphaMode), PackLoadError> {
let key = manifest
.chroma_key_color
.as_deref()
.map(parse_hex_rgb)
.transpose()?
.unwrap_or(MAGENTA_KEY);
let enabled = manifest.chroma_key_enabled.unwrap_or(true);
let rgba_available = source_image.color().has_alpha();
let force_chroma_key = enabled && should_force_chroma_key(&source_image, key);
if rgba_available && !force_chroma_key {
let texture_path = format!("sprite-packs/{}/{}", pack_id, manifest.image);
return Ok((asset_server.load(texture_path), AlphaMode::NativeAlpha));
}
if !enabled {
let texture_path = format!("sprite-packs/{}/{}", pack_id, manifest.image);
return Ok((asset_server.load(texture_path), AlphaMode::NativeAlpha));
}
if force_chroma_key {
warn!(
pack_id,
source = %image_path.display(),
"sprite image reports alpha but appears opaque with key-colored background; forcing chroma-key"
);
}
let converted = apply_chroma_key(source_image, key);
let (width, height) = converted.dimensions();
let bytes = converted.into_raw();
let image = Image::new(
Extent3d {
width,
height,
depth_or_array_layers: 1,
},
TextureDimension::D2,
bytes,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
);
let handle = images.add(image);
info!(
pack_id,
source = %image_path.display(),
"loaded RGB sprite sheet via chroma-key conversion"
);
Ok((handle, AlphaMode::ChromaKey))
}
fn parse_hex_rgb(value: &str) -> Result<[u8; 3], PackLoadError> {
let bytes = value.trim();
if bytes.len() != 7 || !bytes.starts_with('#') {
return Err(PackLoadError::InvalidChromaKey {
value: value.to_string(),
});
}
let r = u8::from_str_radix(&bytes[1..3], 16).map_err(|_| PackLoadError::InvalidChromaKey {
value: value.to_string(),
})?;
let g = u8::from_str_radix(&bytes[3..5], 16).map_err(|_| PackLoadError::InvalidChromaKey {
value: value.to_string(),
})?;
let b = u8::from_str_radix(&bytes[5..7], 16).map_err(|_| PackLoadError::InvalidChromaKey {
value: value.to_string(),
})?;
Ok([r, g, b])
}
fn apply_chroma_key(image: DynamicImage, key: [u8; 3]) -> image::RgbaImage {
let mut rgba = image.to_rgba8();
let keyed_alpha = chroma_key_output_alpha();
for pixel in rgba.pixels_mut() {
if is_chroma_key_match(pixel.0, key, CHROMA_KEY_TOLERANCE) {
*pixel = Rgba([key[0], key[1], key[2], keyed_alpha]);
}
}
rgba
}
#[cfg(target_os = "windows")]
fn chroma_key_output_alpha() -> u8 {
// Windows fallback uses layered color-key transparency, so keyed pixels must keep
// opaque alpha to preserve exact key RGB through the presentation path.
u8::MAX
}
#[cfg(not(target_os = "windows"))]
fn chroma_key_output_alpha() -> u8 {
0
}
fn should_force_chroma_key(image: &DynamicImage, key: [u8; 3]) -> bool {
if !image.color().has_alpha() {
return false;
}
let rgba = image.to_rgba8();
let mut opaque_alpha = true;
let mut key_like = 0usize;
for pixel in rgba.pixels() {
if pixel[3] != u8::MAX {
opaque_alpha = false;
break;
}
if is_chroma_key_match(pixel.0, key, CHROMA_KEY_TOLERANCE) {
key_like += 1;
}
}
if !opaque_alpha {
return false;
}
let total = (rgba.width() as usize).saturating_mul(rgba.height() as usize);
total != 0 && (key_like as f32 / total as f32) >= CHROMA_KEY_FORCE_RATIO
}
fn is_chroma_key_match(pixel: [u8; 4], key: [u8; 3], tolerance: u8) -> bool {
pixel[0].abs_diff(key[0]) <= tolerance
&& pixel[1].abs_diff(key[1]) <= tolerance
&& pixel[2].abs_diff(key[2]) <= tolerance
}
fn to_clip(animation: AnimationDefinition) -> AnimationClip {
AnimationClip {
fps: animation.fps.max(1),
frames: animation.frames,
one_shot: animation.one_shot.unwrap_or(false),
}
}
fn attach_window_handle_once(
primary: Query<Entity, With<PrimaryWindow>>,
platform: Res<PlatformResource>,
snapshot: Res<SharedSnapshot>,
mut attached: Local<bool>,
#[cfg(target_os = "windows")] winit_windows: NonSend<bevy::winit::WinitWindows>,
) {
if *attached {
return;
}
let Ok(entity) = primary.get_single() else {
return;
};
#[cfg(target_os = "windows")]
{
if let Some(window) = winit_windows.get_window(entity) {
if let Ok(handle) = window.window_handle() {
if let RawWindowHandle::Win32(win32) = handle.as_raw() {
let hwnd = win32.hwnd.get();
if let Err(err) = platform.0.attach_window_handle(hwnd) {
warn!(%err, "failed to attach native window handle");
return;
}
let guard = snapshot
.0
.read()
.expect("frontend snapshot lock poisoned");
let _ = platform.0.set_always_on_top(guard.flags.always_on_top);
let _ = platform.0.set_visible(guard.flags.visible);
let _ = platform.0.set_window_position(guard.x, guard.y);
*attached = true;
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = entity;
*attached = true;
}
}
fn poll_hotkey_recovery(
ingress: NonSendMut<HotkeyIngress>,
platform: Res<PlatformResource>,
runtime_core: Res<RuntimeCoreResource>,
mut pet_query: Query<&mut Visibility, With<PetSprite>>,
) {
while ingress.0.try_recv().is_ok() {
info!("recovery hotkey received");
let _ = platform.0.set_always_on_top(true);
let _ = platform.0.set_visible(true);
if let Ok(mut visibility) = pet_query.get_single_mut() {
*visibility = Visibility::Visible;
}
if let Err(err) = runtime_core.0.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: Some(true),
visible: Some(true),
}) {
warn!(%err, "failed to persist recovery flag config");
}
}
}
fn poll_backend_commands(
ingress: NonSendMut<CommandIngress>,
platform: Res<PlatformResource>,
root: Res<SpritePackRoot>,
asset_server: Res<AssetServer>,
mut images: ResMut<Assets<Image>>,
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
runtime_core: Res<RuntimeCoreResource>,
snapshot: Res<SharedSnapshot>,
mut durable_state: ResMut<DurableState>,
mut reset: ResMut<PendingStateReset>,
mut animation: ResMut<AnimationResource>,
mut current_pack: ResMut<CurrentPackResource>,
mut pet_query: Query<
(&mut Transform, &mut Visibility, &mut TextureAtlas, &mut Handle<Image>),
With<PetSprite>,
>,
mut window_query: Query<&mut Window, With<PrimaryWindow>>,
) {
loop {
let Ok(envelope) = ingress.0.try_recv() else {
break;
};
let command = envelope.command;
let Ok((mut transform, mut visibility, mut atlas, mut image_handle)) =
pet_query.get_single_mut()
else {
continue;
};
if !matches!(command, FrontendCommand::SetSpritePack { .. }) {
if let Err(err) = runtime_core.0.apply_command(&command) {
warn!(%err, "failed to apply command in runtime core");
continue;
}
}
match command {
FrontendCommand::SetState { state, ttl_ms } => {
if !matches!(state, FrontendState::Success | FrontendState::Error) {
durable_state.state = state;
}
let next_animation = default_animation_for_state(state);
set_animation(&mut animation, &current_pack.runtime.clips, next_animation);
if let Some(ttl) = ttl_ms {
reset.timer = Some(Timer::new(Duration::from_millis(ttl), TimerMode::Once));
reset.revert_to = durable_state.state;
} else {
reset.timer = None;
}
if let Ok(mut guard) = snapshot.0.write() {
guard.state = state;
guard.current_animation = next_animation.to_string();
guard.last_error = None;
}
if let Some(frame) = current_frame(&animation, &current_pack.runtime.clips) {
atlas.index = frame as usize;
}
}
FrontendCommand::PlayAnimation { name, .. } => {
set_animation(&mut animation, &current_pack.runtime.clips, &name);
if let Ok(mut guard) = snapshot.0.write() {
guard.current_animation = animation.current.clone();
guard.last_error = None;
}
if let Some(frame) = current_frame(&animation, &current_pack.runtime.clips) {
atlas.index = frame as usize;
}
}
FrontendCommand::SetSpritePack { pack_id_or_path } => {
let requested = pack_id_or_path.clone();
let reload = load_pack_runtime(
&root.asset_root,
&requested,
false,
&asset_server,
&mut images,
&mut texture_layouts,
);
match reload {
Ok(next_pack) => {
let next_animation =
choose_animation_after_reload(&animation.current, &next_pack.clips);
current_pack.runtime = next_pack;
image_handle.clone_from(&current_pack.runtime.texture_handle);
atlas.layout.clone_from(&current_pack.runtime.layout_handle);
set_animation(
&mut animation,
&current_pack.runtime.clips,
&next_animation,
);
if let Some(frame) =
current_frame(&animation, &current_pack.runtime.clips)
{
atlas.index = frame as usize;
} else {
atlas.index = current_pack.runtime.idle_frame as usize;
}
if let Ok(mut window) = window_query.get_single_mut() {
let scale = transform.scale.x;
let size = window_size_for_pack(&current_pack.runtime, scale);
window.resolution.set(size.x, size.y);
}
if let Err(err) = runtime_core.0.apply_command(
&FrontendCommand::SetSpritePack {
pack_id_or_path: requested.clone(),
},
) {
warn!(%err, "failed to persist sprite pack selection");
}
if let Ok(mut guard) = snapshot.0.write() {
guard.active_sprite_pack = current_pack.runtime.pack_id.clone();
guard.current_animation = animation.current.clone();
guard.last_error = None;
}
}
Err(err) => {
warn!(requested, %err, "sprite pack reload failed; keeping current pack");
if let Ok(mut guard) = snapshot.0.write() {
guard.last_error =
Some(format!("SetSpritePack `{}` failed: {}", requested, err));
}
}
}
}
FrontendCommand::SetTransform { x, y, scale, .. } => {
if let Some(value) = scale {
transform.scale = Vec3::splat(value);
if let Ok(mut window) = window_query.get_single_mut() {
let size = window_size_for_pack(&current_pack.runtime, value);
window.resolution.set(size.x, size.y);
}
}
if x.is_some() || y.is_some() {
if let Ok(guard) = snapshot.0.read() {
let _ = platform.0.set_window_position(guard.x, guard.y);
}
}
}
FrontendCommand::SetFlags {
click_through,
always_on_top,
visible,
} => {
let _ = click_through;
if let Some(value) = always_on_top {
let _ = platform.0.set_always_on_top(value);
}
if let Some(value) = visible {
let _ = platform.0.set_visible(value);
*visibility = if value {
Visibility::Visible
} else {
Visibility::Hidden
};
}
}
FrontendCommand::Toast { text, .. } => {
info!(toast = text, "toast command received");
}
}
}
}
fn tick_state_reset(
time: Res<Time>,
runtime_core: Res<RuntimeCoreResource>,
mut reset: ResMut<PendingStateReset>,
mut animation: ResMut<AnimationResource>,
current_pack: Res<CurrentPackResource>,
mut atlas_query: Query<&mut TextureAtlas, With<PetSprite>>,
snapshot: Res<SharedSnapshot>,
) {
let Some(timer) = reset.timer.as_mut() else {
return;
};
timer.tick(time.delta());
if !timer.finished() {
return;
}
let next_state = reset.revert_to;
let next_animation = default_animation_for_state(next_state);
let _ = runtime_core.0.apply_command(&FrontendCommand::SetState {
state: next_state,
ttl_ms: None,
});
set_animation(&mut animation, &current_pack.runtime.clips, next_animation);
if let Ok(mut atlas) = atlas_query.get_single_mut() {
if let Some(frame) = current_frame(&animation, &current_pack.runtime.clips) {
atlas.index = frame as usize;
}
}
if let Ok(mut guard) = snapshot.0.write() {
guard.state = next_state;
guard.current_animation = next_animation.to_string();
guard.last_error = None;
}
reset.timer = None;
}
fn animate_sprite(
time: Res<Time>,
mut animation: ResMut<AnimationResource>,
current_pack: Res<CurrentPackResource>,
mut atlas_query: Query<&mut TextureAtlas, With<PetSprite>>,
) {
let Some(current_clip) = current_pack.runtime.clips.get(&animation.current) else {
return;
};
if current_clip.frames.is_empty() {
return;
}
animation.timer.tick(time.delta());
if !animation.timer.finished() {
return;
}
if current_clip.one_shot && animation.frame_cursor + 1 >= current_clip.frames.len() {
if let Some(next) = animation.one_shot_return.clone() {
set_animation(&mut animation, &current_pack.runtime.clips, &next);
} else {
animation.frame_cursor = 0;
}
} else {
animation.frame_cursor = (animation.frame_cursor + 1) % current_clip.frames.len();
}
if let Ok(mut atlas) = atlas_query.get_single_mut() {
if let Some(frame) = current_frame(&animation, &current_pack.runtime.clips) {
atlas.index = frame as usize;
}
}
}
fn set_animation(
animation: &mut AnimationResource,
clips: &HashMap<String, AnimationClip>,
name: &str,
) {
if let Some(clip) = clips.get(name) {
animation.current = name.to_string();
animation.frame_cursor = 0;
animation.timer = Timer::from_seconds(1.0 / f32::from(clip.fps), TimerMode::Repeating);
animation.one_shot_return = if clip.one_shot {
Some("idle".to_string())
} else {
None
};
} else {
warn!(animation = name, "requested animation missing in current sprite pack");
}
}
fn current_frame(
animation: &AnimationResource,
clips: &HashMap<String, AnimationClip>,
) -> Option<u32> {
clips
.get(&animation.current)
.and_then(|clip| clip.frames.get(animation.frame_cursor).copied())
}
fn choose_animation_after_reload(
current_animation: &str,
clips: &HashMap<String, AnimationClip>,
) -> String {
if clips.contains_key(current_animation) {
return current_animation.to_string();
}
"idle".to_string()
}
fn default_animation_for_state(state: FrontendState) -> &'static str {
match state {
FrontendState::Idle => "idle",
FrontendState::Active => "active",
FrontendState::Success => "success",
FrontendState::Error => "error",
FrontendState::Dragging => "idle",
FrontendState::Hidden => "idle",
}
}
fn compute_grid(
pack_id: &str,
image_width: u32,
image_height: u32,
frame_width: u32,
frame_height: u32,
) -> Result<(u32, u32, u32), PackLoadError> {
let invalid = frame_width == 0
|| frame_height == 0
|| image_width == 0
|| image_height == 0
|| image_width % frame_width != 0
|| image_height % frame_height != 0;
if invalid {
return Err(PackLoadError::InvalidFrameGrid {
pack_id: pack_id.to_string(),
image_width,
image_height,
frame_width,
frame_height,
});
}
let columns = image_width / frame_width;
let rows = image_height / frame_height;
let total = columns.saturating_mul(rows);
if columns == 0 || rows == 0 || total == 0 {
return Err(PackLoadError::InvalidFrameGrid {
pack_id: pack_id.to_string(),
image_width,
image_height,
frame_width,
frame_height,
});
}
Ok((columns, rows, total))
}
fn validate_frames(
pack_id: &str,
clips: &HashMap<String, AnimationClip>,
total_frames: u32,
) -> Result<(), PackLoadError> {
for clip in clips.values() {
for frame in &clip.frames {
if *frame >= total_frames {
return Err(PackLoadError::FrameOutOfRange {
pack_id: pack_id.to_string(),
frame: *frame,
total_frames,
});
}
}
}
Ok(())
}
fn window_size_for_pack(pack: &PackRuntime, scale: f32) -> Vec2 {
let width = pack.frame_width as f32 * scale + WINDOW_PADDING;
let height = pack.frame_height as f32 * scale + WINDOW_PADDING;
Vec2::new(width.max(64.0), height.max(64.0))
}
#[cfg(test)]
mod tests {
use super::{
apply_chroma_key, choose_animation_after_reload, compute_grid, parse_hex_rgb,
should_force_chroma_key, validate_frames, AnimationClip, MAGENTA_KEY,
};
use image::DynamicImage;
use std::collections::HashMap;
#[test]
fn keeps_current_animation_if_present() {
let mut clips = HashMap::new();
clips.insert(
"dance".to_string(),
AnimationClip {
fps: 10,
frames: vec![0, 1],
one_shot: false,
},
);
clips.insert(
"idle".to_string(),
AnimationClip {
fps: 8,
frames: vec![2, 3],
one_shot: false,
},
);
assert_eq!(choose_animation_after_reload("dance", &clips), "dance");
}
#[test]
fn falls_back_to_idle_when_missing() {
let mut clips = HashMap::new();
clips.insert(
"idle".to_string(),
AnimationClip {
fps: 8,
frames: vec![0, 1],
one_shot: false,
},
);
assert_eq!(choose_animation_after_reload("dance", &clips), "idle");
}
#[test]
fn computes_expected_grid_for_8x7_sheet() {
let (columns, rows, total) = compute_grid("default", 4096, 3584, 512, 512).expect("grid");
assert_eq!((columns, rows, total), (8, 7, 56));
}
#[test]
fn rejects_out_of_range_frame() {
let mut clips = HashMap::new();
clips.insert(
"idle".to_string(),
AnimationClip {
fps: 8,
frames: vec![0, 56],
one_shot: false,
},
);
let err = validate_frames("default", &clips, 56).expect_err("out of range");
assert!(format!("{err}").contains("out of bounds"));
}
#[test]
fn parses_hex_color() {
assert_eq!(parse_hex_rgb("#FF00FF").expect("hex"), MAGENTA_KEY);
}
#[test]
fn chroma_key_makes_magenta_transparent() {
let image = DynamicImage::ImageRgb8(image::RgbImage::from_fn(2, 1, |x, _| {
if x == 0 {
image::Rgb([255, 0, 255])
} else {
image::Rgb([1, 2, 3])
}
}));
let converted = apply_chroma_key(image, MAGENTA_KEY);
#[cfg(target_os = "windows")]
assert_eq!(converted.get_pixel(0, 0).0, [255, 0, 255, 255]);
#[cfg(not(target_os = "windows"))]
assert_eq!(converted.get_pixel(0, 0).0, [255, 0, 255, 0]);
assert_eq!(converted.get_pixel(1, 0).0, [1, 2, 3, 255]);
}
#[test]
fn chroma_key_uses_tolerance_for_near_magenta() {
let image = DynamicImage::ImageRgb8(image::RgbImage::from_fn(2, 1, |x, _| {
if x == 0 {
image::Rgb([253, 2, 252])
} else {
image::Rgb([10, 20, 30])
}
}));
let converted = apply_chroma_key(image, MAGENTA_KEY);
#[cfg(target_os = "windows")]
assert_eq!(converted.get_pixel(0, 0).0[3], 255);
#[cfg(not(target_os = "windows"))]
assert_eq!(converted.get_pixel(0, 0).0[3], 0);
assert_eq!(converted.get_pixel(1, 0).0, [10, 20, 30, 255]);
}
#[test]
fn chroma_key_preserves_non_key_alpha() {
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(2, 1, |x, _| {
if x == 0 {
image::Rgba([255, 0, 255, 255])
} else {
image::Rgba([2, 3, 4, 96])
}
}));
let converted = apply_chroma_key(image, MAGENTA_KEY);
#[cfg(target_os = "windows")]
assert_eq!(converted.get_pixel(0, 0).0, [255, 0, 255, 255]);
#[cfg(not(target_os = "windows"))]
assert_eq!(converted.get_pixel(0, 0).0, [255, 0, 255, 0]);
assert_eq!(converted.get_pixel(1, 0).0, [2, 3, 4, 96]);
}
#[test]
fn force_chroma_key_for_opaque_alpha_with_large_key_area() {
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(10, 10, |x, _| {
if x < 8 {
image::Rgba([255, 0, 255, 255])
} else {
image::Rgba([8, 9, 10, 255])
}
}));
assert!(should_force_chroma_key(&image, MAGENTA_KEY));
}
#[test]
fn no_force_chroma_key_when_real_alpha_exists() {
let image = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(10, 10, |x, _| {
if x == 0 {
image::Rgba([255, 0, 255, 0])
} else {
image::Rgba([8, 9, 10, 255])
}
}));
assert!(!should_force_chroma_key(&image, MAGENTA_KEY));
}
}