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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user