Compare commits

...

5 Commits

8 changed files with 278 additions and 43 deletions

View File

@@ -42,15 +42,39 @@ jobs:
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: taiki-e/install-action@nextest
- uses: taiki-e/install-action@just
# Note: We manually link zlib. This should be synchronized with the flags set for Linux in .cargo/config.toml.
- 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

18
Justfile Normal file
View File

@@ -0,0 +1,18 @@
set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
coverage_exclude_pattern := "app.rs|audio.rs|error.rs"
# Display report (for humans)
report-coverage: coverage
cargo llvm-cov report \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
# Run & generate report (for CI)
coverage:
cargo llvm-cov \
--lcov \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--output-path lcov.info \
--profile coverage \
--no-fail-fast nextest

View File

@@ -36,7 +36,7 @@ analyzer = "nextest"
[jobs.coverage]
command = [
"cargo", "llvm-cov", "--profile", "coverage", "--color", "always", "--no-fail-fast", "nextest", "--no-capture", "--summary-only", "--"
"just", "report-coverage"
]
need_stdout = true
ignored_lines = [
@@ -49,8 +49,14 @@ ignored_lines = [
"[─]+",
"test.+ok",
"PASS|START",
"Starting \\d+ test"
"Starting \\d+ test",
"\\s*#",
"\\s*Finished.+in \\d+",
"\\s*Summary\\s+\\[",
"\\s*Blocking",
"Finished report saved to"
]
on_change_strategy = "wait_then_restart"
[jobs.doc]
command = ["cargo", "doc", "--no-deps"]

View File

@@ -3,8 +3,9 @@
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
use std::borrow::Cow;
use strum_macros::EnumIter;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
pub enum Asset {
Wav1,
Wav2,

View File

@@ -49,37 +49,13 @@ 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},
};
/// A text texture that renders characters from the atlas.
pub struct TextTexture {
char_map: HashMap<char, AtlasTile>,
scale: f32,
}
impl TextTexture {
/// Creates a new text texture with the given atlas and scale.
pub fn new(scale: f32) -> Self {
Self {
char_map: HashMap::new(),
scale,
}
}
/// 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)
}
/// Converts a character to its tile name in the atlas.
fn char_to_tile_name(&self, c: char) -> Option<String> {
/// 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"),
@@ -96,6 +72,51 @@ impl TextTexture {
};
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 scale.
pub fn new(scale: f32) -> Self {
Self {
scale,
..Default::default()
}
}
pub fn get_char_map(&self) -> &HashMap<char, AtlasTile> {
&self.char_map
}
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));
}
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;
}
}

14
tests/asset.rs Normal file
View File

@@ -0,0 +1,14 @@
use pacman::asset::Asset;
use std::path::Path;
use strum::IntoEnumIterator;
#[test]
fn test_asset_paths_valid() {
let base_path = Path::new("assets/game/");
for asset in Asset::iter() {
let path = base_path.join(asset.path());
assert!(path.exists(), "Asset path does not exist: {:?}", path);
assert!(path.is_file(), "Asset path is not a file: {:?}", path);
}
}

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)
}

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(())
}