From 25753b47cef86730ce85b9d8bdc89602bcddd06b Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Thu, 9 Oct 2025 09:12:16 +0200 Subject: [PATCH] Initial macroquad version for compiletime reasons ```rust // Movement plan.forward(100); plan.backward(50); // Rotation plan.left(90); // degrees plan.right(45); // Circular arcs plan.circle_left(50.0, 180.0, 36); // radius, angle (degrees), segments plan.circle_right(50.0, 180.0, 36); // draws arc to the right // Pen control plan.pen_up(); plan.pen_down(); // Appearance plan.set_color(RED); plan.set_pen_width(5.0); plan.hide(); plan.show(); // Turtle shape plan.shape(ShapeType::Triangle); plan.shape(ShapeType::Turtle); // Default classic turtle shape plan.shape(ShapeType::Circle); plan.shape(ShapeType::Square); plan.shape(ShapeType::Arrow); // Custom shape let custom = TurtleShape::new( vec![vec2(10.0, 0.0), vec2(-5.0, 5.0), vec2(-5.0, -5.0)], true // filled ); plan.set_shape(custom); // Chaining plan.forward(100).right(90).forward(50); ``` --- .gitignore | 3 +- .vscode/tasks.json | 15 + Cargo.toml | 2 +- turtle-lib-macroquad/Cargo.toml | 13 + turtle-lib-macroquad/README.md | 229 ++++++++++ turtle-lib-macroquad/examples/circle_test.rs | 41 ++ .../examples/direction_test_1.rs | 30 ++ turtle-lib-macroquad/examples/koch.rs | 49 +++ turtle-lib-macroquad/examples/nikolaus.rs | 74 ++++ turtle-lib-macroquad/examples/shapes.rs | 47 ++ turtle-lib-macroquad/examples/square.rs | 30 ++ turtle-lib-macroquad/examples/stern.rs | 34 ++ turtle-lib-macroquad/examples/yinyang.rs | 41 ++ turtle-lib-macroquad/src/builders.rs | 168 ++++++++ turtle-lib-macroquad/src/circle_geometry.rs | 207 +++++++++ turtle-lib-macroquad/src/commands.rs | 114 +++++ turtle-lib-macroquad/src/drawing.rs | 345 +++++++++++++++ turtle-lib-macroquad/src/execution.rs | 402 ++++++++++++++++++ turtle-lib-macroquad/src/general.rs | 24 ++ turtle-lib-macroquad/src/general/angle.rs | 200 +++++++++ turtle-lib-macroquad/src/general/length.rs | 24 ++ turtle-lib-macroquad/src/lib.rs | 222 ++++++++++ turtle-lib-macroquad/src/shapes.rs | 162 +++++++ turtle-lib-macroquad/src/state.rs | 113 +++++ turtle-lib-macroquad/src/tweening.rs | 354 +++++++++++++++ 25 files changed, 2941 insertions(+), 2 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 turtle-lib-macroquad/Cargo.toml create mode 100644 turtle-lib-macroquad/README.md create mode 100644 turtle-lib-macroquad/examples/circle_test.rs create mode 100644 turtle-lib-macroquad/examples/direction_test_1.rs create mode 100644 turtle-lib-macroquad/examples/koch.rs create mode 100644 turtle-lib-macroquad/examples/nikolaus.rs create mode 100644 turtle-lib-macroquad/examples/shapes.rs create mode 100644 turtle-lib-macroquad/examples/square.rs create mode 100644 turtle-lib-macroquad/examples/stern.rs create mode 100644 turtle-lib-macroquad/examples/yinyang.rs create mode 100644 turtle-lib-macroquad/src/builders.rs create mode 100644 turtle-lib-macroquad/src/circle_geometry.rs create mode 100644 turtle-lib-macroquad/src/commands.rs create mode 100644 turtle-lib-macroquad/src/drawing.rs create mode 100644 turtle-lib-macroquad/src/execution.rs create mode 100644 turtle-lib-macroquad/src/general.rs create mode 100644 turtle-lib-macroquad/src/general/angle.rs create mode 100644 turtle-lib-macroquad/src/general/length.rs create mode 100644 turtle-lib-macroquad/src/lib.rs create mode 100644 turtle-lib-macroquad/src/shapes.rs create mode 100644 turtle-lib-macroquad/src/state.rs create mode 100644 turtle-lib-macroquad/src/tweening.rs diff --git a/.gitignore b/.gitignore index c41cc9e..869df07 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/target \ No newline at end of file +/target +Cargo.lock \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9b38a38 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "cargo check (workspace)", + "type": "shell", + "command": "cargo check", + "isBackground": false, + "problemMatcher": [ + "$rustc" + ], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index d4b687c..d741902 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["turtle-lib", "turtle-example"] +members = ["turtle-lib", "turtle-example", "turtle-lib-macroquad"] [workspace.dependencies] # Pin Bevy across the workspace diff --git a/turtle-lib-macroquad/Cargo.toml b/turtle-lib-macroquad/Cargo.toml new file mode 100644 index 0000000..259bc64 --- /dev/null +++ b/turtle-lib-macroquad/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "turtle-lib-macroquad" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[dependencies] +macroquad = "0.4" +earcutr = "0.5" +tween = "2.1.0" + +[dev-dependencies] +# For examples and testing diff --git a/turtle-lib-macroquad/README.md b/turtle-lib-macroquad/README.md new file mode 100644 index 0000000..4b22a9e --- /dev/null +++ b/turtle-lib-macroquad/README.md @@ -0,0 +1,229 @@ +# Turtle Graphics Library for Macroquad + +A turtle graphics library built on [Macroquad](https://macroquad.rs/), providing an intuitive API for creating drawings and animations. + +## Features + +- **Simple Builder API**: Chain commands like `forward(100).right(90)` +- **Smooth Animations**: Tweening support with easing functions +- **Instant Mode**: Execute commands immediately without animation (speed > 999.0) +- **Lightweight**: Fast compilation (~30-60 seconds from clean build) +- **Macroquad Integration**: Built on the simple and fast Macroquad framework + +## Quick Start + +Add to your `Cargo.toml`: +```toml +[dependencies] +turtle-lib-macroquad = { path = "../turtle-lib-macroquad" } +macroquad = "0.4" +``` + +### Basic Example + +```rust +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +#[macroquad::main("Turtle")] +async fn main() { + // Create a turtle plan + let mut plan = create_turtle(); + + // Draw a square + for _ in 0..4 { + plan.forward(100).right(90); + } + + // Create app with animation (100 pixels/sec) + let mut app = TurtleApp::new().with_commands(plan.build(), 100.0); + + loop { + clear_background(WHITE); + app.update(); + app.render(); + next_frame().await + } +} +``` + +## API Overview + +### Creating Plans + +```rust +let mut plan = create_turtle(); + +// Movement +plan.forward(100); +plan.backward(50); + +// Rotation +plan.left(90); // degrees +plan.right(45); + +// Circular arcs +plan.circle_left(50.0, 180.0, 36); // radius, angle (degrees), segments +plan.circle_right(50.0, 180.0, 36); // draws arc to the right + +// Pen control +plan.pen_up(); +plan.pen_down(); + +// Appearance +plan.set_color(RED); +plan.set_pen_width(5.0); +plan.hide(); +plan.show(); + +// Turtle shape +plan.shape(ShapeType::Triangle); +plan.shape(ShapeType::Turtle); // Default classic turtle shape +plan.shape(ShapeType::Circle); +plan.shape(ShapeType::Square); +plan.shape(ShapeType::Arrow); + +// Custom shape +let custom = TurtleShape::new( + vec![vec2(10.0, 0.0), vec2(-5.0, 5.0), vec2(-5.0, -5.0)], + true // filled +); +plan.set_shape(custom); + +// Chaining +plan.forward(100).right(90).forward(50); +``` + +### Execution Modes + +```rust +// Animated mode (speed in pixels/sec, 0.5-999.0) +let app = TurtleApp::new().with_commands(queue, 100.0); + +// Instant mode (speed >= 999.0) +let app = TurtleApp::new().with_commands(queue, 1000.0); +``` + +### Animation Loop + +```rust +loop { + clear_background(WHITE); + + app.update(); // Update animation state + app.render(); // Draw to screen + + if app.is_complete() { + // All commands executed + } + + next_frame().await +} +``` + +## Examples + +Run examples with: +```bash +cargo run --example square +cargo run --example koch +cargo run --example shapes +cargo run --example yinyang +cargo run --example stern +cargo run --example nikolaus +``` + +### Available Examples + +- **square.rs**: Basic square drawing +- **koch.rs**: Koch snowflake fractal +- **shapes.rs**: Demonstrates different turtle shapes +- **yinyang.rs**: Yin-yang symbol drawing +- **stern.rs**: Star pattern drawing +- **nikolaus.rs**: Nikolaus (Santa) drawing + +## Turtle Shapes + +The library supports multiple turtle shapes that can be changed during drawing: + +### Built-in Shapes + +- **Triangle** (default): Simple arrow shape +- **Turtle**: Classic turtle shape with detailed outline +- **Circle**: Circular shape +- **Square**: Square shape +- **Arrow**: Arrow-like shape + +### Using Shapes + +```rust +// Using built-in shapes +plan.shape(ShapeType::Turtle); + +// Creating custom shapes +let my_shape = TurtleShape::new( + vec![ + vec2(15.0, 0.0), // Point at front + vec2(-10.0, -8.0), // Bottom back + vec2(-10.0, 8.0), // Top back + ], + true // filled +); +plan.set_shape(my_shape); +``` + +Shapes are automatically rotated to match the turtle's heading direction. + +## Architecture + +The library is designed for easy extension and potential multi-threading support: + +- **State Management**: Clean separation between turtle state and world state +- **Command Queue**: Commands are queued and can be executed immediately or with tweening +- **Tweening System**: Smooth interpolation between states with easing functions +- **Rendering**: Direct Macroquad drawing calls with earcutr polygon triangulation +- **Shape System**: Extensible turtle shapes with support for both convex and concave polygons + +### Module Structure + +``` +src/ +├── lib.rs - Public API and TurtleApp +├── state.rs - TurtleState and TurtleWorld +├── commands.rs - TurtleCommand enum and CommandQueue +├── builders.rs - Builder traits (DirectionalMovement, Turnable, etc.) +├── execution.rs - Command execution logic +├── tweening.rs - Animation/tweening controller +├── drawing.rs - Macroquad rendering +├── shapes.rs - Turtle shape definitions +└── general/ - Type definitions (Angle, Length, etc.) +``` + +## Design Decisions + +### Multi-threading Ready + +While multi-threading is not implemented yet, the architecture supports future additions: +- State and world are separated +- Commands can be generated on separate threads +- Rendering happens on main thread (Macroquad requirement) + +### Tweening vs Interpolation + +We use "tweening" terminology throughout the codebase for clarity and game development conventions. + +### No API Compatibility Constraints + +This library is designed from scratch without backwards compatibility requirements, allowing for optimal design choices. + +## Future Enhancements + +Potential additions (not yet implemented): +- Multi-threading support for interactive games +- Filled shapes and polygons +- Text rendering +- Image stamps + +## License + +MIT OR Apache-2.0 diff --git a/turtle-lib-macroquad/examples/circle_test.rs b/turtle-lib-macroquad/examples/circle_test.rs new file mode 100644 index 0000000..9d1455d --- /dev/null +++ b/turtle-lib-macroquad/examples/circle_test.rs @@ -0,0 +1,41 @@ +//! Test circle_left and circle_right commands + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +#[macroquad::main("Circle Test")] +async fn main() { + // Create a turtle plan + let mut plan = create_turtle(); + plan.shape(ShapeType::Turtle); + + // Draw some circles + plan.set_color(RED); + plan.set_pen_width(0.5); + plan.left(90.0); + plan.circle_left(100.0, 540.0, 72); // partial circle to the left + + plan.forward(150.0); + + plan.set_color(BLUE); + plan.circle_right(50.0, 270.0, 72); // partial circle to the right + + plan.forward(150.0); + + plan.set_color(GREEN); + plan.circle_left(50.0, 180.0, 36); // Half circle to the left + + // Create turtle app with animation (speed = 100 pixels/sec) + let mut app = TurtleApp::new().with_commands(plan.build(), 100.0); + + // Main loop + loop { + clear_background(WHITE); + + // Update and render + app.update(); + app.render(); + + next_frame().await + } +} diff --git a/turtle-lib-macroquad/examples/direction_test_1.rs b/turtle-lib-macroquad/examples/direction_test_1.rs new file mode 100644 index 0000000..9acb304 --- /dev/null +++ b/turtle-lib-macroquad/examples/direction_test_1.rs @@ -0,0 +1,30 @@ +//! Test circle_left and circle_right commands + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +#[macroquad::main("Circle Test")] +async fn main() { + // Create a turtle plan + let mut plan = create_turtle(); + + plan.right(45.0); + plan.forward(100.0); + plan.right(45.0); + plan.forward(100.0); + //plan.circle_left(100.0, 90.0, 72); // Full circle to the left + + // Create turtle app with animation (speed = 100 pixels/sec) + let mut app = TurtleApp::new().with_commands(plan.build(), 10.0); + + // Main loop + loop { + clear_background(WHITE); + + // Update and render + app.update(); + app.render(); + + next_frame().await + } +} diff --git a/turtle-lib-macroquad/examples/koch.rs b/turtle-lib-macroquad/examples/koch.rs new file mode 100644 index 0000000..8bfd5e3 --- /dev/null +++ b/turtle-lib-macroquad/examples/koch.rs @@ -0,0 +1,49 @@ +//! Koch snowflake fractal example + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +fn koch(depth: u32, plan: &mut TurtlePlan, distance: f32) { + if depth == 0 { + plan.forward(distance); + } else { + let new_distance = distance / 3.0; + koch(depth - 1, plan, new_distance); + plan.left(60.0); + koch(depth - 1, plan, new_distance); + plan.right(120.0); + koch(depth - 1, plan, new_distance); + plan.left(60.0); + koch(depth - 1, plan, new_distance); + } +} + +#[macroquad::main("Koch Snowflake")] +async fn main() { + let mut plan = create_turtle(); + + // Position turtle + plan.set_speed(10); + plan.pen_up(); + plan.backward(150.0); + + plan.pen_down(); + + // Draw Koch snowflake (triangle of Koch curves) + for _ in 0..3 { + koch(4, &mut plan, 300.0); + plan.right(120.0); + } + + plan.hide(); // Hide turtle when done + + // Create app with animation + let mut app = TurtleApp::new().with_commands(plan.build(), 1000.0); + + loop { + clear_background(WHITE); + app.update(); + app.render(); + next_frame().await + } +} diff --git a/turtle-lib-macroquad/examples/nikolaus.rs b/turtle-lib-macroquad/examples/nikolaus.rs new file mode 100644 index 0000000..7190539 --- /dev/null +++ b/turtle-lib-macroquad/examples/nikolaus.rs @@ -0,0 +1,74 @@ +//! Nikolaus example - draws a house-like figure + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +fn nikolausquadrat(plan: &mut TurtlePlan, groesse: f32) { + plan.forward(groesse); + plan.left(90.0); + plan.forward(groesse); + plan.left(90.0); + plan.forward(groesse); + plan.left(90.0); + plan.forward(groesse); + plan.left(90.0); +} + +fn nikolausdiag(plan: &mut TurtlePlan, groesse: f32) { + let quadrat = groesse * groesse; + let diag = (quadrat + quadrat).sqrt(); + + plan.left(45.0); + plan.forward(diag); + plan.left(45.0); + nikolausdach2(plan, groesse); + plan.left(45.0); + plan.forward(diag); + plan.left(45.0); +} + +fn nikolausdach2(plan: &mut TurtlePlan, groesse: f32) { + let quadrat = groesse * groesse; + let diag = (quadrat + quadrat).sqrt(); + plan.left(45.0); + plan.forward(diag / 2.0); + plan.left(90.0); + plan.forward(diag / 2.0); + plan.left(45.0); +} + +fn nikolaus(plan: &mut TurtlePlan, groesse: f32) { + nikolausquadrat(plan, groesse); + nikolausdiag(plan, groesse); +} + +#[macroquad::main("Nikolaus")] +async fn main() { + // Create a turtle plan + let mut plan = create_turtle(); + plan.shape(ShapeType::Turtle); + + // Position the turtle (pen up, move, pen down) + plan.pen_up(); + plan.backward(80.0); + plan.left(90.0); + plan.forward(50.0); + plan.right(90.0); + plan.pen_down(); + + nikolaus(&mut plan, 100.0); + + // Create turtle app with animation (speed = 100 pixels/sec) + let mut app = TurtleApp::new().with_commands(plan.build(), 100.0); + + // Main loop + loop { + clear_background(WHITE); + + // Update and render + app.update(); + app.render(); + + next_frame().await + } +} diff --git a/turtle-lib-macroquad/examples/shapes.rs b/turtle-lib-macroquad/examples/shapes.rs new file mode 100644 index 0000000..876fa20 --- /dev/null +++ b/turtle-lib-macroquad/examples/shapes.rs @@ -0,0 +1,47 @@ +//! Example demonstrating different turtle shapes + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +#[macroquad::main("Turtle Shapes")] +async fn main() { + // Create a turtle plan that demonstrates different shapes + let mut plan = create_turtle(); + + // Start with triangle (default) + plan.forward(100.0); + plan.right(90.0); + + // Change to turtle shape + plan.shape(ShapeType::Turtle); + plan.forward(100.0); + plan.right(90.0); + + // Change to circle + plan.shape(ShapeType::Circle); + plan.forward(100.0); + plan.right(90.0); + + // Change to square + plan.shape(ShapeType::Square); + plan.forward(100.0); + plan.right(90.0); + + // Change to arrow + plan.shape(ShapeType::Arrow); + plan.forward(100.0); + + // Create turtle app with animation (speed = 100 pixels/sec for slower animation) + let mut app = TurtleApp::new().with_commands(plan.build(), 700.0); + + // Main loop + loop { + clear_background(WHITE); + + // Update and render + app.update(); + app.render(); + + next_frame().await + } +} diff --git a/turtle-lib-macroquad/examples/square.rs b/turtle-lib-macroquad/examples/square.rs new file mode 100644 index 0000000..36aacf4 --- /dev/null +++ b/turtle-lib-macroquad/examples/square.rs @@ -0,0 +1,30 @@ +//! Simple square example demonstrating basic turtle graphics + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +#[macroquad::main("Turtle Square")] +async fn main() { + // Create a turtle plan + let mut plan = create_turtle(); + plan.shape(ShapeType::Turtle); + + // Draw a square + for _ in 0..4 { + plan.forward(100.0).right(90.0); + } + + // Create turtle app with animation (speed = 100 pixels/sec) + let mut app = TurtleApp::new().with_commands(plan.build(), 100.0); + + // Main loop + loop { + clear_background(WHITE); + + // Update and render + app.update(); + app.render(); + + next_frame().await + } +} diff --git a/turtle-lib-macroquad/examples/stern.rs b/turtle-lib-macroquad/examples/stern.rs new file mode 100644 index 0000000..ceae419 --- /dev/null +++ b/turtle-lib-macroquad/examples/stern.rs @@ -0,0 +1,34 @@ +//! Simple square example demonstrating basic turtle graphics + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +#[macroquad::main("Turtle Square")] +async fn main() { + // Create a turtle plan + let mut plan = create_turtle(); + plan.shape(ShapeType::Turtle); + plan.set_speed(800); + + // Draw a square + for _ in 0..5 { + plan.forward(200.0); + plan.circle_left(10.0, 72.0, 1000); + plan.circle_right(5.0, 360.0, 1000); + plan.circle_left(10.0, 72.0, 1000); + } + + // Create turtle app with animation (speed = 100 pixels/sec) + let mut app = TurtleApp::new().with_commands(plan.build(), 100.0); + + // Main loop + loop { + clear_background(WHITE); + + // Update and render + app.update(); + app.render(); + + next_frame().await + } +} diff --git a/turtle-lib-macroquad/examples/yinyang.rs b/turtle-lib-macroquad/examples/yinyang.rs new file mode 100644 index 0000000..cd3fd0e --- /dev/null +++ b/turtle-lib-macroquad/examples/yinyang.rs @@ -0,0 +1,41 @@ +//! Simple square example demonstrating basic turtle graphics + +use macroquad::prelude::*; +use turtle_lib_macroquad::*; + +#[macroquad::main("Turtle Square")] +async fn main() { + // Create a turtle plan + let mut t = create_turtle(); + + t.circle_left(90.0, 180.0, 36); + t.circle_left(90.0, 180.0, 36); + t.circle_left(45.0, 180.0, 26); + t.circle_right(45.0, 180.0, 26); + t.pen_up(); + t.right(90.0); + t.forward(37.0); + t.left(90.0); + t.pen_down(); + t.circle_right(8.0, 360.0, 12); + t.pen_up(); + t.right(90.0); + t.forward(90.0); + t.left(90.0); + t.pen_down(); + t.circle_right(8.0, 360.0, 12); + + // Create turtle app with animation (speed = 100 pixels/sec) + let mut app = TurtleApp::new().with_commands(t.build(), 100.0); + + // Main loop + loop { + clear_background(WHITE); + + // Update and render + app.update(); + app.render(); + + next_frame().await + } +} diff --git a/turtle-lib-macroquad/src/builders.rs b/turtle-lib-macroquad/src/builders.rs new file mode 100644 index 0000000..b62610b --- /dev/null +++ b/turtle-lib-macroquad/src/builders.rs @@ -0,0 +1,168 @@ +//! Builder pattern traits for creating turtle command sequences + +use crate::commands::{CommandQueue, TurtleCommand}; +use crate::general::{Color, Precision}; +use crate::shapes::{ShapeType, TurtleShape}; + +/// Trait for adding commands to a queue +pub trait WithCommands { + fn get_commands_mut(&mut self) -> &mut CommandQueue; + fn get_commands(self) -> CommandQueue; +} + +/// Trait for forward/backward movement +pub trait DirectionalMovement: WithCommands { + fn forward(&mut self, distance: T) -> &mut Self + where + T: Into, + { + let dist: Precision = distance.into(); + self.get_commands_mut().push(TurtleCommand::Forward(dist)); + self + } + + fn backward(&mut self, distance: T) -> &mut Self + where + T: Into, + { + let dist: Precision = distance.into(); + self.get_commands_mut().push(TurtleCommand::Backward(dist)); + self + } +} + +/// Trait for turning operations +pub trait Turnable: WithCommands { + fn left(&mut self, angle: T) -> &mut Self + where + T: Into, + { + let degrees: Precision = angle.into(); + self.get_commands_mut().push(TurtleCommand::Left(degrees)); + self + } + + fn right(&mut self, angle: T) -> &mut Self + where + T: Into, + { + let degrees: Precision = angle.into(); + self.get_commands_mut().push(TurtleCommand::Right(degrees)); + self + } +} + +/// Trait for curved movement (circles) +pub trait CurvedMovement: WithCommands { + fn circle_left(&mut self, radius: R, angle: A, steps: usize) -> &mut Self + where + R: Into, + A: Into, + { + let r: Precision = radius.into(); + let a: Precision = angle.into(); + self.get_commands_mut().push(TurtleCommand::CircleLeft { + radius: r, + angle: a, + steps, + }); + self + } + + fn circle_right(&mut self, radius: R, angle: A, steps: usize) -> &mut Self + where + R: Into, + A: Into, + { + let r: Precision = radius.into(); + let a: Precision = angle.into(); + self.get_commands_mut().push(TurtleCommand::CircleRight { + radius: r, + angle: a, + steps, + }); + self + } +} + +/// Builder for creating turtle command sequences +#[derive(Default, Debug)] +pub struct TurtlePlan { + queue: CommandQueue, +} + +impl TurtlePlan { + pub fn new() -> Self { + Self { + queue: CommandQueue::new(), + } + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + queue: CommandQueue::with_capacity(capacity), + } + } + + pub fn set_speed(&mut self, speed: u32) -> &mut Self { + self.queue.push(TurtleCommand::SetSpeed(speed)); + self + } + + pub fn set_color(&mut self, color: Color) -> &mut Self { + self.queue.push(TurtleCommand::SetColor(color)); + self + } + + pub fn set_pen_width(&mut self, width: Precision) -> &mut Self { + self.queue.push(TurtleCommand::SetPenWidth(width)); + self + } + + pub fn pen_up(&mut self) -> &mut Self { + self.queue.push(TurtleCommand::PenUp); + self + } + + pub fn pen_down(&mut self) -> &mut Self { + self.queue.push(TurtleCommand::PenDown); + self + } + + pub fn hide(&mut self) -> &mut Self { + self.queue.push(TurtleCommand::HideTurtle); + self + } + + pub fn show(&mut self) -> &mut Self { + self.queue.push(TurtleCommand::ShowTurtle); + self + } + + pub fn set_shape(&mut self, shape: TurtleShape) -> &mut Self { + self.queue.push(TurtleCommand::SetShape(shape)); + self + } + + pub fn shape(&mut self, shape_type: ShapeType) -> &mut Self { + self.set_shape(shape_type.to_shape()) + } + + pub fn build(self) -> CommandQueue { + self.queue + } +} + +impl WithCommands for TurtlePlan { + fn get_commands_mut(&mut self) -> &mut CommandQueue { + &mut self.queue + } + + fn get_commands(self) -> CommandQueue { + self.queue + } +} + +impl DirectionalMovement for TurtlePlan {} +impl Turnable for TurtlePlan {} +impl CurvedMovement for TurtlePlan {} diff --git a/turtle-lib-macroquad/src/circle_geometry.rs b/turtle-lib-macroquad/src/circle_geometry.rs new file mode 100644 index 0000000..e64c266 --- /dev/null +++ b/turtle-lib-macroquad/src/circle_geometry.rs @@ -0,0 +1,207 @@ +//! Circle geometry calculations - single source of truth for circle_left and circle_right + +use macroquad::prelude::*; + +/// Direction of circular motion (in screen coordinates with Y-down) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CircleDirection { + Left, // Counter-clockwise visually, heading decreases + Right, // Clockwise visually, heading increases +} + +/// Encapsulates all geometry for a circular arc +pub struct CircleGeometry { + pub center: Vec2, + pub radius: f32, + pub start_angle_from_center: f32, // radians + pub direction: CircleDirection, +} + +impl CircleGeometry { + /// Create geometry for a circle command + pub fn new( + turtle_pos: Vec2, + turtle_heading: f32, + radius: f32, + direction: CircleDirection, + ) -> Self { + use std::f32::consts::FRAC_PI_2; + + // Calculate center based on direction + // In screen coordinates (Y-down): + // - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective + // which is heading - π/2 (rotated clockwise from heading vector) + // - Right turn (clockwise visually): center is perpendicular-right from turtle's perspective + // which is heading + π/2 (rotated counter-clockwise from heading vector) + let center_offset_angle = match direction { + CircleDirection::Left => turtle_heading - FRAC_PI_2, + CircleDirection::Right => turtle_heading + FRAC_PI_2, + }; + + let center = vec2( + turtle_pos.x + radius * center_offset_angle.cos(), + turtle_pos.y + radius * center_offset_angle.sin(), + ); + + // Angle from center back to turtle position + let start_angle_from_center = match direction { + CircleDirection::Left => turtle_heading + FRAC_PI_2, + CircleDirection::Right => turtle_heading - FRAC_PI_2, + }; + + Self { + center, + radius, + start_angle_from_center, + direction, + } + } + + /// Calculate position after traveling an angle along the arc + pub fn position_at_angle(&self, angle_traveled: f32) -> Vec2 { + let current_angle = match self.direction { + CircleDirection::Left => self.start_angle_from_center - angle_traveled, + CircleDirection::Right => self.start_angle_from_center + angle_traveled, + }; + + vec2( + self.center.x + self.radius * current_angle.cos(), + self.center.y + self.radius * current_angle.sin(), + ) + } + + /// Calculate position at a given progress (0.0 to 1.0) through total_angle + pub fn position_at_progress(&self, total_angle: f32, progress: f32) -> Vec2 { + let angle_traveled = total_angle * progress; + self.position_at_angle(angle_traveled) + } + + /// Get the angle traveled from start position to a given position + pub fn angle_to_position(&self, position: Vec2) -> f32 { + let displacement = position - self.center; + let current_angle = displacement.y.atan2(displacement.x); + + let mut angle_diff = match self.direction { + CircleDirection::Left => self.start_angle_from_center - current_angle, + CircleDirection::Right => current_angle - self.start_angle_from_center, + }; + + // Normalize to [0, 2π) + if angle_diff < 0.0 { + angle_diff += 2.0 * std::f32::consts::PI; + } + + angle_diff + } + + /// Get draw_arc parameters for the full arc + /// Returns (rotation_degrees, arc_degrees) for macroquad's draw_arc + pub fn draw_arc_params(&self, total_angle_degrees: f32) -> (f32, f32) { + match self.direction { + CircleDirection::Left => { + // For left (counter-clockwise), we need to draw counter-clockwise from end back to start + // so we start at (start - total_angle) and draw total_angle counter-clockwise + let end_angle = self.start_angle_from_center - total_angle_degrees.to_radians(); + (end_angle.to_degrees(), total_angle_degrees) + } + CircleDirection::Right => { + // For right (clockwise), draw from start + ( + self.start_angle_from_center.to_degrees(), + total_angle_degrees, + ) + } + } + } + + /// Get draw_arc parameters for a partial arc (during tweening) + /// Returns (rotation_degrees, arc_degrees) for macroquad's draw_arc + pub fn draw_arc_params_partial(&self, angle_traveled: f32) -> (f32, f32) { + let angle_traveled_degrees = angle_traveled.to_degrees(); + + match self.direction { + CircleDirection::Left => { + // Draw from current position backwards (counter-clockwise) to start + let current_angle = self.start_angle_from_center - angle_traveled; + (current_angle.to_degrees(), angle_traveled_degrees) + } + CircleDirection::Right => { + // Draw from start, counter-clockwise + ( + self.start_angle_from_center.to_degrees(), + angle_traveled_degrees, + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::{FRAC_PI_2, PI}; + + #[test] + fn test_circle_left_geometry() { + let geom = CircleGeometry::new( + vec2(0.0, 0.0), + 0.0, // heading east (0 radians) + 100.0, + CircleDirection::Left, + ); + + // For left turn with heading east (0), center should be at heading - π/2 + // That's -π/2 radians = south + // Center = start + 100 * (cos(-π/2), sin(-π/2)) = (0, 0) + (0, -100) = (0, -100) + assert!( + (geom.center.x - 0.0).abs() < 0.01, + "center.x = {}", + geom.center.x + ); + assert!( + (geom.center.y - (-100.0)).abs() < 0.01, + "center.y = {}", + geom.center.y + ); + + // After π/2 radians counter-clockwise around a circle centered at (0, -100): + // start_angle = π/2 (pointing north from center, which is where (0,0) is) + // after π/2 counter-clockwise (subtract in screen coords): angle = π/2 - π/2 = 0 (pointing east from center) + // pos = (0, -100) + 100 * (cos(0), sin(0)) = (0, -100) + (100, 0) = (100, -100) + let pos = geom.position_at_angle(FRAC_PI_2); + assert!((pos.x - 100.0).abs() < 0.01, "pos.x = {}", pos.x); + assert!((pos.y - (-100.0)).abs() < 0.01, "pos.y = {}", pos.y); + } + + #[test] + fn test_circle_right_geometry() { + let geom = CircleGeometry::new( + vec2(0.0, 0.0), + 0.0, // heading east + 100.0, + CircleDirection::Right, + ); + + // For right turn with heading east (0), center should be at heading + π/2 + // That's π/2 radians = north + // Center = start + 100 * (cos(π/2), sin(π/2)) = (0, 0) + (0, 100) = (0, 100) + assert!( + (geom.center.x - 0.0).abs() < 0.01, + "center.x = {}", + geom.center.x + ); + assert!( + (geom.center.y - 100.0).abs() < 0.01, + "center.y = {}", + geom.center.y + ); + + // After π/2 radians clockwise around a circle centered at (0, 100): + // start_angle = -π/2 (pointing south from center, which is where (0,0) is) + // after π/2 clockwise (add in screen coords): angle = -π/2 + π/2 = 0 (pointing east from center) + // pos = (0, 100) + 100 * (cos(0), sin(0)) = (0, 100) + (100, 0) = (100, 100) + let pos = geom.position_at_angle(PI / 2.0); + assert!((pos.x - 100.0).abs() < 0.01, "pos.x = {}", pos.x); + assert!((pos.y - 100.0).abs() < 0.01, "pos.y = {}", pos.y); + } +} diff --git a/turtle-lib-macroquad/src/commands.rs b/turtle-lib-macroquad/src/commands.rs new file mode 100644 index 0000000..7f70e3d --- /dev/null +++ b/turtle-lib-macroquad/src/commands.rs @@ -0,0 +1,114 @@ +//! Turtle commands and command queue + +use crate::general::{Color, Coordinate, Precision}; +use crate::shapes::TurtleShape; + +/// Individual turtle commands +#[derive(Clone, Debug)] +pub enum TurtleCommand { + // Movement + Forward(Precision), + Backward(Precision), + + // Rotation + Left(Precision), // degrees + Right(Precision), // degrees + + // Circle drawing + CircleLeft { + radius: Precision, + angle: Precision, // degrees + steps: usize, + }, + CircleRight { + radius: Precision, + angle: Precision, // degrees + steps: usize, + }, + + // Pen control + PenUp, + PenDown, + + // Appearance + SetColor(Color), + SetFillColor(Option), + SetPenWidth(Precision), + SetSpeed(u32), + SetShape(TurtleShape), + + // Position + Goto(Coordinate), + SetHeading(Precision), // radians + + // Visibility + ShowTurtle, + HideTurtle, +} + +/// Queue of turtle commands with execution state +#[derive(Debug)] +pub struct CommandQueue { + commands: Vec, + current_index: usize, +} + +impl CommandQueue { + pub fn new() -> Self { + Self { + commands: Vec::new(), + current_index: 0, + } + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + commands: Vec::with_capacity(capacity), + current_index: 0, + } + } + + pub fn push(&mut self, command: TurtleCommand) { + self.commands.push(command); + } + + pub fn extend(&mut self, commands: impl IntoIterator) { + self.commands.extend(commands); + } + + pub fn next(&mut self) -> Option<&TurtleCommand> { + if self.current_index < self.commands.len() { + let cmd = &self.commands[self.current_index]; + self.current_index += 1; + Some(cmd) + } else { + None + } + } + + pub fn is_complete(&self) -> bool { + self.current_index >= self.commands.len() + } + + pub fn reset(&mut self) { + self.current_index = 0; + } + + pub fn len(&self) -> usize { + self.commands.len() + } + + pub fn is_empty(&self) -> bool { + self.commands.is_empty() + } + + pub fn remaining(&self) -> usize { + self.commands.len().saturating_sub(self.current_index) + } +} + +impl Default for CommandQueue { + fn default() -> Self { + Self::new() + } +} diff --git a/turtle-lib-macroquad/src/drawing.rs b/turtle-lib-macroquad/src/drawing.rs new file mode 100644 index 0000000..60d148c --- /dev/null +++ b/turtle-lib-macroquad/src/drawing.rs @@ -0,0 +1,345 @@ +//! Rendering logic using Macroquad + +use crate::circle_geometry::{CircleDirection, CircleGeometry}; +use crate::state::{DrawCommand, TurtleState, TurtleWorld}; +use macroquad::prelude::*; + +// Import the easing function from the tween crate +// To change the easing, change both this import and the usage in the draw_tween_arc_* functions below +// Available options: Linear, SineInOut, QuadInOut, CubicInOut, QuartInOut, QuintInOut, +// ExpoInOut, CircInOut, BackInOut, ElasticInOut, BounceInOut, etc. +// See https://easings.net/ for visual demonstrations +use tween::CubicInOut; + +/// Render the entire turtle world +pub fn render_world(world: &TurtleWorld) { + // Update camera zoom based on current screen size to prevent stretching + let camera = Camera2D { + zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0), + target: world.camera.target, + ..Default::default() + }; + + // Set camera + set_camera(&camera); + + // Draw all accumulated commands + for cmd in &world.commands { + match cmd { + DrawCommand::Line { + start, + end, + color, + width, + } => { + draw_line(start.x, start.y, end.x, end.y, *width, *color); + } + DrawCommand::Circle { + center, + radius, + color, + filled, + } => { + if *filled { + draw_circle(center.x, center.y, *radius, *color); + } else { + draw_circle_lines(center.x, center.y, *radius, 2.0, *color); + } + } + DrawCommand::Arc { + center, + radius, + rotation, + arc, + color, + width, + sides, + } => { + draw_arc( + center.x, center.y, *sides, *radius, *rotation, *width, *arc, *color, + ); + } + DrawCommand::FilledPolygon { vertices, color } => { + if vertices.len() >= 3 { + draw_filled_polygon(vertices, *color); + } + } + } + } + + // Draw turtle if visible + if world.turtle.visible { + draw_turtle(&world.turtle); + } + + // Reset to default camera + set_default_camera(); +} + +/// Render the turtle world with active tween visualization +pub(crate) fn render_world_with_tween( + world: &TurtleWorld, + active_tween: Option<&crate::tweening::CommandTween>, + zoom_level: f32, +) { + // Update camera zoom based on current screen size to prevent stretching + // Apply user zoom level by dividing by it (smaller zoom value = more zoomed in) + let camera = Camera2D { + zoom: vec2( + 1.0 / screen_width() * 2.0 / zoom_level, + 1.0 / screen_height() * 2.0 / zoom_level, + ), + target: world.camera.target, + ..Default::default() + }; + + // Set camera + set_camera(&camera); + + // Draw all accumulated commands + for cmd in &world.commands { + match cmd { + DrawCommand::Line { + start, + end, + color, + width, + } => { + draw_line(start.x, start.y, end.x, end.y, *width, *color); + } + DrawCommand::Circle { + center, + radius, + color, + filled, + } => { + if *filled { + draw_circle(center.x, center.y, *radius, *color); + } else { + draw_circle_lines(center.x, center.y, *radius, 2.0, *color); + } + } + DrawCommand::Arc { + center, + radius, + rotation, + arc, + color, + width, + sides, + } => { + draw_arc( + center.x, center.y, *sides, *radius, *rotation, *width, *arc, *color, + ); + } + DrawCommand::FilledPolygon { vertices, color } => { + if vertices.len() >= 3 { + draw_filled_polygon(vertices, *color); + } + } + } + } + + // Draw in-progress tween line if pen is down + if let Some(tween) = active_tween { + if tween.start_state.pen_down { + match &tween.command { + crate::commands::TurtleCommand::CircleLeft { + radius, + angle, + steps, + } => { + // Draw arc segments from start to current position + draw_tween_arc_left(tween, *radius, *angle, *steps); + } + crate::commands::TurtleCommand::CircleRight { + radius, + angle, + steps, + } => { + // Draw arc segments from start to current position + draw_tween_arc_right(tween, *radius, *angle, *steps); + } + _ if should_draw_tween_line(&tween.command) => { + // Draw straight line for other movement commands + draw_line( + tween.start_state.position.x, + tween.start_state.position.y, + world.turtle.position.x, + world.turtle.position.y, + tween.start_state.pen_width, + tween.start_state.color, + ); + // Add circle at current position for smooth line joins + draw_circle( + world.turtle.position.x, + world.turtle.position.y, + tween.start_state.pen_width / 2.0, + tween.start_state.color, + ); + } + _ => {} + } + } + } + + // Draw turtle if visible + if world.turtle.visible { + draw_turtle(&world.turtle); + } + + // Reset to default camera + set_default_camera(); +} + +fn should_draw_tween_line(command: &crate::commands::TurtleCommand) -> bool { + use crate::commands::TurtleCommand; + matches!( + command, + TurtleCommand::Forward(..) | TurtleCommand::Backward(..) | TurtleCommand::Goto(..) + ) +} + +/// Draw arc segments for circle_left tween animation +fn draw_tween_arc_left( + tween: &crate::tweening::CommandTween, + radius: f32, + total_angle: f32, + steps: usize, +) { + let geom = CircleGeometry::new( + tween.start_state.position, + tween.start_state.heading, + radius, + CircleDirection::Left, + ); + + // Debug: draw center + draw_circle(geom.center.x, geom.center.y, 5.0, GRAY); + + // Calculate how much of the arc we've traveled based on tween progress + // Use the same eased progress as the turtle position for synchronized animation + let elapsed = (get_time() - tween.start_time) as f32; + let t = (elapsed / tween.duration as f32).min(1.0); + let progress = CubicInOut.tween(1.0, t); // tween from 0 to 1 + let angle_traveled = total_angle.to_radians() * progress; + let (rotation_degrees, arc_degrees) = geom.draw_arc_params_partial(angle_traveled); + + // Adjust radius inward by half the line width so the line sits on the turtle's path + let draw_radius = radius - tween.start_state.pen_width / 2.0; + + // Draw the partial arc + draw_arc( + geom.center.x, + geom.center.y, + steps as u8, + draw_radius, + rotation_degrees, + tween.start_state.pen_width, + arc_degrees, + tween.start_state.color, + ); +} + +/// Draw arc segments for circle_right tween animation +fn draw_tween_arc_right( + tween: &crate::tweening::CommandTween, + radius: f32, + total_angle: f32, + steps: usize, +) { + let geom = CircleGeometry::new( + tween.start_state.position, + tween.start_state.heading, + radius, + CircleDirection::Right, + ); + + // Debug: draw center + draw_circle(geom.center.x, geom.center.y, 5.0, GRAY); + + // Calculate how much of the arc we've traveled based on tween progress + // Use the same eased progress as the turtle position for synchronized animation + let elapsed = (get_time() - tween.start_time) as f32; + let t = (elapsed / tween.duration as f32).min(1.0); + let progress = CubicInOut.tween(1.0, t); // tween from 0 to 1 + let angle_traveled = total_angle.to_radians() * progress; + let (rotation_degrees, arc_degrees) = geom.draw_arc_params_partial(angle_traveled); + + // Adjust radius inward by half the line width so the line sits on the turtle's path + let draw_radius = radius - tween.start_state.pen_width / 2.0; + + // Draw the partial arc + draw_arc( + geom.center.x, + geom.center.y, + steps as u8, + draw_radius, + rotation_degrees, + tween.start_state.pen_width, + arc_degrees, + tween.start_state.color, + ); +} + +/// Draw the turtle shape +pub fn draw_turtle(turtle: &TurtleState) { + let rotated_vertices = turtle.shape.rotated_vertices(turtle.heading); + + if turtle.shape.filled { + // Draw filled polygon (now supports concave shapes via ear clipping) + if rotated_vertices.len() >= 3 { + let absolute_vertices: Vec = rotated_vertices + .iter() + .map(|v| turtle.position + *v) + .collect(); + + draw_filled_polygon(&absolute_vertices, Color::new(0.0, 0.5, 1.0, 1.0)); + } + } else { + // Draw outline + if !rotated_vertices.is_empty() { + for i in 0..rotated_vertices.len() { + let next_i = (i + 1) % rotated_vertices.len(); + let p1 = turtle.position + rotated_vertices[i]; + let p2 = turtle.position + rotated_vertices[next_i]; + draw_line(p1.x, p1.y, p2.x, p2.y, 2.0, Color::new(0.0, 0.5, 1.0, 1.0)); + } + } + } +} + +/// Draw a filled polygon using triangulation +fn draw_filled_polygon(vertices: &[Vec2], color: Color) { + if vertices.len() < 3 { + return; + } + + // Flatten vertices into the format expected by earcutr: [x0, y0, x1, y1, ...] + let flattened: Vec = vertices + .iter() + .flat_map(|v| vec![v.x as f64, v.y as f64]) + .collect(); + + // Triangulate using earcutr (no holes, 2 dimensions) + match earcutr::earcut(&flattened, &[], 2) { + Ok(indices) => { + // Draw each triangle + for triangle in indices.chunks(3) { + if triangle.len() == 3 { + let v0 = vertices[triangle[0]]; + let v1 = vertices[triangle[1]]; + let v2 = vertices[triangle[2]]; + draw_triangle(v0, v1, v2, color); + } + } + } + Err(_) => { + // Fallback: if triangulation fails, try simple fan triangulation + let first = vertices[0]; + for i in 1..vertices.len() - 1 { + draw_triangle(first, vertices[i], vertices[i + 1], color); + } + } + } +} diff --git a/turtle-lib-macroquad/src/execution.rs b/turtle-lib-macroquad/src/execution.rs new file mode 100644 index 0000000..a32aa85 --- /dev/null +++ b/turtle-lib-macroquad/src/execution.rs @@ -0,0 +1,402 @@ +//! Command execution logic + +use crate::circle_geometry::{CircleDirection, CircleGeometry}; +use crate::commands::TurtleCommand; +use crate::state::{DrawCommand, TurtleState, TurtleWorld}; +use macroquad::prelude::*; + +/// Execute a single turtle command, updating state and adding draw commands +pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world: &mut TurtleWorld) { + match command { + TurtleCommand::Forward(distance) => { + let start = state.position; + let dx = distance * state.heading.cos(); + let dy = distance * state.heading.sin(); + state.position = vec2(state.position.x + dx, state.position.y + dy); + + if state.pen_down { + world.add_command(DrawCommand::Line { + start, + end: state.position, + color: state.color, + width: state.pen_width, + }); + // Add circle at end point for smooth line joins + world.add_command(DrawCommand::Circle { + center: state.position, + radius: state.pen_width / 2.0, + color: state.color, + filled: true, + }); + } + } + + TurtleCommand::Backward(distance) => { + let start = state.position; + let dx = -distance * state.heading.cos(); + let dy = -distance * state.heading.sin(); + state.position = vec2(state.position.x + dx, state.position.y + dy); + + if state.pen_down { + world.add_command(DrawCommand::Line { + start, + end: state.position, + color: state.color, + width: state.pen_width, + }); + // Add circle at end point for smooth line joins + world.add_command(DrawCommand::Circle { + center: state.position, + radius: state.pen_width / 2.0, + color: state.color, + filled: true, + }); + } + } + + TurtleCommand::Left(degrees) => { + state.heading -= degrees.to_radians(); + } + + TurtleCommand::Right(degrees) => { + state.heading += degrees.to_radians(); + } + + TurtleCommand::CircleLeft { + radius, + angle, + steps, + } => { + let start_heading = state.heading; + let geom = CircleGeometry::new( + state.position, + start_heading, + *radius, + CircleDirection::Left, + ); + + if state.pen_down { + let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle); + + world.add_command(DrawCommand::Arc { + center: geom.center, + radius: *radius - state.pen_width, // Adjust radius for pen width to keep arc inside + rotation: rotation_degrees, + arc: arc_degrees, + color: state.color, + width: state.pen_width, + sides: *steps as u8, + }); + } + + // Update turtle position and heading + state.position = geom.position_at_angle(angle.to_radians()); + state.heading = start_heading - angle.to_radians(); + } + + TurtleCommand::CircleRight { + radius, + angle, + steps, + } => { + let start_heading = state.heading; + let geom = CircleGeometry::new( + state.position, + start_heading, + *radius, + CircleDirection::Right, + ); + + if state.pen_down { + let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle); + + world.add_command(DrawCommand::Arc { + center: geom.center, + radius: *radius - state.pen_width, // Adjust radius for pen width to keep arc inside + rotation: rotation_degrees, + arc: arc_degrees, + color: state.color, + width: state.pen_width, + sides: *steps as u8, + }); + } + + // Update turtle position and heading + state.position = geom.position_at_angle(angle.to_radians()); + state.heading = start_heading + angle.to_radians(); + } + + TurtleCommand::PenUp => { + state.pen_down = false; + } + + TurtleCommand::PenDown => { + state.pen_down = true; + } + + TurtleCommand::SetColor(color) => { + state.color = *color; + } + + TurtleCommand::SetFillColor(color) => { + state.fill_color = *color; + } + + TurtleCommand::SetPenWidth(width) => { + state.pen_width = *width; + } + + TurtleCommand::SetSpeed(speed) => { + state.set_speed(*speed); + } + + TurtleCommand::SetShape(shape) => { + state.shape = shape.clone(); + } + + TurtleCommand::Goto(coord) => { + let start = state.position; + state.position = *coord; + + if state.pen_down { + world.add_command(DrawCommand::Line { + start, + end: state.position, + color: state.color, + width: state.pen_width, + }); + // Add circle at end point for smooth line joins + world.add_command(DrawCommand::Circle { + center: state.position, + radius: state.pen_width / 2.0, + color: state.color, + filled: true, + }); + } + } + + TurtleCommand::SetHeading(heading) => { + state.heading = *heading; + } + + TurtleCommand::ShowTurtle => { + state.visible = true; + } + + TurtleCommand::HideTurtle => { + state.visible = false; + } + } +} + +/// Execute all commands immediately (no animation) +pub fn execute_all_immediate( + queue: &mut crate::commands::CommandQueue, + state: &mut TurtleState, + world: &mut TurtleWorld, +) { + while let Some(command) = queue.next() { + execute_command(command, state, world); + } +} + +/// Add drawing command for a completed tween (state transition already occurred) +pub fn add_draw_for_completed_tween( + command: &TurtleCommand, + start_state: &TurtleState, + end_state: &TurtleState, + world: &mut TurtleWorld, +) { + match command { + TurtleCommand::Forward(_) | TurtleCommand::Backward(_) | TurtleCommand::Goto(_) => { + if start_state.pen_down { + world.add_command(DrawCommand::Line { + start: start_state.position, + end: end_state.position, + color: start_state.color, + width: start_state.pen_width, + }); + // Add circle at end point for smooth line joins + world.add_command(DrawCommand::Circle { + center: end_state.position, + radius: start_state.pen_width / 2.0, + color: start_state.color, + filled: true, + }); + } + } + TurtleCommand::CircleLeft { + radius, + angle, + steps, + } => { + if start_state.pen_down { + let geom = CircleGeometry::new( + start_state.position, + start_state.heading, + *radius, + CircleDirection::Left, + ); + let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle); + + world.add_command(DrawCommand::Arc { + center: geom.center, + radius: *radius - start_state.pen_width / 2.0, + rotation: rotation_degrees, + arc: arc_degrees, + color: start_state.color, + width: start_state.pen_width, + sides: *steps as u8, + }); + + // Add endpoint circles for smooth joins + world.add_command(DrawCommand::Circle { + center: start_state.position, + radius: start_state.pen_width / 2.0, + color: start_state.color, + filled: true, + }); + world.add_command(DrawCommand::Circle { + center: end_state.position, + radius: start_state.pen_width / 2.0, + color: start_state.color, + filled: true, + }); + } + } + TurtleCommand::CircleRight { + radius, + angle, + steps, + } => { + if start_state.pen_down { + let geom = CircleGeometry::new( + start_state.position, + start_state.heading, + *radius, + CircleDirection::Right, + ); + let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle); + + world.add_command(DrawCommand::Arc { + center: geom.center, + radius: *radius - start_state.pen_width / 2.0, + rotation: rotation_degrees, + arc: arc_degrees, + color: start_state.color, + width: start_state.pen_width, + sides: *steps as u8, + }); + + // Add endpoint circles for smooth joins + world.add_command(DrawCommand::Circle { + center: start_state.position, + radius: start_state.pen_width / 2.0, + color: start_state.color, + filled: true, + }); + world.add_command(DrawCommand::Circle { + center: end_state.position, + radius: start_state.pen_width / 2.0, + color: start_state.color, + filled: true, + }); + } + } + _ => { + // Other commands don't create drawing + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::TurtleCommand; + use crate::shapes::TurtleShape; + + #[test] + fn test_forward_left_forward() { + // Test that after forward(100), left(90), forward(50) + // the turtle ends up at (100, -50) from initial position (0, 0) + let mut state = TurtleState { + position: vec2(0.0, 0.0), + heading: 0.0, + pen_down: false, // Disable drawing to avoid needing TurtleWorld + pen_width: 1.0, + color: Color::new(0.0, 0.0, 0.0, 1.0), + fill_color: None, + speed: 100, + visible: true, + shape: TurtleShape::turtle(), + }; + + // We'll use a dummy world but won't actually call drawing commands + let mut world = TurtleWorld { + turtle: state.clone(), + commands: Vec::new(), + camera: macroquad::camera::Camera2D { + zoom: vec2(1.0, 1.0), + target: vec2(0.0, 0.0), + offset: vec2(0.0, 0.0), + rotation: 0.0, + render_target: None, + viewport: None, + }, + background_color: Color::new(1.0, 1.0, 1.0, 1.0), + }; + + // Initial state: position (0, 0), heading 0 (east) + assert_eq!(state.position.x, 0.0); + assert_eq!(state.position.y, 0.0); + assert_eq!(state.heading, 0.0); + + // Forward 100 - should move to (100, 0) + execute_command(&TurtleCommand::Forward(100.0), &mut state, &mut world); + assert!( + (state.position.x - 100.0).abs() < 0.01, + "After forward(100): x = {}", + state.position.x + ); + assert!( + (state.position.y - 0.0).abs() < 0.01, + "After forward(100): y = {}", + state.position.y + ); + assert!((state.heading - 0.0).abs() < 0.01); + + // Left 90 degrees - should face north (heading decreases by 90°) + // In screen coords: north = -90° = -π/2 + execute_command(&TurtleCommand::Left(90.0), &mut state, &mut world); + assert!( + (state.position.x - 100.0).abs() < 0.01, + "After left(90): x = {}", + state.position.x + ); + assert!( + (state.position.y - 0.0).abs() < 0.01, + "After left(90): y = {}", + state.position.y + ); + let expected_heading = -90.0f32.to_radians(); + assert!( + (state.heading - expected_heading).abs() < 0.01, + "After left(90): heading = {} (expected {})", + state.heading, + expected_heading + ); + + // Forward 50 - should move north (negative Y) to (100, -50) + execute_command(&TurtleCommand::Forward(50.0), &mut state, &mut world); + assert!( + (state.position.x - 100.0).abs() < 0.01, + "Final position: x = {} (expected 100.0)", + state.position.x + ); + assert!( + (state.position.y - (-50.0)).abs() < 0.01, + "Final position: y = {} (expected -50.0)", + state.position.y + ); + } +} diff --git a/turtle-lib-macroquad/src/general.rs b/turtle-lib-macroquad/src/general.rs new file mode 100644 index 0000000..2adfdd6 --- /dev/null +++ b/turtle-lib-macroquad/src/general.rs @@ -0,0 +1,24 @@ +//! General types and type aliases used throughout the turtle library + +use macroquad::prelude::*; + +pub mod angle; +pub mod length; + +pub use angle::Angle; +pub use length::Length; + +/// Precision type for calculations +pub type Precision = f32; + +/// 2D coordinate in screen space +pub type Coordinate = Vec2; + +/// Visibility flag for turtle +pub type Visibility = bool; + +/// Speed of animations (higher = faster, >= 999 = instant) +pub type Speed = u32; + +/// Color type re-export from macroquad +pub use macroquad::color::Color; diff --git a/turtle-lib-macroquad/src/general/angle.rs b/turtle-lib-macroquad/src/general/angle.rs new file mode 100644 index 0000000..74b1076 --- /dev/null +++ b/turtle-lib-macroquad/src/general/angle.rs @@ -0,0 +1,200 @@ +//! Angle type with degrees and radians support + +use super::Precision; +use std::ops::{Add, Div, Mul, Neg, Rem, Sub}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum AngleUnit { + Degrees(Precision), + Radians(Precision), +} + +impl Default for AngleUnit { + fn default() -> Self { + Self::Degrees(0.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Angle { + value: AngleUnit, +} + +impl Default for Angle { + fn default() -> Self { + Self { + value: AngleUnit::Degrees(0.0), + } + } +} + +impl From for Angle { + fn from(i: i16) -> Self { + Self { + value: AngleUnit::Degrees(i as Precision), + } + } +} + +impl From for Angle { + fn from(f: f32) -> Self { + Self { + value: AngleUnit::Degrees(f), + } + } +} + +impl Rem for Angle { + type Output = Self; + + fn rem(self, rhs: Precision) -> Self::Output { + match self.value { + AngleUnit::Degrees(v) => Self::degrees(v % rhs), + AngleUnit::Radians(v) => Self::radians(v % rhs), + } + } +} + +impl Mul for Angle { + type Output = Self; + + fn mul(self, rhs: Precision) -> Self::Output { + match self.value { + AngleUnit::Degrees(v) => Self::degrees(v * rhs), + AngleUnit::Radians(v) => Self::radians(v * rhs), + } + } +} + +impl Div for Angle { + type Output = Self; + + fn div(self, rhs: Precision) -> Self::Output { + match self.value { + AngleUnit::Degrees(v) => Self::degrees(v / rhs), + AngleUnit::Radians(v) => Self::radians(v / rhs), + } + } +} + +impl Neg for Angle { + type Output = Self; + + fn neg(self) -> Self::Output { + match self.value { + AngleUnit::Degrees(v) => Self::degrees(-v), + AngleUnit::Radians(v) => Self::radians(-v), + } + } +} + +impl Neg for &Angle { + type Output = Angle; + + fn neg(self) -> Self::Output { + match self.value { + AngleUnit::Degrees(v) => Angle::degrees(-v), + AngleUnit::Radians(v) => Angle::radians(-v), + } + } +} + +impl Add for Angle { + type Output = Angle; + + fn add(self, rhs: Self) -> Self::Output { + match (self.value, rhs.value) { + (AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v + o), + (AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() + o), + (AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v + o.to_radians()), + (AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v + o), + } + } +} + +impl Sub for Angle { + type Output = Angle; + + fn sub(self, rhs: Self) -> Self::Output { + match (self.value, rhs.value) { + (AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v - o), + (AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() - o), + (AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v - o.to_radians()), + (AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v - o), + } + } +} + +impl Angle { + pub fn degrees(value: Precision) -> Self { + Self { + value: AngleUnit::Degrees(value), + } + } + + pub fn radians(value: Precision) -> Self { + Self { + value: AngleUnit::Radians(value), + } + } + + pub fn value(&self) -> Precision { + match self.value { + AngleUnit::Degrees(v) => v, + AngleUnit::Radians(v) => v, + } + } + + pub fn to_radians(self) -> Self { + match self.value { + AngleUnit::Degrees(v) => Self::radians(v.to_radians()), + AngleUnit::Radians(_) => self, + } + } + + pub fn to_degrees(self) -> Self { + match self.value { + AngleUnit::Degrees(_) => self, + AngleUnit::Radians(v) => Self::degrees(v.to_degrees()), + } + } + + pub fn limit_smaller_than_full_circle(self) -> Self { + use std::f32::consts::PI; + match self.value { + AngleUnit::Degrees(v) => Self::degrees(v % 360.0), + AngleUnit::Radians(v) => Self::radians(v % (2.0 * PI)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn convert_to_radians() { + let radi = Angle::radians(30f32.to_radians()); + let degr = Angle::degrees(30f32); + let converted = degr.to_radians(); + assert!((radi.value() - converted.value()).abs() < 0.0001); + } + + #[test] + fn sum_degrees() { + let fst = Angle::degrees(30f32); + let snd = Angle::degrees(30f32); + let sum = fst + snd; + assert!((sum.value() - 60f32).abs() < 0.0001); + assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001); + } + + #[test] + fn sum_mixed() { + let fst = Angle::degrees(30f32); + let snd = Angle::radians(30f32.to_radians()); + let sum = fst + snd; + assert!((sum.to_degrees().value() - 60f32).abs() < 0.0001); + assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001); + } +} diff --git a/turtle-lib-macroquad/src/general/length.rs b/turtle-lib-macroquad/src/general/length.rs new file mode 100644 index 0000000..e884b38 --- /dev/null +++ b/turtle-lib-macroquad/src/general/length.rs @@ -0,0 +1,24 @@ +//! Length type for distance measurements + +use super::Precision; + +#[derive(Default, Copy, Clone, Debug, PartialEq)] +pub struct Length(pub Precision); + +impl From for Length { + fn from(i: i16) -> Self { + Self(i as Precision) + } +} + +impl From for Length { + fn from(f: f32) -> Self { + Self(f) + } +} + +impl From for Length { + fn from(i: i32) -> Self { + Self(i as Precision) + } +} diff --git a/turtle-lib-macroquad/src/lib.rs b/turtle-lib-macroquad/src/lib.rs new file mode 100644 index 0000000..a0d3915 --- /dev/null +++ b/turtle-lib-macroquad/src/lib.rs @@ -0,0 +1,222 @@ +//! Turtle graphics library for Macroquad +//! +//! This library provides a turtle graphics API for creating drawings and animations +//! using the Macroquad game framework. +//! +//! # Example +//! ```no_run +//! use macroquad::prelude::*; +//! use turtle_lib_macroquad::*; +//! +//! #[macroquad::main("Turtle")] +//! async fn main() { +//! let mut plan = create_turtle(); +//! plan.forward(100.0).right(90.0).forward(100.0); +//! +//! let mut app = TurtleApp::new().with_commands(plan.build(), 100.0); +//! +//! loop { +//! clear_background(WHITE); +//! app.update(); +//! app.render(); +//! next_frame().await +//! } +//! } +//! ``` + +pub mod builders; +pub mod circle_geometry; +pub mod commands; +pub mod drawing; +pub mod execution; +pub mod general; +pub mod shapes; +pub mod state; +pub mod tweening; + +// Re-export commonly used types +pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands}; +pub use commands::{CommandQueue, TurtleCommand}; +pub use general::{Angle, Color, Coordinate, Length, Precision, Speed}; +pub use shapes::{ShapeType, TurtleShape}; +pub use state::{DrawCommand, TurtleState, TurtleWorld}; +pub use tweening::TweenController; + +use macroquad::prelude::*; + +/// Main turtle application struct +pub struct TurtleApp { + world: TurtleWorld, + tween_controller: Option, + mode: ExecutionMode, + // Mouse panning state + is_dragging: bool, + last_mouse_pos: Option, + // Zoom state + zoom_level: f32, +} + +enum ExecutionMode { + Immediate, + Animated, +} + +impl TurtleApp { + /// Create a new turtle application with default settings + pub fn new() -> Self { + Self { + world: TurtleWorld::new(), + tween_controller: None, + mode: ExecutionMode::Immediate, + is_dragging: false, + last_mouse_pos: None, + zoom_level: 1.0, + } + } + + /// Add commands to the turtle with specified speed + /// + /// # Arguments + /// * `queue` - The command queue to execute + /// * `speed` - Animation speed in pixels/sec (>= 999.0 = instant, 0.5-999.0 = animated) + pub fn with_commands(mut self, queue: CommandQueue, speed: f32) -> Self { + if speed <= 0.5 || speed.is_infinite() || speed.is_nan() { + // Compiler error speed should be between 0.5 and 1000.0 + panic!("Speed must be greater than 0.5 and less than 1000.0"); + } + if speed >= 999.0 { + // Immediate mode - execute all commands instantly + self.mode = ExecutionMode::Immediate; + let mut state = TurtleState::default(); + let mut queue_mut = queue; + execution::execute_all_immediate(&mut queue_mut, &mut state, &mut self.world); + self.world.turtle = state; + } else { + // Animated mode - tween between states + self.mode = ExecutionMode::Animated; + self.tween_controller = Some(TweenController::new(queue, speed)); + } + self + } + + /// Update animation state (call every frame) + pub fn update(&mut self) { + // Handle mouse panning and zoom + self.handle_mouse_panning(); + self.handle_mouse_zoom(); + + if let Some(ref mut controller) = self.tween_controller { + if let Some((completed_cmd, start_state)) = controller.update(&mut self.world.turtle) { + // Copy end state before we borrow world mutably + let end_state = self.world.turtle.clone(); + + // Add draw commands for the completed tween + execution::add_draw_for_completed_tween( + &completed_cmd, + &start_state, + &end_state, + &mut self.world, + ); + } + } + } + + /// Handle mouse click and drag for panning + fn handle_mouse_panning(&mut self) { + let mouse_pos = mouse_position(); + let mouse_pos = vec2(mouse_pos.0, mouse_pos.1); + + if is_mouse_button_pressed(MouseButton::Left) { + self.is_dragging = true; + self.last_mouse_pos = Some(mouse_pos); + } + + if is_mouse_button_released(MouseButton::Left) { + self.is_dragging = false; + self.last_mouse_pos = None; + } + + if self.is_dragging { + if let Some(last_pos) = self.last_mouse_pos { + // Calculate delta in screen space + let delta = mouse_pos - last_pos; + + // Convert screen delta to world space delta + // The camera zoom is 2.0 / screen_width, so world_units = screen_pixels / (screen_size * zoom / 2) + let world_delta = vec2( + -delta.x, -delta.y, // Flip Y because screen Y is down + ); + + self.world.camera.target += world_delta * self.zoom_level; + } + self.last_mouse_pos = Some(mouse_pos); + } + } + + /// Handle mouse wheel for zooming + fn handle_mouse_zoom(&mut self) { + let (_wheel_x, wheel_y) = mouse_wheel(); + + if wheel_y != 0.0 { + // Zoom factor: positive wheel_y = zoom in, negative = zoom out + let zoom_factor = 1.0 + wheel_y * 0.1; + self.zoom_level *= zoom_factor; + + // Clamp zoom level to reasonable values + self.zoom_level = self.zoom_level.clamp(0.1, 10.0); + } + } + + /// Render the turtle world (call every frame) + pub fn render(&self) { + // Get active tween if in animated mode + let active_tween = self + .tween_controller + .as_ref() + .and_then(|c| c.current_tween()); + drawing::render_world_with_tween(&self.world, active_tween, self.zoom_level); + } + + /// Check if all commands have been executed + pub fn is_complete(&self) -> bool { + self.tween_controller + .as_ref() + .map(|c| c.is_complete()) + .unwrap_or(true) + } + + /// Get reference to the world state + pub fn world(&self) -> &TurtleWorld { + &self.world + } + + /// Get mutable reference to the world state + pub fn world_mut(&mut self) -> &mut TurtleWorld { + &mut self.world + } +} + +impl Default for TurtleApp { + fn default() -> Self { + Self::new() + } +} + +/// Helper function to create a new turtle plan +/// +/// # Example +/// ``` +/// use turtle_lib_macroquad::*; +/// +/// let mut turtle = create_turtle(); +/// turtle.forward(100.0).right(90.0).forward(50.0); +/// let commands = turtle.build(); +/// ``` +pub fn create_turtle() -> TurtlePlan { + TurtlePlan::new() +} + +/// Convenience function to get a turtle plan (alias for create_turtle) +pub fn get_a_turtle() -> TurtlePlan { + create_turtle() +} diff --git a/turtle-lib-macroquad/src/shapes.rs b/turtle-lib-macroquad/src/shapes.rs new file mode 100644 index 0000000..d8d8c10 --- /dev/null +++ b/turtle-lib-macroquad/src/shapes.rs @@ -0,0 +1,162 @@ +//! Turtle shape definitions + +use macroquad::prelude::*; +use std::f32::consts::PI; + +/// A shape that can be drawn for the turtle +#[derive(Clone, Debug)] +pub struct TurtleShape { + /// Vertices of the shape (relative to turtle position) + pub vertices: Vec, + /// Whether to draw as filled polygon (true) or outline (false) + pub filled: bool, +} + +impl TurtleShape { + /// Create a new custom shape from vertices + pub fn new(vertices: Vec, filled: bool) -> Self { + Self { vertices, filled } + } + + /// Get vertices rotated by the given angle + pub fn rotated_vertices(&self, angle: f32) -> Vec { + self.vertices + .iter() + .map(|v| { + let cos_a = angle.cos(); + let sin_a = angle.sin(); + vec2(v.x * cos_a - v.y * sin_a, v.x * sin_a + v.y * cos_a) + }) + .collect() + } + + /// Triangle shape (simple arrow pointing right) + pub fn triangle() -> Self { + Self { + vertices: vec![ + vec2(15.0, 0.0), // Point + vec2(-10.0, -8.0), // Bottom left + vec2(-10.0, 8.0), // Top left + ], + filled: true, + } + } + + /// Classic turtle shape + pub fn turtle() -> Self { + // Based on the original turtle shape from turtle-lib + let polygon: &[[f32; 2]; 23] = &[ + [-2.5, 14.0], + [-1.25, 10.0], + [-4.0, 7.0], + [-7.0, 9.0], + [-9.0, 8.0], + [-6.0, 5.0], + [-7.0, 1.0], + [-5.0, -3.0], + [-8.0, -6.0], + [-6.0, -8.0], + [-4.0, -5.0], + [0.0, -7.0], + [4.0, -5.0], + [6.0, -8.0], + [8.0, -6.0], + [5.0, -3.0], + [7.0, 1.0], + [6.0, 5.0], + [9.0, 8.0], + [7.0, 9.0], + [4.0, 7.0], + [1.25, 10.0], + [2.5, 14.0], + ]; + + // Rotate by -90 degrees to point right (original points up) + let vertices: Vec = polygon + .iter() + .map(|[x, y]| { + let v = vec2(*x, *y); + let cos_a = (-PI / 2.0).cos(); + let sin_a = (-PI / 2.0).sin(); + vec2(v.x * cos_a - v.y * sin_a, v.x * sin_a + v.y * cos_a) + }) + .collect(); + + Self { + vertices, + filled: true, // Now uses ear clipping for proper concave polygon rendering + } + } + + /// Circle shape + pub fn circle() -> Self { + let segments = 16; + let radius = 10.0; + let vertices: Vec = (0..segments) + .map(|i| { + let angle = (i as f32 / segments as f32) * 2.0 * PI; + vec2(radius * angle.cos(), radius * angle.sin()) + }) + .collect(); + + Self { + vertices, + filled: true, + } + } + + /// Square shape + pub fn square() -> Self { + Self { + vertices: vec![ + vec2(8.0, 8.0), + vec2(-8.0, 8.0), + vec2(-8.0, -8.0), + vec2(8.0, -8.0), + ], + filled: true, + } + } + + /// Arrow shape (simple arrow pointing right) + pub fn arrow() -> Self { + Self { + vertices: vec![ + vec2(12.0, 0.0), // Point + vec2(-8.0, 6.0), // Top back + vec2(-4.0, 0.0), // Middle back + vec2(-8.0, -6.0), // Bottom back + ], + filled: true, + } + } +} + +/// Pre-defined shape types +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShapeType { + Triangle, + Turtle, + Circle, + Square, + Arrow, +} + +impl ShapeType { + /// Get the corresponding TurtleShape + pub fn to_shape(&self) -> TurtleShape { + match self { + ShapeType::Triangle => TurtleShape::triangle(), + ShapeType::Turtle => TurtleShape::turtle(), + ShapeType::Circle => TurtleShape::circle(), + ShapeType::Square => TurtleShape::square(), + ShapeType::Arrow => TurtleShape::arrow(), + } + } +} + +impl Default for ShapeType { + fn default() -> Self { + ShapeType::Turtle + } +} diff --git a/turtle-lib-macroquad/src/state.rs b/turtle-lib-macroquad/src/state.rs new file mode 100644 index 0000000..6868be1 --- /dev/null +++ b/turtle-lib-macroquad/src/state.rs @@ -0,0 +1,113 @@ +//! Turtle state and world state management + +use crate::general::{Angle, Color, Coordinate, Precision, Speed}; +use crate::shapes::TurtleShape; +use macroquad::prelude::*; + +/// State of a single turtle +#[derive(Clone, Debug)] +pub struct TurtleState { + pub position: Coordinate, + pub heading: Precision, // radians + pub pen_down: bool, + pub color: Color, + pub fill_color: Option, + pub pen_width: Precision, + pub speed: Speed, + pub visible: bool, + pub shape: TurtleShape, +} + +impl Default for TurtleState { + fn default() -> Self { + Self { + position: vec2(0.0, 0.0), + heading: 0.0, // pointing right (0 radians) + pen_down: true, + color: BLACK, + fill_color: None, + pen_width: 2.0, + speed: 100, // pixels per second + visible: true, + shape: TurtleShape::turtle(), + } + } +} + +impl TurtleState { + pub fn set_speed(&mut self, speed: Speed) { + self.speed = speed.max(1); + } + + pub fn heading_angle(&self) -> Angle { + Angle::radians(self.heading) + } +} + +/// Drawable elements in the world +#[derive(Clone, Debug)] +pub enum DrawCommand { + Line { + start: Coordinate, + end: Coordinate, + color: Color, + width: Precision, + }, + Circle { + center: Coordinate, + radius: Precision, + color: Color, + filled: bool, + }, + Arc { + center: Coordinate, + radius: Precision, + rotation: Precision, // Start angle in degrees + arc: Precision, // Arc extent in degrees + color: Color, + width: Precision, + sides: u8, // Number of segments for quality + }, + FilledPolygon { + vertices: Vec, + color: Color, + }, +} + +/// The complete turtle world containing all drawing state +pub struct TurtleWorld { + pub turtle: TurtleState, + pub commands: Vec, + pub camera: Camera2D, + pub background_color: Color, +} + +impl TurtleWorld { + pub fn new() -> Self { + Self { + turtle: TurtleState::default(), + commands: Vec::new(), + camera: Camera2D { + zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0), + target: vec2(0.0, 0.0), + ..Default::default() + }, + background_color: WHITE, + } + } + + pub fn add_command(&mut self, cmd: DrawCommand) { + self.commands.push(cmd); + } + + pub fn clear(&mut self) { + self.commands.clear(); + self.turtle = TurtleState::default(); + } +} + +impl Default for TurtleWorld { + fn default() -> Self { + Self::new() + } +} diff --git a/turtle-lib-macroquad/src/tweening.rs b/turtle-lib-macroquad/src/tweening.rs new file mode 100644 index 0000000..1b3a27a --- /dev/null +++ b/turtle-lib-macroquad/src/tweening.rs @@ -0,0 +1,354 @@ +//! Tweening system for smooth animations + +use crate::circle_geometry::{CircleDirection, CircleGeometry}; +use crate::commands::{CommandQueue, TurtleCommand}; +use crate::state::TurtleState; +use macroquad::prelude::*; +use tween::{CubicInOut, TweenValue, Tweener}; + +// Newtype wrapper for Vec2 to implement TweenValue +#[derive(Debug, Clone, Copy)] +pub(crate) struct TweenVec2(Vec2); + +impl TweenValue for TweenVec2 { + fn scale(self, scalar: f32) -> Self { + TweenVec2(self.0 * scalar) + } +} + +impl std::ops::Add for TweenVec2 { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + TweenVec2(self.0 + rhs.0) + } +} + +impl std::ops::Sub for TweenVec2 { + type Output = Self; + fn sub(self, rhs: Self) -> Self::Output { + TweenVec2(self.0 - rhs.0) + } +} + +impl From for TweenVec2 { + fn from(v: Vec2) -> Self { + TweenVec2(v) + } +} + +impl From for Vec2 { + fn from(v: TweenVec2) -> Self { + v.0 + } +} + +/// Controls tweening of turtle commands +pub struct TweenController { + queue: CommandQueue, + current_tween: Option, + speed: f32, // pixels per second (or degrees per second for rotations) +} + +pub(crate) struct CommandTween { + pub command: TurtleCommand, + pub start_time: f64, + pub duration: f64, + pub start_state: TurtleState, + pub target_state: TurtleState, + pub position_tweener: Tweener, + pub heading_tweener: Tweener, + pub pen_width_tweener: Tweener, +} + +impl TweenController { + pub fn new(queue: CommandQueue, speed: f32) -> Self { + Self { + queue, + current_tween: None, + speed: speed.max(1.0), + } + } + + pub fn set_speed(&mut self, speed: f32) { + self.speed = speed.max(1.0); + } + + /// Update the tween, returns (command, start_state) if command completed + pub fn update(&mut self, state: &mut TurtleState) -> Option<(TurtleCommand, TurtleState)> { + // Process current tween + if let Some(ref mut tween) = self.current_tween { + let elapsed = (get_time() - tween.start_time) as f32; + + // Use tweeners to calculate current values + // For circles, calculate position along the arc instead of straight line + let progress = tween.heading_tweener.move_to(elapsed); + + state.position = match &tween.command { + TurtleCommand::CircleLeft { radius, angle, .. } => { + let angle_traveled = angle.to_radians() * progress; + calculate_circle_left_position( + tween.start_state.position, + tween.start_state.heading, + *radius, + angle_traveled, + ) + } + TurtleCommand::CircleRight { radius, angle, .. } => { + let angle_traveled = angle.to_radians() * progress; + calculate_circle_right_position( + tween.start_state.position, + tween.start_state.heading, + *radius, + angle_traveled, + ) + } + _ => { + // For non-circle commands, use normal position tweening + tween.position_tweener.move_to(elapsed).into() + } + }; + + // Heading changes proportionally with progress for all commands + state.heading = match &tween.command { + TurtleCommand::CircleLeft { angle, .. } => { + tween.start_state.heading - angle.to_radians() * progress + } + TurtleCommand::CircleRight { angle, .. } => { + tween.start_state.heading + angle.to_radians() * progress + } + TurtleCommand::Left(angle) => { + tween.start_state.heading - angle.to_radians() * progress + } + TurtleCommand::Right(angle) => { + tween.start_state.heading + angle.to_radians() * progress + } + TurtleCommand::SetHeading(_) | _ => { + // For other commands that change heading, lerp directly + let heading_diff = tween.target_state.heading - tween.start_state.heading; + tween.start_state.heading + heading_diff * progress + } + }; + state.pen_width = tween.pen_width_tweener.move_to(elapsed); + + // Discrete properties (switch at 50% progress) + let progress = (elapsed / tween.duration as f32).min(1.0); + if progress >= 0.5 { + state.pen_down = tween.target_state.pen_down; + state.color = tween.target_state.color; + state.fill_color = tween.target_state.fill_color; + state.visible = tween.target_state.visible; + state.shape = tween.target_state.shape.clone(); + } + + // Check if tween is finished (use heading_tweener as it's used by all commands) + if tween.heading_tweener.is_finished() { + // Tween complete, finalize state + let start_state = tween.start_state.clone(); + *state = tween.target_state.clone(); + + // Return the completed command and start state to add draw commands + let completed_command = tween.command.clone(); + self.current_tween = None; + + // Only return command if it creates drawable elements + if Self::command_creates_drawing(&completed_command) { + return Some((completed_command, start_state)); + } + } + + return None; + } + + // Start next tween + if let Some(command) = self.queue.next() { + let command_clone = command.clone(); + let speed = state.speed; // Extract speed before borrowing self + let duration = self.calculate_duration(&command_clone, speed); + + // Calculate target state + let target_state = self.calculate_target_state(state, &command_clone); + + // Create tweeners for smooth animation + let position_tweener = Tweener::new( + TweenVec2::from(state.position), + TweenVec2::from(target_state.position), + duration as f32, + CubicInOut, + ); + + let heading_tweener = Tweener::new( + 0.0, // We'll handle angle wrapping separately + 1.0, + duration as f32, + CubicInOut, + ); + + let pen_width_tweener = Tweener::new( + state.pen_width, + target_state.pen_width, + duration as f32, + CubicInOut, + ); + + self.current_tween = Some(CommandTween { + command: command_clone, + start_time: get_time(), + duration, + start_state: state.clone(), + target_state, + position_tweener, + heading_tweener, + pen_width_tweener, + }); + } + + None + } + + pub fn is_complete(&self) -> bool { + self.current_tween.is_none() && self.queue.is_complete() + } + + /// Get the current active tween if one is in progress + pub(crate) fn current_tween(&self) -> Option<&CommandTween> { + self.current_tween.as_ref() + } + + fn command_creates_drawing(command: &TurtleCommand) -> bool { + matches!( + command, + TurtleCommand::Forward(_) + | TurtleCommand::Backward(_) + | TurtleCommand::CircleLeft { .. } + | TurtleCommand::CircleRight { .. } + | TurtleCommand::Goto(_) + ) + } + + fn calculate_duration(&self, command: &TurtleCommand, speed: u32) -> f64 { + let speed = speed.max(1) as f32; + + let base_time = match command { + TurtleCommand::Forward(dist) | TurtleCommand::Backward(dist) => dist.abs() / speed, + TurtleCommand::Left(angle) | TurtleCommand::Right(angle) => { + // Rotation speed: assume 180 degrees per second at speed 100 + angle.abs() / (speed * 1.8) + } + TurtleCommand::CircleLeft { radius, angle, .. } + | TurtleCommand::CircleRight { radius, angle, .. } => { + let arc_length = radius * angle.to_radians().abs(); + arc_length / speed + } + TurtleCommand::Goto(_target) => { + // Calculate distance (handled in calculate_target_state) + 0.1 // Placeholder, will be calculated properly + } + _ => 0.0, // Instant commands + }; + base_time.max(0.01) as f64 // Minimum duration + } + + fn calculate_target_state( + &self, + current: &TurtleState, + command: &TurtleCommand, + ) -> TurtleState { + let mut target = current.clone(); + + match command { + TurtleCommand::Forward(dist) => { + let dx = dist * current.heading.cos(); + let dy = dist * current.heading.sin(); + target.position = vec2(current.position.x + dx, current.position.y + dy); + } + TurtleCommand::Backward(dist) => { + let dx = -dist * current.heading.cos(); + let dy = -dist * current.heading.sin(); + target.position = vec2(current.position.x + dx, current.position.y + dy); + } + TurtleCommand::Left(angle) => { + target.heading -= angle.to_radians(); + } + TurtleCommand::Right(angle) => { + target.heading += angle.to_radians(); + } + TurtleCommand::CircleLeft { radius, angle, .. } => { + // Use helper function to calculate final position + target.position = calculate_circle_left_position( + current.position, + current.heading, + *radius, + angle.to_radians(), + ); + target.heading = current.heading - angle.to_radians(); + } + TurtleCommand::CircleRight { radius, angle, .. } => { + // Use helper function to calculate final position + target.position = calculate_circle_right_position( + current.position, + current.heading, + *radius, + angle.to_radians(), + ); + target.heading = current.heading + angle.to_radians(); + } + TurtleCommand::Goto(coord) => { + target.position = *coord; + } + TurtleCommand::SetHeading(heading) => { + target.heading = *heading; + } + TurtleCommand::SetColor(color) => { + target.color = *color; + } + TurtleCommand::SetPenWidth(width) => { + target.pen_width = *width; + } + TurtleCommand::SetSpeed(speed) => { + target.speed = *speed; + } + TurtleCommand::SetShape(shape) => { + target.shape = shape.clone(); + } + TurtleCommand::PenUp => { + target.pen_down = false; + } + TurtleCommand::PenDown => { + target.pen_down = true; + } + TurtleCommand::ShowTurtle => { + target.visible = true; + } + TurtleCommand::HideTurtle => { + target.visible = false; + } + TurtleCommand::SetFillColor(color) => { + target.fill_color = *color; + } + } + + target + } +} + +/// Calculate position on a circular arc for circle_left +fn calculate_circle_left_position( + start_pos: Vec2, + start_heading: f32, + radius: f32, + angle_traveled: f32, // How much of the total angle we've traveled (in radians) +) -> Vec2 { + let geom = CircleGeometry::new(start_pos, start_heading, radius, CircleDirection::Left); + geom.position_at_angle(angle_traveled) +} + +/// Calculate position on a circular arc for circle_right +fn calculate_circle_right_position( + start_pos: Vec2, + start_heading: f32, + radius: f32, + angle_traveled: f32, // How much of the total angle we've traveled (in radians) +) -> Vec2 { + let geom = CircleGeometry::new(start_pos, start_heading, radius, CircleDirection::Right); + geom.position_at_angle(angle_traveled) +}