From d86864b6a37fac01bfbd8963f0eb2fd56e43d363 Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Wed, 10 Sep 2025 21:59:23 -0500 Subject: [PATCH] feat: fruit display hud --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/game.rs | 19 ++++++---- src/systems/collision.rs | 7 +++- src/systems/hud/fruits.rs | 79 +++++++++++++++++++++++++++++++++++++++ src/systems/hud/mod.rs | 2 + src/systems/item.rs | 14 +++++++ 7 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 src/systems/hud/fruits.rs diff --git a/Cargo.lock b/Cargo.lock index 754f8bb..e408151 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "pacman" -version = "0.79.0" +version = "0.79.1" dependencies = [ "anyhow", "bevy_ecs", diff --git a/Cargo.toml b/Cargo.toml index 0a8a80d..9d880b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pacman" -version = "0.79.0" +version = "0.79.1" authors = ["Xevion"] edition = "2021" rust-version = "1.86.0" diff --git a/src/game.rs b/src/game.rs index c560112..08d6e87 100644 --- a/src/game.rs +++ b/src/game.rs @@ -13,14 +13,14 @@ use crate::map::builder::Map; use crate::map::direction::Direction; use crate::systems::{ self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system, - dirty_render_system, eaten_ghost_system, ghost_collision_observer, ghost_movement_system, ghost_state_system, - hud_render_system, item_collision_observer, linear_render_system, player_life_sprite_system, present_system, profile, - time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking, - BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen, - GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, ItemBundle, - ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, PacmanCollider, - PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, - ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, + dirty_render_system, eaten_ghost_system, fruit_sprite_system, ghost_collision_observer, ghost_movement_system, + ghost_state_system, hud_render_system, item_collision_observer, linear_render_system, player_life_sprite_system, + present_system, profile, time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState, + BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, + EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, + GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, + PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, + Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, }; use crate::texture::animated::{DirectionalTiles, TileSequence}; @@ -424,6 +424,7 @@ impl Game { world.insert_resource(PlayerAnimation(player_animation)); world.insert_resource(PlayerDeathAnimation(death_animation)); + world.insert_resource(FruitSprites::default()); world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE)); world.insert_resource(map); world.insert_resource(GlobalState { exit: false }); @@ -468,6 +469,7 @@ impl Game { let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system); let hud_render_system = profile(SystemId::HudRender, hud_render_system); let player_life_sprite_system = profile(SystemId::HudRender, player_life_sprite_system); + let fruit_sprite_system = profile(SystemId::HudRender, fruit_sprite_system); let present_system = profile(SystemId::Present, present_system); let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system); let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system); @@ -504,6 +506,7 @@ impl Game { directional_render_system, linear_render_system, player_life_sprite_system, + fruit_sprite_system, ) .in_set(RenderSet::Animation), stage_system.in_set(GameplaySet::Respond), diff --git a/src/systems/collision.rs b/src/systems/collision.rs index 40a8563..aa44cb8 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -10,7 +10,7 @@ use tracing::{debug, trace, warn}; use crate::{ constants, - systems::{movement::Position, AudioEvent, DyingSequence, GameStage, Ghost, ScoreResource, SpawnTrigger}, + systems::{movement::Position, AudioEvent, DyingSequence, FruitSprites, GameStage, Ghost, ScoreResource, SpawnTrigger}, }; use crate::{error::GameError, systems::GhostState}; use crate::{ @@ -188,6 +188,7 @@ pub fn item_collision_observer( mut pellet_count: ResMut, item_query: Query<(Entity, &EntityType, &Position), With>, mut ghost_query: Query<&mut GhostState, With>, + mut fruit_sprites: ResMut, mut events: EventWriter, ) { if let CollisionTrigger::ItemCollision { item } = *trigger { @@ -213,7 +214,9 @@ pub fn item_collision_observer( } // Trigger bonus points effect if a fruit is collected - if matches!(*entity_type, EntityType::Fruit(_)) { + if let EntityType::Fruit(fruit) = *entity_type { + fruit_sprites.0.push(fruit); + commands.trigger(SpawnTrigger::Bonus { position: *position, value: entity_type.score_value().unwrap(), diff --git a/src/systems/hud/fruits.rs b/src/systems/hud/fruits.rs new file mode 100644 index 0000000..59e3716 --- /dev/null +++ b/src/systems/hud/fruits.rs @@ -0,0 +1,79 @@ +use crate::systems::item::FruitType; +use crate::texture::sprites::GameSprite; +use bevy_ecs::component::Component; +use bevy_ecs::resource::Resource; + +#[derive(Component)] +pub struct FruitInHud { + pub index: u32, +} + +#[derive(Resource, Default)] +pub struct FruitSprites(pub Vec); + +use crate::constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE}; +use crate::error::GameError; +use crate::systems::{PixelPosition, Renderable}; +use crate::texture::sprite::SpriteAtlas; +use bevy_ecs::entity::Entity; +use bevy_ecs::event::EventWriter; +use bevy_ecs::system::{Commands, NonSendMut, Query, Res}; +use glam::Vec2; + +/// Calculates the pixel position for a fruit sprite based on its index +fn calculate_fruit_sprite_position(index: u32) -> Vec2 { + let start_x = CANVAS_SIZE.x - CELL_SIZE * 2; // 2 cells from right + let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area + let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites + + let x = start_x - ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32; + let y = start_y - CELL_SIZE / 2; + + Vec2::new((x - CELL_SIZE) as f32, (y + CELL_SIZE) as f32) +} + +/// System that manages fruit sprite entities in the HUD. +/// Spawns and despawns fruit sprite entities based on changes to FruitSprites resource. +/// Displays up to 6 fruits, sorted by value. +pub fn fruit_sprite_system( + mut commands: Commands, + atlas: NonSendMut, + current_fruit_sprites: Query<(Entity, &FruitInHud)>, + fruit_sprites: Res, + mut errors: EventWriter, +) { + // We only want to display the greatest 6 fruits + let fruits_to_display: Vec<_> = fruit_sprites.0.iter().rev().take(6).collect(); + + let mut current_sprites: Vec<_> = current_fruit_sprites.iter().collect(); + current_sprites.sort_by_key(|(_, fruit)| fruit.index); + + // Despawn all current sprites. We will respawn them. + // This is simpler than trying to match them up. + for (entity, _) in ¤t_sprites { + commands.entity(*entity).despawn(); + } + + for (i, fruit_type) in fruits_to_display.iter().enumerate() { + let fruit_sprite = match atlas.get_tile(&GameSprite::Fruit(**fruit_type).to_path()) { + Ok(sprite) => sprite, + Err(e) => { + errors.write(e.into()); + continue; + } + }; + + let position = calculate_fruit_sprite_position(i as u32); + + commands.spawn(( + FruitInHud { index: i as u32 }, + Renderable { + sprite: fruit_sprite, + layer: 255, // High layer to render on top + }, + PixelPosition { + pixel_position: position, + }, + )); + } +} diff --git a/src/systems/hud/mod.rs b/src/systems/hud/mod.rs index 5477855..fcde666 100644 --- a/src/systems/hud/mod.rs +++ b/src/systems/hud/mod.rs @@ -1,7 +1,9 @@ +pub mod fruits; pub mod lives; pub mod score; pub mod touch; +pub use self::fruits::*; pub use self::lives::*; pub use self::score::*; pub use self::touch::*; diff --git a/src/systems/item.rs b/src/systems/item.rs index f707a59..6fc1ec4 100644 --- a/src/systems/item.rs +++ b/src/systems/item.rs @@ -18,6 +18,8 @@ use crate::{ use crate::{systems::common::components::EntityType, systems::ItemCollider}; +use std::cmp::Ordering; + /// 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); @@ -36,6 +38,18 @@ pub enum FruitType { Key, } +impl PartialOrd for FruitType { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for FruitType { + fn cmp(&self, other: &Self) -> Ordering { + (self.score_value()).cmp(&other.score_value()) + } +} + impl FruitType { /// Returns the score value for this fruit type. pub fn score_value(self) -> u32 {