diff --git a/turtle-lib/Cargo.toml b/turtle-lib/Cargo.toml index e453079..a2d60d2 100644 --- a/turtle-lib/Cargo.toml +++ b/turtle-lib/Cargo.toml @@ -13,6 +13,7 @@ tracing = { version = "0.1", features = [ "attributes", ], default-features = false } turtle-lib-macros = { path = "../turtle-lib-macros" } +crossbeam = "0.8" [dev-dependencies] # For examples and testing diff --git a/turtle-lib/src/commands_channel.rs b/turtle-lib/src/commands_channel.rs new file mode 100644 index 0000000..7a70fa2 --- /dev/null +++ b/turtle-lib/src/commands_channel.rs @@ -0,0 +1,223 @@ +//! Per-turtle command channels for multi-threaded game logic +//! +//! Enables sending turtle commands from game logic threads to the render thread +//! without blocking the render loop. +//! +//! # Usage +//! +//! ```no_run +//! use turtle_lib::*; +//! use std::thread; +//! +//! # #[macroquad::main("Threading")] +//! # async fn main() { +//! let mut app = TurtleApp::new(); +//! +//! // Create a turtle and get its command sender +//! let turtle_tx = app.create_turtle_channel(100); +//! +//! // Spawn a game logic thread +//! thread::spawn({ +//! let tx = turtle_tx.clone(); +//! move || { +//! let mut plan = create_turtle_plan(); +//! plan.forward(100.0).right(90.0); +//! tx.send(plan.build()).ok(); +//! } +//! }); +//! +//! // Main render loop +//! loop { +//! clear_background(WHITE); +//! app.process_commands(); +//! app.update(); +//! app.render(); +//! next_frame().await; +//! } +//! # } +//! ``` + +use crate::commands::CommandQueue; +use crossbeam::channel::{bounded, Receiver, Sender}; + +/// Sender for turtle commands from a game logic thread +/// +/// This is tied to a specific turtle created via `TurtleApp::create_turtle_channel()`. +/// The turtle is guaranteed to exist on the render thread. +/// +/// # Thread Safety +/// Can be cloned and shared across threads. Multiple game threads can send +/// commands to the same turtle safely. +/// +/// # Examples +/// ```no_run +/// # use turtle_lib::*; +/// # fn example() -> Result<(), String> { +/// # let mut app = TurtleApp::new(); +/// let tx = app.create_turtle_channel(100); +/// +/// // Send commands from game thread +/// let mut plan = create_turtle_plan(); +/// plan.forward(50.0); +/// tx.send(plan.build())?; +/// +/// // Or non-blocking variant +/// tx.try_send(plan.build()).ok(); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct TurtleCommandSender { + turtle_id: usize, + tx: Sender, +} + +/// Receiver for turtle commands on the render thread +/// +/// Paired with `TurtleCommandSender` via `turtle_command_channel()`. +/// Automatically managed by `TurtleApp::process_commands()`. +pub struct TurtleCommandReceiver { + turtle_id: usize, + rx: Receiver, +} + +impl TurtleCommandSender { + /// Get the turtle ID this sender is bound to + #[must_use] + pub fn turtle_id(&self) -> usize { + self.turtle_id + } + + /// Send commands (blocking) + /// + /// Blocks if the channel buffer is full. This is appropriate for game logic + /// threads where blocking is acceptable. The buffer size is specified when + /// creating the channel. + /// + /// # Errors + /// Returns error if the receiver has been dropped (render thread exited). + /// + /// # Examples + /// ```no_run + /// # use turtle_lib::*; + /// # fn example() -> Result<(), String> { + /// # let mut app = TurtleApp::new(); + /// # let tx = app.create_turtle_channel(100); + /// let mut plan = create_turtle_plan(); + /// plan.forward(100.0); + /// tx.send(plan.build())?; + /// # Ok(()) + /// # } + /// ``` + pub fn send(&self, queue: CommandQueue) -> Result<(), String> { + self.tx + .send(queue) + .map_err(|e| format!("Channel disconnected: {}", e)) + } + + /// Send commands (non-blocking) + /// + /// Returns immediately. If the channel buffer is full, returns an error + /// without blocking. + /// + /// # Errors + /// Returns error if the buffer is full or the receiver has been dropped. + /// + /// # Examples + /// ```no_run + /// # use turtle_lib::*; + /// # fn example() { + /// # let mut app = TurtleApp::new(); + /// # let tx = app.create_turtle_channel(100); + /// let mut plan = create_turtle_plan(); + /// plan.forward(100.0); + /// tx.try_send(plan.build()).ok(); // Ignore if buffer full + /// # } + /// ``` + pub fn try_send(&self, queue: CommandQueue) -> Result<(), String> { + self.tx + .try_send(queue) + .map_err(|e| format!("Failed to send: {}", e)) + } +} + +impl TurtleCommandReceiver { + /// Get the turtle ID this receiver is bound to + #[must_use] + pub fn turtle_id(&self) -> usize { + self.turtle_id + } + + /// Drain all pending commands for this turtle (non-blocking) + /// + /// # Examples + /// ```no_run + /// # use turtle_lib::*; + /// # async fn example() { + /// # let mut app = TurtleApp::new(); + /// # let _tx = app.create_turtle_channel(100); + /// // This is called automatically by app.process_commands() + /// // But you can also do it manually: + /// loop { + /// app.update(); + /// app.render(); + /// # break; + /// } + /// # } + /// ``` + pub fn recv_all(&self) -> Vec { + self.rx.try_iter().collect() + } + + /// Try to receive one command batch (non-blocking) + #[must_use] + pub fn try_recv(&self) -> Option { + self.rx.try_recv().ok() + } + + /// Check if this receiver's queue is empty + #[must_use] + pub fn is_empty(&self) -> bool { + self.rx.is_empty() + } + + /// Get the number of pending command batches + #[must_use] + pub fn len(&self) -> usize { + self.rx.len() + } +} + +/// Create a command channel for a specific turtle +/// +/// The tuple represents (sender, receiver) where: +/// - Sender goes to game logic threads (cloneable, can be distributed) +/// - Receiver stays in the render thread (part of TurtleApp internally) +/// +/// # Arguments +/// * `turtle_id` - The ID of the turtle this channel is for (must be valid) +/// * `buffer_size` - Maximum number of pending command batches before sender blocks +/// +/// # Panics +/// Panics if buffer_size is 0. +/// +/// # Examples +/// ```no_run +/// # use turtle_lib::*; +/// # fn example() { +/// let (tx, _rx) = turtle_command_channel(0, 100); +/// // Sender goes to game threads +/// // Receiver stays in render thread (or TurtleApp) +/// # } +/// ``` +pub fn turtle_command_channel( + turtle_id: usize, + buffer_size: usize, +) -> (TurtleCommandSender, TurtleCommandReceiver) { + assert!(buffer_size > 0, "buffer_size must be > 0"); + let (tx, rx) = bounded(buffer_size); + ( + TurtleCommandSender { turtle_id, tx }, + TurtleCommandReceiver { turtle_id, rx }, + ) +} diff --git a/turtle-lib/src/lib.rs b/turtle-lib/src/lib.rs index a82d345..0163175 100644 --- a/turtle-lib/src/lib.rs +++ b/turtle-lib/src/lib.rs @@ -49,6 +49,7 @@ pub mod builders; pub mod circle_geometry; pub mod commands; +pub mod commands_channel; pub mod drawing; pub mod execution; pub mod general; @@ -60,6 +61,7 @@ pub mod tweening; // Re-export commonly used types pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands}; pub use commands::{CommandQueue, TurtleCommand}; +pub use commands_channel::{turtle_command_channel, TurtleCommandReceiver, TurtleCommandSender}; pub use general::{Angle, AnimationSpeed, Color, Coordinate, Length, Precision}; pub use shapes::{ShapeType, TurtleShape}; pub use state::{DrawCommand, Turtle, TurtleWorld}; @@ -74,10 +76,13 @@ pub use macroquad::prelude::{ }; use macroquad::prelude::*; +use std::collections::HashMap; /// Main turtle application struct pub struct TurtleApp { world: TurtleWorld, + // Receivers for turtle command channels + receivers: HashMap, // Mouse panning state is_dragging: bool, last_mouse_pos: Option, @@ -91,6 +96,7 @@ impl TurtleApp { pub fn new() -> Self { Self { world: TurtleWorld::new(), + receivers: HashMap::new(), is_dragging: false, last_mouse_pos: None, zoom_level: 1.0, @@ -102,6 +108,78 @@ impl TurtleApp { self.world.add_turtle() } + /// Create a turtle and a command channel for it + /// + /// This is the preferred way to set up turtles when using threading. + /// Call this ONCE per turtle during setup, before spawning game logic threads. + /// + /// # Arguments + /// * `buffer_size` - Maximum pending command batches before sender blocks (typically 50-200) + /// + /// # Returns + /// A `TurtleCommandSender` that can be cloned and sent to game logic threads. + /// The turtle is automatically managed by TurtleApp. + /// + /// # Examples + /// ```no_run + /// # use turtle_lib::*; + /// # #[macroquad::main("Threading")] + /// # async fn main() { + /// let mut app = TurtleApp::new(); + /// + /// // Create turtle and get sender + /// let turtle_tx = app.create_turtle_channel(100); + /// + /// // Send to game threads + /// let tx_clone = turtle_tx.clone(); + /// std::thread::spawn(move || { + /// let mut plan = create_turtle_plan(); + /// plan.forward(100.0); + /// tx_clone.send(plan.build()).ok(); + /// }); + /// # } + /// ``` + pub fn create_turtle_channel(&mut self, buffer_size: usize) -> TurtleCommandSender { + let turtle_id = self.world.add_turtle(); + let (tx, rx) = commands_channel::turtle_command_channel(turtle_id, buffer_size); + self.receivers.insert(turtle_id, rx); + tx + } + + /// Process all pending commands from all turtle channels + /// + /// Call this once per frame in your render loop, before `update()`. + /// Drains all receivers and applies commands to their respective turtles. + /// + /// # Examples + /// ```no_run + /// # use turtle_lib::*; + /// # #[macroquad::main("Threading")] + /// # async fn main() { + /// # let mut app = TurtleApp::new(); + /// # let _tx = app.create_turtle_channel(100); + /// loop { + /// clear_background(WHITE); + /// app.process_commands(); // ← Process channel commands + /// app.update(); + /// app.render(); + /// next_frame().await; + /// } + /// # } + /// ``` + pub fn process_commands(&mut self) { + // Collect all turtle IDs to avoid borrow issues + let turtle_ids: Vec = self.receivers.keys().copied().collect(); + + for turtle_id in turtle_ids { + if let Some(receiver) = self.receivers.get(&turtle_id) { + for queue in receiver.recv_all() { + self.append_commands(turtle_id, queue); + } + } + } + } + /// Add commands from a turtle plan to the application for the default turtle (ID 0) /// /// Speed is controlled by `SetSpeed` commands in the queue. @@ -157,6 +235,21 @@ impl TurtleApp { } } + /// Append commands from a CommandQueue to a turtle's animation queue + /// + /// Used internally by `process_commands()` and can be used directly + /// when you have a `CommandQueue` instead of a `TurtlePlan`. + pub fn append_commands(&mut self, turtle_id: usize, queue: CommandQueue) { + // Ensure turtle exists + while self.world.turtles.len() <= turtle_id { + self.world.add_turtle(); + } + + if let Some(turtle) = self.world.get_turtle_mut(turtle_id) { + turtle.tween_controller.append_commands(queue); + } + } + /// Update animation state (call every frame) pub fn update(&mut self) { // Handle mouse panning and zoom