initial arhitectural overhaul
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
//! Cube entity management
|
||||
|
||||
use bevy::prelude::*;
|
||||
use lib::{
|
||||
use libmarathon::{
|
||||
networking::{
|
||||
NetworkEntityMap,
|
||||
NetworkedEntity,
|
||||
NetworkedSelection,
|
||||
NetworkedTransform,
|
||||
NodeVectorClock,
|
||||
Synced,
|
||||
},
|
||||
persistence::Persisted,
|
||||
@@ -20,53 +23,79 @@ use uuid::Uuid;
|
||||
#[reflect(Component)]
|
||||
pub struct CubeMarker;
|
||||
|
||||
/// Message to spawn a new cube at a specific position
|
||||
#[derive(Message)]
|
||||
pub struct SpawnCubeEvent {
|
||||
pub position: Vec3,
|
||||
}
|
||||
|
||||
/// Message to delete a cube by its network ID
|
||||
#[derive(Message)]
|
||||
pub struct DeleteCubeEvent {
|
||||
pub entity_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct CubePlugin;
|
||||
|
||||
impl Plugin for CubePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<CubeMarker>()
|
||||
.add_systems(Startup, spawn_cube);
|
||||
.add_message::<SpawnCubeEvent>()
|
||||
.add_message::<DeleteCubeEvent>()
|
||||
.add_systems(Update, (handle_spawn_cube, handle_delete_cube));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the synced cube on startup
|
||||
fn spawn_cube(
|
||||
/// Handle cube spawn messages
|
||||
fn handle_spawn_cube(
|
||||
mut commands: Commands,
|
||||
mut messages: MessageReader<SpawnCubeEvent>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
node_clock: Option<Res<lib::networking::NodeVectorClock>>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
) {
|
||||
// Wait until NodeVectorClock is available (after networking plugin initializes)
|
||||
let Some(clock) = node_clock else {
|
||||
warn!("NodeVectorClock not ready, deferring cube spawn");
|
||||
return;
|
||||
};
|
||||
for event in messages.read() {
|
||||
let entity_id = Uuid::new_v4();
|
||||
let node_id = node_clock.node_id;
|
||||
|
||||
let entity_id = Uuid::new_v4();
|
||||
let node_id = clock.node_id;
|
||||
info!("Spawning cube {} at {:?}", entity_id, event.position);
|
||||
|
||||
info!("Spawning cube with network ID: {}", entity_id);
|
||||
|
||||
commands.spawn((
|
||||
CubeMarker,
|
||||
// Bevy 3D components
|
||||
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
|
||||
MeshMaterial3d(materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.8, 0.3, 0.6),
|
||||
perceptual_roughness: 0.7,
|
||||
metallic: 0.3,
|
||||
..default()
|
||||
})),
|
||||
Transform::from_xyz(0.0, 0.5, 0.0),
|
||||
GlobalTransform::default(),
|
||||
// Networking
|
||||
NetworkedEntity::with_id(entity_id, node_id),
|
||||
NetworkedTransform,
|
||||
// Persistence
|
||||
Persisted::with_id(entity_id),
|
||||
// Sync marker
|
||||
Synced,
|
||||
));
|
||||
|
||||
info!("Cube spawned successfully");
|
||||
commands.spawn((
|
||||
CubeMarker,
|
||||
// Bevy 3D components
|
||||
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
|
||||
MeshMaterial3d(materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.8, 0.3, 0.6),
|
||||
perceptual_roughness: 0.7,
|
||||
metallic: 0.3,
|
||||
..default()
|
||||
})),
|
||||
Transform::from_translation(event.position),
|
||||
GlobalTransform::default(),
|
||||
// Networking
|
||||
NetworkedEntity::with_id(entity_id, node_id),
|
||||
NetworkedTransform,
|
||||
NetworkedSelection::default(),
|
||||
// Persistence
|
||||
Persisted::with_id(entity_id),
|
||||
// Sync marker
|
||||
Synced,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle cube delete messages
|
||||
fn handle_delete_cube(
|
||||
mut commands: Commands,
|
||||
mut messages: MessageReader<DeleteCubeEvent>,
|
||||
entity_map: Res<NetworkEntityMap>,
|
||||
) {
|
||||
for event in messages.read() {
|
||||
if let Some(bevy_entity) = entity_map.get_entity(event.entity_id) {
|
||||
info!("Deleting cube {}", event.entity_id);
|
||||
commands.entity(bevy_entity).despawn();
|
||||
} else {
|
||||
warn!("Attempted to delete unknown cube {}", event.entity_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@ use bevy_egui::{
|
||||
EguiContexts,
|
||||
EguiPrimaryContextPass,
|
||||
};
|
||||
use lib::networking::{
|
||||
use libmarathon::networking::{
|
||||
EntityLockRegistry,
|
||||
GossipBridge,
|
||||
NetworkedEntity,
|
||||
NodeVectorClock,
|
||||
};
|
||||
|
||||
use crate::cube::{CubeMarker, DeleteCubeEvent, SpawnCubeEvent};
|
||||
|
||||
pub struct DebugUiPlugin;
|
||||
|
||||
impl Plugin for DebugUiPlugin {
|
||||
@@ -24,10 +28,10 @@ fn render_debug_ui(
|
||||
mut contexts: EguiContexts,
|
||||
node_clock: Option<Res<NodeVectorClock>>,
|
||||
gossip_bridge: Option<Res<GossipBridge>>,
|
||||
cube_query: Query<
|
||||
(&Transform, &lib::networking::NetworkedEntity),
|
||||
With<crate::cube::CubeMarker>,
|
||||
>,
|
||||
lock_registry: Option<Res<EntityLockRegistry>>,
|
||||
cube_query: Query<(&Transform, &NetworkedEntity), With<CubeMarker>>,
|
||||
mut spawn_events: MessageWriter<SpawnCubeEvent>,
|
||||
mut delete_events: MessageWriter<DeleteCubeEvent>,
|
||||
) {
|
||||
let Ok(ctx) = contexts.ctx_mut() else {
|
||||
return;
|
||||
@@ -106,11 +110,80 @@ fn render_debug_ui(
|
||||
},
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.heading("Entity Controls");
|
||||
ui.separator();
|
||||
|
||||
if ui.button("➕ Spawn Cube").clicked() {
|
||||
spawn_events.write(SpawnCubeEvent {
|
||||
position: Vec3::new(
|
||||
rand::random::<f32>() * 4.0 - 2.0,
|
||||
0.5,
|
||||
rand::random::<f32>() * 4.0 - 2.0,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
ui.label(format!("Total cubes: {}", cube_query.iter().count()));
|
||||
|
||||
// List all cubes with delete buttons
|
||||
ui.add_space(5.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("cube_list")
|
||||
.max_height(150.0)
|
||||
.show(ui, |ui| {
|
||||
for (_transform, networked) in cube_query.iter() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("Cube {:.8}...", networked.network_id));
|
||||
if ui.small_button("🗑").clicked() {
|
||||
delete_events.write(DeleteCubeEvent {
|
||||
entity_id: networked.network_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.heading("Lock Status");
|
||||
ui.separator();
|
||||
|
||||
if let (Some(lock_registry), Some(clock)) = (&lock_registry, &node_clock) {
|
||||
let node_id = clock.node_id;
|
||||
let locked_cubes = cube_query
|
||||
.iter()
|
||||
.filter(|(_, networked)| lock_registry.is_locked(networked.network_id, node_id))
|
||||
.count();
|
||||
|
||||
ui.label(format!("Locked entities: {}", locked_cubes));
|
||||
|
||||
ui.add_space(5.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("lock_list")
|
||||
.max_height(100.0)
|
||||
.show(ui, |ui| {
|
||||
for (_, networked) in cube_query.iter() {
|
||||
let entity_id = networked.network_id;
|
||||
if let Some(holder) = lock_registry.get_holder(entity_id, node_id) {
|
||||
let is_ours = holder == node_id;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("🔒 {:.8}...", entity_id));
|
||||
ui.label(if is_ours { "(you)" } else { "(peer)" });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ui.label("Lock registry: Not ready");
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.heading("Controls");
|
||||
ui.separator();
|
||||
ui.label("Left click: Select cube");
|
||||
ui.label("Left drag: Move cube (XY)");
|
||||
ui.label("Right drag: Rotate cube");
|
||||
ui.label("Scroll: Move cube (Z)");
|
||||
ui.label("ESC: Deselect");
|
||||
});
|
||||
}
|
||||
|
||||
124
crates/app/src/engine_bridge.rs
Normal file
124
crates/app/src/engine_bridge.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
//! Bevy plugin for polling engine events and dispatching them
|
||||
//!
|
||||
//! This plugin bridges the gap between the tokio-based engine and Bevy's ECS.
|
||||
//! It polls events from the EngineBridge every frame and dispatches them to
|
||||
//! Bevy systems.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use libmarathon::{
|
||||
engine::{EngineBridge, EngineCommand, EngineEvent},
|
||||
networking::{CurrentSession, NetworkedEntity, NodeVectorClock, Session, SessionState, VectorClock},
|
||||
};
|
||||
|
||||
pub struct EngineBridgePlugin;
|
||||
|
||||
impl Plugin for EngineBridgePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Add the event polling system - runs every tick in Update
|
||||
app.add_systems(Update, poll_engine_events);
|
||||
// Detect changes and send clock tick commands to engine
|
||||
app.add_systems(PostUpdate, detect_changes_and_tick);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect changes to networked entities and send tick commands to engine
|
||||
///
|
||||
/// Uses Bevy's change detection to detect when Transform changes on any
|
||||
/// NetworkedEntity. When changes are detected, sends a TickClock command
|
||||
/// to the engine, which will increment its clock and send back a ClockTicked event.
|
||||
fn detect_changes_and_tick(
|
||||
bridge: Res<EngineBridge>,
|
||||
changed_query: Query<(), (With<NetworkedEntity>, Changed<Transform>)>,
|
||||
) {
|
||||
// If any networked transforms changed this frame, tick the clock
|
||||
if !changed_query.is_empty() {
|
||||
bridge.send_command(EngineCommand::TickClock);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll events from the engine and dispatch to Bevy
|
||||
///
|
||||
/// This system runs every tick and:
|
||||
/// 1. Polls all available events from the EngineBridge
|
||||
/// 2. Dispatches them to update Bevy resources and state
|
||||
fn poll_engine_events(
|
||||
mut commands: Commands,
|
||||
bridge: Res<EngineBridge>,
|
||||
mut current_session: Option<ResMut<CurrentSession>>,
|
||||
mut node_clock: ResMut<NodeVectorClock>,
|
||||
) {
|
||||
let events = (*bridge).poll_events();
|
||||
|
||||
if !events.is_empty() {
|
||||
for event in events {
|
||||
match event {
|
||||
EngineEvent::NetworkingStarted { session_id, node_id } => {
|
||||
info!("🌐 Networking started: session={}, node={}",
|
||||
session_id.to_code(), node_id);
|
||||
|
||||
// Create session if it doesn't exist
|
||||
if current_session.is_none() {
|
||||
let mut session = Session::new(session_id.clone());
|
||||
session.state = SessionState::Active;
|
||||
commands.insert_resource(CurrentSession::new(session, VectorClock::new()));
|
||||
info!("Created new session resource: {}", session_id.to_code());
|
||||
} else if let Some(ref mut session) = current_session {
|
||||
// Update existing session state to Active
|
||||
session.session.state = SessionState::Active;
|
||||
}
|
||||
|
||||
// Update node ID in clock
|
||||
node_clock.node_id = node_id;
|
||||
}
|
||||
EngineEvent::NetworkingFailed { error } => {
|
||||
error!("❌ Networking failed: {}", error);
|
||||
|
||||
// Keep session state as Created (if session exists)
|
||||
if let Some(ref mut session) = current_session {
|
||||
session.session.state = SessionState::Created;
|
||||
}
|
||||
}
|
||||
EngineEvent::NetworkingStopped => {
|
||||
info!("🔌 Networking stopped");
|
||||
|
||||
// Update session state to Disconnected (if session exists)
|
||||
if let Some(ref mut session) = current_session {
|
||||
session.session.state = SessionState::Disconnected;
|
||||
}
|
||||
}
|
||||
EngineEvent::PeerJoined { node_id } => {
|
||||
info!("👋 Peer joined: {}", node_id);
|
||||
// TODO(Phase 3.3): Trigger sync
|
||||
}
|
||||
EngineEvent::PeerLeft { node_id } => {
|
||||
info!("👋 Peer left: {}", node_id);
|
||||
}
|
||||
EngineEvent::LockAcquired { entity_id, holder } => {
|
||||
debug!("🔒 Lock acquired: entity={}, holder={}", entity_id, holder);
|
||||
// TODO(Phase 3.4): Update lock visuals
|
||||
}
|
||||
EngineEvent::LockReleased { entity_id } => {
|
||||
debug!("🔓 Lock released: entity={}", entity_id);
|
||||
// TODO(Phase 3.4): Update lock visuals
|
||||
}
|
||||
EngineEvent::LockDenied { entity_id, current_holder } => {
|
||||
debug!("⛔ Lock denied: entity={}, holder={}", entity_id, current_holder);
|
||||
// TODO(Phase 3.4): Show visual feedback
|
||||
}
|
||||
EngineEvent::LockExpired { entity_id } => {
|
||||
debug!("⏰ Lock expired: entity={}", entity_id);
|
||||
// TODO(Phase 3.4): Update lock visuals
|
||||
}
|
||||
EngineEvent::ClockTicked { sequence, clock } => {
|
||||
debug!("🕐 Clock ticked to {}", sequence);
|
||||
|
||||
// Update the NodeVectorClock resource with the new clock state
|
||||
node_clock.clock = clock;
|
||||
}
|
||||
_ => {
|
||||
debug!("Unhandled engine event: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
235
crates/app/src/executor.rs
Normal file
235
crates/app/src/executor.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
//! Application executor - owns winit and drives Bevy ECS
|
||||
//!
|
||||
//! The executor gives us full control over the event loop and allows
|
||||
//! both the window and ECS to run unbounded (maximum performance).
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::app::AppExit;
|
||||
use bevy::input::{
|
||||
ButtonInput,
|
||||
mouse::MouseButton as BevyMouseButton,
|
||||
keyboard::KeyCode as BevyKeyCode,
|
||||
touch::{Touches, TouchInput},
|
||||
};
|
||||
use bevy::window::{
|
||||
PrimaryWindow, WindowCreated, WindowResized, WindowScaleFactorChanged, WindowClosing,
|
||||
WindowResolution, WindowMode, WindowPosition, WindowEvent as BevyWindowEvent,
|
||||
RawHandleWrapper, WindowWrapper,
|
||||
};
|
||||
use bevy::ecs::message::Messages;
|
||||
use libmarathon::engine::InputEvent;
|
||||
use libmarathon::platform::desktop;
|
||||
use std::sync::Arc;
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::WindowEvent as WinitWindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::window::{Window as WinitWindow, WindowId, WindowAttributes};
|
||||
|
||||
// Re-export InputEventBuffer from the input module
|
||||
pub use crate::input::event_buffer::InputEventBuffer;
|
||||
|
||||
/// Application handler state machine
|
||||
enum AppHandler {
|
||||
Initializing { app: App },
|
||||
Running {
|
||||
window: Arc<WinitWindow>,
|
||||
bevy_window_entity: Entity,
|
||||
bevy_app: App,
|
||||
},
|
||||
}
|
||||
|
||||
impl AppHandler {
|
||||
fn initialize(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// Only initialize if we're in the Initializing state
|
||||
if !matches!(self, AppHandler::Initializing { .. }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Take ownership of the app (replace with placeholder temporarily)
|
||||
let temp_state = std::mem::replace(self, AppHandler::Initializing { app: App::new() });
|
||||
let AppHandler::Initializing { app } = temp_state else { unreachable!() };
|
||||
let mut bevy_app = app;
|
||||
|
||||
// Insert InputEventBuffer resource
|
||||
bevy_app.insert_resource(InputEventBuffer::default());
|
||||
|
||||
// Initialize window message channels
|
||||
bevy_app.init_resource::<Messages<WindowCreated>>();
|
||||
bevy_app.init_resource::<Messages<WindowResized>>();
|
||||
bevy_app.init_resource::<Messages<WindowScaleFactorChanged>>();
|
||||
bevy_app.init_resource::<Messages<WindowClosing>>();
|
||||
bevy_app.init_resource::<Messages<BevyWindowEvent>>();
|
||||
|
||||
// Initialize input resources that Bevy UI and picking expect
|
||||
bevy_app.init_resource::<ButtonInput<BevyMouseButton>>();
|
||||
bevy_app.init_resource::<ButtonInput<BevyKeyCode>>();
|
||||
bevy_app.init_resource::<Touches>();
|
||||
bevy_app.init_resource::<Messages<TouchInput>>();
|
||||
|
||||
// Create the winit window BEFORE finishing the app
|
||||
let window_attributes = WindowAttributes::default()
|
||||
.with_title("Marathon")
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280, 720));
|
||||
|
||||
let winit_window = event_loop.create_window(window_attributes)
|
||||
.expect("Failed to create window");
|
||||
let winit_window = Arc::new(winit_window);
|
||||
info!("Created window before app.finish()");
|
||||
|
||||
let physical_size = winit_window.inner_size();
|
||||
let scale_factor = winit_window.scale_factor();
|
||||
|
||||
// Create window entity with all required components
|
||||
let mut window = bevy::window::Window {
|
||||
title: "Marathon".to_string(),
|
||||
resolution: WindowResolution::new(
|
||||
physical_size.width,
|
||||
physical_size.height,
|
||||
),
|
||||
mode: WindowMode::Windowed,
|
||||
position: WindowPosition::Automatic,
|
||||
focused: true,
|
||||
..Default::default()
|
||||
};
|
||||
window.resolution.set_scale_factor_override(Some(scale_factor as f32));
|
||||
|
||||
// Create WindowWrapper and RawHandleWrapper for renderer
|
||||
let window_wrapper = WindowWrapper::new(winit_window.clone());
|
||||
let raw_handle_wrapper = RawHandleWrapper::new(&window_wrapper)
|
||||
.expect("Failed to create RawHandleWrapper");
|
||||
|
||||
let window_entity = bevy_app.world_mut().spawn((
|
||||
window,
|
||||
PrimaryWindow,
|
||||
raw_handle_wrapper,
|
||||
)).id();
|
||||
info!("Created window entity {}", window_entity);
|
||||
|
||||
// Send WindowCreated event
|
||||
bevy_app.world_mut()
|
||||
.resource_mut::<Messages<WindowCreated>>()
|
||||
.write(WindowCreated { window: window_entity });
|
||||
|
||||
// Send WindowResized event
|
||||
bevy_app.world_mut()
|
||||
.resource_mut::<Messages<WindowResized>>()
|
||||
.write(WindowResized {
|
||||
window: window_entity,
|
||||
width: physical_size.width as f32 / scale_factor as f32,
|
||||
height: physical_size.height as f32 / scale_factor as f32,
|
||||
});
|
||||
|
||||
// Now finish the app - the renderer will initialize with the window
|
||||
bevy_app.finish();
|
||||
bevy_app.cleanup();
|
||||
info!("App finished and cleaned up");
|
||||
|
||||
// Transition to Running state
|
||||
*self = AppHandler::Running {
|
||||
window: winit_window,
|
||||
bevy_window_entity: window_entity,
|
||||
bevy_app,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for AppHandler {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// Initialize on first resumed() call
|
||||
self.initialize(event_loop);
|
||||
info!("App resumed");
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
_window_id: WindowId,
|
||||
event: WinitWindowEvent,
|
||||
) {
|
||||
// Only handle events if we're in Running state
|
||||
let AppHandler::Running {
|
||||
ref window,
|
||||
bevy_window_entity,
|
||||
ref mut bevy_app,
|
||||
} = self
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Forward input events to platform bridge
|
||||
desktop::push_window_event(&event);
|
||||
|
||||
match event {
|
||||
WinitWindowEvent::CloseRequested => {
|
||||
info!("Window close requested");
|
||||
event_loop.exit();
|
||||
}
|
||||
|
||||
WinitWindowEvent::Resized(physical_size) => {
|
||||
// Notify Bevy of window resize
|
||||
let scale_factor = window.scale_factor();
|
||||
bevy_app.world_mut()
|
||||
.resource_mut::<Messages<WindowResized>>()
|
||||
.write(WindowResized {
|
||||
window: *bevy_window_entity,
|
||||
width: physical_size.width as f32 / scale_factor as f32,
|
||||
height: physical_size.height as f32 / scale_factor as f32,
|
||||
});
|
||||
}
|
||||
|
||||
WinitWindowEvent::RedrawRequested => {
|
||||
// Collect input events from platform bridge
|
||||
let input_events = desktop::drain_as_input_events();
|
||||
|
||||
// Write events to InputEventBuffer resource
|
||||
bevy_app.world_mut().resource_mut::<InputEventBuffer>().events = input_events;
|
||||
|
||||
// Run one Bevy ECS update (unbounded)
|
||||
bevy_app.update();
|
||||
|
||||
// Check if app should exit
|
||||
if let Some(exit) = bevy_app.should_exit() {
|
||||
info!("App exit requested: {:?}", exit);
|
||||
event_loop.exit();
|
||||
}
|
||||
|
||||
// Clear input buffer for next frame
|
||||
bevy_app.world_mut().resource_mut::<InputEventBuffer>().clear();
|
||||
|
||||
// Request next frame immediately (unbounded loop)
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
// Request redraw to keep loop running
|
||||
if let AppHandler::Running { ref window, .. } = self {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the application executor
|
||||
pub fn run(app: App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
|
||||
// TODO: Add battery power detection and adaptive frame/tick rate limiting
|
||||
// When on battery: reduce to 60fps cap, lower ECS tick rate
|
||||
// When plugged in: run unbounded for maximum performance
|
||||
|
||||
// Run as fast as possible (unbounded)
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
||||
info!("Starting executor (unbounded mode)");
|
||||
|
||||
// Create handler in Initializing state
|
||||
// It will transition to Running state on first resumed() callback
|
||||
let mut handler = AppHandler::Initializing { app };
|
||||
|
||||
event_loop.run_app(&mut handler)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
226
crates/app/src/input/desktop_bridge.rs
Normal file
226
crates/app/src/input/desktop_bridge.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! Bridge Bevy's input to the engine's InputEvent system
|
||||
//!
|
||||
//! This temporarily reads Bevy's input and converts to InputEvents.
|
||||
//! Later, we'll replace this with direct winit ownership.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::input::keyboard::KeyboardInput;
|
||||
use bevy::input::mouse::{MouseButtonInput, MouseWheel};
|
||||
use bevy::window::CursorMoved;
|
||||
use libmarathon::engine::{InputEvent, KeyCode as EngineKeyCode, MouseButton as EngineMouseButton, TouchPhase, Modifiers};
|
||||
|
||||
/// Convert Bevy's Vec2 to glam::Vec2
|
||||
///
|
||||
/// Bevy re-exports glam types, so they're the same layout.
|
||||
/// We just construct a new one to be safe.
|
||||
#[inline]
|
||||
fn to_glam_vec2(v: bevy::math::Vec2) -> glam::Vec2 {
|
||||
glam::Vec2::new(v.x, v.y)
|
||||
}
|
||||
|
||||
/// Convert Bevy's KeyCode to engine's KeyCode (winit::keyboard::KeyCode)
|
||||
///
|
||||
/// Bevy re-exports winit's KeyCode but wraps it, so we need to extract it.
|
||||
/// For now, we'll just match the common keys. TODO: Complete mapping.
|
||||
fn bevy_to_engine_keycode(bevy_key: KeyCode) -> Option<EngineKeyCode> {
|
||||
// In Bevy 0.17, KeyCode variants match winit directly
|
||||
// We can use format matching as a temporary solution
|
||||
use EngineKeyCode as E;
|
||||
|
||||
Some(match bevy_key {
|
||||
KeyCode::KeyA => E::KeyA,
|
||||
KeyCode::KeyB => E::KeyB,
|
||||
KeyCode::KeyC => E::KeyC,
|
||||
KeyCode::KeyD => E::KeyD,
|
||||
KeyCode::KeyE => E::KeyE,
|
||||
KeyCode::KeyF => E::KeyF,
|
||||
KeyCode::KeyG => E::KeyG,
|
||||
KeyCode::KeyH => E::KeyH,
|
||||
KeyCode::KeyI => E::KeyI,
|
||||
KeyCode::KeyJ => E::KeyJ,
|
||||
KeyCode::KeyK => E::KeyK,
|
||||
KeyCode::KeyL => E::KeyL,
|
||||
KeyCode::KeyM => E::KeyM,
|
||||
KeyCode::KeyN => E::KeyN,
|
||||
KeyCode::KeyO => E::KeyO,
|
||||
KeyCode::KeyP => E::KeyP,
|
||||
KeyCode::KeyQ => E::KeyQ,
|
||||
KeyCode::KeyR => E::KeyR,
|
||||
KeyCode::KeyS => E::KeyS,
|
||||
KeyCode::KeyT => E::KeyT,
|
||||
KeyCode::KeyU => E::KeyU,
|
||||
KeyCode::KeyV => E::KeyV,
|
||||
KeyCode::KeyW => E::KeyW,
|
||||
KeyCode::KeyX => E::KeyX,
|
||||
KeyCode::KeyY => E::KeyY,
|
||||
KeyCode::KeyZ => E::KeyZ,
|
||||
KeyCode::Digit1 => E::Digit1,
|
||||
KeyCode::Digit2 => E::Digit2,
|
||||
KeyCode::Digit3 => E::Digit3,
|
||||
KeyCode::Digit4 => E::Digit4,
|
||||
KeyCode::Digit5 => E::Digit5,
|
||||
KeyCode::Digit6 => E::Digit6,
|
||||
KeyCode::Digit7 => E::Digit7,
|
||||
KeyCode::Digit8 => E::Digit8,
|
||||
KeyCode::Digit9 => E::Digit9,
|
||||
KeyCode::Digit0 => E::Digit0,
|
||||
KeyCode::Space => E::Space,
|
||||
KeyCode::Enter => E::Enter,
|
||||
KeyCode::Escape => E::Escape,
|
||||
KeyCode::Backspace => E::Backspace,
|
||||
KeyCode::Tab => E::Tab,
|
||||
KeyCode::ShiftLeft => E::ShiftLeft,
|
||||
KeyCode::ShiftRight => E::ShiftRight,
|
||||
KeyCode::ControlLeft => E::ControlLeft,
|
||||
KeyCode::ControlRight => E::ControlRight,
|
||||
KeyCode::AltLeft => E::AltLeft,
|
||||
KeyCode::AltRight => E::AltRight,
|
||||
KeyCode::SuperLeft => E::SuperLeft,
|
||||
KeyCode::SuperRight => E::SuperRight,
|
||||
KeyCode::ArrowUp => E::ArrowUp,
|
||||
KeyCode::ArrowDown => E::ArrowDown,
|
||||
KeyCode::ArrowLeft => E::ArrowLeft,
|
||||
KeyCode::ArrowRight => E::ArrowRight,
|
||||
_ => return None, // Unmapped keys
|
||||
})
|
||||
}
|
||||
|
||||
pub struct DesktopInputBridgePlugin;
|
||||
|
||||
impl Plugin for DesktopInputBridgePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<InputEventBuffer>()
|
||||
.add_systems(PreUpdate, (
|
||||
clear_buffer,
|
||||
collect_mouse_buttons,
|
||||
collect_mouse_motion,
|
||||
collect_mouse_wheel,
|
||||
collect_keyboard,
|
||||
).chain());
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffer for InputEvents collected this frame
|
||||
#[derive(Resource, Default)]
|
||||
pub struct InputEventBuffer {
|
||||
pub events: Vec<InputEvent>,
|
||||
}
|
||||
|
||||
impl InputEventBuffer {
|
||||
/// Get all events from this frame
|
||||
pub fn events(&self) -> &[InputEvent] {
|
||||
&self.events
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the buffer at the start of each frame
|
||||
fn clear_buffer(mut buffer: ResMut<InputEventBuffer>) {
|
||||
buffer.events.clear();
|
||||
}
|
||||
|
||||
/// Collect mouse button events
|
||||
fn collect_mouse_buttons(
|
||||
mut buffer: ResMut<InputEventBuffer>,
|
||||
mut mouse_button_events: MessageReader<MouseButtonInput>,
|
||||
windows: Query<&Window>,
|
||||
) {
|
||||
let cursor_pos = windows
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|w| w.cursor_position())
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
|
||||
for event in mouse_button_events.read() {
|
||||
let button = match event.button {
|
||||
MouseButton::Left => EngineMouseButton::Left,
|
||||
MouseButton::Right => EngineMouseButton::Right,
|
||||
MouseButton::Middle => EngineMouseButton::Middle,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let phase = if event.state.is_pressed() {
|
||||
TouchPhase::Started
|
||||
} else {
|
||||
TouchPhase::Ended
|
||||
};
|
||||
|
||||
buffer.events.push(InputEvent::Mouse {
|
||||
pos: to_glam_vec2(cursor_pos),
|
||||
button,
|
||||
phase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect mouse motion events (for drag tracking)
|
||||
fn collect_mouse_motion(
|
||||
mut buffer: ResMut<InputEventBuffer>,
|
||||
mut cursor_moved: MessageReader<CursorMoved>,
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
) {
|
||||
// Only process if cursor actually moved
|
||||
for event in cursor_moved.read() {
|
||||
let cursor_pos = event.position;
|
||||
|
||||
// Generate drag events for currently pressed buttons
|
||||
if mouse_buttons.pressed(MouseButton::Left) {
|
||||
buffer.events.push(InputEvent::Mouse {
|
||||
pos: to_glam_vec2(cursor_pos),
|
||||
button: EngineMouseButton::Left,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
}
|
||||
if mouse_buttons.pressed(MouseButton::Right) {
|
||||
buffer.events.push(InputEvent::Mouse {
|
||||
pos: to_glam_vec2(cursor_pos),
|
||||
button: EngineMouseButton::Right,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect mouse wheel events
|
||||
fn collect_mouse_wheel(
|
||||
mut buffer: ResMut<InputEventBuffer>,
|
||||
mut wheel_events: MessageReader<MouseWheel>,
|
||||
windows: Query<&Window>,
|
||||
) {
|
||||
let cursor_pos = windows
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|w| w.cursor_position())
|
||||
.unwrap_or(Vec2::ZERO);
|
||||
|
||||
for event in wheel_events.read() {
|
||||
buffer.events.push(InputEvent::MouseWheel {
|
||||
delta: to_glam_vec2(Vec2::new(event.x, event.y)),
|
||||
pos: to_glam_vec2(cursor_pos),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect keyboard events
|
||||
fn collect_keyboard(
|
||||
mut buffer: ResMut<InputEventBuffer>,
|
||||
mut keyboard_events: MessageReader<KeyboardInput>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
) {
|
||||
for event in keyboard_events.read() {
|
||||
let modifiers = Modifiers {
|
||||
shift: keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]),
|
||||
ctrl: keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]),
|
||||
alt: keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
meta: keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]),
|
||||
};
|
||||
|
||||
// Convert Bevy's KeyCode to engine's KeyCode
|
||||
if let Some(engine_key) = bevy_to_engine_keycode(event.key_code) {
|
||||
buffer.events.push(InputEvent::Keyboard {
|
||||
key: engine_key,
|
||||
pressed: event.state.is_pressed(),
|
||||
modifiers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
22
crates/app/src/input/event_buffer.rs
Normal file
22
crates/app/src/input/event_buffer.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! Input event buffer shared between executor and ECS
|
||||
|
||||
use bevy::prelude::*;
|
||||
use libmarathon::engine::InputEvent;
|
||||
|
||||
/// Input event buffer resource for Bevy ECS
|
||||
#[derive(Resource, Default)]
|
||||
pub struct InputEventBuffer {
|
||||
pub events: Vec<InputEvent>,
|
||||
}
|
||||
|
||||
impl InputEventBuffer {
|
||||
/// Get all events from this frame
|
||||
pub fn events(&self) -> &[InputEvent] {
|
||||
&self.events
|
||||
}
|
||||
|
||||
/// Clear the buffer
|
||||
pub fn clear(&mut self) {
|
||||
self.events.clear();
|
||||
}
|
||||
}
|
||||
152
crates/app/src/input/input_handler.rs
Normal file
152
crates/app/src/input/input_handler.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! Input handling using engine GameActions
|
||||
//!
|
||||
//! Processes GameActions (from InputController) and applies them to game entities.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use libmarathon::{
|
||||
engine::{GameAction, InputController},
|
||||
networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock},
|
||||
};
|
||||
|
||||
use super::event_buffer::InputEventBuffer;
|
||||
|
||||
pub struct InputHandlerPlugin;
|
||||
|
||||
impl Plugin for InputHandlerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<InputControllerResource>()
|
||||
.add_systems(Update, handle_game_actions);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource wrapping the InputController
|
||||
#[derive(Resource)]
|
||||
struct InputControllerResource {
|
||||
controller: InputController,
|
||||
}
|
||||
|
||||
impl Default for InputControllerResource {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
controller: InputController::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert glam::Vec2 to Bevy's Vec2
|
||||
///
|
||||
/// They're the same type, just construct a new one.
|
||||
#[inline]
|
||||
fn to_bevy_vec2(v: glam::Vec2) -> bevy::math::Vec2 {
|
||||
bevy::math::Vec2::new(v.x, v.y)
|
||||
}
|
||||
|
||||
/// Process GameActions and apply to entities
|
||||
fn handle_game_actions(
|
||||
input_buffer: Res<InputEventBuffer>,
|
||||
mut controller_res: ResMut<InputControllerResource>,
|
||||
lock_registry: Res<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
mut cube_query: Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
let node_id = node_clock.node_id;
|
||||
|
||||
// Process all input events through the controller to get game actions
|
||||
let mut all_actions = Vec::new();
|
||||
for event in input_buffer.events() {
|
||||
let actions = controller_res.controller.process_event(event);
|
||||
all_actions.extend(actions);
|
||||
}
|
||||
|
||||
// Apply game actions to entities
|
||||
for action in all_actions {
|
||||
match action {
|
||||
GameAction::MoveEntity { delta } => {
|
||||
apply_move_entity(delta, &lock_registry, node_id, &mut cube_query);
|
||||
}
|
||||
|
||||
GameAction::RotateEntity { delta } => {
|
||||
apply_rotate_entity(delta, &lock_registry, node_id, &mut cube_query);
|
||||
}
|
||||
|
||||
GameAction::MoveEntityDepth { delta } => {
|
||||
apply_move_depth(delta, &lock_registry, node_id, &mut cube_query);
|
||||
}
|
||||
|
||||
GameAction::ResetEntity => {
|
||||
apply_reset_entity(&lock_registry, node_id, &mut cube_query);
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Other actions not yet implemented
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply MoveEntity action to locked cubes
|
||||
fn apply_move_entity(
|
||||
delta: glam::Vec2,
|
||||
lock_registry: &EntityLockRegistry,
|
||||
node_id: uuid::Uuid,
|
||||
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
let bevy_delta = to_bevy_vec2(delta);
|
||||
let sensitivity = 0.01; // Scale factor
|
||||
|
||||
for (networked, mut transform) in cube_query.iter_mut() {
|
||||
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
|
||||
transform.translation.x += bevy_delta.x * sensitivity;
|
||||
transform.translation.y -= bevy_delta.y * sensitivity; // Invert Y for screen coords
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply RotateEntity action to locked cubes
|
||||
fn apply_rotate_entity(
|
||||
delta: glam::Vec2,
|
||||
lock_registry: &EntityLockRegistry,
|
||||
node_id: uuid::Uuid,
|
||||
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
let bevy_delta = to_bevy_vec2(delta);
|
||||
let sensitivity = 0.01;
|
||||
|
||||
for (networked, mut transform) in cube_query.iter_mut() {
|
||||
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
|
||||
let rotation_x = Quat::from_rotation_y(bevy_delta.x * sensitivity);
|
||||
let rotation_y = Quat::from_rotation_x(-bevy_delta.y * sensitivity);
|
||||
transform.rotation = rotation_x * transform.rotation * rotation_y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply MoveEntityDepth action to locked cubes
|
||||
fn apply_move_depth(
|
||||
delta: f32,
|
||||
lock_registry: &EntityLockRegistry,
|
||||
node_id: uuid::Uuid,
|
||||
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
let sensitivity = 0.1;
|
||||
|
||||
for (networked, mut transform) in cube_query.iter_mut() {
|
||||
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
|
||||
transform.translation.z += delta * sensitivity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply ResetEntity action to locked cubes
|
||||
fn apply_reset_entity(
|
||||
lock_registry: &EntityLockRegistry,
|
||||
node_id: uuid::Uuid,
|
||||
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
for (networked, mut transform) in cube_query.iter_mut() {
|
||||
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
|
||||
transform.translation = Vec3::ZERO;
|
||||
transform.rotation = Quat::IDENTITY;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
crates/app/src/input/mod.rs
Normal file
28
crates/app/src/input/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Input handling modules
|
||||
//!
|
||||
//! This module contains platform-specific input adapters that bridge
|
||||
//! native input (Bevy/winit, iOS pencil) to libmarathon's InputEvent system.
|
||||
|
||||
pub mod event_buffer;
|
||||
pub mod input_handler;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod pencil;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub mod desktop_bridge;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub mod mouse;
|
||||
|
||||
pub use event_buffer::InputEventBuffer;
|
||||
pub use input_handler::InputHandlerPlugin;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub use pencil::PencilInputPlugin;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub use desktop_bridge::DesktopInputBridgePlugin;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub use mouse::MouseInputPlugin;
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||
use libmarathon::networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock};
|
||||
|
||||
pub struct MouseInputPlugin;
|
||||
|
||||
@@ -20,13 +21,15 @@ struct MouseState {
|
||||
right_pressed: bool,
|
||||
}
|
||||
|
||||
/// Handle mouse input to move and rotate the cube
|
||||
/// Handle mouse input to move and rotate cubes that are locked by us
|
||||
fn handle_mouse_input(
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
mut mouse_motion: EventReader<MouseMotion>,
|
||||
mut mouse_wheel: EventReader<MouseWheel>,
|
||||
mut mouse_motion: MessageReader<MouseMotion>,
|
||||
mut mouse_wheel: MessageReader<MouseWheel>,
|
||||
mut mouse_state: Local<Option<MouseState>>,
|
||||
mut cube_query: Query<&mut Transform, With<crate::cube::CubeMarker>>,
|
||||
lock_registry: Res<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
mut cube_query: Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
// Initialize mouse state if needed
|
||||
if mouse_state.is_none() {
|
||||
@@ -38,42 +41,57 @@ fn handle_mouse_input(
|
||||
state.left_pressed = mouse_buttons.pressed(MouseButton::Left);
|
||||
state.right_pressed = mouse_buttons.pressed(MouseButton::Right);
|
||||
|
||||
let node_id = node_clock.node_id;
|
||||
|
||||
// Get total mouse delta this frame
|
||||
let mut total_delta = Vec2::ZERO;
|
||||
for motion in mouse_motion.read() {
|
||||
total_delta += motion.delta;
|
||||
}
|
||||
|
||||
// Process mouse motion
|
||||
// Process mouse motion - only for cubes locked by us
|
||||
if total_delta != Vec2::ZERO {
|
||||
for mut transform in cube_query.iter_mut() {
|
||||
for (networked, mut transform) in cube_query.iter_mut() {
|
||||
// Only move cubes that we have locked
|
||||
if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if state.left_pressed {
|
||||
// Left drag: Move cube in XY plane
|
||||
// Scale factor for sensitivity
|
||||
let sensitivity = 0.01;
|
||||
transform.translation.x += total_delta.x * sensitivity;
|
||||
transform.translation.y -= total_delta.y * sensitivity; // Invert Y
|
||||
// Change detection will trigger clock tick automatically
|
||||
} else if state.right_pressed {
|
||||
// Right drag: Rotate cube
|
||||
let sensitivity = 0.01;
|
||||
let rotation_x = Quat::from_rotation_y(total_delta.x * sensitivity);
|
||||
let rotation_y = Quat::from_rotation_x(-total_delta.y * sensitivity);
|
||||
transform.rotation = rotation_x * transform.rotation * rotation_y;
|
||||
// Change detection will trigger clock tick automatically
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process mouse wheel for Z-axis movement
|
||||
// Process mouse wheel for Z-axis movement - only for cubes locked by us
|
||||
let mut total_scroll = 0.0;
|
||||
for wheel in mouse_wheel.read() {
|
||||
total_scroll += wheel.y;
|
||||
}
|
||||
|
||||
if total_scroll != 0.0 {
|
||||
for mut transform in cube_query.iter_mut() {
|
||||
for (networked, mut transform) in cube_query.iter_mut() {
|
||||
// Only move cubes that we have locked
|
||||
if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scroll: Move in Z axis
|
||||
let sensitivity = 0.1;
|
||||
transform.translation.z += total_scroll * sensitivity;
|
||||
// Change detection will trigger clock tick automatically
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
crates/app/src/input/pencil.rs
Normal file
69
crates/app/src/input/pencil.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! Apple Pencil input system for iOS
|
||||
//!
|
||||
//! This module integrates the platform-agnostic pencil bridge with Bevy.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use libmarathon::{engine::InputEvent, platform::ios};
|
||||
|
||||
pub struct PencilInputPlugin;
|
||||
|
||||
impl Plugin for PencilInputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, attach_pencil_capture)
|
||||
.add_systems(PreUpdate, poll_pencil_input);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource to track the latest pencil state
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PencilState {
|
||||
pub latest: Option<InputEvent>,
|
||||
pub points_this_frame: usize,
|
||||
}
|
||||
|
||||
/// Attach the Swift pencil capture to Bevy's window
|
||||
#[cfg(target_os = "ios")]
|
||||
fn attach_pencil_capture(windows: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>) {
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
let Ok(handle) = windows.get_single() else {
|
||||
warn!("No primary window for pencil capture");
|
||||
return;
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if let Ok(raw) = handle.window_handle() {
|
||||
if let RawWindowHandle::UiKit(h) = raw.as_ref() {
|
||||
ios::swift_attach_pencil_capture(h.ui_view.as_ptr() as *mut _);
|
||||
info!("✏️ Apple Pencil capture attached");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
fn attach_pencil_capture() {
|
||||
// No-op on non-iOS platforms
|
||||
}
|
||||
|
||||
/// Poll pencil input from the platform layer and update PencilState
|
||||
fn poll_pencil_input(mut commands: Commands, state: Option<ResMut<PencilState>>) {
|
||||
let events = ios::drain_as_input_events();
|
||||
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert resource if it doesn't exist
|
||||
if state.is_none() {
|
||||
commands.insert_resource(PencilState::default());
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(mut state) = state {
|
||||
state.points_this_frame = events.len();
|
||||
if let Some(latest) = events.last() {
|
||||
state.latest = Some(*latest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
pub mod camera;
|
||||
pub mod cube;
|
||||
pub mod debug_ui;
|
||||
pub mod engine_bridge;
|
||||
pub mod input;
|
||||
pub mod rendering;
|
||||
pub mod setup;
|
||||
|
||||
pub use cube::CubeMarker;
|
||||
pub use engine_bridge::EngineBridgePlugin;
|
||||
|
||||
@@ -3,38 +3,36 @@
|
||||
//! This demonstrates real-time CRDT synchronization with Apple Pencil input.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::EguiPlugin;
|
||||
use lib::{
|
||||
networking::{NetworkingConfig, NetworkingPlugin},
|
||||
// use bevy_egui::EguiPlugin; // Disabled - needs WinitPlugin which we own directly
|
||||
use libmarathon::{
|
||||
engine::{EngineBridge, EngineCore},
|
||||
persistence::{PersistenceConfig, PersistencePlugin},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod camera;
|
||||
mod cube;
|
||||
mod debug_ui;
|
||||
mod executor;
|
||||
mod engine_bridge;
|
||||
mod rendering;
|
||||
mod selection;
|
||||
mod session;
|
||||
mod session_ui;
|
||||
mod setup;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
mod input {
|
||||
pub mod mouse;
|
||||
pub use mouse::MouseInputPlugin;
|
||||
}
|
||||
use engine_bridge::EngineBridgePlugin;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
mod input {
|
||||
pub mod pencil;
|
||||
pub use pencil::PencilInputPlugin;
|
||||
}
|
||||
mod input;
|
||||
|
||||
use camera::*;
|
||||
use cube::*;
|
||||
use debug_ui::*;
|
||||
use input::*;
|
||||
use rendering::*;
|
||||
use setup::*;
|
||||
use selection::*;
|
||||
use session::*;
|
||||
use session_ui::*;
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
@@ -46,56 +44,62 @@ fn main() {
|
||||
)
|
||||
.init();
|
||||
|
||||
// Create node ID (in production, load from config or generate once)
|
||||
let node_id = Uuid::new_v4();
|
||||
info!("Starting app with node ID: {}", node_id);
|
||||
|
||||
// Database path
|
||||
let db_path = PathBuf::from("cube_demo.db");
|
||||
let db_path_str = db_path.to_str().unwrap().to_string();
|
||||
|
||||
// Create Bevy app
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: format!("Replicated Cube Demo - Node {}", &node_id.to_string()[..8]),
|
||||
resolution: (1280, 720).into(),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
.disable::<bevy::log::LogPlugin>() // Disable Bevy's logger, using tracing-subscriber instead
|
||||
)
|
||||
.add_plugins(EguiPlugin::default())
|
||||
// Networking (bridge will be set up in startup)
|
||||
.add_plugins(NetworkingPlugin::new(NetworkingConfig {
|
||||
node_id,
|
||||
sync_interval_secs: 1.0,
|
||||
prune_interval_secs: 60.0,
|
||||
tombstone_gc_interval_secs: 300.0,
|
||||
}))
|
||||
// Persistence
|
||||
.add_plugins(PersistencePlugin::with_config(
|
||||
db_path,
|
||||
PersistenceConfig {
|
||||
flush_interval_secs: 2,
|
||||
checkpoint_interval_secs: 30,
|
||||
battery_adaptive: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
// Camera
|
||||
.add_plugins(CameraPlugin)
|
||||
// Rendering
|
||||
.add_plugins(RenderingPlugin)
|
||||
// Input
|
||||
.add_plugins(MouseInputPlugin)
|
||||
// Cube management
|
||||
.add_plugins(CubePlugin)
|
||||
// Debug UI
|
||||
.add_plugins(DebugUiPlugin)
|
||||
// Gossip networking setup
|
||||
.add_systems(Startup, setup_gossip_networking)
|
||||
.add_systems(Update, poll_gossip_bridge)
|
||||
.run();
|
||||
// Create EngineBridge (for communication between Bevy and EngineCore)
|
||||
let (engine_bridge, engine_handle) = EngineBridge::new();
|
||||
info!("EngineBridge created");
|
||||
|
||||
// Spawn EngineCore on tokio runtime (runs in background thread)
|
||||
std::thread::spawn(move || {
|
||||
info!("Starting EngineCore on tokio runtime...");
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let core = EngineCore::new(engine_handle, &db_path_str);
|
||||
core.run().await;
|
||||
});
|
||||
});
|
||||
info!("EngineCore spawned in background");
|
||||
|
||||
// Create Bevy app (without winit - we own the event loop)
|
||||
let mut app = App::new();
|
||||
|
||||
// Insert EngineBridge as a resource for Bevy systems to use
|
||||
app.insert_resource(engine_bridge);
|
||||
|
||||
// Use DefaultPlugins but disable winit/window/input (we own those)
|
||||
app.add_plugins(
|
||||
DefaultPlugins
|
||||
.build()
|
||||
.disable::<bevy::log::LogPlugin>() // Using tracing-subscriber
|
||||
.disable::<bevy::winit::WinitPlugin>() // We own winit
|
||||
.disable::<bevy::window::WindowPlugin>() // We own the window
|
||||
.disable::<bevy::input::InputPlugin>() // We provide InputEvents directly
|
||||
.disable::<bevy::gilrs::GilrsPlugin>() // We handle gamepad input ourselves
|
||||
);
|
||||
|
||||
// app.add_plugins(EguiPlugin::default()); // Disabled - needs WinitPlugin
|
||||
app.add_plugins(EngineBridgePlugin);
|
||||
app.add_plugins(PersistencePlugin::with_config(
|
||||
db_path,
|
||||
PersistenceConfig {
|
||||
flush_interval_secs: 2,
|
||||
checkpoint_interval_secs: 30,
|
||||
battery_adaptive: true,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
app.add_plugins(CameraPlugin);
|
||||
app.add_plugins(RenderingPlugin);
|
||||
app.add_plugins(input::InputHandlerPlugin);
|
||||
app.add_plugins(CubePlugin);
|
||||
app.add_plugins(SelectionPlugin);
|
||||
// app.add_plugins(DebugUiPlugin); // Disabled - uses egui
|
||||
// app.add_plugins(SessionUiPlugin); // Disabled - uses egui
|
||||
app.add_systems(Startup, initialize_offline_resources);
|
||||
|
||||
// Run with our executor (unbounded event loop)
|
||||
executor::run(app).expect("Failed to run executor");
|
||||
}
|
||||
|
||||
217
crates/app/src/selection.rs
Normal file
217
crates/app/src/selection.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! Entity selection and lock acquisition
|
||||
//!
|
||||
//! Handles clicking/tapping on entities to select them, acquiring locks,
|
||||
//! and providing visual feedback based on lock state.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use libmarathon::networking::{
|
||||
EntityLockRegistry,
|
||||
GossipBridge,
|
||||
LockMessage,
|
||||
NetworkedEntity,
|
||||
NetworkedSelection,
|
||||
NodeVectorClock,
|
||||
SyncMessage,
|
||||
VersionedMessage,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cube::CubeMarker;
|
||||
|
||||
pub struct SelectionPlugin;
|
||||
|
||||
impl Plugin for SelectionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_entity_click,
|
||||
handle_deselect_key,
|
||||
update_lock_visuals,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// System to handle clicking/tapping on entities to select and acquire locks
|
||||
fn handle_entity_click(
|
||||
mouse_button: Res<ButtonInput<MouseButton>>,
|
||||
windows: Query<&Window>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
cubes: Query<(Entity, &Transform, &NetworkedEntity), With<CubeMarker>>,
|
||||
mut selections: Query<&mut NetworkedSelection>,
|
||||
mut lock_registry: ResMut<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
bridge: Option<Res<GossipBridge>>,
|
||||
) {
|
||||
// Only on left click press
|
||||
if !mouse_button.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(cursor_pos) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
let Ok((camera, cam_transform)) = cameras.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Cast ray from cursor
|
||||
let Ok(ray) = camera.viewport_to_world(cam_transform, cursor_pos) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find closest cube that intersects ray
|
||||
let mut closest: Option<(f32, Entity, Uuid)> = None;
|
||||
|
||||
for (entity, transform, networked) in cubes.iter() {
|
||||
// Simple sphere collision (approximate cube as sphere with radius ~0.7)
|
||||
let to_cube = transform.translation - ray.origin;
|
||||
let t = to_cube.dot(*ray.direction);
|
||||
if t < 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let closest_point = ray.origin + *ray.direction * t;
|
||||
let distance = (closest_point - transform.translation).length();
|
||||
|
||||
if distance < 0.7 {
|
||||
// Hit!
|
||||
if let Some((best_dist, _, _)) = closest {
|
||||
if t < best_dist {
|
||||
closest = Some((t, entity, networked.network_id));
|
||||
}
|
||||
} else {
|
||||
closest = Some((t, entity, networked.network_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process result
|
||||
if let Some((_, bevy_entity, entity_id)) = closest {
|
||||
// Clicked on a cube - try to acquire lock
|
||||
match lock_registry.try_acquire(entity_id, node_clock.node_id) {
|
||||
Ok(()) => {
|
||||
info!("Lock acquired for {}", entity_id);
|
||||
|
||||
// Update selection component
|
||||
if let Ok(mut selection) = selections.get_mut(bevy_entity) {
|
||||
selection.selected_ids.clear(); // Clear previous selections
|
||||
selection.selected_ids.insert(entity_id);
|
||||
}
|
||||
|
||||
// Broadcast LockRequest to other nodes
|
||||
if let Some(bridge) = bridge.as_ref() {
|
||||
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRequest {
|
||||
entity_id,
|
||||
node_id: node_clock.node_id,
|
||||
}));
|
||||
if let Err(e) = bridge.send(msg) {
|
||||
error!("Failed to broadcast lock request: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to acquire lock for {}: {}", entity_id, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clicked on empty space - deselect all and release locks
|
||||
for mut selection in selections.iter_mut() {
|
||||
// Release all locks we're holding
|
||||
for entity_id in selection.selected_ids.iter() {
|
||||
lock_registry.release(*entity_id, node_clock.node_id);
|
||||
info!("Released lock for {} (clicked away)", entity_id);
|
||||
|
||||
// Broadcast LockRelease to other nodes
|
||||
if let Some(bridge) = bridge.as_ref() {
|
||||
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRelease {
|
||||
entity_id: *entity_id,
|
||||
node_id: node_clock.node_id,
|
||||
}));
|
||||
if let Err(e) = bridge.send(msg) {
|
||||
error!("Failed to broadcast lock release: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selection.selected_ids.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System to handle ESC key for deselection
|
||||
fn handle_deselect_key(
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
mut selections: Query<&mut NetworkedSelection>,
|
||||
mut lock_registry: ResMut<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
bridge: Option<Res<GossipBridge>>,
|
||||
) {
|
||||
if keyboard.just_pressed(KeyCode::Escape) {
|
||||
for mut selection in selections.iter_mut() {
|
||||
if !selection.selected_ids.is_empty() {
|
||||
info!("Deselecting {} entities via ESC key", selection.selected_ids.len());
|
||||
|
||||
// Release all locks we're holding
|
||||
for entity_id in selection.selected_ids.iter() {
|
||||
lock_registry.release(*entity_id, node_clock.node_id);
|
||||
info!("Released lock for {} (ESC key)", entity_id);
|
||||
|
||||
// Broadcast LockRelease to other nodes
|
||||
if let Some(bridge) = bridge.as_ref() {
|
||||
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRelease {
|
||||
entity_id: *entity_id,
|
||||
node_id: node_clock.node_id,
|
||||
}));
|
||||
if let Err(e) = bridge.send(msg) {
|
||||
error!("Failed to broadcast lock release: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selection.selected_ids.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System to update visual appearance based on lock state
|
||||
///
|
||||
/// Color scheme:
|
||||
/// - Green: Locked by us (we can edit)
|
||||
/// - Red: Locked by someone else (they can edit, we can't)
|
||||
/// - Pink: Not locked (nobody is editing)
|
||||
fn update_lock_visuals(
|
||||
lock_registry: Res<EntityLockRegistry>,
|
||||
node_clock: Res<NodeVectorClock>,
|
||||
mut cubes: Query<(&NetworkedEntity, &mut MeshMaterial3d<StandardMaterial>), With<CubeMarker>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
) {
|
||||
|
||||
for (networked, material_handle) in cubes.iter_mut() {
|
||||
let entity_id = networked.network_id;
|
||||
|
||||
// Determine color based on lock state
|
||||
let node_id = node_clock.node_id;
|
||||
let color = if lock_registry.is_locked_by(entity_id, node_id, node_id) {
|
||||
// Locked by us - green
|
||||
Color::srgb(0.3, 0.8, 0.3)
|
||||
} else if lock_registry.is_locked(entity_id, node_id) {
|
||||
// Locked by someone else - red
|
||||
Color::srgb(0.8, 0.3, 0.3)
|
||||
} else {
|
||||
// Not locked - default pink
|
||||
Color::srgb(0.8, 0.3, 0.6)
|
||||
};
|
||||
|
||||
// Update material color
|
||||
if let Some(mat) = materials.get_mut(&material_handle.0) {
|
||||
mat.base_color = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
crates/app/src/session.rs
Normal file
36
crates/app/src/session.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! App-level offline resource management
|
||||
//!
|
||||
//! Sets up vector clock and networking resources. Sessions are created later
|
||||
//! when the user starts networking.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use libmarathon::{
|
||||
networking::{
|
||||
EntityLockRegistry, NetworkEntityMap, NodeVectorClock, VectorClock,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Initialize offline resources on app startup
|
||||
///
|
||||
/// This sets up the vector clock and networking-related resources, but does NOT
|
||||
/// create a session. Sessions only exist when networking is active.
|
||||
pub fn initialize_offline_resources(world: &mut World) {
|
||||
info!("Initializing offline resources (no session yet)...");
|
||||
|
||||
// Create node ID (persists for this app instance)
|
||||
let node_id = Uuid::new_v4();
|
||||
info!("Node ID: {}", node_id);
|
||||
|
||||
// Insert vector clock resource (always available, offline or online)
|
||||
world.insert_resource(NodeVectorClock {
|
||||
node_id,
|
||||
clock: VectorClock::new(),
|
||||
});
|
||||
|
||||
// Insert networking resources (available from startup, even before networking starts)
|
||||
world.insert_resource(NetworkEntityMap::default());
|
||||
world.insert_resource(EntityLockRegistry::default());
|
||||
|
||||
info!("Offline resources initialized (vector clock ready)");
|
||||
}
|
||||
141
crates/app/src/session_ui.rs
Normal file
141
crates/app/src/session_ui.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Session UI panel
|
||||
//!
|
||||
//! Displays current session code, allows joining different sessions,
|
||||
//! and shows connected peer information.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
|
||||
use libmarathon::{
|
||||
engine::{EngineBridge, EngineCommand},
|
||||
networking::{CurrentSession, NodeVectorClock, SessionId},
|
||||
};
|
||||
|
||||
pub struct SessionUiPlugin;
|
||||
|
||||
impl Plugin for SessionUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SessionUiState>()
|
||||
.add_systems(EguiPrimaryContextPass, session_ui_panel);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct SessionUiState {
|
||||
join_code_input: String,
|
||||
show_join_dialog: bool,
|
||||
}
|
||||
|
||||
fn session_ui_panel(
|
||||
mut contexts: EguiContexts,
|
||||
mut ui_state: ResMut<SessionUiState>,
|
||||
current_session: Option<Res<CurrentSession>>,
|
||||
node_clock: Option<Res<NodeVectorClock>>,
|
||||
bridge: Res<EngineBridge>,
|
||||
) {
|
||||
let Ok(ctx) = contexts.ctx_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
egui::Window::new("Session")
|
||||
.default_pos([320.0, 10.0])
|
||||
.default_width(280.0)
|
||||
.show(ctx, |ui| {
|
||||
if let Some(session) = current_session.as_ref() {
|
||||
// ONLINE MODE: Session exists, networking is active
|
||||
ui.heading("Session (Online)");
|
||||
ui.separator();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Code:");
|
||||
ui.code(session.session.id.to_code());
|
||||
if ui.small_button("📋").clicked() {
|
||||
// TODO: Copy to clipboard (requires clipboard API)
|
||||
info!("Session code: {}", session.session.id.to_code());
|
||||
}
|
||||
});
|
||||
|
||||
ui.label(format!("State: {:?}", session.session.state));
|
||||
|
||||
if let Some(clock) = node_clock.as_ref() {
|
||||
ui.label(format!("Connected nodes: {}", clock.clock.clocks.len()));
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Stop networking button
|
||||
if ui.button("🔌 Stop Networking").clicked() {
|
||||
info!("Stopping networking");
|
||||
bridge.send_command(EngineCommand::StopNetworking);
|
||||
}
|
||||
} else {
|
||||
// OFFLINE MODE: No session, networking not started
|
||||
ui.heading("Offline Mode");
|
||||
ui.separator();
|
||||
|
||||
ui.label("World is running offline");
|
||||
ui.label("Vector clock is tracking changes");
|
||||
|
||||
if let Some(clock) = node_clock.as_ref() {
|
||||
let current_seq = clock.clock.clocks.get(&clock.node_id).copied().unwrap_or(0);
|
||||
ui.label(format!("Local sequence: {}", current_seq));
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Start networking button
|
||||
if ui.button("🌐 Start Networking").clicked() {
|
||||
info!("Starting networking (will create new session)");
|
||||
// Generate a new session ID on the fly
|
||||
let new_session_id = libmarathon::networking::SessionId::new();
|
||||
info!("New session code: {}", new_session_id.to_code());
|
||||
bridge.send_command(EngineCommand::StartNetworking {
|
||||
session_id: new_session_id,
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(5.0);
|
||||
|
||||
// Join existing session button
|
||||
if ui.button("➕ Join Session").clicked() {
|
||||
ui_state.show_join_dialog = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Join dialog (using same context)
|
||||
if ui_state.show_join_dialog {
|
||||
egui::Window::new("Join Session")
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Enter session code (abc-def-123):");
|
||||
ui.text_edit_singleline(&mut ui_state.join_code_input);
|
||||
|
||||
ui.add_space(5.0);
|
||||
ui.label("Note: Joining requires app restart");
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Join").clicked() {
|
||||
match SessionId::from_code(&ui_state.join_code_input) {
|
||||
Ok(session_id) => {
|
||||
info!("Joining session: {} → {}", ui_state.join_code_input, session_id);
|
||||
bridge.send_command(EngineCommand::JoinSession {
|
||||
session_id,
|
||||
});
|
||||
ui_state.show_join_dialog = false;
|
||||
ui_state.join_code_input.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid session code '{}': {:?}", ui_state.join_code_input, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Cancel").clicked() {
|
||||
ui_state.show_join_dialog = false;
|
||||
ui_state.join_code_input.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use bevy::prelude::*;
|
||||
use lib::networking::{GossipBridge, SessionId};
|
||||
use libmarathon::networking::{GossipBridge, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Session ID to use for network initialization
|
||||
@@ -271,7 +271,7 @@ fn spawn_bridge_tasks(
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_lite::StreamExt;
|
||||
use lib::networking::VersionedMessage;
|
||||
use libmarathon::networking::VersionedMessage;
|
||||
|
||||
let node_id = bridge.node_id();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user