refactor: reorganize hud-related elements into systems/hud submodule

This commit is contained in:
Ryan Walters
2025-09-09 17:00:32 -05:00
parent afae3c5e7b
commit ce8ea347e1
7 changed files with 270 additions and 239 deletions

View File

@@ -7,7 +7,7 @@ rustflags = [
]
runner = "node"
# despite being semantically identical to `target_os = "linux"`, the `cfg(linux)` syntax is not supported here. Who knows why...\
# despite being semantically identical to `target_os = "linux"`, the `cfg(linux)` syntax is not supported here. Who knows why...
# https://github.com/Xevion/Pac-Man/actions/runs/17596477856
[target.'cfg(target_os = "linux")']
rustflags = [

89
src/systems/hud/lives.rs Normal file
View File

@@ -0,0 +1,89 @@
use std::cmp::Ordering;
use crate::constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE};
use crate::error::GameError;
use crate::map::direction::Direction;
use crate::systems::{PixelPosition, PlayerLife, PlayerLives, Renderable};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::sprites::{GameSprite, PacmanSprite};
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 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)
}
/// 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<SpriteAtlas>,
current_life_sprites: Query<(Entity, &PlayerLife)>,
player_lives: Res<PlayerLives>,
mut errors: EventWriter<GameError>,
) {
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);
match diff.cmp(&0) {
// Ignore when the number of lives displayed is correct
Ordering::Equal => {}
// Spawn new life sprites
Ordering::Greater => {
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 {
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,
},
));
}
}
// Remove excess life sprites (highest indices first)
Ordering::Less => {
let to_remove = diff.unsigned_abs();
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();
}
}
}
}

7
src/systems/hud/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod lives;
pub mod score;
pub mod touch;
pub use self::lives::*;
pub use self::score::*;
pub use self::touch::*;

86
src/systems/hud/score.rs Normal file
View File

@@ -0,0 +1,86 @@
use crate::constants;
use crate::error::{GameError, TextureError};
use crate::systems::{BackbufferResource, GameStage, ScoreResource, StartupSequence};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::text::TextTexture;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{NonSendMut, Res};
use sdl2::pixels::Color;
use sdl2::render::Canvas;
use sdl2::video::Window;
/// Renders the HUD (score, lives, etc.) on top of the game.
#[allow(clippy::too_many_arguments)]
pub fn hud_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut atlas: NonSendMut<SpriteAtlas>,
score: Res<ScoreResource>,
stage: Res<GameStage>,
mut errors: EventWriter<GameError>,
) {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
let mut text_renderer = TextTexture::new(1.0);
// Render lives and high score text in white
let lives_text = "1UP HIGH SCORE ";
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
}
// Render score text
let score_text = format!("{:02}", score.0);
let score_offset = 7 - (score_text.len() as i32);
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
}
// Render high score text
let high_score_text = format!("{:02}", score.0);
let high_score_offset = 17 - (high_score_text.len() as i32);
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
}
// Render GAME OVER text
if matches!(*stage, GameStage::GameOver) {
let game_over_text = "GAME OVER";
let game_over_width = text_renderer.text_width(game_over_text);
let game_over_position = glam::UVec2::new((constants::CANVAS_SIZE.x - game_over_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
}
}
// Render text based on StartupSequence stage
if matches!(
*stage,
GameStage::Starting(StartupSequence::TextOnly { .. })
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
) {
let ready_text = "READY!";
let ready_width = text_renderer.text_width(ready_text);
let ready_position = glam::UVec2::new((constants::CANVAS_SIZE.x - ready_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
}
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
let player_one_text = "PLAYER ONE";
let player_one_width = text_renderer.text_width(player_one_text);
let player_one_position = glam::UVec2::new((constants::CANVAS_SIZE.x - player_one_width) / 2, 113);
if let Err(e) =
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
{
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
}
}
}
});
}

81
src/systems/hud/touch.rs Normal file
View File

@@ -0,0 +1,81 @@
use crate::error::{GameError, TextureError};
use crate::systems::{BackbufferResource, TouchState};
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{NonSendMut, Res};
use sdl2::pixels::Color;
use sdl2::rect::Point;
use sdl2::render::{BlendMode, Canvas};
use sdl2::video::Window;
/// Renders touch UI overlay for mobile/testing.
pub fn touch_ui_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
touch_state: Res<TouchState>,
mut errors: EventWriter<GameError>,
) {
if let Some(ref touch_data) = touch_state.active_touch {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
// Set blend mode for transparency
canvas.set_blend_mode(BlendMode::Blend);
// Draw semi-transparent circle at touch start position
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
// Draw a simple circle by drawing filled rectangles (basic approach)
let radius = 30;
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy <= radius * radius {
let point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_point(point) {
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
return;
}
}
}
}
// Draw direction indicator if we have a direction
if let Some(direction) = touch_data.current_direction {
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
// Draw arrow indicating direction
let arrow_length = 40;
let (dx, dy) = match direction {
crate::map::direction::Direction::Up => (0, -arrow_length),
crate::map::direction::Direction::Down => (0, arrow_length),
crate::map::direction::Direction::Left => (-arrow_length, 0),
crate::map::direction::Direction::Right => (arrow_length, 0),
};
let end_point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_line(center, end_point) {
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
}
// Draw arrowhead (simple approach)
let arrow_size = 8;
match direction {
crate::map::direction::Direction::Up => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Down => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
}
crate::map::direction::Direction::Left => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Right => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
}
}
}
});
}
}

View File

@@ -15,6 +15,7 @@ pub mod blinking;
pub mod collision;
pub mod common;
pub mod ghost;
pub mod hud;
pub mod input;
pub mod item;
pub mod lifetime;
@@ -31,6 +32,7 @@ pub use self::collision::*;
pub use self::common::*;
pub use self::debug::*;
pub use self::ghost::*;
pub use self::hud::*;
pub use self::input::*;
pub use self::item::*;
pub use self::lifetime::*;

View File

@@ -1,29 +1,21 @@
use crate::error::{GameError, TextureError};
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::{
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, GameStage, PlayerLife,
PlayerLives, Position, ScoreResource, StartupSequence, SystemId, SystemTimings, TouchState, TtfAtlasResource,
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, Position, SystemId,
SystemTimings, TtfAtlasResource,
};
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use crate::texture::sprites::{GameSprite, PacmanSprite};
use crate::texture::text::TextTexture;
use crate::{
constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE},
error::{GameError, TextureError},
};
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::query::{Changed, Or, With, Without};
use bevy_ecs::removal_detection::RemovedComponents;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{Commands, NonSendMut, Query, Res, ResMut};
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
use glam::Vec2;
use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect};
use sdl2::render::{BlendMode, Canvas, Texture};
use sdl2::video::Window;
use std::cmp::Ordering;
use std::time::Instant;
/// A component for entities that have a sprite, with a layer for ordering.
@@ -64,244 +56,18 @@ pub fn dirty_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<SpriteAtlas>,
current_life_sprites: Query<(Entity, &PlayerLife)>,
player_lives: Res<PlayerLives>,
mut errors: EventWriter<GameError>,
) {
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);
match diff.cmp(&0) {
// Ignore when the number of lives displayed is correct
Ordering::Equal => {}
// Spawn new life sprites
Ordering::Greater => {
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 {
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,
},
));
}
}
// Remove excess life sprites (highest indices first)
Ordering::Less => {
let to_remove = diff.unsigned_abs();
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);
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
pub struct BackbufferResource(pub Texture);
/// Renders touch UI overlay for mobile/testing.
pub fn touch_ui_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
touch_state: Res<TouchState>,
mut errors: EventWriter<GameError>,
) {
if let Some(ref touch_data) = touch_state.active_touch {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
// Set blend mode for transparency
canvas.set_blend_mode(BlendMode::Blend);
// Draw semi-transparent circle at touch start position
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
// Draw a simple circle by drawing filled rectangles (basic approach)
let radius = 30;
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy <= radius * radius {
let point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_point(point) {
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
return;
}
}
}
}
// Draw direction indicator if we have a direction
if let Some(direction) = touch_data.current_direction {
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
// Draw arrow indicating direction
let arrow_length = 40;
let (dx, dy) = match direction {
crate::map::direction::Direction::Up => (0, -arrow_length),
crate::map::direction::Direction::Down => (0, arrow_length),
crate::map::direction::Direction::Left => (-arrow_length, 0),
crate::map::direction::Direction::Right => (arrow_length, 0),
};
let end_point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_line(center, end_point) {
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
}
// Draw arrowhead (simple approach)
let arrow_size = 8;
match direction {
crate::map::direction::Direction::Up => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Down => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
}
crate::map::direction::Direction::Left => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Right => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
}
}
}
});
}
}
/// Renders the HUD (score, lives, etc.) on top of the game.
#[allow(clippy::too_many_arguments)]
pub fn hud_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut atlas: NonSendMut<SpriteAtlas>,
score: Res<ScoreResource>,
stage: Res<GameStage>,
mut errors: EventWriter<GameError>,
) {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
let mut text_renderer = TextTexture::new(1.0);
// Render lives and high score text in white
let lives_text = "1UP HIGH SCORE ";
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
}
// Render score text
let score_text = format!("{:02}", score.0);
let score_offset = 7 - (score_text.len() as i32);
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
}
// Render high score text
let high_score_text = format!("{:02}", score.0);
let high_score_offset = 17 - (high_score_text.len() as i32);
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
}
// Render GAME OVER text
if matches!(*stage, GameStage::GameOver) {
let game_over_text = "GAME OVER";
let game_over_width = text_renderer.text_width(game_over_text);
let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
}
}
// Render text based on StartupSequence stage
if matches!(
*stage,
GameStage::Starting(StartupSequence::TextOnly { .. })
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
) {
let ready_text = "READY!";
let ready_width = text_renderer.text_width(ready_text);
let ready_position = glam::UVec2::new((CANVAS_SIZE.x - ready_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
}
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
let player_one_text = "PLAYER ONE";
let player_one_width = text_renderer.text_width(player_one_text);
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
if let Err(e) =
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
{
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
}
}
}
});
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn render_system(