Formatting pass across simulation, perception, knowledge, NPC, and test modules. Includes two clippy fixes in monologue.rs (.values() instead of for (_, v) pattern). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
257 lines
8.9 KiB
Rust
257 lines
8.9 KiB
Rust
// Stance system — D-053 movement stances with tick-based speed
|
|
//
|
|
// MovementStance (Sprint/Walk/Careful/Crouch) affects:
|
|
// - Movement speed (ticks per step): Sprint=1, Walk=2, Careful=3, Crouch=4
|
|
// - Monologue rate: Sprint=40%, Walk=100%, Careful=150%, Crouch=100%
|
|
// - Interaction buffer: Sprint suppresses (D-055, wired in #419)
|
|
//
|
|
// The stance ladder is toggled via PlayerAction::ToggleStanceUp/Down.
|
|
// This module provides the ECS component and movement cooldown.
|
|
|
|
use bevy_ecs::prelude::*;
|
|
|
|
use crate::bridge::types::MovementStance;
|
|
|
|
/// Per-archetype default movement configuration (D-053).
|
|
/// Stores the default stance so spawn code can initialize Stance from it.
|
|
///
|
|
/// v0.1: smuggler and detective both default to Walk.
|
|
/// Future archetypes may differ (e.g., maintenance worker → Careful).
|
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct MovementProfile {
|
|
pub default_stance: MovementStance,
|
|
}
|
|
|
|
impl Default for MovementProfile {
|
|
fn default() -> Self {
|
|
Self {
|
|
default_stance: MovementStance::Walk,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MovementProfile {
|
|
pub fn smuggler() -> Self {
|
|
Self {
|
|
default_stance: MovementStance::Walk,
|
|
}
|
|
}
|
|
|
|
pub fn detective() -> Self {
|
|
Self {
|
|
default_stance: MovementStance::Walk,
|
|
}
|
|
}
|
|
|
|
/// Create the initial Stance component from this profile's default.
|
|
pub fn initial_stance(&self) -> Stance {
|
|
Stance(self.default_stance)
|
|
}
|
|
}
|
|
|
|
/// ECS component tracking an entity's current movement stance.
|
|
/// Attached to PlayerCharacter (and potentially NPCs in future).
|
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Stance(pub MovementStance);
|
|
|
|
impl Default for Stance {
|
|
fn default() -> Self {
|
|
Stance(MovementStance::Walk)
|
|
}
|
|
}
|
|
|
|
/// Tracks ticks since last movement step for stance-based speed enforcement.
|
|
/// The player's movement is throttled server-side based on their current stance.
|
|
#[derive(Component, Debug, Clone)]
|
|
pub struct PlayerMoveCooldown {
|
|
pub ticks_since_last_move: u32,
|
|
}
|
|
|
|
impl Default for PlayerMoveCooldown {
|
|
fn default() -> Self {
|
|
Self {
|
|
// Start at max so first move is immediate
|
|
ticks_since_last_move: u32::MAX,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PlayerMoveCooldown {
|
|
/// Check if the player can move this tick given their stance.
|
|
/// Returns true and resets the counter if movement is allowed.
|
|
pub fn try_move(&mut self, stance: MovementStance) -> bool {
|
|
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
|
|
if self.ticks_since_last_move >= stance.ticks_per_move() {
|
|
self.ticks_since_last_move = 0;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Advance the cooldown counter without attempting a move.
|
|
/// Call this each tick when no move input is present to keep the counter progressing.
|
|
pub fn tick(&mut self) {
|
|
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn stance_default_is_walk() {
|
|
assert_eq!(Stance::default().0, MovementStance::Walk);
|
|
}
|
|
|
|
#[test]
|
|
fn stance_ladder_step_up() {
|
|
assert_eq!(MovementStance::Crouch.step_up(), MovementStance::Careful);
|
|
assert_eq!(MovementStance::Careful.step_up(), MovementStance::Walk);
|
|
assert_eq!(MovementStance::Walk.step_up(), MovementStance::Sprint);
|
|
assert_eq!(MovementStance::Sprint.step_up(), MovementStance::Sprint);
|
|
}
|
|
|
|
#[test]
|
|
fn stance_ladder_step_down() {
|
|
assert_eq!(MovementStance::Sprint.step_down(), MovementStance::Walk);
|
|
assert_eq!(MovementStance::Walk.step_down(), MovementStance::Careful);
|
|
assert_eq!(MovementStance::Careful.step_down(), MovementStance::Crouch);
|
|
assert_eq!(MovementStance::Crouch.step_down(), MovementStance::Crouch);
|
|
}
|
|
|
|
#[test]
|
|
fn ticks_per_move_values() {
|
|
assert_eq!(MovementStance::Sprint.ticks_per_move(), 1);
|
|
assert_eq!(MovementStance::Walk.ticks_per_move(), 2);
|
|
assert_eq!(MovementStance::Careful.ticks_per_move(), 3);
|
|
assert_eq!(MovementStance::Crouch.ticks_per_move(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn monologue_rate_values() {
|
|
assert_eq!(MovementStance::Sprint.monologue_rate_percent(), 40);
|
|
assert_eq!(MovementStance::Walk.monologue_rate_percent(), 100);
|
|
assert_eq!(MovementStance::Careful.monologue_rate_percent(), 150);
|
|
assert_eq!(MovementStance::Crouch.monologue_rate_percent(), 100);
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_first_move_immediate() {
|
|
let mut cd = PlayerMoveCooldown::default();
|
|
// First move should always succeed (counter starts at MAX)
|
|
assert!(cd.try_move(MovementStance::Walk));
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_sprint_every_tick() {
|
|
let mut cd = PlayerMoveCooldown::default();
|
|
assert!(cd.try_move(MovementStance::Sprint)); // tick 1
|
|
assert!(cd.try_move(MovementStance::Sprint)); // tick 2
|
|
assert!(cd.try_move(MovementStance::Sprint)); // tick 3
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_walk_every_two_ticks() {
|
|
let mut cd = PlayerMoveCooldown::default();
|
|
assert!(cd.try_move(MovementStance::Walk)); // tick 1: allowed (first)
|
|
assert!(!cd.try_move(MovementStance::Walk)); // tick 2: cooldown
|
|
assert!(cd.try_move(MovementStance::Walk)); // tick 3: allowed
|
|
assert!(!cd.try_move(MovementStance::Walk)); // tick 4: cooldown
|
|
assert!(cd.try_move(MovementStance::Walk)); // tick 5: allowed
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_careful_every_three_ticks() {
|
|
let mut cd = PlayerMoveCooldown::default();
|
|
assert!(cd.try_move(MovementStance::Careful)); // tick 1: allowed (first)
|
|
assert!(!cd.try_move(MovementStance::Careful)); // tick 2: cd
|
|
assert!(!cd.try_move(MovementStance::Careful)); // tick 3: cd
|
|
assert!(cd.try_move(MovementStance::Careful)); // tick 4: allowed
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_crouch_every_four_ticks() {
|
|
let mut cd = PlayerMoveCooldown::default();
|
|
assert!(cd.try_move(MovementStance::Crouch)); // tick 1: allowed (first)
|
|
assert!(!cd.try_move(MovementStance::Crouch)); // tick 2: cd
|
|
assert!(!cd.try_move(MovementStance::Crouch)); // tick 3: cd
|
|
assert!(!cd.try_move(MovementStance::Crouch)); // tick 4: cd
|
|
assert!(cd.try_move(MovementStance::Crouch)); // tick 5: allowed
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_tick_advances_counter() {
|
|
let mut cd = PlayerMoveCooldown::default();
|
|
assert!(cd.try_move(MovementStance::Walk)); // move
|
|
cd.tick(); // no move, but counter advances
|
|
assert!(cd.try_move(MovementStance::Walk)); // allowed after tick + try_move = 2
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_stance_switch_mid_cooldown() {
|
|
let mut cd = PlayerMoveCooldown::default();
|
|
assert!(cd.try_move(MovementStance::Crouch)); // move at crouch speed
|
|
// Switch to sprint mid-cooldown
|
|
assert!(cd.try_move(MovementStance::Sprint)); // sprint allows every tick
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// MovementProfile tests (#418, D-053)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn movement_profile_default_is_walk() {
|
|
let profile = MovementProfile::default();
|
|
assert_eq!(profile.default_stance, MovementStance::Walk);
|
|
}
|
|
|
|
#[test]
|
|
fn movement_profile_smuggler_defaults_to_walk() {
|
|
let profile = MovementProfile::smuggler();
|
|
assert_eq!(profile.default_stance, MovementStance::Walk);
|
|
}
|
|
|
|
#[test]
|
|
fn movement_profile_detective_defaults_to_walk() {
|
|
let profile = MovementProfile::detective();
|
|
assert_eq!(profile.default_stance, MovementStance::Walk);
|
|
}
|
|
|
|
#[test]
|
|
fn movement_profile_initial_stance_matches_default() {
|
|
let profile = MovementProfile::smuggler();
|
|
let stance = profile.initial_stance();
|
|
assert_eq!(stance.0, profile.default_stance);
|
|
}
|
|
|
|
#[test]
|
|
fn movement_profile_custom_default_stance() {
|
|
let profile = MovementProfile {
|
|
default_stance: MovementStance::Careful,
|
|
};
|
|
assert_eq!(profile.default_stance, MovementStance::Careful);
|
|
assert_eq!(profile.initial_stance().0, MovementStance::Careful);
|
|
}
|
|
|
|
#[test]
|
|
fn movement_profile_as_ecs_component() {
|
|
let mut world = bevy_ecs::world::World::new();
|
|
let profile = MovementProfile::smuggler();
|
|
let entity = world
|
|
.spawn((
|
|
profile,
|
|
profile.initial_stance(),
|
|
PlayerMoveCooldown::default(),
|
|
))
|
|
.id();
|
|
|
|
let stored = world.get::<MovementProfile>(entity).unwrap();
|
|
assert_eq!(stored.default_stance, MovementStance::Walk);
|
|
|
|
let stance = world.get::<Stance>(entity).unwrap();
|
|
assert_eq!(stance.0, MovementStance::Walk);
|
|
}
|
|
}
|