├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rusty-sandbox" 3 | description = "A lightweight sandbox sim written in Rust." 4 | version = "0.1.0" 5 | edition = "2021" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | macroquad = "0.3.23" 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 JSKitty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Rusty Sandbox 3 |

4 | 5 |

6 | A lightweight sandbox sim written in Rust. 7 |

8 | 9 |

10 | Play via Browser (WASM) | Compile by yourself 11 |

12 | 13 |

14 | 15 |

16 | 17 |

18 | This is a quick hobby project written to practice three things: Rust, Macroquad and Maths! 19 |

20 | 21 | --- 22 | 23 | # Dev Builds 24 | 25 | Prerequisites: The Rust Toolchain (stable preferred). 26 | 27 |
Local Compile (For your architecture) 28 | 29 | ```bash 30 | git clone https://github.com/JSKitty/rusty-sandbox.git && cd rusty-sandbox 31 | cargo run --release 32 | cargo build --release 33 | ``` 34 |
35 | 36 | 37 |
WASM Compile (For web-based usage like this!) 38 | 39 | ```bash 40 | git clone https://github.com/JSKitty/rusty-sandbox.git && cd rusty-sandbox 41 | rustup target add wasm32-unknown-unknown 42 | cargo build --release --target wasm32-unknown-unknown 43 | ``` 44 |
45 | 46 | --- 47 | 48 | # Aim / Goals 49 | 50 | The primary aims of the project being: 51 | - **Minimalistic codebase:** easy to follow, easy to learn from, a 'living' tutorial. 52 | - **Low Dependency:** as much written in-house as possible, such as physics algorithms, etc. 53 | - **Lightweight:** should compile super fast, and execute super fast by users. 54 | - **Fun:** should be pretty fun to play with! Both in code and in user-land. 55 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use macroquad::prelude::*; 2 | 3 | // NOTE: enable DEBUG and recompile for runtime stats / tracking / debugging helpers 4 | static DEBUG: bool = false; 5 | 6 | // Font size for the '{ParticleVariant} Selected' screen 7 | static SELECTED_FONT_SIZE: f32 = 150.0; 8 | 9 | #[derive(Clone, PartialEq, Eq)] 10 | enum ParticleVariant { 11 | Sand, 12 | Dirt, 13 | Water, 14 | Brick 15 | } 16 | 17 | impl ParticleVariant { 18 | // Return a percentage (1-100) chance of this particle moving, based on it's variant 19 | fn get_movement_chance(&self) -> u8 { 20 | match self { 21 | ParticleVariant::Sand => 50, 22 | ParticleVariant::Dirt => 5, 23 | ParticleVariant::Water => 100, 24 | // Other particles (ie: brick) will default to being still 25 | _ => 0 26 | } 27 | } 28 | } 29 | 30 | impl std::fmt::Display for ParticleVariant { 31 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 32 | match self { 33 | ParticleVariant::Sand => write!(f, "Sand"), 34 | ParticleVariant::Dirt => write!(f, "Dirt"), 35 | ParticleVariant::Water => write!(f, "Water"), 36 | ParticleVariant::Brick => write!(f, "Brick") 37 | } 38 | } 39 | } 40 | 41 | #[derive(Clone)] 42 | struct Particle { 43 | id: u32, 44 | variant: ParticleVariant, 45 | active: bool 46 | } 47 | 48 | impl Particle { 49 | fn new(id: u32, variant: ParticleVariant, active: bool) -> Particle { 50 | Particle { id, variant, active } 51 | } 52 | 53 | // Return a potential (non-guarenteed) movement delta for this particle, based on it's properties 54 | fn try_generate_movement(&self) -> usize { 55 | if rand::gen_range(0, 100) < self.variant.get_movement_chance() { 56 | rand::gen_range(-2, 2) as usize 57 | } else { 0 } 58 | } 59 | 60 | // Return a colour for this particle, based on it's properties 61 | // BUG (?): using a custom `Color::new(r, g, b, a);` doesn't seem to work here... so try to stick to defaults? 62 | fn get_colour(&self) -> Color { 63 | match self.variant { 64 | ParticleVariant::Sand => BEIGE, 65 | ParticleVariant::Dirt => DARKBROWN, 66 | ParticleVariant::Water => BLUE, 67 | ParticleVariant::Brick => RED 68 | } 69 | } 70 | } 71 | 72 | #[macroquad::main("Rusty Sandbox")] 73 | async fn main() { 74 | // The 2D world-space particle grid 75 | let mut world: Vec> = Vec::new(); 76 | 77 | // The last particle ID generated 78 | let mut last_id: u32 = 0; 79 | 80 | // The size (in pixels) of our paint radius 81 | let mut paint_radius: u16 = 1; 82 | 83 | // The zoom multiplyer 84 | let mut camera_zoom: u8 = 1; 85 | 86 | // The camera offsets (used to 'control' the camera's location on the grid via zoomed X/Y offset) 87 | let mut camera_offset_x: i16 = 0; 88 | let mut camera_offset_y: i16 = 0; 89 | 90 | // Flag to ensure paint 'smoothing' doesn't activate between clicks (individual paints) 91 | let mut is_drawing_secondary = false; 92 | 93 | // Trackers for mouse movements (used in 'smoothing' fast paints) 94 | let mut last_x: u16 = 0; 95 | let mut last_y: u16 = 0; 96 | 97 | // Flag lock to tell the engine when the user is hitting a GUI button 98 | let mut is_clicking_ui = false; 99 | 100 | // The current primary particle variant selected by the user 101 | let mut selected_variant = ParticleVariant::Sand; 102 | 103 | // The logic + renderer loop 104 | loop { 105 | clear_background(BLACK); 106 | 107 | // For every screen-height-pixel missing in world-space: 108 | for x in world.len()..screen_width() as usize { 109 | 110 | // Push the Y-axis particle vector 111 | let yvec: Vec = Vec::new(); 112 | world.push(yvec); 113 | 114 | // For every screen-width-pixel missing in world-space: 115 | for _y in world[x].len()..screen_height() as usize { 116 | 117 | // Generate a non-interactive placeholder particle 118 | last_id += 1; 119 | let air = Particle::new( 120 | last_id, 121 | ParticleVariant::Sand, 122 | false 123 | ); 124 | 125 | // Push the air particle 126 | world[x].push(air); 127 | } 128 | } 129 | 130 | // UI: Top-right 131 | if macroquad::ui::root_ui().button(vec2(25.0, 25.0), "Sand") { 132 | is_clicking_ui = true; 133 | selected_variant = ParticleVariant::Sand; 134 | } 135 | 136 | if macroquad::ui::root_ui().button(vec2(75.0, 25.0), "Dirt") { 137 | is_clicking_ui = true; 138 | selected_variant = ParticleVariant::Dirt; 139 | } 140 | 141 | if macroquad::ui::root_ui().button(vec2(125.0, 25.0), "Water") { 142 | is_clicking_ui = true; 143 | selected_variant = ParticleVariant::Water; 144 | } 145 | 146 | // UI: Top-Centre 147 | let selected_display_str = format!("{}", selected_variant); 148 | let selected_display_size = measure_text(selected_display_str.as_str(), None, SELECTED_FONT_SIZE as u16, 1.0); 149 | draw_text(selected_display_str.as_str(), (screen_width() / 2.0) - (selected_display_size.width / 2.0), 175.0, SELECTED_FONT_SIZE, Color::new(0.0, 0.47, 0.95, 0.275)); 150 | 151 | // UI: Bottom-left 152 | draw_text(format!("Paint Size: {}px", paint_radius).as_str(), 25.0, screen_height() - 50.0, 50.0, BLUE); 153 | draw_text("Use the Numpad (+ and -) to increase/decrease size!", 25.0, screen_height() - 25.0, 20.0, BLUE); 154 | 155 | 156 | // Disable the mouse when clicking UI elements 157 | if !is_clicking_ui { 158 | // Control: left click for Sand 159 | if is_mouse_button_down(MouseButton::Left) { 160 | let (mouse_x, mouse_y) = mouse_position(); 161 | let mouse_x = (mouse_x as u16 / camera_zoom as u16) - camera_offset_x as u16; 162 | let mouse_y = (mouse_y as u16 / camera_zoom as u16) - camera_offset_y as u16; 163 | 164 | // Fill an X/Y radius from the cursor with Sand particles 165 | for y in mouse_y..(mouse_y + paint_radius) { 166 | for x in mouse_x - paint_radius..(mouse_x + paint_radius) { 167 | // Note: macroquad doesn't like the mouse leaving the window when dragging. 168 | // ... so make sure no crazy out-of-bounds happen! 169 | if x > 0 && x < screen_width() as u16 && y > 0 && y < screen_height() as u16 { 170 | let ptr = &mut world[x as usize][y as usize]; 171 | // If not occupied: assign Sand as the Variant and activate 172 | if !ptr.active { 173 | ptr.variant = selected_variant.clone(); 174 | ptr.active = true; 175 | } 176 | } 177 | } 178 | } 179 | } 180 | 181 | // Control: right click for Brick 182 | if is_mouse_button_down(MouseButton::Right) { 183 | let (mouse_x, mouse_y) = mouse_position(); 184 | let mouse_x = (mouse_x as u16 / camera_zoom as u16) - camera_offset_x as u16; 185 | let mouse_y = (mouse_y as u16 / camera_zoom as u16) - camera_offset_y as u16; 186 | // If the distance is large (e.g: a fast mouse flick) then we need to 'best-guess' the path of the cursor mid-frame 187 | // ... so that there's no gaps left between paint intersections, a nice touch for UX! 188 | if is_drawing_secondary { 189 | // TODO: We can do a much better algorithm than this (perhaps linear interpolation?) 190 | // While the X or Y coords of the last particle don't match the current mouse coords, pathfind our way to it! 191 | while last_x != mouse_x || last_y != mouse_y { 192 | if mouse_x > last_x { last_x += 1; } 193 | if mouse_x < last_x { last_x -= 1; } 194 | if mouse_y > last_y { last_y += 1; } 195 | if mouse_y < last_y { last_y -= 1; } 196 | // Note: macroquad doesn't like the mouse leaving the window when dragging. 197 | // ... so make sure no crazy out-of-bounds happen! 198 | if last_x > 0 && last_x < screen_width() as u16 && last_y > 0 && last_y < screen_height() as u16 { 199 | // Place a particle along the path 200 | let ptr = &mut world[last_x as usize][last_y as usize]; 201 | if !ptr.active { 202 | ptr.variant = ParticleVariant::Brick; 203 | ptr.active = true; 204 | } 205 | } 206 | } 207 | } else { 208 | // Reset X/Y tracking when we're not smoothing 209 | last_x = mouse_x; 210 | last_y = mouse_y; 211 | // Switch the secondary draw on after one frame (to avoid the pathing system activating between 'paints') 212 | is_drawing_secondary = true; 213 | } 214 | } 215 | } 216 | 217 | // Control release: Disable the secondary paint smoothing 218 | if is_mouse_button_released(MouseButton::Right) { 219 | is_drawing_secondary = false; 220 | } 221 | 222 | // Control: increase paint radius 223 | if is_key_pressed(KeyCode::KpAdd) { 224 | paint_radius += 1; 225 | } 226 | 227 | // Control: decrease paint radius 228 | if is_key_pressed(KeyCode::KpSubtract) && paint_radius > 1 { 229 | paint_radius -= 1; 230 | } 231 | 232 | // Control: rendering scale (zoom) 233 | let (_, scroll_y) = mouse_wheel(); 234 | if scroll_y != 0.0 { 235 | if scroll_y > 0.0 { 236 | // Maximum zoom of 5x 237 | if camera_zoom < 5 { 238 | camera_zoom += 1; 239 | } 240 | } else { 241 | // Minimum zoom of 1x (default) 242 | if camera_zoom > 1 { 243 | camera_zoom -= 1; 244 | } 245 | } 246 | } 247 | 248 | // Control: WASD and Arrow Keys for camera 'offset' movement 249 | if is_key_down(KeyCode::W) || is_key_down(KeyCode::Up) { camera_offset_y += 1 } 250 | if is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) { camera_offset_x += 1 } 251 | if is_key_down(KeyCode::S) || is_key_down(KeyCode::Down) { camera_offset_y -= 1 } 252 | if is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) { camera_offset_x -= 1 } 253 | 254 | // Keep track of particle IDs that were modified within this frame. 255 | // ... this is to avoid 'infinite simulation' since gravity pulls them down the Y-axis progressively. 256 | let mut updated_ids: Vec = Vec::new(); 257 | 258 | // Update the state of all particles + render 259 | let mut sand_count = 0; 260 | let mut dirt_count = 0; 261 | let mut water_count = 0; 262 | let mut brick_count = 0; 263 | for px in 0..world.len() { 264 | // A couple pre-use-casts to make macroquad float calculations easier and faster 265 | let px32 = px as f32; 266 | 267 | for py in 0..world[px].len() { 268 | let py32 = py as f32; 269 | 270 | // Only process active elements (inactive is essentially thin air / invisible) 271 | if !world[px][py].active { 272 | continue; 273 | } 274 | // Don't re-simulate particles that have already been simulated this frame 275 | if updated_ids.contains(&world[px][py].id) { 276 | continue; 277 | } 278 | 279 | // Debugging: track pixel counts 280 | if DEBUG { 281 | match world[px][py].variant { 282 | ParticleVariant::Sand => { sand_count += 1 }, 283 | ParticleVariant::Dirt => { dirt_count += 1 }, 284 | ParticleVariant::Water => { water_count += 1 }, 285 | ParticleVariant::Brick => { brick_count += 1 }, 286 | } 287 | } 288 | 289 | // Only process Sand (and other future interactive particles) here 290 | if world[px][py].variant == ParticleVariant::Sand || world[px][py].variant == ParticleVariant::Dirt || world[px][py].variant == ParticleVariant::Water { 291 | // Clone for use in pixel tracking 292 | let particle_under = &mut world[px].get(py + 1).cloned(); 293 | let is_below_free = particle_under.as_ref().is_some() && !particle_under.as_ref().unwrap().active; 294 | 295 | // Check for a floor 296 | if py32 < screen_height() - 1.0 && is_below_free { 297 | // There's no floor nor any particles below, so fall! 298 | 299 | // Swap the particles (TODO: optimise!) 300 | world[px][py + 1].variant = world[px][py].variant.clone(); 301 | world[px][py + 1].active = true; 302 | let new_id = world[px][py + 1].id; 303 | world[px][py + 1].id = world[px][py].id; 304 | updated_ids.push(world[px][py + 1].id); 305 | world[px][py].id = new_id; 306 | world[px][py].active = false; 307 | } else { 308 | // Check particle has hit a floor and is within the screen width bounds 309 | if !is_below_free && px > 0 && px32 < screen_width() { 310 | 311 | // Compute the new X-axis based on Particle properties 312 | let x_new = px + world[px][py].try_generate_movement(); 313 | 314 | // Ensure the new X-axis is valid 315 | if x_new > 0 && x_new < screen_width() as usize { 316 | // Generate some Y-axis entropy 317 | let mut y_new = py; 318 | let y_rand = py + rand::gen_range(0, 2) as usize; 319 | 320 | // Ensure the new Y-axis is valid 321 | if y_rand > 0 && y_rand < screen_height() as usize { y_new = y_rand; } 322 | 323 | // Figure out some context data 324 | let is_water = world[px][py].variant == ParticleVariant::Water; 325 | let is_swapping_with_water = world[x_new][y_new].active && world[x_new][y_new].variant == ParticleVariant::Water && !is_water; 326 | 327 | // 'Sinking' only applies when it's Solid <---> Liquid or physically dense elements 328 | if !is_swapping_with_water { y_new = py; } 329 | 330 | // Ensure a neighbouring solid particle doesn't exist 331 | if !world[x_new][y_new].active || is_swapping_with_water { 332 | // Swap the particles (TODO: optimise!) 333 | world[x_new][y_new].variant = world[px][py].variant.clone(); 334 | world[x_new][y_new].active = true; 335 | let new_id = world[x_new][y_new].id; 336 | 337 | // Swap IDs and prevent further updates via vec tracker 338 | world[x_new][y_new].id = world[px][py].id; 339 | updated_ids.push(world[x_new][y_new].id); 340 | world[px][py].id = new_id; 341 | 342 | // If a solid particle swaps with water: then the prior solid position must be filled with water 343 | world[px][py].active = is_swapping_with_water; 344 | if is_swapping_with_water { 345 | world[px][py].variant = ParticleVariant::Water; 346 | } 347 | } 348 | } 349 | } 350 | } 351 | } 352 | 353 | // Render updated particle state 354 | let zoomf = camera_zoom as f32; 355 | draw_rectangle((px32 * zoomf) + (camera_offset_x as f32 * zoomf), (py32 * zoomf) + (camera_offset_y as f32 * zoomf), zoomf, zoomf, world[px][py].get_colour()); 356 | } 357 | } 358 | 359 | // Disable the UI lock if buttons were released 360 | if is_mouse_button_released(MouseButton::Left) { 361 | is_clicking_ui = false; 362 | } 363 | 364 | // Debugging UI 365 | if DEBUG { 366 | draw_text(format!("Sand: {}, Dirt: {}, Water: {}, Brick: {}", sand_count, dirt_count, water_count, brick_count).as_str(), 25.0, screen_height() / 2.0, 20.0, BLUE); 367 | } 368 | 369 | next_frame().await 370 | } 371 | } --------------------------------------------------------------------------------