mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 07:15:41 -06:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d275b8e85 | |||
| bd61db9aae | |||
| ed8bd07518 | |||
| 27705f1ba2 |
27
.github/workflows/audit.yaml
vendored
27
.github/workflows/audit.yaml
vendored
@@ -1,27 +0,0 @@
|
||||
name: Audit
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_TOOLCHAIN: 1.88.0
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
1
.github/workflows/build.yaml
vendored
1
.github/workflows/build.yaml
vendored
@@ -1,5 +1,4 @@
|
||||
name: Builds
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
permissions:
|
||||
|
||||
3
.github/workflows/coverage.yaml
vendored
3
.github/workflows/coverage.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Coverage
|
||||
name: Code Coverage
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
@@ -8,7 +8,6 @@ env:
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Code Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: Tests & Checks
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
@@ -8,7 +8,6 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -52,3 +51,8 @@ jobs:
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- uses: taiki-e/install-action@cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -192,6 +192,7 @@ dependencies = [
|
||||
"sdl2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"spin_sleep",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
|
||||
@@ -20,6 +20,7 @@ anyhow = "1.0"
|
||||
glam = { version = "0.30.4", features = [] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.141"
|
||||
smallvec = "1.15.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
|
||||
|
||||
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml/badge.svg
|
||||
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
|
||||
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
|
||||
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
|
||||
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
|
||||
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
|
||||
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
||||
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml
|
||||
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml
|
||||
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
|
||||
[demo]: https://xevion.github.io/Pac-Man/
|
||||
[commits]: https://github.com/Xevion/Pac-Man/commits/master
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 250 250"
|
||||
class="fill-yellow-400 text-white"
|
||||
class="fill-yellow-400 [&>.octo-arm,.octo-body]:fill-black"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
@@ -46,16 +46,12 @@
|
||||
</a>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="pt-10">
|
||||
<h1 class="text-4xl arcade-title scaled-text">Pac-Man in Rust</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-5xl">
|
||||
<canvas
|
||||
id="canvas"
|
||||
oncontextmenu="event.preventDefault()"
|
||||
class="block bg-black w-full max-w-[90vw] h-auto rounded-xl shadow-[inset_0_0_0_2px_rgba(255,255,255,0.12),0_10px_30px_rgba(0,0,0,0.8)]"
|
||||
class="block w-full h-full max-h-[90vh] aspect-square"
|
||||
></canvas>
|
||||
|
||||
<div
|
||||
|
||||
216
src/entity/ghost.rs
Normal file
216
src/entity/ghost.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! Ghost entity implementation.
|
||||
//!
|
||||
//! This module contains the ghost character logic, including movement,
|
||||
//! animation, and rendering. Ghosts move through the game graph using
|
||||
//! a traverser and display directional animated textures.
|
||||
|
||||
use glam::Vec2;
|
||||
use rand::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::constants::BOARD_PIXEL_OFFSET;
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId, Position, Traverser};
|
||||
use crate::helpers::centered_with_size;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::directional::DirectionalAnimatedTexture;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
|
||||
/// Determines if a ghost can traverse a given edge.
|
||||
///
|
||||
/// Ghosts can move through edges that allow all entities or ghost-only edges.
|
||||
fn can_ghost_traverse(edge: Edge) -> bool {
|
||||
matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly)
|
||||
}
|
||||
|
||||
/// The four classic ghost types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GhostType {
|
||||
Blinky,
|
||||
Pinky,
|
||||
Inky,
|
||||
Clyde,
|
||||
}
|
||||
|
||||
impl GhostType {
|
||||
/// Returns the ghost type name for atlas lookups.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GhostType::Blinky => "blinky",
|
||||
GhostType::Pinky => "pinky",
|
||||
GhostType::Inky => "inky",
|
||||
GhostType::Clyde => "clyde",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the base movement speed for this ghost type.
|
||||
pub fn base_speed(self) -> f32 {
|
||||
match self {
|
||||
GhostType::Blinky => 1.0,
|
||||
GhostType::Pinky => 0.95,
|
||||
GhostType::Inky => 0.9,
|
||||
GhostType::Clyde => 0.85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ghost entity that roams the game world.
|
||||
///
|
||||
/// Ghosts move through the game world using a graph-based navigation system
|
||||
/// and display directional animated sprites. They randomly choose directions
|
||||
/// at each intersection.
|
||||
pub struct Ghost {
|
||||
/// Handles movement through the game graph
|
||||
pub traverser: Traverser,
|
||||
/// The type of ghost (affects appearance and speed)
|
||||
pub ghost_type: GhostType,
|
||||
/// Manages directional animated textures for different movement states
|
||||
texture: DirectionalAnimatedTexture,
|
||||
/// Current movement speed
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
impl Ghost {
|
||||
/// Creates a new ghost instance at the specified starting node.
|
||||
///
|
||||
/// Sets up animated textures for all four directions with moving and stopped states.
|
||||
/// The moving animation cycles through two sprite variants.
|
||||
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> Self {
|
||||
let mut textures = [None, None, None, None];
|
||||
let mut stopped_textures = [None, None, None, None];
|
||||
|
||||
for direction in Direction::DIRECTIONS {
|
||||
let moving_prefix = match direction {
|
||||
Direction::Up => "up",
|
||||
Direction::Down => "down",
|
||||
Direction::Left => "left",
|
||||
Direction::Right => "right",
|
||||
};
|
||||
let moving_tiles = vec![
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")).unwrap(),
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")).unwrap(),
|
||||
];
|
||||
|
||||
let stopped_tiles =
|
||||
vec![
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2).expect("Invalid frame duration"));
|
||||
stopped_textures[direction.as_usize()] =
|
||||
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
|
||||
}
|
||||
|
||||
Self {
|
||||
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
|
||||
ghost_type,
|
||||
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||
speed: ghost_type.base_speed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the ghost's position and animation state.
|
||||
///
|
||||
/// Advances movement through the graph, updates texture animation,
|
||||
/// and chooses random directions at intersections.
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
// Choose random direction when at a node
|
||||
if self.traverser.position.is_at_node() {
|
||||
self.choose_random_direction(graph);
|
||||
}
|
||||
|
||||
self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
/// Chooses a random available direction at the current intersection.
|
||||
fn choose_random_direction(&mut self, graph: &Graph) {
|
||||
let current_node = self.traverser.position.from_node_id();
|
||||
let intersection = &graph.adjacency_list[current_node];
|
||||
|
||||
// Collect all available directions
|
||||
let mut available_directions = SmallVec::<[_; 4]>::new();
|
||||
for direction in Direction::DIRECTIONS {
|
||||
if let Some(edge) = intersection.get(direction) {
|
||||
if can_ghost_traverse(edge) {
|
||||
available_directions.push(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Ghost {} at node {}: available directions: {:?}, current direction: {:?}",
|
||||
self.ghost_type.as_str(),
|
||||
current_node,
|
||||
available_directions,
|
||||
self.traverser.direction
|
||||
);
|
||||
|
||||
// Choose a random direction (avoid reversing unless necessary)
|
||||
if !available_directions.is_empty() {
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
// Filter out the opposite direction if possible, but allow it if we have limited options
|
||||
let opposite = self.traverser.direction.opposite();
|
||||
let filtered_directions: Vec<_> = available_directions
|
||||
.iter()
|
||||
.filter(|&&dir| dir != opposite || available_directions.len() <= 2)
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Ghost {}: filtered directions: {:?}, opposite: {:?}",
|
||||
self.ghost_type.as_str(),
|
||||
filtered_directions,
|
||||
opposite
|
||||
);
|
||||
|
||||
if let Some(&random_direction) = filtered_directions.choose(&mut rng) {
|
||||
self.traverser.set_next_direction(*random_direction);
|
||||
debug!("Ghost {} chose direction: {:?}", self.ghost_type.as_str(), random_direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the current pixel position in the game world.
|
||||
///
|
||||
/// Converts the graph position to screen coordinates, accounting for
|
||||
/// the board offset and centering the sprite.
|
||||
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
|
||||
let pos = match self.traverser.position {
|
||||
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
let from_pos = graph.get_node(from).unwrap().position;
|
||||
let to_pos = graph.get_node(to).unwrap().position;
|
||||
let edge = graph.find_edge(from, to).unwrap();
|
||||
from_pos + (to_pos - from_pos) * (traversed / edge.distance)
|
||||
}
|
||||
};
|
||||
|
||||
Vec2::new(pos.x + BOARD_PIXEL_OFFSET.x as f32, pos.y + BOARD_PIXEL_OFFSET.y as f32)
|
||||
}
|
||||
|
||||
/// Renders the ghost at its current position.
|
||||
///
|
||||
/// Draws the appropriate directional sprite based on the ghost's
|
||||
/// current movement state and direction.
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||
let pixel_pos = self.get_pixel_pos(graph);
|
||||
let dest = centered_with_size(
|
||||
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
|
||||
glam::UVec2::new(16, 16),
|
||||
);
|
||||
|
||||
if self.traverser.position.is_stopped() {
|
||||
self.texture
|
||||
.render_stopped(canvas, atlas, dest, self.traverser.direction)
|
||||
.expect("Failed to render ghost");
|
||||
} else {
|
||||
self.texture
|
||||
.render(canvas, atlas, dest, self.traverser.direction)
|
||||
.expect("Failed to render ghost");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod direction;
|
||||
pub mod ghost;
|
||||
pub mod graph;
|
||||
pub mod pacman;
|
||||
|
||||
57
src/game.rs
57
src/game.rs
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use glam::UVec2;
|
||||
use rand::{rngs::SmallRng, Rng, SeedableRng};
|
||||
use sdl2::{
|
||||
image::LoadTexture,
|
||||
keyboard::Keycode,
|
||||
@@ -14,7 +15,10 @@ use crate::{
|
||||
asset::{get_asset_bytes, Asset},
|
||||
audio::Audio,
|
||||
constants::RAW_BOARD,
|
||||
entity::pacman::Pacman,
|
||||
entity::{
|
||||
ghost::{Ghost, GhostType},
|
||||
pacman::Pacman,
|
||||
},
|
||||
map::Map,
|
||||
texture::{
|
||||
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
|
||||
@@ -30,6 +34,7 @@ pub struct Game {
|
||||
pub score: u32,
|
||||
pub map: Map,
|
||||
pub pacman: Pacman,
|
||||
pub ghosts: Vec<Ghost>,
|
||||
pub debug_mode: bool,
|
||||
|
||||
// Rendering resources
|
||||
@@ -73,10 +78,23 @@ impl Game {
|
||||
let audio = Audio::new();
|
||||
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
|
||||
|
||||
// Create ghosts at random positions
|
||||
let mut ghosts = Vec::new();
|
||||
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
for &ghost_type in &ghost_types {
|
||||
// Find a random node for the ghost to start at
|
||||
let random_node = rng.random_range(0..map.graph.node_count());
|
||||
let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas);
|
||||
ghosts.push(ghost);
|
||||
}
|
||||
|
||||
Game {
|
||||
score: 0,
|
||||
map,
|
||||
pacman,
|
||||
ghosts,
|
||||
debug_mode: false,
|
||||
map_texture,
|
||||
text_texture,
|
||||
@@ -91,10 +109,41 @@ impl Game {
|
||||
if keycode == Keycode::M {
|
||||
self.audio.set_mute(!self.audio.is_muted());
|
||||
}
|
||||
|
||||
if keycode == Keycode::R {
|
||||
self.reset_game_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the game state, randomizing ghost positions and resetting Pac-Man
|
||||
fn reset_game_state(&mut self) {
|
||||
// Reset Pac-Man to starting position
|
||||
let pacman_start_pos = self.map.find_starting_position(0).unwrap();
|
||||
let pacman_start_node = *self
|
||||
.map
|
||||
.grid_to_node
|
||||
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
|
||||
.expect("Pac-Man starting position not found in graph");
|
||||
|
||||
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas);
|
||||
|
||||
// Randomize ghost positions
|
||||
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
for (i, ghost) in self.ghosts.iter_mut().enumerate() {
|
||||
let random_node = rng.random_range(0..self.map.graph.node_count());
|
||||
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
self.pacman.tick(dt, &self.map.graph);
|
||||
|
||||
// Update all ghosts
|
||||
for ghost in &mut self.ghosts {
|
||||
ghost.tick(dt, &self.map.graph);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
|
||||
@@ -102,6 +151,12 @@ impl Game {
|
||||
canvas.set_draw_color(Color::BLACK);
|
||||
canvas.clear();
|
||||
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
|
||||
|
||||
// Render all ghosts
|
||||
for ghost in &self.ghosts {
|
||||
ghost.render(canvas, &mut self.atlas, &self.map.graph);
|
||||
}
|
||||
|
||||
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
|
||||
})?;
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ impl MapRenderer {
|
||||
|
||||
// Draw node ID text (small, offset to top right)
|
||||
text_renderer.set_scale(0.5); // Small text
|
||||
let id_text = format!("#{}", nearest_id);
|
||||
let id_text = format!("#{nearest_id}");
|
||||
let text_pos = glam::UVec2::new(
|
||||
(nearest_pos.x + 4.0) as u32, // Offset to the right
|
||||
(nearest_pos.y - 6.0) as u32, // Offset to the top
|
||||
|
||||
48
tests/ghost.rs
Normal file
48
tests/ghost.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use pacman::entity::ghost::{Ghost, GhostType};
|
||||
use pacman::entity::graph::Graph;
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
let directions = ["up", "down", "left", "right"];
|
||||
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
|
||||
|
||||
for ghost_type in &ghost_types {
|
||||
for (i, dir) in directions.iter().enumerate() {
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 16,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghost_creation() {
|
||||
let graph = Graph::new();
|
||||
let atlas = create_test_atlas();
|
||||
|
||||
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas);
|
||||
|
||||
assert_eq!(ghost.ghost_type, GhostType::Blinky);
|
||||
assert_eq!(ghost.traverser.position.from_node_id(), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user