mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Game audio: ambience, water, rolling, trick cues, footsteps, settings
New crates/skate-game/src/game_audio/ module (Bevy audio): - Loudness rules: master 25 % by default (settings/audio.json, Master/ Ambience/Effects rows in the GRAPHICS menu), --mute; a clip may be raised only to its own full-scale peak (x4 max); fades, voice and repeat limits; silent while the menu is open or a replay runs. Mod audio follows the master volume and shares the one listener. - Per-map ambience beds; water emitters on the map's water bodies (fountains: moving water, canals/lakes: lapping). - Rolling: grain speed bands by board speed and the wheels' audio surface (new WheelLineState::audio_surfaces in skate-core, additive), restarted cleanly after leaving the ground. - Cues found from physics/animation state each tick: pop, flip/grab (new trick only), landing (+ weight layer by impact), board-down, grind (metal vs other surfaces), powerslide and foot drag (shuffled), push, bail tiers, splash (retail patch record 869, played with its layers), footsteps from foot strikes. Sample picks confirmed by ear; each matches a retail SPLC patch record. - AUDIO_CUE / AUDIO_LOOP / AUDIO_EVENT log lines; SKATE_AUDIO_TRACE=1. - Read-only helpers: physics::foot_clearance, an animation-event latch in AnimationInput (physics never reads it). - skate-data example audio_surfaces (per-map audio IDs, AT= point query).
This commit is contained in:
19 files changed
+2000
-27
No files matched your search
@@ -46,6 +46,9 @@ pub struct WheelLineState {
|
||||
pub normals: [Vector3; 4],
|
||||
pub distances: [f32; 4],
|
||||
pub physics_surfaces: [u32; 4],
|
||||
/// Audio material (surface tag bits 0..6, `& 0x7F`) per wheel, 0 on a miss. Not
|
||||
/// part of 82C079E0; kept for the engine's rolling sound.
|
||||
pub audio_surfaces: [u32; 4],
|
||||
pub minimum_distance: f32,
|
||||
}
|
||||
impl Default for WheelLineState {
|
||||
@@ -55,6 +58,7 @@ impl Default for WheelLineState {
|
||||
normals: [UP; 4],
|
||||
distances: [0.0; 4],
|
||||
physics_surfaces: [0; 4],
|
||||
audio_surfaces: [0; 4],
|
||||
minimum_distance: 0.0,
|
||||
}
|
||||
}
|
||||
@@ -66,6 +70,7 @@ impl WheelLineState {
|
||||
self.minimum_distance = WHEEL_LINE_LENGTH;
|
||||
for (i, hit) in hits.into_iter().enumerate() {
|
||||
self.physics_surfaces[i] = 0;
|
||||
self.audio_surfaces[i] = 0;
|
||||
if let Some(hit) = hit {
|
||||
let distance = hit.fraction * WHEEL_LINE_LENGTH;
|
||||
self.minimum_distance = if distance - self.minimum_distance >= -0.0 {
|
||||
@@ -76,6 +81,7 @@ impl WheelLineState {
|
||||
self.normals[i] = hit.normal;
|
||||
self.distances[i] = distance;
|
||||
self.physics_surfaces[i] = (hit.surface_tag >> 7) & 31;
|
||||
self.audio_surfaces[i] = hit.surface_tag & 0x7F;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,3 +156,15 @@ fn opposing_contacts_use_deck_projection_range_and_reset_without_deck_contacts()
|
||||
state.update(&reports[2..], &lines, UP, 80.0, false);
|
||||
assert_eq!(state.opposing_contact, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_lines_keep_audio_and_physics_bits_of_the_same_tag() {
|
||||
let mut lines = WheelLineState::default();
|
||||
let hit = |tag| Some(WheelLineHit { fraction: 0.5, normal: UP, surface_tag: tag });
|
||||
// Audio bits 0..7, physics type bits 7..12 (water 1591 = audio 55, physics 12).
|
||||
lines.publish([hit(1591), hit(3 << 7 | 9), None, hit(0)]);
|
||||
assert_eq!(lines.physics_surfaces, [12, 3, 0, 0]);
|
||||
assert_eq!(lines.audio_surfaces, [55, 9, 0, 0]);
|
||||
lines.publish([None; 4]);
|
||||
assert_eq!(lines.audio_surfaces, [0; 4]);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Lists the audio material IDs (`surface & 0x7f`) used by retail collision in
|
||||
//! SKATE maps, to label them for rolling/grind sounds (game_audio). For each ID:
|
||||
//! triangle count, upward-facing area, the physics types (`(surface >> 7) & 31`)
|
||||
//! it appears with, and the albedo textures of the rendered geometry found at
|
||||
//! sample points on it.
|
||||
//! Usage: cargo run -p skate-data --example audio_surfaces -- <map.skate>...
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
const SAMPLES: usize = 12;
|
||||
const CELL: f32 = 4.0;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Audio {
|
||||
triangles: usize,
|
||||
up_area: f32,
|
||||
physics: BTreeSet<u16>,
|
||||
samples: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// Render triangles bucketed on the XZ plane.
|
||||
struct Grid {
|
||||
cells: HashMap<(i32, i32), Vec<u32>>,
|
||||
}
|
||||
impl Grid {
|
||||
fn new(map: &skate_data::skate_map::SkateMap) -> Self {
|
||||
let mut cells = HashMap::<(i32, i32), Vec<u32>>::new();
|
||||
for (index, tri) in map.geometry.indices.chunks_exact(3).enumerate() {
|
||||
let p: [[f32; 3]; 3] = std::array::from_fn(|k| map.geometry.vertices[tri[k] as usize].position);
|
||||
let (x0, x1) = (p.iter().map(|v| v[0]).fold(f32::MAX, f32::min), p.iter().map(|v| v[0]).fold(f32::MIN, f32::max));
|
||||
let (z0, z1) = (p.iter().map(|v| v[2]).fold(f32::MAX, f32::min), p.iter().map(|v| v[2]).fold(f32::MIN, f32::max));
|
||||
if x1 - x0 > 200. || z1 - z0 > 200. {
|
||||
continue; // sky/backdrop sheets
|
||||
}
|
||||
for cx in (x0 / CELL).floor() as i32..=(x1 / CELL).floor() as i32 {
|
||||
for cz in (z0 / CELL).floor() as i32..=(z1 / CELL).floor() as i32 {
|
||||
cells.entry((cx, cz)).or_default().push(index as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { cells }
|
||||
}
|
||||
|
||||
/// Material of the render triangle under/at `point` (within 0.4 m vertically).
|
||||
fn material_at(&self, map: &skate_data::skate_map::SkateMap, point: [f32; 3]) -> Option<u32> {
|
||||
let key = ((point[0] / CELL).floor() as i32, (point[2] / CELL).floor() as i32);
|
||||
let mut best: Option<(f32, u32)> = None;
|
||||
for &index in self.cells.get(&key)? {
|
||||
let tri = &map.geometry.indices[index as usize * 3..index as usize * 3 + 3];
|
||||
let v = [0, 1, 2].map(|k| &map.geometry.vertices[tri[k] as usize]);
|
||||
let [a, b, c] = v.map(|v| v.position);
|
||||
let (x, z) = (point[0], point[2]);
|
||||
let det = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
|
||||
if det.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let l1 = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / det;
|
||||
let l2 = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / det;
|
||||
let l3 = 1. - l1 - l2;
|
||||
if l1 < 0. || l2 < 0. || l3 < 0. {
|
||||
continue;
|
||||
}
|
||||
let y = l1 * a[1] + l2 * b[1] + l3 * c[1];
|
||||
let dy = (y - point[1]).abs();
|
||||
if dy < 0.4 && best.is_none_or(|(d, _)| dy < d) {
|
||||
best = Some((dy, v[0].material));
|
||||
}
|
||||
}
|
||||
best.map(|(_, m)| m)
|
||||
}
|
||||
}
|
||||
|
||||
fn texture_name(map: &skate_data::skate_map::SkateMap, material: u32) -> String {
|
||||
let Some(m) = map.materials.get(material as usize - 1) else { return "?".into() };
|
||||
let albedo = m.textures[0];
|
||||
if albedo == 0 {
|
||||
return format!("({})", m.name);
|
||||
}
|
||||
map.textures.get(albedo as usize - 1).map_or_else(|| m.name.clone(), |t| t.name.clone())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut failed = false;
|
||||
for path in std::env::args_os().skip(1) {
|
||||
let map = match skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) {
|
||||
Ok(map) => map,
|
||||
Err(e) => {
|
||||
eprintln!("{}: {e}", path.to_string_lossy());
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(archive) = map.extensions.iter().find(|e| e.tag == *b"RWCM") else {
|
||||
println!("{}: no RWCM collision", map.name);
|
||||
continue;
|
||||
};
|
||||
// AT=x,y,z[,r]: collision surfaces within r (default 3) m of a point.
|
||||
if let Ok(at) = std::env::var("AT") {
|
||||
let v: Vec<f32> = at.split(',').filter_map(|s| s.trim().parse().ok()).collect();
|
||||
if let [x, y, z, ref rest @ ..] = v[..] {
|
||||
let r = rest.first().copied().unwrap_or(3.0);
|
||||
let mut near = BTreeMap::<(u16, u16), (usize, f32, [usize; 3])>::new();
|
||||
let _ = skate_data::retail_collision::visit_clusters(&archive.payload, |_, cluster| {
|
||||
for t in cluster {
|
||||
let c: [f32; 3] = std::array::from_fn(|a| t.points.iter().map(|p| p[a]).sum::<f32>() / 3.);
|
||||
let d = ((c[0] - x).powi(2) + (c[1] - y).powi(2) + (c[2] - z).powi(2)).sqrt();
|
||||
if d < r {
|
||||
let e = near.entry((t.surface & 0x7F, (t.surface >> 7) & 31)).or_insert((0, f32::MAX, [0usize; 3]));
|
||||
e.0 += 1;
|
||||
e.1 = e.1.min(d);
|
||||
// Slope: 0 flat (< 20°), 1 ramp (20-70°), 2 wall (> 70°).
|
||||
let n = cross(sub(t.points[1], t.points[0]), sub(t.points[2], t.points[0]));
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-9);
|
||||
let angle = (n[1].abs() / len).clamp(0.0, 1.0).acos().to_degrees();
|
||||
e.2[if angle < 20.0 { 0 } else if angle < 70.0 { 1 } else { 2 }] += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
println!("{}: surfaces within {r} m of ({x}, {y}, {z})", map.name);
|
||||
for ((audio, physics), (n, d, slopes)) in near {
|
||||
println!(" audio {audio:3} physics {physics:2}: {n:4} tris, nearest {d:.2} m, flat/ramp/wall {slopes:?}");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut ids = BTreeMap::<u16, Audio>::new();
|
||||
let result = skate_data::retail_collision::visit_clusters(&archive.payload, |_, cluster| {
|
||||
for t in cluster {
|
||||
let entry = ids.entry(t.surface & 0x7F).or_default();
|
||||
entry.triangles += 1;
|
||||
entry.physics.insert((t.surface >> 7) & 31);
|
||||
let n = cross(sub(t.points[1], t.points[0]), sub(t.points[2], t.points[0]));
|
||||
let area = 0.5 * (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
if area > 0. && n[1].abs() / (2. * area) > 0.7 {
|
||||
entry.up_area += area;
|
||||
// Keep a spread of samples: every k-th upward triangle.
|
||||
if entry.samples.len() < SAMPLES * 8 {
|
||||
entry.samples.push(std::array::from_fn(|a| t.points.iter().map(|p| p[a]).sum::<f32>() / 3.));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = result {
|
||||
eprintln!("{}: {e}", map.name);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
let grid = Grid::new(&map);
|
||||
println!("== {} ({} audio IDs)", map.name, ids.len());
|
||||
for (id, audio) in &ids {
|
||||
let mut textures = BTreeMap::<String, usize>::new();
|
||||
let step = (audio.samples.len() / SAMPLES).max(1);
|
||||
for point in audio.samples.iter().step_by(step) {
|
||||
if let Some(material) = grid.material_at(&map, *point) {
|
||||
*textures.entry(texture_name(&map, material)).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut top: Vec<_> = textures.into_iter().collect();
|
||||
top.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let top: Vec<String> = top.iter().take(4).map(|(name, n)| format!("{name} x{n}")).collect();
|
||||
println!(
|
||||
" audio {id:3}: {:7} tris, {:9.0} m2 up, physics {:?}, at: {}",
|
||||
audio.triangles, audio.up_area, audio.physics, top.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,7 @@ pub(crate) fn build(
|
||||
app.add_plugins(crate::scoring_hud::ScoringHudPlugin);
|
||||
app.add_plugins(crate::debug_cam::DebugCamPlugin);
|
||||
app.add_plugins(crate::water_splash::WaterSplashPlugin);
|
||||
app.add_plugins(crate::game_audio::GameAudioPlugin);
|
||||
app.add_systems(Last, crate::crash_context::sample);
|
||||
crate::profiling::install(&mut app);
|
||||
app
|
||||
|
||||
@@ -15,6 +15,8 @@ pub(crate) struct Config {
|
||||
pub multiplayer: crate::multiplayer::Options,
|
||||
pub map_fingerprint: u64,
|
||||
pub teleport: Option<String>,
|
||||
/// `--mute`: no game or mod audio (game_audio).
|
||||
pub mute: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -31,6 +33,7 @@ impl Config {
|
||||
multiplayer: crate::multiplayer::Options::default(),
|
||||
map_fingerprint: 0,
|
||||
teleport: None,
|
||||
mute: false,
|
||||
};
|
||||
let mut difficulty_override = None;
|
||||
let mut explicit_map = false;
|
||||
@@ -76,6 +79,7 @@ impl Config {
|
||||
// Maps arrive on stdin; never load the saved default map.
|
||||
Some("--validate-maps") => { config.validate_maps = true; explicit_map = true; }
|
||||
Some("--start-paused") => config.start_paused = true,
|
||||
Some("--mute") => config.mute = true,
|
||||
Some("--teleport") => config.teleport = Some(args.next().ok_or("--teleport requires a destination ID")?.to_string_lossy().into_owned()),
|
||||
Some("--difficulty") => {
|
||||
let value = args.next().ok_or("--difficulty requires easy, normal, hardcore, motorized or custom")?;
|
||||
@@ -90,7 +94,7 @@ impl Config {
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore|motorized|custom] [--verify CAPTURE.png] [--check-assets | --validate-maps] [--start-paused]"
|
||||
"Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore|motorized|custom] [--verify CAPTURE.png] [--check-assets | --validate-maps] [--start-paused] [--mute]"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! One ambience bed per map, cross-faded on map change. Retail switches beds by
|
||||
//! zone (per-map `.ems` emitter files); here each map gets the bed that fits
|
||||
//! it best. The choices are by name and ear, not from retail data
|
||||
//! (docs/hails-additions/11-audio.md).
|
||||
use super::{Category, Library, Play, Voices, library::Clip, voices::VoiceId};
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Bed level before the ambience and master volumes.
|
||||
const LEVEL: f32 = 0.6;
|
||||
const CROSSFADE: f32 = 2.0;
|
||||
|
||||
/// Map name (maps.json / CurrentMap) -> ambience bed. Unlisted maps are silent.
|
||||
const BEDS: &[(&str, &str)] = &[
|
||||
("University", "09_univ_campus"),
|
||||
("StartPark", "10_univ_housing"),
|
||||
("DownTown", "04_dt_main"),
|
||||
("DownTownSkatePark", "05_dt_parks"),
|
||||
("MegaPark", "05_dt_parks"),
|
||||
("Industrial", "11_indu_shipyard"),
|
||||
("IndustrialSkatePark", "20_indu_old_factory"),
|
||||
("SkateSchool", "18_skate_school"),
|
||||
("MaloofMoneyCup", "21_interior_arena_amb"),
|
||||
("BlackBoxPark", "22_interior_tunnel_amb"),
|
||||
];
|
||||
|
||||
pub(super) fn bed_for(map: &str) -> Option<&'static str> {
|
||||
BEDS.iter().find(|(name, _)| name.eq_ignore_ascii_case(map)).map(|(_, bed)| *bed)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
map: Option<(String, u64)>,
|
||||
current: Option<(VoiceId, Clip)>,
|
||||
fading: Vec<Clip>,
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
mut state: Local<State>,
|
||||
mut commands: Commands,
|
||||
map: Res<crate::map_transition::CurrentMap>,
|
||||
library: Option<ResMut<Library>>,
|
||||
mut voices: ResMut<Voices>,
|
||||
mut assets: ResMut<Assets<AudioSource>>,
|
||||
time: Res<Time<Real>>,
|
||||
) {
|
||||
let Some(mut library) = library else { return };
|
||||
// Free beds whose fade-out has finished.
|
||||
let fading = std::mem::take(&mut state.fading);
|
||||
for clip in fading {
|
||||
if voices.uses(&clip) { state.fading.push(clip); } else { library.release(&mut assets, &clip); }
|
||||
}
|
||||
let identity = (map.name.clone(), map.generation);
|
||||
if state.map.as_ref() == Some(&identity) {
|
||||
return;
|
||||
}
|
||||
state.map = Some(identity);
|
||||
let bed = bed_for(&map.name);
|
||||
if let Some((voice, clip)) = state.current.take() {
|
||||
// Released only once no voice uses it (a reload may restart the same bed).
|
||||
voices.stop(voice, CROSSFADE);
|
||||
state.fading.push(clip);
|
||||
}
|
||||
let Some(bed) = bed else {
|
||||
info!("Ambience: none for map {:?}", map.name);
|
||||
return;
|
||||
};
|
||||
let Some(clip) = library.ambience(&mut assets, bed) else { return };
|
||||
let play = Play { category: Category::Ambience, volume: LEVEL, pitch: 1.0, position: None, looping: true, fade_in: CROSSFADE };
|
||||
match voices.play(&mut commands, &clip, play, time.elapsed_secs_f64()) {
|
||||
Some(voice) => {
|
||||
info!("Ambience: {bed} for map {:?}", map.name);
|
||||
state.current = Some((voice, clip));
|
||||
}
|
||||
None => state.fading.push(clip),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_installed_map_has_a_bed_from_the_export_list() {
|
||||
for map in ["University", "BlackBoxPark", "DownTown", "DownTownSkatePark", "Industrial",
|
||||
"IndustrialSkatePark", "MaloofMoneyCup", "MegaPark", "SkateSchool", "StartPark"] {
|
||||
assert!(bed_for(map).is_some(), "{map}");
|
||||
}
|
||||
assert_eq!(bed_for("university"), Some("09_univ_campus"));
|
||||
assert_eq!(bed_for("Test world"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Which decoded retail samples play for which gameplay cue. Retail picks them
|
||||
//! through compiled AEMS event scripts we do not run; these picks are our own,
|
||||
//! made from the decoded banks by duration/attack/brightness and then by ear
|
||||
//! (docs/hails-additions/11-audio.md lists the candidates and how to audition
|
||||
//! them). Bank names and indices refer to assets/private/audio/audio_manifest.json.
|
||||
//!
|
||||
//! Levels are linear and deliberately low: many raw samples peak at 0 dBFS
|
||||
//! (retail mixes them down at runtime). Final gain = level x effects x master.
|
||||
|
||||
/// A set of interchangeable samples: one is picked at random per play.
|
||||
pub(super) struct Cue {
|
||||
pub bank: &'static str,
|
||||
pub samples: &'static [usize],
|
||||
pub level: f32,
|
||||
}
|
||||
|
||||
macro_rules! range {
|
||||
($a:literal..=$b:literal) => {{
|
||||
const N: usize = $b - $a + 1;
|
||||
const R: [usize; N] = {
|
||||
let mut out = [0; N];
|
||||
let mut i = 0;
|
||||
while i < N {
|
||||
out[i] = $a + i;
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
};
|
||||
&R
|
||||
}};
|
||||
}
|
||||
|
||||
/// Skate_Collisions 1066-1092 were picked out by ear as "heard a lot while
|
||||
/// skating": short knocks with most energy at 400-2500 Hz (the wooden deck),
|
||||
/// unlike the low thumps on either side (1060-1065, 1093-1100).
|
||||
/// Ollie / nollie pop (tail strike): the sharpest, shortest of them.
|
||||
pub(super) const POP: Cue = Cue { bank: "Skate_Collisions", samples: range!(1074..=1078), level: 0.45 };
|
||||
/// Board flip/spin whoosh when a trick registers in the air.
|
||||
pub(super) const FLIP: Cue = Cue { bank: "Sk8_Air_Flip_Tricks", samples: range!(10..=13), level: 0.3 };
|
||||
/// Landing (scaled by impact speed): the longer deck slaps of that family.
|
||||
/// (Rejected before: 35-40 harsh; 0-2 is kept for setting the board down.)
|
||||
pub(super) const LAND: Cue = Cue { bank: "Skate_Collisions", samples: range!(1088..=1092), level: 0.9 };
|
||||
/// Extra weight for hard landings and jumping onto the board: the low thumps
|
||||
/// right after the deck family (trucks/wheels), replacing the guessed 18-20.
|
||||
pub(super) const LAND_HEAVY: Cue = Cue { bank: "Skate_Collisions", samples: range!(1097..=1100), level: 1.0 };
|
||||
/// Grind/slide loop on rails and ledges.
|
||||
/// GRINDS holds every material's grind loops (all 1.4-2.6 s). 9 and 11 (noisy,
|
||||
/// low tonality) sounded like wood on a metal rail in play: they stay for
|
||||
/// ledges and other non-metal surfaces.
|
||||
pub(super) const GRIND: Cue = Cue { bank: "GRINDS", samples: &[9, 11], level: 0.3 };
|
||||
/// Metal rails: 54-56 are the most tonal, brightest (4-5.6 kHz) loops in the
|
||||
/// bank — a ringing metal scrape. Picked by measurement; not yet confirmed by ear.
|
||||
pub(super) const GRIND_METAL: Cue = Cue { bank: "GRINDS", samples: range!(54..=56), level: 0.3 };
|
||||
|
||||
/// Whether a grind's audio surface (`grinds.audio_surface_216`) is metal:
|
||||
/// Metal (9), the solid/hollow/square/complex metal shapes (11-40), manhole
|
||||
/// and grates (67-69), DMORail (85), Metal_Rail_4 (89), Metal_Ramp (91).
|
||||
pub(super) fn metal(audio_surface: u32) -> bool {
|
||||
matches!(audio_surface, 9 | 11..=40 | 67..=69 | 85 | 89 | 91)
|
||||
}
|
||||
/// Powerslide wheel skid: short pieces played back to back ("shuffle").
|
||||
pub(super) const POWERSLIDE: Cue = Cue { bank: "WHEEL_SKID_BANK", samples: range!(0..=7), level: 0.2 };
|
||||
/// Foot dragging to brake: ~0.2 s scuffs (0.1 s ones sounded choppy), overlapped.
|
||||
pub(super) const FOOT_DRAG: Cue = Cue { bank: "FOOT_DRAG", samples: &[36, 37, 38, 39, 44, 45, 46, 47, 60, 61, 62, 63, 64], level: 0.25 };
|
||||
/// (seconds between shuffled pieces, fade-in of each piece).
|
||||
pub(super) const POWERSLIDE_SHUFFLE: (f32, f32) = (0.08, 0.01);
|
||||
pub(super) const FOOT_DRAG_SHUFFLE: (f32, f32) = (0.11, 0.05);
|
||||
/// Putting the board down: wheels touching after stepping on (softer, shorter impacts).
|
||||
pub(super) const BOARD_DOWN: Cue = Cue { bank: "Skate_Collisions", samples: range!(0..=2), level: 0.3 };
|
||||
/// Bail: body sliding/hitting the ground.
|
||||
/// Bodyslide 0-15 comes in three blocks of five — one longer slide, four
|
||||
/// hits — taken as soft/medium/hard tiers; 15 joins the hard tier. In play
|
||||
/// they sound like additions to a fall, not the fall itself, so they are a
|
||||
/// quieter layer until a main body-impact sound is picked
|
||||
/// (`.local/audio-audition/bail.html`). `bail_tier` picks by impact speed.
|
||||
pub(super) const BAIL: Cue = Cue { bank: "Bodyslide", samples: range!(0..=15), level: 0.25 };
|
||||
pub(super) const BAIL_TIERS: [&[usize]; 3] = [range!(0..=4), range!(5..=9), range!(10..=15)];
|
||||
/// Body speed (m/s) at the start of a bail for the medium and hard tiers.
|
||||
pub(super) fn bail_tier(speed: f32) -> usize {
|
||||
if speed >= 7.0 { 2 } else if speed >= 3.5 { 1 } else { 0 }
|
||||
}
|
||||
/// A retail patch to play as its data describes (layers, probabilities, gain
|
||||
/// ranges): record `id` of an SPLC bank, or a container (id >= record count).
|
||||
pub(super) struct Record {
|
||||
pub bank: &'static str,
|
||||
pub id: usize,
|
||||
pub level: f32,
|
||||
}
|
||||
|
||||
/// Falling into water. The user found Skate_Collisions 476 by ear; the bank's
|
||||
/// patch tree puts 476-478 in record 869 with two more layers: 169 (played 75 %
|
||||
/// of the time) and one of 171/172 — retail's full splash. Level follows the
|
||||
/// fall speed. (Rejected: water_misc 0/4, Skate_Collisions 741-762, 473-475.)
|
||||
pub(super) const SPLASH: Record = Record { bank: "Skate_Collisions", id: 869, level: 0.5 };
|
||||
/// Samples of SPLASH, for preloading.
|
||||
pub(super) const SPLASH_SAMPLES: Cue = Cue { bank: "Skate_Collisions", samples: &[169, 171, 172, 476, 477, 478], level: 0.5 };
|
||||
/// Pushing: foot hitting the ground.
|
||||
pub(super) const PUSH: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(74..=84), level: 0.6 };
|
||||
/// On-foot steps (walk) and heavier steps (run, landing on foot).
|
||||
// These recordings peak ~11 dB below full scale and average ~28 dB below, so
|
||||
// they are raised (the voice pool caps each clip at its own full-scale peak).
|
||||
pub(super) const STEP: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(74..=84), level: 0.85 };
|
||||
pub(super) const RUN_STEP: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(6..=17), level: 0.68 };
|
||||
|
||||
/// Every one-shot/loop cue, for preloading.
|
||||
pub(super) const ALL: &[&Cue] = &[&POP, &FLIP, &LAND, &LAND_HEAVY, &BOARD_DOWN, &GRIND, &GRIND_METAL, &POWERSLIDE, &FOOT_DRAG, &BAIL, &SPLASH_SAMPLES, &PUSH, &STEP, &RUN_STEP];
|
||||
|
||||
/// Shortest time between two plays of one cue (s): a safety net against
|
||||
/// state flags that stay set for many ticks.
|
||||
pub(super) fn min_gap(cue: &str) -> f32 {
|
||||
match cue {
|
||||
"step" => 0.2,
|
||||
"run_step" => 0.15,
|
||||
"push" => 0.3,
|
||||
"bail" => 0.5,
|
||||
"splash" => 1.0,
|
||||
_ => 0.12,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling loop level at full speed.
|
||||
pub(super) const ROLL_LEVEL: f32 = 0.15;
|
||||
/// Wheel spin after leaving the ground.
|
||||
pub(super) const AIR_WHEELS: (&str, f32) = ("Whls_spins_Jump_1", 0.15);
|
||||
|
||||
/// Rolling grain for an audio material ID (low 7 bits of the collision
|
||||
/// surface tag; names from the map tooling's audio surface table). Only the
|
||||
/// "hard" grains exist for every material, so those are used.
|
||||
pub(super) fn grain_for(audio_surface: u32) -> &'static str {
|
||||
match audio_surface {
|
||||
1 => "asphalt_smooth_hard",
|
||||
2 => "asphalt_rough_hard",
|
||||
4 | 66 => "concrete_rough_hard", // Concrete_Rough, Brick_Coarse
|
||||
5 | 8 | 10 | 55 | 56 => "concrete_aggregate_hard", // Aggregate, Dirt, Grass, Leaves, Bush
|
||||
6 | 7 | 41..=46 | 90 => "wood_ramp_hard", // Wood_Ramp, Plywood, Wood_*
|
||||
9 | 11..=40 | 67..=69 | 85 | 89 | 91 => "metal_smooth_hard", // Metal_*, grates, rails, Metal_Ramp
|
||||
// Concrete_Polished (the common default), curbs, benches, tile, marble,
|
||||
// smooth brick and anything unlisted.
|
||||
_ => "concrete_smooth_hard",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cue_ranges_and_levels_are_sane() {
|
||||
assert_eq!(POP.samples, &[1074, 1075, 1076, 1077, 1078]);
|
||||
assert_eq!(STEP.samples.len(), 11);
|
||||
for cue in [&POP, &FLIP, &LAND, &LAND_HEAVY, &BOARD_DOWN, &GRIND, &GRIND_METAL, &POWERSLIDE, &FOOT_DRAG, &BAIL, &SPLASH_SAMPLES, &PUSH, &STEP, &RUN_STEP] {
|
||||
assert!(!cue.samples.is_empty());
|
||||
assert!(cue.level > 0.0 && cue.level <= 4.0, "{} out of range", cue.bank);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bail_tiers_cover_the_bank_and_rise_with_speed() {
|
||||
let all: Vec<usize> = BAIL_TIERS.iter().flat_map(|t| t.iter().copied()).collect();
|
||||
assert_eq!(all, BAIL.samples);
|
||||
assert_eq!((bail_tier(1.0), bail_tier(4.0), bail_tier(9.0)), (0, 1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_surface_has_a_grain() {
|
||||
assert_eq!(grain_for(3), "concrete_smooth_hard");
|
||||
assert_eq!(grain_for(6), "wood_ramp_hard");
|
||||
assert_eq!(grain_for(31), "metal_smooth_hard");
|
||||
assert_eq!(grain_for(127), "concrete_smooth_hard");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,222 @@
|
||||
//! Game audio: per-map ambience, board rolling, trick cues and footsteps,
|
||||
//! played from the owned disc's sounds that setup decodes to PCM WAV
|
||||
//! (tools/asset_pipeline/audio_export.py -> assets/private/audio).
|
||||
//!
|
||||
//! Gameplay events have no event bus, so each cue is found by comparing this
|
||||
//! frame's physics state with the last one, as the mod observation layer does.
|
||||
//! Which retail sample plays for which event is our own hand-made table
|
||||
//! (docs/hails-additions/11-audio.md); EA's AEMS event runtime is not
|
||||
//! re-implemented.
|
||||
//!
|
||||
//! Loudness is deliberately conservative: master volume defaults to 25%,
|
||||
//! a cue may raise a quiet clip only until the clip's own peak reaches full
|
||||
//! scale (at most x4) before the master and category volumes (both <= 1)
|
||||
//! scale it down (voices.rs), sounds fade in, voice counts are capped, and nothing plays while
|
||||
//! the menu is open or a replay runs. `--mute` silences game and mod audio.
|
||||
mod ambience;
|
||||
mod cues;
|
||||
mod library;
|
||||
mod skate_events;
|
||||
mod voices;
|
||||
mod water;
|
||||
|
||||
use bevy::{audio::Volume, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) use library::Library;
|
||||
pub(crate) use voices::{Category, Play, Voices};
|
||||
|
||||
/// Volume steps for the menu (percent).
|
||||
const STEP: u32 = 5;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct SavedSettings {
|
||||
master: u32,
|
||||
ambience: u32,
|
||||
effects: u32,
|
||||
}
|
||||
impl Default for SavedSettings {
|
||||
fn default() -> Self {
|
||||
Self { master: 25, ambience: 100, effects: 100 }
|
||||
}
|
||||
}
|
||||
impl SavedSettings {
|
||||
fn validated(mut self) -> Self {
|
||||
for value in [&mut self.master, &mut self.ambience, &mut self.effects] {
|
||||
*value = (*value).min(100) / STEP * STEP;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Player volume settings, saved beside the graphics settings.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct AudioSettings {
|
||||
saved: SavedSettings,
|
||||
path: PathBuf,
|
||||
muted: bool,
|
||||
}
|
||||
impl AudioSettings {
|
||||
fn load(config: &crate::config::Config) -> Self {
|
||||
let path = config.asset_root.parent().unwrap_or(&config.asset_root).join("settings/audio.json");
|
||||
let saved = match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice::<SavedSettings>(&bytes).unwrap_or_else(|e| {
|
||||
warn!("Audio settings: {e}");
|
||||
SavedSettings::default()
|
||||
}),
|
||||
Err(_) => SavedSettings::default(),
|
||||
}
|
||||
.validated();
|
||||
Self { saved, path, muted: config.mute }
|
||||
}
|
||||
/// Linear master gain (0 when muted).
|
||||
pub(crate) fn master(&self) -> f32 {
|
||||
if self.muted { 0.0 } else { self.saved.master as f32 / 100.0 }
|
||||
}
|
||||
pub(crate) fn category(&self, category: Category) -> f32 {
|
||||
let percent = match category {
|
||||
Category::Ambience => self.saved.ambience,
|
||||
Category::Effects => self.saved.effects,
|
||||
};
|
||||
percent as f32 / 100.0
|
||||
}
|
||||
fn field(&mut self, row: AudioRow) -> &mut u32 {
|
||||
match row {
|
||||
AudioRow::Master => &mut self.saved.master,
|
||||
AudioRow::Ambience => &mut self.saved.ambience,
|
||||
AudioRow::Effects => &mut self.saved.effects,
|
||||
}
|
||||
}
|
||||
/// Step a menu row by `direction` (wrapping 0..=100) and save; returns a status line.
|
||||
pub(crate) fn adjust(&mut self, row: AudioRow, direction: i32) -> String {
|
||||
let steps = (100 / STEP + 1) as i32;
|
||||
let value = self.field(row);
|
||||
*value = ((*value / STEP) as i32 + direction).rem_euclid(steps) as u32 * STEP;
|
||||
let save = (|| -> Result<(), String> {
|
||||
std::fs::create_dir_all(self.path.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&self.path, serde_json::to_vec_pretty(&self.saved).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())
|
||||
})();
|
||||
match save {
|
||||
Ok(()) if self.muted => "Saved (audio is muted by --mute)".into(),
|
||||
Ok(()) => "Saved".into(),
|
||||
Err(e) => format!("Could not save: {e}"),
|
||||
}
|
||||
}
|
||||
pub(crate) fn label(&self, row: AudioRow) -> String {
|
||||
let (name, value) = match row {
|
||||
AudioRow::Master => ("Master volume", self.saved.master),
|
||||
AudioRow::Ambience => ("Ambience volume", self.saved.ambience),
|
||||
AudioRow::Effects => ("Effects volume", self.saved.effects),
|
||||
};
|
||||
if row == AudioRow::Master && self.muted {
|
||||
format!("{name:<22}{value}% (muted)")
|
||||
} else {
|
||||
format!("{name:<22}{value}%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AudioRow {
|
||||
Master,
|
||||
Ambience,
|
||||
Effects,
|
||||
}
|
||||
|
||||
/// The one spatial listener (game and mod audio), following the gameplay camera.
|
||||
#[derive(Component)]
|
||||
struct GameAudioListener;
|
||||
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct CueSet;
|
||||
|
||||
pub(crate) struct GameAudioPlugin;
|
||||
impl Plugin for GameAudioPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Voices>()
|
||||
.init_resource::<skate_events::Cues>()
|
||||
.add_systems(Startup, setup)
|
||||
.add_systems(FixedUpdate, skate_events::observe.after(crate::app::SimulationSet::Physics))
|
||||
.add_systems(Update, (ambience::update, skate_events::play, water::update).in_set(CueSet).after(crate::app::FrameSet::Animation))
|
||||
.add_systems(Update, voices::sync.after(CueSet))
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
(apply_global_volume, follow_camera).before(bevy::transform::TransformSystems::Propagate),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, config: Res<crate::config::Config>, mut assets: ResMut<Assets<AudioSource>>) {
|
||||
commands.insert_resource(AudioSettings::load(&config));
|
||||
commands.spawn((GameAudioListener, SpatialListener::new(0.2), Transform::default()));
|
||||
match Library::load(&config.asset_root) {
|
||||
Ok(mut library) => {
|
||||
let mut samples: Vec<(&str, &[usize])> = cues::ALL.iter().map(|c| (c.bank, c.samples)).collect();
|
||||
samples.extend(water::PRELOAD);
|
||||
let started = std::time::Instant::now();
|
||||
let count = library.preload(&mut assets, &samples);
|
||||
info!("Game audio: preloaded {count} clips in {:.0} ms", started.elapsed().as_secs_f64() * 1000.0);
|
||||
commands.insert_resource(library);
|
||||
}
|
||||
Err(error) => info!("Game audio unavailable (run setup to extract it): {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mod voices scale by GlobalVolume, so the master volume and --mute apply to them too.
|
||||
fn apply_global_volume(settings: Res<AudioSettings>, mut global: ResMut<GlobalVolume>) {
|
||||
let master = Volume::Linear(settings.master());
|
||||
if global.volume != master {
|
||||
global.volume = master;
|
||||
}
|
||||
}
|
||||
|
||||
fn follow_camera(
|
||||
camera: Query<&Transform, (With<crate::camera::GameplayCamera>, Without<GameAudioListener>)>,
|
||||
mut listener: Query<&mut Transform, With<GameAudioListener>>,
|
||||
) {
|
||||
if let (Some(camera), Ok(mut listener)) = (camera.iter().next(), listener.single_mut()) {
|
||||
*listener = *camera;
|
||||
}
|
||||
}
|
||||
|
||||
/// True while game sound must be silent: menu open, replay running.
|
||||
fn silenced(menu: Option<&crate::graphics_menu::Menu>, replay: &crate::replay::Replay) -> bool {
|
||||
menu.is_some_and(|m| m.open) || replay.active
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(master: u32) -> AudioSettings {
|
||||
AudioSettings {
|
||||
saved: SavedSettings { master, ..SavedSettings::default() },
|
||||
path: std::env::temp_dir().join(format!("skate-audio-test-{}-{master}/audio.json", std::process::id())),
|
||||
muted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_quiet_and_saved_values_are_bounded() {
|
||||
assert_eq!(SavedSettings::default().master, 25);
|
||||
let loaded: SavedSettings = serde_json::from_str(r#"{"master":400,"ambience":33,"effects":7}"#).unwrap();
|
||||
assert_eq!(loaded.validated(), SavedSettings { master: 100, ambience: 30, effects: 5 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_wraps_in_steps_and_mute_wins() {
|
||||
let mut s = settings(95);
|
||||
s.adjust(AudioRow::Master, 1);
|
||||
assert_eq!(s.saved.master, 100);
|
||||
s.adjust(AudioRow::Master, 1);
|
||||
assert_eq!(s.saved.master, 0);
|
||||
s.adjust(AudioRow::Master, -1);
|
||||
assert_eq!(s.saved.master, 100);
|
||||
s.muted = true;
|
||||
assert_eq!(s.master(), 0.0);
|
||||
let _ = std::fs::remove_dir_all(s.path.parent().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
//! Skateboarding sounds. `observe` runs after every physics tick and turns
|
||||
//! state changes into cues (some flags, like the ollie launch, only last one
|
||||
//! tick); `play` turns them into voices each frame and keeps the continuous
|
||||
//! loops (rolling, grind, powerslide, foot drag, wheel spin) following the board.
|
||||
//! Every one-shot cue is logged as `AUDIO_CUE` so play sessions can be checked.
|
||||
use super::{Library, Play, Voices, cues, library::Clip, voices::VoiceId};
|
||||
use crate::physics::{GamePhysics, SkaterRuntime};
|
||||
use bevy::prelude::*;
|
||||
use skate_core::{
|
||||
physics::{board::BodyId, filtered_state::FilteredCategory},
|
||||
player::state::PhysicalStateId,
|
||||
};
|
||||
|
||||
/// Board ground speed (m/s) mapped to the fastest rolling band.
|
||||
const FULL_ROLL_SPEED: f32 = 10.0;
|
||||
/// Landing impact speed (m/s) for full landing level.
|
||||
const FULL_IMPACT: f32 = 12.0;
|
||||
/// Horizontal speed (m/s) above which on-foot steps use the running set.
|
||||
const RUN_SPEED: f32 = 3.0;
|
||||
/// Downward speed (m/s) a body needs for a water-entry splash, and the
|
||||
/// shortest time between two splashes (floating bodies touch water every tick).
|
||||
const SPLASH_SPEED: f32 = 1.0;
|
||||
const SPLASH_COOLDOWN: f32 = 1.5;
|
||||
/// Rolling: speed smoothing time constant, shortest time on one band, and how
|
||||
/// long a new surface must stay under the wheels before the sound changes.
|
||||
const ROLL_SPEED_SMOOTHING: f32 = 0.25;
|
||||
const BAND_HOLD: f32 = 1.0;
|
||||
const SURFACE_HOLD: f32 = 0.25;
|
||||
/// Seconds off the ground after which the rolling loop stops (restarting
|
||||
/// cleanly on the surface it lands on).
|
||||
const ROLL_OFF_STOP: f32 = 0.15;
|
||||
/// Foot drag: the board must move this fast (m/s) to make a sound. The
|
||||
/// foot-down brake event repeats every tick while the foot brakes (then
|
||||
/// foot-up repeats while it lifts), so braking ends this long (s) after the
|
||||
/// last foot-down.
|
||||
const DRAG_MIN_SPEED: f32 = 0.5;
|
||||
const BRAKE_TIMEOUT: f32 = 0.1;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum Event {
|
||||
Pop(Vec3),
|
||||
Flip(Vec3),
|
||||
Land { at: Vec3, impact: f32 },
|
||||
/// Wheels touch down after stepping onto the board (an Air phase that began on foot).
|
||||
BoardDown { at: Vec3, impact: f32 },
|
||||
Bail { at: Vec3, speed: f32 },
|
||||
Push(Vec3),
|
||||
/// `level` 0..1 from the animation's AudibleFootStepStrength (walk ~0.6, run 1.0).
|
||||
Step { at: Vec3, run: bool, level: f32 },
|
||||
Splash { at: Vec3, speed: f32 },
|
||||
}
|
||||
|
||||
impl Event {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Event::Pop(_) => "pop",
|
||||
Event::Flip(_) => "flip",
|
||||
Event::Land { .. } => "land",
|
||||
Event::BoardDown { .. } => "board_down",
|
||||
Event::Bail { .. } => "bail",
|
||||
Event::Push(_) => "push",
|
||||
Event::Step { run: false, .. } => "step",
|
||||
Event::Step { run: true, .. } => "run_step",
|
||||
Event::Splash { .. } => "splash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Continuous state sampled at the last physics tick.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct Riding {
|
||||
pub board: Vec3,
|
||||
pub speed: f32,
|
||||
pub rolling: bool,
|
||||
pub surface: u32,
|
||||
pub airborne: bool,
|
||||
pub grinding: bool,
|
||||
/// Audio surface of the grind (`grinds.audio_surface_216`).
|
||||
pub grind_surface: u32,
|
||||
pub sliding: bool,
|
||||
pub braking: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct Cues {
|
||||
pub events: Vec<Event>,
|
||||
pub riding: Riding,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Seen {
|
||||
started: bool,
|
||||
launched: bool,
|
||||
filtered: u32,
|
||||
state: u32,
|
||||
trick_seq: u32,
|
||||
in_water: bool,
|
||||
splash_cooldown: f32,
|
||||
push: bool,
|
||||
feet: [FootStrike; 2],
|
||||
/// The current Air phase began on foot (stepping onto the board).
|
||||
air_from_foot: bool,
|
||||
/// Seconds since the skater was last on foot (BipedGround/BipedAir).
|
||||
since_foot: f32,
|
||||
/// Foot braking, time since the last foot-down event, and the last event (+1/-1/0).
|
||||
braking: bool,
|
||||
brake_time: f32,
|
||||
brake_event: i32,
|
||||
on_rail: bool,
|
||||
/// Seconds since the last splash (a wipeout right after one is the water entry).
|
||||
since_splash: f32,
|
||||
/// Last trick announced in the current air phase.
|
||||
air_trick: Option<String>,
|
||||
trace: Option<bool>,
|
||||
}
|
||||
|
||||
/// Foot-strike detector on one foot's height above the ground under it. The
|
||||
/// resting height (ankle above sole) is learned: it follows the lowest height
|
||||
/// and drifts back up slowly. A strike is the foot coming back down to rest
|
||||
/// after having lifted clearly above it.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct FootStrike {
|
||||
rest: Option<f32>,
|
||||
lifted: bool,
|
||||
}
|
||||
impl FootStrike {
|
||||
const LIFT: f32 = 0.05;
|
||||
const STRIKE: f32 = 0.02;
|
||||
const DRIFT: f32 = 2.0;
|
||||
|
||||
/// Feed this tick's height (None: no ground found); true on a strike.
|
||||
pub(super) fn update(&mut self, height: Option<f32>, dt: f32) -> bool {
|
||||
let Some(height) = height.filter(|h| h.is_finite()) else {
|
||||
self.lifted = false;
|
||||
return false;
|
||||
};
|
||||
let rest = match self.rest {
|
||||
Some(rest) if height >= rest => rest + (height - rest) * (1.0 - (-dt / Self::DRIFT).exp()),
|
||||
_ => height,
|
||||
};
|
||||
self.rest = Some(rest);
|
||||
if height > rest + Self::LIFT {
|
||||
self.lifted = true;
|
||||
} else if self.lifted && height < rest + Self::STRIKE {
|
||||
self.lifted = false;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn vec(v: skate_core::math::Vector3) -> Vec3 {
|
||||
Vec3::new(v.x, v.y, v.z)
|
||||
}
|
||||
|
||||
fn grinding(state: u32) -> bool {
|
||||
(PhysicalStateId::GrindBoardslide as u32..=PhysicalStateId::GrindDarkslide as u32).contains(&state)
|
||||
}
|
||||
|
||||
/// Majority audio surface under the wheels that touch the ground.
|
||||
fn wheel_surface(physics: &GamePhysics) -> (bool, u32) {
|
||||
let ground = &physics.riding.ground;
|
||||
let lines = &physics.riding.wheel_lines;
|
||||
let touching: Vec<u32> = (0..4).filter(|&i| ground.parts[i].in_contact).map(|i| lines.audio_surfaces[i]).collect();
|
||||
let surface = touching.iter().copied().max_by_key(|s| touching.iter().filter(|t| *t == s).count());
|
||||
(!touching.is_empty(), surface.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(super) fn observe(
|
||||
physics: Res<GamePhysics>,
|
||||
mut skater: ResMut<SkaterRuntime>,
|
||||
mut cues: ResMut<Cues>,
|
||||
mut seen: Local<Seen>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
// Animation events latched during the tick (physics never reads these).
|
||||
let audio = std::mem::take(&mut skater.animation_input.audio);
|
||||
let p = &skater.player_input.physical;
|
||||
let filtered = p.filtered_state_0;
|
||||
let state = skater.player_state.current() as u32;
|
||||
let deck = physics.board.bodies()[BodyId::Deck.index()].rates;
|
||||
let root = skater.skeleton.bodies()[0].rates;
|
||||
let (board, body) = (vec(deck.position), vec(root.position));
|
||||
let launched = skater.ground_animation.launched;
|
||||
let trick_seq = skater.scoring.trick_seq();
|
||||
let in_water = skater.collision_feedback.flags.material_12;
|
||||
let air = FilteredCategory::Air as u32;
|
||||
let ground = FilteredCategory::Ground as u32;
|
||||
let wipeout = PhysicalStateId::WipeoutGround as u32;
|
||||
let mut cooldown = (seen.splash_cooldown - time.delta_secs()).max(0.0);
|
||||
let mut since_splash = seen.since_splash + time.delta_secs();
|
||||
// The physical state leaves BipedGround a few ticks before the filtered
|
||||
// category reaches Air, so "began on foot" means on foot very recently.
|
||||
let since_foot = if matches!(state, 500 | 501) { 0.0 } else { seen.since_foot + time.delta_secs() };
|
||||
let air_from_foot = if filtered == air && seen.filtered != air { seen.since_foot < 0.5 } else { seen.air_from_foot };
|
||||
let trace = *seen.trace.get_or_insert_with(|| std::env::var_os("SKATE_AUDIO_TRACE").is_some_and(|v| v != "0"));
|
||||
if trace && (audio.footstep > 0.0 || audio.push || audio.brake_down || audio.brake_up) {
|
||||
info!("AUDIO_TRACE footstep={:.3} push={} brake_down={} brake_up={} state={state}",
|
||||
audio.footstep, audio.push, audio.brake_down, audio.brake_up);
|
||||
}
|
||||
// Foot braking lasts from the foot-down brake event to the foot-up one,
|
||||
// only while riding on the ground (a safety timeout covers a missed foot-up).
|
||||
let speed = physics.riding.motion.ground_speed.abs();
|
||||
// Logged on change only (the events repeat every tick).
|
||||
let brake_event = if audio.brake_down { 1 } else if audio.brake_up { -1 } else { 0 };
|
||||
if brake_event != 0 && brake_event != seen.brake_event {
|
||||
info!("AUDIO_EVENT brake {} speed={speed:.2} state={state}", if brake_event > 0 { "down" } else { "up" });
|
||||
}
|
||||
if audio.push && !seen.push {
|
||||
info!("AUDIO_EVENT push speed={speed:.2} state={state}");
|
||||
}
|
||||
let mut brake_time = if audio.brake_down { 0.0 } else { seen.brake_time + time.delta_secs() };
|
||||
let mut braking = (seen.braking || audio.brake_down) && !audio.brake_up;
|
||||
if !matches!(state, 100 | 101) || filtered != ground || brake_time > BRAKE_TIMEOUT {
|
||||
braking = false;
|
||||
brake_time = BRAKE_TIMEOUT;
|
||||
}
|
||||
if seen.started {
|
||||
let events = &mut cues.events;
|
||||
if launched && !seen.launched {
|
||||
events.push(Event::Pop(board));
|
||||
}
|
||||
if seen.filtered == air && filtered == ground {
|
||||
let impact = physics.riding.ground.maximum_closing_speed;
|
||||
events.push(if air_from_foot {
|
||||
Event::BoardDown { at: board, impact }
|
||||
} else {
|
||||
Event::Land { at: board, impact }
|
||||
});
|
||||
}
|
||||
// The scoring re-announces a held grab (trick_seq keeps rising), so the
|
||||
// whoosh plays only for a different trick than the last in this air.
|
||||
if trick_seq != seen.trick_seq && filtered == air {
|
||||
let name = skater.scoring.trick_name();
|
||||
if seen.air_trick.as_deref() != Some(name) {
|
||||
events.push(Event::Flip(board));
|
||||
seen.air_trick = Some(name.to_owned());
|
||||
}
|
||||
}
|
||||
// A water entry is also a wipeout; the splash covers it (no body slide).
|
||||
let watery = in_water || seen.since_splash < 0.5;
|
||||
// Wading in and falling (feet already wet, slow drop) never gives a
|
||||
// fresh fast contact below, so a wipeout in water splashes by itself.
|
||||
if state == wipeout && seen.state != wipeout && in_water && seen.since_splash > 1.0 {
|
||||
events.push(Event::Splash { at: body, speed: vec(root.linear_velocity).length().max(2.0) });
|
||||
since_splash = 0.0;
|
||||
cooldown = SPLASH_COOLDOWN;
|
||||
}
|
||||
if state == wipeout && seen.state != wipeout && !watery {
|
||||
events.push(Event::Bail { at: body, speed: vec(root.linear_velocity).length() });
|
||||
}
|
||||
if audio.push && !seen.push {
|
||||
events.push(Event::Push(board));
|
||||
}
|
||||
// AudibleFootStepStrength is a held loudness level (2.5-4), not a
|
||||
// step event; steps come from each foot coming down to the ground.
|
||||
if matches!(state, 500 | 501) {
|
||||
let horizontal = Vec2::new(root.linear_velocity.x, root.linear_velocity.z).length();
|
||||
let clearance = crate::physics::foot_clearance(&skater);
|
||||
if trace {
|
||||
let h = clearance.map(|c| c.map_or(f32::NAN, |(height, _)| height));
|
||||
info!("AUDIO_TRACE feet left={:.3} right={:.3} speed={horizontal:.2}", h[0], h[1]);
|
||||
}
|
||||
for (foot, clearance) in seen.feet.iter_mut().zip(clearance) {
|
||||
if foot.update(clearance.map(|(height, _)| height), time.delta_secs()) && horizontal > 0.3 {
|
||||
let level = if audio.footstep > 0.0 { (audio.footstep / 4.0).clamp(0.3, 1.0) } else { 0.7 };
|
||||
events.push(Event::Step { at: body, run: horizontal > RUN_SPEED, level });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seen.feet = Default::default();
|
||||
}
|
||||
if in_water && !seen.in_water && -root.linear_velocity.y > SPLASH_SPEED && cooldown == 0.0 {
|
||||
events.push(Event::Splash { at: body, speed: -root.linear_velocity.y });
|
||||
cooldown = SPLASH_COOLDOWN;
|
||||
since_splash = 0.0;
|
||||
}
|
||||
}
|
||||
// Grind: the selector's grinding flag can lead the named grind state, and
|
||||
// `leaving` marks coming off before the state changes.
|
||||
let grinds = &skater.player_input.physical.grinds;
|
||||
let on_rail = (grinds.grinding_316 != 0 || grinding(state)) && grinds.leaving_317 == 0;
|
||||
if on_rail != seen.on_rail {
|
||||
info!("AUDIO_EVENT grind {} surface={} ledge={} flag={} leaving={} state={state}", if on_rail { "start" } else { "stop" },
|
||||
grinds.audio_surface_216, grinds.is_ledge_320, grinds.grinding_316, grinds.leaving_317);
|
||||
}
|
||||
let (rolling, surface) = wheel_surface(&physics);
|
||||
cues.riding = Riding {
|
||||
board,
|
||||
speed: physics.riding.motion.ground_speed,
|
||||
rolling,
|
||||
surface,
|
||||
airborne: filtered == air,
|
||||
grinding: on_rail,
|
||||
grind_surface: grinds.audio_surface_216,
|
||||
sliding: state == PhysicalStateId::SlideGround as u32,
|
||||
braking: braking && speed > DRAG_MIN_SPEED,
|
||||
};
|
||||
*seen = Seen {
|
||||
started: true, launched, filtered, state, trick_seq, in_water, splash_cooldown: cooldown,
|
||||
push: audio.push, feet: seen.feet, air_from_foot, since_foot, braking, brake_time, brake_event, on_rail, since_splash, trace: Some(trace),
|
||||
air_trick: if filtered == air { seen.air_trick.take() } else { None },
|
||||
};
|
||||
}
|
||||
|
||||
/// A looping voice and the clip it plays.
|
||||
#[derive(Default)]
|
||||
struct Loop {
|
||||
voice: Option<(VoiceId, Clip)>,
|
||||
}
|
||||
impl Loop {
|
||||
fn stop(&mut self, voices: &mut Voices, fade: f32) {
|
||||
if let Some((id, _)) = self.voice.take() {
|
||||
voices.stop(id, fade);
|
||||
}
|
||||
}
|
||||
/// Forget a voice that ended on its own (or was refused).
|
||||
fn prune(&mut self, voices: &Voices) {
|
||||
if self.voice.as_ref().is_some_and(|(id, _)| !voices.playing(*id)) {
|
||||
self.voice = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Loops {
|
||||
roll: Loop,
|
||||
/// Grain and band the rolling loop plays, and how long it has played.
|
||||
roll_band: Option<(&'static str, usize)>,
|
||||
roll_held: f32,
|
||||
/// Board speed low-passed for band choice (landings and pushes jump it).
|
||||
roll_speed: f32,
|
||||
/// Surface grain under the wheels, switched to only once stable (seams,
|
||||
/// curbs and tile edges flicker between surfaces for a few ticks).
|
||||
roll_grain: Option<&'static str>,
|
||||
roll_candidate: Option<(&'static str, f32)>,
|
||||
/// Seconds the wheels have been off the ground (or the board stopped).
|
||||
roll_off: f32,
|
||||
grind: Loop,
|
||||
/// Countdown to the next shuffled powerslide / foot-drag piece.
|
||||
slide_next: f32,
|
||||
drag_next: f32,
|
||||
wheels: Loop,
|
||||
was_airborne: bool,
|
||||
rng: u32,
|
||||
/// Last member picked per (bank, record, layer), for sequential/shuffled layers.
|
||||
patch_order: std::collections::HashMap<(&'static str, usize, usize), usize>,
|
||||
/// Last start time per one-shot cue, for `cues::min_gap`.
|
||||
last: std::collections::HashMap<&'static str, f64>,
|
||||
}
|
||||
|
||||
fn random(rng: &mut u32) -> f32 {
|
||||
if *rng == 0 {
|
||||
*rng = 0x2545_f491;
|
||||
}
|
||||
*rng ^= *rng << 13;
|
||||
*rng ^= *rng >> 17;
|
||||
*rng ^= *rng << 5;
|
||||
(*rng >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
|
||||
/// (soft sample scale, landing-layer scale) for a board touch-down at `impact` m/s.
|
||||
fn board_down_levels(impact: f32) -> (f32, f32) {
|
||||
let impact = if impact.is_finite() { impact.max(0.0) } else { 0.0 };
|
||||
(0.3 + 0.7 * (impact / 5.0).min(1.0), ((impact - 2.5) / 4.0).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Speed band for `fraction` (0..1) with hysteresis around the current band.
|
||||
fn band_for(fraction: f32, bands: usize, current: Option<usize>) -> usize {
|
||||
let scaled = fraction.clamp(0.0, 1.0) * (bands - 1) as f32;
|
||||
match current {
|
||||
// Switch only a full band away: on ramps speed changes constantly, and
|
||||
// every switch cross-fades two different recordings ("plays twice").
|
||||
Some(band) if (scaled - band as f32).abs() < 1.0 => band,
|
||||
_ => (scaled.round() as usize).min(bands - 1),
|
||||
}
|
||||
}
|
||||
|
||||
struct Player<'a, 'w, 's> {
|
||||
commands: Commands<'w, 's>,
|
||||
library: &'a mut Library,
|
||||
voices: &'a mut Voices,
|
||||
assets: &'a mut Assets<AudioSource>,
|
||||
now: f64,
|
||||
}
|
||||
impl Player<'_, '_, '_> {
|
||||
fn pick(&mut self, cue: &cues::Cue, rng: &mut u32) -> Option<(usize, Clip)> {
|
||||
let index = cue.samples[(random(rng) * cue.samples.len() as f32) as usize % cue.samples.len()];
|
||||
self.library.sample(self.assets, cue.bank, index).map(|clip| (index, clip))
|
||||
}
|
||||
/// One-shot with a small random pitch spread (+-4%).
|
||||
fn once(&mut self, name: &str, cue: &cues::Cue, scale: f32, at: Vec3, rng: &mut u32) {
|
||||
self.once_pitched(name, cue, scale, 1.0, at, rng);
|
||||
}
|
||||
/// Play retail patch `id` of `bank` the way its data describes: an id past
|
||||
/// the records is a container that picks one record; each of the record's
|
||||
/// groups is a layer, one member each, chosen by the group mode (0 random,
|
||||
/// 1 in sequence, 2 shuffled without an immediate repeat), played if its
|
||||
/// probability allows, at its gain +- its random range and a pitch between
|
||||
/// 1/s and s for a pitch spread s > 1. (Semantics as documented in
|
||||
/// upstream PRs #1/#4; implemented here independently.)
|
||||
fn play_record(&mut self, name: &str, record: &cues::Record, scale: f32, at: Vec3, rng: &mut u32,
|
||||
order: &mut std::collections::HashMap<(&'static str, usize, usize), usize>) {
|
||||
let Some(patches) = self.library.patches(record.bank) else { return };
|
||||
let records = patches.records.len();
|
||||
let id = if record.id < records {
|
||||
record.id
|
||||
} else {
|
||||
let Some(choices) = patches.containers.get(record.id - records).filter(|c| !c.is_empty()) else { return };
|
||||
choices[(random(rng) * choices.len() as f32) as usize % choices.len()]
|
||||
};
|
||||
let Some(groups) = patches.records.get(id) else { return };
|
||||
let mut layers = Vec::new();
|
||||
for (index, group) in groups.iter().enumerate() {
|
||||
let count = group.members.len();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let state = order.entry((record.bank, id, index)).or_insert(usize::MAX);
|
||||
let pick = match (count, group.mode) {
|
||||
(1, _) => 0,
|
||||
(_, 0) => (random(rng) * count as f32) as usize % count,
|
||||
(_, 2) => {
|
||||
// Shuffle: never the same member twice in a row.
|
||||
let offset = 1 + (random(rng) * (count - 1) as f32) as usize % (count - 1);
|
||||
if *state < count { (*state + offset) % count } else { (random(rng) * count as f32) as usize % count }
|
||||
}
|
||||
_ => if *state < count { (*state + 1) % count } else { 0 },
|
||||
};
|
||||
*state = pick;
|
||||
let (sample, gain, gain_range, spread, probability) = group.members[pick];
|
||||
if random(rng) > probability {
|
||||
continue;
|
||||
}
|
||||
let gain = (gain + gain_range * (2.0 * random(rng) - 1.0)).max(0.0);
|
||||
let pitch = if spread > 1.0 && spread.is_finite() {
|
||||
let (low, high) = (1.0 / spread, spread);
|
||||
low + (high - low) * random(rng)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
layers.push((sample, gain, pitch));
|
||||
}
|
||||
let mut played = Vec::new();
|
||||
for (sample, gain, pitch) in layers {
|
||||
let Some(clip) = self.library.sample(self.assets, record.bank, sample) else { continue };
|
||||
let volume = record.level * scale.clamp(0.0, 1.0) * gain;
|
||||
let mut play = Play::effect(volume, at);
|
||||
play.pitch = pitch;
|
||||
if self.voices.play(&mut self.commands, &clip, play, self.now).is_some() {
|
||||
played.push(sample);
|
||||
}
|
||||
}
|
||||
info!("AUDIO_CUE {name} {}:record{id} samples={played:?}", record.bank);
|
||||
}
|
||||
|
||||
/// One-shot at `pitch` (playback speed), with the same small random spread.
|
||||
fn once_pitched(&mut self, name: &str, cue: &cues::Cue, scale: f32, pitch: f32, at: Vec3, rng: &mut u32) {
|
||||
let Some((index, clip)) = self.pick(cue, rng) else { return };
|
||||
let volume = cue.level * scale.clamp(0.0, 1.0);
|
||||
let mut play = Play::effect(volume, at);
|
||||
play.pitch = pitch * (0.96 + 0.08 * random(rng));
|
||||
let played = self.voices.play(&mut self.commands, &clip, play, self.now).is_some();
|
||||
if !name.is_empty() {
|
||||
info!("AUDIO_CUE {name} {}:{index} volume={volume:.2}{}", cue.bank, if played { "" } else { " (voice limit)" });
|
||||
}
|
||||
}
|
||||
/// While `active`, a random piece of `cue` every `interval` s, each fading in
|
||||
/// over `fade` so overlapping pieces blend into one continuous sound.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shuffle(&mut self, name: &str, next: &mut f32, active: bool, dt: f32, cue: &cues::Cue, (interval, fade): (f32, f32), scale: f32, at: Vec3, rng: &mut u32) {
|
||||
if !active {
|
||||
*next = 0.0;
|
||||
return;
|
||||
}
|
||||
if *next == 0.0 {
|
||||
info!("AUDIO_LOOP start {name} ({})", cue.bank);
|
||||
}
|
||||
*next -= dt;
|
||||
if *next <= 0.0 {
|
||||
if let Some((_, clip)) = self.pick(cue, rng) {
|
||||
let mut play = Play::effect(cue.level * scale.clamp(0.0, 1.0), at);
|
||||
play.pitch = 0.96 + 0.08 * random(rng);
|
||||
play.fade_in = fade;
|
||||
self.voices.play(&mut self.commands, &clip, play, self.now);
|
||||
}
|
||||
*next = interval * (0.8 + 0.4 * random(rng));
|
||||
}
|
||||
}
|
||||
fn start_loop(&mut self, slot: &mut Loop, clip: Clip, volume: f32, pitch: f32, at: Vec3, fade: f32) {
|
||||
let mut play = Play::effect(volume, at);
|
||||
play.looping = true;
|
||||
play.pitch = pitch;
|
||||
play.fade_in = fade;
|
||||
if let Some(id) = self.voices.play(&mut self.commands, &clip, play, self.now) {
|
||||
info!("AUDIO_LOOP start {}", clip.key);
|
||||
slot.voice = Some((id, clip));
|
||||
}
|
||||
}
|
||||
/// Keep `slot` playing one of `cue`'s samples while `active`.
|
||||
fn hold(&mut self, slot: &mut Loop, active: bool, cue: &cues::Cue, volume: f32, at: Vec3, rng: &mut u32) {
|
||||
slot.prune(self.voices);
|
||||
if !active {
|
||||
slot.stop(self.voices, 0.06);
|
||||
} else if let Some((id, _)) = &slot.voice {
|
||||
self.voices.set(*id, volume, 1.0, Some(at));
|
||||
} else if let Some((_, clip)) = self.pick(cue, rng) {
|
||||
self.start_loop(slot, clip, volume, 1.0, at, 0.03);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn play(
|
||||
commands: Commands,
|
||||
mut cues: ResMut<Cues>,
|
||||
library: Option<ResMut<Library>>,
|
||||
mut voices: ResMut<Voices>,
|
||||
mut assets: ResMut<Assets<AudioSource>>,
|
||||
mut loops: Local<Loops>,
|
||||
time: Res<Time<Real>>,
|
||||
menu: Option<Res<crate::graphics_menu::Menu>>,
|
||||
replay: Res<crate::replay::Replay>,
|
||||
) {
|
||||
let events = std::mem::take(&mut cues.events);
|
||||
let Some(mut library) = library else { return };
|
||||
// Cues raised while silenced are dropped, never played late in a burst.
|
||||
if super::silenced(menu.as_deref(), &replay) {
|
||||
return;
|
||||
}
|
||||
let loops = &mut *loops;
|
||||
let mut rng = loops.rng;
|
||||
let dt = time.delta_secs().clamp(0.0, 0.25);
|
||||
let now = time.elapsed_secs_f64();
|
||||
let mut player = Player { commands, library: &mut library, voices: &mut voices, assets: &mut assets, now };
|
||||
for event in events {
|
||||
// Safety net: a cue never repeats faster than its minimum gap.
|
||||
let name = event.name();
|
||||
if loops.last.get(name).is_some_and(|t| now - t < f64::from(cues::min_gap(name))) {
|
||||
continue;
|
||||
}
|
||||
loops.last.insert(name, now);
|
||||
match event {
|
||||
Event::Pop(at) => player.once("pop", &cues::POP, 1.0, at, &mut rng),
|
||||
Event::Flip(at) => player.once("flip", &cues::FLIP, 1.0, at, &mut rng),
|
||||
Event::Land { at, impact } => {
|
||||
let scale = 0.3 + 0.7 * (impact / FULL_IMPACT).clamp(0.0, 1.0);
|
||||
player.once("land", &cues::LAND, scale, at, &mut rng);
|
||||
// Weight layer: from 1.5 m/s, louder and deeper (down to -20 %
|
||||
// pitch) as the impact grows.
|
||||
let heavy = ((impact - 1.5) / 7.5).clamp(0.0, 1.0);
|
||||
if heavy > 0.0 {
|
||||
player.once_pitched("land_heavy", &cues::LAND_HEAVY, 0.4 + 0.6 * heavy, 1.0 - 0.2 * heavy, at, &mut rng);
|
||||
}
|
||||
}
|
||||
Event::BoardDown { at, impact } => {
|
||||
// Set down gently: the soft sample, quietly. Jumped on (caveman):
|
||||
// louder, plus a landing impact layer that grows with the impact.
|
||||
let (soft, heavy) = board_down_levels(impact);
|
||||
player.once("board_down", &cues::BOARD_DOWN, soft, at, &mut rng);
|
||||
if heavy > 0.0 {
|
||||
player.once("board_down_heavy", &cues::LAND_HEAVY, heavy, at, &mut rng);
|
||||
}
|
||||
}
|
||||
Event::Bail { at, speed } => {
|
||||
let tier = cues::bail_tier(speed);
|
||||
let cue = cues::Cue { samples: cues::BAIL_TIERS[tier], ..cues::BAIL };
|
||||
player.once(["bail_soft", "bail_medium", "bail_hard"][tier], &cue, 1.0, at, &mut rng);
|
||||
}
|
||||
Event::Push(at) => player.once("push", &cues::PUSH, 1.0, at, &mut rng),
|
||||
Event::Step { at, run, level } => {
|
||||
player.once(if run { "run_step" } else { "step" }, if run { &cues::RUN_STEP } else { &cues::STEP }, level, at, &mut rng)
|
||||
}
|
||||
Event::Splash { at, speed } => {
|
||||
let scale = 0.4 + 0.6 * (speed / 8.0).min(1.0);
|
||||
player.play_record("splash", &cues::SPLASH, scale, at, &mut rng, &mut loops.patch_order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let r = cues.riding;
|
||||
let fraction = (r.speed.abs() / FULL_ROLL_SPEED).clamp(0.0, 1.0);
|
||||
|
||||
// Rolling: the grain band recorded at about this speed. The recordings get
|
||||
// louder with speed by themselves, so the level only fades in from a stop.
|
||||
let rolling = r.rolling && !r.grinding && r.speed.abs() > 0.3;
|
||||
loops.roll_speed += (r.speed.abs() - loops.roll_speed) * (1.0 - (-dt / ROLL_SPEED_SMOOTHING).exp());
|
||||
let touching = cues::grain_for(r.surface);
|
||||
// Off the ground for a moment (air, coping, ramp transition): stop the
|
||||
// loop, and on touching down start straight on the surface underneath —
|
||||
// the stability wait below is only for surface changes while rolling.
|
||||
loops.roll_off = if rolling { 0.0 } else { loops.roll_off + dt };
|
||||
if loops.roll_off > ROLL_OFF_STOP && loops.roll.voice.is_some() {
|
||||
loops.roll.stop(player.voices, 0.1);
|
||||
loops.roll_band = None;
|
||||
loops.roll_grain = None;
|
||||
loops.roll_candidate = None;
|
||||
}
|
||||
let grain = match (loops.roll_grain, loops.roll_candidate) {
|
||||
_ if loops.roll.voice.is_none() => touching,
|
||||
(None, _) => touching,
|
||||
(Some(current), _) if current == touching => current,
|
||||
(Some(current), Some((candidate, held))) if candidate == touching => {
|
||||
if held + dt >= SURFACE_HOLD { touching } else { loops.roll_candidate = Some((candidate, held + dt)); current }
|
||||
}
|
||||
(Some(current), _) => { loops.roll_candidate = Some((touching, 0.0)); current }
|
||||
};
|
||||
if Some(grain) != loops.roll_grain || grain == touching {
|
||||
loops.roll_candidate = None;
|
||||
}
|
||||
loops.roll_grain = Some(grain);
|
||||
let bands = player.library.grain_bands(grain);
|
||||
loops.roll.prune(player.voices);
|
||||
loops.roll_held += dt;
|
||||
if bands > 0 {
|
||||
let current = loops.roll_band.filter(|(g, _)| *g == grain).map(|(_, b)| b);
|
||||
let wanted = band_for((loops.roll_speed / FULL_ROLL_SPEED).clamp(0.0, 1.0), bands, current);
|
||||
// A band plays for at least BAND_HOLD before the next speed change.
|
||||
let band = match current {
|
||||
Some(band) if loops.roll_held < BAND_HOLD && loops.roll.voice.is_some() => band,
|
||||
_ => wanted,
|
||||
};
|
||||
let volume = if rolling { cues::ROLL_LEVEL * (r.speed.abs() / 1.5).min(1.0) } else { 0.0 };
|
||||
// Within a band, follow speed with a slight pitch change (+-6%).
|
||||
let scaled = (loops.roll_speed / FULL_ROLL_SPEED).clamp(0.0, 1.0) * (bands - 1) as f32;
|
||||
let pitch = 1.0 + 0.06 * (scaled - band as f32).clamp(-1.0, 1.0);
|
||||
if rolling && loops.roll_band != Some((grain, band)) {
|
||||
loops.roll_held = 0.0;
|
||||
loops.roll.stop(player.voices, 0.15);
|
||||
if let Some(clip) = player.library.grain(player.assets, grain, band) {
|
||||
info!("AUDIO_EVENT rolling surface={} grain={grain} band={band}", r.surface);
|
||||
player.start_loop(&mut loops.roll, clip, volume, pitch, r.board, 0.15);
|
||||
loops.roll_band = Some((grain, band));
|
||||
}
|
||||
} else if let Some((id, _)) = &loops.roll.voice {
|
||||
player.voices.set(*id, volume, pitch, Some(r.board));
|
||||
}
|
||||
}
|
||||
|
||||
// Grinds, powerslides and foot drag loop while the state lasts. Braking is
|
||||
// reported in pulses, so it is held briefly.
|
||||
let dragging = r.braking && !r.airborne && !r.grinding;
|
||||
let grind = if cues::metal(r.grind_surface) { &cues::GRIND_METAL } else { &cues::GRIND };
|
||||
player.hold(&mut loops.grind, r.grinding, grind, grind.level * (0.5 + 0.5 * fraction), r.board, &mut rng);
|
||||
let sliding = r.sliding && !r.grinding;
|
||||
player.shuffle("powerslide", &mut loops.slide_next, sliding, dt, &cues::POWERSLIDE, cues::POWERSLIDE_SHUFFLE, fraction, r.board, &mut rng);
|
||||
player.shuffle("foot_drag", &mut loops.drag_next, dragging, dt, &cues::FOOT_DRAG, cues::FOOT_DRAG_SHUFFLE, 0.3 + 0.7 * fraction, r.board, &mut rng);
|
||||
|
||||
// Wheels spin down after take-off; cut when they touch again.
|
||||
loops.wheels.prune(player.voices);
|
||||
if r.airborne && !loops.was_airborne && fraction > 0.1 {
|
||||
loops.wheels.stop(player.voices, 0.05);
|
||||
if let Some(clip) = player.library.wheels(player.assets, cues::AIR_WHEELS.0) {
|
||||
let mut play = Play::effect(cues::AIR_WHEELS.1 * fraction, r.board);
|
||||
play.fade_in = 0.05;
|
||||
if let Some(id) = player.voices.play(&mut player.commands, &clip, play, player.now) {
|
||||
loops.wheels.voice = Some((id, clip));
|
||||
}
|
||||
}
|
||||
} else if !r.airborne {
|
||||
loops.wheels.stop(player.voices, 0.05);
|
||||
} else if let Some((id, _)) = &loops.wheels.voice {
|
||||
player.voices.set(*id, cues::AIR_WHEELS.1 * fraction, 1.0, Some(r.board));
|
||||
}
|
||||
loops.was_airborne = r.airborne;
|
||||
loops.rng = rng;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn random_stays_in_unit_range() {
|
||||
let mut rng = 0;
|
||||
for _ in 0..10_000 {
|
||||
let x = random(&mut rng);
|
||||
assert!((0.0..1.0).contains(&x));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_bands_have_hysteresis() {
|
||||
assert_eq!(band_for(0.0, 6, None), 0);
|
||||
assert_eq!(band_for(1.0, 6, None), 5);
|
||||
assert_eq!(band_for(2.0, 6, None), 5);
|
||||
// 0.5 of 6 bands = 2.5: stays on the current band near the boundary.
|
||||
assert_eq!(band_for(0.5, 6, Some(2)), 2);
|
||||
assert_eq!(band_for(0.5, 6, Some(3)), 3);
|
||||
assert_eq!(band_for(0.75, 6, Some(2)), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_strike_per_step_with_a_learned_resting_height() {
|
||||
let mut foot = FootStrike::default();
|
||||
let dt = 1.0 / 60.0;
|
||||
let mut strikes = 0;
|
||||
// Ankle rests 0.09 m above the ground; each step lifts it ~0.12 m.
|
||||
for step in 0..4 {
|
||||
for tick in 0..30 {
|
||||
let phase = tick as f32 / 30.0;
|
||||
let lift = if phase < 0.5 { (phase * std::f32::consts::TAU).sin().abs() * 0.12 } else { 0.0 };
|
||||
strikes += foot.update(Some(0.09 + lift + step as f32 * 0.001), dt) as u32;
|
||||
}
|
||||
}
|
||||
assert_eq!(strikes, 4);
|
||||
// Standing still or losing the ground never strikes.
|
||||
let mut still = FootStrike::default();
|
||||
assert!((0..120).all(|_| !still.update(Some(0.09), dt)));
|
||||
assert!(!still.update(None, dt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn board_down_follows_the_impact() {
|
||||
assert_eq!(board_down_levels(0.0), (0.3, 0.0));
|
||||
let (soft, heavy) = board_down_levels(2.0);
|
||||
assert!(soft > 0.3 && soft < 1.0 && heavy == 0.0);
|
||||
let (soft, heavy) = board_down_levels(6.5);
|
||||
assert_eq!((soft, heavy), (1.0, 1.0));
|
||||
assert_eq!(board_down_levels(f32::NAN), (0.3, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grind_states_are_the_six_grinds() {
|
||||
assert!(grinding(PhysicalStateId::GrindBoardslide as u32));
|
||||
assert!(grinding(PhysicalStateId::GrindDarkslide as u32));
|
||||
assert!(!grinding(PhysicalStateId::WipeoutGround as u32));
|
||||
assert!(!grinding(PhysicalStateId::SlideGround as u32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Voice pool for game sounds. Same playback model as the mod adapter
|
||||
//! (modding/audio.rs): Bevy `AudioPlayer` entities whose sink gain and speed are
|
||||
//! smoothed every frame. Pitch is playback speed, so it also changes duration.
|
||||
//!
|
||||
//! Loudness rules (keep them; the user asked for safe test levels):
|
||||
//! - a requested volume may raise a quiet clip only until the clip's own peak
|
||||
//! reaches full scale (and never more than x4); category and master volumes
|
||||
//! (both <= 1) then scale it down. No clip plays hotter than full scale x master;
|
||||
//! - sounds start silent and fade in over at least `MIN_FADE`;
|
||||
//! - at most `MAX_VOICES` voices, `MAX_PER_CLIP` of one sound, and a repeat of
|
||||
//! the same sound needs `MIN_REPEAT` seconds.
|
||||
use super::{AudioSettings, library::Clip};
|
||||
use bevy::{
|
||||
audio::{AudioSinkPlayback, PlaybackMode, SpatialScale, Volume},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
const MAX_VOICES: usize = 32;
|
||||
const MAX_PER_CLIP: usize = 3;
|
||||
const MIN_REPEAT: f64 = 0.04;
|
||||
const MIN_FADE: f32 = 0.01;
|
||||
/// World metres are scaled by this before Bevy's distance attenuation, which
|
||||
/// never amplifies (gain = min(1, 1 / distance^2)). 0.1 keeps a skater a few
|
||||
/// metres from the camera at full level, like the mod adapter's default.
|
||||
const SPATIAL_SCALE: f32 = 0.1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Category {
|
||||
Ambience,
|
||||
Effects,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Play {
|
||||
pub category: Category,
|
||||
pub volume: f32,
|
||||
pub pitch: f32,
|
||||
/// World position for a spatial sound; None plays it unpanned.
|
||||
pub position: Option<Vec3>,
|
||||
pub looping: bool,
|
||||
pub fade_in: f32,
|
||||
}
|
||||
impl Play {
|
||||
pub(crate) fn effect(volume: f32, position: Vec3) -> Self {
|
||||
Self { category: Category::Effects, volume, pitch: 1.0, position: Some(position), looping: false, fade_in: MIN_FADE }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct VoiceId(u64);
|
||||
|
||||
struct Voice {
|
||||
id: VoiceId,
|
||||
entity: Entity,
|
||||
key: std::sync::Arc<str>,
|
||||
category: Category,
|
||||
looping: bool,
|
||||
volume: f32,
|
||||
max_volume: f32,
|
||||
pitch: f32,
|
||||
gain: f32,
|
||||
speed: f32,
|
||||
position: Option<Vec3>,
|
||||
attack: f32,
|
||||
attack_elapsed: f32,
|
||||
stopping: Option<(f32, f32)>,
|
||||
pending: f32,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Voices {
|
||||
voices: Vec<Voice>,
|
||||
next: u64,
|
||||
last_start: std::collections::HashMap<std::sync::Arc<str>, f64>,
|
||||
}
|
||||
|
||||
/// Largest volume for a clip whose loudest sample is `peak` (0..1 of full scale).
|
||||
fn max_volume(peak: f32) -> f32 {
|
||||
if peak.is_finite() && peak > 0.0 { (1.0 / peak).clamp(1.0, 4.0) } else { 1.0 }
|
||||
}
|
||||
fn clamp_volume(volume: f32, max: f32) -> f32 {
|
||||
if volume.is_finite() { volume.clamp(0.0, max) } else { 0.0 }
|
||||
}
|
||||
fn clamp_pitch(pitch: f32) -> f32 {
|
||||
if pitch.is_finite() { pitch.clamp(0.25, 4.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
impl Voices {
|
||||
/// Whether a new voice for `clip` may start now (`now` = real seconds).
|
||||
fn admit(&self, clip: &Clip, now: f64) -> bool {
|
||||
let active = self.voices.iter().filter(|v| v.stopping.is_none());
|
||||
if self.voices.len() >= MAX_VOICES || active.filter(|v| v.key == clip.key).count() >= MAX_PER_CLIP {
|
||||
return false;
|
||||
}
|
||||
self.last_start.get(&clip.key).is_none_or(|t| now - t >= MIN_REPEAT)
|
||||
}
|
||||
|
||||
pub(crate) fn play(&mut self, commands: &mut Commands, clip: &Clip, play: Play, now: f64) -> Option<VoiceId> {
|
||||
if !self.admit(clip, now) {
|
||||
return None;
|
||||
}
|
||||
self.last_start.insert(clip.key.clone(), now);
|
||||
let speed = clamp_pitch(play.pitch);
|
||||
let settings = PlaybackSettings {
|
||||
mode: if play.looping { PlaybackMode::Loop } else { PlaybackMode::Once },
|
||||
// Silent until `sync` applies the faded-in gain.
|
||||
volume: Volume::Linear(0.0),
|
||||
speed,
|
||||
paused: true,
|
||||
spatial: play.position.is_some(),
|
||||
spatial_scale: Some(SpatialScale::new(SPATIAL_SCALE)),
|
||||
..Default::default()
|
||||
};
|
||||
let transform = Transform::from_translation(play.position.unwrap_or(Vec3::ZERO));
|
||||
let entity = commands.spawn((AudioPlayer::new(clip.handle.clone()), settings, transform)).id();
|
||||
let id = VoiceId(self.next);
|
||||
self.next += 1;
|
||||
self.voices.push(Voice {
|
||||
id, entity, key: clip.key.clone(), category: play.category, looping: play.looping,
|
||||
volume: clamp_volume(play.volume, max_volume(clip.peak)), max_volume: max_volume(clip.peak), pitch: speed, gain: 0.0, speed,
|
||||
position: play.position, attack: play.fade_in.max(MIN_FADE), attack_elapsed: 0.0,
|
||||
stopping: None, pending: 0.0,
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Change a playing voice's target volume, pitch and position (smoothed).
|
||||
pub(crate) fn set(&mut self, id: VoiceId, volume: f32, pitch: f32, position: Option<Vec3>) {
|
||||
if let Some(v) = self.voices.iter_mut().find(|v| v.id == id && v.stopping.is_none()) {
|
||||
v.volume = clamp_volume(volume, v.max_volume);
|
||||
v.pitch = clamp_pitch(pitch);
|
||||
if v.position.is_some() {
|
||||
v.position = position.or(v.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade out and remove. Repeated calls never extend the fade.
|
||||
pub(crate) fn stop(&mut self, id: VoiceId, fade: f32) {
|
||||
if let Some(v) = self.voices.iter_mut().find(|v| v.id == id) {
|
||||
if v.stopping.is_none() {
|
||||
let fade = fade.max(MIN_FADE);
|
||||
v.stopping = Some((fade, fade));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn playing(&self, id: VoiceId) -> bool {
|
||||
self.voices.iter().any(|v| v.id == id)
|
||||
}
|
||||
|
||||
/// Whether any voice still plays `clip` (fading voices included).
|
||||
pub(crate) fn uses(&self, clip: &Clip) -> bool {
|
||||
self.voices.iter().any(|v| v.key == clip.key)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_sink(sink: &mut impl AudioSinkPlayback, volume: f32, speed: f32, paused: bool) -> bool {
|
||||
sink.set_volume(Volume::Linear(volume));
|
||||
sink.set_speed(speed);
|
||||
if paused {
|
||||
if !sink.is_paused() { sink.pause(); }
|
||||
} else if sink.is_paused() {
|
||||
sink.play();
|
||||
}
|
||||
sink.empty()
|
||||
}
|
||||
|
||||
type SinkQuery<'w, 's> = Query<
|
||||
'w, 's,
|
||||
(Option<&'static mut AudioSink>, Option<&'static mut SpatialAudioSink>, &'static mut PlaybackSettings, &'static mut Transform),
|
||||
>;
|
||||
|
||||
pub(super) fn sync(
|
||||
mut commands: Commands,
|
||||
mut voices: ResMut<Voices>,
|
||||
mut sinks: SinkQuery,
|
||||
settings: Option<Res<AudioSettings>>,
|
||||
time: Res<Time<Real>>,
|
||||
menu: Option<Res<crate::graphics_menu::Menu>>,
|
||||
replay: Res<crate::replay::Replay>,
|
||||
) {
|
||||
let Some(settings) = settings else { return };
|
||||
let dt = time.delta_secs().clamp(0.0, 0.25);
|
||||
let silenced = super::silenced(menu.as_deref(), &replay);
|
||||
let master = settings.master().clamp(0.0, 1.0);
|
||||
let mut finished = Vec::new();
|
||||
for v in &mut voices.voices {
|
||||
let Ok((sink, spatial, mut playback, mut transform)) = sinks.get_mut(v.entity) else {
|
||||
// Not spawned yet (commands from this frame) or despawned elsewhere.
|
||||
v.pending += dt;
|
||||
if v.pending > 1.0 { finished.push(v.id); }
|
||||
continue;
|
||||
};
|
||||
if let Some(position) = v.position { transform.translation = position; }
|
||||
if !silenced { v.attack_elapsed += dt; }
|
||||
let attack = (v.attack_elapsed / v.attack).min(1.0);
|
||||
let mut release = 1.0;
|
||||
if let Some((remaining, total)) = &mut v.stopping {
|
||||
*remaining -= dt;
|
||||
release = (*remaining / *total).max(0.0);
|
||||
if *remaining <= 0.0 { finished.push(v.id); continue; }
|
||||
}
|
||||
// Smooths parameter steps only (no dynamics processing).
|
||||
v.gain += (v.volume - v.gain) * (1.0 - (-dt / 0.012).exp());
|
||||
v.speed += (v.pitch - v.speed) * (1.0 - (-dt / 0.025).exp());
|
||||
let category = settings.category(v.category).clamp(0.0, 1.0);
|
||||
let gain = (v.gain * attack * release).clamp(0.0, v.max_volume) * category * master;
|
||||
let speed = v.speed.clamp(0.25, 4.0);
|
||||
// Before Bevy creates the sink, keep its initial settings current.
|
||||
playback.volume = Volume::Linear(gain);
|
||||
playback.speed = speed;
|
||||
playback.paused = silenced;
|
||||
let done = if let Some(mut sink) = sink {
|
||||
Some(set_sink(&mut *sink, gain, speed, silenced))
|
||||
} else if let Some(mut sink) = spatial {
|
||||
Some(set_sink(&mut *sink, gain, speed, silenced))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match done {
|
||||
Some(true) if !v.looping => finished.push(v.id),
|
||||
Some(_) => v.pending = 0.0,
|
||||
None => {
|
||||
if !silenced { v.pending += dt; }
|
||||
if v.pending > 2.0 && !v.looping { finished.push(v.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
voices.voices.retain(|v| {
|
||||
let keep = !finished.contains(&v.id);
|
||||
if !keep {
|
||||
if let Ok(mut entity) = commands.get_entity(v.entity) { entity.despawn(); }
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn clip(name: &str) -> Clip {
|
||||
Clip { handle: Handle::default(), key: name.into(), peak: 1.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_levels_are_clamped() {
|
||||
assert_eq!(clamp_volume(3.0, 1.0), 1.0);
|
||||
assert_eq!(clamp_volume(-1.0, 1.0), 0.0);
|
||||
assert_eq!(clamp_volume(f32::NAN, 1.0), 0.0);
|
||||
// A clip peaking at -12 dBFS may be raised x~4 (to full scale), never more.
|
||||
assert_eq!(max_volume(0.25), 4.0);
|
||||
assert_eq!(max_volume(0.5), 2.0);
|
||||
assert_eq!(max_volume(0.1), 4.0);
|
||||
assert_eq!(max_volume(1.0), 1.0);
|
||||
assert_eq!(max_volume(0.0), 1.0);
|
||||
assert_eq!(clamp_pitch(f32::INFINITY), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_voices_per_clip_and_repeats() {
|
||||
let mut world = World::new();
|
||||
let mut queue = bevy::ecs::world::CommandQueue::default();
|
||||
let mut commands = Commands::new(&mut queue, &world);
|
||||
let mut voices = Voices::default();
|
||||
let pop = clip("pop");
|
||||
let play = Play::effect(2.0, Vec3::ZERO);
|
||||
let first = voices.play(&mut commands, &pop, play, 0.0).unwrap();
|
||||
assert_eq!(voices.voices[0].volume, 1.0, "volume clamped");
|
||||
assert!(voices.play(&mut commands, &pop, play, 0.01).is_none(), "repeat too soon");
|
||||
assert!(voices.play(&mut commands, &pop, play, 0.05).is_some());
|
||||
assert!(voices.play(&mut commands, &pop, play, 0.10).is_some());
|
||||
assert!(voices.play(&mut commands, &pop, play, 0.20).is_none(), "three of one sound at most");
|
||||
voices.stop(first, 0.1);
|
||||
voices.stop(first, 5.0);
|
||||
assert_eq!(voices.voices[0].stopping, Some((0.1, 0.1)));
|
||||
assert!(voices.play(&mut commands, &pop, play, 0.30).is_some(), "fading voices do not count");
|
||||
for i in 0..64 {
|
||||
voices.play(&mut commands, &clip(&format!("c{i}")), play, 1.0);
|
||||
}
|
||||
assert_eq!(voices.voices.len(), MAX_VOICES);
|
||||
queue.apply(&mut world);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Water sounds on the map's water. When a map loads, its retail water
|
||||
//! collision (surface type 12) is grouped into bodies; each body gets a sound
|
||||
//! by size — moving fountain water, canal lapping, pond lapping — played from
|
||||
//! the point of that water nearest the camera and fading out with distance.
|
||||
//! Retail places water emitters by hand in its per-map `.ems` files; this is
|
||||
//! our own placement from the geometry (docs/hails-additions/11-audio.md).
|
||||
use super::{Category, Library, Play, Voices, library::Clip, voices::VoiceId};
|
||||
use crate::physics::GamePhysics;
|
||||
use bevy::prelude::*;
|
||||
use skate_core::physics::board_world::is_water_tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Banks and samples the water emitters play (preloaded at startup).
|
||||
pub(super) const PRELOAD: [(&str, &[usize]); 3] = [
|
||||
("water_fountain", &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
|
||||
("water_lapping", &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
|
||||
("water_lapping_pond", &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
|
||||
];
|
||||
|
||||
/// Surface sample spacing (m) for finding the nearest water.
|
||||
const GRID: f32 = 6.0;
|
||||
/// Positions closer than this (m) count as the same vertex when grouping.
|
||||
const WELD: f32 = 0.05;
|
||||
/// At most this many water bodies sound at once (the closest).
|
||||
const MAX_BODIES: usize = 3;
|
||||
/// Emitters are placed at most this far from the listener, in the direction
|
||||
/// of the water: Bevy's attenuation (scale 0.1) is then 1, and the distance
|
||||
/// fade below is the only one, while panning still points at the water.
|
||||
const PAN_DISTANCE: f32 = 8.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Fountain,
|
||||
Canal,
|
||||
Lake,
|
||||
}
|
||||
impl Kind {
|
||||
fn of(area: f32) -> Self {
|
||||
if area < 400.0 { Kind::Fountain } else if area < 30_000.0 { Kind::Canal } else { Kind::Lake }
|
||||
}
|
||||
/// (level, audible radius in m)
|
||||
fn level_and_reach(self) -> (f32, f32) {
|
||||
match self {
|
||||
Kind::Fountain => (0.45, 25.0),
|
||||
Kind::Canal => (0.4, 40.0),
|
||||
Kind::Lake => (0.45, 60.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Body {
|
||||
kind: Kind,
|
||||
points: Vec<Vec3>,
|
||||
}
|
||||
|
||||
/// Group water triangles into bodies (shared vertex positions) and sample each
|
||||
/// on a GRID. Pure: tested below.
|
||||
fn bodies(triangles: &[[Vec3; 3]]) -> Vec<(f32, Vec<Vec3>)> {
|
||||
let mut parent: Vec<usize> = (0..triangles.len()).collect();
|
||||
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
let mut first = HashMap::<[i32; 3], usize>::new();
|
||||
for (t, triangle) in triangles.iter().enumerate() {
|
||||
for p in triangle {
|
||||
let key = [p.x, p.y, p.z].map(|c| (c / WELD).round() as i32);
|
||||
let other = *first.entry(key).or_insert(t);
|
||||
let (a, b) = (root(&mut parent, t), root(&mut parent, other));
|
||||
parent[a] = b;
|
||||
}
|
||||
}
|
||||
let mut groups = HashMap::<usize, (f32, HashMap<(i32, i32), Vec3>)>::new();
|
||||
for (t, [a, b, c]) in triangles.iter().enumerate() {
|
||||
let r = root(&mut parent, t);
|
||||
let entry = groups.entry(r).or_default();
|
||||
entry.0 += 0.5 * (*b - *a).cross(*c - *a).length();
|
||||
let centre = (*a + *b + *c) / 3.0;
|
||||
// One sample per grid cell, plus the vertices of large triangles.
|
||||
for p in [centre, *a, *b, *c] {
|
||||
entry.1.entry(((p.x / GRID).floor() as i32, (p.z / GRID).floor() as i32)).or_insert(p);
|
||||
}
|
||||
}
|
||||
groups.into_values().map(|(area, cells)| (area, cells.into_values().collect())).collect()
|
||||
}
|
||||
|
||||
/// Distance fade: full near the water, silent at `reach`.
|
||||
fn fade(distance: f32, reach: f32) -> f32 {
|
||||
(1.0 - distance / reach).clamp(0.0, 1.0).powf(1.5)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Emitter {
|
||||
voice: Option<(VoiceId, Clip)>,
|
||||
next: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
map: Option<(String, u64)>,
|
||||
bodies: Vec<Body>,
|
||||
emitters: HashMap<usize, Emitter>,
|
||||
rng: u32,
|
||||
}
|
||||
|
||||
fn random(rng: &mut u32) -> f32 {
|
||||
if *rng == 0 {
|
||||
*rng = 0x9e37_79b9;
|
||||
}
|
||||
*rng ^= *rng << 13;
|
||||
*rng ^= *rng >> 17;
|
||||
*rng ^= *rng << 5;
|
||||
(*rng >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn update(
|
||||
mut state: Local<State>,
|
||||
mut commands: Commands,
|
||||
map: Res<crate::map_transition::CurrentMap>,
|
||||
physics: Res<GamePhysics>,
|
||||
library: Option<ResMut<Library>>,
|
||||
mut voices: ResMut<Voices>,
|
||||
mut assets: ResMut<Assets<AudioSource>>,
|
||||
listener: Query<&GlobalTransform, With<super::GameAudioListener>>,
|
||||
time: Res<Time<Real>>,
|
||||
menu: Option<Res<crate::graphics_menu::Menu>>,
|
||||
replay: Res<crate::replay::Replay>,
|
||||
) {
|
||||
let Some(mut library) = library else { return };
|
||||
let identity = (map.name.clone(), map.generation);
|
||||
if state.map.as_ref() != Some(&identity) {
|
||||
for (_, emitter) in state.emitters.drain() {
|
||||
if let Some((id, _)) = emitter.voice { voices.stop(id, 1.0); }
|
||||
}
|
||||
let water: Vec<[Vec3; 3]> = physics.world().triangles().iter().filter(|t| is_water_tag(t.tag))
|
||||
.map(|t| t.triangle.vertices.map(|v| Vec3::new(v.x, v.y, v.z))).collect();
|
||||
state.bodies = bodies(&water).into_iter().map(|(area, points)| Body { kind: Kind::of(area), points }).collect();
|
||||
let count = |k| state.bodies.iter().filter(|b| b.kind == k).count();
|
||||
info!("Water audio: {} bodies ({} fountains, {} canals, {} lakes)", state.bodies.len(),
|
||||
count(Kind::Fountain), count(Kind::Canal), count(Kind::Lake));
|
||||
state.map = Some(identity);
|
||||
}
|
||||
let Ok(ear) = listener.single().map(GlobalTransform::translation) else { return };
|
||||
if super::silenced(menu.as_deref(), &replay) {
|
||||
return;
|
||||
}
|
||||
let dt = time.delta_secs().clamp(0.0, 0.25);
|
||||
let now = time.elapsed_secs_f64();
|
||||
// Nearest point of each body; keep the closest audible ones.
|
||||
let mut audible: Vec<(usize, f32, Vec3)> = state.bodies.iter().enumerate().filter_map(|(i, body)| {
|
||||
let nearest = body.points.iter().min_by(|a, b| a.distance_squared(ear).total_cmp(&b.distance_squared(ear)))?;
|
||||
let d = nearest.distance(ear);
|
||||
(d < body.kind.level_and_reach().1).then_some((i, d, *nearest))
|
||||
}).collect();
|
||||
audible.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
// Only the nearest body of each kind: neighbouring fountains would play
|
||||
// copies of the same loop, which phase against each other.
|
||||
let mut kinds = Vec::new();
|
||||
audible.retain(|(i, ..)| {
|
||||
let kind = state.bodies[*i].kind;
|
||||
let first = !kinds.contains(&kind);
|
||||
kinds.push(kind);
|
||||
first
|
||||
});
|
||||
audible.truncate(MAX_BODIES);
|
||||
|
||||
let state = &mut *state;
|
||||
let mut rng = state.rng;
|
||||
// Stop bodies that went out of reach.
|
||||
state.emitters.retain(|i, emitter| {
|
||||
let keep = audible.iter().any(|(j, ..)| j == i);
|
||||
if !keep {
|
||||
if let Some((id, _)) = emitter.voice.take() { voices.stop(id, 0.8); }
|
||||
}
|
||||
keep
|
||||
});
|
||||
for (index, distance, nearest) in audible {
|
||||
let kind = state.bodies[index].kind;
|
||||
let (level, reach) = kind.level_and_reach();
|
||||
let volume = level * fade(distance, reach);
|
||||
let towards = (nearest - ear).normalize_or_zero() * distance.min(PAN_DISTANCE);
|
||||
let at = ear + towards;
|
||||
let emitter = state.emitters.entry(index).or_default();
|
||||
if emitter.voice.as_ref().is_some_and(|(id, _)| !voices.playing(*id)) {
|
||||
emitter.voice = None;
|
||||
}
|
||||
let mut play = Play { category: Category::Ambience, volume, pitch: 1.0, position: Some(at), looping: false, fade_in: 0.4 };
|
||||
// Short pieces, overlapping with soft fade-ins, so the water keeps
|
||||
// moving: fountains splash and flow (not still-water lapping, which
|
||||
// the user rejected for fountains), canals and lakes lap.
|
||||
let bank = match kind {
|
||||
Kind::Fountain => "water_fountain",
|
||||
Kind::Canal => "water_lapping",
|
||||
Kind::Lake => "water_lapping_pond",
|
||||
};
|
||||
emitter.next -= dt;
|
||||
if emitter.next <= 0.0 {
|
||||
let index = (random(&mut rng) * 10.0) as usize % 10;
|
||||
if let Some(clip) = library.sample(&mut assets, bank, index) {
|
||||
play.pitch = 0.95 + 0.1 * random(&mut rng);
|
||||
voices.play(&mut commands, &clip, play, now);
|
||||
}
|
||||
emitter.next = if kind == Kind::Fountain { 0.6 + 0.5 * random(&mut rng) } else { 0.7 + 0.6 * random(&mut rng) };
|
||||
}
|
||||
}
|
||||
state.rng = rng;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn quad(x0: f32, size: f32) -> [[Vec3; 3]; 2] {
|
||||
let p = |x: f32, z: f32| Vec3::new(x0 + x, 1.0, z);
|
||||
[[p(0., 0.), p(size, 0.), p(size, size)], [p(0., 0.), p(size, size), p(0., size)]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_water_is_separate_bodies_with_samples() {
|
||||
let mut triangles = quad(0.0, 10.0).to_vec();
|
||||
triangles.extend(quad(500.0, 200.0));
|
||||
let mut found = bodies(&triangles);
|
||||
found.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert_eq!(found.len(), 2);
|
||||
assert!((found[0].0 - 100.0).abs() < 0.01);
|
||||
assert!((found[1].0 - 40_000.0).abs() < 1.0);
|
||||
assert_eq!(Kind::of(found[0].0), Kind::Fountain);
|
||||
assert_eq!(Kind::of(found[1].0), Kind::Lake);
|
||||
assert!(!found[0].1.is_empty() && !found[1].1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_is_full_near_and_silent_at_reach() {
|
||||
assert_eq!(fade(0.0, 40.0), 1.0);
|
||||
assert_eq!(fade(40.0, 40.0), 0.0);
|
||||
assert_eq!(fade(80.0, 40.0), 0.0);
|
||||
assert!(fade(20.0, 40.0) > 0.3 && fade(20.0, 40.0) < 0.4);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ pub(crate) fn gameplay_active(menu: Option<Res<Menu>>) -> bool {
|
||||
const SECTIONS: &[(&str, &str)] = &[
|
||||
("MAPS", "Choose a map, then pick your drop-in spot."),
|
||||
("SKATER", "Make it yours."),
|
||||
("GRAPHICS", "Dial in your display and performance."),
|
||||
("GRAPHICS", "Dial in your display, performance and sound."),
|
||||
("MULTIPLAYER", "A session is better with friends."),
|
||||
("EXTRAS", "Mods, updates and more."),
|
||||
];
|
||||
@@ -153,7 +153,7 @@ impl Menu {
|
||||
0 => (1000..1000 + self.maps.len()).collect(),
|
||||
1 if self.difficulty == Difficulty::Custom => std::iter::once(3).chain(300..337).chain([8,10]).collect(),
|
||||
1 => vec![3, 8, 10],
|
||||
2 => vec![0, 1, 2, 13],
|
||||
2 => vec![0, 1, 2, 13, 16, 17, 18],
|
||||
4 => vec![7, 11, 14],
|
||||
i if i >= SECTIONS.len() => self.custom_sections.get(i-SECTIONS.len()).map_or(Vec::new(), |(_,entries)| (200..200+entries.len()).collect()),
|
||||
_ => Vec::new(),
|
||||
@@ -288,7 +288,7 @@ fn setup(
|
||||
body.spawn((Text::new(""),MenuSubtitle,TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.65,0.72,0.72))));
|
||||
body.spawn((Node {height:px(3),width:px(64),margin:UiRect::bottom(px(10)),..default()},BackgroundColor(Color::srgb(0.78,0.96,0.3))));
|
||||
body.spawn((MenuScroll,ScrollPosition::default(),Node {flex_grow:1.,min_height:px(0),overflow:Overflow::scroll_y(),flex_direction:FlexDirection::Column,row_gap:px(8),..default()})).with_children(|list| {
|
||||
for i in (0..4).chain(300..337).chain(4..10).chain(11..16).chain(20..27).chain([10]).chain(200..264).chain([50,51]).chain(1000..1000+maps.len()).chain(1_000_000..1_000_000+destinations.len()) {
|
||||
for i in (0..4).chain(300..337).chain(4..10).chain(11..19).chain(20..27).chain([10]).chain(200..264).chain([50,51]).chain(1000..1000+maps.len()).chain(1_000_000..1_000_000+destinations.len()) {
|
||||
list.spawn((Button,MenuRow(i),Node {flex_direction:if (300..335).contains(&i) {FlexDirection::Column} else {FlexDirection::Row},width:percent(100),min_height:px(56),flex_shrink:0.,padding:UiRect::axes(px(18),px(12)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(4)),..default()},BackgroundColor(Color::srgb(0.075,0.09,0.095))))
|
||||
.with_children(|row| {
|
||||
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
|
||||
@@ -322,6 +322,12 @@ fn setup(
|
||||
daylight: false, section: 0, custom_sections: Vec::new(), map_detail: false, destinations, pending_travel: None,
|
||||
});
|
||||
}
|
||||
/// GRAPHICS-section rows for the game_audio volume settings.
|
||||
const AUDIO_ROWS: std::ops::Range<usize> = 16..19;
|
||||
fn audio_row(row: usize) -> crate::game_audio::AudioRow {
|
||||
use crate::game_audio::AudioRow;
|
||||
match row { 16 => AudioRow::Master, 17 => AudioRow::Ambience, _ => AudioRow::Effects }
|
||||
}
|
||||
fn cycle<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
|
||||
let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32;
|
||||
values[(index + direction).rem_euclid(values.len() as i32) as usize]
|
||||
@@ -354,7 +360,7 @@ pub(crate) fn interact(
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
mut net: ResMut<crate::multiplayer::Multiplayer>,
|
||||
mut typing: MessageReader<bevy::input::keyboard::KeyboardInput>,
|
||||
mut updater: ResMut<crate::updater::Updater>,
|
||||
(mut updater, mut audio): (ResMut<crate::updater::Updater>, ResMut<crate::game_audio::AudioSettings>),
|
||||
travel: Res<crate::teleport_menu::Travel>,
|
||||
mut mods: ResMut<crate::modding::ModMenu>,
|
||||
) {
|
||||
@@ -423,7 +429,7 @@ pub(crate) fn interact(
|
||||
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
|
||||
menu.selected = visible[(index + 1) % rows];
|
||||
}
|
||||
let adjustable = (menu.section == 1 && menu.difficulty == Difficulty::Custom && (300..335).contains(&menu.selected)) || (menu.daylight && menu.selected < 3) || (!menu.multiplayer && !menu.daylight && menu.selected < 4);
|
||||
let adjustable = (menu.section == 1 && menu.difficulty == Difficulty::Custom && (300..335).contains(&menu.selected)) || (menu.daylight && menu.selected < 3) || (!menu.multiplayer && !menu.daylight && (menu.selected < 4 || AUDIO_ROWS.contains(&menu.selected)));
|
||||
if adjustable && (keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0) {
|
||||
action = Some((menu.selected, -1));
|
||||
}
|
||||
@@ -587,6 +593,7 @@ pub(crate) fn interact(
|
||||
},
|
||||
13 => { menu.daylight = true; menu.selected = 0; menu.status = "Custom maps: change time, cycle speed and ambient light. Retail lighting stays authored.".into(); },
|
||||
14 => mods.begin(),
|
||||
16..=18 => menu.status = audio.adjust(audio_row(row), direction),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -675,6 +682,7 @@ fn labels(
|
||||
mut headings: Query<(&mut Text, Has<MenuTitle>), (Or<(With<MenuTitle>, With<MenuSubtitle>)>, Without<StatusLabel>)>,
|
||||
mut status: Single<&mut Text, With<StatusLabel>>,
|
||||
debug: (Res<crate::modding::Mods>, Res<crate::physics::GamePhysics>, Res<crate::multiplayer::appearance::Appearances>),
|
||||
audio: Option<Res<crate::game_audio::AudioSettings>>,
|
||||
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without<MenuRoot>>,
|
||||
) {
|
||||
root.display = if menu.open && !travel.open && !customiser.open && !custom_models.open && !mods.open {
|
||||
@@ -814,6 +822,7 @@ fn labels(
|
||||
12 => "Teleport".into(),
|
||||
13 => "Day & night".into(),
|
||||
14 => "Mods".into(),
|
||||
16..=18 => audio.as_ref().map(|a| a.label(audio_row(label.0))).unwrap_or_default(),
|
||||
_ => "Multiplayer".into(),
|
||||
}
|
||||
};
|
||||
@@ -1011,7 +1020,8 @@ mod tests {
|
||||
let rows = menu.rows();
|
||||
assert!(rows.contains(&menu.selected));
|
||||
assert!(rows.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
assert!(rows.iter().all(|id| *id < 16 || *id >= 1000));
|
||||
// Spawned row ids: 0..19 (16..19 are the audio rows), 1000+ maps.
|
||||
assert!(rows.iter().all(|id| *id < 19 || *id >= 1000));
|
||||
}
|
||||
menu.select_section(1);
|
||||
assert_eq!(menu.rows(),vec![3,8,10]);
|
||||
@@ -1046,7 +1056,7 @@ mod tests {
|
||||
assert!(!SECTIONS.iter().any(|(name,_)| matches!(*name,"SESSION"|"WORLD")));
|
||||
menu.select_section(2);
|
||||
assert!(!menu.multiplayer && !menu.browser);
|
||||
assert_eq!(menu.rows(), vec![0,1,2,13]);
|
||||
assert_eq!(menu.rows(), vec![0,1,2,13,16,17,18]);
|
||||
menu.daylight = true;
|
||||
assert_eq!(menu.rows(), vec![0,1,2,3]);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ mod grind_world;
|
||||
mod skate_world;
|
||||
mod map_validation;
|
||||
mod water_splash;
|
||||
mod game_audio;
|
||||
mod water_bodies;
|
||||
|
||||
fn main() -> bevy::app::AppExit {
|
||||
|
||||
@@ -37,8 +37,6 @@ struct ModAudio {
|
||||
clips: BTreeMap<Key, Clip>,
|
||||
voices: BTreeMap<Key, Voice>,
|
||||
}
|
||||
#[derive(Component)]
|
||||
struct ModAudioListener;
|
||||
|
||||
pub(super) fn install(app: &mut App) {
|
||||
app.init_resource::<ModAudio>();
|
||||
@@ -202,21 +200,9 @@ fn sync(world: &mut World) {
|
||||
let global_gain = world.get_resource::<GlobalVolume>()
|
||||
.map_or(1.0, |volume| volume.volume.to_linear());
|
||||
let global_gain = if global_gain.is_finite() { global_gain.clamp(0.0, 1.0) } else { 0.0 };
|
||||
// Use this frame's camera Transform. Bevy propagates it before audio PostUpdate.
|
||||
// The spatial listener (shared with game audio) follows the camera in game_audio.
|
||||
let camera = world.query_filtered::<&Transform, With<crate::camera::GameplayCamera>>()
|
||||
.iter(world).next().copied();
|
||||
if let Some(camera) = camera {
|
||||
let listener = world.query_filtered::<Entity, With<ModAudioListener>>().iter(world).next();
|
||||
if let Some(entity) = listener { world.entity_mut(entity).insert(camera); }
|
||||
else {
|
||||
// The supplied project has no other audio listener. Do not create two
|
||||
// if a later engine version already supplies one.
|
||||
let external = world.query_filtered::<Entity, With<SpatialListener>>().iter(world).next();
|
||||
if external.is_none() {
|
||||
world.spawn((ModAudioListener, SpatialListener::new(0.2), camera));
|
||||
}
|
||||
}
|
||||
}
|
||||
let paused = {
|
||||
let mods = world.resource::<Mods>();
|
||||
camera.is_none() || mods.manager.snapshot["paused"].as_bool().unwrap_or(true)
|
||||
|
||||
@@ -408,6 +408,17 @@ impl Plugin for PhysicsPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of each animated foot (left, right) above the ground line test under
|
||||
/// it, with that line's surface tag; None where the line found no ground.
|
||||
/// Read-only, for game_audio footsteps.
|
||||
pub(crate) fn foot_clearance(skater: &SkaterRuntime) -> [Option<(f32, u32)>; 2] {
|
||||
let input = biped_ground::services::feet_input(skater);
|
||||
std::array::from_fn(|i| {
|
||||
let line = &input.lines[i];
|
||||
line.valid.then(|| (input.world_foot_pairs[i][0][1] - line.position[1], line.surface))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn advance(
|
||||
mut physics: ResMut<GamePhysics>,
|
||||
mut skater: ResMut<SkaterRuntime>,
|
||||
|
||||
@@ -20,7 +20,21 @@ use skate_core::{
|
||||
};
|
||||
use skate_data::{animation_frames::AnimationFrames, collections::Collections};
|
||||
|
||||
/// Audible animation events since game_audio last took them. Native clears
|
||||
/// these attributes within the tick, so they are latched here as they occur.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct AudioEvents {
|
||||
/// `push_contact` (foot hits the ground while pushing).
|
||||
pub push: bool,
|
||||
/// `brake_contact` with strength +1 (foot goes down to brake) / -1 (foot up).
|
||||
pub brake_down: bool,
|
||||
pub brake_up: bool,
|
||||
/// Largest `AudibleFootStepStrength` seen.
|
||||
pub footstep: f32,
|
||||
}
|
||||
|
||||
pub(crate) struct AnimationInput {
|
||||
pub audio: AudioEvents,
|
||||
pub fields: ScalarAttributeInputs,
|
||||
pub extra: ExtendedAttributes,
|
||||
pub contacts: ContactEventState,
|
||||
@@ -41,6 +55,7 @@ impl AnimationInput {
|
||||
.position(|n| n.eq_ignore_ascii_case("RightToeBase"))
|
||||
.ok_or("Stock skeleton is missing RightToeBase")?;
|
||||
Ok(Self {
|
||||
audio: AudioEvents::default(),
|
||||
height_overrides: crate::difficulty::NATIVE_MODES.map(|key|
|
||||
data.boolean("physics_mode", key, "JumpHeightOverrideEnabled"))
|
||||
.into_iter().collect::<Result<Vec<_>, _>>()?.try_into().unwrap(),
|
||||
@@ -135,7 +150,7 @@ impl AnimationInput {
|
||||
external_impulse_active,
|
||||
..self.settings
|
||||
};
|
||||
process_attributes::process(
|
||||
let result = process_attributes::process(
|
||||
attributes,
|
||||
&pose,
|
||||
&mut self.fields,
|
||||
@@ -145,6 +160,14 @@ impl AnimationInput {
|
||||
&mut self.output,
|
||||
settings,
|
||||
Some(map),
|
||||
)
|
||||
);
|
||||
// Observation only: nothing in physics reads `audio`.
|
||||
self.audio.push |= self.fields.flags2468 & (1 << 27) != 0;
|
||||
// contact_events: brake_contact sets bit 28, plus 30 for strength +1 and 23 for -1.
|
||||
let brake = self.fields.flags2468 & (1 << 28) != 0;
|
||||
self.audio.brake_down |= brake && self.fields.flags2468 & (1 << 30) != 0;
|
||||
self.audio.brake_up |= brake && self.fields.flags2468 & (1 << 23) != 0;
|
||||
self.audio.footstep = self.audio.footstep.max(self.extra.footstep_strength);
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -616,7 +616,7 @@ mod tests {
|
||||
world.insert_resource(crate::config::Config {
|
||||
asset_root: "unused".into(), verification_capture: None,
|
||||
map: None, map_path: None, difficulty: crate::difficulty::Difficulty::Hardcore,
|
||||
check_assets: false, validate_maps: false, start_paused: false, teleport: None,
|
||||
check_assets: false, validate_maps: false, start_paused: false, teleport: None, mute: false,
|
||||
multiplayer: Default::default(), map_fingerprint: 0,
|
||||
});
|
||||
world.insert_resource(crate::retail_render::RetailScene(false));
|
||||
|
||||
@@ -27,7 +27,7 @@ fn installed_worlds_prepare_commit_and_retire_without_simulation() {
|
||||
};
|
||||
let mut world = World::new();
|
||||
world.insert_resource(Config { asset_root: root.clone(), verification_capture: None,
|
||||
map: None, map_path: None, difficulty, check_assets: false, validate_maps: false, start_paused: false, teleport: None, multiplayer: Default::default(), map_fingerprint: crate::config::map_fingerprint(None).unwrap() });
|
||||
map: None, map_path: None, difficulty, check_assets: false, validate_maps: false, start_paused: false, teleport: None, mute: false, multiplayer: Default::default(), map_fingerprint: crate::config::map_fingerprint(None).unwrap() });
|
||||
world.insert_resource(CurrentMap::from_package(None, None));
|
||||
world.insert_resource(graphs);
|
||||
world.insert_resource(physics);
|
||||
|
||||
Reference in new issue
Block a user