initial arhitectural overhaul
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
72
crates/libmarathon/src/engine/bridge.rs
Normal file
72
crates/libmarathon/src/engine/bridge.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Bridge between Bevy and Core Engine
|
||||
//!
|
||||
//! TODO(Phase 3): Create a Bevy-specific system (in app crate) that polls
|
||||
//! `EngineBridge::poll_events()` every tick and dispatches EngineEvents to Bevy
|
||||
//! (spawn entities, update transforms, update locks, emit Bevy messages, etc.)
|
||||
//!
|
||||
//! NOTE: The bridge is ECS-agnostic. Later we can create adapters for other engines
|
||||
//! like Flecs once we're closer to release.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use bevy::prelude::Resource;
|
||||
|
||||
use super::{EngineCommand, EngineEvent};
|
||||
|
||||
/// Shared bridge between Bevy and Core Engine
|
||||
#[derive(Clone, Resource)]
|
||||
pub struct EngineBridge {
|
||||
command_tx: mpsc::UnboundedSender<EngineCommand>,
|
||||
event_rx: Arc<Mutex<mpsc::UnboundedReceiver<EngineEvent>>>,
|
||||
}
|
||||
|
||||
/// Engine-side handle for receiving commands and sending events
|
||||
pub struct EngineHandle {
|
||||
pub(crate) command_rx: mpsc::UnboundedReceiver<EngineCommand>,
|
||||
pub(crate) event_tx: mpsc::UnboundedSender<EngineEvent>,
|
||||
}
|
||||
|
||||
impl EngineBridge {
|
||||
/// Create a new bridge and return both the Bevy-side bridge and Engine-side handle
|
||||
pub fn new() -> (Self, EngineHandle) {
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let bridge = Self {
|
||||
command_tx,
|
||||
event_rx: Arc::new(Mutex::new(event_rx)),
|
||||
};
|
||||
|
||||
let handle = EngineHandle {
|
||||
command_rx,
|
||||
event_tx,
|
||||
};
|
||||
|
||||
(bridge, handle)
|
||||
}
|
||||
|
||||
/// Send command from Bevy to Engine
|
||||
pub fn send_command(&self, cmd: EngineCommand) {
|
||||
// Ignore send errors (engine might be shut down)
|
||||
let _ = self.command_tx.send(cmd);
|
||||
}
|
||||
|
||||
/// Poll events from Engine to Bevy (non-blocking)
|
||||
/// Returns all available events in the queue
|
||||
pub fn poll_events(&self) -> Vec<EngineEvent> {
|
||||
let mut events = Vec::new();
|
||||
// Try to lock without blocking (returns immediately if locked)
|
||||
if let Ok(mut rx) = self.event_rx.try_lock() {
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EngineBridge {
|
||||
fn default() -> Self {
|
||||
Self::new().0
|
||||
}
|
||||
}
|
||||
50
crates/libmarathon/src/engine/commands.rs
Normal file
50
crates/libmarathon/src/engine/commands.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Commands sent from Bevy to the Core Engine
|
||||
|
||||
use crate::networking::SessionId;
|
||||
use bevy::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Commands that Bevy sends to the Core Engine
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EngineCommand {
|
||||
// Networking lifecycle
|
||||
StartNetworking { session_id: SessionId },
|
||||
StopNetworking,
|
||||
JoinSession { session_id: SessionId },
|
||||
LeaveSession,
|
||||
|
||||
// CRDT operations
|
||||
SpawnEntity {
|
||||
entity_id: Uuid,
|
||||
position: Vec3,
|
||||
rotation: Quat,
|
||||
},
|
||||
UpdateTransform {
|
||||
entity_id: Uuid,
|
||||
position: Vec3,
|
||||
rotation: Quat,
|
||||
},
|
||||
DeleteEntity {
|
||||
entity_id: Uuid,
|
||||
},
|
||||
|
||||
// Lock operations
|
||||
AcquireLock {
|
||||
entity_id: Uuid,
|
||||
},
|
||||
ReleaseLock {
|
||||
entity_id: Uuid,
|
||||
},
|
||||
BroadcastHeartbeat {
|
||||
entity_id: Uuid,
|
||||
},
|
||||
|
||||
// Persistence
|
||||
SaveSession,
|
||||
LoadSession {
|
||||
session_id: SessionId,
|
||||
},
|
||||
|
||||
// Clock
|
||||
TickClock,
|
||||
}
|
||||
140
crates/libmarathon/src/engine/core.rs
Normal file
140
crates/libmarathon/src/engine/core.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Core Engine event loop - runs on tokio outside Bevy
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{EngineCommand, EngineEvent, EngineHandle, NetworkingManager, PersistenceManager};
|
||||
use crate::networking::{SessionId, VectorClock};
|
||||
|
||||
pub struct EngineCore {
|
||||
handle: EngineHandle,
|
||||
networking_task: Option<JoinHandle<()>>,
|
||||
#[allow(dead_code)]
|
||||
persistence: PersistenceManager,
|
||||
|
||||
// Clock state
|
||||
node_id: Uuid,
|
||||
clock: VectorClock,
|
||||
}
|
||||
|
||||
impl EngineCore {
|
||||
pub fn new(handle: EngineHandle, db_path: &str) -> Self {
|
||||
let persistence = PersistenceManager::new(db_path);
|
||||
let node_id = Uuid::new_v4();
|
||||
let clock = VectorClock::new();
|
||||
|
||||
tracing::info!("EngineCore node ID: {}", node_id);
|
||||
|
||||
Self {
|
||||
handle,
|
||||
networking_task: None, // Start offline
|
||||
persistence,
|
||||
node_id,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the engine event loop (runs on tokio)
|
||||
/// Processes commands unbounded - tokio handles internal polling
|
||||
pub async fn run(mut self) {
|
||||
tracing::info!("EngineCore starting (unbounded)...");
|
||||
|
||||
// Process commands as they arrive
|
||||
while let Some(cmd) = self.handle.command_rx.recv().await {
|
||||
self.handle_command(cmd).await;
|
||||
}
|
||||
|
||||
tracing::info!("EngineCore shutting down (command channel closed)");
|
||||
}
|
||||
|
||||
async fn handle_command(&mut self, cmd: EngineCommand) {
|
||||
match cmd {
|
||||
EngineCommand::StartNetworking { session_id } => {
|
||||
self.start_networking(session_id).await;
|
||||
}
|
||||
EngineCommand::StopNetworking => {
|
||||
self.stop_networking().await;
|
||||
}
|
||||
EngineCommand::JoinSession { session_id } => {
|
||||
self.join_session(session_id).await;
|
||||
}
|
||||
EngineCommand::LeaveSession => {
|
||||
self.stop_networking().await;
|
||||
}
|
||||
EngineCommand::SaveSession => {
|
||||
// TODO: Save current session state
|
||||
tracing::debug!("SaveSession command received (stub)");
|
||||
}
|
||||
EngineCommand::LoadSession { session_id } => {
|
||||
tracing::debug!("LoadSession command received for {} (stub)", session_id.to_code());
|
||||
}
|
||||
EngineCommand::TickClock => {
|
||||
self.tick_clock();
|
||||
}
|
||||
// TODO: Handle CRDT and lock commands in Phase 2
|
||||
_ => {
|
||||
tracing::debug!("Unhandled command: {:?}", cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_clock(&mut self) {
|
||||
let seq = self.clock.increment(self.node_id);
|
||||
let _ = self.handle.event_tx.send(EngineEvent::ClockTicked {
|
||||
sequence: seq,
|
||||
clock: self.clock.clone(),
|
||||
});
|
||||
tracing::debug!("Clock ticked to {}", seq);
|
||||
}
|
||||
|
||||
async fn start_networking(&mut self, session_id: SessionId) {
|
||||
if self.networking_task.is_some() {
|
||||
tracing::warn!("Networking already started");
|
||||
return;
|
||||
}
|
||||
|
||||
match NetworkingManager::new(session_id.clone()).await {
|
||||
Ok(net_manager) => {
|
||||
let node_id = net_manager.node_id();
|
||||
|
||||
// Spawn NetworkingManager in background task
|
||||
let event_tx = self.handle.event_tx.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
net_manager.run(event_tx).await;
|
||||
});
|
||||
|
||||
self.networking_task = Some(task);
|
||||
|
||||
let _ = self.handle.event_tx.send(EngineEvent::NetworkingStarted {
|
||||
session_id: session_id.clone(),
|
||||
node_id,
|
||||
});
|
||||
tracing::info!("Networking started for session {}", session_id.to_code());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.handle.event_tx.send(EngineEvent::NetworkingFailed {
|
||||
error: e.to_string(),
|
||||
});
|
||||
tracing::error!("Failed to start networking: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_networking(&mut self) {
|
||||
if let Some(task) = self.networking_task.take() {
|
||||
task.abort(); // Cancel the networking task
|
||||
let _ = self.handle.event_tx.send(EngineEvent::NetworkingStopped);
|
||||
tracing::info!("Networking stopped");
|
||||
}
|
||||
}
|
||||
|
||||
async fn join_session(&mut self, session_id: SessionId) {
|
||||
// Stop existing networking if any
|
||||
if self.networking_task.is_some() {
|
||||
self.stop_networking().await;
|
||||
}
|
||||
|
||||
// Start networking with new session
|
||||
self.start_networking(session_id).await;
|
||||
}
|
||||
}
|
||||
71
crates/libmarathon/src/engine/events.rs
Normal file
71
crates/libmarathon/src/engine/events.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Events emitted from the Core Engine to Bevy
|
||||
|
||||
use crate::networking::{NodeId, SessionId, VectorClock};
|
||||
use bevy::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Events that the Core Engine emits to Bevy
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EngineEvent {
|
||||
// Networking status
|
||||
NetworkingStarted {
|
||||
session_id: SessionId,
|
||||
node_id: NodeId,
|
||||
},
|
||||
NetworkingFailed {
|
||||
error: String,
|
||||
},
|
||||
NetworkingStopped,
|
||||
SessionJoined {
|
||||
session_id: SessionId,
|
||||
},
|
||||
SessionLeft,
|
||||
|
||||
// Peer events
|
||||
PeerJoined {
|
||||
node_id: NodeId,
|
||||
},
|
||||
PeerLeft {
|
||||
node_id: NodeId,
|
||||
},
|
||||
|
||||
// CRDT sync events
|
||||
EntitySpawned {
|
||||
entity_id: Uuid,
|
||||
position: Vec3,
|
||||
rotation: Quat,
|
||||
version: VectorClock,
|
||||
},
|
||||
EntityUpdated {
|
||||
entity_id: Uuid,
|
||||
position: Vec3,
|
||||
rotation: Quat,
|
||||
version: VectorClock,
|
||||
},
|
||||
EntityDeleted {
|
||||
entity_id: Uuid,
|
||||
version: VectorClock,
|
||||
},
|
||||
|
||||
// Lock events
|
||||
LockAcquired {
|
||||
entity_id: Uuid,
|
||||
holder: NodeId,
|
||||
},
|
||||
LockReleased {
|
||||
entity_id: Uuid,
|
||||
},
|
||||
LockDenied {
|
||||
entity_id: Uuid,
|
||||
current_holder: NodeId,
|
||||
},
|
||||
LockExpired {
|
||||
entity_id: Uuid,
|
||||
},
|
||||
|
||||
// Clock events
|
||||
ClockTicked {
|
||||
sequence: u64,
|
||||
clock: VectorClock,
|
||||
},
|
||||
}
|
||||
118
crates/libmarathon/src/engine/game_actions.rs
Normal file
118
crates/libmarathon/src/engine/game_actions.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! Semantic game actions
|
||||
//!
|
||||
//! Actions represent what the player wants to do, independent of how they
|
||||
//! triggered it. This enables input remapping and accessibility.
|
||||
|
||||
use glam::Vec2;
|
||||
|
||||
/// High-level game actions that result from input processing
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GameAction {
|
||||
/// Move an entity in 2D (XY plane)
|
||||
MoveEntity {
|
||||
/// Movement delta (in screen/world space)
|
||||
delta: Vec2,
|
||||
},
|
||||
|
||||
/// Rotate an entity
|
||||
RotateEntity {
|
||||
/// Rotation delta (yaw, pitch)
|
||||
delta: Vec2,
|
||||
},
|
||||
|
||||
/// Move entity along Z axis (depth)
|
||||
MoveEntityDepth {
|
||||
/// Depth delta
|
||||
delta: f32,
|
||||
},
|
||||
|
||||
/// Select/deselect an entity at a position
|
||||
SelectEntity {
|
||||
/// Screen position
|
||||
position: Vec2,
|
||||
},
|
||||
|
||||
/// Begin dragging at a position
|
||||
BeginDrag {
|
||||
/// Screen position
|
||||
position: Vec2,
|
||||
},
|
||||
|
||||
/// Continue dragging
|
||||
ContinueDrag {
|
||||
/// Current screen position
|
||||
position: Vec2,
|
||||
/// Delta since last drag event
|
||||
delta: Vec2,
|
||||
},
|
||||
|
||||
/// End dragging
|
||||
EndDrag {
|
||||
/// Final screen position
|
||||
position: Vec2,
|
||||
},
|
||||
|
||||
/// Reset entity to default state
|
||||
ResetEntity,
|
||||
|
||||
/// Delete selected entity
|
||||
DeleteEntity,
|
||||
|
||||
/// Spawn new entity at position
|
||||
SpawnEntity {
|
||||
/// Screen position
|
||||
position: Vec2,
|
||||
},
|
||||
|
||||
/// Camera movement
|
||||
MoveCamera {
|
||||
/// Movement delta
|
||||
delta: Vec2,
|
||||
},
|
||||
|
||||
/// Camera zoom
|
||||
ZoomCamera {
|
||||
/// Zoom delta
|
||||
delta: f32,
|
||||
},
|
||||
|
||||
/// Toggle UI panel
|
||||
ToggleUI,
|
||||
|
||||
/// Confirm action (Enter, Space, etc.)
|
||||
Confirm,
|
||||
|
||||
/// Cancel action (Escape, etc.)
|
||||
Cancel,
|
||||
|
||||
/// Undo last action
|
||||
Undo,
|
||||
|
||||
/// Redo last undone action
|
||||
Redo,
|
||||
}
|
||||
|
||||
impl GameAction {
|
||||
/// Get a human-readable description of this action
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
GameAction::MoveEntity { .. } => "Move entity in XY plane",
|
||||
GameAction::RotateEntity { .. } => "Rotate entity",
|
||||
GameAction::MoveEntityDepth { .. } => "Move entity along Z axis",
|
||||
GameAction::SelectEntity { .. } => "Select/deselect entity",
|
||||
GameAction::BeginDrag { .. } => "Begin dragging",
|
||||
GameAction::ContinueDrag { .. } => "Continue dragging",
|
||||
GameAction::EndDrag { .. } => "End dragging",
|
||||
GameAction::ResetEntity => "Reset entity to default",
|
||||
GameAction::DeleteEntity => "Delete selected entity",
|
||||
GameAction::SpawnEntity { .. } => "Spawn new entity",
|
||||
GameAction::MoveCamera { .. } => "Move camera",
|
||||
GameAction::ZoomCamera { .. } => "Zoom camera",
|
||||
GameAction::ToggleUI => "Toggle UI panel",
|
||||
GameAction::Confirm => "Confirm",
|
||||
GameAction::Cancel => "Cancel",
|
||||
GameAction::Undo => "Undo",
|
||||
GameAction::Redo => "Redo",
|
||||
}
|
||||
}
|
||||
}
|
||||
337
crates/libmarathon/src/engine/input_controller.rs
Normal file
337
crates/libmarathon/src/engine/input_controller.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
//! Input controller - maps raw InputEvents to semantic GameActions
|
||||
//!
|
||||
//! This layer provides:
|
||||
//! - Input remapping (change key bindings)
|
||||
//! - Accessibility (alternative input methods)
|
||||
//! - Context-aware bindings (different actions in different modes)
|
||||
|
||||
use super::game_actions::GameAction;
|
||||
use super::input_events::{InputEvent, KeyCode, MouseButton, TouchPhase};
|
||||
use glam::Vec2;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Input binding - maps an input trigger to a game action
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum InputBinding {
|
||||
/// Mouse button press/release
|
||||
MouseButton(MouseButton),
|
||||
|
||||
/// Mouse drag with a specific button
|
||||
MouseDrag(MouseButton),
|
||||
|
||||
/// Mouse wheel scroll
|
||||
MouseWheel,
|
||||
|
||||
/// Keyboard key press
|
||||
Key(KeyCode),
|
||||
|
||||
/// Keyboard key with modifiers
|
||||
KeyWithModifiers {
|
||||
key: KeyCode,
|
||||
shift: bool,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
meta: bool,
|
||||
},
|
||||
|
||||
/// Stylus input (Apple Pencil, etc.)
|
||||
StylusDrag,
|
||||
|
||||
/// Touch input
|
||||
TouchDrag,
|
||||
}
|
||||
|
||||
/// Input context - different binding sets for different game modes
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum InputContext {
|
||||
/// Manipulating 3D entities
|
||||
EntityManipulation,
|
||||
|
||||
/// Camera control
|
||||
CameraControl,
|
||||
|
||||
/// UI interaction
|
||||
UI,
|
||||
|
||||
/// Text input
|
||||
TextInput,
|
||||
}
|
||||
|
||||
/// Accessibility settings for input processing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessibilitySettings {
|
||||
/// Mouse sensitivity multiplier (1.0 = normal)
|
||||
pub mouse_sensitivity: f32,
|
||||
|
||||
/// Scroll sensitivity multiplier (1.0 = normal)
|
||||
pub scroll_sensitivity: f32,
|
||||
|
||||
/// Stylus pressure sensitivity (1.0 = normal)
|
||||
pub stylus_sensitivity: f32,
|
||||
|
||||
/// Enable one-handed mode (use keyboard for rotation)
|
||||
pub one_handed_mode: bool,
|
||||
|
||||
/// Invert Y axis for rotation
|
||||
pub invert_y: bool,
|
||||
|
||||
/// Minimum drag distance before registering as drag (in pixels)
|
||||
pub drag_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for AccessibilitySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mouse_sensitivity: 1.0,
|
||||
scroll_sensitivity: 1.0,
|
||||
stylus_sensitivity: 1.0,
|
||||
one_handed_mode: false,
|
||||
invert_y: false,
|
||||
drag_threshold: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Input controller - converts InputEvents to GameActions
|
||||
pub struct InputController {
|
||||
/// Current input context
|
||||
current_context: InputContext,
|
||||
|
||||
/// Bindings for each context
|
||||
bindings: HashMap<InputContext, HashMap<InputBinding, GameAction>>,
|
||||
|
||||
/// Accessibility settings
|
||||
accessibility: AccessibilitySettings,
|
||||
|
||||
/// Drag state tracking
|
||||
drag_state: DragState,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DragState {
|
||||
/// Is currently dragging
|
||||
active: bool,
|
||||
|
||||
/// Which button/input is dragging
|
||||
source: Option<DragSource>,
|
||||
|
||||
/// Start position
|
||||
start_pos: Vec2,
|
||||
|
||||
/// Last position
|
||||
last_pos: Vec2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DragSource {
|
||||
MouseLeft,
|
||||
MouseRight,
|
||||
Stylus,
|
||||
Touch,
|
||||
}
|
||||
|
||||
impl InputController {
|
||||
/// Create a new input controller with default bindings
|
||||
pub fn new() -> Self {
|
||||
let mut controller = Self {
|
||||
current_context: InputContext::EntityManipulation,
|
||||
bindings: HashMap::new(),
|
||||
accessibility: AccessibilitySettings::default(),
|
||||
drag_state: DragState::default(),
|
||||
};
|
||||
|
||||
controller.setup_default_bindings();
|
||||
controller
|
||||
}
|
||||
|
||||
/// Set the current input context
|
||||
pub fn set_context(&mut self, context: InputContext) {
|
||||
self.current_context = context;
|
||||
}
|
||||
|
||||
/// Get the current context
|
||||
pub fn context(&self) -> InputContext {
|
||||
self.current_context
|
||||
}
|
||||
|
||||
/// Update accessibility settings
|
||||
pub fn set_accessibility(&mut self, settings: AccessibilitySettings) {
|
||||
self.accessibility = settings;
|
||||
}
|
||||
|
||||
/// Get current accessibility settings
|
||||
pub fn accessibility(&self) -> &AccessibilitySettings {
|
||||
&self.accessibility
|
||||
}
|
||||
|
||||
/// Process an input event and produce game actions
|
||||
pub fn process_event(&mut self, event: &InputEvent) -> Vec<GameAction> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
match event {
|
||||
InputEvent::Mouse { pos, button, phase } => {
|
||||
self.process_mouse(*pos, *button, *phase, &mut actions);
|
||||
}
|
||||
|
||||
InputEvent::MouseWheel { delta, pos: _ } => {
|
||||
let adjusted_delta = delta.y * self.accessibility.scroll_sensitivity;
|
||||
actions.push(GameAction::MoveEntityDepth { delta: adjusted_delta });
|
||||
}
|
||||
|
||||
InputEvent::Keyboard { key, pressed, modifiers: _ } => {
|
||||
if *pressed {
|
||||
self.process_key(*key, &mut actions);
|
||||
}
|
||||
}
|
||||
|
||||
InputEvent::Stylus { pos, pressure: _, tilt: _, phase, timestamp: _ } => {
|
||||
self.process_stylus(*pos, *phase, &mut actions);
|
||||
}
|
||||
|
||||
InputEvent::Touch { pos, phase, id: _ } => {
|
||||
self.process_touch(*pos, *phase, &mut actions);
|
||||
}
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
/// Process mouse input
|
||||
fn process_mouse(&mut self, pos: Vec2, button: MouseButton, phase: TouchPhase, actions: &mut Vec<GameAction>) {
|
||||
match phase {
|
||||
TouchPhase::Started => {
|
||||
// Single click = select
|
||||
actions.push(GameAction::SelectEntity { position: pos });
|
||||
|
||||
// Start drag tracking
|
||||
self.drag_state.active = true;
|
||||
self.drag_state.source = Some(match button {
|
||||
MouseButton::Left => DragSource::MouseLeft,
|
||||
MouseButton::Right => DragSource::MouseRight,
|
||||
MouseButton::Middle => return, // Don't handle middle button
|
||||
});
|
||||
self.drag_state.start_pos = pos;
|
||||
self.drag_state.last_pos = pos;
|
||||
|
||||
actions.push(GameAction::BeginDrag { position: pos });
|
||||
}
|
||||
|
||||
TouchPhase::Moved => {
|
||||
if self.drag_state.active {
|
||||
let delta = (pos - self.drag_state.last_pos) * self.accessibility.mouse_sensitivity;
|
||||
self.drag_state.last_pos = pos;
|
||||
|
||||
// Check if we've exceeded drag threshold
|
||||
let total_delta = pos - self.drag_state.start_pos;
|
||||
if total_delta.length() < self.accessibility.drag_threshold {
|
||||
return; // Too small to count as drag
|
||||
}
|
||||
|
||||
actions.push(GameAction::ContinueDrag { position: pos, delta });
|
||||
|
||||
// Context-specific drag actions
|
||||
match self.current_context {
|
||||
InputContext::EntityManipulation => {
|
||||
match self.drag_state.source {
|
||||
Some(DragSource::MouseLeft) => {
|
||||
actions.push(GameAction::MoveEntity { delta });
|
||||
}
|
||||
Some(DragSource::MouseRight) => {
|
||||
let adjusted_delta = if self.accessibility.invert_y {
|
||||
Vec2::new(delta.x, -delta.y)
|
||||
} else {
|
||||
delta
|
||||
};
|
||||
actions.push(GameAction::RotateEntity { delta: adjusted_delta });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
InputContext::CameraControl => {
|
||||
actions.push(GameAction::MoveCamera { delta });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TouchPhase::Ended | TouchPhase::Cancelled => {
|
||||
if self.drag_state.active {
|
||||
actions.push(GameAction::EndDrag { position: pos });
|
||||
self.drag_state.active = false;
|
||||
self.drag_state.source = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process keyboard input
|
||||
fn process_key(&mut self, key: KeyCode, actions: &mut Vec<GameAction>) {
|
||||
match key {
|
||||
KeyCode::KeyR => actions.push(GameAction::ResetEntity),
|
||||
KeyCode::Delete | KeyCode::Backspace => actions.push(GameAction::DeleteEntity),
|
||||
KeyCode::KeyZ if self.accessibility.one_handed_mode => {
|
||||
// In one-handed mode, Z key can trigger actions
|
||||
actions.push(GameAction::Undo);
|
||||
}
|
||||
KeyCode::Escape => actions.push(GameAction::Cancel),
|
||||
KeyCode::Enter => actions.push(GameAction::Confirm),
|
||||
KeyCode::Tab => actions.push(GameAction::ToggleUI),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process stylus input (Apple Pencil, etc.)
|
||||
fn process_stylus(&mut self, pos: Vec2, phase: TouchPhase, actions: &mut Vec<GameAction>) {
|
||||
match phase {
|
||||
TouchPhase::Started => {
|
||||
actions.push(GameAction::SelectEntity { position: pos });
|
||||
actions.push(GameAction::BeginDrag { position: pos });
|
||||
self.drag_state.active = true;
|
||||
self.drag_state.source = Some(DragSource::Stylus);
|
||||
self.drag_state.start_pos = pos;
|
||||
self.drag_state.last_pos = pos;
|
||||
}
|
||||
|
||||
TouchPhase::Moved => {
|
||||
if self.drag_state.active {
|
||||
let delta = (pos - self.drag_state.last_pos) * self.accessibility.stylus_sensitivity;
|
||||
self.drag_state.last_pos = pos;
|
||||
|
||||
actions.push(GameAction::ContinueDrag { position: pos, delta });
|
||||
actions.push(GameAction::MoveEntity { delta });
|
||||
}
|
||||
}
|
||||
|
||||
TouchPhase::Ended | TouchPhase::Cancelled => {
|
||||
if self.drag_state.active {
|
||||
actions.push(GameAction::EndDrag { position: pos });
|
||||
self.drag_state.active = false;
|
||||
self.drag_state.source = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process touch input
|
||||
fn process_touch(&mut self, pos: Vec2, phase: TouchPhase, actions: &mut Vec<GameAction>) {
|
||||
// For now, treat touch like stylus
|
||||
self.process_stylus(pos, phase, actions);
|
||||
}
|
||||
|
||||
/// Set up default input bindings
|
||||
fn setup_default_bindings(&mut self) {
|
||||
// For now, bindings are hardcoded in process_event
|
||||
// Later, we can make this fully data-driven
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InputController {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "input_controller_tests.rs"]
|
||||
mod tests;
|
||||
326
crates/libmarathon/src/engine/input_controller_tests.rs
Normal file
326
crates/libmarathon/src/engine/input_controller_tests.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
//! Unit tests for InputController
|
||||
|
||||
use super::{AccessibilitySettings, InputContext, InputController};
|
||||
use crate::engine::game_actions::GameAction;
|
||||
use crate::engine::input_events::{InputEvent, KeyCode, MouseButton, TouchPhase};
|
||||
use glam::Vec2;
|
||||
|
||||
#[test]
|
||||
fn test_mouse_left_drag_produces_move_entity() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
// Mouse down at (100, 100)
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Started,
|
||||
});
|
||||
|
||||
// Should select entity and begin drag
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::SelectEntity { .. })));
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::BeginDrag { .. })));
|
||||
|
||||
// Mouse drag to (150, 120)
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(150.0, 120.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
|
||||
// Should produce MoveEntity with delta
|
||||
let move_action = actions.iter().find_map(|a| {
|
||||
if let GameAction::MoveEntity { delta } = a {
|
||||
Some(delta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
assert!(move_action.is_some());
|
||||
let delta = move_action.unwrap();
|
||||
assert_eq!(*delta, Vec2::new(50.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mouse_right_drag_produces_rotate_entity() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
// Right mouse down
|
||||
controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
button: MouseButton::Right,
|
||||
phase: TouchPhase::Started,
|
||||
});
|
||||
|
||||
// Right mouse drag
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(120.0, 130.0),
|
||||
button: MouseButton::Right,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
|
||||
// Should produce RotateEntity
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::RotateEntity { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mouse_wheel_produces_depth_movement() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
let actions = controller.process_event(&InputEvent::MouseWheel {
|
||||
delta: Vec2::new(0.0, 10.0),
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
});
|
||||
|
||||
// Should produce MoveEntityDepth
|
||||
let depth_action = actions.iter().find_map(|a| {
|
||||
if let GameAction::MoveEntityDepth { delta } = a {
|
||||
Some(*delta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(depth_action, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keyboard_r_resets_entity() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
let actions = controller.process_event(&InputEvent::Keyboard {
|
||||
key: KeyCode::KeyR,
|
||||
pressed: true,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
|
||||
assert!(actions.contains(&GameAction::ResetEntity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keyboard_delete_removes_entity() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
let actions = controller.process_event(&InputEvent::Keyboard {
|
||||
key: KeyCode::Delete,
|
||||
pressed: true,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
|
||||
assert!(actions.contains(&GameAction::DeleteEntity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drag_threshold_prevents_tiny_movements() {
|
||||
let mut controller = InputController::new();
|
||||
controller.set_accessibility(AccessibilitySettings {
|
||||
drag_threshold: 10.0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Start drag
|
||||
controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Started,
|
||||
});
|
||||
|
||||
// Move only 2 pixels (below threshold of 10)
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(102.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
|
||||
// Should NOT produce MoveEntity (below threshold)
|
||||
assert!(!actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
|
||||
|
||||
// Move 15 pixels total (above threshold)
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(115.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
|
||||
// NOW should produce MoveEntity
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mouse_sensitivity_multiplier() {
|
||||
let mut controller = InputController::new();
|
||||
controller.set_accessibility(AccessibilitySettings {
|
||||
mouse_sensitivity: 2.0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Start drag
|
||||
controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Started,
|
||||
});
|
||||
|
||||
// Move 10 pixels
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(110.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
|
||||
// Delta should be doubled (10 * 2.0 = 20)
|
||||
let delta = actions.iter().find_map(|a| {
|
||||
if let GameAction::MoveEntity { delta } = a {
|
||||
Some(*delta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(delta, Some(Vec2::new(20.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invert_y_axis() {
|
||||
let mut controller = InputController::new();
|
||||
controller.set_accessibility(AccessibilitySettings {
|
||||
invert_y: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Start right-click drag
|
||||
controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
button: MouseButton::Right,
|
||||
phase: TouchPhase::Started,
|
||||
});
|
||||
|
||||
// Drag down (positive Y)
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 110.0),
|
||||
button: MouseButton::Right,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
|
||||
// Y delta should be inverted
|
||||
let delta = actions.iter().find_map(|a| {
|
||||
if let GameAction::RotateEntity { delta } = a {
|
||||
Some(*delta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
assert!(delta.is_some());
|
||||
assert!(delta.unwrap().y < 0.0); // Should be negative (inverted)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drag_sequence_produces_begin_continue_end() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
// Started
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Started,
|
||||
});
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::BeginDrag { .. })));
|
||||
|
||||
// Moved
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(150.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::ContinueDrag { .. })));
|
||||
|
||||
// Ended
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(150.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Ended,
|
||||
});
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::EndDrag { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stylus_produces_move_entity() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
// Stylus down
|
||||
controller.process_event(&InputEvent::Stylus {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
pressure: 0.5,
|
||||
tilt: Vec2::ZERO,
|
||||
phase: TouchPhase::Started,
|
||||
timestamp: 0.0,
|
||||
});
|
||||
|
||||
// Stylus drag
|
||||
let actions = controller.process_event(&InputEvent::Stylus {
|
||||
pos: Vec2::new(150.0, 120.0),
|
||||
pressure: 0.8,
|
||||
tilt: Vec2::ZERO,
|
||||
phase: TouchPhase::Moved,
|
||||
timestamp: 0.016,
|
||||
});
|
||||
|
||||
// Should produce MoveEntity
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_switching() {
|
||||
let mut controller = InputController::new();
|
||||
|
||||
// Start in EntityManipulation context
|
||||
assert_eq!(controller.context(), InputContext::EntityManipulation);
|
||||
|
||||
// Switch to CameraControl
|
||||
controller.set_context(InputContext::CameraControl);
|
||||
assert_eq!(controller.context(), InputContext::CameraControl);
|
||||
|
||||
// Start drag
|
||||
controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(100.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Started,
|
||||
});
|
||||
|
||||
// Drag in CameraControl context
|
||||
let actions = controller.process_event(&InputEvent::Mouse {
|
||||
pos: Vec2::new(150.0, 100.0),
|
||||
button: MouseButton::Left,
|
||||
phase: TouchPhase::Moved,
|
||||
});
|
||||
|
||||
// Should produce MoveCamera instead of MoveEntity
|
||||
assert!(actions.iter().any(|a| matches!(a, GameAction::MoveCamera { .. })));
|
||||
assert!(!actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_sensitivity() {
|
||||
let mut controller = InputController::new();
|
||||
controller.set_accessibility(AccessibilitySettings {
|
||||
scroll_sensitivity: 3.0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let actions = controller.process_event(&InputEvent::MouseWheel {
|
||||
delta: Vec2::new(0.0, 5.0),
|
||||
pos: Vec2::ZERO,
|
||||
});
|
||||
|
||||
// Delta should be tripled (5.0 * 3.0 = 15.0)
|
||||
let depth_delta = actions.iter().find_map(|a| {
|
||||
if let GameAction::MoveEntityDepth { delta } = a {
|
||||
Some(*delta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(depth_delta, Some(15.0));
|
||||
}
|
||||
133
crates/libmarathon/src/engine/input_events.rs
Normal file
133
crates/libmarathon/src/engine/input_events.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Abstract input event types for the engine
|
||||
//!
|
||||
//! These types are platform-agnostic and represent all forms of input
|
||||
//! (stylus, mouse, touch) in a unified way. Platform-specific code
|
||||
//! (iOS pencil bridge, desktop mouse) converts to these types.
|
||||
|
||||
use glam::Vec2;
|
||||
|
||||
/// Phase of a touch/stylus/mouse input
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TouchPhase {
|
||||
/// Input just started
|
||||
Started,
|
||||
/// Input moved
|
||||
Moved,
|
||||
/// Input ended normally
|
||||
Ended,
|
||||
/// Input was cancelled (e.g., system gesture)
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Mouse button types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
/// Keyboard key (using winit's KeyCode for now - can abstract later)
|
||||
pub use winit::keyboard::KeyCode;
|
||||
|
||||
/// Keyboard modifiers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Modifiers {
|
||||
pub shift: bool,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
pub meta: bool, // Command on macOS, Windows key on Windows
|
||||
}
|
||||
|
||||
/// Abstract input event that the engine processes
|
||||
///
|
||||
/// Platform-specific code converts native input (UITouch, winit events)
|
||||
/// into these engine-agnostic events.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum InputEvent {
|
||||
/// Stylus input (Apple Pencil, Surface Pen, etc.)
|
||||
Stylus {
|
||||
/// Screen position in pixels
|
||||
pos: Vec2,
|
||||
/// Pressure (0.0 = no pressure, 1.0+ = max pressure)
|
||||
/// Note: Apple Pencil reports 0.0-4.0 range
|
||||
pressure: f32,
|
||||
/// Tilt vector:
|
||||
/// - x: altitude angle (0 = flat on screen, π/2 = perpendicular)
|
||||
/// - y: azimuth angle (rotation around vertical axis)
|
||||
tilt: Vec2,
|
||||
/// Touch phase
|
||||
phase: TouchPhase,
|
||||
/// Platform timestamp (for input prediction)
|
||||
timestamp: f64,
|
||||
},
|
||||
|
||||
/// Mouse input (desktop)
|
||||
Mouse {
|
||||
/// Screen position in pixels
|
||||
pos: Vec2,
|
||||
/// Which button
|
||||
button: MouseButton,
|
||||
/// Touch phase
|
||||
phase: TouchPhase,
|
||||
},
|
||||
|
||||
/// Touch input (fingers on touchscreen)
|
||||
Touch {
|
||||
/// Screen position in pixels
|
||||
pos: Vec2,
|
||||
/// Touch phase
|
||||
phase: TouchPhase,
|
||||
/// Touch ID (for multi-touch tracking)
|
||||
id: u64,
|
||||
},
|
||||
|
||||
/// Keyboard input
|
||||
Keyboard {
|
||||
/// Physical key code
|
||||
key: KeyCode,
|
||||
/// Whether the key was pressed or released
|
||||
pressed: bool,
|
||||
/// Modifier keys held during the event
|
||||
modifiers: Modifiers,
|
||||
},
|
||||
|
||||
/// Mouse wheel scroll
|
||||
MouseWheel {
|
||||
/// Scroll delta (pixels or lines depending on device)
|
||||
delta: Vec2,
|
||||
/// Current mouse position
|
||||
pos: Vec2,
|
||||
},
|
||||
}
|
||||
|
||||
impl InputEvent {
|
||||
/// Get the position for positional input types
|
||||
pub fn position(&self) -> Option<Vec2> {
|
||||
match self {
|
||||
InputEvent::Stylus { pos, .. } => Some(*pos),
|
||||
InputEvent::Mouse { pos, .. } => Some(*pos),
|
||||
InputEvent::Touch { pos, .. } => Some(*pos),
|
||||
InputEvent::MouseWheel { pos, .. } => Some(*pos),
|
||||
InputEvent::Keyboard { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the phase for input types that have phases
|
||||
pub fn phase(&self) -> Option<TouchPhase> {
|
||||
match self {
|
||||
InputEvent::Stylus { phase, .. } => Some(*phase),
|
||||
InputEvent::Mouse { phase, .. } => Some(*phase),
|
||||
InputEvent::Touch { phase, .. } => Some(*phase),
|
||||
InputEvent::Keyboard { .. } | InputEvent::MouseWheel { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is an active input (not ended/cancelled)
|
||||
pub fn is_active(&self) -> bool {
|
||||
match self.phase() {
|
||||
Some(phase) => !matches!(phase, TouchPhase::Ended | TouchPhase::Cancelled),
|
||||
None => true, // Keyboard and wheel events are considered instantaneous
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/libmarathon/src/engine/mod.rs
Normal file
21
crates/libmarathon/src/engine/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Core Engine module - networking and persistence outside Bevy
|
||||
|
||||
mod bridge;
|
||||
mod commands;
|
||||
mod core;
|
||||
mod events;
|
||||
mod game_actions;
|
||||
mod input_controller;
|
||||
mod input_events;
|
||||
mod networking;
|
||||
mod persistence;
|
||||
|
||||
pub use bridge::{EngineBridge, EngineHandle};
|
||||
pub use commands::EngineCommand;
|
||||
pub use core::EngineCore;
|
||||
pub use events::EngineEvent;
|
||||
pub use game_actions::GameAction;
|
||||
pub use input_controller::{AccessibilitySettings, InputContext, InputController};
|
||||
pub use input_events::{InputEvent, KeyCode, Modifiers, MouseButton, TouchPhase};
|
||||
pub use networking::NetworkingManager;
|
||||
pub use persistence::PersistenceManager;
|
||||
243
crates/libmarathon/src/engine/networking.rs
Normal file
243
crates/libmarathon/src/engine/networking.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Networking Manager - handles iroh networking and CRDT state outside Bevy
|
||||
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time;
|
||||
use bytes::Bytes;
|
||||
use futures_lite::StreamExt;
|
||||
|
||||
use crate::networking::{
|
||||
EntityLockRegistry, NodeId, OperationLog, SessionId, TombstoneRegistry, VectorClock,
|
||||
VersionedMessage, SyncMessage, LockMessage,
|
||||
};
|
||||
|
||||
use super::EngineEvent;
|
||||
|
||||
pub struct NetworkingManager {
|
||||
session_id: SessionId,
|
||||
node_id: NodeId,
|
||||
|
||||
// Iroh networking
|
||||
sender: iroh_gossip::api::GossipSender,
|
||||
receiver: iroh_gossip::api::GossipReceiver,
|
||||
|
||||
// Keep these alive for the lifetime of the manager
|
||||
_endpoint: iroh::Endpoint,
|
||||
_router: iroh::protocol::Router,
|
||||
_gossip: iroh_gossip::net::Gossip,
|
||||
|
||||
// CRDT state
|
||||
vector_clock: VectorClock,
|
||||
operation_log: OperationLog,
|
||||
tombstones: TombstoneRegistry,
|
||||
locks: EntityLockRegistry,
|
||||
|
||||
// Track locks we own for heartbeat broadcasting
|
||||
our_locks: std::collections::HashSet<uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl NetworkingManager {
|
||||
pub async fn new(session_id: SessionId) -> anyhow::Result<Self> {
|
||||
use iroh::{
|
||||
discovery::mdns::MdnsDiscovery,
|
||||
protocol::Router,
|
||||
Endpoint,
|
||||
};
|
||||
use iroh_gossip::{
|
||||
net::Gossip,
|
||||
proto::TopicId,
|
||||
};
|
||||
|
||||
// Create iroh endpoint with mDNS discovery
|
||||
let endpoint = Endpoint::builder()
|
||||
.discovery(MdnsDiscovery::builder())
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
let endpoint_id = endpoint.addr().id;
|
||||
|
||||
// Convert endpoint ID to NodeId (using first 16 bytes)
|
||||
let id_bytes = endpoint_id.as_bytes();
|
||||
let mut node_id_bytes = [0u8; 16];
|
||||
node_id_bytes.copy_from_slice(&id_bytes[..16]);
|
||||
let node_id = NodeId::from_bytes(node_id_bytes);
|
||||
|
||||
// Create gossip protocol
|
||||
let gossip = Gossip::builder().spawn(endpoint.clone());
|
||||
|
||||
// Derive session-specific ALPN for network isolation
|
||||
let session_alpn = session_id.to_alpn();
|
||||
|
||||
// Set up router to accept session ALPN
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(session_alpn.as_slice(), gossip.clone())
|
||||
.spawn();
|
||||
|
||||
// Subscribe to topic derived from session ALPN
|
||||
let topic_id = TopicId::from_bytes(session_alpn);
|
||||
let subscribe_handle = gossip.subscribe(topic_id, vec![]).await?;
|
||||
|
||||
let (sender, receiver) = subscribe_handle.split();
|
||||
|
||||
tracing::info!(
|
||||
"NetworkingManager started for session {} with node {}",
|
||||
session_id.to_code(),
|
||||
node_id
|
||||
);
|
||||
|
||||
let manager = Self {
|
||||
session_id,
|
||||
node_id,
|
||||
sender,
|
||||
receiver,
|
||||
_endpoint: endpoint,
|
||||
_router: router,
|
||||
_gossip: gossip,
|
||||
vector_clock: VectorClock::new(),
|
||||
operation_log: OperationLog::new(),
|
||||
tombstones: TombstoneRegistry::new(),
|
||||
locks: EntityLockRegistry::new(),
|
||||
our_locks: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn node_id(&self) -> NodeId {
|
||||
self.node_id
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id.clone()
|
||||
}
|
||||
|
||||
/// Process gossip events (unbounded) and periodic tasks (heartbeats, lock cleanup)
|
||||
pub async fn run(mut self, event_tx: mpsc::UnboundedSender<EngineEvent>) {
|
||||
let mut heartbeat_interval = time::interval(Duration::from_secs(1));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Process gossip events unbounded (as fast as they arrive)
|
||||
Some(result) = self.receiver.next() => {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
use iroh_gossip::api::Event;
|
||||
if let Event::Received(msg) = event {
|
||||
self.handle_sync_message(&msg.content, &event_tx).await;
|
||||
}
|
||||
// Note: Neighbor events are not exposed in the current API
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Gossip receiver error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic tasks: heartbeats and lock cleanup
|
||||
_ = heartbeat_interval.tick() => {
|
||||
self.broadcast_lock_heartbeats(&event_tx).await;
|
||||
self.cleanup_expired_locks(&event_tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn handle_sync_message(&mut self, msg_bytes: &[u8], event_tx: &mpsc::UnboundedSender<EngineEvent>) {
|
||||
// Deserialize SyncMessage
|
||||
let versioned: VersionedMessage = match bincode::deserialize(msg_bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to deserialize sync message: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match versioned.message {
|
||||
SyncMessage::Lock(lock_msg) => {
|
||||
self.handle_lock_message(lock_msg, event_tx);
|
||||
}
|
||||
_ => {
|
||||
// TODO: Handle other message types (ComponentOp, EntitySpawn, etc.)
|
||||
tracing::debug!("Unhandled sync message type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_lock_message(&mut self, msg: LockMessage, event_tx: &mpsc::UnboundedSender<EngineEvent>) {
|
||||
match msg {
|
||||
LockMessage::LockRequest { entity_id, node_id } => {
|
||||
match self.locks.try_acquire(entity_id, node_id) {
|
||||
Ok(()) => {
|
||||
// Track if this is our lock
|
||||
if node_id == self.node_id {
|
||||
self.our_locks.insert(entity_id);
|
||||
}
|
||||
|
||||
let _ = event_tx.send(EngineEvent::LockAcquired {
|
||||
entity_id,
|
||||
holder: node_id,
|
||||
});
|
||||
}
|
||||
Err(current_holder) => {
|
||||
let _ = event_tx.send(EngineEvent::LockDenied {
|
||||
entity_id,
|
||||
current_holder,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
LockMessage::LockHeartbeat { entity_id, holder } => {
|
||||
self.locks.renew_heartbeat(entity_id, holder);
|
||||
}
|
||||
LockMessage::LockRelease { entity_id, node_id } => {
|
||||
self.locks.release(entity_id, node_id);
|
||||
|
||||
// Remove from our locks tracking
|
||||
if node_id == self.node_id {
|
||||
self.our_locks.remove(&entity_id);
|
||||
}
|
||||
|
||||
let _ = event_tx.send(EngineEvent::LockReleased { entity_id });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn broadcast_lock_heartbeats(&mut self, _event_tx: &mpsc::UnboundedSender<EngineEvent>) {
|
||||
// Broadcast heartbeats for locks we hold
|
||||
for entity_id in self.our_locks.iter().copied() {
|
||||
self.locks.renew_heartbeat(entity_id, self.node_id);
|
||||
|
||||
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockHeartbeat {
|
||||
entity_id,
|
||||
holder: self.node_id,
|
||||
}));
|
||||
|
||||
if let Ok(bytes) = bincode::serialize(&msg) {
|
||||
let _ = self.sender.broadcast(Bytes::from(bytes)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_expired_locks(&mut self, event_tx: &mpsc::UnboundedSender<EngineEvent>) {
|
||||
// Get expired locks from registry
|
||||
let expired = self.locks.get_expired_locks();
|
||||
|
||||
for entity_id in expired {
|
||||
// Only cleanup if it's not our lock
|
||||
if let Some(holder) = self.locks.get_holder(entity_id, self.node_id) {
|
||||
if holder != self.node_id {
|
||||
self.locks.force_release(entity_id);
|
||||
let _ = event_tx.send(EngineEvent::LockExpired { entity_id });
|
||||
tracing::info!("Lock expired for entity {}", entity_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) {
|
||||
tracing::info!("NetworkingManager shut down");
|
||||
// endpoint and gossip will be dropped automatically
|
||||
}
|
||||
}
|
||||
79
crates/libmarathon/src/engine/persistence.rs
Normal file
79
crates/libmarathon/src/engine/persistence.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Persistence Manager - handles SQLite storage outside Bevy
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::networking::{Session, SessionId};
|
||||
|
||||
pub struct PersistenceManager {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl PersistenceManager {
|
||||
pub fn new(db_path: &str) -> Self {
|
||||
let conn = Connection::open(db_path).expect("Failed to open database");
|
||||
|
||||
// Initialize schema (Phase 1 stub - will load from file in Phase 4)
|
||||
let schema = "
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_active_at INTEGER NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
if let Err(e) = conn.execute_batch(schema) {
|
||||
tracing::warn!("Failed to initialize schema: {}", e);
|
||||
}
|
||||
|
||||
Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_session(&self, session: &Session) -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions (id, state, created_at, last_active_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
(
|
||||
session.id.to_code(),
|
||||
format!("{:?}", session.state),
|
||||
session.created_at,
|
||||
session.last_active,
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_last_active_session(&self) -> anyhow::Result<Option<Session>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
// Query for the most recently active session
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, state, created_at, last_active_at
|
||||
FROM sessions
|
||||
ORDER BY last_active_at DESC
|
||||
LIMIT 1"
|
||||
)?;
|
||||
|
||||
let session = stmt.query_row([], |row| {
|
||||
let id_code: String = row.get(0)?;
|
||||
let _state: String = row.get(1)?;
|
||||
let _created_at: String = row.get(2)?;
|
||||
let _last_active_at: String = row.get(3)?;
|
||||
|
||||
// Parse session ID from code
|
||||
if let Ok(session_id) = SessionId::from_code(&id_code) {
|
||||
Ok(Some(Session::new(session_id)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}).optional()?;
|
||||
|
||||
Ok(session.flatten())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user