1251 lines
39 KiB
Rust
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, ¤t_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, ¤t_pack.runtime.clips) {
|
|
atlas.index = frame as usize;
|
|
}
|
|
}
|
|
FrontendCommand::PlayAnimation { name, .. } => {
|
|
set_animation(&mut animation, ¤t_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, ¤t_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(¤t_pack.runtime.texture_handle);
|
|
atlas.layout.clone_from(¤t_pack.runtime.layout_handle);
|
|
set_animation(
|
|
&mut animation,
|
|
¤t_pack.runtime.clips,
|
|
&next_animation,
|
|
);
|
|
if let Some(frame) =
|
|
current_frame(&animation, ¤t_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(¤t_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(¤t_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, ¤t_pack.runtime.clips, next_animation);
|
|
if let Ok(mut atlas) = atlas_query.get_single_mut() {
|
|
if let Some(frame) = current_frame(&animation, ¤t_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, ¤t_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, ¤t_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));
|
|
}
|
|
}
|