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 b098a19d6b
commit 5cb258fe6b
99 changed files with 4137 additions and 311 deletions

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

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

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

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

View 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",
}
}
}

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

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

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

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

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

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