diff --git a/Cargo.lock b/Cargo.lock index 70139cf..8972723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,8 +773,10 @@ dependencies = [ "env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)", "getrandom 0.1.13 (registry+https://github.com/rust-lang/crates.io-index)", "gilrs 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)", + "line_drawing 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "pixels-mocks 0.1.0", + "randomize 3.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "simple-invaders 0.1.0", "wgpu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "winit 0.20.0-alpha4 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 2aaa9c2..43ba2b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ pixels-mocks = { path = "pixels-mocks" } simple-invaders = { path = "simple-invaders" } winit = "0.20.0-alpha4" winit_input_helper = "0.4.0-alpha4" +randomize = "3.0" +line_drawing = "0.8" [workspace] members = [ diff --git a/examples/conway/README.md b/examples/conway/README.md new file mode 100644 index 0000000..d75cf05 --- /dev/null +++ b/examples/conway/README.md @@ -0,0 +1,12 @@ +# Conway's Game of Life + +![Conway's Game of Life](../../img/conway.png) + + +## Controls + +- Mouse: Left click toggles cells, dragging draws lines. +- P: Toggle pause. +- space: Frame step (enables pause if not already paused) +- R: Randomize +- escape: Quit diff --git a/examples/conway/main.rs b/examples/conway/main.rs new file mode 100644 index 0000000..56d9c55 --- /dev/null +++ b/examples/conway/main.rs @@ -0,0 +1,373 @@ +use log::debug; +use pixels::{Error, Pixels, SurfaceTexture}; +use winit::dpi::{LogicalPosition, LogicalSize, PhysicalSize}; +use winit::event::{Event, VirtualKeyCode, WindowEvent}; +use winit::event_loop::{ControlFlow, EventLoop}; +use winit_input_helper::WinitInputHelper; + +const SCREEN_WIDTH: u32 = 400; +const SCREEN_HEIGHT: u32 = 300; + +fn main() -> Result<(), Error> { + env_logger::init(); + let event_loop = EventLoop::new(); + let mut input = WinitInputHelper::new(); + let (window, surface, mut p_width, mut p_height, mut hidpi_factor) = + create_window("Conway's Game of Life", &event_loop); + + let surface_texture = SurfaceTexture::new(p_width, p_height, surface); + + let mut life = ConwayGrid::new_random(SCREEN_WIDTH as usize, SCREEN_HEIGHT as usize); + let mut pixels = Pixels::new(SCREEN_WIDTH, SCREEN_HEIGHT, surface_texture)?; + let mut paused = false; + + let mut draw_state: Option = None; + + event_loop.run(move |event, _, control_flow| { + // The one and only event that winit_input_helper doesn't have for us... + if let Event::WindowEvent { + event: WindowEvent::RedrawRequested, + .. + } = event + { + life.draw(pixels.get_frame()); + pixels.render(); + } + + // For everything else, for let winit_input_helper collect events to build its state. + // It returns `true` when it is time to update our game state and request a redraw. + if input.update(event) { + // Close events + if input.key_pressed(VirtualKeyCode::Escape) || input.quit() { + *control_flow = ControlFlow::Exit; + return; + } + if input.key_pressed(VirtualKeyCode::P) { + paused = !paused; + } + if input.key_pressed(VirtualKeyCode::Space) { + // Space is frame-step, so ensure we're paused + paused = true; + } + if input.key_pressed(VirtualKeyCode::R) { + life.randomize(); + } + // Handle mouse. This is a bit involved since support some simple + // line drawing (mostly because it makes nice looking patterns). + let (mouse_cell, mouse_prev_cell) = input + .mouse() + .map(|(mx, my)| { + let (dx, dy) = input.mouse_diff(); + let prev_x = mx - dx; + let prev_y = my - dy; + let dpx = hidpi_factor as f32; + let (w, h) = (p_width as f32 / dpx, p_height as f32 / dpx); + let mx_i = ((mx / w) * (SCREEN_WIDTH as f32)).round() as isize; + let my_i = ((my / h) * (SCREEN_HEIGHT as f32)).round() as isize; + let px_i = ((prev_x / w) * (SCREEN_WIDTH as f32)).round() as isize; + let py_i = ((prev_y / h) * (SCREEN_HEIGHT as f32)).round() as isize; + ((mx_i, my_i), (px_i, py_i)) + }) + .unwrap_or_default(); + + if input.mouse_pressed(0) { + debug!("Mouse click at {:?}", mouse_cell); + draw_state = Some(life.toggle(mouse_cell.0, mouse_cell.1)); + } else if let Some(draw_alive) = draw_state { + let release = input.mouse_released(0); + let held = input.mouse_held(0); + debug!("Draw at {:?} => {:?}", mouse_prev_cell, mouse_cell); + debug!("Mouse held {:?}, release {:?}", held, release); + // If they either released (finishing the drawing) or are still + // in the middle of drawing, keep going. + if release || held { + debug!("Draw line of {:?}", draw_alive); + life.set_line( + mouse_prev_cell.0, + mouse_prev_cell.1, + mouse_cell.0, + mouse_cell.1, + draw_alive, + ); + } + // If they let go or are otherwise not clicking anymore, stop drawing. + if release || !held { + debug!("Draw end"); + draw_state = None; + } + } + // Adjust high DPI factor + if let Some(factor) = input.hidpi_changed() { + hidpi_factor = factor; + } + // Resize the window + if let Some(size) = input.window_resized() { + let size = size.to_physical(hidpi_factor); + p_width = size.width.round() as u32; + p_height = size.height.round() as u32; + pixels.resize(p_width, p_height); + } + if !paused || input.key_pressed(VirtualKeyCode::Space) { + life.update(); + } + window.request_redraw(); + } + }); +} + +// COPYPASTE: ideally this could be shared. + +/// Create a window for the game. +/// +/// Automatically scales the window to cover about 2/3 of the monitor height. +/// +/// # Returns +/// +/// Tuple of `(window, surface, width, height, hidpi_factor)` +/// `width` and `height` are in `PhysicalSize` units. +fn create_window( + title: &str, + event_loop: &EventLoop<()>, +) -> (winit::window::Window, pixels::wgpu::Surface, u32, u32, f64) { + // Create a hidden window so we can estimate a good default window size + let window = winit::window::WindowBuilder::new() + .with_visible(false) + .with_title(title) + .build(&event_loop) + .unwrap(); + let hidpi_factor = window.hidpi_factor(); + + // Get dimensions + let width = SCREEN_WIDTH as f64; + let height = SCREEN_HEIGHT as f64; + let (monitor_width, monitor_height) = { + let size = window.current_monitor().size(); + (size.width / hidpi_factor, size.height / hidpi_factor) + }; + let scale = (monitor_height / height * 2.0 / 3.0).round(); + + // Resize, center, and display the window + let min_size = PhysicalSize::new(width, height).to_logical(hidpi_factor); + let default_size = LogicalSize::new(width * scale, height * scale); + let center = LogicalPosition::new( + (monitor_width - width * scale) / 2.0, + (monitor_height - height * scale) / 2.0, + ); + window.set_inner_size(default_size); + window.set_min_inner_size(Some(min_size)); + window.set_outer_position(center); + window.set_visible(true); + + let surface = pixels::wgpu::Surface::create(&window); + let size = default_size.to_physical(hidpi_factor); + + ( + window, + surface, + size.width.round() as u32, + size.height.round() as u32, + hidpi_factor, + ) +} + +/// Generate a pseudorandom seed for the game's PRNG. +fn generate_seed() -> (u64, u64) { + use byteorder::{ByteOrder, NativeEndian}; + use getrandom::getrandom; + + let mut seed = [0_u8; 16]; + + getrandom(&mut seed).expect("failed to getrandom"); + + ( + NativeEndian::read_u64(&seed[0..8]), + NativeEndian::read_u64(&seed[8..16]), + ) +} + +const BIRTH_RULE: [bool; 9] = [false, false, false, true, false, false, false, false, false]; +const SURVIVE_RULE: [bool; 9] = [false, false, true, true, false, false, false, false, false]; +const INITIAL_FILL: f32 = 0.3; + +#[derive(Clone, Copy, Debug, Default)] +struct Cell { + alive: bool, + // Used for the trail effect. Always 255 if `self.alive` is true (We could + // use an enum for Cell, but it makes several functions slightly more + // complex, and doesn't actually make anything any simpler here, or save any + // memory, so we don't) + heat: u8, +} + +impl Cell { + fn new(alive: bool) -> Self { + Self { alive, heat: 0 } + } + + #[must_use] + fn update_neibs(self, n: usize) -> Self { + let next_alive = if self.alive { + SURVIVE_RULE[n] + } else { + BIRTH_RULE[n] + }; + self.next_state(next_alive) + } + + #[must_use] + fn next_state(mut self, alive: bool) -> Self { + self.alive = alive; + if self.alive { + self.heat = 255; + } else { + self.heat = self.heat.saturating_sub(1); + } + self + } + + fn set_alive(&mut self, alive: bool) { + *self = self.next_state(alive); + } + + fn cool_off(&mut self, decay: f32) { + if !self.alive { + let heat = (self.heat as f32 * decay).min(255.0).max(0.0); + assert!(heat.is_finite()); + self.heat = heat as u8; + } + } +} + +#[derive(Clone, Debug)] +struct ConwayGrid { + cells: Vec, + width: usize, + height: usize, + // Should always be the same size as `cells`. When updating, we read from + // `cells` and write to `scratch_cells`, then swap. Otherwise it's not in + // use, and `cells` should be updated directly. + scratch_cells: Vec, +} + +impl ConwayGrid { + fn new_empty(width: usize, height: usize) -> Self { + assert!(width != 0 && height != 0); + let size = width.checked_mul(height).expect("too big"); + Self { + cells: vec![Cell::default(); size], + scratch_cells: vec![Cell::default(); size], + width, + height, + } + } + + fn new_random(width: usize, height: usize) -> Self { + let mut result = Self::new_empty(width, height); + result.randomize(); + result + } + + fn randomize(&mut self) { + let mut rng: randomize::PCG32 = generate_seed().into(); + for c in self.cells.iter_mut() { + let alive = randomize::f32_half_open_right(rng.next_u32()) > INITIAL_FILL; + *c = Cell::new(alive); + } + // run a few simulation iterations for aesthetics (If we don't, the + // noise is ugly) + for _ in 0..3 { + self.update(); + } + // Smooth out noise in the heatmap that would remain for a while + for c in self.cells.iter_mut() { + c.cool_off(0.4); + } + } + + fn count_neibs(&self, x: usize, y: usize) -> usize { + let (xm1, xp1) = if x == 0 { + (self.width - 1, x + 1) + } else if x == self.width - 1 { + (x - 1, 0) + } else { + (x - 1, x + 1) + }; + let (ym1, yp1) = if y == 0 { + (self.height - 1, y + 1) + } else if y == self.height - 1 { + (y - 1, 0) + } else { + (y - 1, y + 1) + }; + (self.cells[xm1 + ym1 * self.width].alive as usize + + self.cells[x + ym1 * self.width].alive as usize + + self.cells[xp1 + ym1 * self.width].alive as usize + + self.cells[xm1 + y * self.width].alive as usize + + self.cells[xp1 + y * self.width].alive as usize + + self.cells[xm1 + yp1 * self.width].alive as usize + + self.cells[x + yp1 * self.width].alive as usize + + self.cells[xp1 + yp1 * self.width].alive as usize) + } + + fn update(&mut self) { + for y in 0..self.height { + for x in 0..self.width { + let neibs = self.count_neibs(x, y); + let idx = x + y * self.width; + let next = self.cells[idx].update_neibs(neibs); + // Write into scratch_cells, since we're still reading from `self.cells` + self.scratch_cells[idx] = next; + } + } + std::mem::swap(&mut self.scratch_cells, &mut self.cells); + } + + fn toggle(&mut self, x: isize, y: isize) -> bool { + if let Some(i) = self.grid_idx(x, y) { + let was_alive = self.cells[i].alive; + self.cells[i].set_alive(!was_alive); + !was_alive + } else { + false + } + } + + fn draw(&self, screen: &mut [u8]) { + debug_assert_eq!(screen.len(), 4 * self.cells.len()); + for (c, pix) in self.cells.iter().zip(screen.chunks_exact_mut(4)) { + let color = if c.alive { + [0, 0xff, 0xff, 0xff] + } else { + [0, 0, c.heat, 0xff] + }; + pix.copy_from_slice(&color); + } + } + + fn set_line(&mut self, x0: isize, y0: isize, x1: isize, y1: isize, alive: bool) { + // probably should do sutherland-hodgeman if this were more serious. + // instead just clamp the start pos, and draw until moving towards the + // end pos takes us out of bounds. + let x0 = x0.max(0).min(self.width as isize); + let y0 = y0.max(0).min(self.height as isize); + for (x, y) in line_drawing::Bresenham::new((x0, y0), (x1, y1)) { + if let Some(i) = self.grid_idx(x, y) { + self.cells[i].set_alive(alive); + } else { + break; + } + } + } + + fn grid_idx>(&self, x: I, y: I) -> Option { + if let (Ok(x), Ok(y)) = (x.try_into(), y.try_into()) { + if x < self.width && y < self.height { + Some(x + y * self.width) + } else { + None + } + } else { + None + } + } +} diff --git a/img/conway.png b/img/conway.png new file mode 100644 index 0000000..f2bf239 Binary files /dev/null and b/img/conway.png differ