mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-16 18:12:34 -06:00
feat: ghost animation states, frightened/eaten behaviors, smallvec animation arrays
This commit is contained in:
@@ -8,7 +8,7 @@ use crate::error::GameError;
|
||||
use crate::events::GameEvent;
|
||||
use crate::map::builder::Map;
|
||||
use crate::systems::movement::Position;
|
||||
use crate::systems::{AudioEvent, Ghost, PlayerControlled, ScoreResource, Vulnerable};
|
||||
use crate::systems::{AudioEvent, Eaten, Ghost, PlayerControlled, ScoreResource, Vulnerable};
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct Collider {
|
||||
@@ -136,7 +136,7 @@ pub fn ghost_collision_system(
|
||||
score.0 += 200;
|
||||
|
||||
// Remove the ghost
|
||||
commands.entity(ghost_ent).despawn();
|
||||
commands.entity(ghost_ent).remove::<Vulnerable>().insert(Eaten);
|
||||
|
||||
// Play eat sound
|
||||
events.write(AudioEvent::PlayEat);
|
||||
|
||||
@@ -9,12 +9,13 @@ use crate::{
|
||||
},
|
||||
texture::{animated::AnimatedTexture, sprite::AtlasTile},
|
||||
};
|
||||
use micromap::Map;
|
||||
|
||||
/// A tag component for entities that are controlled by the player.
|
||||
#[derive(Default, Component)]
|
||||
pub struct PlayerControlled;
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Ghost {
|
||||
Blinky,
|
||||
Pinky,
|
||||
@@ -96,12 +97,33 @@ pub struct Renderable {
|
||||
}
|
||||
|
||||
/// A component for entities that have a directional animated texture.
|
||||
#[derive(Component)]
|
||||
#[derive(Component, Clone, Default)]
|
||||
pub struct DirectionalAnimated {
|
||||
pub textures: [Option<AnimatedTexture>; 4],
|
||||
pub stopped_textures: [Option<AnimatedTexture>; 4],
|
||||
}
|
||||
|
||||
impl DirectionalAnimated {
|
||||
pub fn from_animation(animation: AnimatedTexture) -> Self {
|
||||
// Create 4 copies of the animation - necessary for independent state per direction
|
||||
// This is initialization-time only, so the cloning cost is acceptable
|
||||
Self {
|
||||
textures: [
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
],
|
||||
stopped_textures: [
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct CollisionLayer: u8 {
|
||||
@@ -144,12 +166,92 @@ impl Default for MovementModifiers {
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Frozen;
|
||||
|
||||
/// Tag component for eaten ghosts
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Eaten;
|
||||
|
||||
/// Component for ghosts that are vulnerable to Pac-Man
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Vulnerable {
|
||||
pub remaining_ticks: u32,
|
||||
}
|
||||
|
||||
/// Enumeration of different ghost animation states.
|
||||
/// Note that this is used in micromap which has a fixed size based on the number of variants,
|
||||
/// so extending this should be done with caution, and will require updating the micromap's capacity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GhostAnimation {
|
||||
/// Normal ghost appearance with directional movement animations
|
||||
Normal,
|
||||
/// Blue ghost appearance when vulnerable (power pellet active)
|
||||
Frightened { flash: bool },
|
||||
/// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state)
|
||||
Eyes,
|
||||
}
|
||||
|
||||
/// A complete set of animations for a ghost in different behavioral states.
|
||||
///
|
||||
/// Each ghost maintains animations mapped by their current gameplay state.
|
||||
/// The animation system automatically switches between these states based on
|
||||
/// the presence of `Vulnerable` and `Eaten` components on the ghost entity.
|
||||
#[derive(Component, Clone)]
|
||||
pub struct GhostAnimationSet {
|
||||
pub animations: Map<GhostAnimation, DirectionalAnimated, 4>,
|
||||
}
|
||||
|
||||
impl GhostAnimationSet {
|
||||
/// Creates a new GhostAnimationSet with the provided animations.
|
||||
pub fn new(
|
||||
normal: DirectionalAnimated,
|
||||
frightened: DirectionalAnimated,
|
||||
frightened_flashing: DirectionalAnimated,
|
||||
eyes: DirectionalAnimated,
|
||||
) -> Self {
|
||||
let mut animations = Map::new();
|
||||
animations.insert(GhostAnimation::Normal, normal);
|
||||
animations.insert(GhostAnimation::Frightened { flash: false }, frightened);
|
||||
animations.insert(GhostAnimation::Frightened { flash: true }, frightened_flashing);
|
||||
animations.insert(GhostAnimation::Eyes, eyes);
|
||||
Self { animations }
|
||||
}
|
||||
|
||||
/// Gets the animation for the specified ghost animation state.
|
||||
pub fn get(&self, animation: GhostAnimation) -> Option<&DirectionalAnimated> {
|
||||
self.animations.get(&animation)
|
||||
}
|
||||
|
||||
/// Gets the normal animation state.
|
||||
pub fn normal(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Normal)
|
||||
}
|
||||
|
||||
/// Gets the frightened animation state (non-flashing).
|
||||
pub fn frightened(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Frightened { flash: false })
|
||||
}
|
||||
|
||||
/// Gets the frightened flashing animation state.
|
||||
pub fn frightened_flashing(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Frightened { flash: true })
|
||||
}
|
||||
|
||||
/// Gets the eyes animation state (for eaten ghosts).
|
||||
pub fn eyes(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Eyes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global resource containing pre-loaded animation sets for all ghost types.
|
||||
///
|
||||
/// This resource is initialized once during game startup and provides O(1) access
|
||||
/// to animation sets for each ghost type. The animation system uses this resource
|
||||
/// to efficiently switch between different ghost states without runtime asset loading.
|
||||
///
|
||||
/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and
|
||||
/// contains complete animation sets mapped by GhostAnimation states.
|
||||
#[derive(Resource)]
|
||||
pub struct GhostAnimations(pub std::collections::HashMap<Ghost, GhostAnimationSet>);
|
||||
|
||||
#[derive(Bundle)]
|
||||
pub struct PlayerBundle {
|
||||
pub player: PlayerControlled,
|
||||
|
||||
@@ -10,8 +10,16 @@ use crate::{
|
||||
movement::{Position, Velocity},
|
||||
},
|
||||
};
|
||||
use bevy_ecs::query::Without;
|
||||
use bevy_ecs::system::{Query, Res};
|
||||
|
||||
use bevy_ecs::{
|
||||
query::Added,
|
||||
removal_detection::RemovedComponents,
|
||||
system::{Commands, Query, Res},
|
||||
};
|
||||
|
||||
use crate::systems::{Eaten, GhostAnimations, Vulnerable};
|
||||
|
||||
use bevy_ecs::query::{With, Without};
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::seq::IndexedRandom;
|
||||
use rand::SeedableRng;
|
||||
@@ -68,3 +76,160 @@ pub fn ghost_movement_system(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System that manages ghost animation state transitions based on ghost behavior.
|
||||
///
|
||||
/// This system handles the following animation state changes:
|
||||
/// - When a ghost becomes vulnerable (power pellet eaten): switches to frightened animation
|
||||
/// - When a ghost is eaten by Pac-Man: switches to eaten (eyes) animation
|
||||
/// - When vulnerability ends: switches back to normal animation
|
||||
///
|
||||
/// The system uses ECS change detection to efficiently track state transitions:
|
||||
/// - `Added<Vulnerable>` detects when ghosts become frightened
|
||||
/// - `Added<Eaten>` detects when ghosts are consumed
|
||||
/// - `RemovedComponents<Vulnerable>` detects when fright period ends
|
||||
///
|
||||
/// This ensures smooth visual feedback for gameplay state changes while maintaining
|
||||
/// separation between game logic and animation state.
|
||||
pub fn ghost_state_animation_system(
|
||||
mut commands: Commands,
|
||||
animations: Res<GhostAnimations>,
|
||||
mut vulnerable_added: Query<(bevy_ecs::entity::Entity, &Ghost), Added<Vulnerable>>,
|
||||
mut eaten_added: Query<(bevy_ecs::entity::Entity, &Ghost), Added<Eaten>>,
|
||||
mut vulnerable_removed: RemovedComponents<Vulnerable>,
|
||||
ghosts: Query<&Ghost>,
|
||||
) {
|
||||
// When a ghost becomes vulnerable, switch to the frightened animation
|
||||
for (entity, ghost_type) in vulnerable_added.iter_mut() {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.frightened() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When a ghost is eaten, switch to the eaten animation
|
||||
for (entity, ghost_type) in eaten_added.iter_mut() {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.eyes() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When a ghost is no longer vulnerable, switch back to the normal animation
|
||||
for entity in vulnerable_removed.read() {
|
||||
if let Ok(ghost_type) = ghosts.get(entity) {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.normal() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System that handles eaten ghost behavior and respawn logic.
|
||||
///
|
||||
/// When a ghost is eaten by Pac-Man, it enters an "eaten" state where:
|
||||
/// 1. It displays eyes-only animation
|
||||
/// 2. It moves directly back to the ghost house at increased speed
|
||||
/// 3. Once it reaches the ghost house center, it respawns as a normal ghost
|
||||
///
|
||||
/// This system runs after the main movement system to override eaten ghost movement.
|
||||
pub fn eaten_ghost_system(
|
||||
map: Res<Map>,
|
||||
delta_time: Res<DeltaTime>,
|
||||
animations: Res<GhostAnimations>,
|
||||
mut commands: Commands,
|
||||
mut eaten_ghosts: Query<(bevy_ecs::entity::Entity, &Ghost, &mut Position, &mut Velocity), With<Eaten>>,
|
||||
) {
|
||||
for (entity, ghost_type, mut position, mut velocity) in eaten_ghosts.iter_mut() {
|
||||
// Set higher speed for eaten ghosts returning to ghost house
|
||||
let original_speed = velocity.speed;
|
||||
velocity.speed = ghost_type.base_speed() * 2.0; // Move twice as fast when eaten
|
||||
|
||||
// Calculate direction towards ghost house center (using Clyde's start position)
|
||||
let ghost_house_center = map.start_positions.clyde;
|
||||
|
||||
match *position {
|
||||
Position::Stopped { node: current_node } => {
|
||||
// Find path to ghost house center and start moving
|
||||
if let Some(direction) = find_direction_to_target(&map, current_node, ghost_house_center) {
|
||||
velocity.direction = direction;
|
||||
*position = Position::Moving {
|
||||
from: current_node,
|
||||
to: map.graph.adjacency_list[current_node].get(direction).unwrap().target,
|
||||
remaining_distance: map.graph.adjacency_list[current_node].get(direction).unwrap().distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
Position::Moving { to, .. } => {
|
||||
let distance = velocity.speed * 60.0 * delta_time.0;
|
||||
if let Some(_overflow) = position.tick(distance) {
|
||||
// Reached target node, check if we're at ghost house center
|
||||
if to == ghost_house_center {
|
||||
// Respawn the ghost - remove Eaten component and switch to normal animation
|
||||
commands.entity(entity).remove::<Eaten>();
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.normal() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
// Reset to stopped at ghost house center
|
||||
*position = Position::Stopped {
|
||||
node: ghost_house_center,
|
||||
};
|
||||
} else {
|
||||
// Continue pathfinding to ghost house
|
||||
if let Some(next_direction) = find_direction_to_target(&map, to, ghost_house_center) {
|
||||
velocity.direction = next_direction;
|
||||
*position = Position::Moving {
|
||||
from: to,
|
||||
to: map.graph.adjacency_list[to].get(next_direction).unwrap().target,
|
||||
remaining_distance: map.graph.adjacency_list[to].get(next_direction).unwrap().distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original speed
|
||||
velocity.speed = original_speed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to find the direction from a node towards a target node.
|
||||
/// Uses simple greedy pathfinding - prefers straight lines when possible.
|
||||
fn find_direction_to_target(map: &Map, from_node: usize, target_node: usize) -> Option<Direction> {
|
||||
let from_pos = map.graph.get_node(from_node).unwrap().position;
|
||||
let target_pos = map.graph.get_node(target_node).unwrap().position;
|
||||
|
||||
let dx = target_pos.x as i32 - from_pos.x as i32;
|
||||
let dy = target_pos.y as i32 - from_pos.y as i32;
|
||||
|
||||
// Prefer horizontal movement first, then vertical
|
||||
let preferred_dirs = if dx.abs() > dy.abs() {
|
||||
if dx > 0 {
|
||||
[Direction::Right, Direction::Up, Direction::Down, Direction::Left]
|
||||
} else {
|
||||
[Direction::Left, Direction::Up, Direction::Down, Direction::Right]
|
||||
}
|
||||
} else if dy > 0 {
|
||||
[Direction::Down, Direction::Left, Direction::Right, Direction::Up]
|
||||
} else {
|
||||
[Direction::Up, Direction::Left, Direction::Right, Direction::Down]
|
||||
};
|
||||
|
||||
// Return first available direction towards target
|
||||
for direction in preferred_dirs {
|
||||
if let Some(edge) = map.graph.adjacency_list[from_node].get(direction) {
|
||||
if edge.traversal_flags.contains(TraversalFlags::GHOST) {
|
||||
return Some(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ pub enum SystemId {
|
||||
PlayerMovement,
|
||||
GhostCollision,
|
||||
Stage,
|
||||
GhostStateAnimation,
|
||||
EatenGhost,
|
||||
}
|
||||
|
||||
impl Display for SystemId {
|
||||
@@ -144,15 +146,13 @@ impl SystemTimings {
|
||||
};
|
||||
|
||||
// Collect timing data for formatting
|
||||
let mut timing_data = Vec::new();
|
||||
let mut timing_data = vec![(effective_fps, total_avg, total_std)];
|
||||
|
||||
// Add total stats
|
||||
timing_data.push((effective_fps, total_avg, total_std));
|
||||
|
||||
// Add top 5 most expensive systems
|
||||
// Sort the stats by average duration
|
||||
let mut sorted_stats: Vec<_> = stats.iter().collect();
|
||||
sorted_stats.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));
|
||||
|
||||
// Add the top 5 most expensive systems
|
||||
for (name, (avg, std_dev)) in sorted_stats.iter().take(5) {
|
||||
timing_data.push((name.to_string(), *avg, *std_dev));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
use bevy_ecs::query::With;
|
||||
use bevy_ecs::system::{Commands, Query};
|
||||
use bevy_ecs::{
|
||||
query::With,
|
||||
system::{Commands, Query, Res},
|
||||
};
|
||||
|
||||
use crate::systems::{GhostCollider, Vulnerable};
|
||||
use crate::constants::animation::FRIGHTENED_FLASH_START_TICKS;
|
||||
use crate::systems::{Ghost, GhostAnimations, GhostCollider, Vulnerable};
|
||||
|
||||
/// System that decrements the remaining_ticks on Vulnerable components and removes them when they reach zero
|
||||
pub fn vulnerable_tick_system(
|
||||
mut commands: Commands,
|
||||
mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable), With<GhostCollider>>,
|
||||
animations: Res<GhostAnimations>,
|
||||
mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable, &Ghost), With<GhostCollider>>,
|
||||
) {
|
||||
for (entity, mut vulnerable) in vulnerable_query.iter_mut() {
|
||||
for (entity, mut vulnerable, ghost_type) in vulnerable_query.iter_mut() {
|
||||
if vulnerable.remaining_ticks > 0 {
|
||||
vulnerable.remaining_ticks -= 1;
|
||||
}
|
||||
|
||||
// When 2 seconds are remaining, start flashing
|
||||
if vulnerable.remaining_ticks == FRIGHTENED_FLASH_START_TICKS {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.frightened_flashing() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vulnerable.remaining_ticks == 0 {
|
||||
commands.entity(entity).remove::<Vulnerable>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user