feat(client_web): sound effects
This commit is contained in:
parent
68ecafd0dc
commit
f2dc81d613
5 changed files with 939 additions and 504 deletions
1210
Cargo.lock
generated
1210
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,14 +9,14 @@ locales = ["en", "fr"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
|
||||||
trictrac-store = { path = "../store" }
|
trictrac-store = { path = "../store" }
|
||||||
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
|
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
|
||||||
leptos = { version = "0.7", features = ["csr"] }
|
leptos = { version = "0.7", features = ["csr"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
gloo-storage = "0.3"
|
gloo-storage = "0.3"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
|
|
@ -24,3 +24,14 @@ gloo-timers = { version = "0.3", features = ["futures"] }
|
||||||
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
||||||
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
||||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"AudioContext",
|
||||||
|
"AudioParam",
|
||||||
|
"AudioNode",
|
||||||
|
"AudioDestinationNode",
|
||||||
|
"AudioScheduledSourceNode",
|
||||||
|
"GainNode",
|
||||||
|
"OscillatorNode",
|
||||||
|
"OscillatorType",
|
||||||
|
"BaseAudioContext",
|
||||||
|
] }
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,15 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
let pending =
|
let pending =
|
||||||
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
|
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
|
||||||
let cmd_tx_effect = cmd_tx.clone();
|
let cmd_tx_effect = cmd_tx.clone();
|
||||||
Effect::new(move |_| {
|
// Tracks staged_moves length across Effect runs so we can detect additions.
|
||||||
|
Effect::new(move |prev_len: Option<usize>| {
|
||||||
let moves = staged_moves.get();
|
let moves = staged_moves.get();
|
||||||
if moves.len() == 2 {
|
let n = moves.len();
|
||||||
|
// Play checker sound whenever a move is added (own moves, immediate feedback).
|
||||||
|
if prev_len.map_or(false, |p| n > p) {
|
||||||
|
crate::sound::play_checker_move();
|
||||||
|
}
|
||||||
|
if n == 2 {
|
||||||
let to_cm = |&(from, to): &(u8, u8)| {
|
let to_cm = |&(from, to): &(u8, u8)| {
|
||||||
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
|
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
@ -55,6 +61,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
staged_moves.set(vec![]);
|
staged_moves.set(vec![]);
|
||||||
selected_origin.set(None);
|
selected_origin.set(None);
|
||||||
}
|
}
|
||||||
|
n
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Auto-roll effect ─────────────────────────────────────────────────────
|
// ── Auto-roll effect ─────────────────────────────────────────────────────
|
||||||
|
|
@ -162,6 +169,24 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
fields
|
fields
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Sound effects (fire once on mount = once per state snapshot) ──────────
|
||||||
|
// Dice roll: dice just appeared (no preceding moves in this snapshot).
|
||||||
|
if show_dice && last_moves.is_none() {
|
||||||
|
crate::sound::play_dice_roll_cinematic();
|
||||||
|
}
|
||||||
|
// Checker move: moves were committed in the preceding action.
|
||||||
|
if last_moves.is_some() {
|
||||||
|
crate::sound::play_checker_move();
|
||||||
|
}
|
||||||
|
// Scoring: hole takes priority over plain points.
|
||||||
|
if let Some(ref ev) = my_scored_event {
|
||||||
|
if ev.holes_gained > 0 {
|
||||||
|
crate::sound::play_hole_scored();
|
||||||
|
} else {
|
||||||
|
crate::sound::play_points_scored();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Capture for closures ───────────────────────────────────────────────────
|
// ── Capture for closures ───────────────────────────────────────────────────
|
||||||
let stage = vs.stage.clone();
|
let stage = vs.stage.clone();
|
||||||
let turn_stage = vs.turn_stage.clone();
|
let turn_stage = vs.turn_stage.clone();
|
||||||
|
|
@ -322,10 +347,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
|
||||||
// ── Game-over overlay ─────────────────────────────────────────────
|
// ── Game-over overlay ─────────────────────────────────────────────
|
||||||
{stage_is_ended.then(|| {
|
{stage_is_ended.then(|| {
|
||||||
let winner_text = if winner_is_me {
|
let opp_name_end_clone = opp_name_end.clone();
|
||||||
|
let winner_text = move || if winner_is_me {
|
||||||
t_string!(i18n, you_win).to_owned()
|
t_string!(i18n, you_win).to_owned()
|
||||||
} else {
|
} else {
|
||||||
t_string!(i18n, opp_wins, name = opp_name_end.as_str())
|
t_string!(i18n, opp_wins, name = opp_name_end_clone.as_str())
|
||||||
};
|
};
|
||||||
view! {
|
view! {
|
||||||
<div class="game-over-overlay">
|
<div class="game-over-overlay">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ leptos_i18n::load_locales!();
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod sound;
|
||||||
mod trictrac;
|
mod trictrac;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
|
|
||||||
171
client_web/src/sound.rs
Normal file
171
client_web/src/sound.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
//! Synthesised sound effects using the Web Audio API.
|
||||||
|
//!
|
||||||
|
//! All public functions are no-ops on non-WASM targets so callers need no
|
||||||
|
//! `#[cfg]` guards themselves.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod inner {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use web_sys::{AudioContext, OscillatorType};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CTX: RefCell<Option<AudioContext>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_ctx<F: FnOnce(&AudioContext)>(f: F) {
|
||||||
|
CTX.with(|cell| {
|
||||||
|
let mut opt = cell.borrow_mut();
|
||||||
|
if opt.is_none() {
|
||||||
|
*opt = AudioContext::new().ok();
|
||||||
|
}
|
||||||
|
if let Some(ctx) = opt.as_ref() {
|
||||||
|
f(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule a single oscillator tone with an exponential gain decay.
|
||||||
|
///
|
||||||
|
/// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
|
||||||
|
/// - `duration`: how long (in seconds) until gain reaches ~0
|
||||||
|
fn play_tone(
|
||||||
|
ctx: &AudioContext,
|
||||||
|
freq: f32,
|
||||||
|
gain: f32,
|
||||||
|
duration: f64,
|
||||||
|
start_offset: f64,
|
||||||
|
wave: OscillatorType,
|
||||||
|
) {
|
||||||
|
let t0 = ctx.current_time() + start_offset;
|
||||||
|
let t1 = t0 + duration;
|
||||||
|
|
||||||
|
let Ok(osc) = ctx.create_oscillator() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(gain_node) = ctx.create_gain() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
osc.set_type(wave);
|
||||||
|
osc.frequency().set_value(freq);
|
||||||
|
|
||||||
|
let gain_param = gain_node.gain();
|
||||||
|
let _ = gain_param.set_value_at_time(gain, t0);
|
||||||
|
// exponential_ramp requires a positive target; 0.001 is inaudible
|
||||||
|
let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
|
||||||
|
|
||||||
|
let dest = ctx.destination();
|
||||||
|
let _ = osc.connect_with_audio_node(&gain_node);
|
||||||
|
let _ = gain_node.connect_with_audio_node(&dest);
|
||||||
|
|
||||||
|
let _ = osc.start_with_when(t0);
|
||||||
|
let _ = osc.stop_with_when(t1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
|
||||||
|
pub fn play_checker_move() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
// Sine at 300 Hz for the clean attack click
|
||||||
|
play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
|
||||||
|
// Triangle at 150 Hz for the woody body resonance
|
||||||
|
play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
|
||||||
|
// Sub at 80 Hz for weight
|
||||||
|
play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
|
||||||
|
///
|
||||||
|
/// Two layers:
|
||||||
|
/// - A dense series of detuned sawtooth bursts that thin out over time,
|
||||||
|
/// modelling the continuous scrape/rattle of dice tumbling.
|
||||||
|
/// - Five percussive impacts (square clicks + triangle thuds) whose
|
||||||
|
/// inter-arrival gap shrinks as the dice decelerate and settle.
|
||||||
|
pub fn play_dice_roll_cinematic() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
// ── Continuous rolling texture ─────────────────────────────────
|
||||||
|
// 16 steps over 440 ms; each step is two detuned sawtooth waves
|
||||||
|
// (the interference between them produces a noise-like texture).
|
||||||
|
// Gain fades by ~55 % from first to last step.
|
||||||
|
const N: u32 = 16;
|
||||||
|
for i in 0..N {
|
||||||
|
let t = i as f64 * 0.028;
|
||||||
|
let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
|
||||||
|
// Quasi-random frequencies so each step sounds different.
|
||||||
|
let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
|
||||||
|
let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
|
||||||
|
play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
|
||||||
|
play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impact transients ──────────────────────────────────────────
|
||||||
|
// Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
|
||||||
|
// mimicking dice decelerating and settling.
|
||||||
|
let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
|
||||||
|
for &(t_off, amp) in impacts {
|
||||||
|
// Hard click: bright square partials → percussive attack
|
||||||
|
for &freq in &[700.0f32, 1_050.0, 1_500.0] {
|
||||||
|
play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
|
||||||
|
}
|
||||||
|
// Woody body thud: two low triangle partials
|
||||||
|
play_tone(
|
||||||
|
ctx,
|
||||||
|
130.0,
|
||||||
|
amp * 0.05,
|
||||||
|
0.070,
|
||||||
|
t_off,
|
||||||
|
OscillatorType::Triangle,
|
||||||
|
);
|
||||||
|
play_tone(
|
||||||
|
ctx,
|
||||||
|
68.0,
|
||||||
|
amp * 0.07,
|
||||||
|
0.090,
|
||||||
|
t_off,
|
||||||
|
OscillatorType::Triangle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ascending three-note chime (C5 – E5 – G5).
|
||||||
|
pub fn play_points_scored() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
|
||||||
|
for (freq, offset) in notes {
|
||||||
|
play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
|
||||||
|
pub fn play_hole_scored() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
let notes: [(f32, f64, f64); 4] = [
|
||||||
|
(523.25, 0.0, 0.35),
|
||||||
|
(659.25, 0.17, 0.35),
|
||||||
|
(783.99, 0.34, 0.35),
|
||||||
|
(1046.5, 0.51, 0.55),
|
||||||
|
];
|
||||||
|
for (freq, offset, dur) in notes {
|
||||||
|
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use inner::{
|
||||||
|
play_checker_move, play_dice_roll_cinematic, play_hole_scored, play_points_scored,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_checker_move() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_dice_roll_cinematic() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_points_scored() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_hole_scored() {}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue