Compare commits

...

3 Commits

Author SHA1 Message Date
1dc8aca373 feat: item collection & collisions, pellet & energizer generation 2025-08-11 22:45:36 -05:00
02089a78da chore: downgrade toolchain to 1.86 on all versions
This is just because managing both 1.86 and 1.88 is really annoying, so
it's better to just be unified. There's no real point to using 1.88
besides more clippy warnings, which are already impeding my work right
now. So we're downgrading.
2025-08-11 22:10:41 -05:00
1f8e7c6d71 fix: resolve clippy warnings, inline format vars, use tracing to log warnings 2025-08-11 22:09:08 -05:00
10 changed files with 217 additions and 20 deletions

View File

@@ -14,19 +14,19 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: pacman
toolchain: 1.88.0
toolchain: 1.86.0
- os: macos-13
target: x86_64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
toolchain: 1.86.0
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
toolchain: 1.86.0
- os: windows-latest
target: x86_64-pc-windows-gnu
artifact_name: pacman.exe
toolchain: 1.88.0
toolchain: 1.86.0
runs-on: ${{ matrix.os }}
steps:
- name: Checkout

View File

@@ -4,7 +4,7 @@ on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0
RUST_TOOLCHAIN: 1.86.0
jobs:
test:

2
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "1.86.0"

View File

@@ -7,6 +7,7 @@
use pathfinding::prelude::dijkstra;
use rand::prelude::*;
use smallvec::SmallVec;
use tracing::error;
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId};
@@ -104,7 +105,7 @@ impl Entity for Ghost {
}
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) {
eprintln!("Ghost movement error: {}", e);
error!("Ghost movement error: {}", e);
}
self.texture.tick(dt);
}

95
src/entity/item.rs Normal file
View File

@@ -0,0 +1,95 @@
use crate::{
constants,
entity::graph::Graph,
error::EntityError,
texture::sprite::{Sprite, SpriteAtlas},
};
use sdl2::render::{Canvas, RenderTarget};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemType {
Pellet,
Energizer,
#[allow(dead_code)]
Fruit {
kind: FruitKind,
},
}
impl ItemType {
pub fn get_score(self) -> u32 {
match self {
ItemType::Pellet => 10,
ItemType::Energizer => 50,
ItemType::Fruit { kind } => kind.get_score(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum FruitKind {
Apple,
Strawberry,
Orange,
Melon,
Bell,
Key,
Galaxian,
}
impl FruitKind {
pub fn get_score(self) -> u32 {
match self {
FruitKind::Apple => 100,
FruitKind::Strawberry => 300,
FruitKind::Orange => 500,
FruitKind::Melon => 700,
FruitKind::Bell => 1000,
FruitKind::Key => 2000,
FruitKind::Galaxian => 3000,
}
}
}
pub struct Item {
pub node_index: usize,
pub item_type: ItemType,
pub sprite: Sprite,
pub collected: bool,
}
impl Item {
pub fn new(node_index: usize, item_type: ItemType, sprite: Sprite) -> Self {
Self {
node_index,
item_type,
sprite,
collected: false,
}
}
pub fn is_collected(&self) -> bool {
self.collected
}
pub fn collect(&mut self) {
self.collected = true;
}
pub fn get_score(&self) -> u32 {
self.item_type.get_score()
}
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> anyhow::Result<()> {
if !self.collected {
let node = graph
.get_node(self.node_index)
.ok_or(EntityError::NodeNotFound(self.node_index))?;
let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2();
self.sprite.render(canvas, atlas, position)
} else {
Ok(())
}
}
}

View File

@@ -1,6 +1,7 @@
pub mod direction;
pub mod ghost;
pub mod graph;
pub mod item;
pub mod pacman;
pub mod r#trait;
pub mod traversal;

View File

@@ -12,6 +12,7 @@ use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode;
use tracing::error;
use crate::error::{GameError, GameResult, TextureError};
@@ -60,7 +61,7 @@ impl Entity for Pacman {
fn tick(&mut self, dt: f32, graph: &Graph) {
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) {
eprintln!("Pac-Man movement error: {}", e);
error!("Pac-Man movement error: {}", e);
}
self.texture.tick(dt);
}

View File

@@ -18,6 +18,7 @@ use crate::{
constants::{CELL_SIZE, RAW_BOARD},
entity::{
ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman,
r#trait::Entity,
},
@@ -37,6 +38,7 @@ pub struct Game {
pub map: Map,
pub pacman: Pacman,
pub ghosts: Vec<Ghost>,
pub items: Vec<Item>,
pub debug_mode: bool,
// Rendering resources
@@ -68,7 +70,7 @@ impl Game {
let atlas_texture = unsafe {
let texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {}", e)))
GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}")))
} else {
GameError::Texture(TextureError::LoadFailed(e.to_string()))
}
@@ -87,6 +89,9 @@ impl Game {
let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?;
// Generate items (pellets and energizers)
let items = map.generate_items(&atlas)?;
// Create ghosts at random positions
let mut ghosts = Vec::new();
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
@@ -94,6 +99,7 @@ impl Game {
if map.graph.node_count() == 0 {
return Err(GameError::Config("Game map has no nodes - invalid configuration".to_string()));
// TODO: This is a bug, we should handle this better
}
for &ghost_type in &ghost_types {
@@ -108,6 +114,7 @@ impl Game {
map,
pacman,
ghosts,
items,
debug_mode: false,
map_texture,
text_texture,
@@ -145,6 +152,9 @@ impl Game {
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas)?;
// Reset items
self.items = self.map.generate_items(&self.atlas)?;
// Randomize ghost positions
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng();
@@ -164,6 +174,26 @@ impl Game {
for ghost in &mut self.ghosts {
ghost.tick(dt, &self.map.graph);
}
// Check for item collisions
self.check_item_collisions();
}
fn check_item_collisions(&mut self) {
let pacman_node = self.pacman.current_node_id();
for item in &mut self.items {
if !item.is_collected() && item.node_index == pacman_node {
item.collect();
self.score += item.get_score();
// Handle energizer effects
if matches!(item.item_type, crate::entity::item::ItemType::Energizer) {
// TODO: Make ghosts frightened
tracing::info!("Energizer collected! Ghosts should become frightened.");
}
}
}
}
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
@@ -173,6 +203,13 @@ impl Game {
canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
// Render all items
for item in &self.items {
if let Err(e) = item.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render item: {}", e);
}
}
// Render all ghosts
for ghost in &self.ghosts {
if let Err(e) = ghost.render(canvas, &mut self.atlas, &self.map.graph) {

View File

@@ -1,11 +1,12 @@
//! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD};
use crate::entity::direction::Direction;
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::entity::item::{Item, ItemType};
use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use crate::texture::sprite::{AtlasTile, Sprite, SpriteAtlas};
use glam::{IVec2, UVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque};
@@ -117,7 +118,7 @@ impl Map {
// Connect the new node to the source node
graph
.connect(*source_node_id, new_node_id, false, None, dir)
.map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
}
}
}
@@ -132,7 +133,7 @@ impl Map {
if let Some(&neighbor_id) = grid_to_node.get(&neighbor) {
graph
.connect(node_id, neighbor_id, false, None, dir)
.map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
}
}
}
@@ -187,6 +188,44 @@ impl Map {
MapRenderer::render_map(canvas, atlas, map_texture);
}
/// Generates Item entities for pellets and energizers from the parsed map.
pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult<Vec<Item>> {
// Pre-load sprites to avoid repeated texture lookups
let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png")
.ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?;
let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png")
.ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?;
// Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers)
let mut items = Vec::with_capacity(250);
// Parse the raw board once
let parsed_map = MapTileParser::parse_board(RAW_BOARD)?;
let map = parsed_map.tiles;
// Iterate through the map and collect items more efficiently
for (x, row) in map.iter().enumerate() {
for (y, tile) in row.iter().enumerate() {
match tile {
MapTile::Pellet | MapTile::PowerPellet => {
let grid_pos = IVec2::new(x as i32, y as i32);
if let Some(&node_id) = self.grid_to_node.get(&grid_pos) {
let (item_type, sprite) = match tile {
MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)),
MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)),
_ => unreachable!(), // We already filtered for these types
};
items.push(Item::new(node_id, item_type, sprite));
}
}
_ => {}
}
}
}
Ok(items)
}
/// Renders a debug visualization with cursor-based highlighting.
///
/// This function provides interactive debugging by highlighting the nearest node
@@ -241,10 +280,10 @@ impl Map {
// Connect the house door to the left and right nodes
graph
.connect(node_id, *left_node, true, None, Direction::Left)
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to left node: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to left node: {e}")))?;
graph
.connect(node_id, *right_node, true, None, Direction::Right)
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to right node: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to right node: {e}")))?;
(node_id, node_position)
};
@@ -263,10 +302,10 @@ impl Map {
// Connect the center node to the top and bottom nodes
graph
.connect(center_node_id, top_node_id, false, None, Direction::Up)
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to top node: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to top node: {e}")))?;
graph
.connect(center_node_id, bottom_node_id, false, None, Direction::Down)
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to bottom node: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to bottom node: {e}")))?;
Ok((center_node_id, top_node_id))
};
@@ -289,7 +328,7 @@ impl Map {
Direction::Down,
EdgePermissions::GhostsOnly,
)
.map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only entrance to house: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only entrance to house: {e}")))?;
graph
.add_edge(
@@ -300,7 +339,7 @@ impl Map {
Direction::Up,
EdgePermissions::GhostsOnly,
)
.map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only exit from house: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only exit from house: {e}")))?;
// Create the left line
let (left_center_node_id, _) = create_house_line(
@@ -319,11 +358,11 @@ impl Map {
// Connect the center line to the left and right lines
graph
.connect(center_center_node_id, left_center_node_id, false, None, Direction::Left)
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to left top line: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to left top line: {e}")))?;
graph
.connect(center_center_node_id, right_center_node_id, false, None, Direction::Right)
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to right top line: {}", e)))?;
.map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to right top line: {e}")))?;
debug!("House entrance node id: {house_entrance_node_id}");

View File

@@ -6,6 +6,27 @@ use sdl2::render::{Canvas, RenderTarget, Texture};
use serde::Deserialize;
use std::collections::HashMap;
/// A simple sprite for stationary items like pellets and energizers.
#[derive(Clone, Debug)]
pub struct Sprite {
pub atlas_tile: AtlasTile,
}
impl Sprite {
pub fn new(atlas_tile: AtlasTile) -> Self {
Self { atlas_tile }
}
pub fn render<C: RenderTarget>(&self, canvas: &mut Canvas<C>, atlas: &mut SpriteAtlas, position: glam::Vec2) -> Result<()> {
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(position.x as i32, position.y as i32),
glam::UVec2::new(self.atlas_tile.size.x as u32, self.atlas_tile.size.y as u32),
);
let mut tile = self.atlas_tile;
tile.render(canvas, atlas, dest)
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct AtlasMapper {
pub frames: HashMap<String, MapperFrame>,