From 5d56b313534fe77ca0e61c814de26c04923cebac Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Tue, 9 Sep 2025 11:25:30 -0500 Subject: [PATCH] feat: fruit spawning mechanism, sprites, pellet counting, fruit trigger observer --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/constants.rs | 2 + src/game.rs | 3 + src/map/builder.rs | 29 ++++++- src/systems/common/components.rs | 4 +- src/systems/item.rs | 130 +++++++++++++++++++++++++++++-- src/texture/sprites.rs | 41 ++++++++++ 8 files changed, 203 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ea7b87..8fb0c16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "pacman" -version = "0.78.3" +version = "0.78.4" dependencies = [ "anyhow", "bevy_ecs", diff --git a/Cargo.toml b/Cargo.toml index f575ed7..bb6ee62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pacman" -version = "0.78.3" +version = "0.78.4" authors = ["Xevion"] edition = "2021" rust-version = "1.86.0" diff --git a/src/constants.rs b/src/constants.rs index db534c4..f328d18 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -79,6 +79,8 @@ pub mod collider { pub const PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.4; /// Collider size for power pellets/energizers (0.95x cell size) pub const POWER_PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.95; + /// Collider size for fruits (0.8x cell size) + pub const FRUIT_SIZE: f32 = CELL_SIZE as f32 * 1.375; } /// UI and rendering constants diff --git a/src/game.rs b/src/game.rs index e5b66e8..0d43d07 100644 --- a/src/game.rs +++ b/src/game.rs @@ -127,6 +127,8 @@ impl Game { debug!("Setting up ECS event registry and observers"); Self::setup_ecs(&mut world); + world.add_observer(systems::spawn_fruit_observer); + debug!("Inserting resources into ECS world"); Self::insert_resources( &mut world, @@ -409,6 +411,7 @@ impl Game { world.insert_resource(GlobalState { exit: false }); world.insert_resource(PlayerLives::default()); world.insert_resource(ScoreResource(0)); + world.insert_resource(crate::systems::item::PelletCount(0)); world.insert_resource(SystemTimings::default()); world.insert_resource(Timing::default()); world.insert_resource(Bindings::default()); diff --git a/src/map/builder.rs b/src/map/builder.rs index 4595077..89e6bef 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -3,7 +3,7 @@ use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::map::direction::Direction; use crate::map::graph::{Graph, Node, TraversalFlags}; use crate::map::parser::MapTileParser; -use crate::systems::NodeId; +use crate::systems::{NodeId, Position}; use bevy_ecs::resource::Resource; use glam::{I8Vec2, IVec2, Vec2}; use std::collections::{HashMap, VecDeque}; @@ -25,6 +25,8 @@ pub struct NodePositions { pub inky: NodeId, /// Clyde starts in the center of the ghost house pub clyde: NodeId, + /// Fruit spawn location directly below the ghost house + pub fruit_spawn: Position, } /// Complete maze representation combining visual layout with navigation pathfinding. @@ -154,12 +156,37 @@ impl Map { let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) = Self::build_house(&mut graph, &grid_to_node, &house_door)?; + // Find fruit spawn location (directly below ghost house) + let left_node_position = I8Vec2::new(13, 17); + let left_node_id = grid_to_node.get(&left_node_position).unwrap(); + let right_node_position = I8Vec2::new(14, 17); + let right_node_id = grid_to_node.get(&right_node_position).unwrap(); + + let distance = graph + .get_node(*right_node_id) + .unwrap() + .position + .distance(graph.get_node(*left_node_id).unwrap().position); + + // interpolate between the two nodes + let fruit_spawn_position: Position = Position::Moving { + from: *left_node_id, + to: *right_node_id, + remaining_distance: distance / 2.0, + }; + + tracing::warn!( + fruit_spawn_position = ?fruit_spawn_position, + "Fruit spawn position found" + ); + let start_positions = NodePositions { pacman: grid_to_node[&start_pos], blinky: house_entrance_node_id, pinky: left_center_node_id, inky: right_center_node_id, clyde: center_center_node_id, + fruit_spawn: fruit_spawn_position, }; // Build tunnel connections diff --git a/src/systems/common/components.rs b/src/systems/common/components.rs index a9dd3f0..45b1f25 100644 --- a/src/systems/common/components.rs +++ b/src/systems/common/components.rs @@ -9,6 +9,7 @@ pub enum EntityType { Ghost, Pellet, PowerPellet, + Fruit(crate::texture::sprites::FruitSprite), } impl EntityType { @@ -24,12 +25,13 @@ impl EntityType { match self { EntityType::Pellet => Some(10), EntityType::PowerPellet => Some(50), + EntityType::Fruit(fruit_type) => Some(fruit_type.score_value()), _ => None, } } pub fn is_collectible(&self) -> bool { - matches!(self, EntityType::Pellet | EntityType::PowerPellet) + matches!(self, EntityType::Pellet | EntityType::PowerPellet | EntityType::Fruit(_)) } } diff --git a/src/systems/item.rs b/src/systems/item.rs index 5335a73..47a00f7 100644 --- a/src/systems/item.rs +++ b/src/systems/item.rs @@ -1,17 +1,65 @@ use bevy_ecs::{ entity::Entity, - event::{EventReader, EventWriter}, + event::{Event, EventReader, EventWriter}, + observer::Trigger, query::With, - system::{Commands, Query, ResMut, Single}, + system::{Commands, NonSendMut, Query, Res, ResMut, Single}, }; use tracing::{debug, trace}; +use crate::{ + constants::collider::FRUIT_SIZE, + map::builder::Map, + systems::{common::bundles::ItemBundle, Collider, Position, Renderable}, + texture::{sprite::SpriteAtlas, sprites::GameSprite}, +}; + use crate::{ constants::animation::FRIGHTENED_FLASH_START_TICKS, events::GameEvent, - systems::{AudioEvent, EntityType, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource}, + systems::common::components::EntityType, + systems::lifetime::TimeToLive, + systems::{AudioEvent, GhostCollider, GhostState, ItemCollider, LinearAnimation, PacmanCollider, ScoreResource}, + texture::animated::TileSequence, }; +/// Tracks the number of pellets consumed by the player for fruit spawning mechanics. +#[derive(bevy_ecs::resource::Resource, Debug, Default)] +pub struct PelletCount(pub u32); + +/// Maps fruit score values to bonus sprite indices for displaying bonus points +fn fruit_score_to_sprite_index(score: u32) -> u8 { + match score { + 100 => 0, // Cherry + 300 => 2, // Strawberry + 500 => 3, // Orange + 700 => 4, // Apple + 1000 => 6, // Melon + 2000 => 8, // Galaxian + 3000 => 9, // Bell + 5000 => 10, // Key + _ => 0, // Default to 100 points sprite + } +} + +/// Maps sprite index to the corresponding effect sprite path (same as in state.rs) +fn sprite_index_to_path(index: u8) -> &'static str { + match index { + 0 => "effects/100.png", + 1 => "effects/200.png", + 2 => "effects/300.png", + 3 => "effects/400.png", + 4 => "effects/700.png", + 5 => "effects/800.png", + 6 => "effects/1000.png", + 7 => "effects/1600.png", + 8 => "effects/2000.png", + 9 => "effects/3000.png", + 10 => "effects/5000.png", + _ => "effects/100.png", // fallback to index 0 + } +} + /// Determines if a collision between two entity types should be handled by the item system. /// /// Returns `true` if one entity is a player and the other is a collectible item. @@ -23,14 +71,17 @@ pub fn is_valid_item_collision(entity1: EntityType, entity2: EntityType) -> bool } } +#[allow(clippy::too_many_arguments)] pub fn item_system( mut commands: Commands, mut collision_events: EventReader, mut score: ResMut, + mut pellet_count: ResMut, pacman: Single>, - item_query: Query<(Entity, &EntityType), With>, + item_query: Query<(Entity, &EntityType, &Position), With>, mut ghost_query: Query<&mut GhostState, With>, mut events: EventWriter, + atlas: NonSendMut, ) { for event in collision_events.read() { if let GameEvent::Collision(entity1, entity2) = event { @@ -44,21 +95,59 @@ pub fn item_system( }; // Get the item type and update score - if let Ok((item_ent, entity_type)) = item_query.get(item_entity) { + if let Ok((item_ent, entity_type, item_position)) = item_query.get(item_entity) { if let Some(score_value) = entity_type.score_value() { trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player"); score.0 += score_value; + // Spawn bonus sprite for fruits at the fruit's position (similar to ghost eating bonus) + if matches!(entity_type, EntityType::Fruit(_)) { + let sprite_index = fruit_score_to_sprite_index(score_value); + let sprite_path = sprite_index_to_path(sprite_index); + + if let Ok(sprite_tile) = SpriteAtlas::get_tile(&atlas, sprite_path) { + let tile_sequence = TileSequence::single(sprite_tile); + let animation = LinearAnimation::new(tile_sequence, 1); + + commands.spawn(( + *item_position, + Renderable { + sprite: sprite_tile, + layer: 2, // Above other entities + }, + animation, + TimeToLive::new(120), // 2 seconds at 60 FPS + )); + + debug!( + fruit_score = score_value, + sprite_index, "Fruit bonus sprite spawned at fruit position" + ); + } + } + // Remove the collected item commands.entity(item_ent).despawn(); + // Track pellet consumption for fruit spawning + if *entity_type == EntityType::Pellet { + pellet_count.0 += 1; + trace!(pellet_count = pellet_count.0, "Pellet consumed"); + + // Check if we should spawn a fruit + if pellet_count.0 == 70 || pellet_count.0 == 170 { + debug!(pellet_count = pellet_count.0, "Fruit spawn milestone reached"); + commands.trigger(SpawnFruitTrigger); + } + } + // Trigger audio if appropriate if entity_type.is_collectible() { events.write(AudioEvent::PlayEat); } // Make ghosts frightened when power pellet is collected - if *entity_type == EntityType::PowerPellet { + if matches!(*entity_type, EntityType::PowerPellet) { // Convert seconds to frames (assumes 60 FPS) let total_ticks = 60 * 5; // 5 seconds total debug!(duration_ticks = total_ticks, "Power pellet collected, frightening ghosts"); @@ -78,3 +167,32 @@ pub fn item_system( } } } + +/// Trigger to spawn a fruit +#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpawnFruitTrigger; + +pub fn spawn_fruit_observer( + _: Trigger, + mut commands: Commands, + atlas: NonSendMut, + map: Res, +) { + // Use cherry sprite as the default fruit (first fruit in original Pac-Man) + let fruit_sprite = &atlas + .get_tile(&GameSprite::Fruit(crate::texture::sprites::FruitSprite::Cherry).to_path()) + .unwrap(); + + let fruit_entity = commands.spawn(ItemBundle { + position: map.start_positions.fruit_spawn, + sprite: Renderable { + sprite: *fruit_sprite, + layer: 1, + }, + entity_type: EntityType::Fruit(crate::texture::sprites::FruitSprite::Cherry), + collider: Collider { size: FRUIT_SIZE }, + item_collider: ItemCollider, + }); + + debug!(fruit_entity = ?fruit_entity.id(), fruit_spawn_node = ?map.start_positions.fruit_spawn, "Fruit spawned"); +} diff --git a/src/texture/sprites.rs b/src/texture/sprites.rs index aea3e14..8b34a3a 100644 --- a/src/texture/sprites.rs +++ b/src/texture/sprites.rs @@ -47,12 +47,43 @@ pub enum MazeSprite { Energizer, } +/// Represents the different fruit sprites that can appear as bonus items. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[allow(dead_code)] +pub enum FruitSprite { + Cherry, + Strawberry, + Orange, + Apple, + Melon, + Galaxian, + Bell, + Key, +} + +impl FruitSprite { + /// Returns the score value for this fruit type. + pub fn score_value(self) -> u32 { + match self { + FruitSprite::Cherry => 100, + FruitSprite::Strawberry => 300, + FruitSprite::Orange => 500, + FruitSprite::Apple => 700, + FruitSprite::Melon => 1000, + FruitSprite::Galaxian => 2000, + FruitSprite::Bell => 3000, + FruitSprite::Key => 5000, + } + } +} + /// A top-level enum that encompasses all game sprites. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GameSprite { Pacman(PacmanSprite), Ghost(GhostSprite), Maze(MazeSprite), + Fruit(FruitSprite), } impl GameSprite { @@ -105,6 +136,16 @@ impl GameSprite { GameSprite::Maze(MazeSprite::Tile(index)) => format!("maze/tiles/{}.png", index), GameSprite::Maze(MazeSprite::Pellet) => "maze/pellet.png".to_string(), GameSprite::Maze(MazeSprite::Energizer) => "maze/energizer.png".to_string(), + + // Fruit sprites + GameSprite::Fruit(FruitSprite::Cherry) => "edible/cherry.png".to_string(), + GameSprite::Fruit(FruitSprite::Strawberry) => "edible/strawberry.png".to_string(), + GameSprite::Fruit(FruitSprite::Orange) => "edible/orange.png".to_string(), + GameSprite::Fruit(FruitSprite::Apple) => "edible/apple.png".to_string(), + GameSprite::Fruit(FruitSprite::Melon) => "edible/melon.png".to_string(), + GameSprite::Fruit(FruitSprite::Galaxian) => "edible/galaxian.png".to_string(), + GameSprite::Fruit(FruitSprite::Bell) => "edible/bell.png".to_string(), + GameSprite::Fruit(FruitSprite::Key) => "edible/key.png".to_string(), } } }