feat!: implement proper error handling, drop most expect() & unwrap() usages

This commit is contained in:
2025-08-11 20:23:39 -05:00
parent 5e9bb3535e
commit 27079e127d
20 changed files with 555 additions and 194 deletions

View File

@@ -16,6 +16,8 @@ use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
use crate::error::{EntityError, GameError, GameResult, TextureError};
/// Determines if a ghost can traverse a given edge.
///
/// Ghosts can move through edges that allow all entities or ghost-only edges.
@@ -101,7 +103,9 @@ impl Entity for Ghost {
self.choose_random_direction(graph);
}
self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse);
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) {
eprintln!("Ghost movement error: {}", e);
}
self.texture.tick(dt);
}
}
@@ -111,7 +115,7 @@ impl Ghost {
///
/// 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 {
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None];
@@ -123,27 +127,51 @@ impl Ghost {
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(),
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"b"
)))
})?,
];
let stopped_tiles =
vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.unwrap(),
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
];
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2).expect("Invalid frame duration"));
textures[direction.as_usize()] =
Some(AnimatedTexture::new(moving_tiles, 0.2).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
}
Self {
Ok(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(),
}
})
}
/// Chooses a random available direction at the current intersection.
@@ -179,9 +207,9 @@ impl Ghost {
/// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm.
///
/// Returns a vector of NodeIds representing the path, or None if no path exists.
/// Returns a vector of NodeIds representing the path, or an error if pathfinding fails.
/// The path includes the current node and the target node.
pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> Option<Vec<NodeId>> {
pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult<Vec<NodeId>> {
let start_node = self.traverser.position.from_node_id();
// Use Dijkstra's algorithm to find the shortest path
@@ -198,7 +226,12 @@ impl Ghost {
|&node_id| node_id == target,
);
result.map(|(path, _cost)| path)
result.map(|(path, _cost)| path).ok_or_else(|| {
GameError::Entity(EntityError::PathfindingFailed(format!(
"No path found from node {} to target {}",
start_node, target
)))
})
}
/// Returns the ghost's color for debug rendering.

View File

@@ -13,6 +13,8 @@ use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode;
use crate::error::{GameError, GameResult, TextureError};
/// Determines if Pac-Man can traverse a given edge.
///
/// Pac-Man can only move through edges that allow all entities.
@@ -57,7 +59,9 @@ impl Entity for Pacman {
}
fn tick(&mut self, dt: f32, graph: &Graph) {
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) {
eprintln!("Pac-Man movement error: {}", e);
}
self.texture.tick(dt);
}
}
@@ -67,7 +71,7 @@ impl Pacman {
///
/// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through open mouth, closed mouth, and full sprites.
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None];
@@ -79,22 +83,27 @@ impl Pacman {
Direction::Right => "pacman/right",
};
let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")).unwrap(),
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap(),
SpriteAtlas::get_tile(atlas, "pacman/full.png").unwrap(),
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?,
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
];
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?];
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"));
textures[direction.as_usize()] =
Some(AnimatedTexture::new(moving_tiles, 0.08).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
}
Self {
Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
}
})
}
/// Handles keyboard input to change Pac-Man's direction.

View File

@@ -10,6 +10,7 @@ use sdl2::render::{Canvas, RenderTarget};
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, Graph, NodeId};
use crate::entity::traversal::{Position, Traverser};
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
@@ -48,21 +49,24 @@ pub trait Entity {
///
/// Converts the graph position to screen coordinates, accounting for
/// the board offset and centering the sprite.
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self.traverser().position {
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
Position::AtNode(node_id) => {
let node = graph.get_node(node_id).ok_or(EntityError::NodeNotFound(node_id))?;
node.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)
let from_node = graph.get_node(from).ok_or(EntityError::NodeNotFound(from))?;
let to_node = graph.get_node(to).ok_or(EntityError::NodeNotFound(to))?;
let edge = graph.find_edge(from, to).ok_or(EntityError::EdgeNotFound { from, to })?;
from_node.position + (to_node.position - from_node.position) * (traversed / edge.distance)
}
};
Vec2::new(
Ok(Vec2::new(
pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32,
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
)
))
}
/// Returns the current node ID that the entity is at or moving towards.
@@ -88,8 +92,8 @@ pub trait Entity {
///
/// Draws the appropriate directional sprite based on the entity's
/// current movement state and direction.
fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
let pixel_pos = self.get_pixel_pos(graph);
fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> {
let pixel_pos = self.get_pixel_pos(graph)?;
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
glam::UVec2::new(16, 16),
@@ -98,11 +102,13 @@ pub trait Entity {
if self.traverser().position.is_stopped() {
self.texture()
.render_stopped(canvas, atlas, dest, self.traverser().direction)
.expect("Failed to render entity");
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
} else {
self.texture()
.render(canvas, atlas, dest, self.traverser().direction)
.expect("Failed to render entity");
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
}
Ok(())
}
}

View File

@@ -1,3 +1,7 @@
use tracing::error;
use crate::error::GameResult;
use super::direction::Direction;
use super::graph::{Edge, Graph, NodeId};
@@ -82,7 +86,9 @@ impl Traverser {
};
// This will kickstart the traverser into motion
traverser.advance(graph, 0.0, can_traverse);
if let Err(e) = traverser.advance(graph, 0.0, can_traverse) {
error!("Traverser initialization error: {}", e);
}
traverser
}
@@ -108,7 +114,9 @@ impl Traverser {
/// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node.
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F)
///
/// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction).
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()>
where
F: Fn(Edge) -> bool,
{
@@ -134,7 +142,18 @@ impl Traverser {
traversed: distance.max(0.0),
};
self.direction = next_direction;
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!(
"Cannot traverse edge from {} to {} in direction {:?}",
node_id, edge.target, next_direction
),
)));
}
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!("No edge found in direction {:?} from node {}", next_direction, node_id),
)));
}
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
@@ -143,12 +162,15 @@ impl Traverser {
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return;
return Ok(());
}
let edge = graph
.find_edge(from, to)
.expect("Inconsistent state: Traverser is on a non-existent edge.");
let edge = graph.find_edge(from, to).ok_or_else(|| {
crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!(
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
)))
})?;
let new_traversed = traversed + distance;
@@ -201,5 +223,7 @@ impl Traverser {
}
}
}
Ok(())
}
}