add threading

This commit is contained in:
Franz Dietrich 2025-10-17 19:15:56 +02:00
parent fcca1a2db4
commit 3f21afadb2
3 changed files with 317 additions and 0 deletions

View File

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

View File

@ -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<CommandQueue>,
}
/// 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<CommandQueue>,
}
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<CommandQueue> {
self.rx.try_iter().collect()
}
/// Try to receive one command batch (non-blocking)
#[must_use]
pub fn try_recv(&self) -> Option<CommandQueue> {
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 },
)
}

View File

@ -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<usize, TurtleCommandReceiver>,
// Mouse panning state
is_dragging: bool,
last_mouse_pos: Option<Vec2>,
@ -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<usize> = 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