initial arhitectural overhaul
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
87
Cargo.lock
generated
87
Cargo.lock
generated
@@ -227,16 +227,20 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
|
"glam 0.29.3",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-gossip",
|
"iroh-gossip",
|
||||||
"lib",
|
"libmarathon",
|
||||||
"objc",
|
"objc",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"raw-window-handle",
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1145,7 +1149,7 @@ dependencies = [
|
|||||||
"approx",
|
"approx",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"derive_more 2.0.1",
|
"derive_more 2.0.1",
|
||||||
"glam",
|
"glam 0.30.9",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"libm",
|
"libm",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
@@ -1320,7 +1324,7 @@ dependencies = [
|
|||||||
"downcast-rs 2.0.2",
|
"downcast-rs 2.0.2",
|
||||||
"erased-serde",
|
"erased-serde",
|
||||||
"foldhash 0.2.0",
|
"foldhash 0.2.0",
|
||||||
"glam",
|
"glam 0.30.9",
|
||||||
"inventory",
|
"inventory",
|
||||||
"petgraph",
|
"petgraph",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2815,7 +2819,7 @@ checksum = "02ba239319a4f60905966390f5e52799d868103a533bb7e27822792332504ddd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"const_panic",
|
"const_panic",
|
||||||
"encase_derive",
|
"encase_derive",
|
||||||
"glam",
|
"glam 0.30.9",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3345,6 +3349,12 @@ dependencies = [
|
|||||||
"xml-rs",
|
"xml-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glam"
|
||||||
|
version = "0.29.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glam"
|
name = "glam"
|
||||||
version = "0.30.9"
|
version = "0.30.9"
|
||||||
@@ -3628,7 +3638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b"
|
checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"constgebra",
|
"constgebra",
|
||||||
"glam",
|
"glam 0.30.9",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4466,36 +4476,6 @@ dependencies = [
|
|||||||
"tinyvec",
|
"tinyvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lib"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"bevy",
|
|
||||||
"bincode",
|
|
||||||
"blake3",
|
|
||||||
"blocking",
|
|
||||||
"chrono",
|
|
||||||
"crdts",
|
|
||||||
"criterion",
|
|
||||||
"futures-lite",
|
|
||||||
"iroh",
|
|
||||||
"iroh-gossip",
|
|
||||||
"proptest",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"rusqlite",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"sync-macros",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.17",
|
|
||||||
"tokio",
|
|
||||||
"toml",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.177"
|
version = "0.2.177"
|
||||||
@@ -4518,6 +4498,40 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libmarathon"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bevy",
|
||||||
|
"bincode",
|
||||||
|
"blake3",
|
||||||
|
"blocking",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"crdts",
|
||||||
|
"criterion",
|
||||||
|
"futures-lite",
|
||||||
|
"glam 0.29.3",
|
||||||
|
"iroh",
|
||||||
|
"iroh-gossip",
|
||||||
|
"proptest",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"sync-macros",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -7014,7 +7028,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bevy",
|
"bevy",
|
||||||
"bincode",
|
"bincode",
|
||||||
"lib",
|
"libmarathon",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7959,6 +7973,7 @@ checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dlib",
|
"dlib",
|
||||||
"log",
|
"log",
|
||||||
|
"once_cell",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/lib", "crates/sync-macros", "crates/app"]
|
members = ["crates/libmarathon", "crates/sync-macros", "crates/app"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ ios = []
|
|||||||
headless = []
|
headless = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lib = { path = "../lib" }
|
libmarathon = { path = "../libmarathon" }
|
||||||
bevy = { version = "0.17", default-features = false, features = [
|
bevy = { version = "0.17", default-features = false, features = [
|
||||||
"bevy_winit",
|
|
||||||
"bevy_render",
|
"bevy_render",
|
||||||
"bevy_core_pipeline",
|
"bevy_core_pipeline",
|
||||||
"bevy_pbr",
|
"bevy_pbr",
|
||||||
@@ -21,12 +20,16 @@ bevy = { version = "0.17", default-features = false, features = [
|
|||||||
"png",
|
"png",
|
||||||
] }
|
] }
|
||||||
bevy_egui = "0.38"
|
bevy_egui = "0.38"
|
||||||
|
glam = "0.29"
|
||||||
|
winit = "0.30"
|
||||||
|
raw-window-handle = "0.6"
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
rand = "0.8"
|
||||||
iroh = { version = "0.95", features = ["discovery-local-network"] }
|
iroh = { version = "0.95", features = ["discovery-local-network"] }
|
||||||
iroh-gossip = "0.95"
|
iroh-gossip = "0.95"
|
||||||
futures-lite = "2.0"
|
futures-lite = "2.0"
|
||||||
@@ -36,6 +39,7 @@ crossbeam-channel = "0.5.15"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "ios")'.dependencies]
|
[target.'cfg(target_os = "ios")'.dependencies]
|
||||||
objc = "0.2"
|
objc = "0.2"
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
iroh = { version = "0.95", features = ["discovery-local-network"] }
|
iroh = { version = "0.95", features = ["discovery-local-network"] }
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
//! Cube entity management
|
//! Cube entity management
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use lib::{
|
use libmarathon::{
|
||||||
networking::{
|
networking::{
|
||||||
|
NetworkEntityMap,
|
||||||
NetworkedEntity,
|
NetworkedEntity,
|
||||||
|
NetworkedSelection,
|
||||||
NetworkedTransform,
|
NetworkedTransform,
|
||||||
|
NodeVectorClock,
|
||||||
Synced,
|
Synced,
|
||||||
},
|
},
|
||||||
persistence::Persisted,
|
persistence::Persisted,
|
||||||
@@ -20,53 +23,79 @@ use uuid::Uuid;
|
|||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
pub struct CubeMarker;
|
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;
|
pub struct CubePlugin;
|
||||||
|
|
||||||
impl Plugin for CubePlugin {
|
impl Plugin for CubePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.register_type::<CubeMarker>()
|
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
|
/// Handle cube spawn messages
|
||||||
fn spawn_cube(
|
fn handle_spawn_cube(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
|
mut messages: MessageReader<SpawnCubeEvent>,
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
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)
|
for event in messages.read() {
|
||||||
let Some(clock) = node_clock else {
|
let entity_id = Uuid::new_v4();
|
||||||
warn!("NodeVectorClock not ready, deferring cube spawn");
|
let node_id = node_clock.node_id;
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let entity_id = Uuid::new_v4();
|
info!("Spawning cube {} at {:?}", entity_id, event.position);
|
||||||
let node_id = clock.node_id;
|
|
||||||
|
|
||||||
info!("Spawning cube with network ID: {}", entity_id);
|
commands.spawn((
|
||||||
|
CubeMarker,
|
||||||
commands.spawn((
|
// Bevy 3D components
|
||||||
CubeMarker,
|
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
|
||||||
// Bevy 3D components
|
MeshMaterial3d(materials.add(StandardMaterial {
|
||||||
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
|
base_color: Color::srgb(0.8, 0.3, 0.6),
|
||||||
MeshMaterial3d(materials.add(StandardMaterial {
|
perceptual_roughness: 0.7,
|
||||||
base_color: Color::srgb(0.8, 0.3, 0.6),
|
metallic: 0.3,
|
||||||
perceptual_roughness: 0.7,
|
..default()
|
||||||
metallic: 0.3,
|
})),
|
||||||
..default()
|
Transform::from_translation(event.position),
|
||||||
})),
|
GlobalTransform::default(),
|
||||||
Transform::from_xyz(0.0, 0.5, 0.0),
|
// Networking
|
||||||
GlobalTransform::default(),
|
NetworkedEntity::with_id(entity_id, node_id),
|
||||||
// Networking
|
NetworkedTransform,
|
||||||
NetworkedEntity::with_id(entity_id, node_id),
|
NetworkedSelection::default(),
|
||||||
NetworkedTransform,
|
// Persistence
|
||||||
// Persistence
|
Persisted::with_id(entity_id),
|
||||||
Persisted::with_id(entity_id),
|
// Sync marker
|
||||||
// Sync marker
|
Synced,
|
||||||
Synced,
|
));
|
||||||
));
|
}
|
||||||
|
}
|
||||||
info!("Cube spawned successfully");
|
|
||||||
|
/// 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,
|
EguiContexts,
|
||||||
EguiPrimaryContextPass,
|
EguiPrimaryContextPass,
|
||||||
};
|
};
|
||||||
use lib::networking::{
|
use libmarathon::networking::{
|
||||||
|
EntityLockRegistry,
|
||||||
GossipBridge,
|
GossipBridge,
|
||||||
|
NetworkedEntity,
|
||||||
NodeVectorClock,
|
NodeVectorClock,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::cube::{CubeMarker, DeleteCubeEvent, SpawnCubeEvent};
|
||||||
|
|
||||||
pub struct DebugUiPlugin;
|
pub struct DebugUiPlugin;
|
||||||
|
|
||||||
impl Plugin for DebugUiPlugin {
|
impl Plugin for DebugUiPlugin {
|
||||||
@@ -24,10 +28,10 @@ fn render_debug_ui(
|
|||||||
mut contexts: EguiContexts,
|
mut contexts: EguiContexts,
|
||||||
node_clock: Option<Res<NodeVectorClock>>,
|
node_clock: Option<Res<NodeVectorClock>>,
|
||||||
gossip_bridge: Option<Res<GossipBridge>>,
|
gossip_bridge: Option<Res<GossipBridge>>,
|
||||||
cube_query: Query<
|
lock_registry: Option<Res<EntityLockRegistry>>,
|
||||||
(&Transform, &lib::networking::NetworkedEntity),
|
cube_query: Query<(&Transform, &NetworkedEntity), With<CubeMarker>>,
|
||||||
With<crate::cube::CubeMarker>,
|
mut spawn_events: MessageWriter<SpawnCubeEvent>,
|
||||||
>,
|
mut delete_events: MessageWriter<DeleteCubeEvent>,
|
||||||
) {
|
) {
|
||||||
let Ok(ctx) = contexts.ctx_mut() else {
|
let Ok(ctx) = contexts.ctx_mut() else {
|
||||||
return;
|
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.add_space(10.0);
|
||||||
ui.heading("Controls");
|
ui.heading("Controls");
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
ui.label("Left click: Select cube");
|
||||||
ui.label("Left drag: Move cube (XY)");
|
ui.label("Left drag: Move cube (XY)");
|
||||||
ui.label("Right drag: Rotate cube");
|
ui.label("Right drag: Rotate cube");
|
||||||
ui.label("Scroll: Move cube (Z)");
|
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::prelude::*;
|
||||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||||
|
use libmarathon::networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock};
|
||||||
|
|
||||||
pub struct MouseInputPlugin;
|
pub struct MouseInputPlugin;
|
||||||
|
|
||||||
@@ -20,13 +21,15 @@ struct MouseState {
|
|||||||
right_pressed: bool,
|
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(
|
fn handle_mouse_input(
|
||||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||||
mut mouse_motion: EventReader<MouseMotion>,
|
mut mouse_motion: MessageReader<MouseMotion>,
|
||||||
mut mouse_wheel: EventReader<MouseWheel>,
|
mut mouse_wheel: MessageReader<MouseWheel>,
|
||||||
mut mouse_state: Local<Option<MouseState>>,
|
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
|
// Initialize mouse state if needed
|
||||||
if mouse_state.is_none() {
|
if mouse_state.is_none() {
|
||||||
@@ -38,42 +41,57 @@ fn handle_mouse_input(
|
|||||||
state.left_pressed = mouse_buttons.pressed(MouseButton::Left);
|
state.left_pressed = mouse_buttons.pressed(MouseButton::Left);
|
||||||
state.right_pressed = mouse_buttons.pressed(MouseButton::Right);
|
state.right_pressed = mouse_buttons.pressed(MouseButton::Right);
|
||||||
|
|
||||||
|
let node_id = node_clock.node_id;
|
||||||
|
|
||||||
// Get total mouse delta this frame
|
// Get total mouse delta this frame
|
||||||
let mut total_delta = Vec2::ZERO;
|
let mut total_delta = Vec2::ZERO;
|
||||||
for motion in mouse_motion.read() {
|
for motion in mouse_motion.read() {
|
||||||
total_delta += motion.delta;
|
total_delta += motion.delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process mouse motion
|
// Process mouse motion - only for cubes locked by us
|
||||||
if total_delta != Vec2::ZERO {
|
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 {
|
if state.left_pressed {
|
||||||
// Left drag: Move cube in XY plane
|
// Left drag: Move cube in XY plane
|
||||||
// Scale factor for sensitivity
|
// Scale factor for sensitivity
|
||||||
let sensitivity = 0.01;
|
let sensitivity = 0.01;
|
||||||
transform.translation.x += total_delta.x * sensitivity;
|
transform.translation.x += total_delta.x * sensitivity;
|
||||||
transform.translation.y -= total_delta.y * sensitivity; // Invert Y
|
transform.translation.y -= total_delta.y * sensitivity; // Invert Y
|
||||||
|
// Change detection will trigger clock tick automatically
|
||||||
} else if state.right_pressed {
|
} else if state.right_pressed {
|
||||||
// Right drag: Rotate cube
|
// Right drag: Rotate cube
|
||||||
let sensitivity = 0.01;
|
let sensitivity = 0.01;
|
||||||
let rotation_x = Quat::from_rotation_y(total_delta.x * sensitivity);
|
let rotation_x = Quat::from_rotation_y(total_delta.x * sensitivity);
|
||||||
let rotation_y = Quat::from_rotation_x(-total_delta.y * sensitivity);
|
let rotation_y = Quat::from_rotation_x(-total_delta.y * sensitivity);
|
||||||
transform.rotation = rotation_x * transform.rotation * rotation_y;
|
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;
|
let mut total_scroll = 0.0;
|
||||||
for wheel in mouse_wheel.read() {
|
for wheel in mouse_wheel.read() {
|
||||||
total_scroll += wheel.y;
|
total_scroll += wheel.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
if total_scroll != 0.0 {
|
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
|
// Scroll: Move in Z axis
|
||||||
let sensitivity = 0.1;
|
let sensitivity = 0.1;
|
||||||
transform.translation.z += total_scroll * sensitivity;
|
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 camera;
|
||||||
pub mod cube;
|
pub mod cube;
|
||||||
pub mod debug_ui;
|
pub mod debug_ui;
|
||||||
|
pub mod engine_bridge;
|
||||||
|
pub mod input;
|
||||||
pub mod rendering;
|
pub mod rendering;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
|
|
||||||
pub use cube::CubeMarker;
|
pub use cube::CubeMarker;
|
||||||
|
pub use engine_bridge::EngineBridgePlugin;
|
||||||
|
|||||||
@@ -3,38 +3,36 @@
|
|||||||
//! This demonstrates real-time CRDT synchronization with Apple Pencil input.
|
//! This demonstrates real-time CRDT synchronization with Apple Pencil input.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_egui::EguiPlugin;
|
// use bevy_egui::EguiPlugin; // Disabled - needs WinitPlugin which we own directly
|
||||||
use lib::{
|
use libmarathon::{
|
||||||
networking::{NetworkingConfig, NetworkingPlugin},
|
engine::{EngineBridge, EngineCore},
|
||||||
persistence::{PersistenceConfig, PersistencePlugin},
|
persistence::{PersistenceConfig, PersistencePlugin},
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
mod camera;
|
mod camera;
|
||||||
mod cube;
|
mod cube;
|
||||||
mod debug_ui;
|
mod debug_ui;
|
||||||
|
mod executor;
|
||||||
|
mod engine_bridge;
|
||||||
mod rendering;
|
mod rendering;
|
||||||
|
mod selection;
|
||||||
|
mod session;
|
||||||
|
mod session_ui;
|
||||||
mod setup;
|
mod setup;
|
||||||
|
|
||||||
#[cfg(not(target_os = "ios"))]
|
use engine_bridge::EngineBridgePlugin;
|
||||||
mod input {
|
|
||||||
pub mod mouse;
|
|
||||||
pub use mouse::MouseInputPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
mod input;
|
||||||
mod input {
|
|
||||||
pub mod pencil;
|
|
||||||
pub use pencil::PencilInputPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
use camera::*;
|
use camera::*;
|
||||||
use cube::*;
|
use cube::*;
|
||||||
use debug_ui::*;
|
use debug_ui::*;
|
||||||
use input::*;
|
use input::*;
|
||||||
use rendering::*;
|
use rendering::*;
|
||||||
use setup::*;
|
use selection::*;
|
||||||
|
use session::*;
|
||||||
|
use session_ui::*;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
@@ -46,56 +44,62 @@ fn main() {
|
|||||||
)
|
)
|
||||||
.init();
|
.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
|
// Database path
|
||||||
let db_path = PathBuf::from("cube_demo.db");
|
let db_path = PathBuf::from("cube_demo.db");
|
||||||
|
let db_path_str = db_path.to_str().unwrap().to_string();
|
||||||
|
|
||||||
// Create Bevy app
|
// Create EngineBridge (for communication between Bevy and EngineCore)
|
||||||
App::new()
|
let (engine_bridge, engine_handle) = EngineBridge::new();
|
||||||
.add_plugins(DefaultPlugins
|
info!("EngineBridge created");
|
||||||
.set(WindowPlugin {
|
|
||||||
primary_window: Some(Window {
|
// Spawn EngineCore on tokio runtime (runs in background thread)
|
||||||
title: format!("Replicated Cube Demo - Node {}", &node_id.to_string()[..8]),
|
std::thread::spawn(move || {
|
||||||
resolution: (1280, 720).into(),
|
info!("Starting EngineCore on tokio runtime...");
|
||||||
..default()
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
}),
|
rt.block_on(async {
|
||||||
..default()
|
let core = EngineCore::new(engine_handle, &db_path_str);
|
||||||
})
|
core.run().await;
|
||||||
.disable::<bevy::log::LogPlugin>() // Disable Bevy's logger, using tracing-subscriber instead
|
});
|
||||||
)
|
});
|
||||||
.add_plugins(EguiPlugin::default())
|
info!("EngineCore spawned in background");
|
||||||
// Networking (bridge will be set up in startup)
|
|
||||||
.add_plugins(NetworkingPlugin::new(NetworkingConfig {
|
// Create Bevy app (without winit - we own the event loop)
|
||||||
node_id,
|
let mut app = App::new();
|
||||||
sync_interval_secs: 1.0,
|
|
||||||
prune_interval_secs: 60.0,
|
// Insert EngineBridge as a resource for Bevy systems to use
|
||||||
tombstone_gc_interval_secs: 300.0,
|
app.insert_resource(engine_bridge);
|
||||||
}))
|
|
||||||
// Persistence
|
// Use DefaultPlugins but disable winit/window/input (we own those)
|
||||||
.add_plugins(PersistencePlugin::with_config(
|
app.add_plugins(
|
||||||
db_path,
|
DefaultPlugins
|
||||||
PersistenceConfig {
|
.build()
|
||||||
flush_interval_secs: 2,
|
.disable::<bevy::log::LogPlugin>() // Using tracing-subscriber
|
||||||
checkpoint_interval_secs: 30,
|
.disable::<bevy::winit::WinitPlugin>() // We own winit
|
||||||
battery_adaptive: true,
|
.disable::<bevy::window::WindowPlugin>() // We own the window
|
||||||
..Default::default()
|
.disable::<bevy::input::InputPlugin>() // We provide InputEvents directly
|
||||||
},
|
.disable::<bevy::gilrs::GilrsPlugin>() // We handle gamepad input ourselves
|
||||||
))
|
);
|
||||||
// Camera
|
|
||||||
.add_plugins(CameraPlugin)
|
// app.add_plugins(EguiPlugin::default()); // Disabled - needs WinitPlugin
|
||||||
// Rendering
|
app.add_plugins(EngineBridgePlugin);
|
||||||
.add_plugins(RenderingPlugin)
|
app.add_plugins(PersistencePlugin::with_config(
|
||||||
// Input
|
db_path,
|
||||||
.add_plugins(MouseInputPlugin)
|
PersistenceConfig {
|
||||||
// Cube management
|
flush_interval_secs: 2,
|
||||||
.add_plugins(CubePlugin)
|
checkpoint_interval_secs: 30,
|
||||||
// Debug UI
|
battery_adaptive: true,
|
||||||
.add_plugins(DebugUiPlugin)
|
..Default::default()
|
||||||
// Gossip networking setup
|
},
|
||||||
.add_systems(Startup, setup_gossip_networking)
|
));
|
||||||
.add_systems(Update, poll_gossip_bridge)
|
app.add_plugins(CameraPlugin);
|
||||||
.run();
|
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 anyhow::Result;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use lib::networking::{GossipBridge, SessionId};
|
use libmarathon::networking::{GossipBridge, SessionId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Session ID to use for network initialization
|
/// Session ID to use for network initialization
|
||||||
@@ -271,7 +271,7 @@ fn spawn_bridge_tasks(
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures_lite::StreamExt;
|
use futures_lite::StreamExt;
|
||||||
use lib::networking::VersionedMessage;
|
use libmarathon::networking::VersionedMessage;
|
||||||
|
|
||||||
let node_id = bridge.node_id();
|
let node_id = bridge.node_id();
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ use iroh_gossip::{
|
|||||||
net::Gossip,
|
net::Gossip,
|
||||||
proto::TopicId,
|
proto::TopicId,
|
||||||
};
|
};
|
||||||
use lib::{
|
use libmarathon::{
|
||||||
networking::{
|
networking::{
|
||||||
GossipBridge,
|
GossipBridge,
|
||||||
NetworkedEntity,
|
NetworkedEntity,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lib"
|
name = "libmarathon"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
@@ -16,13 +16,19 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
|
|||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
bevy.workspace = true
|
bevy.workspace = true
|
||||||
|
glam = "0.29"
|
||||||
|
winit = "0.30"
|
||||||
|
raw-window-handle = "0.6"
|
||||||
bincode = "1.3"
|
bincode = "1.3"
|
||||||
|
bytes = "1.0"
|
||||||
futures-lite = "2.0"
|
futures-lite = "2.0"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
blake3 = "1.5"
|
blake3 = "1.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
blocking = "1.6"
|
blocking = "1.6"
|
||||||
|
iroh = { workspace = true, features = ["discovery-local-network"] }
|
||||||
|
iroh-gossip.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -10,7 +10,7 @@ use criterion::{
|
|||||||
criterion_group,
|
criterion_group,
|
||||||
criterion_main,
|
criterion_main,
|
||||||
};
|
};
|
||||||
use lib::networking::VectorClock;
|
use libmarathon::networking::VectorClock;
|
||||||
|
|
||||||
/// Helper to create a vector clock with N nodes
|
/// Helper to create a vector clock with N nodes
|
||||||
fn create_clock_with_nodes(num_nodes: usize) -> VectorClock {
|
fn create_clock_with_nodes(num_nodes: usize) -> VectorClock {
|
||||||
@@ -10,7 +10,7 @@ use criterion::{
|
|||||||
criterion_group,
|
criterion_group,
|
||||||
criterion_main,
|
criterion_main,
|
||||||
};
|
};
|
||||||
use lib::persistence::{
|
use libmarathon::persistence::{
|
||||||
PersistenceOp,
|
PersistenceOp,
|
||||||
WriteBuffer,
|
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
|
//! # Example
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use lib::ChatDb;
|
//! use libmarathon::ChatDb;
|
||||||
//!
|
//!
|
||||||
//! let db = ChatDb::open("chat.db")?;
|
//! let db = ChatDb::open("chat.db")?;
|
||||||
//!
|
//!
|
||||||
//! // Get all messages from January 2024 to now
|
//! // Get all messages from January 2024 to now
|
||||||
//! let messages = db.get_our_messages(None, None)?;
|
//! let messages = db.get_our_messages(None, None)?;
|
||||||
//! println!("Found {} messages", messages.len());
|
//! println!("Found {} messages", messages.len());
|
||||||
//! # Ok::<(), lib::ChatDbError>(())
|
//! # Ok::<(), libmarathon::ChatDbError>(())
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod db;
|
mod db;
|
||||||
mod error;
|
mod error;
|
||||||
mod models;
|
mod models;
|
||||||
|
pub mod engine;
|
||||||
pub mod networking;
|
pub mod networking;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
|
pub mod platform;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use db::ChatDb;
|
pub use db::ChatDb;
|
||||||
@@ -446,7 +446,7 @@ fn apply_set_operation(
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// App::new().add_systems(Update, receive_and_apply_deltas_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -26,7 +26,7 @@ use crate::networking::error::{
|
|||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::auth::validate_session_secret;
|
/// use libmarathon::networking::auth::validate_session_secret;
|
||||||
///
|
///
|
||||||
/// let secret = b"my_secret_key";
|
/// let secret = b"my_secret_key";
|
||||||
/// assert!(validate_session_secret(secret, secret).is_ok());
|
/// assert!(validate_session_secret(secret, secret).is_ok());
|
||||||
@@ -56,7 +56,7 @@ impl BlobStore {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::BlobStore;
|
/// use libmarathon::networking::BlobStore;
|
||||||
///
|
///
|
||||||
/// let store = BlobStore::new();
|
/// let store = BlobStore::new();
|
||||||
/// let data = vec![1, 2, 3, 4, 5];
|
/// let data = vec![1, 2, 3, 4, 5];
|
||||||
@@ -157,7 +157,7 @@ impl Default for BlobStore {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::should_use_blob;
|
/// use libmarathon::networking::should_use_blob;
|
||||||
///
|
///
|
||||||
/// let small_data = vec![1, 2, 3];
|
/// let small_data = vec![1, 2, 3];
|
||||||
/// assert!(!should_use_blob(&small_data));
|
/// assert!(!should_use_blob(&small_data));
|
||||||
@@ -177,7 +177,7 @@ pub fn should_use_blob(data: &[u8]) -> bool {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// BlobStore,
|
/// BlobStore,
|
||||||
/// create_component_data,
|
/// create_component_data,
|
||||||
/// };
|
/// };
|
||||||
@@ -209,7 +209,7 @@ pub fn create_component_data(data: Vec<u8>, blob_store: &BlobStore) -> Result<Co
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// BlobStore,
|
/// BlobStore,
|
||||||
/// ComponentData,
|
/// ComponentData,
|
||||||
/// get_component_data,
|
/// get_component_data,
|
||||||
@@ -21,7 +21,7 @@ use crate::networking::{
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// App::new().add_systems(Update, auto_detect_transform_changes_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -36,7 +36,7 @@ use crate::networking::vector_clock::NodeId;
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::NetworkedEntity;
|
/// use libmarathon::networking::NetworkedEntity;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// fn spawn_networked_entity(mut commands: Commands) {
|
/// fn spawn_networked_entity(mut commands: Commands) {
|
||||||
@@ -72,7 +72,7 @@ impl NetworkedEntity {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::NetworkedEntity;
|
/// use libmarathon::networking::NetworkedEntity;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node_id = Uuid::new_v4();
|
/// let node_id = Uuid::new_v4();
|
||||||
@@ -95,7 +95,7 @@ impl NetworkedEntity {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::NetworkedEntity;
|
/// use libmarathon::networking::NetworkedEntity;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let network_id = Uuid::new_v4();
|
/// let network_id = Uuid::new_v4();
|
||||||
@@ -136,7 +136,7 @@ impl Default for NetworkedEntity {
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// NetworkedEntity,
|
/// NetworkedEntity,
|
||||||
/// NetworkedTransform,
|
/// NetworkedTransform,
|
||||||
/// };
|
/// };
|
||||||
@@ -171,7 +171,7 @@ pub struct NetworkedTransform;
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// NetworkedEntity,
|
/// NetworkedEntity,
|
||||||
/// NetworkedSelection,
|
/// NetworkedSelection,
|
||||||
/// };
|
/// };
|
||||||
@@ -253,7 +253,7 @@ impl NetworkedSelection {
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// NetworkedDrawingPath,
|
/// NetworkedDrawingPath,
|
||||||
/// NetworkedEntity,
|
/// NetworkedEntity,
|
||||||
/// };
|
/// };
|
||||||
@@ -61,7 +61,7 @@ impl NodeVectorClock {
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::generate_delta_system;
|
/// use libmarathon::networking::generate_delta_system;
|
||||||
///
|
///
|
||||||
/// App::new().add_systems(Update, generate_delta_system);
|
/// App::new().add_systems(Update, generate_delta_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -22,7 +22,7 @@ use bevy::prelude::*;
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// NetworkEntityMap,
|
/// NetworkEntityMap,
|
||||||
/// NetworkedEntity,
|
/// NetworkedEntity,
|
||||||
/// };
|
/// };
|
||||||
@@ -68,7 +68,7 @@ impl NetworkEntityMap {
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::NetworkEntityMap;
|
/// use libmarathon::networking::NetworkEntityMap;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// # let mut world = World::new();
|
/// # let mut world = World::new();
|
||||||
@@ -102,7 +102,7 @@ impl NetworkEntityMap {
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::NetworkEntityMap;
|
/// use libmarathon::networking::NetworkEntityMap;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// # let mut world = World::new();
|
/// # let mut world = World::new();
|
||||||
@@ -128,7 +128,7 @@ impl NetworkEntityMap {
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::NetworkEntityMap;
|
/// use libmarathon::networking::NetworkEntityMap;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// # let mut world = World::new();
|
/// # let mut world = World::new();
|
||||||
@@ -154,7 +154,7 @@ impl NetworkEntityMap {
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::NetworkEntityMap;
|
/// use libmarathon::networking::NetworkEntityMap;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// # let mut world = World::new();
|
/// # let mut world = World::new();
|
||||||
@@ -183,7 +183,7 @@ impl NetworkEntityMap {
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::NetworkEntityMap;
|
/// use libmarathon::networking::NetworkEntityMap;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// # let mut world = World::new();
|
/// # let mut world = World::new();
|
||||||
@@ -254,7 +254,7 @@ impl NetworkEntityMap {
|
|||||||
/// Add this to your app:
|
/// Add this to your app:
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// App::new().add_systems(PostUpdate, register_networked_entities_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -278,7 +278,7 @@ pub fn register_networked_entities_system(
|
|||||||
/// Add this to your app:
|
/// Add this to your app:
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// App::new().add_systems(PostUpdate, cleanup_despawned_entities_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -44,7 +44,7 @@ use crate::networking::{
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::{build_join_request, SessionId, JoinType};
|
/// use libmarathon::networking::{build_join_request, SessionId, JoinType};
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node_id = Uuid::new_v4();
|
/// let node_id = Uuid::new_v4();
|
||||||
@@ -329,7 +329,7 @@ pub fn apply_full_state(
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// App::new().add_systems(Update, handle_join_requests_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use bevy::prelude::*;
|
//! 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;
|
//! use uuid::Uuid;
|
||||||
//!
|
//!
|
||||||
//! fn my_system(world: &mut World) {
|
//! fn my_system(world: &mut World) {
|
||||||
@@ -233,22 +233,47 @@ impl EntityLockRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an entity is locked
|
/// Check if an entity is locked by any node
|
||||||
pub fn is_locked(&self, entity_id: Uuid) -> bool {
|
///
|
||||||
self.locks.get(&entity_id).map_or(false, |lock| !lock.is_expired())
|
/// 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
|
/// 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
|
/// Takes the local node ID to properly handle expiration:
|
||||||
.get(&entity_id)
|
/// - If checking our own lock, ignore expiration (held exactly as long as selected)
|
||||||
.map_or(false, |lock| !lock.is_expired() && lock.holder == node_id)
|
/// - 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)
|
/// Get the holder of a lock (if locked and not expired)
|
||||||
pub fn get_holder(&self, entity_id: Uuid) -> Option<NodeId> {
|
///
|
||||||
|
/// 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| {
|
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)
|
Some(lock.holder)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -320,7 +345,7 @@ impl EntityLockRegistry {
|
|||||||
/// Add to your app as an Update system:
|
/// Add to your app as an Update system:
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// 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)
|
/// System to clean up expired locks (crash recovery)
|
||||||
///
|
///
|
||||||
/// This system periodically removes locks that have exceeded their timeout
|
/// This system periodically removes locks that have exceeded their timeout
|
||||||
/// duration (default 5 seconds). This provides crash recovery - if a node
|
/// duration (default 5 seconds). This provides crash recovery - if a **remote**
|
||||||
/// crashes while holding a lock, it will eventually expire.
|
/// 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:
|
/// Add to your app as an Update system:
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// App::new().add_systems(Update, cleanup_expired_locks_system);
|
||||||
/// ```
|
/// ```
|
||||||
pub fn cleanup_expired_locks_system(
|
pub fn cleanup_expired_locks_system(
|
||||||
mut registry: ResMut<EntityLockRegistry>,
|
mut registry: ResMut<EntityLockRegistry>,
|
||||||
|
node_clock: Res<NodeVectorClock>,
|
||||||
bridge: Option<Res<GossipBridge>>,
|
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() {
|
if !expired.is_empty() {
|
||||||
info!("Cleaning up {} expired locks", expired.len());
|
info!("Cleaning up {} expired remote locks", expired.len());
|
||||||
|
|
||||||
for entity_id in expired {
|
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);
|
registry.force_release(entity_id);
|
||||||
|
|
||||||
// Broadcast LockReleased
|
// Broadcast LockReleased
|
||||||
@@ -404,7 +446,7 @@ pub fn cleanup_expired_locks_system(
|
|||||||
if let Err(e) = bridge.send(msg) {
|
if let Err(e) = bridge.send(msg) {
|
||||||
error!("Failed to broadcast LockReleased for expired lock: {}", e);
|
error!("Failed to broadcast LockReleased for expired lock: {}", e);
|
||||||
} else {
|
} 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::prelude::*;
|
||||||
/// use bevy::time::common_conditions::on_timer;
|
/// use bevy::time::common_conditions::on_timer;
|
||||||
/// use std::time::Duration;
|
/// use std::time::Duration;
|
||||||
/// use lib::networking::broadcast_lock_heartbeats_system;
|
/// use libmarathon::networking::broadcast_lock_heartbeats_system;
|
||||||
///
|
///
|
||||||
/// App::new().add_systems(Update,
|
/// App::new().add_systems(Update,
|
||||||
/// broadcast_lock_heartbeats_system.run_if(on_timer(Duration::from_secs(1)))
|
/// broadcast_lock_heartbeats_system.run_if(on_timer(Duration::from_secs(1)))
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
pub fn broadcast_lock_heartbeats_system(
|
pub fn broadcast_lock_heartbeats_system(
|
||||||
registry: Res<EntityLockRegistry>,
|
mut registry: ResMut<EntityLockRegistry>,
|
||||||
node_clock: Res<NodeVectorClock>,
|
node_clock: Res<NodeVectorClock>,
|
||||||
bridge: Option<Res<GossipBridge>>,
|
bridge: Option<Res<GossipBridge>>,
|
||||||
) {
|
) {
|
||||||
@@ -449,7 +491,13 @@ pub fn broadcast_lock_heartbeats_system(
|
|||||||
|
|
||||||
debug!("Broadcasting {} lock heartbeats", our_locks.len());
|
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 {
|
if let Some(ref bridge) = bridge {
|
||||||
for entity_id in our_locks {
|
for entity_id in our_locks {
|
||||||
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockHeartbeat {
|
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockHeartbeat {
|
||||||
@@ -481,9 +529,9 @@ mod tests {
|
|||||||
|
|
||||||
// Should acquire successfully
|
// Should acquire successfully
|
||||||
assert!(registry.try_acquire(entity_id, node_id).is_ok());
|
assert!(registry.try_acquire(entity_id, node_id).is_ok());
|
||||||
assert!(registry.is_locked(entity_id));
|
assert!(registry.is_locked(entity_id, node_id));
|
||||||
assert!(registry.is_locked_by(entity_id, node_id));
|
assert!(registry.is_locked_by(entity_id, node_id, node_id));
|
||||||
assert_eq!(registry.get_holder(entity_id), Some(node_id));
|
assert_eq!(registry.get_holder(entity_id, node_id), Some(node_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -509,7 +557,7 @@ mod tests {
|
|||||||
// Acquire and release
|
// Acquire and release
|
||||||
registry.try_acquire(entity_id, node_id).unwrap();
|
registry.try_acquire(entity_id, node_id).unwrap();
|
||||||
assert!(registry.release(entity_id, node_id));
|
assert!(registry.release(entity_id, node_id));
|
||||||
assert!(!registry.is_locked(entity_id));
|
assert!(!registry.is_locked(entity_id, node_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -524,8 +572,8 @@ mod tests {
|
|||||||
|
|
||||||
// Node 2 cannot release
|
// Node 2 cannot release
|
||||||
assert!(!registry.release(entity_id, node2));
|
assert!(!registry.release(entity_id, node2));
|
||||||
assert!(registry.is_locked(entity_id));
|
assert!(registry.is_locked(entity_id, node2));
|
||||||
assert!(registry.is_locked_by(entity_id, node1));
|
assert!(registry.is_locked_by(entity_id, node1, node2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -556,7 +604,7 @@ mod tests {
|
|||||||
|
|
||||||
registry.try_acquire(entity_id, node_id).unwrap();
|
registry.try_acquire(entity_id, node_id).unwrap();
|
||||||
registry.force_release(entity_id);
|
registry.force_release(entity_id);
|
||||||
assert!(!registry.is_locked(entity_id));
|
assert!(!registry.is_locked(entity_id, node_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -45,7 +45,7 @@ pub enum MergeDecision {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// VectorClock,
|
/// VectorClock,
|
||||||
/// compare_operations_lww,
|
/// compare_operations_lww,
|
||||||
/// };
|
/// };
|
||||||
@@ -48,7 +48,7 @@ use crate::networking::{
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::message_dispatcher_system;
|
/// use libmarathon::networking::message_dispatcher_system;
|
||||||
///
|
///
|
||||||
/// App::new().add_systems(Update, message_dispatcher_system);
|
/// App::new().add_systems(Update, message_dispatcher_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
//! # Example
|
//! # Example
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! ```
|
||||||
//! use lib::networking::*;
|
//! use libmarathon::networking::*;
|
||||||
//! use uuid::Uuid;
|
//! use uuid::Uuid;
|
||||||
//!
|
//!
|
||||||
//! // Create a vector clock and track operations
|
//! // Create a vector clock and track operations
|
||||||
@@ -101,7 +101,7 @@ pub use vector_clock::*;
|
|||||||
/// # Example
|
/// # Example
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::spawn_networked_entity;
|
/// use libmarathon::networking::spawn_networked_entity;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// fn my_system(world: &mut World) {
|
/// fn my_system(world: &mut World) {
|
||||||
@@ -168,7 +168,7 @@ pub fn build_entity_operations(
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// VectorClock,
|
/// VectorClock,
|
||||||
/// build_transform_operation,
|
/// build_transform_operation,
|
||||||
/// };
|
/// };
|
||||||
@@ -89,7 +89,7 @@ impl OperationLog {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// EntityDelta,
|
/// EntityDelta,
|
||||||
/// OperationLog,
|
/// OperationLog,
|
||||||
/// VectorClock,
|
/// VectorClock,
|
||||||
@@ -230,7 +230,7 @@ impl Default for OperationLog {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// VectorClock,
|
/// VectorClock,
|
||||||
/// build_sync_request,
|
/// build_sync_request,
|
||||||
/// };
|
/// };
|
||||||
@@ -263,7 +263,7 @@ pub fn build_missing_deltas(deltas: Vec<EntityDelta>) -> VersionedMessage {
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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);
|
/// App::new().add_systems(Update, handle_sync_requests_system);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
//! ## Example
|
//! ## Example
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! ```
|
||||||
//! use lib::networking::{
|
//! use libmarathon::networking::{
|
||||||
//! OrElement,
|
//! OrElement,
|
||||||
//! OrSet,
|
//! OrSet,
|
||||||
//! };
|
//! };
|
||||||
@@ -116,7 +116,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::OrSet;
|
/// use libmarathon::networking::OrSet;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -143,7 +143,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::OrSet;
|
/// use libmarathon::networking::OrSet;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -190,7 +190,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::OrSet;
|
/// use libmarathon::networking::OrSet;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -234,7 +234,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::OrSet;
|
/// use libmarathon::networking::OrSet;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node1 = Uuid::new_v4();
|
/// let node1 = Uuid::new_v4();
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use bevy::prelude::*;
|
//! use bevy::prelude::*;
|
||||||
//! use lib::networking::{
|
//! use libmarathon::networking::{
|
||||||
//! NetworkingConfig,
|
//! NetworkingConfig,
|
||||||
//! NetworkingPlugin,
|
//! NetworkingPlugin,
|
||||||
//! };
|
//! };
|
||||||
@@ -116,7 +116,7 @@ impl Default for NetworkingConfig {
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// NetworkingPlugin,
|
/// NetworkingPlugin,
|
||||||
/// SessionSecret,
|
/// SessionSecret,
|
||||||
/// };
|
/// };
|
||||||
@@ -192,7 +192,7 @@ impl SessionSecret {
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// NetworkingConfig,
|
/// NetworkingConfig,
|
||||||
/// NetworkingPlugin,
|
/// NetworkingPlugin,
|
||||||
/// };
|
/// };
|
||||||
@@ -293,8 +293,13 @@ impl Plugin for NetworkingPlugin {
|
|||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Last schedule - save session state on shutdown
|
// Auto-save session state every 5 seconds
|
||||||
app.add_systems(Last, save_session_on_shutdown_system);
|
app.add_systems(
|
||||||
|
Last,
|
||||||
|
save_session_on_shutdown_system.run_if(bevy::time::common_conditions::on_timer(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"NetworkingPlugin initialized for node {}",
|
"NetworkingPlugin initialized for node {}",
|
||||||
@@ -315,7 +320,7 @@ impl Plugin for NetworkingPlugin {
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::NetworkingAppExt;
|
/// use libmarathon::networking::NetworkingAppExt;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// App::new()
|
/// App::new()
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
//! ## Example
|
//! ## Example
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! ```
|
||||||
//! use lib::networking::Rga;
|
//! use libmarathon::networking::Rga;
|
||||||
//! use uuid::Uuid;
|
//! use uuid::Uuid;
|
||||||
//!
|
//!
|
||||||
//! let node1 = Uuid::new_v4();
|
//! let node1 = Uuid::new_v4();
|
||||||
@@ -115,7 +115,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::Rga;
|
/// use libmarathon::networking::Rga;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -153,7 +153,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::Rga;
|
/// use libmarathon::networking::Rga;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -227,7 +227,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::Rga;
|
/// use libmarathon::networking::Rga;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -301,7 +301,7 @@ where
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::Rga;
|
/// use libmarathon::networking::Rga;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node1 = Uuid::new_v4();
|
/// let node1 = Uuid::new_v4();
|
||||||
@@ -47,7 +47,7 @@ use crate::{
|
|||||||
/// Add to your app as a Startup system AFTER setup_persistence:
|
/// Add to your app as a Startup system AFTER setup_persistence:
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::initialize_session_system;
|
/// use libmarathon::networking::initialize_session_system;
|
||||||
///
|
///
|
||||||
/// App::new()
|
/// App::new()
|
||||||
/// .add_systems(Startup, initialize_session_system);
|
/// .add_systems(Startup, initialize_session_system);
|
||||||
@@ -136,21 +136,24 @@ pub fn initialize_session_system(world: &mut World) {
|
|||||||
world.insert_resource(current_session);
|
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
|
/// This system periodically saves session state to persist it for auto-rejoin
|
||||||
/// for auto-rejoin on next startup.
|
/// 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
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// 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()
|
/// 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) {
|
pub fn save_session_on_shutdown_system(world: &mut World) {
|
||||||
info!("Saving session state on shutdown...");
|
debug!("Auto-saving session state...");
|
||||||
|
|
||||||
// Get current session
|
// Get current session
|
||||||
let current_session = match world.get_resource::<CurrentSession>() {
|
let current_session = match world.get_resource::<CurrentSession>() {
|
||||||
@@ -48,7 +48,7 @@ pub enum ComponentMergeDecision {
|
|||||||
/// # Example
|
/// # Example
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::{
|
/// use libmarathon::networking::{
|
||||||
/// ClockComparison,
|
/// ClockComparison,
|
||||||
/// ComponentMergeDecision,
|
/// ComponentMergeDecision,
|
||||||
/// SyncComponent,
|
/// SyncComponent,
|
||||||
@@ -105,7 +105,7 @@ pub trait SyncComponent: Component + Reflect + Sized {
|
|||||||
/// # Example
|
/// # Example
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::Synced;
|
/// use libmarathon::networking::Synced;
|
||||||
/// use sync_macros::Synced as SyncedDerive;
|
/// use sync_macros::Synced as SyncedDerive;
|
||||||
///
|
///
|
||||||
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize, SyncedDerive)]
|
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize, SyncedDerive)]
|
||||||
@@ -138,7 +138,7 @@ pub struct Synced;
|
|||||||
/// # Example
|
/// # Example
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::DiagnoseSync;
|
/// use libmarathon::networking::DiagnoseSync;
|
||||||
///
|
///
|
||||||
/// let mut world = World::new();
|
/// let mut world = World::new();
|
||||||
/// let entity = world.spawn_empty().id();
|
/// let entity = world.spawn_empty().id();
|
||||||
@@ -203,7 +203,7 @@ impl TombstoneRegistry {
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::ToDelete;
|
/// use libmarathon::networking::ToDelete;
|
||||||
///
|
///
|
||||||
/// fn delete_entity_system(mut commands: Commands, entity: Entity) {
|
/// fn delete_entity_system(mut commands: Commands, entity: Entity) {
|
||||||
/// commands.entity(entity).insert(ToDelete);
|
/// commands.entity(entity).insert(ToDelete);
|
||||||
@@ -35,7 +35,7 @@ pub type NodeId = uuid::Uuid;
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::VectorClock;
|
/// use libmarathon::networking::VectorClock;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node1 = Uuid::new_v4();
|
/// let node1 = Uuid::new_v4();
|
||||||
@@ -76,7 +76,7 @@ impl VectorClock {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::VectorClock;
|
/// use libmarathon::networking::VectorClock;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -109,7 +109,7 @@ impl VectorClock {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::VectorClock;
|
/// use libmarathon::networking::VectorClock;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node1 = Uuid::new_v4();
|
/// let node1 = Uuid::new_v4();
|
||||||
@@ -141,7 +141,7 @@ impl VectorClock {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::VectorClock;
|
/// use libmarathon::networking::VectorClock;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node = Uuid::new_v4();
|
/// let node = Uuid::new_v4();
|
||||||
@@ -195,7 +195,7 @@ impl VectorClock {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use lib::networking::VectorClock;
|
/// use libmarathon::networking::VectorClock;
|
||||||
/// use uuid::Uuid;
|
/// use uuid::Uuid;
|
||||||
///
|
///
|
||||||
/// let node1 = Uuid::new_v4();
|
/// let node1 = Uuid::new_v4();
|
||||||
@@ -183,7 +183,7 @@ pub fn load_config_from_str(toml: &str) -> Result<PersistenceConfig> {
|
|||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # use lib::persistence::*;
|
/// # use libmarathon::persistence::*;
|
||||||
/// # fn example() -> Result<()> {
|
/// # fn example() -> Result<()> {
|
||||||
/// let config = load_config_from_file("persistence.toml")?;
|
/// let config = load_config_from_file("persistence.toml")?;
|
||||||
/// # Ok(())
|
/// # Ok(())
|
||||||
@@ -280,7 +280,7 @@ pub fn flush_to_sqlite(ops: &[PersistenceOp], conn: &mut Connection) -> Result<u
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # use rusqlite::Connection;
|
/// # use rusqlite::Connection;
|
||||||
/// # use lib::persistence::*;
|
/// # use libmarathon::persistence::*;
|
||||||
/// # fn example() -> anyhow::Result<()> {
|
/// # fn example() -> anyhow::Result<()> {
|
||||||
/// let mut conn = Connection::open("app.db")?;
|
/// let mut conn = Connection::open("app.db")?;
|
||||||
/// let info = checkpoint_wal(&mut conn, CheckpointMode::Passive)?;
|
/// let info = checkpoint_wal(&mut conn, CheckpointMode::Passive)?;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use bevy::prelude::*;
|
//! use bevy::prelude::*;
|
||||||
//! use lib::persistence::*;
|
//! use libmarathon::persistence::*;
|
||||||
//!
|
//!
|
||||||
//! fn setup(mut commands: Commands) {
|
//! fn setup(mut commands: Commands) {
|
||||||
//! // Spawn an entity with the Persisted marker
|
//! // Spawn an entity with the Persisted marker
|
||||||
@@ -21,7 +21,7 @@ use crate::persistence::*;
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::persistence::PersistencePlugin;
|
/// use libmarathon::persistence::PersistencePlugin;
|
||||||
///
|
///
|
||||||
/// App::new()
|
/// App::new()
|
||||||
/// .add_plugins(PersistencePlugin::new("app.db"))
|
/// .add_plugins(PersistencePlugin::new("app.db"))
|
||||||
@@ -250,7 +250,7 @@ fn collect_dirty_entities_bevy_system(world: &mut World) {
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # use bevy::prelude::*;
|
/// # use bevy::prelude::*;
|
||||||
/// # use lib::persistence::*;
|
/// # use libmarathon::persistence::*;
|
||||||
/// App::new()
|
/// App::new()
|
||||||
/// .add_plugins(PersistencePlugin::new("app.db"))
|
/// .add_plugins(PersistencePlugin::new("app.db"))
|
||||||
/// .add_systems(Update, auto_track_transform_changes_system)
|
/// .add_systems(Update, auto_track_transform_changes_system)
|
||||||
@@ -37,7 +37,7 @@ use crate::persistence::error::{
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # use bevy::prelude::*;
|
/// # use bevy::prelude::*;
|
||||||
/// # use lib::persistence::*;
|
/// # use libmarathon::persistence::*;
|
||||||
/// fn update_position(mut query: Query<(&mut Transform, &mut Persisted)>) {
|
/// fn update_position(mut query: Query<(&mut Transform, &mut Persisted)>) {
|
||||||
/// for (mut transform, mut persisted) in query.iter_mut() {
|
/// for (mut transform, mut persisted) in query.iter_mut() {
|
||||||
/// transform.translation.x += 1.0;
|
/// transform.translation.x += 1.0;
|
||||||
@@ -92,7 +92,7 @@ pub trait Persistable: Component + Reflect {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # use bevy::prelude::*;
|
/// # use bevy::prelude::*;
|
||||||
/// # use lib::persistence::*;
|
/// # use libmarathon::persistence::*;
|
||||||
/// # fn example(component: &Transform, registry: &AppTypeRegistry) -> anyhow::Result<()> {
|
/// # fn example(component: &Transform, registry: &AppTypeRegistry) -> anyhow::Result<()> {
|
||||||
/// let registry = registry.read();
|
/// let registry = registry.read();
|
||||||
/// let bytes = serialize_component(component.as_reflect(), ®istry)?;
|
/// let bytes = serialize_component(component.as_reflect(), ®istry)?;
|
||||||
@@ -141,7 +141,7 @@ pub fn serialize_component_typed(
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # use bevy::prelude::*;
|
/// # use bevy::prelude::*;
|
||||||
/// # use lib::persistence::*;
|
/// # use libmarathon::persistence::*;
|
||||||
/// # fn example(bytes: &[u8], registry: &AppTypeRegistry) -> anyhow::Result<()> {
|
/// # fn example(bytes: &[u8], registry: &AppTypeRegistry) -> anyhow::Result<()> {
|
||||||
/// let registry = registry.read();
|
/// let registry = registry.read();
|
||||||
/// let reflected = deserialize_component(bytes, ®istry)?;
|
/// let reflected = deserialize_component(bytes, ®istry)?;
|
||||||
@@ -204,7 +204,7 @@ pub fn deserialize_component_typed(
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # use bevy::prelude::*;
|
/// # use bevy::prelude::*;
|
||||||
/// # use lib::persistence::*;
|
/// # use libmarathon::persistence::*;
|
||||||
/// # fn example(entity: Entity, world: &World, registry: &AppTypeRegistry) -> Option<()> {
|
/// # fn example(entity: Entity, world: &World, registry: &AppTypeRegistry) -> Option<()> {
|
||||||
/// let registry = registry.read();
|
/// let registry = registry.read();
|
||||||
/// let bytes = serialize_component_from_entity(
|
/// 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
|
//! Tests the gossip bridge channel infrastructure. Full iroh-gossip integration
|
||||||
//! will be tested in Phase 3.5.
|
//! will be tested in Phase 3.5.
|
||||||
|
|
||||||
use lib::networking::*;
|
use libmarathon::networking::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gossip_bridge_creation() {
|
fn test_gossip_bridge_creation() {
|
||||||
@@ -15,7 +15,7 @@ fn test_gossip_bridge_creation() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gossip_bridge_send() {
|
fn test_gossip_bridge_send() {
|
||||||
use lib::networking::{
|
use libmarathon::networking::{
|
||||||
JoinType,
|
JoinType,
|
||||||
SessionId,
|
SessionId,
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use lib::{
|
use libmarathon::{
|
||||||
ChatDb,
|
ChatDb,
|
||||||
Result,
|
Result,
|
||||||
};
|
};
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
//! their mathematical properties under all possible inputs and operation
|
//! their mathematical properties under all possible inputs and operation
|
||||||
//! sequences.
|
//! sequences.
|
||||||
|
|
||||||
use lib::{
|
use libmarathon::{
|
||||||
networking::{
|
networking::{
|
||||||
NodeId,
|
NodeId,
|
||||||
VectorClock,
|
VectorClock,
|
||||||
@@ -39,7 +39,7 @@ use iroh_gossip::{
|
|||||||
net::Gossip,
|
net::Gossip,
|
||||||
proto::TopicId,
|
proto::TopicId,
|
||||||
};
|
};
|
||||||
use lib::{
|
use libmarathon::{
|
||||||
networking::{
|
networking::{
|
||||||
EntityLockRegistry,
|
EntityLockRegistry,
|
||||||
GossipBridge,
|
GossipBridge,
|
||||||
@@ -175,7 +175,7 @@ mod test_utils {
|
|||||||
let data = data_result.optional()?;
|
let data = data_result.optional()?;
|
||||||
|
|
||||||
if let Some(bytes) = data {
|
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)?;
|
let reflected = deserialize_component_typed(&bytes, component_type, type_registry)?;
|
||||||
|
|
||||||
if let Some(concrete) = reflected.try_downcast_ref::<T>() {
|
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 registry1 = app1.world().resource::<EntityLockRegistry>();
|
||||||
let registry2 = app2.world().resource::<EntityLockRegistry>();
|
let registry2 = app2.world().resource::<EntityLockRegistry>();
|
||||||
assert!(registry1.is_locked(entity_id), "Lock should exist on node 1");
|
assert!(registry1.is_locked(entity_id, node1_id), "Lock should exist on node 1");
|
||||||
assert!(registry2.is_locked(entity_id), "Lock should exist on node 2");
|
assert!(registry2.is_locked(entity_id, node2_id), "Lock should exist on node 2");
|
||||||
println!("✓ Lock acquired on both nodes");
|
println!("✓ Lock acquired on both nodes");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,12 +1120,12 @@ async fn test_lock_heartbeat_renewal() -> Result<()> {
|
|||||||
let registry1 = app1.world().resource::<EntityLockRegistry>();
|
let registry1 = app1.world().resource::<EntityLockRegistry>();
|
||||||
let registry2 = app2.world().resource::<EntityLockRegistry>();
|
let registry2 = app2.world().resource::<EntityLockRegistry>();
|
||||||
assert!(
|
assert!(
|
||||||
registry1.is_locked(entity_id),
|
registry1.is_locked(entity_id, node1_id),
|
||||||
"Lock should persist on node 1 after heartbeat {}",
|
"Lock should persist on node 1 after heartbeat {}",
|
||||||
i + 1
|
i + 1
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
registry2.is_locked(entity_id),
|
registry2.is_locked(entity_id, node2_id),
|
||||||
"Lock should persist on node 2 after heartbeat {}",
|
"Lock should persist on node 2 after heartbeat {}",
|
||||||
i + 1
|
i + 1
|
||||||
);
|
);
|
||||||
@@ -1205,7 +1205,7 @@ async fn test_lock_heartbeat_expiration() -> Result<()> {
|
|||||||
// Verify lock acquired
|
// Verify lock acquired
|
||||||
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| {
|
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| {
|
||||||
let registry2 = w2.resource::<EntityLockRegistry>();
|
let registry2 = w2.resource::<EntityLockRegistry>();
|
||||||
registry2.is_locked(entity_id)
|
registry2.is_locked(entity_id, node2_id)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
println!("✓ Lock acquired and propagated");
|
println!("✓ Lock acquired and propagated");
|
||||||
@@ -1236,7 +1236,7 @@ async fn test_lock_heartbeat_expiration() -> Result<()> {
|
|||||||
{
|
{
|
||||||
let registry = app2.world().resource::<EntityLockRegistry>();
|
let registry = app2.world().resource::<EntityLockRegistry>();
|
||||||
assert!(
|
assert!(
|
||||||
!registry.is_locked(entity_id),
|
!registry.is_locked(entity_id, node2_id),
|
||||||
"Lock should be expired on node 2 after cleanup"
|
"Lock should be expired on node 2 after cleanup"
|
||||||
);
|
);
|
||||||
println!("✓ Lock expired on node 2 after 5 seconds without heartbeat");
|
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 lock to propagate
|
||||||
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| {
|
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| {
|
||||||
let registry2 = w2.resource::<EntityLockRegistry>();
|
let registry2 = w2.resource::<EntityLockRegistry>();
|
||||||
registry2.is_locked(entity_id)
|
registry2.is_locked(entity_id, node2_id)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
println!("✓ Lock acquired and propagated");
|
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 release to propagate to node 2
|
||||||
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(3), |_, w2| {
|
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(3), |_, w2| {
|
||||||
let registry2 = w2.resource::<EntityLockRegistry>();
|
let registry2 = w2.resource::<EntityLockRegistry>();
|
||||||
!registry2.is_locked(entity_id)
|
!registry2.is_locked(entity_id, node2_id)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
println!("✓ Lock release propagated to node 2");
|
println!("✓ Lock release propagated to node 2");
|
||||||
@@ -6,7 +6,7 @@ use std::sync::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use lib::networking::{
|
use libmarathon::networking::{
|
||||||
NetworkedEntity,
|
NetworkedEntity,
|
||||||
NetworkedTransform,
|
NetworkedTransform,
|
||||||
Synced,
|
Synced,
|
||||||
@@ -21,7 +21,7 @@ fn test_transform_change_detection_basic() {
|
|||||||
// Add the auto_detect system
|
// Add the auto_detect system
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
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
|
// 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"
|
proc-macro2 = "1.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
lib = { path = "../lib" }
|
libmarathon = { path = "../libmarathon" }
|
||||||
bevy = { workspace = true }
|
bevy = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
bincode = "1.3"
|
bincode = "1.3"
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ impl SyncStrategy {
|
|||||||
fn to_tokens(&self) -> proc_macro2::TokenStream {
|
fn to_tokens(&self) -> proc_macro2::TokenStream {
|
||||||
match self {
|
match self {
|
||||||
| SyncStrategy::LastWriteWins => {
|
| SyncStrategy::LastWriteWins => {
|
||||||
quote! { lib::networking::SyncStrategy::LastWriteWins }
|
quote! { libmarathon::networking::SyncStrategy::LastWriteWins }
|
||||||
},
|
},
|
||||||
| SyncStrategy::Set => quote! { lib::networking::SyncStrategy::Set },
|
| SyncStrategy::Set => quote! { libmarathon::networking::SyncStrategy::Set },
|
||||||
| SyncStrategy::Sequence => quote! { lib::networking::SyncStrategy::Sequence },
|
| SyncStrategy::Sequence => quote! { libmarathon::networking::SyncStrategy::Sequence },
|
||||||
| SyncStrategy::Custom => quote! { lib::networking::SyncStrategy::Custom },
|
| SyncStrategy::Custom => quote! { libmarathon::networking::SyncStrategy::Custom },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ impl SyncAttributes {
|
|||||||
/// # Example
|
/// # Example
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use bevy::prelude::*;
|
/// use bevy::prelude::*;
|
||||||
/// use lib::networking::Synced;
|
/// use libmarathon::networking::Synced;
|
||||||
/// use sync_macros::Synced as SyncedDerive;
|
/// use sync_macros::Synced as SyncedDerive;
|
||||||
///
|
///
|
||||||
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize)]
|
/// #[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 merge_impl = generate_merge(&input, &attrs.strategy);
|
||||||
|
|
||||||
let expanded = quote! {
|
let expanded = quote! {
|
||||||
impl lib::networking::SyncComponent for #name {
|
impl libmarathon::networking::SyncComponent for #name {
|
||||||
const VERSION: u32 = #version;
|
const VERSION: u32 = #version;
|
||||||
const STRATEGY: lib::networking::SyncStrategy = #strategy_tokens;
|
const STRATEGY: libmarathon::networking::SyncStrategy = #strategy_tokens;
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn serialize_sync(&self) -> anyhow::Result<Vec<u8>> {
|
fn serialize_sync(&self) -> anyhow::Result<Vec<u8>> {
|
||||||
@@ -175,7 +175,7 @@ pub fn derive_synced(input: TokenStream) -> TokenStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[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
|
#merge_impl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,19 +235,19 @@ fn generate_lww_merge(_input: &DeriveInput) -> proc_macro2::TokenStream {
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
match clock_cmp {
|
match clock_cmp {
|
||||||
lib::networking::ClockComparison::RemoteNewer => {
|
libmarathon::networking::ClockComparison::RemoteNewer => {
|
||||||
info!(
|
info!(
|
||||||
component = std::any::type_name::<Self>(),
|
component = std::any::type_name::<Self>(),
|
||||||
?clock_cmp,
|
?clock_cmp,
|
||||||
"Taking remote (newer)"
|
"Taking remote (newer)"
|
||||||
);
|
);
|
||||||
*self = remote;
|
*self = remote;
|
||||||
lib::networking::ComponentMergeDecision::TookRemote
|
libmarathon::networking::ComponentMergeDecision::TookRemote
|
||||||
}
|
}
|
||||||
lib::networking::ClockComparison::LocalNewer => {
|
libmarathon::networking::ClockComparison::LocalNewer => {
|
||||||
lib::networking::ComponentMergeDecision::KeptLocal
|
libmarathon::networking::ComponentMergeDecision::KeptLocal
|
||||||
}
|
}
|
||||||
lib::networking::ClockComparison::Concurrent => {
|
libmarathon::networking::ClockComparison::Concurrent => {
|
||||||
// Tiebreaker: Compare serialized representations for deterministic choice
|
// Tiebreaker: Compare serialized representations for deterministic choice
|
||||||
// In a real implementation, we'd use node_id, but for now use a simple hash
|
// In a real implementation, we'd use node_id, but for now use a simple hash
|
||||||
#hash_tiebreaker
|
#hash_tiebreaker
|
||||||
@@ -259,9 +259,9 @@ fn generate_lww_merge(_input: &DeriveInput) -> proc_macro2::TokenStream {
|
|||||||
"Taking remote (concurrent, tiebreaker)"
|
"Taking remote (concurrent, tiebreaker)"
|
||||||
);
|
);
|
||||||
*self = remote;
|
*self = remote;
|
||||||
lib::networking::ComponentMergeDecision::TookRemote
|
libmarathon::networking::ComponentMergeDecision::TookRemote
|
||||||
} else {
|
} 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
|
// the component to expose merge() method or implement it directly
|
||||||
|
|
||||||
match clock_cmp {
|
match clock_cmp {
|
||||||
lib::networking::ClockComparison::RemoteNewer => {
|
libmarathon::networking::ClockComparison::RemoteNewer => {
|
||||||
*self = remote;
|
*self = remote;
|
||||||
lib::networking::ComponentMergeDecision::TookRemote
|
libmarathon::networking::ComponentMergeDecision::TookRemote
|
||||||
}
|
}
|
||||||
lib::networking::ClockComparison::LocalNewer => {
|
libmarathon::networking::ClockComparison::LocalNewer => {
|
||||||
lib::networking::ComponentMergeDecision::KeptLocal
|
libmarathon::networking::ComponentMergeDecision::KeptLocal
|
||||||
}
|
}
|
||||||
lib::networking::ClockComparison::Concurrent => {
|
libmarathon::networking::ClockComparison::Concurrent => {
|
||||||
// In a full implementation, we would merge the OrSet here
|
// In a full implementation, we would merge the OrSet here
|
||||||
// For now, use LWW with tiebreaker as fallback
|
// For now, use LWW with tiebreaker as fallback
|
||||||
#hash_tiebreaker
|
#hash_tiebreaker
|
||||||
|
|
||||||
if remote_hash > local_hash {
|
if remote_hash > local_hash {
|
||||||
*self = remote;
|
*self = remote;
|
||||||
lib::networking::ComponentMergeDecision::TookRemote
|
libmarathon::networking::ComponentMergeDecision::TookRemote
|
||||||
} else {
|
} 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
|
// the component to expose merge() method or implement it directly
|
||||||
|
|
||||||
match clock_cmp {
|
match clock_cmp {
|
||||||
lib::networking::ClockComparison::RemoteNewer => {
|
libmarathon::networking::ClockComparison::RemoteNewer => {
|
||||||
*self = remote;
|
*self = remote;
|
||||||
lib::networking::ComponentMergeDecision::TookRemote
|
libmarathon::networking::ComponentMergeDecision::TookRemote
|
||||||
}
|
}
|
||||||
lib::networking::ClockComparison::LocalNewer => {
|
libmarathon::networking::ClockComparison::LocalNewer => {
|
||||||
lib::networking::ComponentMergeDecision::KeptLocal
|
libmarathon::networking::ComponentMergeDecision::KeptLocal
|
||||||
}
|
}
|
||||||
lib::networking::ClockComparison::Concurrent => {
|
libmarathon::networking::ClockComparison::Concurrent => {
|
||||||
// In a full implementation, we would merge the Rga here
|
// In a full implementation, we would merge the Rga here
|
||||||
// For now, use LWW with tiebreaker as fallback
|
// For now, use LWW with tiebreaker as fallback
|
||||||
#hash_tiebreaker
|
#hash_tiebreaker
|
||||||
|
|
||||||
if remote_hash > local_hash {
|
if remote_hash > local_hash {
|
||||||
*self = remote;
|
*self = remote;
|
||||||
lib::networking::ComponentMergeDecision::TookRemote
|
libmarathon::networking::ComponentMergeDecision::TookRemote
|
||||||
} else {
|
} else {
|
||||||
lib::networking::ComponentMergeDecision::KeptLocal
|
libmarathon::networking::ComponentMergeDecision::KeptLocal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,6 +371,6 @@ fn generate_custom_merge(input: &DeriveInput) -> proc_macro2::TokenStream {
|
|||||||
stringify!(#name)
|
stringify!(#name)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
lib::networking::ComponentMergeDecision::KeptLocal
|
libmarathon::networking::ComponentMergeDecision::KeptLocal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// Basic tests for the Synced derive macro
|
/// Basic tests for the Synced derive macro
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use lib::networking::{
|
use libmarathon::networking::{
|
||||||
ClockComparison,
|
ClockComparison,
|
||||||
ComponentMergeDecision,
|
ComponentMergeDecision,
|
||||||
SyncComponent,
|
SyncComponent,
|
||||||
|
|||||||
Reference in New Issue
Block a user