From a587c17978081d449f99aacced1403e3d53f6414 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 9 Jun 2026 10:30:12 -0700 Subject: [PATCH] prototypes/life: game of life wip --- prototypes/assets/life/forward.png | Bin 0 -> 257 bytes prototypes/assets/life/next.png | Bin 0 -> 289 bytes prototypes/assets/life/pause.png | Bin 0 -> 183 bytes prototypes/assets/life/power.png | Bin 0 -> 451 bytes prototypes/assets/life/texture_08.png | Bin 0 -> 2838 bytes prototypes/assets/life/tileBlack_01.png | Bin 0 -> 304 bytes prototypes/assets/life/tileGrey_01.png | Bin 0 -> 304 bytes prototypes/assets/life/zoomIn.png | Bin 0 -> 430 bytes prototypes/assets/life/zoomOut.png | Bin 0 -> 424 bytes prototypes/src/bin/life.rs | 384 ++++++++++++++++++++++++ 10 files changed, 384 insertions(+) create mode 100644 prototypes/assets/life/forward.png create mode 100644 prototypes/assets/life/next.png create mode 100644 prototypes/assets/life/pause.png create mode 100644 prototypes/assets/life/power.png create mode 100644 prototypes/assets/life/texture_08.png create mode 100644 prototypes/assets/life/tileBlack_01.png create mode 100644 prototypes/assets/life/tileGrey_01.png create mode 100644 prototypes/assets/life/zoomIn.png create mode 100644 prototypes/assets/life/zoomOut.png create mode 100644 prototypes/src/bin/life.rs diff --git a/prototypes/assets/life/forward.png b/prototypes/assets/life/forward.png new file mode 100644 index 0000000000000000000000000000000000000000..053cff6b9de0666fcc5abd2753093acda0d5e217 GIT binary patch literal 257 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_>3?$zOPHh5GEa{HEjtmSN`?>!lvI6;X0X`wF zKpGF&u92Swl;$i6@(X@%fB*dY{P_6{3?$zOPHh5GEa{HEjtmSN`?>!lvI6;X0X`wF zKpGF&u92Swl;$i6@(X@%fB*dY{P_6{S5EQzDXw8@`RmIfjcu2Fw#>4hyzuKD`KYtco!nN= z-sW}PDK3a_sn`6yPXc^pQh7s<2G%XyWpPS~sc?#jQlUtxM&Ba2r+z{evnI0KyXC3?$zOPHh5GEa{HEjtmSN`?>!lvI6;{0X`wF zKpGPWF)}Oxin5gi`33Knub=;(p}L;U4Jcym>Eak-aXOjfD62%<2A>6yQBhHwH0HRS zXj#(8pberMU4tHeS#f5?nGe%AiNS%G}x0G|+7 zAWbS*%<6U$s7IhA$S*iwzJI;FzW@IF^UuGJ=dWkT_wnvvU|^K@ba4#vIR19(?Ma6f zINFMXgjrAjwtri;I5gl*Vp{sGo0{QTKLhGoeWp*({iXC@*78Nxac_>f-z+(ft#!{i zV%Ri~X{G;-y|*~-zs`gq)*?b)%i!~2{@x#gvT>jJZD zyl4Nrm2-H)RQF^Kwwb)nlh}V%Hx|oO9qHhHF1%)yhsx2STg`@%=py1CE?u`yY+%{?@c3m zC$?OgoLjsjVSdKr2U-svoIGdmY{UEPz5gHn3G%U06ZJWde^mkny{D_6%Q~loCIGB4 Btv3Jw literal 0 HcmV?d00001 diff --git a/prototypes/assets/life/texture_08.png b/prototypes/assets/life/texture_08.png new file mode 100644 index 0000000000000000000000000000000000000000..72e1a07703f86f06142125b0a44e4bd3bac81ef1 GIT binary patch literal 2838 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7+9ErRIjnw1`sdZ(btiIVPik{pF~z5UoOBW z#C6r$^|R;BU%O$`%-M5y?B2U{`N|cm*G``~`|`D$dk-Ayt{2|Rz`(WG)5S5Q;?~=1 z2Uju~3N$>lz11jtqnWouw!@rXXis;`-Y%Uu+Y_I5)!K463qAYoeWJ!QZPiKble3@g zzp{3F`71G?ZKGgdLqLM}!1syL2VT#TIS^IM905uxBL*llSzLW_E|%G1%8R~QW{&yw zb9b`|*!?ehEq%aD=D@yehTR1W($)>KBPKwA9&NbDQ1fmlqmBMa#x3hi8?G^qB+m~< zzy4uaptfEm|5P2rP5lE?{&JrA$I3Jq-8r({bRdHv?)n{uef>`urcIJQ5H*sriG<*R zg)Zf9qzzopr04%DNl>h($ literal 0 HcmV?d00001 diff --git a/prototypes/assets/life/tileBlack_01.png b/prototypes/assets/life/tileBlack_01.png new file mode 100644 index 0000000000000000000000000000000000000000..a022b620663b539914b5ea8aea28a75e92451c3c GIT binary patch literal 304 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?I3?vN&YJLDImUKs7M+SzC{oH>NS%G|^0G|-o zfPjF)!a^Gxo4UHX*w|Q4Pfy!f)rvsblb$Y)AsP4Ho;%ISxN8(O$t zG(TXhJs>ERz|+Lm!|42?!IDGFfcNS%G|^0G|-o zhYuhA{Q2|b$&>&8|Nrpe!_%iv{cbC}0%cEnx;Tbp+D3$pyb^$Wuoe#NH#9Tr&b)S0qiyl8Ez@CI7n}3 z;d;^hfU)*~pi}}+6I&0X^NR*c4lx7v-z|(0%?ga!6YkGW<+u58fEfqz;6D5M<7wOH zAKYlYV;}qN23H2o2$l^Eo($YGSS~a;GVn^Ud}#1v;D5ow(csp=kinwS;MKsig2ki3 e>A*tXb^Nx+e<>cbTz3ZODF#nhKbLh*2~7YQ9dg?M literal 0 HcmV?d00001 diff --git a/prototypes/assets/life/zoomIn.png b/prototypes/assets/life/zoomIn.png new file mode 100644 index 0000000000000000000000000000000000000000..3e30bbd6c8800d9097da01259eb415e46e77b6d4 GIT binary patch literal 430 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=3?wxlRx|@CmUKs7M+SzC{oH>NS%G}x0G|+7 zAWbS*%<6U$s7IhA$S?RlzkmMv`hNNO`sd%<$M0uw5oy@Pz`)4m>EamTas2JolS0i3 zJPZNmDTa*y{%29?9S;jyd6TyJ&xgif#3+ zTQLf^=6_Sj6N^7ohuq*pA+F^Esve3s=Ix1_YsExjeHW@QIU zsfG7iZQZl*Yv&9f-MdC+-oeeizR~_-=RGq*vY+oiIqOwwp~{gz59%88SY>T?PpW!x dU;5|qC(I7JCc0R&|5yYHWlvW>mvv4FO#o%znjQcE literal 0 HcmV?d00001 diff --git a/prototypes/assets/life/zoomOut.png b/prototypes/assets/life/zoomOut.png new file mode 100644 index 0000000000000000000000000000000000000000..bf55c7e21671337b7ca599233c2d2a80bcad4361 GIT binary patch literal 424 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=3?wxlRx|@CmUKs7M+SzC{oH>NS%G}x0G|+7 zAWbS*%<6U$s7IhA$S?RlzkmMv`hNNO`sd%<$M0uw5oy@Pz`)4j>EamTas2JnlS0i3 zJZ*)JEJ6?d&fmtinsv(C3vyQf#E&=Xt4wIUnYWSsXS_?%RjoFD0=@^(hf!DFnEC)Q>ZDqNq& zl(g>RB++1p6WkyFP2!8!?&uZ#Evr)K6C%J>-xI!3=-=6|6Q*u$Xz=d;c)q^!v_YcC zQ;{VtmuI;DF`Pdo^1RT2nosYaE}HO;HzdGphW^H*sVzU9jQa+=}d*-|-YwtRiERi0Dw>aCyoo0eJW^tD7-J>%ZMn{dH=5}U-l&Ogad YSf5)@G%;`rzYYptPgg&ebxsLQ0M5yp9{>OV literal 0 HcmV?d00001 diff --git a/prototypes/src/bin/life.rs b/prototypes/src/bin/life.rs new file mode 100644 index 0000000..1c19993 --- /dev/null +++ b/prototypes/src/bin/life.rs @@ -0,0 +1,384 @@ +// TODO: Rearchitected so it's Cell and Coordiantes not Cell { position { coordiantes } } + +use engine::*; + +const GREY_TILE: &str = "tileGrey_01.png"; +const BLACK_TILE: &str = "tileBlack_01.png"; +const BACKGROUND: &str = "texture_08.png"; + +fn main() { + let file_path = { + #[cfg(not(target_arch = "wasm32"))] + { + "assets/life".into() + } + #[cfg(target_arch = "wasm32")] + { + "assets".into() + } + }; + App::new() + .add_plugins(DefaultPlugins.set(AssetPlugin { + file_path, + ..default() + })) + .init_resource::() + .init_state::() + .add_systems(Startup, setup) + .add_systems( + Update, + update_grid_position.run_if(on_message::), + ) + .add_systems(Update, grid_gizmo) + .add_systems( + Update, + de_spawn_cells.run_if(input_just_pressed(MouseButton::Left)), + ) + .add_systems( + Update, + assert_cell_uniqueness.run_if(any_with_component::), + ) + .add_systems( + Update, + simulation_step.run_if(input_just_pressed(KeyCode::Space)), + ) + .add_observer(on_add_ui_button) + .add_observer(on_add_coordinates) + .run(); +} + +const SCALE: f32 = 100.0; + +#[derive(Debug, Component)] +#[require(Visibility, Transform)] +enum UiButton { + Power, + Pause, + Step, + Play, + ZoomOut, + ZoomIn, +} + +impl UiButton { + fn icon(&self) -> &'static str { + match self { + Self::Power => "power.png", + Self::Pause => "pause.png", + Self::Step => "next.png", + Self::Play => "forward.png", + Self::ZoomOut => "zoomOut.png", + Self::ZoomIn => "zoomIn.png", + } + } + +} + +fn on_add_ui_button( + trigger: On, + mut commands: Commands, + query: Query<&UiButton>, + server: Res, +) { + let button = query.get(trigger.entity).unwrap(); + commands + .entity(trigger.entity) + .insert(( + Button, + BackgroundColor(GRAY.into()), + ImageNode { + image: server.load(GREY_TILE), + color: WHITE.with_alpha(0.5).into(), + ..default() + }, + children![( + Node { + top: Val::Px(0.0), + left: Val::Px(0.0), + height: Val::Px(40.0), + ..default() + }, + ImageNode { + image: server.load(button.icon()), + ..default() + }, + )], + )) + .observe(responsive_button_over) + .observe(responsive_button_out); +} + +fn setup(mut commands: Commands) { + commands.spawn(( + Camera2d, + Camera { + clear_color: ClearColorConfig::Custom(WHITE.into()), + ..default() + }, + )); + + commands + .spawn((Node { + top: Val::Px(0.0), + left: Val::Px(0.0), + height: Val::Px(40.0), + width: Val::Percent(100.0), + ..default() + }, Transform::default())) + .with_children(|parent| { + parent.spawn(UiButton::Power); + parent.spawn(UiButton::Pause); + parent.spawn(UiButton::Step); + parent.spawn(UiButton::Play); + parent.spawn(UiButton::ZoomOut); + parent.spawn(UiButton::ZoomIn); + }); + + // Spawn Coordiantes + commands + .spawn((Transform::default(), Visibility::Visible)) + .with_children(|parent| { + for x in -10..10 { + for y in -10..10 { + parent.spawn(Coordinates { x, y }); + } + } + }); +} + +#[derive(Debug, Default, Clone, PartialEq, Hash, Eq, Component, Resource)] +#[require(Visibility, Transform)] +struct Coordinates { + x: isize, + y: isize, +} + +impl Coordinates { + fn neighbors(&self) -> impl Iterator { + [ + Coordinates { + x: self.x, + y: self.y + 1, + }, + Coordinates { + x: self.x + 1, + y: self.y + 1, + }, + Coordinates { + x: self.x + 1, + y: self.y, + }, + Coordinates { + x: self.x + 1, + y: self.y - 1, + }, + Coordinates { + x: self.x, + y: self.y - 1, + }, + Coordinates { + x: self.x - 1, + y: self.y - 1, + }, + Coordinates { + x: self.x - 1, + y: self.y, + }, + Coordinates { + x: self.x - 1, + y: self.y + 1, + }, + ] + .into_iter() + } + + fn is_neighbor_of(&self, other: &Coordinates) -> bool { + let (a1, b1) = (self.x, self.y); + let (a2, b2) = (other.x, other.y); + (a1 == a2 + 1 || a1 == a2 - 1) && (b1 == b2 + 1 || b1 == b2 - 1) + } + + fn as_translation(&self) -> Vec3 { + (Vec2::new(self.x as f32, self.y as f32) * SCALE).extend(0.0) + } +} + +fn on_add_coordinates(trigger: On, coordinates: Query<&Coordinates>, + cells: Query<&Cell>, + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + server: ResMut, +) { + // TODO: Reuse the same mesh + let mesh = { + let mesh_h = meshes.add(Rectangle::new(SCALE, SCALE)); + Mesh2d(mesh_h) + }; + + // TODO: Resuse the same material + let mat = { + let image: Handle = if cells.contains(trigger.entity) { + server.load(BLACK_TILE) + } else { + server.load(BACKGROUND) + }; + let mat_h = materials.add(image); + MeshMaterial2d(mat_h) + }; + + let t = { + let c = coordinates.get(trigger.entity).unwrap(); + if cells.contains(trigger.entity) { + Transform::from_translation(c.as_translation() + Vec3::new(0.0, 0.0, 1.0)) + } else { + Transform::from_translation(c.as_translation()) + } + }; + + commands.entity(trigger.entity).insert((mesh, mat, t)); +} + +/// When the cursor moves, update the Coordiantes +fn update_grid_position( + mut coordinates: ResMut, + q_window: Single<&Window>, + q_camera: Single<(&Camera, &GlobalTransform)>, +) { + if let Some(cursor_pos) = q_window.cursor_position() { + let (cam, cam_gt) = *q_camera; + if let Ok(pos) = cam.viewport_to_world_2d(cam_gt, cursor_pos) { + coordinates.x = (pos / SCALE).round().x as isize; + coordinates.y = (pos / SCALE).round().y as isize; + } + } +} + +/// Draw a gizmo at the current coordiantes +fn grid_gizmo(mut gizmos: Gizmos, coordinates: Res) { + let isometry = Isometry2d { + translation: coordinates.as_translation().truncate(), + ..default() + }; + gizmos.rect_2d(isometry, Vec2::ONE * SCALE, PURPLE) +} + +#[derive(Component, Debug, PartialEq, Hash, Eq)] +struct Cell; + +// When left mouse clicked, spawn a cell at GridPosition +fn de_spawn_cells( + mut commands: Commands, + coordinates: Res, + cells: Query<(Entity, &Coordinates), With>, +) { + if let Some(e) = cells + .iter() + .find_map(|(e, c)| (*coordinates == *c).then_some(e)) + { + commands.entity(e).despawn(); + } else { + commands.spawn((coordinates.clone(), Cell)); + } +} + +fn assert_cell_uniqueness(q: Query<(Entity, &Coordinates), With>) { + q.iter().for_each(|(e1, c1)| { + q.iter().for_each(|(e2, c2)| { + if e1 != e2 && c1 == c2 { + panic!("Duplicate cells at {:?}", c1); + } + }) + }) +} + +#[derive(States, Debug, Default, Hash, PartialEq, Eq, Clone)] +enum SimulationState { + #[default] + Off, + Step, + Run, +} + +/// Simulate the Game of Life Rules: +/// 1. Any live cell with fewer than two live neighbours dies, as if by underpopulation. +/// 2. Any live cell with two or three live neighbours lives on to the next generation. +/// 3. Any live cell with more than three live neighbours dies, as if by overpopulation. +/// 4. Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction. +/// +/// TODO: There is a lot of optimizations to be had here +fn simulation_step(q: Query<(Entity, &Coordinates), With>, mut commands: Commands) { + fn is_cell_alive(coordinates: &Coordinates, query: &Query<(Entity, &Coordinates), With>) -> bool { + query + .iter() + .any(|(_e, c)| *c == *coordinates) + } + + fn living_neighbors(c: &Coordinates, query: &Query<(Entity, &Coordinates), With>) -> usize { + c.neighbors() + .filter(|neighbor| { + query.iter().any( + |(_e, coordinates)| { *neighbor == *coordinates }, + ) + }) + .count() + } + + fn cell_entity(coordinates: &Coordinates, query: Query<(Entity, &Coordinates), With>) -> Option { + query + .iter() + .find_map(|(e, c)| (*c == *coordinates).then_some(e)) + } + + // Collect list of candidate positions... + let mut coordinates: HashSet = HashSet::new(); + + // Put all dead cells neighboring living cells into the coordinates hashset + // Each living cell + q.iter().for_each(|(_e, c)| { + coordinates.insert(c.clone()); + // Iterator over neighbor positions + // Filter to just dead neighbors + c.neighbors() + .filter(|c| !is_cell_alive(c, &q)) + .for_each(|c| { + coordinates.insert(c); + }); + }); + + // Get Neighbor Count of living cells + coordinates.iter().for_each(|c| { + let num_living_neighbors = living_neighbors(c, &q); + let is_living = is_cell_alive(c, &q); + + match num_living_neighbors { + ..2 | 4.. => { + if let Some(e) = cell_entity(c, q) { + debug!("killing {:?}", c); + commands.entity(e).despawn(); + } else { + debug!("failed to kill {:?}", c); + } + } + 2 => debug!("continue"), + 3 => { + if !is_living { + debug!("creating {:?}", c); + commands.spawn((c.clone(), Cell)); + } else { + debug!("continue") + } + } + }; + }); +} + +fn responsive_button_over(trigger: On>, mut bg: Query<&mut BackgroundColor>) { + let mut background_color = bg.get_mut(trigger.entity).unwrap(); + background_color.0 = WHITE.into(); +} + +fn responsive_button_out(trigger: On>, mut bg: Query<&mut BackgroundColor>) { + let mut background_color = bg.get_mut(trigger.entity).unwrap(); + background_color.0 = GRAY.into(); +}