initial arhitectural overhaul
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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::<CubeMarker>()
|
||||
.add_systems(Startup, spawn_cube);
|
||||
.add_message::<SpawnCubeEvent>()
|
||||
.add_message::<DeleteCubeEvent>()
|
||||
.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<SpawnCubeEvent>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
node_clock: Option<Res<lib::networking::NodeVectorClock>>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
) {
|
||||
// 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<DeleteCubeEvent>,
|
||||
entity_map: Res<NetworkEntityMap>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Res<NodeVectorClock>>,
|
||||
gossip_bridge: Option<Res<GossipBridge>>,
|
||||
cube_query: Query<
|
||||
(&Transform, &lib::networking::NetworkedEntity),
|
||||
With<crate::cube::CubeMarker>,
|
||||
>,
|
||||
lock_registry: Option<Res<EntityLockRegistry>>,
|
||||
cube_query: Query<(&Transform, &NetworkedEntity), With<CubeMarker>>,
|
||||
mut spawn_events: MessageWriter<SpawnCubeEvent>,
|
||||
mut delete_events: MessageWriter<DeleteCubeEvent>,
|
||||
) {
|
||||
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::<f32>() * 4.0 - 2.0,
|
||||
0.5,
|
||||
rand::random::<f32>() * 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");
|
||||
});
|
||||
}
|
||||
|
||||
124
crates/app/src/engine_bridge.rs
Normal file
124
crates/app/src/engine_bridge.rs
Normal file
@@ -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<EngineBridge>,
|
||||
changed_query: Query<(), (With<NetworkedEntity>, Changed<Transform>)>,
|
||||
) {
|
||||
// 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<EngineBridge>,
|
||||
mut current_session: Option<ResMut<CurrentSession>>,
|
||||
mut node_clock: ResMut<NodeVectorClock>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
235
crates/app/src/executor.rs
Normal file
235
crates/app/src/executor.rs
Normal file
@@ -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<WinitWindow>,
|
||||
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::<Messages<WindowCreated>>();
|
||||
bevy_app.init_resource::<Messages<WindowResized>>();
|
||||
bevy_app.init_resource::<Messages<WindowScaleFactorChanged>>();
|
||||
bevy_app.init_resource::<Messages<WindowClosing>>();
|
||||
bevy_app.init_resource::<Messages<BevyWindowEvent>>();
|
||||
|
||||
// Initialize input resources that Bevy UI and picking expect
|
||||
bevy_app.init_resource::<ButtonInput<BevyMouseButton>>();
|
||||
bevy_app.init_resource::<ButtonInput<BevyKeyCode>>();
|
||||
bevy_app.init_resource::<Touches>();
|
||||
bevy_app.init_resource::<Messages<TouchInput>>();
|
||||
|
||||
// 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::<Messages<WindowCreated>>()
|
||||
.write(WindowCreated { window: window_entity });
|
||||
|
||||
// Send WindowResized event
|
||||
bevy_app.world_mut()
|
||||
.resource_mut::<Messages<WindowResized>>()
|
||||
.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::<Messages<WindowResized>>()
|
||||
.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::<InputEventBuffer>().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::<InputEventBuffer>().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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
226
crates/app/src/input/desktop_bridge.rs
Normal file
226
crates/app/src/input/desktop_bridge.rs
Normal file
@@ -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<EngineKeyCode> {
|
||||
// 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::<InputEventBuffer>()
|
||||
.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<InputEvent>,
|
||||
}
|
||||
|
||||
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<InputEventBuffer>) {
|
||||
buffer.events.clear();
|
||||
}
|
||||
|
||||
/// Collect mouse button events
|
||||
fn collect_mouse_buttons(
|
||||
mut buffer: ResMut<InputEventBuffer>,
|
||||
mut mouse_button_events: MessageReader<MouseButtonInput>,
|
||||
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<InputEventBuffer>,
|
||||
mut cursor_moved: MessageReader<CursorMoved>,
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
) {
|
||||
// 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<InputEventBuffer>,
|
||||
mut wheel_events: MessageReader<MouseWheel>,
|
||||
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<InputEventBuffer>,
|
||||
mut keyboard_events: MessageReader<KeyboardInput>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
22
crates/app/src/input/event_buffer.rs
Normal file
22
crates/app/src/input/event_buffer.rs
Normal file
@@ -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<InputEvent>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
152
crates/app/src/input/input_handler.rs
Normal file
152
crates/app/src/input/input_handler.rs
Normal file
@@ -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::<InputControllerResource>()
|
||||
.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<InputEventBuffer>,
|
||||
mut controller_res: ResMut<InputControllerResource>,
|
||||
lock_registry: Res<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
mut cube_query: Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
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<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
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<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
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<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
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<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
crates/app/src/input/mod.rs
Normal file
28
crates/app/src/input/mod.rs
Normal file
@@ -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;
|
||||
@@ -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<ButtonInput<MouseButton>>,
|
||||
mut mouse_motion: EventReader<MouseMotion>,
|
||||
mut mouse_wheel: EventReader<MouseWheel>,
|
||||
mut mouse_motion: MessageReader<MouseMotion>,
|
||||
mut mouse_wheel: MessageReader<MouseWheel>,
|
||||
mut mouse_state: Local<Option<MouseState>>,
|
||||
mut cube_query: Query<&mut Transform, With<crate::cube::CubeMarker>>,
|
||||
lock_registry: Res<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
mut cube_query: Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
crates/app/src/input/pencil.rs
Normal file
69
crates/app/src/input/pencil.rs
Normal file
@@ -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<InputEvent>,
|
||||
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<bevy::window::PrimaryWindow>>) {
|
||||
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<ResMut<PencilState>>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<bevy::log::LogPlugin>() // 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::<bevy::log::LogPlugin>() // Using tracing-subscriber
|
||||
.disable::<bevy::winit::WinitPlugin>() // We own winit
|
||||
.disable::<bevy::window::WindowPlugin>() // We own the window
|
||||
.disable::<bevy::input::InputPlugin>() // We provide InputEvents directly
|
||||
.disable::<bevy::gilrs::GilrsPlugin>() // 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");
|
||||
}
|
||||
|
||||
217
crates/app/src/selection.rs
Normal file
217
crates/app/src/selection.rs
Normal file
@@ -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<ButtonInput<MouseButton>>,
|
||||
windows: Query<&Window>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
cubes: Query<(Entity, &Transform, &NetworkedEntity), With<CubeMarker>>,
|
||||
mut selections: Query<&mut NetworkedSelection>,
|
||||
mut lock_registry: ResMut<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
bridge: Option<Res<GossipBridge>>,
|
||||
) {
|
||||
// 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<ButtonInput<KeyCode>>,
|
||||
mut selections: Query<&mut NetworkedSelection>,
|
||||
mut lock_registry: ResMut<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
bridge: Option<Res<GossipBridge>>,
|
||||
) {
|
||||
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<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
mut cubes: Query<(&NetworkedEntity, &mut MeshMaterial3d<StandardMaterial>), With<CubeMarker>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
) {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
crates/app/src/session.rs
Normal file
36
crates/app/src/session.rs
Normal file
@@ -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)");
|
||||
}
|
||||
141
crates/app/src/session_ui.rs
Normal file
141
crates/app/src/session_ui.rs
Normal file
@@ -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::<SessionUiState>()
|
||||
.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<SessionUiState>,
|
||||
current_session: Option<Res<CurrentSession>>,
|
||||
node_clock: Option<Res<NodeVectorClock>>,
|
||||
bridge: Res<EngineBridge>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ use iroh_gossip::{
|
||||
net::Gossip,
|
||||
proto::TopicId,
|
||||
};
|
||||
use lib::{
|
||||
use libmarathon::{
|
||||
networking::{
|
||||
GossipBridge,
|
||||
NetworkedEntity,
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
@@ -10,7 +10,7 @@ use criterion::{
|
||||
criterion_group,
|
||||
criterion_main,
|
||||
};
|
||||
use lib::persistence::{
|
||||
use libmarathon::persistence::{
|
||||
PersistenceOp,
|
||||
WriteBuffer,
|
||||
};
|
||||
72
crates/libmarathon/src/engine/bridge.rs
Normal file
72
crates/libmarathon/src/engine/bridge.rs
Normal file
@@ -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<EngineCommand>,
|
||||
event_rx: Arc<Mutex<mpsc::UnboundedReceiver<EngineEvent>>>,
|
||||
}
|
||||
|
||||
/// Engine-side handle for receiving commands and sending events
|
||||
pub struct EngineHandle {
|
||||
pub(crate) command_rx: mpsc::UnboundedReceiver<EngineCommand>,
|
||||
pub(crate) event_tx: mpsc::UnboundedSender<EngineEvent>,
|
||||
}
|
||||
|
||||
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<EngineEvent> {
|
||||
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
|
||||
}
|
||||
}
|
||||
50
crates/libmarathon/src/engine/commands.rs
Normal file
50
crates/libmarathon/src/engine/commands.rs
Normal file
@@ -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,
|
||||
}
|
||||
140
crates/libmarathon/src/engine/core.rs
Normal file
140
crates/libmarathon/src/engine/core.rs
Normal file
@@ -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<JoinHandle<()>>,
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
71
crates/libmarathon/src/engine/events.rs
Normal file
71
crates/libmarathon/src/engine/events.rs
Normal file
@@ -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,
|
||||
},
|
||||
}
|
||||
118
crates/libmarathon/src/engine/game_actions.rs
Normal file
118
crates/libmarathon/src/engine/game_actions.rs
Normal file
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
337
crates/libmarathon/src/engine/input_controller.rs
Normal file
337
crates/libmarathon/src/engine/input_controller.rs
Normal file
@@ -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<InputContext, HashMap<InputBinding, GameAction>>,
|
||||
|
||||
/// 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<DragSource>,
|
||||
|
||||
/// 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<GameAction> {
|
||||
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<GameAction>) {
|
||||
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<GameAction>) {
|
||||
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<GameAction>) {
|
||||
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<GameAction>) {
|
||||
// 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;
|
||||
326
crates/libmarathon/src/engine/input_controller_tests.rs
Normal file
326
crates/libmarathon/src/engine/input_controller_tests.rs
Normal file
@@ -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));
|
||||
}
|
||||
133
crates/libmarathon/src/engine/input_events.rs
Normal file
133
crates/libmarathon/src/engine/input_events.rs
Normal file
@@ -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<Vec2> {
|
||||
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<TouchPhase> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/libmarathon/src/engine/mod.rs
Normal file
21
crates/libmarathon/src/engine/mod.rs
Normal file
@@ -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;
|
||||
243
crates/libmarathon/src/engine/networking.rs
Normal file
243
crates/libmarathon/src/engine/networking.rs
Normal file
@@ -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<uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl NetworkingManager {
|
||||
pub async fn new(session_id: SessionId) -> anyhow::Result<Self> {
|
||||
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<EngineEvent>) {
|
||||
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<EngineEvent>) {
|
||||
// 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<EngineEvent>) {
|
||||
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<EngineEvent>) {
|
||||
// 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<EngineEvent>) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
79
crates/libmarathon/src/engine/persistence.rs
Normal file
79
crates/libmarathon/src/engine/persistence.rs
Normal file
@@ -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<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
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<Option<Session>> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
/// ```
|
||||
@@ -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());
|
||||
@@ -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<u8>, blob_store: &BlobStore) -> Result<Co
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{
|
||||
/// use libmarathon::networking::{
|
||||
/// BlobStore,
|
||||
/// ComponentData,
|
||||
/// get_component_data,
|
||||
@@ -21,7 +21,7 @@ use crate::networking::{
|
||||
///
|
||||
/// ```no_run
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::auto_detect_transform_changes_system;
|
||||
/// use libmarathon::networking::auto_detect_transform_changes_system;
|
||||
///
|
||||
/// App::new().add_systems(Update, auto_detect_transform_changes_system);
|
||||
/// ```
|
||||
@@ -36,7 +36,7 @@ use crate::networking::vector_clock::NodeId;
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::NetworkedEntity;
|
||||
/// use libmarathon::networking::NetworkedEntity;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// fn spawn_networked_entity(mut commands: Commands) {
|
||||
@@ -72,7 +72,7 @@ impl NetworkedEntity {
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::NetworkedEntity;
|
||||
/// use libmarathon::networking::NetworkedEntity;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let node_id = Uuid::new_v4();
|
||||
@@ -95,7 +95,7 @@ impl NetworkedEntity {
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::NetworkedEntity;
|
||||
/// use libmarathon::networking::NetworkedEntity;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let network_id = Uuid::new_v4();
|
||||
@@ -136,7 +136,7 @@ impl Default for NetworkedEntity {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{
|
||||
/// use libmarathon::networking::{
|
||||
/// NetworkedEntity,
|
||||
/// NetworkedTransform,
|
||||
/// };
|
||||
@@ -171,7 +171,7 @@ pub struct NetworkedTransform;
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{
|
||||
/// use libmarathon::networking::{
|
||||
/// NetworkedEntity,
|
||||
/// NetworkedSelection,
|
||||
/// };
|
||||
@@ -253,7 +253,7 @@ impl NetworkedSelection {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{
|
||||
/// use libmarathon::networking::{
|
||||
/// NetworkedDrawingPath,
|
||||
/// NetworkedEntity,
|
||||
/// };
|
||||
@@ -61,7 +61,7 @@ impl NodeVectorClock {
|
||||
///
|
||||
/// ```no_run
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::generate_delta_system;
|
||||
/// use libmarathon::networking::generate_delta_system;
|
||||
///
|
||||
/// App::new().add_systems(Update, generate_delta_system);
|
||||
/// ```
|
||||
@@ -22,7 +22,7 @@ use bevy::prelude::*;
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{
|
||||
/// use libmarathon::networking::{
|
||||
/// NetworkEntityMap,
|
||||
/// NetworkedEntity,
|
||||
/// };
|
||||
@@ -68,7 +68,7 @@ impl NetworkEntityMap {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::NetworkEntityMap;
|
||||
/// use libmarathon::networking::NetworkEntityMap;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// # let mut world = World::new();
|
||||
@@ -102,7 +102,7 @@ impl NetworkEntityMap {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::NetworkEntityMap;
|
||||
/// use libmarathon::networking::NetworkEntityMap;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// # let mut world = World::new();
|
||||
@@ -128,7 +128,7 @@ impl NetworkEntityMap {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::NetworkEntityMap;
|
||||
/// use libmarathon::networking::NetworkEntityMap;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// # let mut world = World::new();
|
||||
@@ -154,7 +154,7 @@ impl NetworkEntityMap {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::NetworkEntityMap;
|
||||
/// use libmarathon::networking::NetworkEntityMap;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// # let mut world = World::new();
|
||||
@@ -183,7 +183,7 @@ impl NetworkEntityMap {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::NetworkEntityMap;
|
||||
/// use libmarathon::networking::NetworkEntityMap;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// # let mut world = World::new();
|
||||
@@ -254,7 +254,7 @@ impl NetworkEntityMap {
|
||||
/// Add this to your app:
|
||||
/// ```no_run
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::register_networked_entities_system;
|
||||
/// use libmarathon::networking::register_networked_entities_system;
|
||||
///
|
||||
/// App::new().add_systems(PostUpdate, register_networked_entities_system);
|
||||
/// ```
|
||||
@@ -278,7 +278,7 @@ pub fn register_networked_entities_system(
|
||||
/// Add this to your app:
|
||||
/// ```no_run
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::cleanup_despawned_entities_system;
|
||||
/// use libmarathon::networking::cleanup_despawned_entities_system;
|
||||
///
|
||||
/// App::new().add_systems(PostUpdate, cleanup_despawned_entities_system);
|
||||
/// ```
|
||||
@@ -44,7 +44,7 @@ use crate::networking::{
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{build_join_request, SessionId, JoinType};
|
||||
/// use libmarathon::networking::{build_join_request, SessionId, JoinType};
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let node_id = Uuid::new_v4();
|
||||
@@ -329,7 +329,7 @@ pub fn apply_full_state(
|
||||
///
|
||||
/// ```no_run
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::handle_join_requests_system;
|
||||
/// use libmarathon::networking::handle_join_requests_system;
|
||||
///
|
||||
/// App::new().add_systems(Update, handle_join_requests_system);
|
||||
/// ```
|
||||
@@ -16,7 +16,7 @@
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use bevy::prelude::*;
|
||||
//! use lib::networking::{EntityLockRegistry, acquire_entity_lock, release_entity_lock};
|
||||
//! use libmarathon::networking::{EntityLockRegistry, acquire_entity_lock, release_entity_lock};
|
||||
//! use uuid::Uuid;
|
||||
//!
|
||||
//! fn my_system(world: &mut World) {
|
||||
@@ -233,22 +233,47 @@ impl EntityLockRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an entity is locked
|
||||
pub fn is_locked(&self, entity_id: Uuid) -> 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<NodeId> {
|
||||
/// 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<NodeId> {
|
||||
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<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
bridge: Option<Res<GossipBridge>>,
|
||||
) {
|
||||
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<Uuid> = 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<EntityLockRegistry>,
|
||||
mut registry: ResMut<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
bridge: Option<Res<GossipBridge>>,
|
||||
) {
|
||||
@@ -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]
|
||||
@@ -45,7 +45,7 @@ pub enum MergeDecision {
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{
|
||||
/// use libmarathon::networking::{
|
||||
/// VectorClock,
|
||||
/// compare_operations_lww,
|
||||
/// };
|
||||
@@ -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);
|
||||
/// ```
|
||||
@@ -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) {
|
||||
@@ -168,7 +168,7 @@ pub fn build_entity_operations(
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{
|
||||
/// use libmarathon::networking::{
|
||||
/// VectorClock,
|
||||
/// build_transform_operation,
|
||||
/// };
|
||||
@@ -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<EntityDelta>) -> 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);
|
||||
/// ```
|
||||
@@ -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();
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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::<CurrentSession>() {
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -183,7 +183,7 @@ pub fn load_config_from_str(toml: &str) -> Result<PersistenceConfig> {
|
||||
///
|
||||
/// # Examples
|
||||
/// ```no_run
|
||||
/// # use lib::persistence::*;
|
||||
/// # use libmarathon::persistence::*;
|
||||
/// # fn example() -> Result<()> {
|
||||
/// let config = load_config_from_file("persistence.toml")?;
|
||||
/// # Ok(())
|
||||
@@ -280,7 +280,7 @@ pub fn flush_to_sqlite(ops: &[PersistenceOp], conn: &mut Connection) -> Result<u
|
||||
/// # Examples
|
||||
/// ```no_run
|
||||
/// # use rusqlite::Connection;
|
||||
/// # use lib::persistence::*;
|
||||
/// # use libmarathon::persistence::*;
|
||||
/// # fn example() -> anyhow::Result<()> {
|
||||
/// let mut conn = Connection::open("app.db")?;
|
||||
/// let info = checkpoint_wal(&mut conn, CheckpointMode::Passive)?;
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
90
crates/libmarathon/src/platform/desktop/event_loop.rs
Normal file
90
crates/libmarathon/src/platform/desktop/event_loop.rs
Normal file
@@ -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<Window>,
|
||||
}
|
||||
|
||||
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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
9
crates/libmarathon/src/platform/desktop/mod.rs
Normal file
9
crates/libmarathon/src/platform/desktop/mod.rs
Normal file
@@ -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};
|
||||
225
crates/libmarathon/src/platform/desktop/winit_bridge.rs
Normal file
225
crates/libmarathon/src/platform/desktop/winit_bridge.rs
Normal file
@@ -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<Vec<RawWinitEvent>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Current input state for tracking drags and modifiers
|
||||
static INPUT_STATE: Mutex<InputState> = 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<InputEvent> {
|
||||
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<InputEvent> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
10
crates/libmarathon/src/platform/ios/mod.rs
Normal file
10
crates/libmarathon/src/platform/ios/mod.rs
Normal file
@@ -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,
|
||||
};
|
||||
103
crates/libmarathon/src/platform/ios/pencil_bridge.rs
Normal file
103
crates/libmarathon/src/platform/ios/pencil_bridge.rs
Normal file
@@ -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<Vec<RawPencilPoint>> = 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<InputEvent> {
|
||||
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<RawPencilPoint> {
|
||||
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
|
||||
}
|
||||
43
crates/libmarathon/src/platform/ios/swift/PencilBridge.h
Normal file
43
crates/libmarathon/src/platform/ios/swift/PencilBridge.h
Normal file
@@ -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 <stdint.h>
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,52 @@
|
||||
import UIKit
|
||||
|
||||
@_cdecl("swift_attach_pencil_capture")
|
||||
func swiftAttachPencilCapture(_ viewPtr: UnsafeMutableRawPointer) {
|
||||
DispatchQueue.main.async {
|
||||
let view = Unmanaged<UIView>.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<UITouch>, with event: UIEvent) {
|
||||
state = .began
|
||||
send(touches, event: event, phase: 0)
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
state = .changed
|
||||
send(touches, event: event, phase: 1)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
state = .ended
|
||||
send(touches, event: event, phase: 2)
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
state = .cancelled
|
||||
send(touches, event: event, phase: 2)
|
||||
}
|
||||
|
||||
private func send(_ touches: Set<UITouch>, 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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
crates/libmarathon/src/platform/mod.rs
Normal file
10
crates/libmarathon/src/platform/mod.rs
Normal file
@@ -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;
|
||||
234
crates/libmarathon/tests/bridge_integration.rs
Normal file
234
crates/libmarathon/tests/bridge_integration.rs
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
use chrono::Datelike;
|
||||
use lib::{
|
||||
use libmarathon::{
|
||||
ChatDb,
|
||||
Result,
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
//! their mathematical properties under all possible inputs and operation
|
||||
//! sequences.
|
||||
|
||||
use lib::{
|
||||
use libmarathon::{
|
||||
networking::{
|
||||
NodeId,
|
||||
VectorClock,
|
||||
@@ -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::<T>() {
|
||||
@@ -1081,8 +1081,8 @@ async fn test_lock_heartbeat_renewal() -> Result<()> {
|
||||
{
|
||||
let registry1 = app1.world().resource::<EntityLockRegistry>();
|
||||
let registry2 = app2.world().resource::<EntityLockRegistry>();
|
||||
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::<EntityLockRegistry>();
|
||||
let registry2 = app2.world().resource::<EntityLockRegistry>();
|
||||
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::<EntityLockRegistry>();
|
||||
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::<EntityLockRegistry>();
|
||||
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::<EntityLockRegistry>();
|
||||
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::<EntityLockRegistry>();
|
||||
!registry2.is_locked(entity_id)
|
||||
!registry2.is_locked(entity_id, node2_id)
|
||||
})
|
||||
.await?;
|
||||
println!("✓ Lock release propagated to node 2");
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
@@ -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::<Self>(),
|
||||
?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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// Basic tests for the Synced derive macro
|
||||
use bevy::prelude::*;
|
||||
use lib::networking::{
|
||||
use libmarathon::networking::{
|
||||
ClockComparison,
|
||||
ComponentMergeDecision,
|
||||
SyncComponent,
|
||||
|
||||
Reference in New Issue
Block a user