mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-09 10:07:58 -06:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a73c5ace | ||
|
|
a2783ae62d | ||
|
|
83e0d1d737 | ||
|
|
d86864b6a3 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pacman"
|
||||
version = "0.79.0"
|
||||
version = "0.79.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pacman"
|
||||
version = "0.79.0"
|
||||
version = "0.79.2"
|
||||
authors = ["Xevion"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86.0"
|
||||
|
||||
@@ -28,7 +28,7 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
|
||||
|
||||
- [x] Fruit Spawning Mechanics
|
||||
- [x] Spawn at pellet counts 70 and 170
|
||||
- [ ] Fruit display in bottom-right corner
|
||||
- [x] Fruit display in bottom-right corner
|
||||
- [x] Fruit collection and scoring
|
||||
- [x] Bonus point display system
|
||||
|
||||
|
||||
BIN
assets/game/sound/pacman/death.ogg
Normal file
BIN
assets/game/sound/pacman/death.ogg
Normal file
Binary file not shown.
15
src/asset.rs
15
src/asset.rs
@@ -10,10 +10,7 @@ use strum_macros::EnumIter;
|
||||
/// binary-embedded data or embedded filesystem (Emscripten).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
|
||||
pub enum Asset {
|
||||
Wav1,
|
||||
Wav2,
|
||||
Wav3,
|
||||
Wav4,
|
||||
Waka(u8),
|
||||
/// Main sprite atlas containing all game graphics (atlas.png)
|
||||
AtlasImage,
|
||||
/// Terminal Vector font for text rendering (TerminalVector.ttf)
|
||||
@@ -32,13 +29,13 @@ impl Asset {
|
||||
pub fn path(&self) -> &str {
|
||||
use Asset::*;
|
||||
match self {
|
||||
Wav1 => "sound/waka/1.ogg",
|
||||
Wav2 => "sound/waka/2.ogg",
|
||||
Wav3 => "sound/waka/3.ogg",
|
||||
Wav4 => "sound/waka/4.ogg",
|
||||
Waka(0) => "sound/pacman/waka/1.ogg",
|
||||
Waka(1) => "sound/pacman/waka/2.ogg",
|
||||
Waka(2) => "sound/pacman/waka/3.ogg",
|
||||
Waka(3..=u8::MAX) => "sound/pacman/waka/4.ogg",
|
||||
DeathSound => "sound/pacman/death.ogg",
|
||||
AtlasImage => "atlas.png",
|
||||
Font => "TerminalVector.ttf",
|
||||
DeathSound => "sound/pacman_death.wav",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
src/audio.rs
28
src/audio.rs
@@ -1,11 +1,11 @@
|
||||
//! This module handles the audio playback for the game.
|
||||
use crate::asset::{get_asset_bytes, Asset};
|
||||
use sdl2::{
|
||||
mixer::{self, Chunk, InitFlag, LoaderRWops, DEFAULT_FORMAT},
|
||||
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
|
||||
rwops::RWops,
|
||||
};
|
||||
|
||||
const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::Wav4];
|
||||
const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2), Asset::Waka(3)];
|
||||
|
||||
/// The audio system for the game.
|
||||
///
|
||||
@@ -33,13 +33,24 @@ impl Audio {
|
||||
/// If audio fails to initialize, the audio system will be disabled and
|
||||
/// all functions will silently do nothing.
|
||||
pub fn new() -> Self {
|
||||
let frequency = 44100;
|
||||
let format = DEFAULT_FORMAT;
|
||||
let channels = 4;
|
||||
let chunk_size = 256; // 256 is minimum for emscripten
|
||||
let frequency = 44_100;
|
||||
let format = AUDIO_S16LSB;
|
||||
let chunk_size = {
|
||||
// 256 is the minimum for Emscripten, but in practice 1024 is much more reliable
|
||||
#[cfg(target_os = "emscripten")]
|
||||
{
|
||||
1024
|
||||
}
|
||||
|
||||
// Otherwise, 256 is plenty safe.
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
{
|
||||
256
|
||||
}
|
||||
};
|
||||
|
||||
// Try to open audio, but don't panic if it fails
|
||||
if let Err(e) = mixer::open_audio(frequency, format, 1, chunk_size) {
|
||||
if let Err(e) = mixer::open_audio(frequency, format, DEFAULT_CHANNELS, chunk_size) {
|
||||
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
|
||||
return Self {
|
||||
_mixer_context: None,
|
||||
@@ -51,7 +62,8 @@ impl Audio {
|
||||
};
|
||||
}
|
||||
|
||||
mixer::allocate_channels(channels);
|
||||
let channels = 4;
|
||||
mixer::allocate_channels(4);
|
||||
|
||||
// set channel volume
|
||||
for i in 0..channels {
|
||||
|
||||
19
src/game.rs
19
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),
|
||||
|
||||
@@ -59,13 +59,13 @@ pub fn init_console() -> Result<(), PlatformError> {
|
||||
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
match asset {
|
||||
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
|
||||
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
|
||||
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
|
||||
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
||||
Asset::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))),
|
||||
Asset::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.ogg"))),
|
||||
Asset::Waka(2) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/3.ogg"))),
|
||||
Asset::Waka(3..=u8::MAX) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/4.ogg"))),
|
||||
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
||||
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
|
||||
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman_death.wav"))),
|
||||
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/death.ogg"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PelletCount>,
|
||||
item_query: Query<(Entity, &EntityType, &Position), With<ItemCollider>>,
|
||||
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
|
||||
mut fruit_sprites: ResMut<FruitSprites>,
|
||||
mut events: EventWriter<AudioEvent>,
|
||||
) {
|
||||
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(),
|
||||
|
||||
79
src/systems/hud/fruits.rs
Normal file
79
src/systems/hud/fruits.rs
Normal file
@@ -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<FruitType>);
|
||||
|
||||
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<SpriteAtlas>,
|
||||
current_fruit_sprites: Query<(Entity, &FruitInHud)>,
|
||||
fruit_sprites: Res<FruitSprites>,
|
||||
mut errors: EventWriter<GameError>,
|
||||
) {
|
||||
// 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,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -85,8 +85,12 @@ impl Default for Bindings {
|
||||
key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug);
|
||||
key_bindings.insert(Keycode::M, GameCommand::MuteAudio);
|
||||
key_bindings.insert(Keycode::R, GameCommand::ResetLevel);
|
||||
key_bindings.insert(Keycode::Escape, GameCommand::Exit);
|
||||
key_bindings.insert(Keycode::Q, GameCommand::Exit);
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
{
|
||||
key_bindings.insert(Keycode::Escape, GameCommand::Exit);
|
||||
key_bindings.insert(Keycode::Q, GameCommand::Exit);
|
||||
}
|
||||
|
||||
let movement_keys = HashSet::from([
|
||||
Keycode::W,
|
||||
|
||||
@@ -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<Ordering> {
|
||||
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 {
|
||||
|
||||
@@ -13,9 +13,9 @@ use pacman::{
|
||||
graph::{Graph, Node},
|
||||
},
|
||||
systems::{
|
||||
item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost,
|
||||
GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled,
|
||||
Position, ScoreResource, Velocity,
|
||||
item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType,
|
||||
FruitSprites, Ghost, GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider,
|
||||
PelletCount, PlayerControlled, Position, ScoreResource, Velocity,
|
||||
},
|
||||
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
|
||||
};
|
||||
@@ -83,6 +83,7 @@ pub fn create_test_world() -> (World, Schedule) {
|
||||
world.insert_resource(Events::<pacman::error::GameError>::default());
|
||||
world.insert_resource(Events::<AudioEvent>::default());
|
||||
world.insert_resource(ScoreResource(0));
|
||||
world.insert_resource(FruitSprites::default());
|
||||
world.insert_resource(AudioState::default());
|
||||
world.insert_resource(GlobalState { exit: false });
|
||||
world.insert_resource(DebugState::default());
|
||||
|
||||
Reference in New Issue
Block a user