// 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, } impl SoundEvent { /// Create an event at a tile position. pub fn at( pos: &TilePosition, kind: SoundEventKind, intensity: f32, range: SoundRange, source_entity_id: Option, ) -> 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; occlusion is a future concern (D-018 note). 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, } 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, } impl SoundEventQueue { /// Drain all queued events, leaving the queue empty. pub fn drain(&mut self) -> Vec { 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 { 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`. pub fn collect_sound_events( mut commands: Commands, mut queue: ResMut, 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::(); } } #[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::(); 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::(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::().events.len(), 1); // Tick 2: no new emitters → queue cleared schedule.run(&mut world); assert_eq!(world.resource::().events.len(), 0); } }