diff --git a/Cargo.lock b/Cargo.lock index c6ee270..b2d21a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,16 +227,20 @@ dependencies = [ "bytes", "crossbeam-channel", "futures-lite", + "glam 0.29.3", "iroh", "iroh-gossip", - "lib", + "libmarathon", "objc", + "rand 0.8.5", + "raw-window-handle", "serde", "tempfile", "tokio", "tracing", "tracing-subscriber", "uuid", + "winit", ] [[package]] @@ -1145,7 +1149,7 @@ dependencies = [ "approx", "bevy_reflect", "derive_more 2.0.1", - "glam", + "glam 0.30.9", "itertools 0.14.0", "libm", "rand 0.9.2", @@ -1320,7 +1324,7 @@ dependencies = [ "downcast-rs 2.0.2", "erased-serde", "foldhash 0.2.0", - "glam", + "glam 0.30.9", "inventory", "petgraph", "serde", @@ -2815,7 +2819,7 @@ checksum = "02ba239319a4f60905966390f5e52799d868103a533bb7e27822792332504ddd" dependencies = [ "const_panic", "encase_derive", - "glam", + "glam 0.30.9", "thiserror 2.0.17", ] @@ -3345,6 +3349,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + [[package]] name = "glam" version = "0.30.9" @@ -3628,7 +3638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b" dependencies = [ "constgebra", - "glam", + "glam 0.30.9", "tinyvec", ] @@ -4466,36 +4476,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "lib" -version = "0.1.0" -dependencies = [ - "anyhow", - "bevy", - "bincode", - "blake3", - "blocking", - "chrono", - "crdts", - "criterion", - "futures-lite", - "iroh", - "iroh-gossip", - "proptest", - "rand 0.8.5", - "rusqlite", - "serde", - "serde_json", - "sha2 0.10.9", - "sync-macros", - "tempfile", - "thiserror 2.0.17", - "tokio", - "toml", - "tracing", - "uuid", -] - [[package]] name = "libc" version = "0.2.177" @@ -4518,6 +4498,40 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libmarathon" +version = "0.1.0" +dependencies = [ + "anyhow", + "bevy", + "bincode", + "blake3", + "blocking", + "bytes", + "chrono", + "crdts", + "criterion", + "futures-lite", + "glam 0.29.3", + "iroh", + "iroh-gossip", + "proptest", + "rand 0.8.5", + "raw-window-handle", + "rusqlite", + "serde", + "serde_json", + "sha2 0.10.9", + "sync-macros", + "tempfile", + "thiserror 2.0.17", + "tokio", + "toml", + "tracing", + "uuid", + "winit", +] + [[package]] name = "libredox" version = "0.1.10" @@ -7014,7 +7028,7 @@ dependencies = [ "anyhow", "bevy", "bincode", - "lib", + "libmarathon", "proc-macro2", "quote", "serde", @@ -7959,6 +7973,7 @@ checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", "log", + "once_cell", "pkg-config", ] diff --git a/Cargo.toml b/Cargo.toml index d1c21a5..9703bf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/lib", "crates/sync-macros", "crates/app"] +members = ["crates/libmarathon", "crates/sync-macros", "crates/app"] resolver = "2" [workspace.package] diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 126140f..7598046 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -10,9 +10,8 @@ ios = [] headless = [] [dependencies] -lib = { path = "../lib" } +libmarathon = { path = "../libmarathon" } bevy = { version = "0.17", default-features = false, features = [ - "bevy_winit", "bevy_render", "bevy_core_pipeline", "bevy_pbr", @@ -21,12 +20,16 @@ bevy = { version = "0.17", default-features = false, features = [ "png", ] } bevy_egui = "0.38" +glam = "0.29" +winit = "0.30" +raw-window-handle = "0.6" uuid = { version = "1.0", features = ["v4", "serde"] } anyhow = "1.0" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1.0", features = ["derive"] } +rand = "0.8" iroh = { version = "0.95", features = ["discovery-local-network"] } iroh-gossip = "0.95" futures-lite = "2.0" @@ -36,6 +39,7 @@ crossbeam-channel = "0.5.15" [target.'cfg(target_os = "ios")'.dependencies] objc = "0.2" +raw-window-handle = "0.6" [dev-dependencies] iroh = { version = "0.95", features = ["discovery-local-network"] } diff --git a/crates/app/src/cube.rs b/crates/app/src/cube.rs index 13535b2..2f56526 100644 --- a/crates/app/src/cube.rs +++ b/crates/app/src/cube.rs @@ -1,10 +1,13 @@ //! Cube entity management use bevy::prelude::*; -use lib::{ +use libmarathon::{ networking::{ + NetworkEntityMap, NetworkedEntity, + NetworkedSelection, NetworkedTransform, + NodeVectorClock, Synced, }, persistence::Persisted, @@ -20,53 +23,79 @@ use uuid::Uuid; #[reflect(Component)] pub struct CubeMarker; +/// Message to spawn a new cube at a specific position +#[derive(Message)] +pub struct SpawnCubeEvent { + pub position: Vec3, +} + +/// Message to delete a cube by its network ID +#[derive(Message)] +pub struct DeleteCubeEvent { + pub entity_id: Uuid, +} + pub struct CubePlugin; impl Plugin for CubePlugin { fn build(&self, app: &mut App) { app.register_type::() - .add_systems(Startup, spawn_cube); + .add_message::() + .add_message::() + .add_systems(Update, (handle_spawn_cube, handle_delete_cube)); } } -/// Spawn the synced cube on startup -fn spawn_cube( +/// Handle cube spawn messages +fn handle_spawn_cube( mut commands: Commands, + mut messages: MessageReader, mut meshes: ResMut>, mut materials: ResMut>, - node_clock: Option>, + node_clock: Res, ) { - // Wait until NodeVectorClock is available (after networking plugin initializes) - let Some(clock) = node_clock else { - warn!("NodeVectorClock not ready, deferring cube spawn"); - return; - }; + for event in messages.read() { + let entity_id = Uuid::new_v4(); + let node_id = node_clock.node_id; - let entity_id = Uuid::new_v4(); - let node_id = clock.node_id; + info!("Spawning cube {} at {:?}", entity_id, event.position); - info!("Spawning cube with network ID: {}", entity_id); - - commands.spawn(( - CubeMarker, - // Bevy 3D components - Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), - MeshMaterial3d(materials.add(StandardMaterial { - base_color: Color::srgb(0.8, 0.3, 0.6), - perceptual_roughness: 0.7, - metallic: 0.3, - ..default() - })), - Transform::from_xyz(0.0, 0.5, 0.0), - GlobalTransform::default(), - // Networking - NetworkedEntity::with_id(entity_id, node_id), - NetworkedTransform, - // Persistence - Persisted::with_id(entity_id), - // Sync marker - Synced, - )); - - info!("Cube spawned successfully"); + commands.spawn(( + CubeMarker, + // Bevy 3D components + Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: Color::srgb(0.8, 0.3, 0.6), + perceptual_roughness: 0.7, + metallic: 0.3, + ..default() + })), + Transform::from_translation(event.position), + GlobalTransform::default(), + // Networking + NetworkedEntity::with_id(entity_id, node_id), + NetworkedTransform, + NetworkedSelection::default(), + // Persistence + Persisted::with_id(entity_id), + // Sync marker + Synced, + )); + } +} + +/// Handle cube delete messages +fn handle_delete_cube( + mut commands: Commands, + mut messages: MessageReader, + entity_map: Res, +) { + for event in messages.read() { + if let Some(bevy_entity) = entity_map.get_entity(event.entity_id) { + info!("Deleting cube {}", event.entity_id); + commands.entity(bevy_entity).despawn(); + } else { + warn!("Attempted to delete unknown cube {}", event.entity_id); + } + } } diff --git a/crates/app/src/debug_ui.rs b/crates/app/src/debug_ui.rs index 3f1af2e..3480d44 100644 --- a/crates/app/src/debug_ui.rs +++ b/crates/app/src/debug_ui.rs @@ -6,11 +6,15 @@ use bevy_egui::{ EguiContexts, EguiPrimaryContextPass, }; -use lib::networking::{ +use libmarathon::networking::{ + EntityLockRegistry, GossipBridge, + NetworkedEntity, NodeVectorClock, }; +use crate::cube::{CubeMarker, DeleteCubeEvent, SpawnCubeEvent}; + pub struct DebugUiPlugin; impl Plugin for DebugUiPlugin { @@ -24,10 +28,10 @@ fn render_debug_ui( mut contexts: EguiContexts, node_clock: Option>, gossip_bridge: Option>, - cube_query: Query< - (&Transform, &lib::networking::NetworkedEntity), - With, - >, + lock_registry: Option>, + cube_query: Query<(&Transform, &NetworkedEntity), With>, + mut spawn_events: MessageWriter, + mut delete_events: MessageWriter, ) { let Ok(ctx) = contexts.ctx_mut() else { return; @@ -106,11 +110,80 @@ fn render_debug_ui( }, } + ui.add_space(10.0); + ui.heading("Entity Controls"); + ui.separator(); + + if ui.button("➕ Spawn Cube").clicked() { + spawn_events.write(SpawnCubeEvent { + position: Vec3::new( + rand::random::() * 4.0 - 2.0, + 0.5, + rand::random::() * 4.0 - 2.0, + ), + }); + } + + ui.label(format!("Total cubes: {}", cube_query.iter().count())); + + // List all cubes with delete buttons + ui.add_space(5.0); + egui::ScrollArea::vertical() + .id_salt("cube_list") + .max_height(150.0) + .show(ui, |ui| { + for (_transform, networked) in cube_query.iter() { + ui.horizontal(|ui| { + ui.label(format!("Cube {:.8}...", networked.network_id)); + if ui.small_button("🗑").clicked() { + delete_events.write(DeleteCubeEvent { + entity_id: networked.network_id, + }); + } + }); + } + }); + + ui.add_space(10.0); + ui.heading("Lock Status"); + ui.separator(); + + if let (Some(lock_registry), Some(clock)) = (&lock_registry, &node_clock) { + let node_id = clock.node_id; + let locked_cubes = cube_query + .iter() + .filter(|(_, networked)| lock_registry.is_locked(networked.network_id, node_id)) + .count(); + + ui.label(format!("Locked entities: {}", locked_cubes)); + + ui.add_space(5.0); + egui::ScrollArea::vertical() + .id_salt("lock_list") + .max_height(100.0) + .show(ui, |ui| { + for (_, networked) in cube_query.iter() { + let entity_id = networked.network_id; + if let Some(holder) = lock_registry.get_holder(entity_id, node_id) { + let is_ours = holder == node_id; + ui.horizontal(|ui| { + ui.label(format!("🔒 {:.8}...", entity_id)); + ui.label(if is_ours { "(you)" } else { "(peer)" }); + }); + } + } + }); + } else { + ui.label("Lock registry: Not ready"); + } + ui.add_space(10.0); ui.heading("Controls"); ui.separator(); + ui.label("Left click: Select cube"); ui.label("Left drag: Move cube (XY)"); ui.label("Right drag: Rotate cube"); ui.label("Scroll: Move cube (Z)"); + ui.label("ESC: Deselect"); }); } diff --git a/crates/app/src/engine_bridge.rs b/crates/app/src/engine_bridge.rs new file mode 100644 index 0000000..b0f3d04 --- /dev/null +++ b/crates/app/src/engine_bridge.rs @@ -0,0 +1,124 @@ +//! Bevy plugin for polling engine events and dispatching them +//! +//! This plugin bridges the gap between the tokio-based engine and Bevy's ECS. +//! It polls events from the EngineBridge every frame and dispatches them to +//! Bevy systems. + +use bevy::prelude::*; +use libmarathon::{ + engine::{EngineBridge, EngineCommand, EngineEvent}, + networking::{CurrentSession, NetworkedEntity, NodeVectorClock, Session, SessionState, VectorClock}, +}; + +pub struct EngineBridgePlugin; + +impl Plugin for EngineBridgePlugin { + fn build(&self, app: &mut App) { + // Add the event polling system - runs every tick in Update + app.add_systems(Update, poll_engine_events); + // Detect changes and send clock tick commands to engine + app.add_systems(PostUpdate, detect_changes_and_tick); + } +} + +/// Detect changes to networked entities and send tick commands to engine +/// +/// Uses Bevy's change detection to detect when Transform changes on any +/// NetworkedEntity. When changes are detected, sends a TickClock command +/// to the engine, which will increment its clock and send back a ClockTicked event. +fn detect_changes_and_tick( + bridge: Res, + changed_query: Query<(), (With, Changed)>, +) { + // If any networked transforms changed this frame, tick the clock + if !changed_query.is_empty() { + bridge.send_command(EngineCommand::TickClock); + } +} + +/// Poll events from the engine and dispatch to Bevy +/// +/// This system runs every tick and: +/// 1. Polls all available events from the EngineBridge +/// 2. Dispatches them to update Bevy resources and state +fn poll_engine_events( + mut commands: Commands, + bridge: Res, + mut current_session: Option>, + mut node_clock: ResMut, +) { + let events = (*bridge).poll_events(); + + if !events.is_empty() { + for event in events { + match event { + EngineEvent::NetworkingStarted { session_id, node_id } => { + info!("🌐 Networking started: session={}, node={}", + session_id.to_code(), node_id); + + // Create session if it doesn't exist + if current_session.is_none() { + let mut session = Session::new(session_id.clone()); + session.state = SessionState::Active; + commands.insert_resource(CurrentSession::new(session, VectorClock::new())); + info!("Created new session resource: {}", session_id.to_code()); + } else if let Some(ref mut session) = current_session { + // Update existing session state to Active + session.session.state = SessionState::Active; + } + + // Update node ID in clock + node_clock.node_id = node_id; + } + EngineEvent::NetworkingFailed { error } => { + error!("❌ Networking failed: {}", error); + + // Keep session state as Created (if session exists) + if let Some(ref mut session) = current_session { + session.session.state = SessionState::Created; + } + } + EngineEvent::NetworkingStopped => { + info!("🔌 Networking stopped"); + + // Update session state to Disconnected (if session exists) + if let Some(ref mut session) = current_session { + session.session.state = SessionState::Disconnected; + } + } + EngineEvent::PeerJoined { node_id } => { + info!("👋 Peer joined: {}", node_id); + // TODO(Phase 3.3): Trigger sync + } + EngineEvent::PeerLeft { node_id } => { + info!("👋 Peer left: {}", node_id); + } + EngineEvent::LockAcquired { entity_id, holder } => { + debug!("🔒 Lock acquired: entity={}, holder={}", entity_id, holder); + // TODO(Phase 3.4): Update lock visuals + } + EngineEvent::LockReleased { entity_id } => { + debug!("🔓 Lock released: entity={}", entity_id); + // TODO(Phase 3.4): Update lock visuals + } + EngineEvent::LockDenied { entity_id, current_holder } => { + debug!("⛔ Lock denied: entity={}, holder={}", entity_id, current_holder); + // TODO(Phase 3.4): Show visual feedback + } + EngineEvent::LockExpired { entity_id } => { + debug!("⏰ Lock expired: entity={}", entity_id); + // TODO(Phase 3.4): Update lock visuals + } + EngineEvent::ClockTicked { sequence, clock } => { + debug!("🕐 Clock ticked to {}", sequence); + + // Update the NodeVectorClock resource with the new clock state + node_clock.clock = clock; + } + _ => { + debug!("Unhandled engine event: {:?}", event); + } + } + } + } +} diff --git a/crates/app/src/executor.rs b/crates/app/src/executor.rs new file mode 100644 index 0000000..e18a043 --- /dev/null +++ b/crates/app/src/executor.rs @@ -0,0 +1,235 @@ +//! Application executor - owns winit and drives Bevy ECS +//! +//! The executor gives us full control over the event loop and allows +//! both the window and ECS to run unbounded (maximum performance). + +use bevy::prelude::*; +use bevy::app::AppExit; +use bevy::input::{ + ButtonInput, + mouse::MouseButton as BevyMouseButton, + keyboard::KeyCode as BevyKeyCode, + touch::{Touches, TouchInput}, +}; +use bevy::window::{ + PrimaryWindow, WindowCreated, WindowResized, WindowScaleFactorChanged, WindowClosing, + WindowResolution, WindowMode, WindowPosition, WindowEvent as BevyWindowEvent, + RawHandleWrapper, WindowWrapper, +}; +use bevy::ecs::message::Messages; +use libmarathon::engine::InputEvent; +use libmarathon::platform::desktop; +use std::sync::Arc; +use winit::application::ApplicationHandler; +use winit::event::WindowEvent as WinitWindowEvent; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::window::{Window as WinitWindow, WindowId, WindowAttributes}; + +// Re-export InputEventBuffer from the input module +pub use crate::input::event_buffer::InputEventBuffer; + +/// Application handler state machine +enum AppHandler { + Initializing { app: App }, + Running { + window: Arc, + bevy_window_entity: Entity, + bevy_app: App, + }, +} + +impl AppHandler { + fn initialize(&mut self, event_loop: &ActiveEventLoop) { + // Only initialize if we're in the Initializing state + if !matches!(self, AppHandler::Initializing { .. }) { + return; + } + + // Take ownership of the app (replace with placeholder temporarily) + let temp_state = std::mem::replace(self, AppHandler::Initializing { app: App::new() }); + let AppHandler::Initializing { app } = temp_state else { unreachable!() }; + let mut bevy_app = app; + + // Insert InputEventBuffer resource + bevy_app.insert_resource(InputEventBuffer::default()); + + // Initialize window message channels + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + + // Initialize input resources that Bevy UI and picking expect + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::(); + bevy_app.init_resource::>(); + + // Create the winit window BEFORE finishing the app + let window_attributes = WindowAttributes::default() + .with_title("Marathon") + .with_inner_size(winit::dpi::LogicalSize::new(1280, 720)); + + let winit_window = event_loop.create_window(window_attributes) + .expect("Failed to create window"); + let winit_window = Arc::new(winit_window); + info!("Created window before app.finish()"); + + let physical_size = winit_window.inner_size(); + let scale_factor = winit_window.scale_factor(); + + // Create window entity with all required components + let mut window = bevy::window::Window { + title: "Marathon".to_string(), + resolution: WindowResolution::new( + physical_size.width, + physical_size.height, + ), + mode: WindowMode::Windowed, + position: WindowPosition::Automatic, + focused: true, + ..Default::default() + }; + window.resolution.set_scale_factor_override(Some(scale_factor as f32)); + + // Create WindowWrapper and RawHandleWrapper for renderer + let window_wrapper = WindowWrapper::new(winit_window.clone()); + let raw_handle_wrapper = RawHandleWrapper::new(&window_wrapper) + .expect("Failed to create RawHandleWrapper"); + + let window_entity = bevy_app.world_mut().spawn(( + window, + PrimaryWindow, + raw_handle_wrapper, + )).id(); + info!("Created window entity {}", window_entity); + + // Send WindowCreated event + bevy_app.world_mut() + .resource_mut::>() + .write(WindowCreated { window: window_entity }); + + // Send WindowResized event + bevy_app.world_mut() + .resource_mut::>() + .write(WindowResized { + window: window_entity, + width: physical_size.width as f32 / scale_factor as f32, + height: physical_size.height as f32 / scale_factor as f32, + }); + + // Now finish the app - the renderer will initialize with the window + bevy_app.finish(); + bevy_app.cleanup(); + info!("App finished and cleaned up"); + + // Transition to Running state + *self = AppHandler::Running { + window: winit_window, + bevy_window_entity: window_entity, + bevy_app, + }; + } +} + +impl ApplicationHandler for AppHandler { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + // Initialize on first resumed() call + self.initialize(event_loop); + info!("App resumed"); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WinitWindowEvent, + ) { + // Only handle events if we're in Running state + let AppHandler::Running { + ref window, + bevy_window_entity, + ref mut bevy_app, + } = self + else { + return; + }; + + // Forward input events to platform bridge + desktop::push_window_event(&event); + + match event { + WinitWindowEvent::CloseRequested => { + info!("Window close requested"); + event_loop.exit(); + } + + WinitWindowEvent::Resized(physical_size) => { + // Notify Bevy of window resize + let scale_factor = window.scale_factor(); + bevy_app.world_mut() + .resource_mut::>() + .write(WindowResized { + window: *bevy_window_entity, + width: physical_size.width as f32 / scale_factor as f32, + height: physical_size.height as f32 / scale_factor as f32, + }); + } + + WinitWindowEvent::RedrawRequested => { + // Collect input events from platform bridge + let input_events = desktop::drain_as_input_events(); + + // Write events to InputEventBuffer resource + bevy_app.world_mut().resource_mut::().events = input_events; + + // Run one Bevy ECS update (unbounded) + bevy_app.update(); + + // Check if app should exit + if let Some(exit) = bevy_app.should_exit() { + info!("App exit requested: {:?}", exit); + event_loop.exit(); + } + + // Clear input buffer for next frame + bevy_app.world_mut().resource_mut::().clear(); + + // Request next frame immediately (unbounded loop) + window.request_redraw(); + } + + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + // Request redraw to keep loop running + if let AppHandler::Running { ref window, .. } = self { + window.request_redraw(); + } + } +} + +/// Run the application executor +pub fn run(app: App) -> Result<(), Box> { + let event_loop = EventLoop::new()?; + + // TODO: Add battery power detection and adaptive frame/tick rate limiting + // When on battery: reduce to 60fps cap, lower ECS tick rate + // When plugged in: run unbounded for maximum performance + + // Run as fast as possible (unbounded) + event_loop.set_control_flow(ControlFlow::Poll); + + info!("Starting executor (unbounded mode)"); + + // Create handler in Initializing state + // It will transition to Running state on first resumed() callback + let mut handler = AppHandler::Initializing { app }; + + event_loop.run_app(&mut handler)?; + + Ok(()) +} diff --git a/crates/app/src/input/desktop_bridge.rs b/crates/app/src/input/desktop_bridge.rs new file mode 100644 index 0000000..9839666 --- /dev/null +++ b/crates/app/src/input/desktop_bridge.rs @@ -0,0 +1,226 @@ +//! Bridge Bevy's input to the engine's InputEvent system +//! +//! This temporarily reads Bevy's input and converts to InputEvents. +//! Later, we'll replace this with direct winit ownership. + +use bevy::prelude::*; +use bevy::input::keyboard::KeyboardInput; +use bevy::input::mouse::{MouseButtonInput, MouseWheel}; +use bevy::window::CursorMoved; +use libmarathon::engine::{InputEvent, KeyCode as EngineKeyCode, MouseButton as EngineMouseButton, TouchPhase, Modifiers}; + +/// Convert Bevy's Vec2 to glam::Vec2 +/// +/// Bevy re-exports glam types, so they're the same layout. +/// We just construct a new one to be safe. +#[inline] +fn to_glam_vec2(v: bevy::math::Vec2) -> glam::Vec2 { + glam::Vec2::new(v.x, v.y) +} + +/// Convert Bevy's KeyCode to engine's KeyCode (winit::keyboard::KeyCode) +/// +/// Bevy re-exports winit's KeyCode but wraps it, so we need to extract it. +/// For now, we'll just match the common keys. TODO: Complete mapping. +fn bevy_to_engine_keycode(bevy_key: KeyCode) -> Option { + // In Bevy 0.17, KeyCode variants match winit directly + // We can use format matching as a temporary solution + use EngineKeyCode as E; + + Some(match bevy_key { + KeyCode::KeyA => E::KeyA, + KeyCode::KeyB => E::KeyB, + KeyCode::KeyC => E::KeyC, + KeyCode::KeyD => E::KeyD, + KeyCode::KeyE => E::KeyE, + KeyCode::KeyF => E::KeyF, + KeyCode::KeyG => E::KeyG, + KeyCode::KeyH => E::KeyH, + KeyCode::KeyI => E::KeyI, + KeyCode::KeyJ => E::KeyJ, + KeyCode::KeyK => E::KeyK, + KeyCode::KeyL => E::KeyL, + KeyCode::KeyM => E::KeyM, + KeyCode::KeyN => E::KeyN, + KeyCode::KeyO => E::KeyO, + KeyCode::KeyP => E::KeyP, + KeyCode::KeyQ => E::KeyQ, + KeyCode::KeyR => E::KeyR, + KeyCode::KeyS => E::KeyS, + KeyCode::KeyT => E::KeyT, + KeyCode::KeyU => E::KeyU, + KeyCode::KeyV => E::KeyV, + KeyCode::KeyW => E::KeyW, + KeyCode::KeyX => E::KeyX, + KeyCode::KeyY => E::KeyY, + KeyCode::KeyZ => E::KeyZ, + KeyCode::Digit1 => E::Digit1, + KeyCode::Digit2 => E::Digit2, + KeyCode::Digit3 => E::Digit3, + KeyCode::Digit4 => E::Digit4, + KeyCode::Digit5 => E::Digit5, + KeyCode::Digit6 => E::Digit6, + KeyCode::Digit7 => E::Digit7, + KeyCode::Digit8 => E::Digit8, + KeyCode::Digit9 => E::Digit9, + KeyCode::Digit0 => E::Digit0, + KeyCode::Space => E::Space, + KeyCode::Enter => E::Enter, + KeyCode::Escape => E::Escape, + KeyCode::Backspace => E::Backspace, + KeyCode::Tab => E::Tab, + KeyCode::ShiftLeft => E::ShiftLeft, + KeyCode::ShiftRight => E::ShiftRight, + KeyCode::ControlLeft => E::ControlLeft, + KeyCode::ControlRight => E::ControlRight, + KeyCode::AltLeft => E::AltLeft, + KeyCode::AltRight => E::AltRight, + KeyCode::SuperLeft => E::SuperLeft, + KeyCode::SuperRight => E::SuperRight, + KeyCode::ArrowUp => E::ArrowUp, + KeyCode::ArrowDown => E::ArrowDown, + KeyCode::ArrowLeft => E::ArrowLeft, + KeyCode::ArrowRight => E::ArrowRight, + _ => return None, // Unmapped keys + }) +} + +pub struct DesktopInputBridgePlugin; + +impl Plugin for DesktopInputBridgePlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(PreUpdate, ( + clear_buffer, + collect_mouse_buttons, + collect_mouse_motion, + collect_mouse_wheel, + collect_keyboard, + ).chain()); + } +} + +/// Buffer for InputEvents collected this frame +#[derive(Resource, Default)] +pub struct InputEventBuffer { + pub events: Vec, +} + +impl InputEventBuffer { + /// Get all events from this frame + pub fn events(&self) -> &[InputEvent] { + &self.events + } +} + +/// Clear the buffer at the start of each frame +fn clear_buffer(mut buffer: ResMut) { + buffer.events.clear(); +} + +/// Collect mouse button events +fn collect_mouse_buttons( + mut buffer: ResMut, + mut mouse_button_events: MessageReader, + windows: Query<&Window>, +) { + let cursor_pos = windows + .single() + .ok() + .and_then(|w| w.cursor_position()) + .unwrap_or(Vec2::ZERO); + + for event in mouse_button_events.read() { + let button = match event.button { + MouseButton::Left => EngineMouseButton::Left, + MouseButton::Right => EngineMouseButton::Right, + MouseButton::Middle => EngineMouseButton::Middle, + _ => continue, + }; + + let phase = if event.state.is_pressed() { + TouchPhase::Started + } else { + TouchPhase::Ended + }; + + buffer.events.push(InputEvent::Mouse { + pos: to_glam_vec2(cursor_pos), + button, + phase, + }); + } +} + +/// Collect mouse motion events (for drag tracking) +fn collect_mouse_motion( + mut buffer: ResMut, + mut cursor_moved: MessageReader, + mouse_buttons: Res>, +) { + // Only process if cursor actually moved + for event in cursor_moved.read() { + let cursor_pos = event.position; + + // Generate drag events for currently pressed buttons + if mouse_buttons.pressed(MouseButton::Left) { + buffer.events.push(InputEvent::Mouse { + pos: to_glam_vec2(cursor_pos), + button: EngineMouseButton::Left, + phase: TouchPhase::Moved, + }); + } + if mouse_buttons.pressed(MouseButton::Right) { + buffer.events.push(InputEvent::Mouse { + pos: to_glam_vec2(cursor_pos), + button: EngineMouseButton::Right, + phase: TouchPhase::Moved, + }); + } + } +} + +/// Collect mouse wheel events +fn collect_mouse_wheel( + mut buffer: ResMut, + mut wheel_events: MessageReader, + windows: Query<&Window>, +) { + let cursor_pos = windows + .single() + .ok() + .and_then(|w| w.cursor_position()) + .unwrap_or(Vec2::ZERO); + + for event in wheel_events.read() { + buffer.events.push(InputEvent::MouseWheel { + delta: to_glam_vec2(Vec2::new(event.x, event.y)), + pos: to_glam_vec2(cursor_pos), + }); + } +} + +/// Collect keyboard events +fn collect_keyboard( + mut buffer: ResMut, + mut keyboard_events: MessageReader, + keys: Res>, +) { + for event in keyboard_events.read() { + let modifiers = Modifiers { + shift: keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]), + ctrl: keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]), + alt: keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]), + meta: keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]), + }; + + // Convert Bevy's KeyCode to engine's KeyCode + if let Some(engine_key) = bevy_to_engine_keycode(event.key_code) { + buffer.events.push(InputEvent::Keyboard { + key: engine_key, + pressed: event.state.is_pressed(), + modifiers, + }); + } + } +} diff --git a/crates/app/src/input/event_buffer.rs b/crates/app/src/input/event_buffer.rs new file mode 100644 index 0000000..0fa00aa --- /dev/null +++ b/crates/app/src/input/event_buffer.rs @@ -0,0 +1,22 @@ +//! Input event buffer shared between executor and ECS + +use bevy::prelude::*; +use libmarathon::engine::InputEvent; + +/// Input event buffer resource for Bevy ECS +#[derive(Resource, Default)] +pub struct InputEventBuffer { + pub events: Vec, +} + +impl InputEventBuffer { + /// Get all events from this frame + pub fn events(&self) -> &[InputEvent] { + &self.events + } + + /// Clear the buffer + pub fn clear(&mut self) { + self.events.clear(); + } +} diff --git a/crates/app/src/input/input_handler.rs b/crates/app/src/input/input_handler.rs new file mode 100644 index 0000000..f4362d8 --- /dev/null +++ b/crates/app/src/input/input_handler.rs @@ -0,0 +1,152 @@ +//! Input handling using engine GameActions +//! +//! Processes GameActions (from InputController) and applies them to game entities. + +use bevy::prelude::*; +use libmarathon::{ + engine::{GameAction, InputController}, + networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock}, +}; + +use super::event_buffer::InputEventBuffer; + +pub struct InputHandlerPlugin; + +impl Plugin for InputHandlerPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, handle_game_actions); + } +} + +/// Resource wrapping the InputController +#[derive(Resource)] +struct InputControllerResource { + controller: InputController, +} + +impl Default for InputControllerResource { + fn default() -> Self { + Self { + controller: InputController::new(), + } + } +} + +/// Convert glam::Vec2 to Bevy's Vec2 +/// +/// They're the same type, just construct a new one. +#[inline] +fn to_bevy_vec2(v: glam::Vec2) -> bevy::math::Vec2 { + bevy::math::Vec2::new(v.x, v.y) +} + +/// Process GameActions and apply to entities +fn handle_game_actions( + input_buffer: Res, + mut controller_res: ResMut, + lock_registry: Res, + node_clock: Res, + mut cube_query: Query<(&NetworkedEntity, &mut Transform), With>, +) { + let node_id = node_clock.node_id; + + // Process all input events through the controller to get game actions + let mut all_actions = Vec::new(); + for event in input_buffer.events() { + let actions = controller_res.controller.process_event(event); + all_actions.extend(actions); + } + + // Apply game actions to entities + for action in all_actions { + match action { + GameAction::MoveEntity { delta } => { + apply_move_entity(delta, &lock_registry, node_id, &mut cube_query); + } + + GameAction::RotateEntity { delta } => { + apply_rotate_entity(delta, &lock_registry, node_id, &mut cube_query); + } + + GameAction::MoveEntityDepth { delta } => { + apply_move_depth(delta, &lock_registry, node_id, &mut cube_query); + } + + GameAction::ResetEntity => { + apply_reset_entity(&lock_registry, node_id, &mut cube_query); + } + + _ => { + // Other actions not yet implemented + } + } + } +} + +/// Apply MoveEntity action to locked cubes +fn apply_move_entity( + delta: glam::Vec2, + lock_registry: &EntityLockRegistry, + node_id: uuid::Uuid, + cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With>, +) { + let bevy_delta = to_bevy_vec2(delta); + let sensitivity = 0.01; // Scale factor + + for (networked, mut transform) in cube_query.iter_mut() { + if lock_registry.is_locked_by(networked.network_id, node_id, node_id) { + transform.translation.x += bevy_delta.x * sensitivity; + transform.translation.y -= bevy_delta.y * sensitivity; // Invert Y for screen coords + } + } +} + +/// Apply RotateEntity action to locked cubes +fn apply_rotate_entity( + delta: glam::Vec2, + lock_registry: &EntityLockRegistry, + node_id: uuid::Uuid, + cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With>, +) { + let bevy_delta = to_bevy_vec2(delta); + let sensitivity = 0.01; + + for (networked, mut transform) in cube_query.iter_mut() { + if lock_registry.is_locked_by(networked.network_id, node_id, node_id) { + let rotation_x = Quat::from_rotation_y(bevy_delta.x * sensitivity); + let rotation_y = Quat::from_rotation_x(-bevy_delta.y * sensitivity); + transform.rotation = rotation_x * transform.rotation * rotation_y; + } + } +} + +/// Apply MoveEntityDepth action to locked cubes +fn apply_move_depth( + delta: f32, + lock_registry: &EntityLockRegistry, + node_id: uuid::Uuid, + cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With>, +) { + let sensitivity = 0.1; + + for (networked, mut transform) in cube_query.iter_mut() { + if lock_registry.is_locked_by(networked.network_id, node_id, node_id) { + transform.translation.z += delta * sensitivity; + } + } +} + +/// Apply ResetEntity action to locked cubes +fn apply_reset_entity( + lock_registry: &EntityLockRegistry, + node_id: uuid::Uuid, + cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With>, +) { + for (networked, mut transform) in cube_query.iter_mut() { + if lock_registry.is_locked_by(networked.network_id, node_id, node_id) { + transform.translation = Vec3::ZERO; + transform.rotation = Quat::IDENTITY; + } + } +} diff --git a/crates/app/src/input/mod.rs b/crates/app/src/input/mod.rs new file mode 100644 index 0000000..59029e0 --- /dev/null +++ b/crates/app/src/input/mod.rs @@ -0,0 +1,28 @@ +//! Input handling modules +//! +//! This module contains platform-specific input adapters that bridge +//! native input (Bevy/winit, iOS pencil) to libmarathon's InputEvent system. + +pub mod event_buffer; +pub mod input_handler; + +#[cfg(target_os = "ios")] +pub mod pencil; + +#[cfg(not(target_os = "ios"))] +pub mod desktop_bridge; + +#[cfg(not(target_os = "ios"))] +pub mod mouse; + +pub use event_buffer::InputEventBuffer; +pub use input_handler::InputHandlerPlugin; + +#[cfg(target_os = "ios")] +pub use pencil::PencilInputPlugin; + +#[cfg(not(target_os = "ios"))] +pub use desktop_bridge::DesktopInputBridgePlugin; + +#[cfg(not(target_os = "ios"))] +pub use mouse::MouseInputPlugin; diff --git a/crates/app/src/input/mouse.rs b/crates/app/src/input/mouse.rs index 226973f..b0d5b9d 100644 --- a/crates/app/src/input/mouse.rs +++ b/crates/app/src/input/mouse.rs @@ -2,6 +2,7 @@ use bevy::prelude::*; use bevy::input::mouse::{MouseMotion, MouseWheel}; +use libmarathon::networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock}; pub struct MouseInputPlugin; @@ -20,13 +21,15 @@ struct MouseState { right_pressed: bool, } -/// Handle mouse input to move and rotate the cube +/// Handle mouse input to move and rotate cubes that are locked by us fn handle_mouse_input( mouse_buttons: Res>, - mut mouse_motion: EventReader, - mut mouse_wheel: EventReader, + mut mouse_motion: MessageReader, + mut mouse_wheel: MessageReader, mut mouse_state: Local>, - mut cube_query: Query<&mut Transform, With>, + lock_registry: Res, + node_clock: Res, + mut cube_query: Query<(&NetworkedEntity, &mut Transform), With>, ) { // Initialize mouse state if needed if mouse_state.is_none() { @@ -38,42 +41,57 @@ fn handle_mouse_input( state.left_pressed = mouse_buttons.pressed(MouseButton::Left); state.right_pressed = mouse_buttons.pressed(MouseButton::Right); + let node_id = node_clock.node_id; + // Get total mouse delta this frame let mut total_delta = Vec2::ZERO; for motion in mouse_motion.read() { total_delta += motion.delta; } - // Process mouse motion + // Process mouse motion - only for cubes locked by us if total_delta != Vec2::ZERO { - for mut transform in cube_query.iter_mut() { + for (networked, mut transform) in cube_query.iter_mut() { + // Only move cubes that we have locked + if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) { + continue; + } + if state.left_pressed { // Left drag: Move cube in XY plane // Scale factor for sensitivity let sensitivity = 0.01; transform.translation.x += total_delta.x * sensitivity; transform.translation.y -= total_delta.y * sensitivity; // Invert Y + // Change detection will trigger clock tick automatically } else if state.right_pressed { // Right drag: Rotate cube let sensitivity = 0.01; let rotation_x = Quat::from_rotation_y(total_delta.x * sensitivity); let rotation_y = Quat::from_rotation_x(-total_delta.y * sensitivity); transform.rotation = rotation_x * transform.rotation * rotation_y; + // Change detection will trigger clock tick automatically } } } - // Process mouse wheel for Z-axis movement + // Process mouse wheel for Z-axis movement - only for cubes locked by us let mut total_scroll = 0.0; for wheel in mouse_wheel.read() { total_scroll += wheel.y; } if total_scroll != 0.0 { - for mut transform in cube_query.iter_mut() { + for (networked, mut transform) in cube_query.iter_mut() { + // Only move cubes that we have locked + if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) { + continue; + } + // Scroll: Move in Z axis let sensitivity = 0.1; transform.translation.z += total_scroll * sensitivity; + // Change detection will trigger clock tick automatically } } } diff --git a/crates/app/src/input/pencil.rs b/crates/app/src/input/pencil.rs new file mode 100644 index 0000000..d25c4f4 --- /dev/null +++ b/crates/app/src/input/pencil.rs @@ -0,0 +1,69 @@ +//! Apple Pencil input system for iOS +//! +//! This module integrates the platform-agnostic pencil bridge with Bevy. + +use bevy::prelude::*; +use libmarathon::{engine::InputEvent, platform::ios}; + +pub struct PencilInputPlugin; + +impl Plugin for PencilInputPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, attach_pencil_capture) + .add_systems(PreUpdate, poll_pencil_input); + } +} + +/// Resource to track the latest pencil state +#[derive(Resource, Default)] +pub struct PencilState { + pub latest: Option, + pub points_this_frame: usize, +} + +/// Attach the Swift pencil capture to Bevy's window +#[cfg(target_os = "ios")] +fn attach_pencil_capture(windows: Query<&bevy::window::RawHandleWrapper, With>) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let Ok(handle) = windows.get_single() else { + warn!("No primary window for pencil capture"); + return; + }; + + unsafe { + if let Ok(raw) = handle.window_handle() { + if let RawWindowHandle::UiKit(h) = raw.as_ref() { + ios::swift_attach_pencil_capture(h.ui_view.as_ptr() as *mut _); + info!("✏️ Apple Pencil capture attached"); + } + } + } +} + +#[cfg(not(target_os = "ios"))] +fn attach_pencil_capture() { + // No-op on non-iOS platforms +} + +/// Poll pencil input from the platform layer and update PencilState +fn poll_pencil_input(mut commands: Commands, state: Option>) { + let events = ios::drain_as_input_events(); + + if events.is_empty() { + return; + } + + // Insert resource if it doesn't exist + if state.is_none() { + commands.insert_resource(PencilState::default()); + return; + } + + if let Some(mut state) = state { + state.points_this_frame = events.len(); + if let Some(latest) = events.last() { + state.latest = Some(*latest); + } + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 16fd111..aa42e18 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -6,7 +6,10 @@ pub mod camera; pub mod cube; pub mod debug_ui; +pub mod engine_bridge; +pub mod input; pub mod rendering; pub mod setup; pub use cube::CubeMarker; +pub use engine_bridge::EngineBridgePlugin; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8ea3590..e5aea71 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -3,38 +3,36 @@ //! This demonstrates real-time CRDT synchronization with Apple Pencil input. use bevy::prelude::*; -use bevy_egui::EguiPlugin; -use lib::{ - networking::{NetworkingConfig, NetworkingPlugin}, +// use bevy_egui::EguiPlugin; // Disabled - needs WinitPlugin which we own directly +use libmarathon::{ + engine::{EngineBridge, EngineCore}, persistence::{PersistenceConfig, PersistencePlugin}, }; use std::path::PathBuf; -use uuid::Uuid; mod camera; mod cube; mod debug_ui; +mod executor; +mod engine_bridge; mod rendering; +mod selection; +mod session; +mod session_ui; mod setup; -#[cfg(not(target_os = "ios"))] -mod input { - pub mod mouse; - pub use mouse::MouseInputPlugin; -} +use engine_bridge::EngineBridgePlugin; -#[cfg(target_os = "ios")] -mod input { - pub mod pencil; - pub use pencil::PencilInputPlugin; -} +mod input; use camera::*; use cube::*; use debug_ui::*; use input::*; use rendering::*; -use setup::*; +use selection::*; +use session::*; +use session_ui::*; fn main() { // Initialize logging @@ -46,56 +44,62 @@ fn main() { ) .init(); - // Create node ID (in production, load from config or generate once) - let node_id = Uuid::new_v4(); - info!("Starting app with node ID: {}", node_id); - // Database path let db_path = PathBuf::from("cube_demo.db"); + let db_path_str = db_path.to_str().unwrap().to_string(); - // Create Bevy app - App::new() - .add_plugins(DefaultPlugins - .set(WindowPlugin { - primary_window: Some(Window { - title: format!("Replicated Cube Demo - Node {}", &node_id.to_string()[..8]), - resolution: (1280, 720).into(), - ..default() - }), - ..default() - }) - .disable::() // Disable Bevy's logger, using tracing-subscriber instead - ) - .add_plugins(EguiPlugin::default()) - // Networking (bridge will be set up in startup) - .add_plugins(NetworkingPlugin::new(NetworkingConfig { - node_id, - sync_interval_secs: 1.0, - prune_interval_secs: 60.0, - tombstone_gc_interval_secs: 300.0, - })) - // Persistence - .add_plugins(PersistencePlugin::with_config( - db_path, - PersistenceConfig { - flush_interval_secs: 2, - checkpoint_interval_secs: 30, - battery_adaptive: true, - ..Default::default() - }, - )) - // Camera - .add_plugins(CameraPlugin) - // Rendering - .add_plugins(RenderingPlugin) - // Input - .add_plugins(MouseInputPlugin) - // Cube management - .add_plugins(CubePlugin) - // Debug UI - .add_plugins(DebugUiPlugin) - // Gossip networking setup - .add_systems(Startup, setup_gossip_networking) - .add_systems(Update, poll_gossip_bridge) - .run(); + // Create EngineBridge (for communication between Bevy and EngineCore) + let (engine_bridge, engine_handle) = EngineBridge::new(); + info!("EngineBridge created"); + + // Spawn EngineCore on tokio runtime (runs in background thread) + std::thread::spawn(move || { + info!("Starting EngineCore on tokio runtime..."); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let core = EngineCore::new(engine_handle, &db_path_str); + core.run().await; + }); + }); + info!("EngineCore spawned in background"); + + // Create Bevy app (without winit - we own the event loop) + let mut app = App::new(); + + // Insert EngineBridge as a resource for Bevy systems to use + app.insert_resource(engine_bridge); + + // Use DefaultPlugins but disable winit/window/input (we own those) + app.add_plugins( + DefaultPlugins + .build() + .disable::() // Using tracing-subscriber + .disable::() // We own winit + .disable::() // We own the window + .disable::() // We provide InputEvents directly + .disable::() // We handle gamepad input ourselves + ); + + // app.add_plugins(EguiPlugin::default()); // Disabled - needs WinitPlugin + app.add_plugins(EngineBridgePlugin); + app.add_plugins(PersistencePlugin::with_config( + db_path, + PersistenceConfig { + flush_interval_secs: 2, + checkpoint_interval_secs: 30, + battery_adaptive: true, + ..Default::default() + }, + )); + app.add_plugins(CameraPlugin); + app.add_plugins(RenderingPlugin); + app.add_plugins(input::InputHandlerPlugin); + app.add_plugins(CubePlugin); + app.add_plugins(SelectionPlugin); + // app.add_plugins(DebugUiPlugin); // Disabled - uses egui + // app.add_plugins(SessionUiPlugin); // Disabled - uses egui + app.add_systems(Startup, initialize_offline_resources); + + // Run with our executor (unbounded event loop) + executor::run(app).expect("Failed to run executor"); } diff --git a/crates/app/src/selection.rs b/crates/app/src/selection.rs new file mode 100644 index 0000000..fda74b3 --- /dev/null +++ b/crates/app/src/selection.rs @@ -0,0 +1,217 @@ +//! Entity selection and lock acquisition +//! +//! Handles clicking/tapping on entities to select them, acquiring locks, +//! and providing visual feedback based on lock state. + +use bevy::prelude::*; +use libmarathon::networking::{ + EntityLockRegistry, + GossipBridge, + LockMessage, + NetworkedEntity, + NetworkedSelection, + NodeVectorClock, + SyncMessage, + VersionedMessage, +}; +use uuid::Uuid; + +use crate::cube::CubeMarker; + +pub struct SelectionPlugin; + +impl Plugin for SelectionPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + Update, + ( + handle_entity_click, + handle_deselect_key, + update_lock_visuals, + ) + .chain(), + ); + } +} + +/// System to handle clicking/tapping on entities to select and acquire locks +fn handle_entity_click( + mouse_button: Res>, + windows: Query<&Window>, + cameras: Query<(&Camera, &GlobalTransform)>, + cubes: Query<(Entity, &Transform, &NetworkedEntity), With>, + mut selections: Query<&mut NetworkedSelection>, + mut lock_registry: ResMut, + node_clock: Res, + bridge: Option>, +) { + // Only on left click press + if !mouse_button.just_pressed(MouseButton::Left) { + return; + } + + let Ok(window) = windows.single() else { + return; + }; + let Some(cursor_pos) = window.cursor_position() else { + return; + }; + let Ok((camera, cam_transform)) = cameras.single() else { + return; + }; + + // Cast ray from cursor + let Ok(ray) = camera.viewport_to_world(cam_transform, cursor_pos) else { + return; + }; + + // Find closest cube that intersects ray + let mut closest: Option<(f32, Entity, Uuid)> = None; + + for (entity, transform, networked) in cubes.iter() { + // Simple sphere collision (approximate cube as sphere with radius ~0.7) + let to_cube = transform.translation - ray.origin; + let t = to_cube.dot(*ray.direction); + if t < 0.0 { + continue; + } + + let closest_point = ray.origin + *ray.direction * t; + let distance = (closest_point - transform.translation).length(); + + if distance < 0.7 { + // Hit! + if let Some((best_dist, _, _)) = closest { + if t < best_dist { + closest = Some((t, entity, networked.network_id)); + } + } else { + closest = Some((t, entity, networked.network_id)); + } + } + } + + // Process result + if let Some((_, bevy_entity, entity_id)) = closest { + // Clicked on a cube - try to acquire lock + match lock_registry.try_acquire(entity_id, node_clock.node_id) { + Ok(()) => { + info!("Lock acquired for {}", entity_id); + + // Update selection component + if let Ok(mut selection) = selections.get_mut(bevy_entity) { + selection.selected_ids.clear(); // Clear previous selections + selection.selected_ids.insert(entity_id); + } + + // Broadcast LockRequest to other nodes + if let Some(bridge) = bridge.as_ref() { + let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRequest { + entity_id, + node_id: node_clock.node_id, + })); + if let Err(e) = bridge.send(msg) { + error!("Failed to broadcast lock request: {}", e); + } + } + } + Err(e) => { + warn!("Failed to acquire lock for {}: {}", entity_id, e); + } + } + } else { + // Clicked on empty space - deselect all and release locks + for mut selection in selections.iter_mut() { + // Release all locks we're holding + for entity_id in selection.selected_ids.iter() { + lock_registry.release(*entity_id, node_clock.node_id); + info!("Released lock for {} (clicked away)", entity_id); + + // Broadcast LockRelease to other nodes + if let Some(bridge) = bridge.as_ref() { + let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRelease { + entity_id: *entity_id, + node_id: node_clock.node_id, + })); + if let Err(e) = bridge.send(msg) { + error!("Failed to broadcast lock release: {}", e); + } + } + } + + selection.selected_ids.clear(); + } + } +} + +/// System to handle ESC key for deselection +fn handle_deselect_key( + keyboard: Res>, + mut selections: Query<&mut NetworkedSelection>, + mut lock_registry: ResMut, + node_clock: Res, + bridge: Option>, +) { + if keyboard.just_pressed(KeyCode::Escape) { + for mut selection in selections.iter_mut() { + if !selection.selected_ids.is_empty() { + info!("Deselecting {} entities via ESC key", selection.selected_ids.len()); + + // Release all locks we're holding + for entity_id in selection.selected_ids.iter() { + lock_registry.release(*entity_id, node_clock.node_id); + info!("Released lock for {} (ESC key)", entity_id); + + // Broadcast LockRelease to other nodes + if let Some(bridge) = bridge.as_ref() { + let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRelease { + entity_id: *entity_id, + node_id: node_clock.node_id, + })); + if let Err(e) = bridge.send(msg) { + error!("Failed to broadcast lock release: {}", e); + } + } + } + + selection.selected_ids.clear(); + } + } + } +} + +/// System to update visual appearance based on lock state +/// +/// Color scheme: +/// - Green: Locked by us (we can edit) +/// - Red: Locked by someone else (they can edit, we can't) +/// - Pink: Not locked (nobody is editing) +fn update_lock_visuals( + lock_registry: Res, + node_clock: Res, + mut cubes: Query<(&NetworkedEntity, &mut MeshMaterial3d), With>, + mut materials: ResMut>, +) { + + for (networked, material_handle) in cubes.iter_mut() { + let entity_id = networked.network_id; + + // Determine color based on lock state + let node_id = node_clock.node_id; + let color = if lock_registry.is_locked_by(entity_id, node_id, node_id) { + // Locked by us - green + Color::srgb(0.3, 0.8, 0.3) + } else if lock_registry.is_locked(entity_id, node_id) { + // Locked by someone else - red + Color::srgb(0.8, 0.3, 0.3) + } else { + // Not locked - default pink + Color::srgb(0.8, 0.3, 0.6) + }; + + // Update material color + if let Some(mat) = materials.get_mut(&material_handle.0) { + mat.base_color = color; + } + } +} diff --git a/crates/app/src/session.rs b/crates/app/src/session.rs new file mode 100644 index 0000000..dbc28f1 --- /dev/null +++ b/crates/app/src/session.rs @@ -0,0 +1,36 @@ +//! App-level offline resource management +//! +//! Sets up vector clock and networking resources. Sessions are created later +//! when the user starts networking. + +use bevy::prelude::*; +use libmarathon::{ + networking::{ + EntityLockRegistry, NetworkEntityMap, NodeVectorClock, VectorClock, + }, +}; +use uuid::Uuid; + +/// Initialize offline resources on app startup +/// +/// This sets up the vector clock and networking-related resources, but does NOT +/// create a session. Sessions only exist when networking is active. +pub fn initialize_offline_resources(world: &mut World) { + info!("Initializing offline resources (no session yet)..."); + + // Create node ID (persists for this app instance) + let node_id = Uuid::new_v4(); + info!("Node ID: {}", node_id); + + // Insert vector clock resource (always available, offline or online) + world.insert_resource(NodeVectorClock { + node_id, + clock: VectorClock::new(), + }); + + // Insert networking resources (available from startup, even before networking starts) + world.insert_resource(NetworkEntityMap::default()); + world.insert_resource(EntityLockRegistry::default()); + + info!("Offline resources initialized (vector clock ready)"); +} diff --git a/crates/app/src/session_ui.rs b/crates/app/src/session_ui.rs new file mode 100644 index 0000000..0821ac6 --- /dev/null +++ b/crates/app/src/session_ui.rs @@ -0,0 +1,141 @@ +//! Session UI panel +//! +//! Displays current session code, allows joining different sessions, +//! and shows connected peer information. + +use bevy::prelude::*; +use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass}; +use libmarathon::{ + engine::{EngineBridge, EngineCommand}, + networking::{CurrentSession, NodeVectorClock, SessionId}, +}; + +pub struct SessionUiPlugin; + +impl Plugin for SessionUiPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(EguiPrimaryContextPass, session_ui_panel); + } +} + +#[derive(Resource, Default)] +struct SessionUiState { + join_code_input: String, + show_join_dialog: bool, +} + +fn session_ui_panel( + mut contexts: EguiContexts, + mut ui_state: ResMut, + current_session: Option>, + node_clock: Option>, + bridge: Res, +) { + let Ok(ctx) = contexts.ctx_mut() else { + return; + }; + + egui::Window::new("Session") + .default_pos([320.0, 10.0]) + .default_width(280.0) + .show(ctx, |ui| { + if let Some(session) = current_session.as_ref() { + // ONLINE MODE: Session exists, networking is active + ui.heading("Session (Online)"); + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Code:"); + ui.code(session.session.id.to_code()); + if ui.small_button("📋").clicked() { + // TODO: Copy to clipboard (requires clipboard API) + info!("Session code: {}", session.session.id.to_code()); + } + }); + + ui.label(format!("State: {:?}", session.session.state)); + + if let Some(clock) = node_clock.as_ref() { + ui.label(format!("Connected nodes: {}", clock.clock.clocks.len())); + } + + ui.add_space(10.0); + + // Stop networking button + if ui.button("🔌 Stop Networking").clicked() { + info!("Stopping networking"); + bridge.send_command(EngineCommand::StopNetworking); + } + } else { + // OFFLINE MODE: No session, networking not started + ui.heading("Offline Mode"); + ui.separator(); + + ui.label("World is running offline"); + ui.label("Vector clock is tracking changes"); + + if let Some(clock) = node_clock.as_ref() { + let current_seq = clock.clock.clocks.get(&clock.node_id).copied().unwrap_or(0); + ui.label(format!("Local sequence: {}", current_seq)); + } + + ui.add_space(10.0); + + // Start networking button + if ui.button("🌐 Start Networking").clicked() { + info!("Starting networking (will create new session)"); + // Generate a new session ID on the fly + let new_session_id = libmarathon::networking::SessionId::new(); + info!("New session code: {}", new_session_id.to_code()); + bridge.send_command(EngineCommand::StartNetworking { + session_id: new_session_id, + }); + } + + ui.add_space(5.0); + + // Join existing session button + if ui.button("➕ Join Session").clicked() { + ui_state.show_join_dialog = true; + } + } + }); + + // Join dialog (using same context) + if ui_state.show_join_dialog { + egui::Window::new("Join Session") + .collapsible(false) + .show(ctx, |ui| { + ui.label("Enter session code (abc-def-123):"); + ui.text_edit_singleline(&mut ui_state.join_code_input); + + ui.add_space(5.0); + ui.label("Note: Joining requires app restart"); + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Join").clicked() { + match SessionId::from_code(&ui_state.join_code_input) { + Ok(session_id) => { + info!("Joining session: {} → {}", ui_state.join_code_input, session_id); + bridge.send_command(EngineCommand::JoinSession { + session_id, + }); + ui_state.show_join_dialog = false; + ui_state.join_code_input.clear(); + } + Err(e) => { + error!("Invalid session code '{}': {:?}", ui_state.join_code_input, e); + } + } + } + + if ui.button("Cancel").clicked() { + ui_state.show_join_dialog = false; + ui_state.join_code_input.clear(); + } + }); + }); + } +} diff --git a/crates/app/src/setup.rs b/crates/app/src/setup.rs index 9ebd9e7..85240b7 100644 --- a/crates/app/src/setup.rs +++ b/crates/app/src/setup.rs @@ -49,7 +49,7 @@ use anyhow::Result; use bevy::prelude::*; -use lib::networking::{GossipBridge, SessionId}; +use libmarathon::networking::{GossipBridge, SessionId}; use uuid::Uuid; /// Session ID to use for network initialization @@ -271,7 +271,7 @@ fn spawn_bridge_tasks( use bytes::Bytes; use futures_lite::StreamExt; - use lib::networking::VersionedMessage; + use libmarathon::networking::VersionedMessage; let node_id = bridge.node_id(); diff --git a/crates/app/tests/cube_sync_headless.rs b/crates/app/tests/cube_sync_headless.rs index 6a5c8a8..3c03382 100644 --- a/crates/app/tests/cube_sync_headless.rs +++ b/crates/app/tests/cube_sync_headless.rs @@ -36,7 +36,7 @@ use iroh_gossip::{ net::Gossip, proto::TopicId, }; -use lib::{ +use libmarathon::{ networking::{ GossipBridge, NetworkedEntity, diff --git a/crates/lib/.gitignore b/crates/libmarathon/.gitignore similarity index 100% rename from crates/lib/.gitignore rename to crates/libmarathon/.gitignore diff --git a/crates/lib/Cargo.toml b/crates/libmarathon/Cargo.toml similarity index 83% rename from crates/lib/Cargo.toml rename to crates/libmarathon/Cargo.toml index bbd3db3..20bc0f8 100644 --- a/crates/lib/Cargo.toml +++ b/crates/libmarathon/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "lib" +name = "libmarathon" version = "0.1.0" edition.workspace = true @@ -16,13 +16,19 @@ uuid = { version = "1.0", features = ["v4", "serde"] } toml.workspace = true tracing.workspace = true bevy.workspace = true +glam = "0.29" +winit = "0.30" +raw-window-handle = "0.6" bincode = "1.3" +bytes = "1.0" futures-lite = "2.0" sha2 = "0.10" blake3 = "1.5" rand = "0.8" tokio.workspace = true blocking = "1.6" +iroh = { workspace = true, features = ["discovery-local-network"] } +iroh-gossip.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/lib/benches/vector_clock.rs b/crates/libmarathon/benches/vector_clock.rs similarity index 99% rename from crates/lib/benches/vector_clock.rs rename to crates/libmarathon/benches/vector_clock.rs index a080da6..9e1c1a7 100644 --- a/crates/lib/benches/vector_clock.rs +++ b/crates/libmarathon/benches/vector_clock.rs @@ -10,7 +10,7 @@ use criterion::{ criterion_group, criterion_main, }; -use lib::networking::VectorClock; +use libmarathon::networking::VectorClock; /// Helper to create a vector clock with N nodes fn create_clock_with_nodes(num_nodes: usize) -> VectorClock { diff --git a/crates/lib/benches/write_buffer.rs b/crates/libmarathon/benches/write_buffer.rs similarity index 99% rename from crates/lib/benches/write_buffer.rs rename to crates/libmarathon/benches/write_buffer.rs index 76ebf0a..1111fc3 100644 --- a/crates/lib/benches/write_buffer.rs +++ b/crates/libmarathon/benches/write_buffer.rs @@ -10,7 +10,7 @@ use criterion::{ criterion_group, criterion_main, }; -use lib::persistence::{ +use libmarathon::persistence::{ PersistenceOp, WriteBuffer, }; diff --git a/crates/lib/scripts/export_messages.rs b/crates/libmarathon/scripts/export_messages.rs similarity index 100% rename from crates/lib/scripts/export_messages.rs rename to crates/libmarathon/scripts/export_messages.rs diff --git a/crates/lib/src/db.rs b/crates/libmarathon/src/db.rs similarity index 100% rename from crates/lib/src/db.rs rename to crates/libmarathon/src/db.rs diff --git a/crates/libmarathon/src/engine/bridge.rs b/crates/libmarathon/src/engine/bridge.rs new file mode 100644 index 0000000..bb6c31c --- /dev/null +++ b/crates/libmarathon/src/engine/bridge.rs @@ -0,0 +1,72 @@ +//! Bridge between Bevy and Core Engine +//! +//! TODO(Phase 3): Create a Bevy-specific system (in app crate) that polls +//! `EngineBridge::poll_events()` every tick and dispatches EngineEvents to Bevy +//! (spawn entities, update transforms, update locks, emit Bevy messages, etc.) +//! +//! NOTE: The bridge is ECS-agnostic. Later we can create adapters for other engines +//! like Flecs once we're closer to release. + +use std::sync::Arc; +use tokio::sync::{mpsc, Mutex}; +use bevy::prelude::Resource; + +use super::{EngineCommand, EngineEvent}; + +/// Shared bridge between Bevy and Core Engine +#[derive(Clone, Resource)] +pub struct EngineBridge { + command_tx: mpsc::UnboundedSender, + event_rx: Arc>>, +} + +/// Engine-side handle for receiving commands and sending events +pub struct EngineHandle { + pub(crate) command_rx: mpsc::UnboundedReceiver, + pub(crate) event_tx: mpsc::UnboundedSender, +} + +impl EngineBridge { + /// Create a new bridge and return both the Bevy-side bridge and Engine-side handle + pub fn new() -> (Self, EngineHandle) { + let (command_tx, command_rx) = mpsc::unbounded_channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + + let bridge = Self { + command_tx, + event_rx: Arc::new(Mutex::new(event_rx)), + }; + + let handle = EngineHandle { + command_rx, + event_tx, + }; + + (bridge, handle) + } + + /// Send command from Bevy to Engine + pub fn send_command(&self, cmd: EngineCommand) { + // Ignore send errors (engine might be shut down) + let _ = self.command_tx.send(cmd); + } + + /// Poll events from Engine to Bevy (non-blocking) + /// Returns all available events in the queue + pub fn poll_events(&self) -> Vec { + let mut events = Vec::new(); + // Try to lock without blocking (returns immediately if locked) + if let Ok(mut rx) = self.event_rx.try_lock() { + while let Ok(event) = rx.try_recv() { + events.push(event); + } + } + events + } +} + +impl Default for EngineBridge { + fn default() -> Self { + Self::new().0 + } +} diff --git a/crates/libmarathon/src/engine/commands.rs b/crates/libmarathon/src/engine/commands.rs new file mode 100644 index 0000000..3966ddb --- /dev/null +++ b/crates/libmarathon/src/engine/commands.rs @@ -0,0 +1,50 @@ +//! Commands sent from Bevy to the Core Engine + +use crate::networking::SessionId; +use bevy::prelude::*; +use uuid::Uuid; + +/// Commands that Bevy sends to the Core Engine +#[derive(Debug, Clone)] +pub enum EngineCommand { + // Networking lifecycle + StartNetworking { session_id: SessionId }, + StopNetworking, + JoinSession { session_id: SessionId }, + LeaveSession, + + // CRDT operations + SpawnEntity { + entity_id: Uuid, + position: Vec3, + rotation: Quat, + }, + UpdateTransform { + entity_id: Uuid, + position: Vec3, + rotation: Quat, + }, + DeleteEntity { + entity_id: Uuid, + }, + + // Lock operations + AcquireLock { + entity_id: Uuid, + }, + ReleaseLock { + entity_id: Uuid, + }, + BroadcastHeartbeat { + entity_id: Uuid, + }, + + // Persistence + SaveSession, + LoadSession { + session_id: SessionId, + }, + + // Clock + TickClock, +} diff --git a/crates/libmarathon/src/engine/core.rs b/crates/libmarathon/src/engine/core.rs new file mode 100644 index 0000000..32fb004 --- /dev/null +++ b/crates/libmarathon/src/engine/core.rs @@ -0,0 +1,140 @@ +//! Core Engine event loop - runs on tokio outside Bevy + +use tokio::task::JoinHandle; +use uuid::Uuid; + +use super::{EngineCommand, EngineEvent, EngineHandle, NetworkingManager, PersistenceManager}; +use crate::networking::{SessionId, VectorClock}; + +pub struct EngineCore { + handle: EngineHandle, + networking_task: Option>, + #[allow(dead_code)] + persistence: PersistenceManager, + + // Clock state + node_id: Uuid, + clock: VectorClock, +} + +impl EngineCore { + pub fn new(handle: EngineHandle, db_path: &str) -> Self { + let persistence = PersistenceManager::new(db_path); + let node_id = Uuid::new_v4(); + let clock = VectorClock::new(); + + tracing::info!("EngineCore node ID: {}", node_id); + + Self { + handle, + networking_task: None, // Start offline + persistence, + node_id, + clock, + } + } + + /// Start the engine event loop (runs on tokio) + /// Processes commands unbounded - tokio handles internal polling + pub async fn run(mut self) { + tracing::info!("EngineCore starting (unbounded)..."); + + // Process commands as they arrive + while let Some(cmd) = self.handle.command_rx.recv().await { + self.handle_command(cmd).await; + } + + tracing::info!("EngineCore shutting down (command channel closed)"); + } + + async fn handle_command(&mut self, cmd: EngineCommand) { + match cmd { + EngineCommand::StartNetworking { session_id } => { + self.start_networking(session_id).await; + } + EngineCommand::StopNetworking => { + self.stop_networking().await; + } + EngineCommand::JoinSession { session_id } => { + self.join_session(session_id).await; + } + EngineCommand::LeaveSession => { + self.stop_networking().await; + } + EngineCommand::SaveSession => { + // TODO: Save current session state + tracing::debug!("SaveSession command received (stub)"); + } + EngineCommand::LoadSession { session_id } => { + tracing::debug!("LoadSession command received for {} (stub)", session_id.to_code()); + } + EngineCommand::TickClock => { + self.tick_clock(); + } + // TODO: Handle CRDT and lock commands in Phase 2 + _ => { + tracing::debug!("Unhandled command: {:?}", cmd); + } + } + } + + fn tick_clock(&mut self) { + let seq = self.clock.increment(self.node_id); + let _ = self.handle.event_tx.send(EngineEvent::ClockTicked { + sequence: seq, + clock: self.clock.clone(), + }); + tracing::debug!("Clock ticked to {}", seq); + } + + async fn start_networking(&mut self, session_id: SessionId) { + if self.networking_task.is_some() { + tracing::warn!("Networking already started"); + return; + } + + match NetworkingManager::new(session_id.clone()).await { + Ok(net_manager) => { + let node_id = net_manager.node_id(); + + // Spawn NetworkingManager in background task + let event_tx = self.handle.event_tx.clone(); + let task = tokio::spawn(async move { + net_manager.run(event_tx).await; + }); + + self.networking_task = Some(task); + + let _ = self.handle.event_tx.send(EngineEvent::NetworkingStarted { + session_id: session_id.clone(), + node_id, + }); + tracing::info!("Networking started for session {}", session_id.to_code()); + } + Err(e) => { + let _ = self.handle.event_tx.send(EngineEvent::NetworkingFailed { + error: e.to_string(), + }); + tracing::error!("Failed to start networking: {}", e); + } + } + } + + async fn stop_networking(&mut self) { + if let Some(task) = self.networking_task.take() { + task.abort(); // Cancel the networking task + let _ = self.handle.event_tx.send(EngineEvent::NetworkingStopped); + tracing::info!("Networking stopped"); + } + } + + async fn join_session(&mut self, session_id: SessionId) { + // Stop existing networking if any + if self.networking_task.is_some() { + self.stop_networking().await; + } + + // Start networking with new session + self.start_networking(session_id).await; + } +} diff --git a/crates/libmarathon/src/engine/events.rs b/crates/libmarathon/src/engine/events.rs new file mode 100644 index 0000000..b611ffd --- /dev/null +++ b/crates/libmarathon/src/engine/events.rs @@ -0,0 +1,71 @@ +//! Events emitted from the Core Engine to Bevy + +use crate::networking::{NodeId, SessionId, VectorClock}; +use bevy::prelude::*; +use uuid::Uuid; + +/// Events that the Core Engine emits to Bevy +#[derive(Debug, Clone)] +pub enum EngineEvent { + // Networking status + NetworkingStarted { + session_id: SessionId, + node_id: NodeId, + }, + NetworkingFailed { + error: String, + }, + NetworkingStopped, + SessionJoined { + session_id: SessionId, + }, + SessionLeft, + + // Peer events + PeerJoined { + node_id: NodeId, + }, + PeerLeft { + node_id: NodeId, + }, + + // CRDT sync events + EntitySpawned { + entity_id: Uuid, + position: Vec3, + rotation: Quat, + version: VectorClock, + }, + EntityUpdated { + entity_id: Uuid, + position: Vec3, + rotation: Quat, + version: VectorClock, + }, + EntityDeleted { + entity_id: Uuid, + version: VectorClock, + }, + + // Lock events + LockAcquired { + entity_id: Uuid, + holder: NodeId, + }, + LockReleased { + entity_id: Uuid, + }, + LockDenied { + entity_id: Uuid, + current_holder: NodeId, + }, + LockExpired { + entity_id: Uuid, + }, + + // Clock events + ClockTicked { + sequence: u64, + clock: VectorClock, + }, +} diff --git a/crates/libmarathon/src/engine/game_actions.rs b/crates/libmarathon/src/engine/game_actions.rs new file mode 100644 index 0000000..a050c59 --- /dev/null +++ b/crates/libmarathon/src/engine/game_actions.rs @@ -0,0 +1,118 @@ +//! Semantic game actions +//! +//! Actions represent what the player wants to do, independent of how they +//! triggered it. This enables input remapping and accessibility. + +use glam::Vec2; + +/// High-level game actions that result from input processing +#[derive(Debug, Clone, PartialEq)] +pub enum GameAction { + /// Move an entity in 2D (XY plane) + MoveEntity { + /// Movement delta (in screen/world space) + delta: Vec2, + }, + + /// Rotate an entity + RotateEntity { + /// Rotation delta (yaw, pitch) + delta: Vec2, + }, + + /// Move entity along Z axis (depth) + MoveEntityDepth { + /// Depth delta + delta: f32, + }, + + /// Select/deselect an entity at a position + SelectEntity { + /// Screen position + position: Vec2, + }, + + /// Begin dragging at a position + BeginDrag { + /// Screen position + position: Vec2, + }, + + /// Continue dragging + ContinueDrag { + /// Current screen position + position: Vec2, + /// Delta since last drag event + delta: Vec2, + }, + + /// End dragging + EndDrag { + /// Final screen position + position: Vec2, + }, + + /// Reset entity to default state + ResetEntity, + + /// Delete selected entity + DeleteEntity, + + /// Spawn new entity at position + SpawnEntity { + /// Screen position + position: Vec2, + }, + + /// Camera movement + MoveCamera { + /// Movement delta + delta: Vec2, + }, + + /// Camera zoom + ZoomCamera { + /// Zoom delta + delta: f32, + }, + + /// Toggle UI panel + ToggleUI, + + /// Confirm action (Enter, Space, etc.) + Confirm, + + /// Cancel action (Escape, etc.) + Cancel, + + /// Undo last action + Undo, + + /// Redo last undone action + Redo, +} + +impl GameAction { + /// Get a human-readable description of this action + pub fn description(&self) -> &'static str { + match self { + GameAction::MoveEntity { .. } => "Move entity in XY plane", + GameAction::RotateEntity { .. } => "Rotate entity", + GameAction::MoveEntityDepth { .. } => "Move entity along Z axis", + GameAction::SelectEntity { .. } => "Select/deselect entity", + GameAction::BeginDrag { .. } => "Begin dragging", + GameAction::ContinueDrag { .. } => "Continue dragging", + GameAction::EndDrag { .. } => "End dragging", + GameAction::ResetEntity => "Reset entity to default", + GameAction::DeleteEntity => "Delete selected entity", + GameAction::SpawnEntity { .. } => "Spawn new entity", + GameAction::MoveCamera { .. } => "Move camera", + GameAction::ZoomCamera { .. } => "Zoom camera", + GameAction::ToggleUI => "Toggle UI panel", + GameAction::Confirm => "Confirm", + GameAction::Cancel => "Cancel", + GameAction::Undo => "Undo", + GameAction::Redo => "Redo", + } + } +} diff --git a/crates/libmarathon/src/engine/input_controller.rs b/crates/libmarathon/src/engine/input_controller.rs new file mode 100644 index 0000000..956965d --- /dev/null +++ b/crates/libmarathon/src/engine/input_controller.rs @@ -0,0 +1,337 @@ +//! Input controller - maps raw InputEvents to semantic GameActions +//! +//! This layer provides: +//! - Input remapping (change key bindings) +//! - Accessibility (alternative input methods) +//! - Context-aware bindings (different actions in different modes) + +use super::game_actions::GameAction; +use super::input_events::{InputEvent, KeyCode, MouseButton, TouchPhase}; +use glam::Vec2; +use std::collections::HashMap; + +/// Input binding - maps an input trigger to a game action +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum InputBinding { + /// Mouse button press/release + MouseButton(MouseButton), + + /// Mouse drag with a specific button + MouseDrag(MouseButton), + + /// Mouse wheel scroll + MouseWheel, + + /// Keyboard key press + Key(KeyCode), + + /// Keyboard key with modifiers + KeyWithModifiers { + key: KeyCode, + shift: bool, + ctrl: bool, + alt: bool, + meta: bool, + }, + + /// Stylus input (Apple Pencil, etc.) + StylusDrag, + + /// Touch input + TouchDrag, +} + +/// Input context - different binding sets for different game modes +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InputContext { + /// Manipulating 3D entities + EntityManipulation, + + /// Camera control + CameraControl, + + /// UI interaction + UI, + + /// Text input + TextInput, +} + +/// Accessibility settings for input processing +#[derive(Debug, Clone)] +pub struct AccessibilitySettings { + /// Mouse sensitivity multiplier (1.0 = normal) + pub mouse_sensitivity: f32, + + /// Scroll sensitivity multiplier (1.0 = normal) + pub scroll_sensitivity: f32, + + /// Stylus pressure sensitivity (1.0 = normal) + pub stylus_sensitivity: f32, + + /// Enable one-handed mode (use keyboard for rotation) + pub one_handed_mode: bool, + + /// Invert Y axis for rotation + pub invert_y: bool, + + /// Minimum drag distance before registering as drag (in pixels) + pub drag_threshold: f32, +} + +impl Default for AccessibilitySettings { + fn default() -> Self { + Self { + mouse_sensitivity: 1.0, + scroll_sensitivity: 1.0, + stylus_sensitivity: 1.0, + one_handed_mode: false, + invert_y: false, + drag_threshold: 2.0, + } + } +} + +/// Input controller - converts InputEvents to GameActions +pub struct InputController { + /// Current input context + current_context: InputContext, + + /// Bindings for each context + bindings: HashMap>, + + /// Accessibility settings + accessibility: AccessibilitySettings, + + /// Drag state tracking + drag_state: DragState, +} + +#[derive(Default)] +struct DragState { + /// Is currently dragging + active: bool, + + /// Which button/input is dragging + source: Option, + + /// Start position + start_pos: Vec2, + + /// Last position + last_pos: Vec2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DragSource { + MouseLeft, + MouseRight, + Stylus, + Touch, +} + +impl InputController { + /// Create a new input controller with default bindings + pub fn new() -> Self { + let mut controller = Self { + current_context: InputContext::EntityManipulation, + bindings: HashMap::new(), + accessibility: AccessibilitySettings::default(), + drag_state: DragState::default(), + }; + + controller.setup_default_bindings(); + controller + } + + /// Set the current input context + pub fn set_context(&mut self, context: InputContext) { + self.current_context = context; + } + + /// Get the current context + pub fn context(&self) -> InputContext { + self.current_context + } + + /// Update accessibility settings + pub fn set_accessibility(&mut self, settings: AccessibilitySettings) { + self.accessibility = settings; + } + + /// Get current accessibility settings + pub fn accessibility(&self) -> &AccessibilitySettings { + &self.accessibility + } + + /// Process an input event and produce game actions + pub fn process_event(&mut self, event: &InputEvent) -> Vec { + let mut actions = Vec::new(); + + match event { + InputEvent::Mouse { pos, button, phase } => { + self.process_mouse(*pos, *button, *phase, &mut actions); + } + + InputEvent::MouseWheel { delta, pos: _ } => { + let adjusted_delta = delta.y * self.accessibility.scroll_sensitivity; + actions.push(GameAction::MoveEntityDepth { delta: adjusted_delta }); + } + + InputEvent::Keyboard { key, pressed, modifiers: _ } => { + if *pressed { + self.process_key(*key, &mut actions); + } + } + + InputEvent::Stylus { pos, pressure: _, tilt: _, phase, timestamp: _ } => { + self.process_stylus(*pos, *phase, &mut actions); + } + + InputEvent::Touch { pos, phase, id: _ } => { + self.process_touch(*pos, *phase, &mut actions); + } + } + + actions + } + + /// Process mouse input + fn process_mouse(&mut self, pos: Vec2, button: MouseButton, phase: TouchPhase, actions: &mut Vec) { + match phase { + TouchPhase::Started => { + // Single click = select + actions.push(GameAction::SelectEntity { position: pos }); + + // Start drag tracking + self.drag_state.active = true; + self.drag_state.source = Some(match button { + MouseButton::Left => DragSource::MouseLeft, + MouseButton::Right => DragSource::MouseRight, + MouseButton::Middle => return, // Don't handle middle button + }); + self.drag_state.start_pos = pos; + self.drag_state.last_pos = pos; + + actions.push(GameAction::BeginDrag { position: pos }); + } + + TouchPhase::Moved => { + if self.drag_state.active { + let delta = (pos - self.drag_state.last_pos) * self.accessibility.mouse_sensitivity; + self.drag_state.last_pos = pos; + + // Check if we've exceeded drag threshold + let total_delta = pos - self.drag_state.start_pos; + if total_delta.length() < self.accessibility.drag_threshold { + return; // Too small to count as drag + } + + actions.push(GameAction::ContinueDrag { position: pos, delta }); + + // Context-specific drag actions + match self.current_context { + InputContext::EntityManipulation => { + match self.drag_state.source { + Some(DragSource::MouseLeft) => { + actions.push(GameAction::MoveEntity { delta }); + } + Some(DragSource::MouseRight) => { + let adjusted_delta = if self.accessibility.invert_y { + Vec2::new(delta.x, -delta.y) + } else { + delta + }; + actions.push(GameAction::RotateEntity { delta: adjusted_delta }); + } + _ => {} + } + } + InputContext::CameraControl => { + actions.push(GameAction::MoveCamera { delta }); + } + _ => {} + } + } + } + + TouchPhase::Ended | TouchPhase::Cancelled => { + if self.drag_state.active { + actions.push(GameAction::EndDrag { position: pos }); + self.drag_state.active = false; + self.drag_state.source = None; + } + } + } + } + + /// Process keyboard input + fn process_key(&mut self, key: KeyCode, actions: &mut Vec) { + match key { + KeyCode::KeyR => actions.push(GameAction::ResetEntity), + KeyCode::Delete | KeyCode::Backspace => actions.push(GameAction::DeleteEntity), + KeyCode::KeyZ if self.accessibility.one_handed_mode => { + // In one-handed mode, Z key can trigger actions + actions.push(GameAction::Undo); + } + KeyCode::Escape => actions.push(GameAction::Cancel), + KeyCode::Enter => actions.push(GameAction::Confirm), + KeyCode::Tab => actions.push(GameAction::ToggleUI), + _ => {} + } + } + + /// Process stylus input (Apple Pencil, etc.) + fn process_stylus(&mut self, pos: Vec2, phase: TouchPhase, actions: &mut Vec) { + match phase { + TouchPhase::Started => { + actions.push(GameAction::SelectEntity { position: pos }); + actions.push(GameAction::BeginDrag { position: pos }); + self.drag_state.active = true; + self.drag_state.source = Some(DragSource::Stylus); + self.drag_state.start_pos = pos; + self.drag_state.last_pos = pos; + } + + TouchPhase::Moved => { + if self.drag_state.active { + let delta = (pos - self.drag_state.last_pos) * self.accessibility.stylus_sensitivity; + self.drag_state.last_pos = pos; + + actions.push(GameAction::ContinueDrag { position: pos, delta }); + actions.push(GameAction::MoveEntity { delta }); + } + } + + TouchPhase::Ended | TouchPhase::Cancelled => { + if self.drag_state.active { + actions.push(GameAction::EndDrag { position: pos }); + self.drag_state.active = false; + self.drag_state.source = None; + } + } + } + } + + /// Process touch input + fn process_touch(&mut self, pos: Vec2, phase: TouchPhase, actions: &mut Vec) { + // For now, treat touch like stylus + self.process_stylus(pos, phase, actions); + } + + /// Set up default input bindings + fn setup_default_bindings(&mut self) { + // For now, bindings are hardcoded in process_event + // Later, we can make this fully data-driven + } +} + +impl Default for InputController { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +#[path = "input_controller_tests.rs"] +mod tests; diff --git a/crates/libmarathon/src/engine/input_controller_tests.rs b/crates/libmarathon/src/engine/input_controller_tests.rs new file mode 100644 index 0000000..6e043b0 --- /dev/null +++ b/crates/libmarathon/src/engine/input_controller_tests.rs @@ -0,0 +1,326 @@ +//! Unit tests for InputController + +use super::{AccessibilitySettings, InputContext, InputController}; +use crate::engine::game_actions::GameAction; +use crate::engine::input_events::{InputEvent, KeyCode, MouseButton, TouchPhase}; +use glam::Vec2; + +#[test] +fn test_mouse_left_drag_produces_move_entity() { + let mut controller = InputController::new(); + + // Mouse down at (100, 100) + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Started, + }); + + // Should select entity and begin drag + assert!(actions.iter().any(|a| matches!(a, GameAction::SelectEntity { .. }))); + assert!(actions.iter().any(|a| matches!(a, GameAction::BeginDrag { .. }))); + + // Mouse drag to (150, 120) + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(150.0, 120.0), + button: MouseButton::Left, + phase: TouchPhase::Moved, + }); + + // Should produce MoveEntity with delta + let move_action = actions.iter().find_map(|a| { + if let GameAction::MoveEntity { delta } = a { + Some(delta) + } else { + None + } + }); + + assert!(move_action.is_some()); + let delta = move_action.unwrap(); + assert_eq!(*delta, Vec2::new(50.0, 20.0)); +} + +#[test] +fn test_mouse_right_drag_produces_rotate_entity() { + let mut controller = InputController::new(); + + // Right mouse down + controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 100.0), + button: MouseButton::Right, + phase: TouchPhase::Started, + }); + + // Right mouse drag + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(120.0, 130.0), + button: MouseButton::Right, + phase: TouchPhase::Moved, + }); + + // Should produce RotateEntity + assert!(actions.iter().any(|a| matches!(a, GameAction::RotateEntity { .. }))); +} + +#[test] +fn test_mouse_wheel_produces_depth_movement() { + let mut controller = InputController::new(); + + let actions = controller.process_event(&InputEvent::MouseWheel { + delta: Vec2::new(0.0, 10.0), + pos: Vec2::new(100.0, 100.0), + }); + + // Should produce MoveEntityDepth + let depth_action = actions.iter().find_map(|a| { + if let GameAction::MoveEntityDepth { delta } = a { + Some(*delta) + } else { + None + } + }); + + assert_eq!(depth_action, Some(10.0)); +} + +#[test] +fn test_keyboard_r_resets_entity() { + let mut controller = InputController::new(); + + let actions = controller.process_event(&InputEvent::Keyboard { + key: KeyCode::KeyR, + pressed: true, + modifiers: Default::default(), + }); + + assert!(actions.contains(&GameAction::ResetEntity)); +} + +#[test] +fn test_keyboard_delete_removes_entity() { + let mut controller = InputController::new(); + + let actions = controller.process_event(&InputEvent::Keyboard { + key: KeyCode::Delete, + pressed: true, + modifiers: Default::default(), + }); + + assert!(actions.contains(&GameAction::DeleteEntity)); +} + +#[test] +fn test_drag_threshold_prevents_tiny_movements() { + let mut controller = InputController::new(); + controller.set_accessibility(AccessibilitySettings { + drag_threshold: 10.0, + ..Default::default() + }); + + // Start drag + controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Started, + }); + + // Move only 2 pixels (below threshold of 10) + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(102.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Moved, + }); + + // Should NOT produce MoveEntity (below threshold) + assert!(!actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. }))); + + // Move 15 pixels total (above threshold) + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(115.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Moved, + }); + + // NOW should produce MoveEntity + assert!(actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. }))); +} + +#[test] +fn test_mouse_sensitivity_multiplier() { + let mut controller = InputController::new(); + controller.set_accessibility(AccessibilitySettings { + mouse_sensitivity: 2.0, + ..Default::default() + }); + + // Start drag + controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Started, + }); + + // Move 10 pixels + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(110.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Moved, + }); + + // Delta should be doubled (10 * 2.0 = 20) + let delta = actions.iter().find_map(|a| { + if let GameAction::MoveEntity { delta } = a { + Some(*delta) + } else { + None + } + }); + + assert_eq!(delta, Some(Vec2::new(20.0, 0.0))); +} + +#[test] +fn test_invert_y_axis() { + let mut controller = InputController::new(); + controller.set_accessibility(AccessibilitySettings { + invert_y: true, + ..Default::default() + }); + + // Start right-click drag + controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 100.0), + button: MouseButton::Right, + phase: TouchPhase::Started, + }); + + // Drag down (positive Y) + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 110.0), + button: MouseButton::Right, + phase: TouchPhase::Moved, + }); + + // Y delta should be inverted + let delta = actions.iter().find_map(|a| { + if let GameAction::RotateEntity { delta } = a { + Some(*delta) + } else { + None + } + }); + + assert!(delta.is_some()); + assert!(delta.unwrap().y < 0.0); // Should be negative (inverted) +} + +#[test] +fn test_drag_sequence_produces_begin_continue_end() { + let mut controller = InputController::new(); + + // Started + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Started, + }); + assert!(actions.iter().any(|a| matches!(a, GameAction::BeginDrag { .. }))); + + // Moved + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(150.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Moved, + }); + assert!(actions.iter().any(|a| matches!(a, GameAction::ContinueDrag { .. }))); + + // Ended + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(150.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Ended, + }); + assert!(actions.iter().any(|a| matches!(a, GameAction::EndDrag { .. }))); +} + +#[test] +fn test_stylus_produces_move_entity() { + let mut controller = InputController::new(); + + // Stylus down + controller.process_event(&InputEvent::Stylus { + pos: Vec2::new(100.0, 100.0), + pressure: 0.5, + tilt: Vec2::ZERO, + phase: TouchPhase::Started, + timestamp: 0.0, + }); + + // Stylus drag + let actions = controller.process_event(&InputEvent::Stylus { + pos: Vec2::new(150.0, 120.0), + pressure: 0.8, + tilt: Vec2::ZERO, + phase: TouchPhase::Moved, + timestamp: 0.016, + }); + + // Should produce MoveEntity + assert!(actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. }))); +} + +#[test] +fn test_context_switching() { + let mut controller = InputController::new(); + + // Start in EntityManipulation context + assert_eq!(controller.context(), InputContext::EntityManipulation); + + // Switch to CameraControl + controller.set_context(InputContext::CameraControl); + assert_eq!(controller.context(), InputContext::CameraControl); + + // Start drag + controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(100.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Started, + }); + + // Drag in CameraControl context + let actions = controller.process_event(&InputEvent::Mouse { + pos: Vec2::new(150.0, 100.0), + button: MouseButton::Left, + phase: TouchPhase::Moved, + }); + + // Should produce MoveCamera instead of MoveEntity + assert!(actions.iter().any(|a| matches!(a, GameAction::MoveCamera { .. }))); + assert!(!actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. }))); +} + +#[test] +fn test_scroll_sensitivity() { + let mut controller = InputController::new(); + controller.set_accessibility(AccessibilitySettings { + scroll_sensitivity: 3.0, + ..Default::default() + }); + + let actions = controller.process_event(&InputEvent::MouseWheel { + delta: Vec2::new(0.0, 5.0), + pos: Vec2::ZERO, + }); + + // Delta should be tripled (5.0 * 3.0 = 15.0) + let depth_delta = actions.iter().find_map(|a| { + if let GameAction::MoveEntityDepth { delta } = a { + Some(*delta) + } else { + None + } + }); + + assert_eq!(depth_delta, Some(15.0)); +} diff --git a/crates/libmarathon/src/engine/input_events.rs b/crates/libmarathon/src/engine/input_events.rs new file mode 100644 index 0000000..f755a77 --- /dev/null +++ b/crates/libmarathon/src/engine/input_events.rs @@ -0,0 +1,133 @@ +//! Abstract input event types for the engine +//! +//! These types are platform-agnostic and represent all forms of input +//! (stylus, mouse, touch) in a unified way. Platform-specific code +//! (iOS pencil bridge, desktop mouse) converts to these types. + +use glam::Vec2; + +/// Phase of a touch/stylus/mouse input +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TouchPhase { + /// Input just started + Started, + /// Input moved + Moved, + /// Input ended normally + Ended, + /// Input was cancelled (e.g., system gesture) + Cancelled, +} + +/// Mouse button types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MouseButton { + Left, + Right, + Middle, +} + +/// Keyboard key (using winit's KeyCode for now - can abstract later) +pub use winit::keyboard::KeyCode; + +/// Keyboard modifiers +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Modifiers { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + pub meta: bool, // Command on macOS, Windows key on Windows +} + +/// Abstract input event that the engine processes +/// +/// Platform-specific code converts native input (UITouch, winit events) +/// into these engine-agnostic events. +#[derive(Debug, Clone, Copy)] +pub enum InputEvent { + /// Stylus input (Apple Pencil, Surface Pen, etc.) + Stylus { + /// Screen position in pixels + pos: Vec2, + /// Pressure (0.0 = no pressure, 1.0+ = max pressure) + /// Note: Apple Pencil reports 0.0-4.0 range + pressure: f32, + /// Tilt vector: + /// - x: altitude angle (0 = flat on screen, π/2 = perpendicular) + /// - y: azimuth angle (rotation around vertical axis) + tilt: Vec2, + /// Touch phase + phase: TouchPhase, + /// Platform timestamp (for input prediction) + timestamp: f64, + }, + + /// Mouse input (desktop) + Mouse { + /// Screen position in pixels + pos: Vec2, + /// Which button + button: MouseButton, + /// Touch phase + phase: TouchPhase, + }, + + /// Touch input (fingers on touchscreen) + Touch { + /// Screen position in pixels + pos: Vec2, + /// Touch phase + phase: TouchPhase, + /// Touch ID (for multi-touch tracking) + id: u64, + }, + + /// Keyboard input + Keyboard { + /// Physical key code + key: KeyCode, + /// Whether the key was pressed or released + pressed: bool, + /// Modifier keys held during the event + modifiers: Modifiers, + }, + + /// Mouse wheel scroll + MouseWheel { + /// Scroll delta (pixels or lines depending on device) + delta: Vec2, + /// Current mouse position + pos: Vec2, + }, +} + +impl InputEvent { + /// Get the position for positional input types + pub fn position(&self) -> Option { + match self { + InputEvent::Stylus { pos, .. } => Some(*pos), + InputEvent::Mouse { pos, .. } => Some(*pos), + InputEvent::Touch { pos, .. } => Some(*pos), + InputEvent::MouseWheel { pos, .. } => Some(*pos), + InputEvent::Keyboard { .. } => None, + } + } + + /// Get the phase for input types that have phases + pub fn phase(&self) -> Option { + match self { + InputEvent::Stylus { phase, .. } => Some(*phase), + InputEvent::Mouse { phase, .. } => Some(*phase), + InputEvent::Touch { phase, .. } => Some(*phase), + InputEvent::Keyboard { .. } | InputEvent::MouseWheel { .. } => None, + } + } + + /// Check if this is an active input (not ended/cancelled) + pub fn is_active(&self) -> bool { + match self.phase() { + Some(phase) => !matches!(phase, TouchPhase::Ended | TouchPhase::Cancelled), + None => true, // Keyboard and wheel events are considered instantaneous + } + } +} diff --git a/crates/libmarathon/src/engine/mod.rs b/crates/libmarathon/src/engine/mod.rs new file mode 100644 index 0000000..90a0d5a --- /dev/null +++ b/crates/libmarathon/src/engine/mod.rs @@ -0,0 +1,21 @@ +//! Core Engine module - networking and persistence outside Bevy + +mod bridge; +mod commands; +mod core; +mod events; +mod game_actions; +mod input_controller; +mod input_events; +mod networking; +mod persistence; + +pub use bridge::{EngineBridge, EngineHandle}; +pub use commands::EngineCommand; +pub use core::EngineCore; +pub use events::EngineEvent; +pub use game_actions::GameAction; +pub use input_controller::{AccessibilitySettings, InputContext, InputController}; +pub use input_events::{InputEvent, KeyCode, Modifiers, MouseButton, TouchPhase}; +pub use networking::NetworkingManager; +pub use persistence::PersistenceManager; diff --git a/crates/libmarathon/src/engine/networking.rs b/crates/libmarathon/src/engine/networking.rs new file mode 100644 index 0000000..2991d5a --- /dev/null +++ b/crates/libmarathon/src/engine/networking.rs @@ -0,0 +1,243 @@ +//! Networking Manager - handles iroh networking and CRDT state outside Bevy + +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time; +use bytes::Bytes; +use futures_lite::StreamExt; + +use crate::networking::{ + EntityLockRegistry, NodeId, OperationLog, SessionId, TombstoneRegistry, VectorClock, + VersionedMessage, SyncMessage, LockMessage, +}; + +use super::EngineEvent; + +pub struct NetworkingManager { + session_id: SessionId, + node_id: NodeId, + + // Iroh networking + sender: iroh_gossip::api::GossipSender, + receiver: iroh_gossip::api::GossipReceiver, + + // Keep these alive for the lifetime of the manager + _endpoint: iroh::Endpoint, + _router: iroh::protocol::Router, + _gossip: iroh_gossip::net::Gossip, + + // CRDT state + vector_clock: VectorClock, + operation_log: OperationLog, + tombstones: TombstoneRegistry, + locks: EntityLockRegistry, + + // Track locks we own for heartbeat broadcasting + our_locks: std::collections::HashSet, +} + +impl NetworkingManager { + pub async fn new(session_id: SessionId) -> anyhow::Result { + use iroh::{ + discovery::mdns::MdnsDiscovery, + protocol::Router, + Endpoint, + }; + use iroh_gossip::{ + net::Gossip, + proto::TopicId, + }; + + // Create iroh endpoint with mDNS discovery + let endpoint = Endpoint::builder() + .discovery(MdnsDiscovery::builder()) + .bind() + .await?; + + let endpoint_id = endpoint.addr().id; + + // Convert endpoint ID to NodeId (using first 16 bytes) + let id_bytes = endpoint_id.as_bytes(); + let mut node_id_bytes = [0u8; 16]; + node_id_bytes.copy_from_slice(&id_bytes[..16]); + let node_id = NodeId::from_bytes(node_id_bytes); + + // Create gossip protocol + let gossip = Gossip::builder().spawn(endpoint.clone()); + + // Derive session-specific ALPN for network isolation + let session_alpn = session_id.to_alpn(); + + // Set up router to accept session ALPN + let router = Router::builder(endpoint.clone()) + .accept(session_alpn.as_slice(), gossip.clone()) + .spawn(); + + // Subscribe to topic derived from session ALPN + let topic_id = TopicId::from_bytes(session_alpn); + let subscribe_handle = gossip.subscribe(topic_id, vec![]).await?; + + let (sender, receiver) = subscribe_handle.split(); + + tracing::info!( + "NetworkingManager started for session {} with node {}", + session_id.to_code(), + node_id + ); + + let manager = Self { + session_id, + node_id, + sender, + receiver, + _endpoint: endpoint, + _router: router, + _gossip: gossip, + vector_clock: VectorClock::new(), + operation_log: OperationLog::new(), + tombstones: TombstoneRegistry::new(), + locks: EntityLockRegistry::new(), + our_locks: std::collections::HashSet::new(), + }; + + Ok(manager) + } + + pub fn node_id(&self) -> NodeId { + self.node_id + } + + pub fn session_id(&self) -> SessionId { + self.session_id.clone() + } + + /// Process gossip events (unbounded) and periodic tasks (heartbeats, lock cleanup) + pub async fn run(mut self, event_tx: mpsc::UnboundedSender) { + let mut heartbeat_interval = time::interval(Duration::from_secs(1)); + + loop { + tokio::select! { + // Process gossip events unbounded (as fast as they arrive) + Some(result) = self.receiver.next() => { + match result { + Ok(event) => { + use iroh_gossip::api::Event; + if let Event::Received(msg) = event { + self.handle_sync_message(&msg.content, &event_tx).await; + } + // Note: Neighbor events are not exposed in the current API + } + Err(e) => { + tracing::warn!("Gossip receiver error: {}", e); + } + } + } + + // Periodic tasks: heartbeats and lock cleanup + _ = heartbeat_interval.tick() => { + self.broadcast_lock_heartbeats(&event_tx).await; + self.cleanup_expired_locks(&event_tx); + } + } + } + } + + + async fn handle_sync_message(&mut self, msg_bytes: &[u8], event_tx: &mpsc::UnboundedSender) { + // Deserialize SyncMessage + let versioned: VersionedMessage = match bincode::deserialize(msg_bytes) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to deserialize sync message: {}", e); + return; + } + }; + + match versioned.message { + SyncMessage::Lock(lock_msg) => { + self.handle_lock_message(lock_msg, event_tx); + } + _ => { + // TODO: Handle other message types (ComponentOp, EntitySpawn, etc.) + tracing::debug!("Unhandled sync message type"); + } + } + } + + fn handle_lock_message(&mut self, msg: LockMessage, event_tx: &mpsc::UnboundedSender) { + match msg { + LockMessage::LockRequest { entity_id, node_id } => { + match self.locks.try_acquire(entity_id, node_id) { + Ok(()) => { + // Track if this is our lock + if node_id == self.node_id { + self.our_locks.insert(entity_id); + } + + let _ = event_tx.send(EngineEvent::LockAcquired { + entity_id, + holder: node_id, + }); + } + Err(current_holder) => { + let _ = event_tx.send(EngineEvent::LockDenied { + entity_id, + current_holder, + }); + } + } + } + LockMessage::LockHeartbeat { entity_id, holder } => { + self.locks.renew_heartbeat(entity_id, holder); + } + LockMessage::LockRelease { entity_id, node_id } => { + self.locks.release(entity_id, node_id); + + // Remove from our locks tracking + if node_id == self.node_id { + self.our_locks.remove(&entity_id); + } + + let _ = event_tx.send(EngineEvent::LockReleased { entity_id }); + } + _ => {} + } + } + + async fn broadcast_lock_heartbeats(&mut self, _event_tx: &mpsc::UnboundedSender) { + // Broadcast heartbeats for locks we hold + for entity_id in self.our_locks.iter().copied() { + self.locks.renew_heartbeat(entity_id, self.node_id); + + let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockHeartbeat { + entity_id, + holder: self.node_id, + })); + + if let Ok(bytes) = bincode::serialize(&msg) { + let _ = self.sender.broadcast(Bytes::from(bytes)).await; + } + } + } + + fn cleanup_expired_locks(&mut self, event_tx: &mpsc::UnboundedSender) { + // Get expired locks from registry + let expired = self.locks.get_expired_locks(); + + for entity_id in expired { + // Only cleanup if it's not our lock + if let Some(holder) = self.locks.get_holder(entity_id, self.node_id) { + if holder != self.node_id { + self.locks.force_release(entity_id); + let _ = event_tx.send(EngineEvent::LockExpired { entity_id }); + tracing::info!("Lock expired for entity {}", entity_id); + } + } + } + } + + pub async fn shutdown(self) { + tracing::info!("NetworkingManager shut down"); + // endpoint and gossip will be dropped automatically + } +} diff --git a/crates/libmarathon/src/engine/persistence.rs b/crates/libmarathon/src/engine/persistence.rs new file mode 100644 index 0000000..cd5f756 --- /dev/null +++ b/crates/libmarathon/src/engine/persistence.rs @@ -0,0 +1,79 @@ +//! Persistence Manager - handles SQLite storage outside Bevy + +use rusqlite::{Connection, OptionalExtension}; +use std::sync::{Arc, Mutex}; + +use crate::networking::{Session, SessionId}; + +pub struct PersistenceManager { + conn: Arc>, +} + +impl PersistenceManager { + pub fn new(db_path: &str) -> Self { + let conn = Connection::open(db_path).expect("Failed to open database"); + + // Initialize schema (Phase 1 stub - will load from file in Phase 4) + let schema = " + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL + ); + "; + + if let Err(e) = conn.execute_batch(schema) { + tracing::warn!("Failed to initialize schema: {}", e); + } + + Self { + conn: Arc::new(Mutex::new(conn)), + } + } + + pub fn save_session(&self, session: &Session) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + + conn.execute( + "INSERT OR REPLACE INTO sessions (id, state, created_at, last_active_at) + VALUES (?1, ?2, ?3, ?4)", + ( + session.id.to_code(), + format!("{:?}", session.state), + session.created_at, + session.last_active, + ), + )?; + + Ok(()) + } + + pub fn load_last_active_session(&self) -> anyhow::Result> { + let conn = self.conn.lock().unwrap(); + + // Query for the most recently active session + let mut stmt = conn.prepare( + "SELECT id, state, created_at, last_active_at + FROM sessions + ORDER BY last_active_at DESC + LIMIT 1" + )?; + + let session = stmt.query_row([], |row| { + let id_code: String = row.get(0)?; + let _state: String = row.get(1)?; + let _created_at: String = row.get(2)?; + let _last_active_at: String = row.get(3)?; + + // Parse session ID from code + if let Ok(session_id) = SessionId::from_code(&id_code) { + Ok(Some(Session::new(session_id))) + } else { + Ok(None) + } + }).optional()?; + + Ok(session.flatten()) + } +} diff --git a/crates/lib/src/error.rs b/crates/libmarathon/src/error.rs similarity index 100% rename from crates/lib/src/error.rs rename to crates/libmarathon/src/error.rs diff --git a/crates/lib/src/lib.rs b/crates/libmarathon/src/lib.rs similarity index 87% rename from crates/lib/src/lib.rs rename to crates/libmarathon/src/lib.rs index 2837811..92719af 100644 --- a/crates/lib/src/lib.rs +++ b/crates/libmarathon/src/lib.rs @@ -11,21 +11,23 @@ //! # Example //! //! ```no_run -//! use lib::ChatDb; +//! use libmarathon::ChatDb; //! //! let db = ChatDb::open("chat.db")?; //! //! // Get all messages from January 2024 to now //! let messages = db.get_our_messages(None, None)?; //! println!("Found {} messages", messages.len()); -//! # Ok::<(), lib::ChatDbError>(()) +//! # Ok::<(), libmarathon::ChatDbError>(()) //! ``` mod db; mod error; mod models; +pub mod engine; pub mod networking; pub mod persistence; +pub mod platform; pub mod sync; pub use db::ChatDb; diff --git a/crates/lib/src/models.rs b/crates/libmarathon/src/models.rs similarity index 100% rename from crates/lib/src/models.rs rename to crates/libmarathon/src/models.rs diff --git a/crates/lib/src/networking/apply_ops.rs b/crates/libmarathon/src/networking/apply_ops.rs similarity index 99% rename from crates/lib/src/networking/apply_ops.rs rename to crates/libmarathon/src/networking/apply_ops.rs index ffda6d6..344dbfa 100644 --- a/crates/lib/src/networking/apply_ops.rs +++ b/crates/libmarathon/src/networking/apply_ops.rs @@ -446,7 +446,7 @@ fn apply_set_operation( /// /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::receive_and_apply_deltas_system; +/// use libmarathon::networking::receive_and_apply_deltas_system; /// /// App::new().add_systems(Update, receive_and_apply_deltas_system); /// ``` diff --git a/crates/lib/src/networking/auth.rs b/crates/libmarathon/src/networking/auth.rs similarity index 98% rename from crates/lib/src/networking/auth.rs rename to crates/libmarathon/src/networking/auth.rs index 842fc73..b69b9db 100644 --- a/crates/lib/src/networking/auth.rs +++ b/crates/libmarathon/src/networking/auth.rs @@ -26,7 +26,7 @@ use crate::networking::error::{ /// /// # Examples /// ``` -/// use lib::networking::auth::validate_session_secret; +/// use libmarathon::networking::auth::validate_session_secret; /// /// let secret = b"my_secret_key"; /// assert!(validate_session_secret(secret, secret).is_ok()); diff --git a/crates/lib/src/networking/blob_support.rs b/crates/libmarathon/src/networking/blob_support.rs similarity index 98% rename from crates/lib/src/networking/blob_support.rs rename to crates/libmarathon/src/networking/blob_support.rs index fc0e196..a4d9eb0 100644 --- a/crates/lib/src/networking/blob_support.rs +++ b/crates/libmarathon/src/networking/blob_support.rs @@ -56,7 +56,7 @@ impl BlobStore { /// # Example /// /// ``` - /// use lib::networking::BlobStore; + /// use libmarathon::networking::BlobStore; /// /// let store = BlobStore::new(); /// let data = vec![1, 2, 3, 4, 5]; @@ -157,7 +157,7 @@ impl Default for BlobStore { /// # Example /// /// ``` -/// use lib::networking::should_use_blob; +/// use libmarathon::networking::should_use_blob; /// /// let small_data = vec![1, 2, 3]; /// assert!(!should_use_blob(&small_data)); @@ -177,7 +177,7 @@ pub fn should_use_blob(data: &[u8]) -> bool { /// # Example /// /// ``` -/// use lib::networking::{ +/// use libmarathon::networking::{ /// BlobStore, /// create_component_data, /// }; @@ -209,7 +209,7 @@ pub fn create_component_data(data: Vec, blob_store: &BlobStore) -> Result bool { - self.locks.get(&entity_id).map_or(false, |lock| !lock.is_expired()) + /// Check if an entity is locked by any node + /// + /// Takes the local node ID to properly handle expiration: + /// - Our own locks are never considered expired (held exactly as long as selected) + /// - Remote locks are subject to the 5-second timeout + pub fn is_locked(&self, entity_id: Uuid, local_node_id: NodeId) -> bool { + self.locks.get(&entity_id).map_or(false, |lock| { + // Our own locks never expire + lock.holder == local_node_id || !lock.is_expired() + }) } /// Check if an entity is locked by a specific node - pub fn is_locked_by(&self, entity_id: Uuid, node_id: NodeId) -> bool { - self.locks - .get(&entity_id) - .map_or(false, |lock| !lock.is_expired() && lock.holder == node_id) + /// + /// Takes the local node ID to properly handle expiration: + /// - If checking our own lock, ignore expiration (held exactly as long as selected) + /// - If checking another node's lock, apply 5-second timeout + pub fn is_locked_by(&self, entity_id: Uuid, node_id: NodeId, local_node_id: NodeId) -> bool { + self.locks.get(&entity_id).map_or(false, |lock| { + if lock.holder != node_id { + // Not held by the queried node + false + } else if lock.holder == local_node_id { + // Checking our own lock - never expires + true + } else { + // Checking remote lock - check expiration + !lock.is_expired() + } + }) } - /// Get the holder of a lock (if locked) - pub fn get_holder(&self, entity_id: Uuid) -> Option { + /// Get the holder of a lock (if locked and not expired) + /// + /// Takes the local node ID to properly handle expiration: + /// - Our own locks are never considered expired + /// - Remote locks are subject to the 5-second timeout + pub fn get_holder(&self, entity_id: Uuid, local_node_id: NodeId) -> Option { self.locks.get(&entity_id).and_then(|lock| { - if !lock.is_expired() { + // Our own locks never expire + if lock.holder == local_node_id || !lock.is_expired() { Some(lock.holder) } else { None @@ -320,7 +345,7 @@ impl EntityLockRegistry { /// Add to your app as an Update system: /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::release_locks_on_deselection_system; +/// use libmarathon::networking::release_locks_on_deselection_system; /// /// App::new().add_systems(Update, release_locks_on_deselection_system); /// ``` @@ -373,27 +398,44 @@ pub fn release_locks_on_deselection_system( /// System to clean up expired locks (crash recovery) /// /// This system periodically removes locks that have exceeded their timeout -/// duration (default 5 seconds). This provides crash recovery - if a node -/// crashes while holding a lock, it will eventually expire. +/// duration (default 5 seconds). This provides crash recovery - if a **remote** +/// node crashes while holding a lock, it will eventually expire. +/// +/// **Important**: Only remote locks are cleaned up. Local locks (held by this node) +/// are never timed out - they're held exactly as long as entities are selected, +/// and only released via deselection. /// /// Add to your app as an Update system: /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::cleanup_expired_locks_system; +/// use libmarathon::networking::cleanup_expired_locks_system; /// /// App::new().add_systems(Update, cleanup_expired_locks_system); /// ``` pub fn cleanup_expired_locks_system( mut registry: ResMut, + node_clock: Res, bridge: Option>, ) { - let expired = registry.get_expired_locks(); + let node_id = node_clock.node_id; + + // Only clean up REMOTE locks (locks held by other nodes) + // Our own locks are managed by release_locks_on_deselection_system + let expired: Vec = registry + .locks + .iter() + .filter(|(_, lock)| { + // Only expire locks held by OTHER nodes + lock.is_expired() && lock.holder != node_id + }) + .map(|(entity_id, _)| *entity_id) + .collect(); if !expired.is_empty() { - info!("Cleaning up {} expired locks", expired.len()); + info!("Cleaning up {} expired remote locks", expired.len()); for entity_id in expired { - debug!("Force-releasing expired lock on entity {}", entity_id); + debug!("Force-releasing expired remote lock on entity {}", entity_id); registry.force_release(entity_id); // Broadcast LockReleased @@ -404,7 +446,7 @@ pub fn cleanup_expired_locks_system( if let Err(e) = bridge.send(msg) { error!("Failed to broadcast LockReleased for expired lock: {}", e); } else { - info!("Expired lock cleaned up: entity {}", entity_id); + info!("Expired remote lock cleaned up: entity {}", entity_id); } } } @@ -422,14 +464,14 @@ pub fn cleanup_expired_locks_system( /// use bevy::prelude::*; /// use bevy::time::common_conditions::on_timer; /// use std::time::Duration; -/// use lib::networking::broadcast_lock_heartbeats_system; +/// use libmarathon::networking::broadcast_lock_heartbeats_system; /// /// App::new().add_systems(Update, /// broadcast_lock_heartbeats_system.run_if(on_timer(Duration::from_secs(1))) /// ); /// ``` pub fn broadcast_lock_heartbeats_system( - registry: Res, + mut registry: ResMut, node_clock: Res, bridge: Option>, ) { @@ -449,7 +491,13 @@ pub fn broadcast_lock_heartbeats_system( debug!("Broadcasting {} lock heartbeats", our_locks.len()); - // Broadcast heartbeat for each lock + // Renew local locks and broadcast heartbeat for each lock + for entity_id in &our_locks { + // Renew the lock locally first (don't rely on network loopback) + registry.renew_heartbeat(*entity_id, node_id); + } + + // Broadcast heartbeat messages to peers if let Some(ref bridge) = bridge { for entity_id in our_locks { let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockHeartbeat { @@ -481,9 +529,9 @@ mod tests { // Should acquire successfully assert!(registry.try_acquire(entity_id, node_id).is_ok()); - assert!(registry.is_locked(entity_id)); - assert!(registry.is_locked_by(entity_id, node_id)); - assert_eq!(registry.get_holder(entity_id), Some(node_id)); + assert!(registry.is_locked(entity_id, node_id)); + assert!(registry.is_locked_by(entity_id, node_id, node_id)); + assert_eq!(registry.get_holder(entity_id, node_id), Some(node_id)); } #[test] @@ -509,7 +557,7 @@ mod tests { // Acquire and release registry.try_acquire(entity_id, node_id).unwrap(); assert!(registry.release(entity_id, node_id)); - assert!(!registry.is_locked(entity_id)); + assert!(!registry.is_locked(entity_id, node_id)); } #[test] @@ -524,8 +572,8 @@ mod tests { // Node 2 cannot release assert!(!registry.release(entity_id, node2)); - assert!(registry.is_locked(entity_id)); - assert!(registry.is_locked_by(entity_id, node1)); + assert!(registry.is_locked(entity_id, node2)); + assert!(registry.is_locked_by(entity_id, node1, node2)); } #[test] @@ -556,7 +604,7 @@ mod tests { registry.try_acquire(entity_id, node_id).unwrap(); registry.force_release(entity_id); - assert!(!registry.is_locked(entity_id)); + assert!(!registry.is_locked(entity_id, node_id)); } #[test] diff --git a/crates/lib/src/networking/merge.rs b/crates/libmarathon/src/networking/merge.rs similarity index 99% rename from crates/lib/src/networking/merge.rs rename to crates/libmarathon/src/networking/merge.rs index 69aebd6..1e42f23 100644 --- a/crates/lib/src/networking/merge.rs +++ b/crates/libmarathon/src/networking/merge.rs @@ -45,7 +45,7 @@ pub enum MergeDecision { /// # Example /// /// ``` -/// use lib::networking::{ +/// use libmarathon::networking::{ /// VectorClock, /// compare_operations_lww, /// }; diff --git a/crates/lib/src/networking/message_dispatcher.rs b/crates/libmarathon/src/networking/message_dispatcher.rs similarity index 99% rename from crates/lib/src/networking/message_dispatcher.rs rename to crates/libmarathon/src/networking/message_dispatcher.rs index 0001276..3a60de5 100644 --- a/crates/lib/src/networking/message_dispatcher.rs +++ b/crates/libmarathon/src/networking/message_dispatcher.rs @@ -48,7 +48,7 @@ use crate::networking::{ /// /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::message_dispatcher_system; +/// use libmarathon::networking::message_dispatcher_system; /// /// App::new().add_systems(Update, message_dispatcher_system); /// ``` diff --git a/crates/lib/src/networking/messages.rs b/crates/libmarathon/src/networking/messages.rs similarity index 100% rename from crates/lib/src/networking/messages.rs rename to crates/libmarathon/src/networking/messages.rs diff --git a/crates/lib/src/networking/mod.rs b/crates/libmarathon/src/networking/mod.rs similarity index 97% rename from crates/lib/src/networking/mod.rs rename to crates/libmarathon/src/networking/mod.rs index 31f2489..6711e76 100644 --- a/crates/lib/src/networking/mod.rs +++ b/crates/libmarathon/src/networking/mod.rs @@ -13,7 +13,7 @@ //! # Example //! //! ``` -//! use lib::networking::*; +//! use libmarathon::networking::*; //! use uuid::Uuid; //! //! // Create a vector clock and track operations @@ -101,7 +101,7 @@ pub use vector_clock::*; /// # Example /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::spawn_networked_entity; +/// use libmarathon::networking::spawn_networked_entity; /// use uuid::Uuid; /// /// fn my_system(world: &mut World) { diff --git a/crates/lib/src/networking/operation_builder.rs b/crates/libmarathon/src/networking/operation_builder.rs similarity index 99% rename from crates/lib/src/networking/operation_builder.rs rename to crates/libmarathon/src/networking/operation_builder.rs index a3cd030..71e4842 100644 --- a/crates/lib/src/networking/operation_builder.rs +++ b/crates/libmarathon/src/networking/operation_builder.rs @@ -168,7 +168,7 @@ pub fn build_entity_operations( /// /// ``` /// use bevy::prelude::*; -/// use lib::networking::{ +/// use libmarathon::networking::{ /// VectorClock, /// build_transform_operation, /// }; diff --git a/crates/lib/src/networking/operation_log.rs b/crates/libmarathon/src/networking/operation_log.rs similarity index 99% rename from crates/lib/src/networking/operation_log.rs rename to crates/libmarathon/src/networking/operation_log.rs index aa5bf41..4338f33 100644 --- a/crates/lib/src/networking/operation_log.rs +++ b/crates/libmarathon/src/networking/operation_log.rs @@ -89,7 +89,7 @@ impl OperationLog { /// # Example /// /// ``` - /// use lib::networking::{ + /// use libmarathon::networking::{ /// EntityDelta, /// OperationLog, /// VectorClock, @@ -230,7 +230,7 @@ impl Default for OperationLog { /// # Example /// /// ``` -/// use lib::networking::{ +/// use libmarathon::networking::{ /// VectorClock, /// build_sync_request, /// }; @@ -263,7 +263,7 @@ pub fn build_missing_deltas(deltas: Vec) -> VersionedMessage { /// /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::handle_sync_requests_system; +/// use libmarathon::networking::handle_sync_requests_system; /// /// App::new().add_systems(Update, handle_sync_requests_system); /// ``` diff --git a/crates/lib/src/networking/operations.rs b/crates/libmarathon/src/networking/operations.rs similarity index 100% rename from crates/lib/src/networking/operations.rs rename to crates/libmarathon/src/networking/operations.rs diff --git a/crates/lib/src/networking/orset.rs b/crates/libmarathon/src/networking/orset.rs similarity index 98% rename from crates/lib/src/networking/orset.rs rename to crates/libmarathon/src/networking/orset.rs index e0b2e71..ae19e48 100644 --- a/crates/lib/src/networking/orset.rs +++ b/crates/libmarathon/src/networking/orset.rs @@ -15,7 +15,7 @@ //! ## Example //! //! ``` -//! use lib::networking::{ +//! use libmarathon::networking::{ //! OrElement, //! OrSet, //! }; @@ -116,7 +116,7 @@ where /// # Example /// /// ``` - /// use lib::networking::OrSet; + /// use libmarathon::networking::OrSet; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -143,7 +143,7 @@ where /// # Example /// /// ``` - /// use lib::networking::OrSet; + /// use libmarathon::networking::OrSet; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -190,7 +190,7 @@ where /// # Example /// /// ``` - /// use lib::networking::OrSet; + /// use libmarathon::networking::OrSet; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -234,7 +234,7 @@ where /// # Example /// /// ``` - /// use lib::networking::OrSet; + /// use libmarathon::networking::OrSet; /// use uuid::Uuid; /// /// let node1 = Uuid::new_v4(); diff --git a/crates/lib/src/networking/plugin.rs b/crates/libmarathon/src/networking/plugin.rs similarity index 96% rename from crates/lib/src/networking/plugin.rs rename to crates/libmarathon/src/networking/plugin.rs index 9e80d80..6a4a742 100644 --- a/crates/lib/src/networking/plugin.rs +++ b/crates/libmarathon/src/networking/plugin.rs @@ -8,7 +8,7 @@ //! //! ```no_run //! use bevy::prelude::*; -//! use lib::networking::{ +//! use libmarathon::networking::{ //! NetworkingConfig, //! NetworkingPlugin, //! }; @@ -116,7 +116,7 @@ impl Default for NetworkingConfig { /// /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::{ +/// use libmarathon::networking::{ /// NetworkingPlugin, /// SessionSecret, /// }; @@ -192,7 +192,7 @@ impl SessionSecret { /// /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::{ +/// use libmarathon::networking::{ /// NetworkingConfig, /// NetworkingPlugin, /// }; @@ -293,8 +293,13 @@ impl Plugin for NetworkingPlugin { )), ); - // Last schedule - save session state on shutdown - app.add_systems(Last, save_session_on_shutdown_system); + // Auto-save session state every 5 seconds + app.add_systems( + Last, + save_session_on_shutdown_system.run_if(bevy::time::common_conditions::on_timer( + std::time::Duration::from_secs(5), + )), + ); info!( "NetworkingPlugin initialized for node {}", @@ -315,7 +320,7 @@ impl Plugin for NetworkingPlugin { /// /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::NetworkingAppExt; +/// use libmarathon::networking::NetworkingAppExt; /// use uuid::Uuid; /// /// App::new() diff --git a/crates/lib/src/networking/rga.rs b/crates/libmarathon/src/networking/rga.rs similarity index 98% rename from crates/lib/src/networking/rga.rs rename to crates/libmarathon/src/networking/rga.rs index 2d2ac57..f7a2f71 100644 --- a/crates/lib/src/networking/rga.rs +++ b/crates/libmarathon/src/networking/rga.rs @@ -13,7 +13,7 @@ //! ## Example //! //! ``` -//! use lib::networking::Rga; +//! use libmarathon::networking::Rga; //! use uuid::Uuid; //! //! let node1 = Uuid::new_v4(); @@ -115,7 +115,7 @@ where /// # Example /// /// ``` - /// use lib::networking::Rga; + /// use libmarathon::networking::Rga; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -153,7 +153,7 @@ where /// # Example /// /// ``` - /// use lib::networking::Rga; + /// use libmarathon::networking::Rga; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -227,7 +227,7 @@ where /// # Example /// /// ``` - /// use lib::networking::Rga; + /// use libmarathon::networking::Rga; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -301,7 +301,7 @@ where /// # Example /// /// ``` - /// use lib::networking::Rga; + /// use libmarathon::networking::Rga; /// use uuid::Uuid; /// /// let node1 = Uuid::new_v4(); diff --git a/crates/lib/src/networking/session.rs b/crates/libmarathon/src/networking/session.rs similarity index 100% rename from crates/lib/src/networking/session.rs rename to crates/libmarathon/src/networking/session.rs diff --git a/crates/lib/src/networking/session_lifecycle.rs b/crates/libmarathon/src/networking/session_lifecycle.rs similarity index 92% rename from crates/lib/src/networking/session_lifecycle.rs rename to crates/libmarathon/src/networking/session_lifecycle.rs index eb7191a..8a97bb9 100644 --- a/crates/lib/src/networking/session_lifecycle.rs +++ b/crates/libmarathon/src/networking/session_lifecycle.rs @@ -47,7 +47,7 @@ use crate::{ /// Add to your app as a Startup system AFTER setup_persistence: /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::initialize_session_system; +/// use libmarathon::networking::initialize_session_system; /// /// App::new() /// .add_systems(Startup, initialize_session_system); @@ -136,21 +136,24 @@ pub fn initialize_session_system(world: &mut World) { world.insert_resource(current_session); } -/// System to save session state on shutdown +/// System to auto-save session state periodically /// -/// This system should run during app shutdown to persist session state -/// for auto-rejoin on next startup. +/// This system periodically saves session state to persist it for auto-rejoin +/// on next startup. Typically run every 5 seconds. /// -/// Add to your app using the Last schedule: +/// Add to your app using the Last schedule with a timer: /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::save_session_on_shutdown_system; +/// use bevy::time::common_conditions::on_timer; +/// use libmarathon::networking::save_session_on_shutdown_system; +/// use std::time::Duration; /// /// App::new() -/// .add_systems(Last, save_session_on_shutdown_system); +/// .add_systems(Last, save_session_on_shutdown_system +/// .run_if(on_timer(Duration::from_secs(5)))); /// ``` pub fn save_session_on_shutdown_system(world: &mut World) { - info!("Saving session state on shutdown..."); + debug!("Auto-saving session state..."); // Get current session let current_session = match world.get_resource::() { diff --git a/crates/lib/src/networking/sync_component.rs b/crates/libmarathon/src/networking/sync_component.rs similarity index 97% rename from crates/lib/src/networking/sync_component.rs rename to crates/libmarathon/src/networking/sync_component.rs index d07b256..bd1f0ce 100644 --- a/crates/lib/src/networking/sync_component.rs +++ b/crates/libmarathon/src/networking/sync_component.rs @@ -48,7 +48,7 @@ pub enum ComponentMergeDecision { /// # Example /// ``` /// use bevy::prelude::*; -/// use lib::networking::{ +/// use libmarathon::networking::{ /// ClockComparison, /// ComponentMergeDecision, /// SyncComponent, @@ -105,7 +105,7 @@ pub trait SyncComponent: Component + Reflect + Sized { /// # Example /// ``` /// use bevy::prelude::*; -/// use lib::networking::Synced; +/// use libmarathon::networking::Synced; /// use sync_macros::Synced as SyncedDerive; /// /// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize, SyncedDerive)] @@ -138,7 +138,7 @@ pub struct Synced; /// # Example /// ``` /// use bevy::prelude::*; -/// use lib::networking::DiagnoseSync; +/// use libmarathon::networking::DiagnoseSync; /// /// let mut world = World::new(); /// let entity = world.spawn_empty().id(); diff --git a/crates/lib/src/networking/tombstones.rs b/crates/libmarathon/src/networking/tombstones.rs similarity index 99% rename from crates/lib/src/networking/tombstones.rs rename to crates/libmarathon/src/networking/tombstones.rs index 1d4315b..16b6713 100644 --- a/crates/lib/src/networking/tombstones.rs +++ b/crates/libmarathon/src/networking/tombstones.rs @@ -203,7 +203,7 @@ impl TombstoneRegistry { /// /// ```no_run /// use bevy::prelude::*; -/// use lib::networking::ToDelete; +/// use libmarathon::networking::ToDelete; /// /// fn delete_entity_system(mut commands: Commands, entity: Entity) { /// commands.entity(entity).insert(ToDelete); diff --git a/crates/lib/src/networking/vector_clock.rs b/crates/libmarathon/src/networking/vector_clock.rs similarity index 98% rename from crates/lib/src/networking/vector_clock.rs rename to crates/libmarathon/src/networking/vector_clock.rs index 35ef0db..fd2a948 100644 --- a/crates/lib/src/networking/vector_clock.rs +++ b/crates/libmarathon/src/networking/vector_clock.rs @@ -35,7 +35,7 @@ pub type NodeId = uuid::Uuid; /// # Example /// /// ``` -/// use lib::networking::VectorClock; +/// use libmarathon::networking::VectorClock; /// use uuid::Uuid; /// /// let node1 = Uuid::new_v4(); @@ -76,7 +76,7 @@ impl VectorClock { /// # Example /// /// ``` - /// use lib::networking::VectorClock; + /// use libmarathon::networking::VectorClock; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -109,7 +109,7 @@ impl VectorClock { /// # Example /// /// ``` - /// use lib::networking::VectorClock; + /// use libmarathon::networking::VectorClock; /// use uuid::Uuid; /// /// let node1 = Uuid::new_v4(); @@ -141,7 +141,7 @@ impl VectorClock { /// # Example /// /// ``` - /// use lib::networking::VectorClock; + /// use libmarathon::networking::VectorClock; /// use uuid::Uuid; /// /// let node = Uuid::new_v4(); @@ -195,7 +195,7 @@ impl VectorClock { /// # Example /// /// ``` - /// use lib::networking::VectorClock; + /// use libmarathon::networking::VectorClock; /// use uuid::Uuid; /// /// let node1 = Uuid::new_v4(); diff --git a/crates/lib/src/persistence/config.rs b/crates/libmarathon/src/persistence/config.rs similarity index 99% rename from crates/lib/src/persistence/config.rs rename to crates/libmarathon/src/persistence/config.rs index ac44b87..4123180 100644 --- a/crates/lib/src/persistence/config.rs +++ b/crates/libmarathon/src/persistence/config.rs @@ -183,7 +183,7 @@ pub fn load_config_from_str(toml: &str) -> Result { /// /// # Examples /// ```no_run -/// # use lib::persistence::*; +/// # use libmarathon::persistence::*; /// # fn example() -> Result<()> { /// let config = load_config_from_file("persistence.toml")?; /// # Ok(()) diff --git a/crates/lib/src/persistence/database.rs b/crates/libmarathon/src/persistence/database.rs similarity index 99% rename from crates/lib/src/persistence/database.rs rename to crates/libmarathon/src/persistence/database.rs index d2b7683..c0aef8f 100644 --- a/crates/lib/src/persistence/database.rs +++ b/crates/libmarathon/src/persistence/database.rs @@ -280,7 +280,7 @@ pub fn flush_to_sqlite(ops: &[PersistenceOp], conn: &mut Connection) -> Result anyhow::Result<()> { /// let mut conn = Connection::open("app.db")?; /// let info = checkpoint_wal(&mut conn, CheckpointMode::Passive)?; diff --git a/crates/lib/src/persistence/error.rs b/crates/libmarathon/src/persistence/error.rs similarity index 100% rename from crates/lib/src/persistence/error.rs rename to crates/libmarathon/src/persistence/error.rs diff --git a/crates/lib/src/persistence/health.rs b/crates/libmarathon/src/persistence/health.rs similarity index 100% rename from crates/lib/src/persistence/health.rs rename to crates/libmarathon/src/persistence/health.rs diff --git a/crates/lib/src/persistence/lifecycle.rs b/crates/libmarathon/src/persistence/lifecycle.rs similarity index 100% rename from crates/lib/src/persistence/lifecycle.rs rename to crates/libmarathon/src/persistence/lifecycle.rs diff --git a/crates/lib/src/persistence/metrics.rs b/crates/libmarathon/src/persistence/metrics.rs similarity index 100% rename from crates/lib/src/persistence/metrics.rs rename to crates/libmarathon/src/persistence/metrics.rs diff --git a/crates/lib/src/persistence/migrations.rs b/crates/libmarathon/src/persistence/migrations.rs similarity index 100% rename from crates/lib/src/persistence/migrations.rs rename to crates/libmarathon/src/persistence/migrations.rs diff --git a/crates/lib/src/persistence/migrations/001_initial_schema.sql b/crates/libmarathon/src/persistence/migrations/001_initial_schema.sql similarity index 100% rename from crates/lib/src/persistence/migrations/001_initial_schema.sql rename to crates/libmarathon/src/persistence/migrations/001_initial_schema.sql diff --git a/crates/lib/src/persistence/migrations/004_sessions.sql b/crates/libmarathon/src/persistence/migrations/004_sessions.sql similarity index 100% rename from crates/lib/src/persistence/migrations/004_sessions.sql rename to crates/libmarathon/src/persistence/migrations/004_sessions.sql diff --git a/crates/lib/src/persistence/mod.rs b/crates/libmarathon/src/persistence/mod.rs similarity index 97% rename from crates/lib/src/persistence/mod.rs rename to crates/libmarathon/src/persistence/mod.rs index bf6e8c5..c327056 100644 --- a/crates/lib/src/persistence/mod.rs +++ b/crates/libmarathon/src/persistence/mod.rs @@ -13,7 +13,7 @@ //! //! ```no_run //! use bevy::prelude::*; -//! use lib::persistence::*; +//! use libmarathon::persistence::*; //! //! fn setup(mut commands: Commands) { //! // Spawn an entity with the Persisted marker diff --git a/crates/lib/src/persistence/plugin.rs b/crates/libmarathon/src/persistence/plugin.rs similarity index 99% rename from crates/lib/src/persistence/plugin.rs rename to crates/libmarathon/src/persistence/plugin.rs index 5c48e9e..96e3cd8 100644 --- a/crates/lib/src/persistence/plugin.rs +++ b/crates/libmarathon/src/persistence/plugin.rs @@ -21,7 +21,7 @@ use crate::persistence::*; /// /// ```no_run /// use bevy::prelude::*; -/// use lib::persistence::PersistencePlugin; +/// use libmarathon::persistence::PersistencePlugin; /// /// App::new() /// .add_plugins(PersistencePlugin::new("app.db")) @@ -250,7 +250,7 @@ fn collect_dirty_entities_bevy_system(world: &mut World) { /// /// ```no_run /// # use bevy::prelude::*; -/// # use lib::persistence::*; +/// # use libmarathon::persistence::*; /// App::new() /// .add_plugins(PersistencePlugin::new("app.db")) /// .add_systems(Update, auto_track_transform_changes_system) diff --git a/crates/lib/src/persistence/reflection.rs b/crates/libmarathon/src/persistence/reflection.rs similarity index 98% rename from crates/lib/src/persistence/reflection.rs rename to crates/libmarathon/src/persistence/reflection.rs index 6d8033b..645e0d6 100644 --- a/crates/lib/src/persistence/reflection.rs +++ b/crates/libmarathon/src/persistence/reflection.rs @@ -37,7 +37,7 @@ use crate::persistence::error::{ /// /// ```no_run /// # use bevy::prelude::*; -/// # use lib::persistence::*; +/// # use libmarathon::persistence::*; /// fn update_position(mut query: Query<(&mut Transform, &mut Persisted)>) { /// for (mut transform, mut persisted) in query.iter_mut() { /// transform.translation.x += 1.0; @@ -92,7 +92,7 @@ pub trait Persistable: Component + Reflect { /// # Examples /// ```no_run /// # use bevy::prelude::*; -/// # use lib::persistence::*; +/// # use libmarathon::persistence::*; /// # fn example(component: &Transform, registry: &AppTypeRegistry) -> anyhow::Result<()> { /// let registry = registry.read(); /// let bytes = serialize_component(component.as_reflect(), ®istry)?; @@ -141,7 +141,7 @@ pub fn serialize_component_typed( /// # Examples /// ```no_run /// # use bevy::prelude::*; -/// # use lib::persistence::*; +/// # use libmarathon::persistence::*; /// # fn example(bytes: &[u8], registry: &AppTypeRegistry) -> anyhow::Result<()> { /// let registry = registry.read(); /// let reflected = deserialize_component(bytes, ®istry)?; @@ -204,7 +204,7 @@ pub fn deserialize_component_typed( /// # Examples /// ```no_run /// # use bevy::prelude::*; -/// # use lib::persistence::*; +/// # use libmarathon::persistence::*; /// # fn example(entity: Entity, world: &World, registry: &AppTypeRegistry) -> Option<()> { /// let registry = registry.read(); /// let bytes = serialize_component_from_entity( diff --git a/crates/lib/src/persistence/systems.rs b/crates/libmarathon/src/persistence/systems.rs similarity index 100% rename from crates/lib/src/persistence/systems.rs rename to crates/libmarathon/src/persistence/systems.rs diff --git a/crates/lib/src/persistence/types.rs b/crates/libmarathon/src/persistence/types.rs similarity index 100% rename from crates/lib/src/persistence/types.rs rename to crates/libmarathon/src/persistence/types.rs diff --git a/crates/libmarathon/src/platform/desktop/event_loop.rs b/crates/libmarathon/src/platform/desktop/event_loop.rs new file mode 100644 index 0000000..8381b16 --- /dev/null +++ b/crates/libmarathon/src/platform/desktop/event_loop.rs @@ -0,0 +1,90 @@ +//! Desktop event loop - owns winit window and event handling +//! +//! This module creates and manages the main window and event loop. +//! It converts winit events to InputEvents and provides them to the engine. + +use super::winit_bridge; +use winit::application::ApplicationHandler; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::window::{Window, WindowId}; + +/// Main event loop runner for desktop platforms +pub struct DesktopApp { + window: Option, +} + +impl DesktopApp { + pub fn new() -> Self { + Self { window: None } + } +} + +impl ApplicationHandler for DesktopApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_none() { + let window_attributes = Window::default_attributes() + .with_title("Marathon") + .with_inner_size(winit::dpi::LogicalSize::new(1280, 720)); + + match event_loop.create_window(window_attributes) { + Ok(window) => { + tracing::info!("Created winit window"); + self.window = Some(window); + } + Err(e) => { + tracing::error!("Failed to create window: {}", e); + } + } + } + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + // Forward all input events to the bridge first + winit_bridge::push_window_event(&event); + + match event { + WindowEvent::CloseRequested => { + tracing::info!("Window close requested"); + event_loop.exit(); + } + + WindowEvent::RedrawRequested => { + // Rendering happens via Bevy + if let Some(window) = &self.window { + window.request_redraw(); + } + } + + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + // Request redraw for next frame + if let Some(window) = &self.window { + window.request_redraw(); + } + } +} + +/// Run the desktop application with the provided game update function +/// +/// This takes ownership of the main thread and runs the winit event loop. +/// The update_fn is called each frame to update game logic. +pub fn run(mut update_fn: impl FnMut() + 'static) -> Result<(), Box> { + let event_loop = EventLoop::new()?; + event_loop.set_control_flow(ControlFlow::Poll); // Run as fast as possible + + let mut app = DesktopApp::new(); + + // Run the event loop, calling update_fn each frame + event_loop.run_app(&mut app)?; + + Ok(()) +} diff --git a/crates/libmarathon/src/platform/desktop/mod.rs b/crates/libmarathon/src/platform/desktop/mod.rs new file mode 100644 index 0000000..ebae680 --- /dev/null +++ b/crates/libmarathon/src/platform/desktop/mod.rs @@ -0,0 +1,9 @@ +//! Desktop platform integration +//! +//! Owns the winit event loop and converts winit events to InputEvents. + +mod event_loop; +mod winit_bridge; + +pub use event_loop::run; +pub use winit_bridge::{drain_as_input_events, push_window_event}; diff --git a/crates/libmarathon/src/platform/desktop/winit_bridge.rs b/crates/libmarathon/src/platform/desktop/winit_bridge.rs new file mode 100644 index 0000000..e19cd0e --- /dev/null +++ b/crates/libmarathon/src/platform/desktop/winit_bridge.rs @@ -0,0 +1,225 @@ +//! Desktop winit event loop integration +//! +//! This module owns the winit event loop and window, converting winit events +//! to engine-agnostic InputEvents. + +use crate::engine::{InputEvent, KeyCode, Modifiers, MouseButton, TouchPhase}; +use glam::Vec2; +use std::sync::Mutex; +use winit::event::{ElementState, MouseButton as WinitMouseButton, MouseScrollDelta, WindowEvent}; +use winit::keyboard::PhysicalKey; + +/// Raw winit input events before conversion +#[derive(Clone, Debug)] +pub enum RawWinitEvent { + MouseButton { + button: MouseButton, + state: ElementState, + position: Vec2, + }, + CursorMoved { + position: Vec2, + }, + Keyboard { + key: KeyCode, + state: ElementState, + modifiers: Modifiers, + }, + MouseWheel { + delta: Vec2, + position: Vec2, + }, +} + +/// Thread-safe buffer for winit events +/// +/// The winit event loop pushes events here. +/// The engine drains them each frame. +static BUFFER: Mutex> = Mutex::new(Vec::new()); + +/// Current input state for tracking drags and modifiers +static INPUT_STATE: Mutex = Mutex::new(InputState { + left_pressed: false, + right_pressed: false, + middle_pressed: false, + last_position: Vec2::ZERO, + modifiers: Modifiers { + shift: false, + ctrl: false, + alt: false, + meta: false, + }, +}); + +#[derive(Clone, Copy, Debug)] +struct InputState { + left_pressed: bool, + right_pressed: bool, + middle_pressed: bool, + last_position: Vec2, + modifiers: Modifiers, +} + +/// Push a winit window event to the buffer +/// +/// Call this from the winit event loop +pub fn push_window_event(event: &WindowEvent) { + match event { + WindowEvent::MouseInput { state, button, .. } => { + let mouse_button = match button { + WinitMouseButton::Left => MouseButton::Left, + WinitMouseButton::Right => MouseButton::Right, + WinitMouseButton::Middle => MouseButton::Middle, + _ => return, // Ignore other buttons + }; + + if let Ok(mut input_state) = INPUT_STATE.lock() { + let position = input_state.last_position; + + // Update button state + match mouse_button { + MouseButton::Left => input_state.left_pressed = *state == ElementState::Pressed, + MouseButton::Right => input_state.right_pressed = *state == ElementState::Pressed, + MouseButton::Middle => input_state.middle_pressed = *state == ElementState::Pressed, + } + + if let Ok(mut buf) = BUFFER.lock() { + buf.push(RawWinitEvent::MouseButton { + button: mouse_button, + state: *state, + position, + }); + } + } + } + + WindowEvent::CursorMoved { position, .. } => { + let pos = Vec2::new(position.x as f32, position.y as f32); + + if let Ok(mut input_state) = INPUT_STATE.lock() { + input_state.last_position = pos; + + // Generate drag events for any pressed buttons + if input_state.left_pressed || input_state.right_pressed || input_state.middle_pressed { + if let Ok(mut buf) = BUFFER.lock() { + buf.push(RawWinitEvent::CursorMoved { position: pos }); + } + } + } + } + + WindowEvent::KeyboardInput { event: key_event, .. } => { + // Only handle physical keys + if let PhysicalKey::Code(key_code) = key_event.physical_key { + if let Ok(input_state) = INPUT_STATE.lock() { + if let Ok(mut buf) = BUFFER.lock() { + buf.push(RawWinitEvent::Keyboard { + key: key_code, + state: key_event.state, + modifiers: input_state.modifiers, + }); + } + } + } + } + + WindowEvent::ModifiersChanged(new_modifiers) => { + if let Ok(mut input_state) = INPUT_STATE.lock() { + input_state.modifiers = Modifiers { + shift: new_modifiers.state().shift_key(), + ctrl: new_modifiers.state().control_key(), + alt: new_modifiers.state().alt_key(), + meta: new_modifiers.state().super_key(), + }; + } + } + + WindowEvent::MouseWheel { delta, .. } => { + let scroll_delta = match delta { + MouseScrollDelta::LineDelta(x, y) => Vec2::new(*x, *y) * 20.0, // Scale line deltas + MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32), + }; + + if let Ok(input_state) = INPUT_STATE.lock() { + if let Ok(mut buf) = BUFFER.lock() { + buf.push(RawWinitEvent::MouseWheel { + delta: scroll_delta, + position: input_state.last_position, + }); + } + } + } + + _ => {} + } +} + +/// Drain all buffered winit events and convert to InputEvents +/// +/// Call this from your engine's input processing to consume events. +pub fn drain_as_input_events() -> Vec { + BUFFER + .lock() + .ok() + .map(|mut b| { + std::mem::take(&mut *b) + .into_iter() + .filter_map(raw_to_input_event) + .collect() + }) + .unwrap_or_default() +} + +/// Convert a raw winit event to an engine InputEvent +fn raw_to_input_event(event: RawWinitEvent) -> Option { + match event { + RawWinitEvent::MouseButton { button, state, position } => { + let phase = match state { + ElementState::Pressed => TouchPhase::Started, + ElementState::Released => TouchPhase::Ended, + }; + + Some(InputEvent::Mouse { + pos: position, + button, + phase, + }) + } + + RawWinitEvent::CursorMoved { position } => { + // Determine which button is pressed for drag events + let input_state = INPUT_STATE.lock().ok()?; + + let button = if input_state.left_pressed { + MouseButton::Left + } else if input_state.right_pressed { + MouseButton::Right + } else if input_state.middle_pressed { + MouseButton::Middle + } else { + return None; // No button pressed, ignore + }; + + Some(InputEvent::Mouse { + pos: position, + button, + phase: TouchPhase::Moved, + }) + } + + RawWinitEvent::Keyboard { key, state, modifiers } => { + Some(InputEvent::Keyboard { + key, + pressed: state == ElementState::Pressed, + modifiers, + }) + } + + RawWinitEvent::MouseWheel { delta, position } => { + Some(InputEvent::MouseWheel { + delta, + pos: position, + }) + } + } +} diff --git a/crates/libmarathon/src/platform/ios/mod.rs b/crates/libmarathon/src/platform/ios/mod.rs new file mode 100644 index 0000000..0c205d4 --- /dev/null +++ b/crates/libmarathon/src/platform/ios/mod.rs @@ -0,0 +1,10 @@ +//! iOS platform support +//! +//! This module contains iOS-specific input capture code. + +pub mod pencil_bridge; + +pub use pencil_bridge::{ + drain_as_input_events, drain_raw, pencil_point_received, swift_attach_pencil_capture, + RawPencilPoint, +}; diff --git a/crates/libmarathon/src/platform/ios/pencil_bridge.rs b/crates/libmarathon/src/platform/ios/pencil_bridge.rs new file mode 100644 index 0000000..d54bfe7 --- /dev/null +++ b/crates/libmarathon/src/platform/ios/pencil_bridge.rs @@ -0,0 +1,103 @@ +//! Apple Pencil input bridge for iOS +//! +//! This module captures raw Apple Pencil input via Swift/UIKit and converts +//! it to engine-agnostic InputEvents. + +use crate::engine::input_events::{InputEvent, TouchPhase}; +use glam::Vec2; +use std::sync::Mutex; + +/// Raw pencil point data from Swift UITouch +/// +/// This matches the C struct defined in PencilBridge.h +#[derive(Clone, Copy, Debug, Default)] +#[repr(C)] // Use C memory layout so Swift can interop +pub struct RawPencilPoint { + /// Screen X coordinate in points (not pixels) + pub x: f32, + /// Screen Y coordinate in points (not pixels) + pub y: f32, + /// Force/pressure (0.0 - 4.0 on Apple Pencil) + pub force: f32, + /// Altitude angle in radians (0 = flat, π/2 = perpendicular) + pub altitude: f32, + /// Azimuth angle in radians (rotation around vertical) + pub azimuth: f32, + /// iOS timestamp (seconds since system boot) + pub timestamp: f64, + /// Touch phase: 0=began, 1=moved, 2=ended + pub phase: u8, +} + +/// Thread-safe buffer for pencil points +/// +/// Swift's main thread pushes points here via C FFI. +/// Bevy's Update schedule drains them each frame. +static BUFFER: Mutex> = Mutex::new(Vec::new()); + +/// FFI function called from Swift when a pencil point is received +/// +/// This is exposed as a C function so Swift can call it. +/// The `#[no_mangle]` prevents Rust from changing the function name. +#[no_mangle] +pub extern "C" fn pencil_point_received(point: RawPencilPoint) { + if let Ok(mut buf) = BUFFER.lock() { + buf.push(point); + } +} + +/// Drain all buffered pencil points and convert to InputEvents +/// +/// Call this from your Bevy Update system to consume input. +pub fn drain_as_input_events() -> Vec { + BUFFER + .lock() + .ok() + .map(|mut b| { + std::mem::take(&mut *b) + .into_iter() + .map(raw_to_input_event) + .collect() + }) + .unwrap_or_default() +} + +/// Drain raw pencil points without conversion +/// +/// Useful for debugging or custom processing. +pub fn drain_raw() -> Vec { + BUFFER + .lock() + .ok() + .map(|mut b| std::mem::take(&mut *b)) + .unwrap_or_default() +} + +/// Convert a raw pencil point to an engine InputEvent +fn raw_to_input_event(p: RawPencilPoint) -> InputEvent { + InputEvent::Stylus { + pos: Vec2::new(p.x, p.y), + pressure: p.force, + tilt: Vec2::new(p.altitude, p.azimuth), + phase: match p.phase { + 0 => TouchPhase::Started, + 1 => TouchPhase::Moved, + 2 => TouchPhase::Ended, + _ => TouchPhase::Cancelled, + }, + timestamp: p.timestamp, + } +} + +/// Attach the pencil capture system to a UIView +/// +/// This is only available on iOS. On other platforms, it's a no-op. +#[cfg(target_os = "ios")] +extern "C" { + pub fn swift_attach_pencil_capture(view: *mut std::ffi::c_void); +} + +#[cfg(not(target_os = "ios"))] +pub unsafe fn swift_attach_pencil_capture(_: *mut std::ffi::c_void) { + // No-op on non-iOS platforms +} diff --git a/crates/libmarathon/src/platform/ios/swift/PencilBridge.h b/crates/libmarathon/src/platform/ios/swift/PencilBridge.h new file mode 100644 index 0000000..976c4d0 --- /dev/null +++ b/crates/libmarathon/src/platform/ios/swift/PencilBridge.h @@ -0,0 +1,43 @@ +/** + * C header for Rust-Swift interop + * + * This defines the interface between Rust and Swift. + * Both sides include this header to ensure they agree on data types. + */ + +#ifndef PENCIL_BRIDGE_H +#define PENCIL_BRIDGE_H + +#include + +/** + * Raw pencil data from iOS UITouch + * + * This struct uses C types that both Rust and Swift understand. + * The memory layout must match exactly on both sides. + */ +typedef struct { + float x; // Screen X in points + float y; // Screen Y in points + float force; // Pressure (0.0 - 4.0) + float altitude; // Angle from screen (radians) + float azimuth; // Rotation angle (radians) + double timestamp; // iOS system timestamp + uint8_t phase; // 0=began, 1=moved, 2=ended +} RawPencilPoint; + +/** + * Called from Swift when a pencil point is captured + * + * This is implemented in Rust (pencil_bridge.rs) + */ +void pencil_point_received(RawPencilPoint point); + +/** + * Attach pencil capture to a UIView + * + * This is implemented in Swift (PencilCapture.swift) + */ +void swift_attach_pencil_capture(void* view); + +#endif diff --git a/crates/libmarathon/src/platform/ios/swift/PencilCapture.swift b/crates/libmarathon/src/platform/ios/swift/PencilCapture.swift new file mode 100644 index 0000000..2895cff --- /dev/null +++ b/crates/libmarathon/src/platform/ios/swift/PencilCapture.swift @@ -0,0 +1,52 @@ +import UIKit + +@_cdecl("swift_attach_pencil_capture") +func swiftAttachPencilCapture(_ viewPtr: UnsafeMutableRawPointer) { + DispatchQueue.main.async { + let view = Unmanaged.fromOpaque(viewPtr).takeUnretainedValue() + let recognizer = PencilGestureRecognizer() + recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesEnded = false + view.addGestureRecognizer(recognizer) + print("[Swift] Pencil capture attached") + } +} + +class PencilGestureRecognizer: UIGestureRecognizer { + override func touchesBegan(_ touches: Set, with event: UIEvent) { + state = .began + send(touches, event: event, phase: 0) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + state = .changed + send(touches, event: event, phase: 1) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + state = .ended + send(touches, event: event, phase: 2) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + state = .cancelled + send(touches, event: event, phase: 2) + } + + private func send(_ touches: Set, event: UIEvent?, phase: UInt8) { + for touch in touches where touch.type == .pencil { + for t in event?.coalescedTouches(for: touch) ?? [touch] { + let loc = t.preciseLocation(in: view) + pencil_point_received(RawPencilPoint( + x: Float(loc.x), + y: Float(loc.y), + force: Float(t.force), + altitude: Float(t.altitudeAngle), + azimuth: Float(t.azimuthAngle(in: view)), + timestamp: t.timestamp, + phase: phase + )) + } + } + } +} diff --git a/crates/libmarathon/src/platform/mod.rs b/crates/libmarathon/src/platform/mod.rs new file mode 100644 index 0000000..9254db9 --- /dev/null +++ b/crates/libmarathon/src/platform/mod.rs @@ -0,0 +1,10 @@ +//! Platform-specific input bridges +//! +//! This module contains platform-specific code for capturing input +//! and converting it to engine-agnostic InputEvents. + +#[cfg(target_os = "ios")] +pub mod ios; + +#[cfg(not(target_os = "ios"))] +pub mod desktop; diff --git a/crates/lib/src/sync.rs b/crates/libmarathon/src/sync.rs similarity index 100% rename from crates/lib/src/sync.rs rename to crates/libmarathon/src/sync.rs diff --git a/crates/libmarathon/tests/bridge_integration.rs b/crates/libmarathon/tests/bridge_integration.rs new file mode 100644 index 0000000..93d077b --- /dev/null +++ b/crates/libmarathon/tests/bridge_integration.rs @@ -0,0 +1,234 @@ +//! Integration tests for EngineBridge command/event routing + +use libmarathon::engine::{EngineBridge, EngineCommand, EngineCore, EngineEvent}; +use libmarathon::networking::SessionId; +use std::time::Duration; +use tokio::time::timeout; + +/// Test that commands sent from "Bevy side" reach the engine +#[tokio::test] +async fn test_command_routing() { + let (bridge, handle) = EngineBridge::new(); + + // Spawn engine in background + let engine_handle = tokio::spawn(async move { + // Run engine for a short time + let core = EngineCore::new(handle, ":memory:"); + timeout(Duration::from_millis(100), core.run()) + .await + .ok(); + }); + + // Give engine time to start + tokio::time::sleep(Duration::from_millis(10)).await; + + // Send a command from "Bevy side" + let session_id = SessionId::new(); + bridge.send_command(EngineCommand::StartNetworking { + session_id: session_id.clone(), + }); + + // Give engine time to process + tokio::time::sleep(Duration::from_millis(50)).await; + + // Poll events + let events = bridge.poll_events(); + + // Verify we got a NetworkingStarted event + assert!(!events.is_empty(), "Should receive at least one event"); + + let has_networking_started = events.iter().any(|e| { + matches!( + e, + EngineEvent::NetworkingStarted { + session_id: sid, + .. + } if sid == &session_id + ) + }); + + assert!( + has_networking_started, + "Should receive NetworkingStarted event" + ); + + // Cleanup + drop(bridge); + let _ = engine_handle.await; +} + +/// Test that events from engine reach "Bevy side" +#[tokio::test] +async fn test_event_routing() { + let (bridge, handle) = EngineBridge::new(); + + // Spawn engine + let engine_handle = tokio::spawn(async move { + let core = EngineCore::new(handle, ":memory:"); + timeout(Duration::from_millis(100), core.run()) + .await + .ok(); + }); + + tokio::time::sleep(Duration::from_millis(10)).await; + + // Send StartNetworking command + let session_id = SessionId::new(); + bridge.send_command(EngineCommand::StartNetworking { + session_id: session_id.clone(), + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // Poll events multiple times to verify queue works + let events1 = bridge.poll_events(); + let events2 = bridge.poll_events(); + + assert!(!events1.is_empty(), "First poll should return events"); + assert!( + events2.is_empty(), + "Second poll should be empty (events already drained)" + ); + + // Cleanup + drop(bridge); + let _ = engine_handle.await; +} + +/// Test full lifecycle: Start → Stop networking +#[tokio::test] +async fn test_networking_lifecycle() { + let (bridge, handle) = EngineBridge::new(); + + let engine_handle = tokio::spawn(async move { + let core = EngineCore::new(handle, ":memory:"); + timeout(Duration::from_millis(200), core.run()) + .await + .ok(); + }); + + tokio::time::sleep(Duration::from_millis(10)).await; + + // Start networking + let session_id = SessionId::new(); + bridge.send_command(EngineCommand::StartNetworking { + session_id: session_id.clone(), + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let events = bridge.poll_events(); + assert!( + events + .iter() + .any(|e| matches!(e, EngineEvent::NetworkingStarted { .. })), + "Should receive NetworkingStarted" + ); + + // Stop networking + bridge.send_command(EngineCommand::StopNetworking); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let events = bridge.poll_events(); + assert!( + events + .iter() + .any(|e| matches!(e, EngineEvent::NetworkingStopped)), + "Should receive NetworkingStopped" + ); + + // Cleanup + drop(bridge); + let _ = engine_handle.await; +} + +/// Test JoinSession command routing +#[tokio::test] +async fn test_join_session_routing() { + let (bridge, handle) = EngineBridge::new(); + + let engine_handle = tokio::spawn(async move { + let core = EngineCore::new(handle, ":memory:"); + timeout(Duration::from_millis(200), core.run()) + .await + .ok(); + }); + + tokio::time::sleep(Duration::from_millis(10)).await; + + // Join a new session (should start networking) + let session_id = SessionId::new(); + bridge.send_command(EngineCommand::JoinSession { + session_id: session_id.clone(), + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let events = bridge.poll_events(); + assert!( + events.iter().any(|e| { + matches!( + e, + EngineEvent::NetworkingStarted { + session_id: sid, + .. + } if sid == &session_id + ) + }), + "JoinSession should start networking" + ); + + // Cleanup + drop(bridge); + let _ = engine_handle.await; +} + +/// Test that multiple commands are processed in order +#[tokio::test] +async fn test_command_ordering() { + let (bridge, handle) = EngineBridge::new(); + + let engine_handle = tokio::spawn(async move { + let core = EngineCore::new(handle, ":memory:"); + timeout(Duration::from_millis(200), core.run()) + .await + .ok(); + }); + + tokio::time::sleep(Duration::from_millis(10)).await; + + // Send multiple commands + let session1 = SessionId::new(); + let session2 = SessionId::new(); + + bridge.send_command(EngineCommand::StartNetworking { + session_id: session1.clone(), + }); + bridge.send_command(EngineCommand::StopNetworking); + bridge.send_command(EngineCommand::JoinSession { + session_id: session2.clone(), + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let events = bridge.poll_events(); + + // Should see: NetworkingStarted(session1), NetworkingStopped, NetworkingStarted(session2) + let started_events: Vec<_> = events + .iter() + .filter(|e| matches!(e, EngineEvent::NetworkingStarted { .. })) + .collect(); + + let stopped_events: Vec<_> = events + .iter() + .filter(|e| matches!(e, EngineEvent::NetworkingStopped)) + .collect(); + + assert_eq!(started_events.len(), 2, "Should have 2 NetworkingStarted events"); + assert_eq!(stopped_events.len(), 1, "Should have 1 NetworkingStopped event"); + + // Cleanup + drop(bridge); + let _ = engine_handle.await; +} diff --git a/crates/lib/tests/networking_gossip_test.rs b/crates/libmarathon/tests/networking_gossip_test.rs similarity index 94% rename from crates/lib/tests/networking_gossip_test.rs rename to crates/libmarathon/tests/networking_gossip_test.rs index 8214968..7597001 100644 --- a/crates/lib/tests/networking_gossip_test.rs +++ b/crates/libmarathon/tests/networking_gossip_test.rs @@ -3,7 +3,7 @@ //! Tests the gossip bridge channel infrastructure. Full iroh-gossip integration //! will be tested in Phase 3.5. -use lib::networking::*; +use libmarathon::networking::*; #[test] fn test_gossip_bridge_creation() { @@ -15,7 +15,7 @@ fn test_gossip_bridge_creation() { #[test] fn test_gossip_bridge_send() { - use lib::networking::{ + use libmarathon::networking::{ JoinType, SessionId, }; diff --git a/crates/lib/tests/our_messages_test.rs b/crates/libmarathon/tests/our_messages_test.rs similarity index 99% rename from crates/lib/tests/our_messages_test.rs rename to crates/libmarathon/tests/our_messages_test.rs index d7779ba..dac3350 100644 --- a/crates/lib/tests/our_messages_test.rs +++ b/crates/libmarathon/tests/our_messages_test.rs @@ -1,5 +1,5 @@ use chrono::Datelike; -use lib::{ +use libmarathon::{ ChatDb, Result, }; diff --git a/crates/lib/tests/property_tests.proptest-regressions b/crates/libmarathon/tests/property_tests.proptest-regressions similarity index 100% rename from crates/lib/tests/property_tests.proptest-regressions rename to crates/libmarathon/tests/property_tests.proptest-regressions diff --git a/crates/lib/tests/property_tests.rs b/crates/libmarathon/tests/property_tests.rs similarity index 99% rename from crates/lib/tests/property_tests.rs rename to crates/libmarathon/tests/property_tests.rs index f8f08bd..8642be7 100644 --- a/crates/lib/tests/property_tests.rs +++ b/crates/libmarathon/tests/property_tests.rs @@ -4,7 +4,7 @@ //! their mathematical properties under all possible inputs and operation //! sequences. -use lib::{ +use libmarathon::{ networking::{ NodeId, VectorClock, diff --git a/crates/lib/tests/sync_integration_headless.rs b/crates/libmarathon/tests/sync_integration_headless.rs similarity index 98% rename from crates/lib/tests/sync_integration_headless.rs rename to crates/libmarathon/tests/sync_integration_headless.rs index 072fb13..996ce09 100644 --- a/crates/lib/tests/sync_integration_headless.rs +++ b/crates/libmarathon/tests/sync_integration_headless.rs @@ -39,7 +39,7 @@ use iroh_gossip::{ net::Gossip, proto::TopicId, }; -use lib::{ +use libmarathon::{ networking::{ EntityLockRegistry, GossipBridge, @@ -175,7 +175,7 @@ mod test_utils { let data = data_result.optional()?; if let Some(bytes) = data { - use lib::persistence::reflection::deserialize_component_typed; + use libmarathon::persistence::reflection::deserialize_component_typed; let reflected = deserialize_component_typed(&bytes, component_type, type_registry)?; if let Some(concrete) = reflected.try_downcast_ref::() { @@ -1081,8 +1081,8 @@ async fn test_lock_heartbeat_renewal() -> Result<()> { { let registry1 = app1.world().resource::(); let registry2 = app2.world().resource::(); - assert!(registry1.is_locked(entity_id), "Lock should exist on node 1"); - assert!(registry2.is_locked(entity_id), "Lock should exist on node 2"); + assert!(registry1.is_locked(entity_id, node1_id), "Lock should exist on node 1"); + assert!(registry2.is_locked(entity_id, node2_id), "Lock should exist on node 2"); println!("✓ Lock acquired on both nodes"); } @@ -1120,12 +1120,12 @@ async fn test_lock_heartbeat_renewal() -> Result<()> { let registry1 = app1.world().resource::(); let registry2 = app2.world().resource::(); assert!( - registry1.is_locked(entity_id), + registry1.is_locked(entity_id, node1_id), "Lock should persist on node 1 after heartbeat {}", i + 1 ); assert!( - registry2.is_locked(entity_id), + registry2.is_locked(entity_id, node2_id), "Lock should persist on node 2 after heartbeat {}", i + 1 ); @@ -1205,7 +1205,7 @@ async fn test_lock_heartbeat_expiration() -> Result<()> { // Verify lock acquired wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| { let registry2 = w2.resource::(); - registry2.is_locked(entity_id) + registry2.is_locked(entity_id, node2_id) }) .await?; println!("✓ Lock acquired and propagated"); @@ -1236,7 +1236,7 @@ async fn test_lock_heartbeat_expiration() -> Result<()> { { let registry = app2.world().resource::(); assert!( - !registry.is_locked(entity_id), + !registry.is_locked(entity_id, node2_id), "Lock should be expired on node 2 after cleanup" ); println!("✓ Lock expired on node 2 after 5 seconds without heartbeat"); @@ -1315,7 +1315,7 @@ async fn test_lock_release_stops_heartbeats() -> Result<()> { // Wait for lock to propagate wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| { let registry2 = w2.resource::(); - registry2.is_locked(entity_id) + registry2.is_locked(entity_id, node2_id) }) .await?; println!("✓ Lock acquired and propagated"); @@ -1349,7 +1349,7 @@ async fn test_lock_release_stops_heartbeats() -> Result<()> { // Wait for release to propagate to node 2 wait_for_sync(&mut app1, &mut app2, Duration::from_secs(3), |_, w2| { let registry2 = w2.resource::(); - !registry2.is_locked(entity_id) + !registry2.is_locked(entity_id, node2_id) }) .await?; println!("✓ Lock release propagated to node 2"); diff --git a/crates/lib/tests/transform_change_test.rs b/crates/libmarathon/tests/transform_change_test.rs similarity index 96% rename from crates/lib/tests/transform_change_test.rs rename to crates/libmarathon/tests/transform_change_test.rs index 7718bec..c77b8e3 100644 --- a/crates/lib/tests/transform_change_test.rs +++ b/crates/libmarathon/tests/transform_change_test.rs @@ -6,7 +6,7 @@ use std::sync::{ }; use bevy::prelude::*; -use lib::networking::{ +use libmarathon::networking::{ NetworkedEntity, NetworkedTransform, Synced, @@ -21,7 +21,7 @@ fn test_transform_change_detection_basic() { // Add the auto_detect system app.add_systems( Update, - lib::networking::auto_detect_transform_changes_system, + libmarathon::networking::auto_detect_transform_changes_system, ); // Add a test system that runs AFTER auto_detect to check if NetworkedEntity was diff --git a/crates/sync-macros/Cargo.toml b/crates/sync-macros/Cargo.toml index 96de018..4d8c9c4 100644 --- a/crates/sync-macros/Cargo.toml +++ b/crates/sync-macros/Cargo.toml @@ -12,7 +12,7 @@ quote = "1.0" proc-macro2 = "1.0" [dev-dependencies] -lib = { path = "../lib" } +libmarathon = { path = "../libmarathon" } bevy = { workspace = true } serde = { workspace = true } bincode = "1.3" diff --git a/crates/sync-macros/src/lib.rs b/crates/sync-macros/src/lib.rs index 4015c90..6e22bb1 100644 --- a/crates/sync-macros/src/lib.rs +++ b/crates/sync-macros/src/lib.rs @@ -31,11 +31,11 @@ impl SyncStrategy { fn to_tokens(&self) -> proc_macro2::TokenStream { match self { | SyncStrategy::LastWriteWins => { - quote! { lib::networking::SyncStrategy::LastWriteWins } + quote! { libmarathon::networking::SyncStrategy::LastWriteWins } }, - | SyncStrategy::Set => quote! { lib::networking::SyncStrategy::Set }, - | SyncStrategy::Sequence => quote! { lib::networking::SyncStrategy::Sequence }, - | SyncStrategy::Custom => quote! { lib::networking::SyncStrategy::Custom }, + | SyncStrategy::Set => quote! { libmarathon::networking::SyncStrategy::Set }, + | SyncStrategy::Sequence => quote! { libmarathon::networking::SyncStrategy::Sequence }, + | SyncStrategy::Custom => quote! { libmarathon::networking::SyncStrategy::Custom }, } } } @@ -124,7 +124,7 @@ impl SyncAttributes { /// # Example /// ```ignore /// use bevy::prelude::*; -/// use lib::networking::Synced; +/// use libmarathon::networking::Synced; /// use sync_macros::Synced as SyncedDerive; /// /// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize)] @@ -160,9 +160,9 @@ pub fn derive_synced(input: TokenStream) -> TokenStream { let merge_impl = generate_merge(&input, &attrs.strategy); let expanded = quote! { - impl lib::networking::SyncComponent for #name { + impl libmarathon::networking::SyncComponent for #name { const VERSION: u32 = #version; - const STRATEGY: lib::networking::SyncStrategy = #strategy_tokens; + const STRATEGY: libmarathon::networking::SyncStrategy = #strategy_tokens; #[inline] fn serialize_sync(&self) -> anyhow::Result> { @@ -175,7 +175,7 @@ pub fn derive_synced(input: TokenStream) -> TokenStream { } #[inline] - fn merge(&mut self, remote: Self, clock_cmp: lib::networking::ClockComparison) -> lib::networking::ComponentMergeDecision { + fn merge(&mut self, remote: Self, clock_cmp: libmarathon::networking::ClockComparison) -> libmarathon::networking::ComponentMergeDecision { #merge_impl } } @@ -235,19 +235,19 @@ fn generate_lww_merge(_input: &DeriveInput) -> proc_macro2::TokenStream { use tracing::info; match clock_cmp { - lib::networking::ClockComparison::RemoteNewer => { + libmarathon::networking::ClockComparison::RemoteNewer => { info!( component = std::any::type_name::(), ?clock_cmp, "Taking remote (newer)" ); *self = remote; - lib::networking::ComponentMergeDecision::TookRemote + libmarathon::networking::ComponentMergeDecision::TookRemote } - lib::networking::ClockComparison::LocalNewer => { - lib::networking::ComponentMergeDecision::KeptLocal + libmarathon::networking::ClockComparison::LocalNewer => { + libmarathon::networking::ComponentMergeDecision::KeptLocal } - lib::networking::ClockComparison::Concurrent => { + libmarathon::networking::ClockComparison::Concurrent => { // Tiebreaker: Compare serialized representations for deterministic choice // In a real implementation, we'd use node_id, but for now use a simple hash #hash_tiebreaker @@ -259,9 +259,9 @@ fn generate_lww_merge(_input: &DeriveInput) -> proc_macro2::TokenStream { "Taking remote (concurrent, tiebreaker)" ); *self = remote; - lib::networking::ComponentMergeDecision::TookRemote + libmarathon::networking::ComponentMergeDecision::TookRemote } else { - lib::networking::ComponentMergeDecision::KeptLocal + libmarathon::networking::ComponentMergeDecision::KeptLocal } } } @@ -292,23 +292,23 @@ fn generate_set_merge(_input: &DeriveInput) -> proc_macro2::TokenStream { // the component to expose merge() method or implement it directly match clock_cmp { - lib::networking::ClockComparison::RemoteNewer => { + libmarathon::networking::ClockComparison::RemoteNewer => { *self = remote; - lib::networking::ComponentMergeDecision::TookRemote + libmarathon::networking::ComponentMergeDecision::TookRemote } - lib::networking::ClockComparison::LocalNewer => { - lib::networking::ComponentMergeDecision::KeptLocal + libmarathon::networking::ClockComparison::LocalNewer => { + libmarathon::networking::ComponentMergeDecision::KeptLocal } - lib::networking::ClockComparison::Concurrent => { + libmarathon::networking::ClockComparison::Concurrent => { // In a full implementation, we would merge the OrSet here // For now, use LWW with tiebreaker as fallback #hash_tiebreaker if remote_hash > local_hash { *self = remote; - lib::networking::ComponentMergeDecision::TookRemote + libmarathon::networking::ComponentMergeDecision::TookRemote } else { - lib::networking::ComponentMergeDecision::KeptLocal + libmarathon::networking::ComponentMergeDecision::KeptLocal } } } @@ -338,23 +338,23 @@ fn generate_sequence_merge(_input: &DeriveInput) -> proc_macro2::TokenStream { // the component to expose merge() method or implement it directly match clock_cmp { - lib::networking::ClockComparison::RemoteNewer => { + libmarathon::networking::ClockComparison::RemoteNewer => { *self = remote; - lib::networking::ComponentMergeDecision::TookRemote + libmarathon::networking::ComponentMergeDecision::TookRemote } - lib::networking::ClockComparison::LocalNewer => { - lib::networking::ComponentMergeDecision::KeptLocal + libmarathon::networking::ClockComparison::LocalNewer => { + libmarathon::networking::ComponentMergeDecision::KeptLocal } - lib::networking::ClockComparison::Concurrent => { + libmarathon::networking::ClockComparison::Concurrent => { // In a full implementation, we would merge the Rga here // For now, use LWW with tiebreaker as fallback #hash_tiebreaker if remote_hash > local_hash { *self = remote; - lib::networking::ComponentMergeDecision::TookRemote + libmarathon::networking::ComponentMergeDecision::TookRemote } else { - lib::networking::ComponentMergeDecision::KeptLocal + libmarathon::networking::ComponentMergeDecision::KeptLocal } } } @@ -371,6 +371,6 @@ fn generate_custom_merge(input: &DeriveInput) -> proc_macro2::TokenStream { stringify!(#name) ) ); - lib::networking::ComponentMergeDecision::KeptLocal + libmarathon::networking::ComponentMergeDecision::KeptLocal } } diff --git a/crates/sync-macros/tests/basic_macro_test.rs b/crates/sync-macros/tests/basic_macro_test.rs index 41282c6..9324238 100644 --- a/crates/sync-macros/tests/basic_macro_test.rs +++ b/crates/sync-macros/tests/basic_macro_test.rs @@ -1,6 +1,6 @@ /// Basic tests for the Synced derive macro use bevy::prelude::*; -use lib::networking::{ +use libmarathon::networking::{ ClockComparison, ComponentMergeDecision, SyncComponent,