initial arhitectural overhaul

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-13 22:22:05 +00:00
parent 9d4e603db3
commit bc5b013582
99 changed files with 4137 additions and 311 deletions

View File

@@ -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);
}
}
}

View File

@@ -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");
});
}

View 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
View 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(())
}

View 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,
});
}
}
}

View 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();
}
}

View 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;
}
}
}

View 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;

View File

@@ -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
}
}
}

View 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);
}
}
}

View File

@@ -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;

View File

@@ -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
View 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
View 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)");
}

View 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();
}
});
});
}
}

View File

@@ -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();