validate_movement now inserts SoundEventEmitter with Footstep events on every successful move. Intensity scales by stance: Sprint 0.8, Walk 0.5, Careful 0.3, Crouch 0.15. Range is Close (3 tiles) for all stances. This completes the sound event pipeline end-to-end: movement produces events → collect_sound_events drains to queue → observer snapshot includes audible events → client bridge receives them. Addresses Tyre critical review item #1 on PR #42. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
503 lines
16 KiB
Rust
503 lines
16 KiB
Rust
// Sound event system — server side (#124)
|
|
// Implements D-038: SoundEventEmitter → SoundEventQueue → ObserverSnapshot.
|
|
// Event-driven: emitters post events each tick, queue fans out to subscribers.
|
|
//
|
|
// Range model per D-018:
|
|
// Close ≤ 3 tiles — always heard, spatial positioning
|
|
// Medium ≤ 8 tiles — heard if not obstructed
|
|
// Long ≤ 20 tiles — heard in quiet conditions
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::knowledge::types::SoundRange;
|
|
use crate::simulation::movement::TilePosition;
|
|
|
|
// --- Sound event type taxonomy ---
|
|
|
|
/// Typed sound event categories.
|
|
/// Client maps each kind to its audio asset registry key (D-038).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum SoundEventKind {
|
|
/// Footstep — emitted by moving entities. Intensity varies by stance.
|
|
Footstep,
|
|
/// Voice — dialogue, monologue, NPC speech.
|
|
Voice,
|
|
/// Machinery — terminals, doors, consoles, mechanical activity.
|
|
Machinery,
|
|
/// Alert — alarms, warnings, emergency signals.
|
|
Alert,
|
|
/// Ambient — location atmosphere, background environment.
|
|
Ambient,
|
|
}
|
|
|
|
// --- Sound event ---
|
|
|
|
/// A single sound event emitted this tick.
|
|
/// Produced by `SoundEventEmitter`, collected into `SoundEventQueue`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SoundEvent {
|
|
/// What kind of sound this is.
|
|
pub kind: SoundEventKind,
|
|
/// World position where the sound originates (tile centre).
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub z: i32,
|
|
/// Normalized intensity in [0.0, 1.0]. Drives volume at the client.
|
|
pub intensity: f32,
|
|
/// How far this sound propagates (D-018 three-range model).
|
|
pub range: SoundRange,
|
|
/// Stable entity ID of the source, if any.
|
|
/// None for procedural or world-generated events (e.g. Ambient).
|
|
pub source_entity_id: Option<u64>,
|
|
}
|
|
|
|
impl SoundEvent {
|
|
/// Create an event at a tile position.
|
|
pub fn at(
|
|
pos: &TilePosition,
|
|
kind: SoundEventKind,
|
|
intensity: f32,
|
|
range: SoundRange,
|
|
source_entity_id: Option<u64>,
|
|
) -> Self {
|
|
let (x, y, z) = pos.to_render_coords();
|
|
Self {
|
|
kind,
|
|
x,
|
|
y,
|
|
z,
|
|
intensity,
|
|
range,
|
|
source_entity_id,
|
|
}
|
|
}
|
|
|
|
/// Manhattan-distance range ceiling in tiles for each category (D-018).
|
|
pub fn max_range_tiles(range: SoundRange) -> u32 {
|
|
match range {
|
|
SoundRange::Close => 3,
|
|
SoundRange::Medium => 8,
|
|
SoundRange::Long => 20,
|
|
}
|
|
}
|
|
|
|
/// Whether this sound is audible at `listener_pos`.
|
|
/// Simple tile-distance check — no wall/obstruction occlusion.
|
|
/// TODO: Medium-range sounds should be attenuated or blocked by walls
|
|
/// per D-018. Requires LOS integration (backlog — not in v0.1 scope).
|
|
pub fn audible_at(&self, listener_pos: &TilePosition) -> bool {
|
|
let ceil = Self::max_range_tiles(self.range);
|
|
let dx = (self.x.floor() as i32).abs_diff(listener_pos.x);
|
|
let dy = (self.y.floor() as i32).abs_diff(listener_pos.y);
|
|
let dz = (self.z).abs_diff(listener_pos.z);
|
|
dz == 0 && dx + dy <= ceil
|
|
}
|
|
}
|
|
|
|
// --- Emitter component ---
|
|
|
|
/// Component: entity emits sound events this tick.
|
|
///
|
|
/// Attached transiently — systems add this component to entities when they
|
|
/// produce sound (step taken, line spoken, door opened). The `collect_sound_events`
|
|
/// system harvests all emitters each tick, drains their pending events into
|
|
/// `SoundEventQueue`, and removes the component.
|
|
///
|
|
/// Usage pattern (illustrative):
|
|
/// ```ignore
|
|
/// commands.entity(npc).insert(SoundEventEmitter::new(
|
|
/// SoundEvent::at(&pos, SoundEventKind::Footstep, 0.6, SoundRange::Close, Some(npc_id))
|
|
/// ));
|
|
/// ```
|
|
#[derive(Component, Debug, Clone, Default)]
|
|
pub struct SoundEventEmitter {
|
|
pub pending: Vec<SoundEvent>,
|
|
}
|
|
|
|
impl SoundEventEmitter {
|
|
pub fn new(event: SoundEvent) -> Self {
|
|
Self {
|
|
pending: vec![event],
|
|
}
|
|
}
|
|
|
|
pub fn with(mut self, event: SoundEvent) -> Self {
|
|
self.pending.push(event);
|
|
self
|
|
}
|
|
}
|
|
|
|
// --- Queue resource ---
|
|
|
|
/// Resource: sound events produced this tick.
|
|
///
|
|
/// `collect_sound_events` drains all `SoundEventEmitter` components into this
|
|
/// resource each tick. Consumers (observer snapshot builder, NPC awareness
|
|
/// system) read from the queue. Cleared at the top of each tick.
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct SoundEventQueue {
|
|
pub events: Vec<SoundEvent>,
|
|
}
|
|
|
|
impl SoundEventQueue {
|
|
/// Drain all queued events, leaving the queue empty.
|
|
pub fn drain(&mut self) -> Vec<SoundEvent> {
|
|
std::mem::take(&mut self.events)
|
|
}
|
|
|
|
/// Return events audible at a listener position, without draining.
|
|
pub fn audible_at<'a>(&'a self, pos: &'a TilePosition) -> impl Iterator<Item = &'a SoundEvent> {
|
|
self.events.iter().filter(move |e| e.audible_at(pos))
|
|
}
|
|
}
|
|
|
|
// --- Collection system ---
|
|
|
|
/// System: harvest SoundEventEmitters → SoundEventQueue.
|
|
///
|
|
/// Runs each tick after movement/monologue/dialogue systems have fired.
|
|
/// Removes the emitter component after draining. Ordering: after movement,
|
|
/// before `compute_observer_snapshot`.
|
|
///
|
|
/// Producers: `validate_movement` inserts SoundEventEmitter with Footstep
|
|
/// events on every successful move. Voice events (dialogue) are future scope.
|
|
pub fn collect_sound_events(
|
|
mut commands: Commands,
|
|
mut queue: ResMut<SoundEventQueue>,
|
|
mut emitters: Query<(Entity, &mut SoundEventEmitter)>,
|
|
) {
|
|
queue.events.clear();
|
|
for (entity, mut emitter) in emitters.iter_mut() {
|
|
queue.events.extend(emitter.pending.drain(..));
|
|
commands.entity(entity).remove::<SoundEventEmitter>();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn tile(x: i32, y: i32) -> TilePosition {
|
|
TilePosition::new(x, y, 0)
|
|
}
|
|
|
|
fn close_event(pos: &TilePosition) -> SoundEvent {
|
|
SoundEvent::at(pos, SoundEventKind::Footstep, 0.5, SoundRange::Close, None)
|
|
}
|
|
|
|
fn medium_event(pos: &TilePosition) -> SoundEvent {
|
|
SoundEvent::at(pos, SoundEventKind::Voice, 0.7, SoundRange::Medium, None)
|
|
}
|
|
|
|
#[test]
|
|
fn close_range_audible_within_3_tiles() {
|
|
let source = tile(5, 5);
|
|
let event = close_event(&source);
|
|
assert!(event.audible_at(&tile(5, 5)), "audible at origin");
|
|
assert!(event.audible_at(&tile(5, 8)), "audible at distance 3");
|
|
assert!(!event.audible_at(&tile(5, 9)), "not audible at distance 4");
|
|
}
|
|
|
|
#[test]
|
|
fn medium_range_audible_within_8_tiles() {
|
|
let source = tile(0, 0);
|
|
let event = medium_event(&source);
|
|
assert!(event.audible_at(&tile(4, 4)), "audible at manhattan 8");
|
|
assert!(!event.audible_at(&tile(5, 4)), "not audible at manhattan 9");
|
|
}
|
|
|
|
#[test]
|
|
fn different_z_level_not_audible() {
|
|
let source = tile(5, 5);
|
|
let event = close_event(&source);
|
|
let above = TilePosition::new(5, 5, 1);
|
|
assert!(!event.audible_at(&above), "different z not audible");
|
|
}
|
|
|
|
#[test]
|
|
fn collect_system_drains_emitters_into_queue() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
|
|
let pos = tile(5, 5);
|
|
let _entity = world
|
|
.spawn(SoundEventEmitter::new(close_event(&pos)))
|
|
.id();
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
schedule.run(&mut world);
|
|
|
|
let queue = world.resource::<SoundEventQueue>();
|
|
assert_eq!(queue.events.len(), 1);
|
|
assert_eq!(queue.events[0].kind, SoundEventKind::Footstep);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_system_removes_emitter_component() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
|
|
let entity = world
|
|
.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))))
|
|
.id();
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
schedule.run(&mut world);
|
|
|
|
assert!(
|
|
world.get::<SoundEventEmitter>(entity).is_none(),
|
|
"emitter component should be removed after collection"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queue_audible_at_filters_by_range() {
|
|
let mut queue = SoundEventQueue::default();
|
|
let close_pos = tile(5, 5);
|
|
let far_pos = tile(20, 20);
|
|
queue.events.push(close_event(&close_pos));
|
|
queue.events.push(close_event(&far_pos));
|
|
|
|
let listener = tile(5, 6);
|
|
let heard: Vec<&SoundEvent> = queue.audible_at(&listener).collect();
|
|
assert_eq!(heard.len(), 1, "only close sound is audible");
|
|
}
|
|
|
|
#[test]
|
|
fn queue_clears_each_tick() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
world.spawn(SoundEventEmitter::new(close_event(&tile(0, 0))));
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
|
|
// Tick 1: event collected
|
|
schedule.run(&mut world);
|
|
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 1);
|
|
|
|
// Tick 2: no new emitters → queue cleared
|
|
schedule.run(&mut world);
|
|
assert_eq!(world.resource::<SoundEventQueue>().events.len(), 0);
|
|
}
|
|
|
|
// --- Multi-emitter and multi-event tests ---
|
|
|
|
#[test]
|
|
fn multiple_emitters_collected_in_one_tick() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
|
|
world.spawn(SoundEventEmitter::new(close_event(&tile(1, 1))));
|
|
world.spawn(SoundEventEmitter::new(close_event(&tile(2, 2))));
|
|
world.spawn(SoundEventEmitter::new(medium_event(&tile(3, 3))));
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
schedule.run(&mut world);
|
|
|
|
let queue = world.resource::<SoundEventQueue>();
|
|
assert_eq!(queue.events.len(), 3, "all three emitters collected");
|
|
}
|
|
|
|
#[test]
|
|
fn emitter_with_multiple_pending_events_all_drained() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
|
|
let pos = tile(5, 5);
|
|
let emitter = SoundEventEmitter::new(close_event(&pos))
|
|
.with(medium_event(&pos))
|
|
.with(SoundEvent::at(
|
|
&pos,
|
|
SoundEventKind::Alert,
|
|
1.0,
|
|
SoundRange::Long,
|
|
None,
|
|
));
|
|
world.spawn(emitter);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
schedule.run(&mut world);
|
|
|
|
let queue = world.resource::<SoundEventQueue>();
|
|
assert_eq!(
|
|
queue.events.len(),
|
|
3,
|
|
"all three pending events from one emitter collected"
|
|
);
|
|
}
|
|
|
|
// --- Long range audibility tests (D-018) ---
|
|
|
|
#[test]
|
|
fn long_range_sound_audible_within_20_tiles() {
|
|
let source = tile(0, 0);
|
|
let event =
|
|
SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
|
|
|
|
// Manhattan distance 20 — exactly at boundary
|
|
let listener = tile(10, 10);
|
|
assert!(
|
|
event.audible_at(&listener),
|
|
"Long range sound audible at manhattan 20"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn long_range_sound_not_audible_beyond_20_tiles() {
|
|
let source = tile(0, 0);
|
|
let event =
|
|
SoundEvent::at(&source, SoundEventKind::Alert, 0.9, SoundRange::Long, None);
|
|
|
|
let listener = tile(11, 10); // manhattan 21
|
|
assert!(
|
|
!event.audible_at(&listener),
|
|
"Long range sound not audible at manhattan 21"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn long_range_audible_at_origin() {
|
|
let source = tile(5, 5);
|
|
let event =
|
|
SoundEvent::at(&source, SoundEventKind::Machinery, 0.5, SoundRange::Long, None);
|
|
assert!(event.audible_at(&source), "audible at source position");
|
|
}
|
|
|
|
// --- Sound event field preservation tests ---
|
|
|
|
#[test]
|
|
fn source_entity_id_preserved_through_collection() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
|
|
let pos = tile(5, 5);
|
|
let event = SoundEvent::at(
|
|
&pos,
|
|
SoundEventKind::Voice,
|
|
0.8,
|
|
SoundRange::Close,
|
|
Some(42),
|
|
);
|
|
world.spawn(SoundEventEmitter::new(event));
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
schedule.run(&mut world);
|
|
|
|
let queue = world.resource::<SoundEventQueue>();
|
|
assert_eq!(queue.events.len(), 1);
|
|
assert_eq!(
|
|
queue.events[0].source_entity_id,
|
|
Some(42),
|
|
"source_entity_id must be preserved through collection"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn intensity_preserved_through_collection() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
|
|
let pos = tile(5, 5);
|
|
let event = SoundEvent::at(
|
|
&pos,
|
|
SoundEventKind::Footstep,
|
|
0.37,
|
|
SoundRange::Close,
|
|
None,
|
|
);
|
|
world.spawn(SoundEventEmitter::new(event));
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
schedule.run(&mut world);
|
|
|
|
let queue = world.resource::<SoundEventQueue>();
|
|
assert_eq!(queue.events.len(), 1);
|
|
assert!(
|
|
(queue.events[0].intensity - 0.37).abs() < f32::EPSILON,
|
|
"intensity must be preserved through collection"
|
|
);
|
|
}
|
|
|
|
// --- All sound event kinds ---
|
|
|
|
#[test]
|
|
fn all_sound_event_kinds_can_be_emitted_and_collected() {
|
|
let kinds = [
|
|
SoundEventKind::Footstep,
|
|
SoundEventKind::Voice,
|
|
SoundEventKind::Machinery,
|
|
SoundEventKind::Alert,
|
|
SoundEventKind::Ambient,
|
|
];
|
|
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(SoundEventQueue::default());
|
|
let pos = tile(5, 5);
|
|
|
|
for kind in kinds {
|
|
world.spawn(SoundEventEmitter::new(SoundEvent::at(
|
|
&pos,
|
|
kind,
|
|
0.5,
|
|
SoundRange::Close,
|
|
None,
|
|
)));
|
|
}
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(collect_sound_events);
|
|
schedule.run(&mut world);
|
|
|
|
let queue = world.resource::<SoundEventQueue>();
|
|
assert_eq!(
|
|
queue.events.len(),
|
|
5,
|
|
"all five SoundEventKind variants collected"
|
|
);
|
|
|
|
let collected_kinds: std::collections::HashSet<SoundEventKind> =
|
|
queue.events.iter().map(|e| e.kind).collect();
|
|
for kind in [
|
|
SoundEventKind::Footstep,
|
|
SoundEventKind::Voice,
|
|
SoundEventKind::Machinery,
|
|
SoundEventKind::Alert,
|
|
SoundEventKind::Ambient,
|
|
] {
|
|
assert!(collected_kinds.contains(&kind), "{kind:?} not collected");
|
|
}
|
|
}
|
|
|
|
// --- max_range_tiles spec verification (D-018) ---
|
|
|
|
#[test]
|
|
fn max_range_tiles_values_match_d018() {
|
|
assert_eq!(SoundEvent::max_range_tiles(SoundRange::Close), 3);
|
|
assert_eq!(SoundEvent::max_range_tiles(SoundRange::Medium), 8);
|
|
assert_eq!(SoundEvent::max_range_tiles(SoundRange::Long), 20);
|
|
}
|
|
|
|
// --- SoundEventQueue::drain test ---
|
|
|
|
#[test]
|
|
fn queue_drain_empties_the_queue() {
|
|
let mut queue = SoundEventQueue::default();
|
|
let pos = tile(5, 5);
|
|
queue.events.push(close_event(&pos));
|
|
queue.events.push(medium_event(&pos));
|
|
assert_eq!(queue.events.len(), 2);
|
|
|
|
let drained = queue.drain();
|
|
assert_eq!(drained.len(), 2, "drain returns all events");
|
|
assert_eq!(queue.events.len(), 0, "queue empty after drain");
|
|
}
|
|
}
|