Compare commits

..

5 Commits

Author SHA1 Message Date
Elijah Voigt 209e5f60c0 web zip for pages.elijah.run, enforced system ordering for cell de-dupe 1 day ago
Elijah Voigt a5825af471 Resume AudioContext on user gesture for web audio
Browsers (Chrome, Safari, mobile Firefox) create every AudioContext
suspended and refuse to start it without a user gesture. Bevy -> rodio ->
cpal build their context at startup, so it stayed suspended and no sound
played except on lenient desktop Firefox.

Wrap the AudioContext constructor in the web shell to track created
contexts and resume them on the first pointer/key/touch gesture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 day ago
Elijah Voigt 7aa159e7b8 use R-pentomino as default pattern 2 days ago
Elijah Voigt 4f84c4cc86 collapsing ui elements in game of life 2 days ago
Elijah Voigt 41bbc6b973 Tiling works, code simplified 2 days ago

@ -20,8 +20,10 @@ codegen-backend = "llvm"
[profile.wasm-release]
inherits = "release"
opt-level = "s"
strip = "debuginfo"
opt-level = "z"
strip = true
panic = "abort"
lto = "thin"
[target.x86_64-unknown-linux-gnu]
linker = "clang"

135
engine/Cargo.lock generated

@ -349,12 +349,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bevy"
version = "0.19.0"
@ -400,7 +394,6 @@ dependencies = [
"bevy_derive",
"bevy_ecs",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_time",
@ -884,7 +877,6 @@ dependencies = [
"bevy_material",
"bevy_math",
"bevy_mesh",
"bevy_pbr",
"bevy_reflect",
"bevy_render",
"bevy_shader",
@ -895,42 +887,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "bevy_gltf"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e21ad83b7dc8d456491ea3d50057685d8c4cca8bc52a1b4a7161c86c369842"
dependencies = [
"async-lock",
"base64",
"bevy_animation",
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_ecs",
"bevy_image",
"bevy_light",
"bevy_material",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_tasks",
"bevy_transform",
"bevy_world_serialization",
"fixedbitset",
"gltf",
"itertools",
"percent-encoding",
"serde",
"serde_json",
"smallvec",
"thiserror 2.0.18",
"tracing",
"wgpu-types",
]
[[package]]
name = "bevy_image"
version = "0.19.0"
@ -951,9 +907,7 @@ dependencies = [
"guillotiere",
"half",
"image",
"ktx2",
"rectangle-pack",
"ruzstd",
"serde",
"thiserror 2.0.18",
"tracing",
@ -1020,7 +974,6 @@ dependencies = [
"bevy_gilrs",
"bevy_gizmos",
"bevy_gizmos_render",
"bevy_gltf",
"bevy_image",
"bevy_input",
"bevy_input_focus",
@ -1213,7 +1166,6 @@ dependencies = [
"bevy_derive",
"bevy_diagnostic",
"bevy_ecs",
"bevy_gltf",
"bevy_image",
"bevy_light",
"bevy_log",
@ -2775,42 +2727,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gltf"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
dependencies = [
"byteorder",
"gltf-json",
"lazy_static",
"serde_json",
]
[[package]]
name = "gltf-derive"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gltf-json"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
dependencies = [
"gltf-derive",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
@ -3134,12 +3050,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]]
name = "inotify"
version = "0.11.2"
@ -3317,15 +3227,6 @@ dependencies = [
"libc",
]
[[package]]
name = "ktx2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d50be8a24c37debdf3170be6ca396d5b5b165f37f94837891adac735ca416b8"
dependencies = [
"bitflags 2.12.1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -3338,17 +3239,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lewton"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
dependencies = [
"byteorder",
"ogg",
"tinyvec",
]
[[package]]
name = "libc"
version = "0.2.186"
@ -4058,15 +3948,6 @@ dependencies = [
"nonmax",
]
[[package]]
name = "ogg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -4521,7 +4402,6 @@ checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
dependencies = [
"cpal",
"dasp_sample",
"lewton",
"num-rational",
"thiserror 2.0.18",
"tracing",
@ -4588,15 +4468,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ruzstd"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
dependencies = [
"twox-hash",
]
[[package]]
name = "same-file"
version = "1.0.6"
@ -5146,12 +5017,6 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "typeid"
version = "1.0.3"

@ -4,7 +4,12 @@ version = "0.1.0"
edition = "2024"
[dependencies]
bevy = "0.19.0"
bevy = { version = "0.19.0", default-features = false, features = [
"2d",
"ui",
"bevy_audio",
"png",
] }
engine_macros = { path = "macros" }
[features]

@ -2,6 +2,7 @@ pub mod actions;
pub use actions::*;
pub mod color;
use bevy::ecs::query::QueryFilter;
pub use color::*;
pub use std::time::Duration;
@ -36,3 +37,15 @@ pub use bevy::{
checkbox_self_update, slider_self_update,
},
};
pub fn any_component_added<C: Component>(q: Query<Entity, Added<C>>) -> bool {
!q.is_empty()
}
pub fn any_component_changed<C: Component>(q: Query<Entity, Changed<C>>) -> bool {
!q.is_empty()
}
pub fn query_condition_empty<F: QueryFilter>(q: Query<Entity, F>) -> bool {
q.is_empty()
}

@ -37,6 +37,10 @@ fmt:
webbuild target:
scripts/webbuild {{target}}
# Zip the built web output from dist/<target>/ into dist/<name>.zip
webzip target:
scripts/webzip {{target}}
# Serve a built web target from dist/<target>/ via simple-http-server
serve target:
scripts/serve {{target}}

151
life/Cargo.lock generated

@ -349,12 +349,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bevy"
version = "0.19.0"
@ -399,7 +393,6 @@ dependencies = [
"bevy_derive",
"bevy_ecs",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_time",
@ -855,7 +848,6 @@ dependencies = [
"bevy_material",
"bevy_math",
"bevy_mesh",
"bevy_pbr",
"bevy_reflect",
"bevy_render",
"bevy_shader",
@ -866,42 +858,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "bevy_gltf"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e21ad83b7dc8d456491ea3d50057685d8c4cca8bc52a1b4a7161c86c369842"
dependencies = [
"async-lock",
"base64",
"bevy_animation",
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_ecs",
"bevy_image",
"bevy_light",
"bevy_material",
"bevy_math",
"bevy_mesh",
"bevy_platform",
"bevy_reflect",
"bevy_tasks",
"bevy_transform",
"bevy_world_serialization",
"fixedbitset",
"gltf",
"itertools",
"percent-encoding",
"serde",
"serde_json",
"smallvec",
"thiserror 2.0.18",
"tracing",
"wgpu-types",
]
[[package]]
name = "bevy_image"
version = "0.19.0"
@ -922,9 +878,7 @@ dependencies = [
"guillotiere",
"half",
"image",
"ktx2",
"rectangle-pack",
"ruzstd",
"serde",
"thiserror 2.0.18",
"tracing",
@ -990,7 +944,6 @@ dependencies = [
"bevy_gilrs",
"bevy_gizmos",
"bevy_gizmos_render",
"bevy_gltf",
"bevy_image",
"bevy_input",
"bevy_input_focus",
@ -1183,7 +1136,6 @@ dependencies = [
"bevy_derive",
"bevy_diagnostic",
"bevy_ecs",
"bevy_gltf",
"bevy_image",
"bevy_light",
"bevy_log",
@ -2727,42 +2679,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gltf"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
dependencies = [
"byteorder",
"gltf-json",
"lazy_static",
"serde_json",
]
[[package]]
name = "gltf-derive"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gltf-json"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
dependencies = [
"gltf-derive",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
@ -3086,12 +3002,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]]
name = "inotify"
version = "0.11.2"
@ -3248,15 +3158,6 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "ktx2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d50be8a24c37debdf3170be6ca396d5b5b165f37f94837891adac735ca416b8"
dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -3269,17 +3170,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lewton"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
dependencies = [
"byteorder",
"ogg",
"tinyvec",
]
[[package]]
name = "libc"
version = "0.2.186"
@ -3324,6 +3214,14 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "life"
version = "0.1.0"
dependencies = [
"bevy",
"engine",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@ -3937,15 +3835,6 @@ dependencies = [
"nonmax",
]
[[package]]
name = "ogg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -4212,14 +4101,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "prototypes"
version = "0.1.0"
dependencies = [
"bevy",
"engine",
]
[[package]]
name = "pxfm"
version = "0.1.29"
@ -4398,7 +4279,6 @@ checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
dependencies = [
"cpal",
"dasp_sample",
"lewton",
"num-rational",
"thiserror 2.0.18",
"tracing",
@ -4465,15 +4345,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ruzstd"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
dependencies = [
"twox-hash",
]
[[package]]
name = "same-file"
version = "1.0.6"
@ -5013,12 +4884,6 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "typeid"
version = "1.0.3"

@ -1,10 +1,15 @@
[package]
name = "prototypes"
name = "life"
version = "0.1.0"
edition = "2024"
[dependencies]
bevy = "0.19.0"
bevy = { version = "0.19.0", default-features = false, features = [
"2d",
"ui",
"bevy_audio",
"png",
] }
[dependencies.engine]
path = "../engine"

@ -15,8 +15,7 @@ impl Plugin for GameActionsPlugin {
.bind(ZoomAction::Zoom, [Binding::Scroll])
.bind(ZoomAction::ZoomIn, [Binding::Key(KeyCode::Equal)])
.bind(ZoomAction::ZoomOut, [Binding::Key(KeyCode::Minus)]),
ActionsPlugin::<Action>::new()
.bind(Action::Quit, [Binding::Key(KeyCode::Escape)]),
ActionsPlugin::<Action>::new().bind(Action::Quit, [Binding::Key(KeyCode::Escape)]),
));
}
}
@ -99,7 +98,7 @@ pub(crate) enum ZoomAction {
#[action_handler(ZoomAction::zoom_out)]
ZoomOut,
#[default]
None
None,
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction, Default)]
@ -111,7 +110,7 @@ pub(crate) enum Action {
}
impl ZoomAction {
fn zoom_by(factor: f32, mut projection: Query<&mut Projection>) {
pub(crate) fn zoom_by(factor: f32, mut projection: Query<&mut Projection>) {
projection.iter_mut().for_each(|mut p| {
if let Projection::Orthographic(o) = &mut *p {
o.scale = (o.scale * factor).clamp(0.05, 20.0);

@ -1,13 +1,17 @@
// TODO:
// * Fix randomly audio not working
// * Lock down system scheduling/run_if conditions
// * Fix tiling repeating
// * Negative coordinates should not mirror
// * UI widget explaining rules/hotkeys
// * Lock down system scheduling/run_if conditions
// * Performance: have a limit on per-note+octave sounds
// * Use a finite set of N audio entities and scale volume instead of
// spawning a bunch of audio entities which is causing issues at >100
// cells
// * Stop creating cells when I didn't mean to (clicking UI, click + dragging)
// * Keep some controls open when main ui panel is closed
// * Exclude power button from web build
// * Show shadow of piece (same tile, just with alpha transparency)
// * Start with an interesting pattern
// * UI minimize widgets
// * Dark mode
// * Make click and drag feel better
// * Add arrow keys to move around
// * "Follow" enhancement: Zoom in/out
#![allow(clippy::complexity)]
// Only because of FromTemplat macros unfortunately...
#![allow(dead_code)]
@ -37,6 +41,8 @@ fn main() {
.insert_resource(UiTheme(create_light_theme()))
.init_state::<SimulationPlayback>()
.init_state::<AudioPlayback>()
.init_state::<DebugUi>()
.init_state::<MainUi>()
.add_message::<Lifecycle>()
.add_systems(
Startup,
@ -48,22 +54,52 @@ fn main() {
load_meshes,
),
)
.configure_sets(
Update,
(GameSet::Plan, GameSet::Apply, GameSet::React).chain(),
)
// 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,
// Produce lifecycle messages
(
default_board.run_if(run_once),
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)
.and_then(cooldown_secs(SIM_FRAME_DURATION))
.or_else(state_changed::<SimulationPlayback>),
),
pause_simulation.run_if(in_state(SimulationPlayback::Step)),
)
.in_set(GameSet::Plan),
// Apply lifecycle message
cell_lifecycle
.run_if(on_message::<Lifecycle>)
.in_set(GameSet::Apply),
// React to board changes
(
reassign_cell_pitch.run_if(resource_changed::<PitchMap>),
update_material.run_if(
any_component_added::<CellPitch>
.or_else(any_component_changed::<CellPitch>),
),
camera_follow
.run_if(in_state(SimulationPlayback::Run))
.run_if(any_component_changed::<Coordinates>),
update_camera_position
.run_if(in_state(SimulationPlayback::Run))
.run_if(not(query_condition_empty::<(
With<Camera>,
Changed<Coordinates>,
)>)),
)
.in_set(GameSet::React),
),
)
// Audio Systems
@ -72,28 +108,41 @@ fn main() {
(
control_volume,
update_pitch_map_resource_ui.run_if(resource_changed::<PitchMap>),
reassign_cell_pitch.run_if(resource_changed::<PitchMap>),
update_audio,
update_audio
.run_if(
any_component_added::<CellPitch>
.or_else(any_component_changed::<CellPitch>),
)
.in_set(GameSet::React),
),
)
// 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,
(
assert_cell_uniqueness
.run_if(any_with_component::<Cell>)
.after(update_material)
.after(update_audio),
update_debug_info_cell_count,
update_debug_info_active,
)
.in_set(GameSet::React),
),
)
// Ui Controllers
.add_systems(
Update,
(
toggle_state_visible::<SimulationPlayback>.run_if(state_changed::<SimulationPlayback>),
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,
),
@ -108,6 +157,13 @@ fn main() {
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,
@ -117,6 +173,20 @@ enum DebugInfo {
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]
@ -135,11 +205,6 @@ enum UiButton {
ZoomIn,
PlayAudio,
MuteAudio,
AddHighOctave,
SubHighOctave,
AddLowOctave,
SubLowOctave,
Note,
Reset,
Wipe,
#[default]
@ -157,11 +222,6 @@ impl UiButton {
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!(),
@ -199,7 +259,10 @@ fn ui_button(button: UiButton) -> impl Scene {
}
}
fn ui_toggle_button<A: Scene, B: Scene>((button_a, state_a): (UiButton, A), (button_b, state_b): (UiButton, B)) -> impl Scene {
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
@ -239,6 +302,7 @@ fn ui_toggle_button<A: Scene, B: Scene>((button_a, state_a): (UiButton, A), (but
fn the_camera() -> impl Scene {
bsn! {
Camera2d
Coordinates
}
}
@ -288,21 +352,33 @@ fn the_ui() -> impl Scene {
}
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,
}
// TODO: Toggle visibility when debugging disabled
pane()
Transform
Children [
pane_header()
on(toggle_debug_ui)
Children [
Text("Debug Info") ThemedText,
],
pane_body()
DebugUi::Visible
ToggleStateVisible
Children [
subpane()
Node {
@ -386,6 +462,182 @@ fn primary_ui() -> impl Scene {
}
}
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,
});
}
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("Quit the game") 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_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),
]
]
}
}
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<ValueChange<f32>>, mut pitch_map: ResMut<PitchMap>| {
if value_change.is_final {
pitch_map.octaves = value_change.value as usize;
}
})
on(slider_self_update),
]
}
}
bsn! {
Node {
display: Display::Flex,
@ -396,133 +648,19 @@ fn primary_ui() -> impl Scene {
pane()
Children [
pane_header()
on(toggle_main_ui)
// todo: toggle main ui
Children [
Text("Conways Game of Life With Sound") ThemedText,
Text("Conways Game of Life With Sound (click here to open control panel)") 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),
],
controls(),
notes(),
octaves(),
description_text(),
]
]
}
@ -587,39 +725,19 @@ impl Coordinates {
}
/// For the purposes of creating assests, what does the given coordinate translate to CellPitch wise
fn to_cell_pitch(&self) -> CellPitch {
let Coordinates { x, y } = self.normalized();
let note = match x {
0 => Note::A,
1 => Note::ASharp,
2 => Note::B,
3 => Note::C,
4 => Note::CSharp,
5 => Note::D,
6 => Note::DSharp,
7 => Note::E,
8 => Note::F,
9 => Note::FSharp,
10 => Note::G,
11 => Note::GSharp,
_ => panic!("This shouldn't happen!"),
};
let octave = y;
CellPitch { note, octave }
}
fn to_cell_pitch(&self, pm: &PitchMap) -> CellPitch {
let rows = pm.notes.len();
let cols = pm.octaves;
// Turn an arbitrary coordinate to a "normalized" coordinate centered on 0,0
fn normalized(&self) -> Self {
println!("normalizing {:?}", (self.x, self.y));
let x = self.x.rem_euclid(12);
let y = self.y.rem_euclid(11);
println!("normalized {:?}", (x,y));
debug_assert!(x >= 0);
debug_assert!(x <= 12);
debug_assert!(y >= 0);
debug_assert!(y <= 11);
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);
Coordinates { x, y }
debug_assert!(this_x >= 0);
let note = pm.notes.get(this_x as usize).unwrap().clone();
let octave = this_y;
CellPitch { note, octave }
}
}
@ -798,24 +916,21 @@ fn new_cell(
let playback_settings = PlaybackSettings::ONCE.with_volume(volume);
let cell_pitch = pitch_map.coordinates_cell_pitch(&c);
let cell_pitch = c.to_cell_pitch(&(*pitch_map));
#[derive(Clone, Default, Component)]
enum Foo {
#[default]
A,
}
debug!("cell pitch: {:#?}", cell_pitch);
debug!("materials: {:#?}", cell_assets.materials);
bsn! {
Cell
Coordinates { x, y }
template_value(cell_pitch)
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(&pitch_map.coordinates_cell_pitch(&c)).unwrap().clone().into()))
template_value(MeshMaterial2dTemplate(cell_assets.materials.get(&cell_pitch).unwrap().clone().into()))
}
}
@ -981,7 +1096,7 @@ impl CellPitch {
]
.into_iter()
.flat_map(|note| {
(0..11).map(move |octave| CellPitch {
(-5..=5).map(move |octave| CellPitch {
note: note.clone(),
octave,
})
@ -1060,6 +1175,15 @@ fn load_meshes(mut cell_assets: ResMut<CellAssets>, mut meshes: ResMut<Assets<Me
cell_assets.mesh = meshes.add(Rectangle::new(SCALE, SCALE));
}
fn default_board(mut writer: MessageWriter<Lifecycle>) {
// https://playgameoflife.com/lexicon/R-pentomino
[(0, 0), (0, -1), (-1, 0), (0, 1), (1, 1)]
.into_iter()
.for_each(|(x, 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(
@ -1143,16 +1267,14 @@ fn manage_interactive<T: Component + States + PartialEq>(
#[derive(Resource, Debug)]
struct PitchMap {
notes: Vec<Note>,
octaves_up: usize,
octaves_down: usize,
octaves: 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,
octaves: 5,
}
}
}
@ -1167,72 +1289,33 @@ impl PitchMap {
// 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 Coordinates { x, y } = c.normalized();
let note: Note = {
let num_notes = self.notes.len();
self.notes.get((x % num_notes as isize) as usize).unwrap().clone()
};
let octave: isize = {
let num_octaves = self.octaves_up + self.octaves_down + 1;
println!("num_octaves: {:?}", num_octaves);
(y % num_octaves as isize) as isize
};
let cp = CellPitch { note, octave };
println!("Cell Pitch: {:?}", cp);
cp
}
}
#[test]
fn test_cell_pitch() {
let pm = PitchMap {
notes: vec![Note::A, Note::B, Note::C],
octaves_up: 1,
octaves_down: 1,
octaves: 3,
};
{
let xs = [-6, -3, 0, 3, 6];
let ys = [-5, -2, 0, 3, 6];
xs.iter().for_each(|x| {
ys.iter().for_each(|y| {
let CellPitch { note, octave } = pm.coordinates_cell_pitch(&Coordinates { x: *x, y: *y });
debug_assert_eq!(note, Note::A);
debug_assert_eq!(octave, 0);
});
});
}
{
let xs = [-5, -2, 1, 4, 7];
let ys = [-4, -1, 1, 4, 7];
xs.iter().for_each(|x| {
ys.iter().for_each(|y| {
let CellPitch { note, octave } = pm.coordinates_cell_pitch(&Coordinates { x: *x, y: *y });
debug_assert_eq!(note, Note::B);
debug_assert_eq!(octave, 1);
});
});
}
{
let xs = [-4, -1, 2, 5, 8];
let ys = [-3, 0, 2, 5, 8];
xs.iter().for_each(|x| {
ys.iter().for_each(|y| {
let CellPitch { note, octave } = pm.coordinates_cell_pitch(&Coordinates { x: *x, y: *y });
debug_assert_eq!(note, Note::C);
debug_assert_eq!(octave, 2);
});
});
}
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 = pitch_map.coordinates_cell_pitch(c);
*cell_pitch = c.to_cell_pitch(&(*pitch_map));
debug!("Updating cell pitch for {:?}", c);
});
}
@ -1321,7 +1404,7 @@ struct ToggleStateVisible;
fn toggle_state_visible<S: States + Component>(
mut q: Query<(&S, &mut Node), With<ToggleStateVisible>>,
c: Res<State<S>>
c: Res<State<S>>,
) {
q.iter_mut().for_each(|(s, mut node)| {
node.display = if s == c.get() {
@ -1331,3 +1414,41 @@ fn toggle_state_visible<S: States + Component>(
};
})
}
fn camera_follow(
mut camera: Query<&mut Coordinates, With<Camera>>,
cells: Query<&Coordinates, (With<Cell>, Without<Camera>)>,
) {
camera.iter_mut().for_each(|mut c| {
let left = cells
.iter()
.map(|Coordinates { x, .. }| *x)
.min()
.unwrap_or(0);
let right = cells
.iter()
.map(|Coordinates { x, .. }| *x)
.max()
.unwrap_or(0);
let top = cells
.iter()
.map(|Coordinates { y, .. }| *y)
.max()
.unwrap_or(0);
let bottom = cells
.iter()
.map(|Coordinates { y, .. }| *y)
.min()
.unwrap_or(0);
c.x = (right + left) / 2;
c.y = (top + bottom) / 2;
});
}
fn update_camera_position(mut query: Query<(&Coordinates, &mut Transform), With<Camera>>) {
query.iter_mut().for_each(|(c, mut t)| {
t.translation = c.as_translation();
});
}

@ -35,8 +35,17 @@ wasm-bindgen \
--out-name "bin" \
"${WASM}"
# wasm-opt size pass
# wasm-opt size pass.
# Enable the wasm features rustc/wasm-bindgen emit (bulk-memory etc.) so
# validation passes, and strip debug/producers sections for size.
wasm-opt -Oz \
--strip-debug --strip-producers \
--enable-bulk-memory \
--enable-nontrapping-float-to-int \
--enable-sign-ext \
--enable-reference-types \
--enable-mutable-globals \
--enable-multivalue \
-o "${DIST}/bin_bg-tmp.wasm" \
"${DIST}/bin_bg.wasm"
mv "${DIST}/bin_bg-tmp.wasm" "${DIST}/bin_bg.wasm"

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET=$1
NAME="$(basename "${TARGET}")"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST="${REPO_ROOT}/dist/${TARGET}"
ARCHIVE="${REPO_ROOT}/dist/${NAME}.tar.zst"
if [[ ! -d "${DIST}" ]]; then
echo "error: nothing built at ${DIST}; run 'just webbuild ${TARGET}' first" >&2
exit 1
fi
# Archive the built web output (files at archive root). The host serves the
# unpacked files, so the archive only affects upload size -- use zstd at its
# max ratio (--ultra -22), which beats zip's DEFLATE substantially on the
# wasm-dominated payload while decompressing far faster than xz.
rm -f "${ARCHIVE}"
tar -C "${DIST}" -cf - . | zstd -q --ultra -22 -c > "${ARCHIVE}"
echo "wrote ${ARCHIVE}"

@ -2,6 +2,41 @@
<html lang="en">
<body style="margin: 0px;">
<script>
// --- Web Audio autoplay unlock -----------------------------------------
// Browsers (Chrome, Safari, mobile Firefox) create every AudioContext in a
// "suspended" state and refuse to start it until the user interacts with
// the page. Bevy -> rodio -> cpal build their AudioContext at startup, long
// before any gesture, so it stays suspended and no sound is ever produced
// ("AudioContext was prevented from starting automatically" in the console).
//
// We wrap the AudioContext constructor to remember every context that gets
// created, then resume them all on the first real user gesture. This must
// run BEFORE the wasm module loads so the wrapper is in place when cpal
// constructs its context.
(function () {
const Native = window.AudioContext || window.webkitAudioContext;
if (!Native) return;
const live = new Set();
function Wrapped(...args) {
const ctx = new Native(...args);
live.add(ctx);
return ctx;
}
Wrapped.prototype = Native.prototype;
window.AudioContext = Wrapped;
window.webkitAudioContext = Wrapped;
function resumeAll() {
for (const ctx of live) {
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
}
}
for (const ev of ['pointerdown', 'mousedown', 'keydown', 'touchstart']) {
window.addEventListener(ev, resumeAll, { capture: true, passive: true });
}
})();
</script>
<script type="module">
import init from './bin.js'

Loading…
Cancel
Save