You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1193 lines
38 KiB
Rust
1193 lines
38 KiB
Rust
// TODO:
|
|
// * Debug Panel
|
|
// * Describe cell I am hovering over (Note, Octave, etc)
|
|
// * Count # of cells on the board
|
|
// * Fix tiling repeating
|
|
// * Negative coordinates should not mirror
|
|
// * Fix randomly audio not working
|
|
// * space -> play/pause sim
|
|
// * m -> un/mute audio
|
|
#![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()
|
|
}))
|
|
.add_plugins(FeathersPlugins)
|
|
.add_plugins(GameActionsPlugin)
|
|
.init_resource::<NextCoordinates>()
|
|
.init_resource::<CellAssets>()
|
|
.init_resource::<Panning>()
|
|
.init_resource::<PitchMap>()
|
|
.init_resource::<LastBoardState>()
|
|
.insert_resource(ClearColor(WHITE.into()))
|
|
.insert_resource(UiTheme(create_light_theme()))
|
|
.init_state::<SimulationPlayback>()
|
|
.init_state::<AudioPlayback>()
|
|
.add_message::<Lifecycle>()
|
|
.add_systems(
|
|
Startup,
|
|
(
|
|
the_camera.spawn(),
|
|
the_ui.spawn(),
|
|
load_audio_tones,
|
|
load_materials,
|
|
load_meshes,
|
|
),
|
|
)
|
|
// Cell management/Simulation
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
cell_lifecycle.run_if(on_message::<Lifecycle>),
|
|
update_grid_position.run_if(on_message::<CursorMoved>),
|
|
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))
|
|
.run_if(cooldown_secs(SIM_FRAME_DURATION)),
|
|
simulation_step.run_if(state_changed::<SimulationPlayback>),
|
|
pause_simulation.run_if(in_state(SimulationPlayback::Step)),
|
|
update_material,
|
|
),
|
|
)
|
|
// Audio Systems
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
control_volume,
|
|
update_pitch_map_resource_ui.run_if(resource_changed::<PitchMap>),
|
|
reassign_cell_pitch.run_if(resource_changed::<PitchMap>),
|
|
update_audio,
|
|
),
|
|
)
|
|
// Debugging
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
assert_cell_uniqueness.run_if(any_with_component::<Cell>),
|
|
grid_gizmo,
|
|
update_debug_info_cell_count,
|
|
update_debug_info_coordinates,
|
|
),
|
|
)
|
|
// Ui Controllers
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
// TODO: Visualize audio play/mute buttons enable/disable
|
|
manage_interactive::<SimulationPlayback>,
|
|
// TODO Visualize simulation run/pause buttons enable/disable
|
|
manage_interactive::<AudioPlayback>,
|
|
update_note_interactivity,
|
|
),
|
|
)
|
|
// Input handling
|
|
.add_systems(
|
|
Update,
|
|
(mouse_pan.run_if(on_message::<MouseMotion>), set_pan_state),
|
|
)
|
|
.run();
|
|
}
|
|
|
|
const SCALE: f32 = 100.0;
|
|
|
|
#[derive(Component, PartialEq, Clone, Default)]
|
|
enum DebugInfo {
|
|
CellCount,
|
|
Coordinates,
|
|
#[default]
|
|
Empty,
|
|
}
|
|
|
|
#[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 {
|
|
Power,
|
|
Pause,
|
|
Step,
|
|
Play,
|
|
ZoomOut,
|
|
ZoomIn,
|
|
PlayAudio,
|
|
MuteAudio,
|
|
AddHighOctave,
|
|
SubHighOctave,
|
|
AddLowOctave,
|
|
SubLowOctave,
|
|
Note,
|
|
Reset,
|
|
Wipe,
|
|
#[default]
|
|
None,
|
|
}
|
|
|
|
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",
|
|
Self::PlayAudio => "audioOn.png",
|
|
Self::MuteAudio => "audioOff.png",
|
|
Self::AddHighOctave => "todo.png",
|
|
Self::SubHighOctave => "todo.png",
|
|
Self::AddLowOctave => "todo.png",
|
|
Self::SubLowOctave => "todo.png",
|
|
Self::Note => "todo.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 the_camera() -> impl Scene {
|
|
bsn! {
|
|
Camera2d
|
|
}
|
|
}
|
|
|
|
fn update_pitch_map_resource_ui(
|
|
query: Query<(Entity, &Note), With<Node>>,
|
|
pitch_map: Res<PitchMap>,
|
|
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::<Checked>();
|
|
}
|
|
});
|
|
}
|
|
|
|
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 {
|
|
bsn! {
|
|
Node {
|
|
justify_self: JustifySelf::End,
|
|
display: Display::Flex,
|
|
flex_direction: FlexDirection::Column,
|
|
}
|
|
// TODO: Toggle visibility when debugging disabled
|
|
pane()
|
|
Transform
|
|
Children [
|
|
pane_header()
|
|
Children [
|
|
Text("Debug Info") ThemedText,
|
|
],
|
|
pane_body()
|
|
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)
|
|
]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
}
|
|
|
|
fn primary_ui() -> impl Scene {
|
|
fn toggle_note(
|
|
change: On<ValueChange<bool>>,
|
|
notes: Query<&Note, With<Checkbox>>,
|
|
mut pitch_map: ResMut<PitchMap>,
|
|
) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
bsn! {
|
|
Node {
|
|
display: Display::Flex,
|
|
flex_direction: FlexDirection::Column,
|
|
justify_content: JustifyContent::Start,
|
|
}
|
|
Transform
|
|
pane()
|
|
Children [
|
|
pane_header()
|
|
Children [
|
|
Text("Conways Game of Life With Sound") ThemedText,
|
|
],
|
|
pane_body()
|
|
Children [
|
|
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_button(UiButton::Power)
|
|
ActionSource<Action>(Action::Quit),
|
|
ui_button(UiButton::Pause)
|
|
SimulationPlayback::Pause
|
|
ActionSource<SimAction>(SimAction::Toggle),
|
|
ui_button(UiButton::Play)
|
|
SimulationPlayback::Run
|
|
ActionSource<SimAction>(SimAction::Toggle),
|
|
ui_button(UiButton::Step)
|
|
ActionSource<SimAction>(SimAction::Step),
|
|
ui_button(UiButton::Reset)
|
|
ActionSource<SimAction>(SimAction::Reset),
|
|
ui_button(UiButton::Wipe)
|
|
ActionSource<SimAction>(SimAction::Wipe),
|
|
ui_button(UiButton::ZoomOut)
|
|
ActionSource<ZoomAction>(ZoomAction::ZoomOut),
|
|
ui_button(UiButton::ZoomIn)
|
|
ActionSource<ZoomAction>(ZoomAction::ZoomIn),
|
|
ui_button(UiButton::MuteAudio)
|
|
AudioPlayback::Mute
|
|
ActionSource<AudioAction>(AudioAction::Toggle),
|
|
ui_button(UiButton::PlayAudio)
|
|
AudioPlayback::Play
|
|
ActionSource<AudioAction>(AudioAction::Toggle),
|
|
]
|
|
],
|
|
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),
|
|
]
|
|
],
|
|
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: 5., @value: 3., @min: 1. }
|
|
SliderStep(1.)
|
|
SliderPrecision(0)
|
|
on(|value_change: On<ValueChange<f32>>, mut pitch_map: ResMut<PitchMap>| {
|
|
if value_change.is_final {
|
|
pitch_map.octaves_up = value_change.value as usize;
|
|
debug!("(octave up) Updated pitch map: {:#?}", pitch_map);
|
|
}
|
|
})
|
|
on(slider_self_update),
|
|
@FeathersSlider { @max: 5., @value: 3., @min: 1. }
|
|
SliderStep(1.)
|
|
SliderPrecision(0)
|
|
on(|value_change: On<ValueChange<f32>>, mut pitch_map: ResMut<PitchMap>| {
|
|
if value_change.is_final {
|
|
pitch_map.octaves_down = value_change.value as usize;
|
|
debug!("(octave down) Updated pitch map: {:#?}", pitch_map);
|
|
}
|
|
})
|
|
on(slider_self_update),
|
|
],
|
|
]
|
|
]
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Debug, Resource)]
|
|
struct NextCoordinates(Coordinates);
|
|
|
|
#[derive(Default, Debug, Resource)]
|
|
struct LastBoardState {
|
|
coordinates: Vec<Coordinates>,
|
|
}
|
|
|
|
#[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<Item = Self> {
|
|
[
|
|
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) -> CellPitch {
|
|
let note = match self.x {
|
|
-6 => Note::A,
|
|
-5 => Note::ASharp,
|
|
-4 => Note::B,
|
|
-3 => Note::C,
|
|
-2 => Note::CSharp,
|
|
-1 => Note::D,
|
|
0 => Note::DSharp,
|
|
1 => Note::E,
|
|
2 => Note::F,
|
|
3 => Note::FSharp,
|
|
4 => Note::G,
|
|
5 => Note::GSharp,
|
|
_ => panic!("This shouldn't happen!"),
|
|
};
|
|
let octave = self.y;
|
|
CellPitch { note, octave }
|
|
}
|
|
}
|
|
|
|
/// When the cursor moves, update the Coordiantes
|
|
fn update_grid_position(
|
|
mut coordinates: ResMut<NextCoordinates>,
|
|
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<NextCoordinates>) {
|
|
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<NextCoordinates>,
|
|
cells: Query<(Entity, &Coordinates), With<Cell>>,
|
|
mut lifecycle: MessageWriter<Lifecycle>,
|
|
) {
|
|
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)
|
|
}
|
|
|
|
fn assert_cell_uniqueness(q: Query<(Entity, &Coordinates), With<Cell>>) {
|
|
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<Cell>>,
|
|
mut writer: MessageWriter<Lifecycle>,
|
|
) {
|
|
fn is_cell_alive(
|
|
coordinates: &Coordinates,
|
|
query: &Query<(Entity, &Coordinates), With<Cell>>,
|
|
) -> bool {
|
|
query.iter().any(|(_e, c)| *c == *coordinates)
|
|
}
|
|
|
|
fn living_neighbors(
|
|
c: &Coordinates,
|
|
query: &Query<(Entity, &Coordinates), With<Cell>>,
|
|
) -> usize {
|
|
c.neighbors()
|
|
.filter(|neighbor| {
|
|
query
|
|
.iter()
|
|
.any(|(_e, coordinates)| *neighbor == *coordinates)
|
|
})
|
|
.count()
|
|
}
|
|
|
|
fn cell_entity(
|
|
coordinates: &Coordinates,
|
|
query: Query<(Entity, &Coordinates), With<Cell>>,
|
|
) -> Option<Entity> {
|
|
query
|
|
.iter()
|
|
.find_map(|(e, c)| (*c == *coordinates).then_some(e))
|
|
}
|
|
|
|
// Collect list of candidate positions...
|
|
let mut coordinates: HashSet<Coordinates> = 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 is_living {
|
|
writer.write(Lifecycle::Dead(cell_entity(c, q).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<CellAssets>,
|
|
sinks: &Query<Entity, With<AudioSink>>,
|
|
sim_state: &Res<State<SimulationPlayback>>,
|
|
audio_state: &Res<State<AudioPlayback>>,
|
|
pitch_map: &Res<PitchMap>,
|
|
) -> impl Scene {
|
|
let c = Coordinates { x, y };
|
|
|
|
let volume = {
|
|
if *sim_state.get() == SimulationPlayback::Run && *audio_state.get() == AudioPlayback::Play
|
|
{
|
|
let ratio = 1.0 / (sinks.count() + 1) as f32;
|
|
Volume::Linear(ratio)
|
|
} else {
|
|
Volume::SILENT
|
|
}
|
|
};
|
|
|
|
let playback_settings = PlaybackSettings::ONCE.with_volume(volume);
|
|
|
|
let cell_pitch = pitch_map.coordinates_cell_pitch(&c);
|
|
|
|
#[derive(Clone, Default, Component)]
|
|
enum Foo {
|
|
#[default]
|
|
A,
|
|
}
|
|
|
|
bsn! {
|
|
Cell
|
|
Coordinates { x, y }
|
|
template_value(cell_pitch)
|
|
template_value(playback_settings)
|
|
template_value(Transform::from_translation(c.as_translation() + Vec3::new(0.0, 0.0, 1.0)))
|
|
template_value(Mesh2dTemplate(cell_assets.mesh.clone().into()))
|
|
// AudioPlayer added by update_audio
|
|
// MeshMaterial2d added by update_material
|
|
template_value(MeshMaterial2dTemplate(cell_assets.materials.get(&pitch_map.coordinates_cell_pitch(&c)).unwrap().clone().into()))
|
|
}
|
|
}
|
|
|
|
fn cell_lifecycle(
|
|
mut reader: MessageReader<Lifecycle>,
|
|
mut commands: Commands,
|
|
cell_assets: Res<CellAssets>,
|
|
query: Query<Entity, With<AudioSink>>,
|
|
sim_state: Res<State<SimulationPlayback>>,
|
|
audio_state: Res<State<AudioPlayback>>,
|
|
pitch_map: Res<PitchMap>,
|
|
) {
|
|
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,
|
|
&query,
|
|
&sim_state,
|
|
&audio_state,
|
|
&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<Timer>, Res<Time>) -> bool + Clone {
|
|
move |mut timer: Local<Timer>, time: Res<Time>| {
|
|
if *timer == Timer::default() {
|
|
*timer = Timer::from_seconds(input, TimerMode::Repeating);
|
|
} else {
|
|
timer.tick(time.delta());
|
|
}
|
|
timer.is_finished()
|
|
}
|
|
}
|
|
|
|
// Scale grid with background?
|
|
// Pan camera: click and drag
|
|
|
|
#[derive(Resource, Default, Debug)]
|
|
struct CellAssets {
|
|
pitches: HashMap<CellPitch, Handle<Pitch>>,
|
|
materials: HashMap<CellPitch, Handle<ColorMaterial>>,
|
|
mesh: Handle<Mesh>,
|
|
}
|
|
|
|
#[derive(Component, Clone, Debug, Default, Reflect, PartialEq, Eq, Hash, Ord, PartialOrd)]
|
|
enum Note {
|
|
#[default]
|
|
A,
|
|
ASharp,
|
|
B,
|
|
C,
|
|
CSharp,
|
|
D,
|
|
DSharp,
|
|
E,
|
|
F,
|
|
FSharp,
|
|
G,
|
|
GSharp,
|
|
}
|
|
|
|
impl Note {
|
|
/// Pitches: https://en.wikipedia.org/wiki/Piano_key_frequencies
|
|
fn as_frequency(&self) -> f32 {
|
|
match self {
|
|
Self::A => 440.0,
|
|
Self::ASharp => 466.1638,
|
|
Self::B => 493.8833,
|
|
Self::C => 523.2511,
|
|
Self::CSharp => 554.3653,
|
|
Self::D => 587.3295,
|
|
Self::DSharp => 622.254,
|
|
Self::E => 659.2551,
|
|
Self::F => 698.4565,
|
|
Self::FSharp => 739.9888,
|
|
Self::G => 783.9909,
|
|
Self::GSharp => 830.6094,
|
|
}
|
|
}
|
|
|
|
fn _as_str(&self) -> &str {
|
|
match self {
|
|
Self::A => "A",
|
|
Self::ASharp => "A#",
|
|
Self::B => "B",
|
|
Self::C => "C",
|
|
Self::CSharp => "C#",
|
|
Self::D => "D",
|
|
Self::DSharp => "D#",
|
|
Self::E => "E",
|
|
Self::F => "F",
|
|
Self::FSharp => "F#",
|
|
Self::G => "G",
|
|
Self::GSharp => "G#",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Component, Clone, Debug, Default, Reflect, PartialEq, Eq, Hash)]
|
|
struct CellPitch {
|
|
note: Note,
|
|
octave: isize,
|
|
}
|
|
|
|
impl CellPitch {
|
|
fn to_frequency(&self) -> f32 {
|
|
// Thanks Claude Opus 4.8
|
|
self.note.as_frequency() * 2f32.powi(self.octave as i32)
|
|
}
|
|
|
|
fn to_color(&self) -> Color {
|
|
let hue = {
|
|
let segment = 360.0 / 12.0;
|
|
match self.note {
|
|
// Equally distribute hue among the OkLab spectrum (0..360)
|
|
Note::A => segment * 1.0,
|
|
Note::ASharp => segment * 2.0,
|
|
Note::B => segment * 3.0,
|
|
Note::C => segment * 4.0,
|
|
Note::CSharp => segment * 5.0,
|
|
Note::D => segment * 6.0,
|
|
Note::DSharp => segment * 7.0,
|
|
Note::E => segment * 8.0,
|
|
Note::F => segment * 9.0,
|
|
Note::FSharp => segment * 10.0,
|
|
Note::G => segment * 11.0,
|
|
Note::GSharp => segment * 12.0,
|
|
}
|
|
};
|
|
|
|
let chroma = (self.octave as f32 + 5.0) / 11.0;
|
|
|
|
Color::Oklcha(Oklcha {
|
|
hue,
|
|
chroma,
|
|
..default()
|
|
})
|
|
}
|
|
|
|
fn iter_all() -> impl Iterator<Item = Self> {
|
|
[
|
|
Note::A,
|
|
Note::ASharp,
|
|
Note::B,
|
|
Note::C,
|
|
Note::CSharp,
|
|
Note::D,
|
|
Note::DSharp,
|
|
Note::E,
|
|
Note::F,
|
|
Note::FSharp,
|
|
Note::G,
|
|
Note::GSharp,
|
|
]
|
|
.into_iter()
|
|
.flat_map(|note| {
|
|
(-5..6).map(move |octave| CellPitch {
|
|
note: note.clone(),
|
|
octave,
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
fn load_audio_tones(mut pitches: ResMut<Assets<Pitch>>, mut cell_assets: ResMut<CellAssets>) {
|
|
CellPitch::iter_all().for_each(|cell_pitch| {
|
|
let frequency = cell_pitch.to_frequency();
|
|
let duration = Duration::from_secs(100);
|
|
debug!(
|
|
"Adding Pitch frequency: {:?} duration: {:?}",
|
|
frequency, duration
|
|
);
|
|
let handle = pitches.add(Pitch {
|
|
frequency,
|
|
duration,
|
|
});
|
|
cell_assets.pitches.insert(cell_pitch, handle);
|
|
});
|
|
debug_assert_eq!(cell_assets.pitches.iter().len(), 132);
|
|
}
|
|
|
|
fn control_volume(
|
|
audio_state: Res<State<AudioPlayback>>,
|
|
simulation_state: Res<State<SimulationPlayback>>,
|
|
mut sinks: Query<&mut AudioSink>,
|
|
) {
|
|
match audio_state.get() {
|
|
AudioPlayback::Play => match simulation_state.get() {
|
|
SimulationPlayback::Run => {
|
|
let ratio = 1.0 / sinks.count() as f32;
|
|
let volume = Volume::Linear(ratio);
|
|
sinks.par_iter_mut().for_each(|mut sink| {
|
|
sink.set_volume(volume);
|
|
})
|
|
}
|
|
SimulationPlayback::Pause | SimulationPlayback::Step => {
|
|
sinks.par_iter_mut().for_each(|mut sink| {
|
|
sink.mute();
|
|
})
|
|
}
|
|
},
|
|
AudioPlayback::Mute => sinks.par_iter_mut().for_each(|mut sink| {
|
|
sink.mute();
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn load_materials(
|
|
server: ResMut<AssetServer>,
|
|
mut cell_assets: ResMut<CellAssets>,
|
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
) {
|
|
let tile_handle = server.load(TILE);
|
|
|
|
CellPitch::iter_all().for_each(|cell_pitch| {
|
|
let handle = {
|
|
let color = cell_pitch.to_color();
|
|
let texture = Some(tile_handle.clone());
|
|
let color_material = ColorMaterial {
|
|
color,
|
|
texture,
|
|
..default()
|
|
};
|
|
materials.add(color_material)
|
|
};
|
|
cell_assets.materials.insert(cell_pitch, handle);
|
|
});
|
|
debug_assert_eq!(cell_assets.materials.iter().len(), 132);
|
|
}
|
|
|
|
fn load_meshes(mut cell_assets: ResMut<CellAssets>, mut meshes: ResMut<Assets<Mesh>>) {
|
|
cell_assets.mesh = meshes.add(Rectangle::new(SCALE, SCALE));
|
|
}
|
|
|
|
// TODO: Do not place piece if panning
|
|
// TODO: Tie cursor to real world space, not pixels
|
|
fn mouse_pan(
|
|
mouse_motion: Res<AccumulatedMouseMotion>,
|
|
button: Res<ButtonInput<MouseButton>>,
|
|
mut camera: Query<(&Camera, &mut Transform, &GlobalTransform), With<Camera2d>>,
|
|
window: Query<&Window>,
|
|
) {
|
|
if button.pressed(MouseButton::Left) {
|
|
let delta = mouse_motion.delta;
|
|
camera.iter_mut().for_each(|(c, mut t, gt)| {
|
|
window.iter().for_each(|w| {
|
|
let Some(curr_cursor_position) = w.cursor_position() else {
|
|
return;
|
|
};
|
|
let old_cursor_position = curr_cursor_position - delta / w.scale_factor();
|
|
|
|
let Ok(curr_world) = c.viewport_to_world_2d(gt, curr_cursor_position) else {
|
|
return;
|
|
};
|
|
let Ok(old_world) = c.viewport_to_world_2d(gt, old_cursor_position) else {
|
|
return;
|
|
};
|
|
|
|
let delta_world = old_world - curr_world;
|
|
t.translation.x += delta_world.x / 7.0;
|
|
t.translation.y += delta_world.y / 7.0;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
struct Panning(bool);
|
|
|
|
fn set_pan_state(
|
|
mut panning: ResMut<Panning>,
|
|
mouse_motion: Res<AccumulatedMouseMotion>,
|
|
button: Res<ButtonInput<MouseButton>>,
|
|
) {
|
|
// Set to panning if clicked and delta > 0
|
|
if button.pressed(MouseButton::Left) && mouse_motion.delta.abs().length() > 0.0 {
|
|
panning.0 = true
|
|
} else if button.just_released(MouseButton::Left) {
|
|
panning.0 = false;
|
|
}
|
|
}
|
|
|
|
fn is_panning(panning: Res<Panning>) -> bool {
|
|
panning.0
|
|
}
|
|
|
|
// This may or may not be an eye sore
|
|
fn create_light_theme() -> ThemeProps {
|
|
build(
|
|
Seeds::from_coolors([
|
|
Srgba::hex("264653").unwrap().into(),
|
|
Srgba::hex("2A9D8F").unwrap().into(),
|
|
Srgba::hex("E9C46A").unwrap().into(),
|
|
Srgba::hex("F4A261").unwrap().into(),
|
|
Srgba::hex("E76F51").unwrap().into(),
|
|
]),
|
|
Mode::Light,
|
|
)
|
|
}
|
|
|
|
fn manage_interactive<T: Component + States + PartialEq>(
|
|
q: Query<(Entity, &T)>,
|
|
s: Res<State<T>>,
|
|
mut commands: Commands,
|
|
) {
|
|
q.iter().for_each(|(e, c)| {
|
|
if c == s.get() {
|
|
commands.entity(e).insert(InteractionDisabled);
|
|
} else {
|
|
commands.entity(e).remove::<InteractionDisabled>();
|
|
}
|
|
})
|
|
}
|
|
|
|
#[derive(Resource, Debug)]
|
|
struct PitchMap {
|
|
notes: Vec<Note>,
|
|
octaves_up: usize,
|
|
octaves_down: usize,
|
|
}
|
|
|
|
impl Default for PitchMap {
|
|
fn default() -> Self {
|
|
Self {
|
|
notes: vec![Note::A, Note::B, Note::CSharp, Note::E, Note::FSharp],
|
|
octaves_up: 3,
|
|
octaves_down: 3,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PitchMap {
|
|
fn set_note(&mut self, n: &Note, value: bool) {
|
|
if !value && let Some(idx) = self.notes.iter().position(|v| v == n) {
|
|
self.notes.swap_remove(idx);
|
|
} else if value {
|
|
self.notes.push(n.clone());
|
|
}
|
|
// always ensure the notes list is sorted
|
|
self.notes.sort();
|
|
}
|
|
|
|
/// Given the currently selected notes and octave range selected in the PitchMap convert the given coordinates to a CellPitch
|
|
fn coordinates_cell_pitch(&self, c: &Coordinates) -> CellPitch {
|
|
let note: Note = {
|
|
let num_notes: usize = self.notes.len();
|
|
let x_coord: usize = c.x.strict_abs() as usize; // TODO: shift first or something
|
|
self.notes.get(x_coord % num_notes).unwrap().clone()
|
|
};
|
|
let octave: isize = {
|
|
let num_octaves: usize = self.octaves_up + self.octaves_down + 1;
|
|
let y_coord: usize = c.y.strict_abs() as usize; // TODO: shift first or something
|
|
debug!("num_octaves: {:?}", num_octaves);
|
|
debug!("y_coord: {:?}", y_coord);
|
|
(y_coord % num_octaves) as isize - 5
|
|
};
|
|
let cp = CellPitch { note, octave };
|
|
debug!("Cell Pitch: {:?}", cp);
|
|
cp
|
|
}
|
|
}
|
|
|
|
/// When the pitch map changes, update all cells to use the correct pitch (note + octave)
|
|
fn reassign_cell_pitch(mut query: Query<(&mut CellPitch, &Coordinates)>, pitch_map: Res<PitchMap>) {
|
|
debug!("query size: {:?}", query.iter().len());
|
|
query.iter_mut().for_each(|(mut cell_pitch, c)| {
|
|
*cell_pitch = pitch_map.coordinates_cell_pitch(c);
|
|
debug!("Updating cell pitch for {:?}", c);
|
|
});
|
|
}
|
|
|
|
// When CellPitch is added/updated on a cell, update the material
|
|
fn update_material(
|
|
mut query: Query<(&CellPitch, Entity), Or<(Added<CellPitch>, Changed<CellPitch>)>>,
|
|
cell_assets: ResMut<CellAssets>,
|
|
mut commands: Commands,
|
|
) {
|
|
query.iter_mut().for_each(|(cell_pitch, e)| {
|
|
let h = cell_assets.materials.get(cell_pitch).unwrap().clone();
|
|
commands.entity(e).insert(MeshMaterial2d(h));
|
|
})
|
|
}
|
|
|
|
// When CellPitch is added/updated on a cell, update the Pitch
|
|
fn update_audio(
|
|
mut query: Query<(&CellPitch, Entity), Or<(Added<CellPitch>, Changed<CellPitch>)>>,
|
|
cell_assets: ResMut<CellAssets>,
|
|
mut commands: Commands,
|
|
) {
|
|
query.iter_mut().for_each(|(cell_pitch, e)| {
|
|
let h = cell_assets.pitches.get(cell_pitch).unwrap().clone();
|
|
commands.entity(e).insert(AudioPlayer(h));
|
|
});
|
|
}
|
|
|
|
fn update_debug_info_cell_count(query: Query<&Cell>, mut text: Query<(&DebugInfo, &mut Text)>) {
|
|
text.iter_mut()
|
|
.filter(|(di, _)| **di == DebugInfo::CellCount)
|
|
.for_each(|(_, mut t)| {
|
|
*t = Text::new(format!("{}", query.count()));
|
|
});
|
|
}
|
|
|
|
fn update_debug_info_coordinates(
|
|
coordinates: Res<NextCoordinates>,
|
|
mut text: Query<(&DebugInfo, &mut Text)>,
|
|
) {
|
|
text.iter_mut()
|
|
.filter(|(di, _)| **di == DebugInfo::Coordinates)
|
|
.for_each(|(_, mut t)| {
|
|
*t = Text::new(format!("{},{}", coordinates.0.x, coordinates.0.y));
|
|
});
|
|
}
|
|
|
|
fn update_note_interactivity(
|
|
checked: Query<Entity, (With<Note>, With<Checkbox>, With<Checked>)>,
|
|
mut commands: Commands,
|
|
) {
|
|
if checked.iter().len() == 1 {
|
|
checked.iter().for_each(|e| {
|
|
commands.entity(e).insert(InteractionDisabled);
|
|
});
|
|
} else {
|
|
checked.iter().for_each(|e| {
|
|
commands.entity(e).remove::<InteractionDisabled>();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sets the sim state to paused
|
|
fn pause_simulation(mut next: ResMut<NextState<SimulationPlayback>>) {
|
|
next.set(SimulationPlayback::Pause)
|
|
}
|