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.

1332 lines
42 KiB
Rust

// TODO:
// * Fix randomly audio not working
// * Lock down system scheduling/run_if conditions
// * UI widget explaining rules/hotkeys
// * Show shadow of piece (same tile, just with alpha transparency)
// * Start with an interesting pattern
// * UI minimize widgets
// * Add "follow action" that adjusts camera to zoom in/out/move to see everything
// * Make click and drag feel better
// * Add arrow keys to move around
// * web/wasm build
#![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>()
.init_state::<DebugUi>()
.init_state::<MainUi>()
.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,
(
default_board.run_if(run_once),
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,
update_debug_info_active,
),
)
// Ui Controllers
.add_systems(
Update,
(
toggle_state_visible::<SimulationPlayback>
.run_if(state_changed::<SimulationPlayback>),
manage_interactive::<SimulationPlayback>,
toggle_state_visible::<AudioPlayback>.run_if(state_changed::<AudioPlayback>),
toggle_state_visible::<DebugUi>.run_if(state_changed::<DebugUi>),
toggle_state_visible::<MainUi>.run_if(state_changed::<MainUi>),
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,
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 {
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 ui_toggle_button<A: Scene, B: Scene>(
(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
}
}
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 {
fn toggle_debug_ui(
_: On<Pointer<Click>>,
mut next: ResMut<NextState<DebugUi>>,
curr: Res<State<DebugUi>>,
) {
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<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);
}
}
fn toggle_main_ui(
_: On<Pointer<Click>>,
mut next: ResMut<NextState<MainUi>>,
curr: Res<State<MainUi>>,
) {
next.set(match curr.get() {
MainUi::Hidden => MainUi::Visible,
MainUi::Visible => MainUi::Hidden,
});
}
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") ThemedText,
],
pane_body()
MainUi::Visible
ToggleStateVisible
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_toggle_button((UiButton::Pause, bsn! { SimulationPlayback::Pause }), (UiButton::Play, bsn! { 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_toggle_button((UiButton::MuteAudio, bsn! { AudioPlayback::Mute }), (UiButton::PlayAudio, bsn! { 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, pm: &PitchMap) -> CellPitch {
let rows = pm.notes.len();
let cols = pm.octaves_up + pm.octaves_down + 1;
let this_x = self.x.rem_euclid(rows as isize);
let this_y = self.y.rem_euclid(cols as isize) - (pm.octaves_down as isize);
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<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 = c.to_cell_pitch(&(*pitch_map));
debug!("cell pitch: {:#?}", cell_pitch);
debug!("materials: {:#?}", cell_assets.materials);
bsn! {
Cell
Coordinates { x, y }
template_value(cell_pitch.clone())
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(&cell_pitch).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..=5).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);
});
// 12 notes * 11 octaves = 132 materials
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));
}
fn default_board(mut writer: MessageWriter<Lifecycle>) {
(-5..=5).for_each(|x| {
(-5..=5).for_each(|y| {
writer.write(Lifecycle::Alive(Coordinates { x, y }));
});
});
}
// 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();
}
}
#[test]
fn test_cell_pitch() {
let pm = PitchMap {
notes: vec![Note::A, Note::B, Note::C],
octaves_up: 1,
octaves_down: 1,
};
let xs = -6..=5;
let ys = -6..=4;
xs.enumerate().for_each(|(i, x)| {
ys.clone().enumerate().for_each(|(j, y)| {
let c = Coordinates { x, y };
let n = pm.notes.get(i % 3).unwrap();
let o = (j as isize % 3) - 1;
let cp = c.to_cell_pitch(&pm);
debug_assert_eq!(cp.note, *n);
debug_assert_eq!(cp.octave, o);
})
});
}
/// 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 = c.to_cell_pitch(&(*pitch_map));
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_debug_info_active(
next_coordinates: Res<NextCoordinates>,
cells: Query<(&CellPitch, &Coordinates)>,
mut text: Query<(&DebugInfo, &mut Text)>,
) {
text.iter_mut()
.filter(|(di, _)| **di == DebugInfo::Active)
.for_each(|(_, mut t)| {
if let Some((cp, _c)) = cells.iter().find(|(_cp, c)| **c == next_coordinates.0) {
*t = Text::new(format!("Note: {:?} | Octave: {}", cp.note, cp.octave));
} else {
*t = Text::new(String::from("N/A"));
}
});
}
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)
}
#[derive(Component, Debug, FromTemplate)]
struct ToggleStateVisible;
fn toggle_state_visible<S: States + Component>(
mut q: Query<(&S, &mut Node), With<ToggleStateVisible>>,
c: Res<State<S>>,
) {
q.iter_mut().for_each(|(s, mut node)| {
node.display = if s == c.get() {
Display::None
} else {
Display::Flex
};
})
}