// TODO: // * Volume slider // * Only move camera when cells move out of view // * "Follow" enhancement: Zoom in/out // * optimize simulation_step // * Stop creating cells when I didn't mean to (clicking UI, click + dragging) // * Keep some controls open when main ui panel is closed // * Show shadow of piece (same tile, just with alpha transparency) // * Dark mode // * Make click and drag feel better // * Add arrow keys to move around #![allow(clippy::complexity)] // Only because of FromTemplat macros unfortunately... #![allow(dead_code)] use engine::*; mod actions; use actions::*; const TILE: &str = "tileGrey_01.png"; const SIM_FRAME_DURATION: f32 = 0.5; fn main() { App::new() .add_plugins( DefaultPlugins .set(AssetPlugin { file_path: "assets".into(), ..default() }) .set(TaskPoolPlugin { task_pool_options: TaskPoolOptions::with_num_threads(1), }), ) .add_plugins(FeathersPlugins) .add_plugins(GameActionsPlugin) .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .insert_resource(ClearColor(WHITE.into())) .insert_resource(UiTheme(create_light_theme())) .init_state::() .init_state::() .init_state::() .init_state::() .add_message::() .add_systems( Startup, ( the_camera.spawn(), the_ui.spawn(), load_audio_tones, load_materials, load_meshes, ), ) .configure_sets( Update, (GameSet::Plan, GameSet::Apply, GameSet::React).chain(), ) // Cell management/Simulation .add_systems( Update, ( // Produce lifecycle messages ( default_board.run_if(run_once), update_grid_position.run_if(on_message::), de_spawn_cells .run_if(not(is_ui_hovered)) .run_if(not(is_panning)) .run_if(input_just_released(MouseButton::Left)), simulation_step.run_if( in_state(SimulationPlayback::Run) .and_then(cooldown_secs(SIM_FRAME_DURATION)) .or_else(state_changed::), ), pause_simulation.run_if(in_state(SimulationPlayback::Step)), ) .in_set(GameSet::Plan), // Apply lifecycle message cell_lifecycle .run_if(on_message::) .in_set(GameSet::Apply), // React to board changes ( reassign_cell_pitch.run_if(resource_changed::), update_material.run_if( any_component_added:: .or_else(any_component_changed::), ), camera_follow .run_if(in_state(SimulationPlayback::Run)) .run_if(any_component_changed::), update_camera_position .run_if(in_state(SimulationPlayback::Run)) .run_if(not(query_condition_empty::<( With, Changed, )>)), ) .in_set(GameSet::React), ), ) // Audio Systems .add_systems( Update, ( control_audio_players .run_if( on_message:: .or_else(state_changed::) .or_else(state_changed::), ) .in_set(GameSet::React), manage_audio_players.run_if(resource_changed::), update_pitch_map_resource_ui.run_if(resource_changed::), ), ) // Debugging .add_systems( Update, ( grid_gizmo, update_debug_info_coordinates, ( #[cfg(debug_assertions)] assert_cell_uniqueness .run_if(any_with_component::) .after(update_material), update_debug_info_cell_count, update_debug_info_active, ) .in_set(GameSet::React), ), ) // Ui Controllers .add_systems( Update, ( toggle_state_visible:: .run_if(state_changed::), manage_interactive::, toggle_state_visible::.run_if(state_changed::), toggle_state_visible::.run_if(state_changed::), toggle_state_visible::.run_if(state_changed::), manage_interactive::, update_note_interactivity, ), ) // Input handling .add_systems( Update, (mouse_pan.run_if(on_message::), set_pan_state), ) .run(); } const SCALE: f32 = 100.0; #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] enum GameSet { Plan, Apply, React, } #[derive(Component, PartialEq, Clone, Default)] enum DebugInfo { CellCount, Coordinates, Active, #[default] Empty, } #[derive(States, Debug, Default, Hash, PartialEq, Eq, Clone, Component, FromTemplate)] enum DebugUi { #[default] Visible, Hidden, } #[derive(States, Debug, Default, Hash, PartialEq, Eq, Clone, Component, FromTemplate)] enum MainUi { #[default] Visible, Hidden, } #[derive(States, Debug, Default, Hash, PartialEq, Eq, Clone, Component, FromTemplate)] enum AudioPlayback { #[default] Mute, Play, } #[derive(Debug, Component, PartialEq, Default, Clone, FromTemplate)] #[require(Visibility, Transform)] enum UiButton { Pause, Step, Play, ZoomOut, ZoomIn, PlayAudio, MuteAudio, Reset, Wipe, #[default] None, } impl UiButton { fn icon(&self) -> &'static str { match self { Self::Pause => "pause.png", Self::Step => "next.png", Self::Play => "forward.png", Self::ZoomOut => "zoomOut.png", Self::ZoomIn => "zoomIn.png", Self::PlayAudio => "audioOff.png", Self::MuteAudio => "audioOn.png", Self::Reset => "return.png", Self::Wipe => "trashcan.png", Self::None => todo!(), } } } fn ui_checkbox(text: &str) -> impl Scene { bsn! { @FeathersCheckbox { @caption: bsn! { Text(text) ThemedText } } ActivateOnPress on(checkbox_self_update) } } fn ui_button(button: UiButton) -> impl Scene { bsn! { @FeathersButton Hovered Node { top: Val::Px(2.5), left: Val::Px(2.5), margin: UiRect::all(Val::Px(2.5)), } Children [ Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, width: Val::Percent(100.0), height: Val::Percent(100.0), } ImageNode { image: HandleTemplate::Path(AssetPath::parse(button.icon())), } ] } } fn ui_toggle_button( (button_a, state_a): (UiButton, A), (button_b, state_b): (UiButton, B), ) -> impl Scene { bsn! { @FeathersButton Hovered Node { top: Val::Px(2.5), left: Val::Px(2.5), margin: UiRect::all(Val::Px(2.5)), align_items: AlignItems::Center, } Children [ Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, width: Val::Percent(100.0), height: Val::Percent(100.0), } ImageNode { image: HandleTemplate::Path(AssetPath::parse(button_a.icon())), } state_a ToggleStateVisible, Node { align_self: AlignSelf::Center, justify_self: JustifySelf::Center, width: Val::Percent(100.0), height: Val::Percent(100.0), } ImageNode { image: HandleTemplate::Path(AssetPath::parse(button_b.icon())), } state_b ToggleStateVisible, ] } } fn the_camera() -> impl Scene { bsn! { Camera2d Coordinates } } fn update_pitch_map_resource_ui( query: Query<(Entity, &Note), With>, pitch_map: Res, mut commands: Commands, ) { query.iter().for_each(|(e, n)| { if pitch_map.notes.contains(n) { commands.entity(e).insert(Checked); } else { commands.entity(e).remove::(); } }); } fn text_setup() -> impl Scene { bsn! { // Feathers propagates text styles only through entities that themselves have // ThemedText, so a body holding loose text must carry these itself. InheritableThemeTextColor(tokens::TEXT_MAIN) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, weight: FontWeight::NORMAL, } } } fn the_ui() -> impl Scene { bsn! { Node { width: Val::Percent(100.0), top: Val::Px(0.0), left: Val::Px(0.0), display: Display::Flex, flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, } Transform Children [ primary_ui(), debug_ui(), ] } } fn debug_ui() -> impl Scene { fn toggle_debug_ui( _: On>, mut next: ResMut>, curr: Res>, ) { next.set(match curr.get() { DebugUi::Hidden => DebugUi::Visible, DebugUi::Visible => DebugUi::Hidden, }); } bsn! { Node { justify_self: JustifySelf::End, display: Display::Flex, flex_direction: FlexDirection::Column, } pane() Transform Children [ pane_header() on(toggle_debug_ui) Children [ Text("Debug Info") ThemedText, ], pane_body() DebugUi::Visible ToggleStateVisible Children [ subpane() Node { display: Display::Flex, flex_direction: FlexDirection::Row, } Children [ subpane_header() Children [ Text("Cell Count") ThemedText ] subpane_body() Node { display: Display::Flex, flex_direction: FlexDirection::Row, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } text_setup() Children [ Text("###") ThemedText template_value(DebugInfo::CellCount) ] ], subpane() Node { display: Display::Flex, flex_direction: FlexDirection::Row, } Children [ subpane_header() Children [ Text("Coordinates") ThemedText ] subpane_body() Node { display: Display::Flex, flex_direction: FlexDirection::Row, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } text_setup() Children [ Text("##,##") ThemedText template_value(DebugInfo::Coordinates) ] ], subpane() Node { display: Display::Flex, flex_direction: FlexDirection::Row, } Children [ subpane_header() Children [ Text("Active Cell") ThemedText ] subpane_body() Node { display: Display::Flex, flex_direction: FlexDirection::Row, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } text_setup() Children [ Text("Foo/Bar") ThemedText template_value(DebugInfo::Active) ] ] ] ] } } fn primary_ui() -> impl Scene { fn toggle_note( change: On>, notes: Query<&Note, With>, mut pitch_map: ResMut, ) { if change.is_final { // TODO: if last selected note and de-selected, undo that move please let this_note = notes.get(change.source).unwrap(); pitch_map.set_note(this_note, change.value); debug!("(note {:?}) Updated pitch map: {:#?}", this_note, pitch_map); } } fn toggle_main_ui( _: On>, mut next: ResMut>, curr: Res>, ) { next.set(match curr.get() { MainUi::Hidden => MainUi::Visible, MainUi::Visible => MainUi::Hidden, }); } fn description_text() -> impl Scene { bsn! { subpane() Node { display: Display::Flex, flex_direction: FlexDirection::Column, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } text_setup() Children [ Text("Welcome to a little experiment I made: adding sound to Conways Game of Life") ThemedText, Text("---") ThemedText, Text("The buttons above control (in order)") ThemedText, Text("Start/Pause the simulation") ThemedText, Text("Step the simulation") ThemedText, Text("Reset the world state") ThemedText, Text("Wipe the world state") ThemedText, Text("Zoom in/out") ThemedText, Text("Toggle sound on/off") ThemedText, Text("---") ThemedText, Text("Below that are the notes you can toggle on/off") ThemedText, Text("And below that are the number of octaves for each note") ThemedText, Text("Notes are color coded horizontally") ThemedText, Text("Octaves are color coded vertically") ThemedText, Text("---") ThemedText, Text("The camera automatically follows the action") ThemedText, Text("---") ThemedText, Text("Hotkyes") ThemedText, Text("* Space: Play/Pause Simulation") ThemedText, Text("* m: Mute/Play Audio") ThemedText, Text("* r: Reset Simulation") ThemedText, Text("* n: Next Simulation Step") ThemedText, Text("* -/=: Zoom In/Out") ThemedText, ] } } fn controls() -> impl Scene { bsn! { subpane() Node { display: Display::Flex, flex_direction: FlexDirection::Row, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } Children [ subpane_header() Children [ Text("Nav") ThemedText ] subpane_body() Node { display: Display::Flex, flex_direction: FlexDirection::Row, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } Children [ ui_toggle_button((UiButton::Pause, bsn! { SimulationPlayback::Pause }), (UiButton::Play, bsn! { SimulationPlayback::Run })) ActionSource(SimAction::Toggle), ui_button(UiButton::Step) ActionSource(SimAction::Step), ui_button(UiButton::Reset) ActionSource(SimAction::Reset), ui_button(UiButton::Wipe) ActionSource(SimAction::Wipe), ui_button(UiButton::ZoomOut) ActionSource(ZoomAction::ZoomOut), ui_button(UiButton::ZoomIn) ActionSource(ZoomAction::ZoomIn), ui_toggle_button( (UiButton::MuteAudio, bsn! { AudioPlayback::Mute }), (UiButton::PlayAudio, bsn! { AudioPlayback::Play }) ) ActionSource(AudioAction::Toggle), ] ] } } fn notes() -> impl Scene { bsn! { subpane() Children [ subpane_header() Children [ Text("Notes") ThemedText ] subpane_body() Node { display: Display::Flex, flex_direction: FlexDirection::Row, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } Children [ ui_checkbox("A") template_value(Note::A) on(toggle_note), ui_checkbox("A#") template_value(Note::ASharp) on(toggle_note), ui_checkbox("B") template_value(Note::B) on(toggle_note), ui_checkbox("C") template_value(Note::C) on(toggle_note), ui_checkbox("C#") template_value(Note::CSharp) on(toggle_note), ui_checkbox("D") template_value(Note::D) on(toggle_note), ui_checkbox("D#") template_value(Note::DSharp) on(toggle_note), ui_checkbox("E") template_value(Note::E) on(toggle_note), ui_checkbox("F") template_value(Note::F) on(toggle_note), ui_checkbox("F#") template_value(Note::FSharp) on(toggle_note), ui_checkbox("G") template_value(Note::G) on(toggle_note), ui_checkbox("G#") template_value(Note::GSharp) on(toggle_note), ] ] } } fn octaves() -> impl Scene { bsn! { subpane() subpane_header() Children [ Text("Octaves") ThemedText ] subpane_body() Node { display: Display::Flex, flex_direction: FlexDirection::Row, align_items: AlignItems::Stretch, justify_content: JustifyContent::Start, } Children [ @FeathersSlider { @max: 11., @value: 5., @min: 1. } SliderStep(2.) SliderPrecision(0) on(|value_change: On>, mut pitch_map: ResMut| { if value_change.is_final { pitch_map.octaves = value_change.value as usize; } }) on(slider_self_update), ] } } bsn! { Node { display: Display::Flex, flex_direction: FlexDirection::Column, justify_content: JustifyContent::Start, } Transform pane() Children [ pane_header() on(toggle_main_ui) // todo: toggle main ui Children [ Text("Conways Game of Life With Sound (click here to open control panel)") ThemedText, ], pane_body() MainUi::Visible ToggleStateVisible Children [ controls(), notes(), octaves(), description_text(), ] ] } } #[derive(Default, Debug, Resource)] struct NextCoordinates(Coordinates); #[derive(Default, Debug, Resource)] struct LastBoardState { coordinates: Vec, } #[derive(Debug, Default, Clone, PartialEq, Hash, Eq, Component, FromTemplate)] #[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 as_translation(&self) -> Vec3 { (Vec2::new(self.x as f32, self.y as f32) * SCALE).extend(0.0) } /// For the purposes of creating assests, what does the given coordinate translate to CellPitch wise fn to_cell_pitch(&self, pm: &PitchMap) -> CellPitch { let rows = pm.notes.len(); let cols = pm.octaves; let this_x = self.x.rem_euclid(rows as isize); let this_y = self.y.rem_euclid(cols as isize) - (pm.octaves as isize / 2); debug_assert!(this_x >= 0); let note = pm.notes.get(this_x as usize).unwrap().clone(); let octave = this_y; CellPitch { note, octave } } } /// When the cursor moves, update the Coordinates 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.0.x = (pos / SCALE).round().x as isize; coordinates.0.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.0.as_translation().truncate(), ..default() }; gizmos.rect_2d(isometry, Vec2::ONE * SCALE, PURPLE) } #[derive(Component, Debug, PartialEq, Hash, Eq, FromTemplate)] struct Cell; // When left mouse clicked, spawn a cell at GridPosition fn de_spawn_cells( coordinates: Res, cells: Query<(Entity, &Coordinates), With>, mut lifecycle: MessageWriter, ) { if let Some(e) = cells .iter() .find_map(|(e, c)| (coordinates.0 == *c).then_some(e)) { lifecycle.write(Lifecycle::Dead(e)); } else { lifecycle.write(Lifecycle::Alive(coordinates.0.clone())); } } /// Run condition if the UI is the active element on the screen fn is_ui_hovered(hovered: Query<&Hovered>) -> bool { hovered.iter().any(|Hovered(h)| *h) } #[cfg(debug_assertions)] 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, Component, FromTemplate)] #[allow(dead_code)] enum SimulationPlayback { #[default] Pause, Run, Step, } /// 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 writer: MessageWriter, mut alive: Local>, mut candidates: Local>, ) { let living_neighbors = |a: &HashMap, c: &Coordinates| -> usize { c.neighbors() .filter(|neighbor| { a.contains_key(neighbor) }) .count() }; // Reset alive cell cache *alive = q.iter().map(|(e, c)| (c.clone(), e)).collect(); // Clear candidate coordinates candidates.clear(); // Put all dead cells neighboring living cells into the coordinates hashset // Each living cell q.iter().for_each(|(_e, c)| { candidates.insert(c.clone()); // Iterator over neighbor positions // Filter to just dead neighbors c.neighbors() .filter(|c| !alive.contains_key(c)) .for_each(|c| { candidates.insert(c); }); }); // Get Neighbor Count of living cells candidates.iter().for_each(|c| { let num_living_neighbors = living_neighbors(&*alive, c); let is_living = alive.contains_key(c); match num_living_neighbors { ..2 | 4.. => { if is_living { writer.write(Lifecycle::Dead(alive.get(c).cloned().unwrap())); } } 2 => debug!("continue"), 3 => { if !is_living { writer.write(Lifecycle::Alive(c.clone())); } else { debug!("continue") } } }; }); } /// Bring a cell to life, or kill it #[derive(Message)] enum Lifecycle { Alive(Coordinates), Dead(Entity), } fn new_cell( Coordinates { x, y }: Coordinates, cell_assets: &Res, pitch_map: &Res, ) -> impl Scene { let c = Coordinates { x, y }; let cell_pitch = c.to_cell_pitch(&(*pitch_map)); let translation = c.as_translation() + Vec3::new(0.0, 0.0, 1.0); debug!("cell pitch: {:#?}", cell_pitch); debug!("materials: {:#?}", cell_assets.materials); bsn! { Cell Coordinates { x, y } template_value(cell_pitch.clone()) template_value(Transform::from_translation(translation)) template_value(Mesh2dTemplate(cell_assets.mesh.clone().into())) // MeshMaterial2d added by update_material template_value(MeshMaterial2dTemplate(cell_assets.materials.get(&cell_pitch).unwrap().clone().into())) } } fn cell_lifecycle( mut reader: MessageReader, mut commands: Commands, cell_assets: Res, pitch_map: Res, ) { reader.read().for_each(|msg| match msg { Lifecycle::Alive(c) => { debug!("creating {:?}", c); // TODO: Safeguard: ensure no other cell there? commands.spawn_scene(new_cell(c.clone(), &cell_assets, &pitch_map)); } Lifecycle::Dead(e) => { commands.entity(*e).despawn(); } }) } // Run condition for running a system every `input` seconds fn cooldown_secs(input: f32) -> impl FnMut(Local, Res