Add: logical verification workflow for bevy

This commit is contained in:
DaZuo0122
2026-02-13 21:54:30 +08:00
parent 8e79bd98e5
commit 875bc54c4f
8 changed files with 266 additions and 121 deletions

View File

@@ -6,10 +6,9 @@ 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_api::{ApiConfig, ApiState};
use sprimo_config::{save, AppConfig, ConfigError};
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,
};
@@ -20,8 +19,7 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use thiserror::Error;
use tokio::runtime::Runtime;
use tokio::sync::mpsc as tokio_mpsc;
use tracing::{error, info, warn};
use tracing::{info, warn};
const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default";
@@ -35,7 +33,7 @@ const WINDOWS_COLOR_KEY: [u8; 3] = [255, 0, 255];
#[derive(Debug, Error)]
enum AppError {
#[error("{0}")]
Config(#[from] ConfigError),
RuntimeCore(#[from] RuntimeCoreError),
}
#[derive(Resource, Clone)]
@@ -45,10 +43,7 @@ struct SharedSnapshot(Arc<RwLock<FrontendStateSnapshot>>);
struct PlatformResource(Arc<dyn PlatformAdapter>);
#[derive(Resource)]
struct ConfigResource {
path: PathBuf,
config: AppConfig,
}
struct RuntimeCoreResource(Arc<RuntimeCore>);
#[derive(Resource, Copy, Clone)]
struct DurableState {
@@ -181,35 +176,30 @@ fn main() -> Result<(), AppError> {
.compact()
.init();
let (config_path, mut config) = sprimo_config::load_or_create(APP_NAME)?;
if config.window.click_through {
config.window.click_through = false;
if let Err(err) = save(&config_path, &config) {
warn!(%err, "failed to persist click-through disable at startup");
}
}
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
let capabilities = platform.capabilities();
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
snapshot.x = config.window.x;
snapshot.y = config.window.y;
snapshot.scale = config.window.scale;
snapshot.flags.click_through = false;
snapshot.flags.always_on_top = config.window.always_on_top;
snapshot.flags.visible = config.window.visible;
snapshot.active_sprite_pack = config.sprite.selected_pack.clone();
let shared_snapshot = Arc::new(RwLock::new(snapshot));
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 (api_command_tx, api_command_rx) = tokio_mpsc::channel(1_024);
let (bevy_command_tx, bevy_command_rx) = mpsc::channel();
let (hotkey_tx, hotkey_rx) = mpsc::channel();
spawn_api(&runtime, &config, Arc::clone(&shared_snapshot), api_command_tx);
runtime_core.spawn_api(&runtime);
let command_rx = runtime_core.command_receiver();
runtime.spawn(async move {
let mut rx = api_command_rx;
while let Some(command) = rx.recv().await {
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;
}
@@ -245,11 +235,8 @@ fn main() -> Result<(), AppError> {
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(ConfigResource {
path: config_path,
config,
});
app.insert_resource(DurableState {
state: FrontendState::Idle,
});
@@ -281,23 +268,6 @@ fn main() -> Result<(), AppError> {
Ok(())
}
fn spawn_api(
runtime: &Runtime,
config: &AppConfig,
shared_snapshot: Arc<RwLock<FrontendStateSnapshot>>,
api_command_tx: tokio_mpsc::Sender<CommandEnvelope>,
) {
let mut api_config = ApiConfig::default_with_token(config.api.auth_token.clone());
api_config.bind_addr = ([127, 0, 0, 1], config.api.port).into();
let api_state = Arc::new(ApiState::new(api_config.clone(), shared_snapshot, api_command_tx));
runtime.spawn(async move {
if let Err(err) = sprimo_api::run_server(api_config, api_state).await {
error!(%err, "api server exited");
}
});
}
fn default_asset_root() -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
@@ -307,7 +277,7 @@ fn default_asset_root() -> PathBuf {
fn setup_scene(
mut commands: Commands,
config: Res<ConfigResource>,
runtime_core: Res<RuntimeCoreResource>,
root: Res<SpritePackRoot>,
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
mut images: ResMut<Assets<Image>>,
@@ -323,7 +293,14 @@ fn setup_scene(
..default()
});
let selected = config.config.sprite.selected_pack.clone();
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,
@@ -749,8 +726,7 @@ fn attach_window_handle_once(
fn poll_hotkey_recovery(
ingress: NonSendMut<HotkeyIngress>,
platform: Res<PlatformResource>,
mut config: ResMut<ConfigResource>,
snapshot: Res<SharedSnapshot>,
runtime_core: Res<RuntimeCoreResource>,
mut pet_query: Query<&mut Visibility, With<PetSprite>>,
) {
while ingress.0.try_recv().is_ok() {
@@ -761,18 +737,12 @@ fn poll_hotkey_recovery(
if let Ok(mut visibility) = pet_query.get_single_mut() {
*visibility = Visibility::Visible;
}
config.config.window.always_on_top = true;
config.config.window.visible = true;
if let Err(err) = save(&config.path, &config.config) {
warn!(%err, "failed to persist config after hotkey recovery");
}
if let Ok(mut guard) = snapshot.0.write() {
guard.flags.click_through = false;
guard.flags.always_on_top = true;
guard.flags.visible = true;
guard.last_error = None;
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");
}
}
}
@@ -784,7 +754,7 @@ fn poll_backend_commands(
asset_server: Res<AssetServer>,
mut images: ResMut<Assets<Image>>,
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
mut config: ResMut<ConfigResource>,
runtime_core: Res<RuntimeCoreResource>,
snapshot: Res<SharedSnapshot>,
mut durable_state: ResMut<DurableState>,
mut reset: ResMut<PendingStateReset>,
@@ -800,6 +770,7 @@ fn poll_backend_commands(
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()
@@ -807,7 +778,14 @@ fn poll_backend_commands(
continue;
};
match envelope.command {
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;
@@ -878,8 +856,11 @@ fn poll_backend_commands(
window.resolution.set(size.x, size.y);
}
config.config.sprite.selected_pack = requested;
if let Err(err) = save(&config.path, &config.config) {
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");
}
@@ -899,37 +880,17 @@ fn poll_backend_commands(
}
}
FrontendCommand::SetTransform { x, y, scale, .. } => {
if let Some(value) = x {
config.config.window.x = value;
}
if let Some(value) = y {
config.config.window.y = value;
}
if let Some(value) = scale {
transform.scale = Vec3::splat(value);
config.config.window.scale = 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);
}
}
let _ = platform
.0
.set_window_position(config.config.window.x, config.config.window.y);
if let Err(err) = save(&config.path, &config.config) {
warn!(%err, "failed to persist transform config");
}
if let Ok(mut guard) = snapshot.0.write() {
if let Some(value) = x {
guard.x = value;
if x.is_some() || y.is_some() {
if let Ok(guard) = snapshot.0.read() {
let _ = platform.0.set_window_position(guard.x, guard.y);
}
if let Some(value) = y {
guard.y = value;
}
if let Some(value) = scale {
guard.scale = value;
}
guard.last_error = None;
}
}
FrontendCommand::SetFlags {
@@ -937,36 +898,18 @@ fn poll_backend_commands(
always_on_top,
visible,
} => {
if click_through.is_some() {
config.config.window.click_through = false;
}
let _ = click_through;
if let Some(value) = always_on_top {
let _ = platform.0.set_always_on_top(value);
config.config.window.always_on_top = value;
}
if let Some(value) = visible {
let _ = platform.0.set_visible(value);
config.config.window.visible = value;
*visibility = if value {
Visibility::Visible
} else {
Visibility::Hidden
};
}
if let Err(err) = save(&config.path, &config.config) {
warn!(%err, "failed to persist flag config");
}
if let Ok(mut guard) = snapshot.0.write() {
guard.flags.click_through = false;
if let Some(value) = always_on_top {
guard.flags.always_on_top = value;
}
if let Some(value) = visible {
guard.flags.visible = value;
}
guard.last_error = None;
}
}
FrontendCommand::Toast { text, .. } => {
info!(toast = text, "toast command received");
@@ -977,6 +920,7 @@ fn poll_backend_commands(
fn tick_state_reset(
time: Res<Time>,
runtime_core: Res<RuntimeCoreResource>,
mut reset: ResMut<PendingStateReset>,
mut animation: ResMut<AnimationResource>,
current_pack: Res<CurrentPackResource>,
@@ -993,6 +937,10 @@ fn tick_state_reset(
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) {