From 87a90b27bdd99c5a8823dc8c6f0e94745949f091 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 15:14:52 +0100 Subject: [PATCH] feat(simulation): wire footstep sound events into movement system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/simulation/movement.rs | 129 +++++++++++++++++++++++++++++- server/src/simulation/sound.rs | 8 +- 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 5fd5e7aec..2c1ca9407 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -8,6 +8,11 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::bridge::types::MovementStance; +use crate::knowledge::types::SoundRange; +use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind}; +use crate::simulation::stance::Stance; + /// Chunk size in tiles (32x32 per chunk) pub const CHUNK_SIZE: i32 = 32; @@ -266,12 +271,13 @@ pub fn validate_movement( &MoveIntent, &mut TilePosition, Option<&TilePresence>, + Option<&Stance>, )>, stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without>, ) { let Some(map) = walkability else { tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); - for (entity, _, _, _) in movers.iter() { + for (entity, _, _, _, _) in movers.iter() { commands.entity(entity).remove::(); } return; @@ -286,11 +292,11 @@ pub fn validate_movement( } // Sort movers by Entity::to_bits() for deterministic collision resolution (#458) - let mut mover_entities: Vec = movers.iter().map(|(e, _, _, _)| e).collect(); + let mut mover_entities: Vec = movers.iter().map(|(e, _, _, _, _)| e).collect(); mover_entities.sort_by_key(|e| e.to_bits()); for entity in mover_entities { - let Ok((_, intent, mut position, presence)) = movers.get_mut(entity) else { + let Ok((_, intent, mut position, presence, stance_opt)) = movers.get_mut(entity) else { continue; }; let target = intent.target; @@ -318,6 +324,23 @@ pub fn validate_movement( occupied.remove(&(*position, layer)); *position = target; occupied.insert(slot, entity); + + // Emit Footstep sound event (#124, D-018) + let intensity = match stance_opt.map(|s| s.0) { + Some(MovementStance::Sprint) => 0.8, + Some(MovementStance::Walk) | None => 0.5, + Some(MovementStance::Careful) => 0.3, + Some(MovementStance::Crouch) => 0.15, + }; + commands.entity(entity).insert(SoundEventEmitter::new( + SoundEvent::at( + &target, + SoundEventKind::Footstep, + intensity, + SoundRange::Close, + None, + ), + )); } commands.entity(entity).remove::(); } @@ -975,4 +998,104 @@ mod tests { "Seated should share tile with Fixture + Prone + Standing" ); } + + // --- Footstep sound emission tests (#124, D-018) --- + + #[test] + fn successful_move_emits_footstep_sound() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + let entity = world + .spawn((TilePosition::new(5, 5, 0), MoveIntent { target })) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + let emitter = world + .get::(entity) + .expect("successful move should insert SoundEventEmitter"); + assert_eq!(emitter.pending.len(), 1); + assert_eq!(emitter.pending[0].kind, SoundEventKind::Footstep); + assert_eq!(emitter.pending[0].range, SoundRange::Close); + // Default stance (None) → Walk intensity 0.5 + assert!((emitter.pending[0].intensity - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn blocked_move_does_not_emit_footstep() { + let mut world = bevy_ecs::world::World::new(); + let mut map = WalkabilityMap::new(10, 10, 1); + map.set_walkable(&TilePosition::new(5, 4, 0), false); + world.insert_resource(map); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert!( + world.get::(entity).is_none(), + "blocked move should not emit sound" + ); + } + + #[test] + fn sprint_stance_produces_louder_footstep() { + use crate::simulation::stance::Stance; + + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { target }, + Stance(MovementStance::Sprint), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + let emitter = world.get::(entity).unwrap(); + assert!((emitter.pending[0].intensity - 0.8).abs() < f32::EPSILON); + } + + #[test] + fn crouch_stance_produces_quieter_footstep() { + use crate::simulation::stance::Stance; + + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { target }, + Stance(MovementStance::Crouch), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + let emitter = world.get::(entity).unwrap(); + assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON); + } } diff --git a/server/src/simulation/sound.rs b/server/src/simulation/sound.rs index 3183fb1ad..3b3e638fa 100644 --- a/server/src/simulation/sound.rs +++ b/server/src/simulation/sound.rs @@ -160,12 +160,8 @@ impl SoundEventQueue { /// Removes the emitter component after draining. Ordering: after movement, /// before `compute_observer_snapshot`. /// -/// NOTE: v0.1 has no sound producers — no system currently inserts -/// SoundEventEmitter components. The pipeline (emitter → queue → snapshot → -/// client bridge) is fully wired but produces zero events at runtime. -/// Sound producers (Footstep on movement, Voice on dialogue) are backlog -/// scope and will be added when the client audio bus routing (#125) is -/// integrated. See D-018 for the sound model specification. +/// 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,