Compare commits

...

7 Commits

99 changed files with 1421 additions and 1084 deletions

View File

@@ -42,14 +42,39 @@ jobs:
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: taiki-e/install-action@nextest
- uses: taiki-e/install-action@just
- name: Generate coverage report
run: |
cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest
just coverage
- name: Download Coveralls CLI
run: |
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
with:
files: ./lcov.info
format: lcov
allow-empty: false
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
run: |
if [ ! -f "lcov.info" ]; then
echo "Error: lcov.info file not found. Coverage generation may have failed."
exit 1
fi
for i in {1..10}; do
echo "Attempt $i: Uploading coverage to Coveralls..."
if coveralls -n report lcov.info; then
echo "Successfully uploaded coverage report."
exit 0
fi
if [ $i -lt 10 ]; then
delay=$((2**i))
echo "Attempt $i failed. Retrying in $delay seconds..."
sleep $delay
fi
done
echo "Failed to upload coverage report after 10 attempts."
exit 1

15
.gitignore vendored
View File

@@ -1,8 +1,17 @@
# IDE, Other files
.vscode
.idea
rust-sdl2-emscripten/
# Build files
target/
dist/
emsdk/
.idea
rust-sdl2-emscripten/
assets/site/build.css
# Site build f iles
tailwindcss-*
assets/site/build.css
# Coverage reports
lcov.info
coverage.html

View File

@@ -1,16 +1,33 @@
set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
coverage_exclude_pattern := "app.rs|audio.rs"
# Regex to exclude files from coverage report, double escapes for Justfile + CLI
# You can use src\\\\..., but the filename alone is acceptable too
coverage_exclude_pattern := "src\\\\app.rs|audio.rs|src\\\\error.rs|platform\\\\emscripten.rs"
# !!! --ignore-filename-regex should be used on both reports & coverage testing
# !!! --remap-path-prefix prevents the absolute path from being used in the generated report
# Generate HTML report (for humans, source line inspection)
html: coverage
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--html \
--open
# Display report (for humans)
report-coverage: coverage
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
# Run & generate report (for CI)
coverage:
# Run & generate report
cargo llvm-cov \
--lcov \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--output-path lcov.info \
--profile coverage \
--no-fail-fast nextest
# Display report
cargo llvm-cov report \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -36,7 +36,7 @@ analyzer = "nextest"
[jobs.coverage]
command = [
"just", "coverage"
"just", "report-coverage"
]
need_stdout = true
ignored_lines = [

View File

@@ -18,8 +18,6 @@ pub const SCALE: f32 = 2.6;
pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3);
/// The offset of the game board from the top-left corner of the window, in pixels.
pub const BOARD_PIXEL_OFFSET: UVec2 = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
/// The size of the game board, in pixels.
pub const BOARD_PIXEL_SIZE: UVec2 = UVec2::new(BOARD_CELL_SIZE.x * CELL_SIZE, BOARD_CELL_SIZE.y * CELL_SIZE);
/// The size of the canvas, in pixels.
pub const CANVAS_SIZE: UVec2 = UVec2::new(
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,

View File

@@ -1,7 +1,7 @@
use smallvec::SmallVec;
use std::collections::HashMap;
use crate::entity::traversal::Position;
use crate::entity::{graph::NodeId, traversal::Position};
/// Trait for entities that can participate in collision detection.
pub trait Collidable {
@@ -19,7 +19,7 @@ pub trait Collidable {
#[derive(Default)]
pub struct CollisionSystem {
/// Maps node IDs to lists of entity IDs that are at that node
node_entities: HashMap<usize, Vec<EntityId>>,
node_entities: HashMap<NodeId, Vec<EntityId>>,
/// Maps entity IDs to their current positions
entity_positions: HashMap<EntityId, Position>,
/// Next available entity ID
@@ -62,7 +62,7 @@ impl CollisionSystem {
}
/// Gets all entity IDs at a specific node
pub fn entities_at_node(&self, node: usize) -> &[EntityId] {
pub fn entities_at_node(&self, node: NodeId) -> &[EntityId] {
self.node_entities.get(&node).map(|v| v.as_slice()).unwrap_or(&[])
}
@@ -115,7 +115,7 @@ fn positions_overlap(a: &Position, b: &Position) -> bool {
}
/// Gets all nodes that an entity is currently at or between.
fn get_nodes(pos: &Position) -> SmallVec<[usize; 2]> {
fn get_nodes(pos: &Position) -> SmallVec<[NodeId; 2]> {
let mut nodes = SmallVec::new();
match pos {
Position::AtNode(node) => nodes.push(*node),

View File

@@ -192,14 +192,15 @@ impl Graph {
// Check if the edge already exists in this direction or to the same target
if let Some(err) = adjacency_list.edges().find_map(|e| {
// If we're not replacing the edge, we don't want to replace an edge that already exists in this direction
if !replace && e.direction == direction {
Some(Err("Edge already exists in this direction."))
} else if e.target == to {
Some(Err("Edge already exists."))
} else {
None
if !replace {
// If we're not replacing the edge, we don't want to replace an edge that already exists in this direction
if e.direction == direction {
return Some(Err("Edge already exists in this direction."));
} else if e.target == to {
return Some(Err("Edge already exists."));
}
}
None
}) {
return err;
}

View File

@@ -174,7 +174,7 @@ impl Game {
canvas.clear();
self.state
.map
.render(canvas, &mut self.state.atlas, &mut self.state.map_texture);
.render(canvas, &mut self.state.atlas, &mut self.state.map_tiles);
// Render all items
for item in &self.state.items {

View File

@@ -1,4 +1,4 @@
use sdl2::{image::LoadTexture, pixels::Color, render::TextureCreator, video::WindowContext};
use sdl2::{image::LoadTexture, render::TextureCreator, video::WindowContext};
use smallvec::SmallVec;
use crate::{
@@ -6,12 +6,13 @@ use crate::{
audio::Audio,
constants::RAW_BOARD,
entity::{
collision::{Collidable, CollisionSystem, EntityId},
collision::{Collidable, CollisionSystem},
ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman,
},
error::{GameError, GameResult, TextureError},
game::EntityId,
map::Map,
texture::{
sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
@@ -28,20 +29,20 @@ use crate::{
pub struct GameState {
pub score: u32,
pub map: Map,
pub map_tiles: Vec<AtlasTile>,
pub pacman: Pacman,
pub pacman_id: EntityId,
pub ghosts: SmallVec<[Ghost; 4]>,
pub ghost_ids: SmallVec<[EntityId; 4]>,
pub items: Vec<Item>,
pub item_ids: Vec<EntityId>,
pub debug_mode: bool,
// Collision system
pub(crate) collision_system: CollisionSystem,
pub(crate) pacman_id: EntityId,
pub(crate) ghost_ids: SmallVec<[EntityId; 4]>,
pub(crate) item_ids: Vec<EntityId>,
// Rendering resources
pub(crate) atlas: SpriteAtlas,
pub(crate) map_texture: AtlasTile,
pub(crate) text_texture: TextTexture,
// Audio
@@ -71,9 +72,13 @@ impl GameState {
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json)?;
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/full.png".to_string())))?;
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let mut map_tiles = Vec::with_capacity(35);
for i in 0..35 {
let tile_name = format!("maze/tiles/{}.png", i);
let tile = SpriteAtlas::get_tile(&atlas, &tile_name)
.ok_or(GameError::Texture(TextureError::AtlasTileNotFound(tile_name)))?;
map_tiles.push(tile);
}
let text_texture = TextTexture::new(1.0);
let audio = Audio::new();
@@ -89,11 +94,10 @@ impl GameState {
let pacman_id = collision_system.register_entity(pacman.position());
// Register items
let mut item_ids = Vec::new();
for item in &items {
let item_id = collision_system.register_entity(item.position());
item_ids.push(item_id);
}
let item_ids = items
.iter()
.map(|item| collision_system.register_entity(item.position()))
.collect();
// Create and register ghosts
let ghosts = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]
@@ -110,26 +114,27 @@ impl GameState {
.map(|(ghost_type, start_node)| Ghost::new(&map.graph, *start_node, *ghost_type, &atlas))
.collect::<GameResult<SmallVec<[_; 4]>>>()?;
// Register ghosts
let ghost_ids = ghosts
.iter()
.map(|ghost| collision_system.register_entity(ghost.position()))
.collect::<SmallVec<[_; 4]>>();
.collect();
Ok(Self {
score: 0,
map,
atlas,
map_tiles,
pacman,
ghosts,
items,
debug_mode: false,
collision_system,
pacman_id,
ghosts,
ghost_ids,
items,
item_ids,
map_texture,
text_texture,
audio,
atlas,
score: 0,
debug_mode: false,
collision_system,
})
}
}

View File

@@ -158,8 +158,8 @@ impl Map {
///
/// This function draws the static map texture to the screen at the correct
/// position and scale.
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
MapRenderer::render_map(canvas, atlas, map_texture);
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_tiles: &mut [AtlasTile]) {
MapRenderer::render_map(canvas, atlas, map_tiles);
}
/// Generates Item entities for pellets and energizers from the parsed map.

95
src/map/layout.rs Normal file
View File

@@ -0,0 +1,95 @@
pub const TILE_MAP: [[usize; 28]; 31] = [
[
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4,
],
[
5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9,
],
[
5, 6, 10, 11, 11, 12, 6, 10, 11, 11, 11, 12, 6, 7, 8, 6, 10, 11, 11, 11, 12, 6, 10, 11, 11, 12, 6, 9,
],
[
5, 6, 7, 6, 6, 8, 6, 7, 6, 6, 6, 8, 6, 7, 8, 6, 7, 6, 6, 6, 8, 6, 7, 6, 6, 8, 6, 9,
],
[
5, 6, 13, 14, 14, 15, 6, 13, 14, 14, 14, 15, 6, 13, 15, 6, 13, 14, 14, 14, 15, 6, 13, 14, 14, 15, 6, 9,
],
[
5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9,
],
[
5, 6, 10, 11, 11, 12, 6, 10, 12, 6, 10, 11, 11, 11, 11, 11, 11, 12, 6, 10, 12, 6, 10, 11, 11, 12, 6, 9,
],
[
5, 6, 13, 14, 14, 15, 6, 7, 8, 6, 13, 14, 14, 16, 17, 14, 14, 15, 6, 7, 8, 6, 13, 14, 14, 15, 6, 9,
],
[
5, 6, 6, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 6, 6, 9,
],
[
18, 19, 19, 19, 19, 12, 6, 7, 20, 11, 11, 12, 6, 7, 8, 6, 10, 11, 11, 21, 8, 6, 10, 19, 19, 19, 19, 22,
],
[
6, 6, 6, 6, 6, 5, 6, 7, 17, 14, 14, 15, 6, 13, 15, 6, 13, 14, 14, 16, 8, 6, 9, 6, 6, 6, 6, 6,
],
[
6, 6, 6, 6, 6, 5, 6, 7, 8, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 8, 6, 9, 6, 6, 6, 6, 6,
],
[
6, 6, 6, 6, 6, 5, 6, 7, 8, 6, 23, 19, 24, 25, 25, 26, 19, 27, 6, 7, 8, 6, 9, 6, 6, 6, 6, 6,
],
[
1, 1, 1, 1, 1, 15, 6, 13, 15, 6, 9, 6, 6, 6, 6, 6, 6, 5, 6, 13, 15, 6, 13, 1, 1, 1, 1, 1,
],
[
6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 6, 6, 6, 6, 6, 6, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
],
[
19, 19, 19, 19, 19, 12, 6, 10, 12, 6, 9, 6, 6, 6, 6, 6, 6, 5, 6, 10, 12, 6, 10, 19, 19, 19, 19, 19,
],
[
6, 6, 6, 6, 6, 5, 6, 7, 8, 6, 28, 1, 1, 1, 1, 1, 1, 29, 6, 7, 8, 6, 9, 6, 6, 6, 6, 6,
],
[
6, 6, 6, 6, 6, 5, 6, 7, 8, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 8, 6, 9, 6, 6, 6, 6, 6,
],
[
6, 6, 6, 6, 6, 5, 6, 7, 8, 6, 10, 11, 11, 11, 11, 11, 11, 12, 6, 7, 8, 6, 9, 6, 6, 6, 6, 6,
],
[
0, 1, 1, 1, 1, 15, 6, 13, 15, 6, 13, 14, 14, 16, 17, 14, 14, 15, 6, 13, 15, 6, 13, 1, 1, 1, 1, 4,
],
[
5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9,
],
[
5, 6, 10, 11, 11, 12, 6, 10, 11, 11, 11, 12, 6, 7, 8, 6, 10, 11, 11, 11, 12, 6, 30, 11, 11, 12, 6, 9,
],
[
5, 6, 13, 14, 16, 8, 6, 13, 14, 14, 14, 15, 6, 13, 15, 6, 13, 14, 14, 14, 15, 6, 7, 17, 14, 15, 6, 9,
],
[
5, 6, 6, 6, 7, 8, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 8, 6, 6, 6, 9,
],
[
31, 11, 12, 6, 7, 8, 6, 10, 12, 6, 10, 11, 11, 11, 11, 11, 11, 12, 6, 10, 12, 6, 7, 8, 6, 10, 11, 32,
],
[
33, 14, 15, 6, 13, 15, 6, 7, 8, 6, 13, 14, 14, 16, 17, 14, 14, 15, 6, 7, 8, 6, 13, 15, 6, 13, 14, 34,
],
[
5, 6, 6, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 7, 8, 6, 6, 6, 6, 6, 6, 9,
],
[
5, 6, 10, 11, 11, 11, 11, 21, 20, 11, 11, 12, 6, 7, 8, 6, 10, 11, 11, 21, 20, 11, 11, 11, 11, 12, 6, 9,
],
[
5, 6, 13, 14, 14, 14, 14, 14, 14, 14, 14, 15, 6, 13, 15, 6, 13, 14, 14, 14, 14, 14, 14, 14, 14, 15, 6, 9,
],
[
5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9,
],
[
18, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 22,
],
];

View File

@@ -1,6 +1,7 @@
//! This module defines the game map and provides functions for interacting with it.
pub mod builder;
pub mod layout;
pub mod parser;
pub mod render;

View File

@@ -1,5 +1,7 @@
//! Map rendering functionality.
use crate::constants::{BOARD_CELL_OFFSET, CELL_SIZE};
use crate::map::layout::TILE_MAP;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use crate::texture::text::TextTexture;
use glam::Vec2;
@@ -17,15 +19,22 @@ impl MapRenderer {
///
/// This function draws the static map texture to the screen at the correct
/// position and scale.
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
let dest = Rect::new(
crate::constants::BOARD_PIXEL_OFFSET.x as i32,
crate::constants::BOARD_PIXEL_OFFSET.y as i32,
crate::constants::BOARD_PIXEL_SIZE.x,
crate::constants::BOARD_PIXEL_SIZE.y,
);
if let Err(e) = map_texture.render(canvas, atlas, dest) {
tracing::error!("Failed to render map: {}", e);
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_tiles: &mut [AtlasTile]) {
for (y, row) in TILE_MAP.iter().enumerate() {
for (x, &tile_index) in row.iter().enumerate() {
let mut tile = map_tiles[tile_index];
tile.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let dest = Rect::new(
(BOARD_CELL_OFFSET.x as usize * CELL_SIZE as usize + x * CELL_SIZE as usize) as i32,
(BOARD_CELL_OFFSET.y as usize * CELL_SIZE as usize + y * CELL_SIZE as usize) as i32,
CELL_SIZE,
CELL_SIZE,
);
if let Err(e) = tile.render(canvas, atlas, dest) {
tracing::error!("Failed to render map tile: {}", e);
}
}
}
}

View File

@@ -49,53 +49,74 @@ use glam::UVec2;
use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use crate::{
error::{GameError, TextureError},
texture::sprite::{AtlasTile, SpriteAtlas},
};
/// Converts a character to its tile name in the atlas.
fn char_to_tile_name(c: char) -> Option<String> {
let name = match c {
// Letters A-Z
'A'..='Z' | '0'..='9' => format!("text/{c}.png"),
// Special characters
'!' => "text/!.png".to_string(),
'-' => "text/-.png".to_string(),
'"' => "text/_double_quote.png".to_string(),
'/' => "text/_forward_slash.png".to_string(),
// Skip spaces for now - they don't have a tile
' ' => return None,
// Unsupported character
_ => return None,
};
Some(name)
}
/// A text texture that renders characters from the atlas.
#[derive(Debug)]
pub struct TextTexture {
char_map: HashMap<char, AtlasTile>,
scale: f32,
}
impl Default for TextTexture {
fn default() -> Self {
Self {
scale: 1.0,
char_map: Default::default(),
}
}
}
impl TextTexture {
/// Creates a new text texture with the given atlas and scale.
/// Creates a new text texture with the given scale.
pub fn new(scale: f32) -> Self {
Self {
char_map: HashMap::new(),
scale,
..Default::default()
}
}
/// Maps a character to its atlas tile, handling special characters.
fn get_char_tile(&mut self, atlas: &SpriteAtlas, c: char) -> Option<AtlasTile> {
if let Some(tile) = self.char_map.get(&c) {
return Some(*tile);
}
let tile_name = self.char_to_tile_name(c)?;
let tile = atlas.get_tile(&tile_name)?;
self.char_map.insert(c, tile);
Some(tile)
pub fn get_char_map(&self) -> &HashMap<char, AtlasTile> {
&self.char_map
}
/// Converts a character to its tile name in the atlas.
fn char_to_tile_name(&self, c: char) -> Option<String> {
let name = match c {
// Letters A-Z
'A'..='Z' | '0'..='9' => format!("text/{c}.png"),
// Special characters
'!' => "text/!.png".to_string(),
'-' => "text/-.png".to_string(),
'"' => "text/_double_quote.png".to_string(),
'/' => "text/_forward_slash.png".to_string(),
// Skip spaces for now - they don't have a tile
' ' => return None,
pub fn get_tile(&mut self, c: char, atlas: &mut SpriteAtlas) -> Result<Option<&mut AtlasTile>> {
if self.char_map.contains_key(&c) {
return Ok(self.char_map.get_mut(&c));
}
// Unsupported character
_ => return None,
};
Some(name)
if let Some(tile_name) = char_to_tile_name(c) {
let tile = atlas
.get_tile(&tile_name)
.ok_or(GameError::Texture(TextureError::AtlasTileNotFound(tile_name)))?;
self.char_map.insert(c, tile);
Ok(self.char_map.get_mut(&c))
} else {
Ok(None)
}
}
/// Renders a string of text at the given position.
@@ -108,13 +129,16 @@ impl TextTexture {
) -> Result<()> {
let mut x_offset = 0;
let char_width = (8.0 * self.scale) as u32;
let char_height = (8.0 * self.scale) as u32;
let char_height = self.text_height();
for c in text.chars() {
if let Some(mut tile) = self.get_char_tile(atlas, c) {
// Get the tile from the char_map, or insert it if it doesn't exist
if let Some(tile) = self.get_tile(c, atlas)? {
// Render the tile if it exists
let dest = sdl2::rect::Rect::new((position.x + x_offset) as i32, position.y as i32, char_width, char_height);
tile.render(canvas, atlas, dest)?;
}
// Always advance x_offset for all characters (including spaces)
x_offset += char_width;
}
@@ -138,7 +162,7 @@ impl TextTexture {
let mut width = 0;
for c in text.chars() {
if self.char_to_tile_name(c).is_some() {
if char_to_tile_name(c).is_some() || c == ' ' {
width += char_width;
}
}

39
tests/common/mod.rs Normal file
View File

@@ -0,0 +1,39 @@
#![allow(dead_code)]
use pacman::{
asset::{get_asset_bytes, Asset},
texture::sprite::SpriteAtlas,
};
use sdl2::{
image::LoadTexture,
render::{Canvas, Texture, TextureCreator},
video::{Window, WindowContext},
Sdl,
};
pub fn setup_sdl() -> Result<(Canvas<Window>, TextureCreator<WindowContext>, Sdl), String> {
let sdl_context = sdl2::init()?;
let video_subsystem = sdl_context.video()?;
let window = video_subsystem
.window("test", 800, 600)
.position_centered()
.hidden()
.build()
.map_err(|e| e.to_string())?;
let canvas = window.into_canvas().build().map_err(|e| e.to_string())?;
let texture_creator = canvas.texture_creator();
Ok((canvas, texture_creator, sdl_context))
}
pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas {
let texture_creator = canvas.texture_creator();
let atlas_bytes = get_asset_bytes(Asset::Atlas).unwrap();
let atlas_json = get_asset_bytes(Asset::AtlasJson).unwrap();
let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap();
let texture: Texture<'static> = unsafe { std::mem::transmute(texture) };
let mapper: pacman::texture::sprite::AtlasMapper = serde_json::from_slice(&atlas_json).unwrap();
SpriteAtlas::new(texture, mapper)
}

View File

@@ -86,6 +86,71 @@ fn test_graph_edge_permissions() {
assert_eq!(edge.permissions, EdgePermissions::GhostsOnly);
}
#[test]
fn should_add_connected_node() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph
.add_connected(
node1,
Direction::Right,
Node {
position: glam::Vec2::new(16.0, 0.0),
},
)
.unwrap();
assert_eq!(graph.node_count(), 2);
let edge = graph.find_edge(node1, node2);
assert!(edge.is_some());
assert_eq!(edge.unwrap().direction, Direction::Right);
}
#[test]
fn should_error_on_negative_edge_distance() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
let result = graph.add_edge(node1, node2, false, Some(-1.0), Direction::Right, EdgePermissions::All);
assert!(result.is_err());
}
#[test]
fn should_error_on_duplicate_edge_without_replace() {
let mut graph = create_test_graph();
let result = graph.add_edge(0, 1, false, None, Direction::Right, EdgePermissions::All);
assert!(result.is_err());
}
#[test]
fn should_allow_replacing_an_edge() {
let mut graph = create_test_graph();
let result = graph.add_edge(0, 1, true, Some(42.0), Direction::Right, EdgePermissions::All);
assert!(result.is_ok());
let edge = graph.find_edge(0, 1).unwrap();
assert_eq!(edge.distance, 42.0);
}
#[test]
fn should_find_edge_between_nodes() {
let graph = create_test_graph();
let edge = graph.find_edge(0, 1);
assert!(edge.is_some());
assert_eq!(edge.unwrap().target, 1);
let non_existent_edge = graph.find_edge(0, 99);
assert!(non_existent_edge.is_none());
}
#[test]
fn test_traverser_basic() {
let graph = create_test_graph();

109
tests/text.rs Normal file
View File

@@ -0,0 +1,109 @@
use pacman::texture::{sprite::SpriteAtlas, text::TextTexture};
use crate::common::create_atlas;
mod common;
/// Helper function to get all characters that should be in the atlas
fn get_all_chars() -> String {
let mut chars = Vec::new();
chars.extend('A'..='Z');
chars.extend('0'..='9');
chars.extend(['!', '-', '"', '/']);
chars.into_iter().collect()
}
/// Helper function to check if a character is in the atlas and char_map
fn check_char(text_texture: &mut TextTexture, atlas: &mut SpriteAtlas, c: char) {
// Check that the character is not in the char_map yet
assert!(
!text_texture.get_char_map().contains_key(&c),
"Character {c} should not yet be in char_map"
);
// Get the tile from the atlas, which caches the tile in the char_map
let tile = text_texture.get_tile(c, atlas);
assert!(tile.is_ok(), "Failed to get tile for character {c}");
assert!(tile.unwrap().is_some(), "Tile for character {c} not found in atlas");
// Check that the tile is now cached in the char_map
assert!(
text_texture.get_char_map().contains_key(&c),
"Tile for character {c} was not cached in char_map"
);
}
#[test]
fn test_chars() -> Result<(), String> {
let (mut canvas, ..) = common::setup_sdl().map_err(|e| e.to_string())?;
let mut atlas = create_atlas(&mut canvas);
let mut text_texture = TextTexture::default();
get_all_chars()
.chars()
.for_each(|c| check_char(&mut text_texture, &mut atlas, c));
Ok(())
}
#[test]
fn test_render() -> Result<(), String> {
let (mut canvas, ..) = common::setup_sdl().map_err(|e| e.to_string())?;
let mut atlas = create_atlas(&mut canvas);
let mut text_texture = TextTexture::default();
let test_strings = vec!["Hello, world!".to_string(), get_all_chars()];
for string in test_strings {
if let Err(e) = text_texture.render(&mut canvas, &mut atlas, &string, glam::UVec2::new(0, 0)) {
return Err(e.to_string());
}
}
Ok(())
}
#[test]
fn test_text_width() -> Result<(), String> {
let text_texture = TextTexture::default();
let test_strings = vec!["Hello, world!".to_string(), get_all_chars()];
for string in test_strings {
let width = text_texture.text_width(&string);
let height = text_texture.text_height();
assert!(width > 0, "Width for string {string} should be greater than 0");
assert!(height > 0, "Height for string {string} should be greater than 0");
}
Ok(())
}
#[test]
fn test_text_scale() -> Result<(), String> {
let string = "ABCDEFG !-/\"";
let base_width = (string.len() * 8) as u32;
let mut text_texture = TextTexture::new(0.5);
assert_eq!(text_texture.scale(), 0.5);
assert_eq!(text_texture.text_height(), 4);
assert_eq!(text_texture.text_width(""), 0);
assert_eq!(text_texture.text_width(string), base_width / 2);
text_texture.set_scale(2.0);
assert_eq!(text_texture.scale(), 2.0);
assert_eq!(text_texture.text_height(), 16);
assert_eq!(text_texture.text_width(string), base_width * 2);
assert_eq!(text_texture.text_width(""), 0);
text_texture.set_scale(1.0);
assert_eq!(text_texture.scale(), 1.0);
assert_eq!(text_texture.text_height(), 8);
assert_eq!(text_texture.text_width(string), base_width);
assert_eq!(text_texture.text_width(""), 0);
Ok(())
}