From 63e1059df8b35e6547a88dc51a2acde1a6eff616 Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Mon, 8 Sep 2025 16:20:19 -0500 Subject: [PATCH] feat: implement entity-based sprite system for HUD display (lives) - Spawn HUD elements as Renderables with simple change-based entity updates - Updated rendering systems to accommodate new precise pixel positioning for life sprites. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/game.rs | 24 +++---- src/systems/components.rs | 8 +++ src/systems/render.rs | 140 +++++++++++++++++++++++++++----------- 5 files changed, 123 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e47e59d..6edadda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "pacman" -version = "0.78.1" +version = "0.78.2" dependencies = [ "anyhow", "bevy_ecs", diff --git a/Cargo.toml b/Cargo.toml index 62ea124..ae958f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pacman" -version = "0.78.1" +version = "0.78.2" authors = ["Xevion"] edition = "2021" rust-version = "1.86.0" diff --git a/src/game.rs b/src/game.rs index 3af20c8..af5697f 100644 --- a/src/game.rs +++ b/src/game.rs @@ -13,13 +13,13 @@ 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_system, ghost_movement_system, ghost_state_system, - hud_render_system, item_system, linear_render_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, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, - MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, - PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, - SystemTimings, Timing, TouchState, Velocity, + hud_render_system, item_system, 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, Hidden, ItemBundle, + ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, PacmanCollider, + PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, + ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, }; use crate::texture::animated::{DirectionalTiles, TileSequence}; @@ -449,10 +449,9 @@ impl Game { let linear_render_system = profile(SystemId::LinearRender, linear_render_system); 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 present_system = profile(SystemId::Present, present_system); let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system); - // let death_sequence_system = profile(SystemId::DeathSequence, death_sequence_system); - // let game_over_system = profile(SystemId::GameOver, systems::game_over_system); let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system); let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system); @@ -460,10 +459,8 @@ impl Game { dirty.0 = true; }; - schedule.add_systems( - forced_dirty_system - .run_if(|score: Res, stage: Res| score.is_changed() || stage.is_changed()), - ); + schedule.add_systems((forced_dirty_system + .run_if(|score: Res, stage: Res| score.is_changed() || stage.is_changed()),)); // Input system should always run to prevent SDL event pump from blocking let input_systems = ( @@ -496,6 +493,7 @@ impl Game { dirty_render_system, combined_render_system, hud_render_system, + player_life_sprite_system, touch_ui_render_system, present_system, ) diff --git a/src/systems/components.rs b/src/systems/components.rs index 7057c05..6a6595c 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -229,6 +229,14 @@ pub struct Eaten; #[derive(Component, Debug, Clone, Copy)] pub struct Dying; +/// Component for HUD life sprite entities. +/// Each life sprite entity has an index indicating its position from left to right (0, 1, 2, etc.). +/// This mostly functions as a tag component for sprites. +#[derive(Component, Debug, Clone, Copy)] +pub struct PlayerLife { + pub index: u32, +} + #[derive(Component, Debug, Clone, Copy)] pub enum GhostState { /// Normal ghost behavior - chasing Pac-Man diff --git a/src/systems/render.rs b/src/systems/render.rs index f7f3daf..284d704 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -3,8 +3,8 @@ use crate::map::direction::Direction; use crate::systems::input::TouchState; use crate::systems::{ debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime, - DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLives, Position, Renderable, ScoreResource, - StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity, + DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLife, PlayerLives, Position, Renderable, + ScoreResource, StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity, }; use crate::texture::sprite::SpriteAtlas; use crate::texture::sprites::{GameSprite, PacmanSprite}; @@ -19,7 +19,8 @@ use bevy_ecs::event::EventWriter; use bevy_ecs::query::{Changed, Has, Or, With, Without}; use bevy_ecs::removal_detection::RemovedComponents; use bevy_ecs::resource::Resource; -use bevy_ecs::system::{NonSendMut, Query, Res, ResMut}; +use bevy_ecs::system::{Commands, NonSendMut, Query, Res, ResMut}; +use glam::Vec2; use sdl2::pixels::Color; use sdl2::rect::{Point, Rect}; use sdl2::render::{BlendMode, Canvas, Texture}; @@ -126,6 +127,84 @@ pub fn linear_render_system( } } +/// System that manages player life sprite entities. +/// Spawns and despawns life sprite entities based on changes to PlayerLives resource. +/// Each life sprite is positioned based on its index (0, 1, 2, etc. from left to right). +pub fn player_life_sprite_system( + mut commands: Commands, + atlas: NonSendMut, + current_life_sprites: Query<(Entity, &PlayerLife)>, + player_lives: Res, + mut errors: EventWriter, +) { + let displayed_lives = player_lives.0.saturating_sub(1); + + // Get current life sprite entities, sorted by index + let mut current_sprites: Vec<_> = current_life_sprites.iter().collect(); + current_sprites.sort_by_key(|(_, life)| life.index); + let current_count = current_sprites.len() as u8; + + // Calculate the difference + let diff = (displayed_lives as i8) - (current_count as i8); + + if diff > 0 { + // Spawn new life sprites + let life_sprite = match atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path()) { + Ok(sprite) => sprite, + Err(e) => { + errors.write(e.into()); + return; + } + }; + + for i in 0..diff.abs() { + let position = calculate_life_sprite_position(i as u32); + + commands.spawn(( + PlayerLife { index: i as u32 }, + Renderable { + sprite: life_sprite, + layer: 255, // High layer to render on top + }, + PixelPosition { + pixel_position: position, + }, + )); + } + } else if diff < 0 { + // Remove excess life sprites (highest indices first) + let to_remove = diff.abs() as usize; + let sprites_to_remove: Vec<_> = current_sprites + .iter() + .rev() // Start from highest index + .take(to_remove as usize) + .map(|(entity, _)| *entity) + .collect(); + + for entity in sprites_to_remove { + commands.entity(entity).despawn(); + } + } +} + +/// Component for Renderables to store an exact pixel position +#[derive(Component)] +pub struct PixelPosition { + pub pixel_position: Vec2, +} + +/// Calculates the pixel position for a life sprite based on its index +fn calculate_life_sprite_position(index: u32) -> Vec2 { + let start_x = CELL_SIZE * 2; // 2 cells from left + 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) +} + /// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource. pub struct MapTextureResource(pub Texture); @@ -211,7 +290,6 @@ pub fn hud_render_system( mut backbuffer: NonSendMut, mut canvas: NonSendMut<&mut Canvas>, mut atlas: NonSendMut, - player_lives: Res, score: Res, stage: Res, mut errors: EventWriter, @@ -227,35 +305,6 @@ pub fn hud_render_system( errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into()); } - // Render Pac-Man life sprites in bottom left - let lives = player_lives.0; - let life_sprite_path = &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path(); - - // Get the sprite from the atlas for life display - match atlas.get_tile(life_sprite_path) { - Ok(life_sprite) => { - let start_x = CELL_SIZE * 2; // 2 cells from left - 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 - - // Render one sprite for each remaining life (lives - 1, since current life isn't shown) - let sprites_to_show = if lives > 0 { lives - 1 } else { 0 }; - for i in 0..sprites_to_show { - let x = start_x + ((i as f32) * (sprite_spacing as f32 * 1.5)).round() as u32; - let y = start_y - CELL_SIZE / 2; - - let dest = sdl2::rect::Rect::new(x as i32, y as i32, life_sprite.size.x as u32, life_sprite.size.y as u32); - - if let Err(e) = life_sprite.render(canvas, &mut atlas, dest) { - errors.write(TextureError::RenderFailed(format!("Failed to render life sprite: {}", e)).into()); - } - } - } - Err(e) => { - errors.write(e.into()); - } - } - // Render score text let score_text = format!("{:02}", score.0); let score_offset = 7 - (score_text.len() as i32); @@ -318,7 +367,10 @@ pub fn render_system( atlas: &mut SpriteAtlas, map: &Res, dirty: &Res, - renderables: &Query<(Entity, &Renderable, &Position), Without>, + renderables: &Query< + (Entity, &Renderable, Option<&Position>, Option<&PixelPosition>), + (Without, Or<(With, With)>), + >, errors: &mut EventWriter, ) { if !dirty.0 { @@ -335,12 +387,21 @@ pub fn render_system( } // Render all entities to the backbuffer - for (_, renderable, position) in renderables + for (_entity, renderable, position, pixel_position) in renderables .iter() - .sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer) + .sort_by_key::<(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>), _>(|(_, renderable, _, _)| { + renderable.layer + }) .rev() { - let pos = position.get_pixel_position(&map.graph); + let pos = if let Some(position) = position { + position.get_pixel_position(&map.graph) + } else { + Ok(pixel_position + .expect("Pixel position should be present via query filtering, but got None on both") + .pixel_position) + }; + match pos { Ok(pos) => { let dest = Rect::from_center( @@ -378,7 +439,10 @@ pub fn combined_render_system( timing: Res, map: Res, dirty: Res, - renderables: Query<(Entity, &Renderable, &Position), Without>, + renderables: Query< + (Entity, &Renderable, Option<&Position>, Option<&PixelPosition>), + (Without, Or<(With, With)>), + >, colliders: Query<(&Collider, &Position)>, cursor: Res, mut errors: EventWriter,