feat(simulation): wire footstep sound events into movement system

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>
This commit is contained in:
2026-02-19 15:14:52 +01:00
co-authored by Claude Opus 4.6
parent f533235f58
commit 87a90b27bd
2 changed files with 128 additions and 9 deletions
+126 -3
View File
@@ -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<MoveIntent>>,
) {
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::<MoveIntent>();
}
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<Entity> = movers.iter().map(|(e, _, _, _)| e).collect();
let mut mover_entities: Vec<Entity> = 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::<MoveIntent>();
}
@@ -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::<SoundEventEmitter>(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::<SoundEventEmitter>(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::<SoundEventEmitter>(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::<SoundEventEmitter>(entity).unwrap();
assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON);
}
}
+2 -6
View File
@@ -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<SoundEventQueue>,