mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 15:15:48 -06:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e1059df8 | ||
|
|
11af44c469 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pacman"
|
||||
version = "0.78.0"
|
||||
version = "0.78.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pacman"
|
||||
version = "0.78.0"
|
||||
version = "0.78.2"
|
||||
authors = ["Xevion"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86.0"
|
||||
|
||||
@@ -25,12 +25,25 @@ pub const SCALE: f32 = 2.6;
|
||||
/// screen for score display, player lives, and other UI elements.
|
||||
pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3);
|
||||
|
||||
/// Bottom HUD row offset to reserve space below the game board.
|
||||
///
|
||||
/// The 2-cell vertical offset (16 pixels) provides space at the bottom of the
|
||||
/// screen for displaying Pac-Man's lives (left) and fruit symbols (right).
|
||||
pub const BOARD_BOTTOM_CELL_OFFSET: UVec2 = UVec2::new(0, 2);
|
||||
|
||||
/// Pixel-space equivalent of `BOARD_CELL_OFFSET` for rendering calculations.
|
||||
///
|
||||
/// Automatically calculated from the cell offset to maintain consistency
|
||||
/// when the cell size changes. Used for positioning sprites and debug overlays.
|
||||
pub const BOARD_PIXEL_OFFSET: UVec2 = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
|
||||
|
||||
/// Pixel-space equivalent of `BOARD_BOTTOM_CELL_OFFSET` for rendering calculations.
|
||||
///
|
||||
/// Automatically calculated from the cell offset to maintain consistency
|
||||
/// when the cell size changes. Used for positioning bottom HUD elements.
|
||||
pub const BOARD_BOTTOM_PIXEL_OFFSET: UVec2 =
|
||||
UVec2::new(BOARD_BOTTOM_CELL_OFFSET.x * CELL_SIZE, BOARD_BOTTOM_CELL_OFFSET.y * CELL_SIZE);
|
||||
|
||||
/// Animation timing constants for ghost state management
|
||||
pub mod animation {
|
||||
/// Normal ghost movement animation speed (ticks per frame at 60 ticks/sec)
|
||||
@@ -45,15 +58,15 @@ pub mod animation {
|
||||
}
|
||||
/// The size of the canvas, in pixels.
|
||||
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,
|
||||
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE,
|
||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE,
|
||||
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y + BOARD_BOTTOM_CELL_OFFSET.y) * CELL_SIZE,
|
||||
);
|
||||
|
||||
pub const LARGE_SCALE: f32 = 2.6;
|
||||
|
||||
pub const LARGE_CANVAS_SIZE: UVec2 = UVec2::new(
|
||||
(((BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
(((BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
(((BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
(((BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y + BOARD_BOTTOM_CELL_OFFSET.y) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
);
|
||||
|
||||
/// Collider size constants for different entity types
|
||||
|
||||
24
src/game.rs
24
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<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),
|
||||
);
|
||||
schedule.add_systems((forced_dirty_system
|
||||
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| 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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use crate::map::builder::Map;
|
||||
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};
|
||||
use crate::texture::text::TextTexture;
|
||||
use crate::{
|
||||
constants::CANVAS_SIZE,
|
||||
constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE},
|
||||
error::{GameError, TextureError},
|
||||
};
|
||||
use bevy_ecs::component::Component;
|
||||
@@ -17,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};
|
||||
@@ -124,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<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);
|
||||
|
||||
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);
|
||||
|
||||
@@ -209,7 +290,6 @@ pub fn hud_render_system(
|
||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||
mut atlas: NonSendMut<SpriteAtlas>,
|
||||
player_lives: Res<PlayerLives>,
|
||||
score: Res<ScoreResource>,
|
||||
stage: Res<GameStage>,
|
||||
mut errors: EventWriter<GameError>,
|
||||
@@ -218,11 +298,10 @@ pub fn hud_render_system(
|
||||
let mut text_renderer = TextTexture::new(1.0);
|
||||
|
||||
// Render lives and high score text in white
|
||||
let lives = player_lives.0;
|
||||
let lives_text = format!("{lives}UP HIGH SCORE ");
|
||||
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) {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -288,7 +367,10 @@ pub fn render_system(
|
||||
atlas: &mut SpriteAtlas,
|
||||
map: &Res<Map>,
|
||||
dirty: &Res<RenderDirty>,
|
||||
renderables: &Query<(Entity, &Renderable, &Position), Without<Hidden>>,
|
||||
renderables: &Query<
|
||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
||||
>,
|
||||
errors: &mut EventWriter<GameError>,
|
||||
) {
|
||||
if !dirty.0 {
|
||||
@@ -305,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(
|
||||
@@ -348,7 +439,10 @@ pub fn combined_render_system(
|
||||
timing: Res<crate::systems::profiling::Timing>,
|
||||
map: Res<Map>,
|
||||
dirty: Res<RenderDirty>,
|
||||
renderables: Query<(Entity, &Renderable, &Position), Without<Hidden>>,
|
||||
renderables: Query<
|
||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
||||
>,
|
||||
colliders: Query<(&Collider, &Position)>,
|
||||
cursor: Res<CursorPosition>,
|
||||
mut errors: EventWriter<GameError>,
|
||||
|
||||
Reference in New Issue
Block a user