Compare commits

...

13 Commits

22 changed files with 1411 additions and 58 deletions

View File

@@ -5,6 +5,7 @@ rustflags = [
"-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_MIXER=2 -sUSE_OGG=1 -sUSE_SDL_GFX=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=['png']",
"-C", "link-args=--preload-file assets/game/",
]
runner = "node"
[target.'cfg(target_os = "linux")']
rustflags = [
@@ -13,4 +14,4 @@ rustflags = [
# By adding `-lz` here, we ensure it's passed to the linker after `libpng`,
# which is required for the linker to correctly resolve symbols.
"-C", "link-arg=-lz",
]
]

2
.config/nextest.toml Normal file
View File

@@ -0,0 +1,2 @@
[profile.default]
fail-fast = false

View File

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

View File

@@ -5,9 +5,6 @@ on: ["push", "pull_request"]
permissions:
contents: write
env:
RUST_TOOLCHAIN: 1.86.0
jobs:
build:
name: Build (${{ matrix.target }})
@@ -18,15 +15,19 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: pacman
toolchain: 1.88.0
- os: macos-13
target: x86_64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
- os: windows-latest
target: x86_64-pc-windows-gnu
artifact_name: pacman.exe
toolchain: 1.88.0
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
@@ -36,7 +37,7 @@ jobs:
uses: dtolnay/rust-toolchain@master
with:
target: ${{ matrix.target }}
toolchain: ${{ env.RUST_TOOLCHAIN }}
toolchain: ${{ matrix.toolchain }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -92,13 +93,13 @@ jobs:
uses: pyodide/setup-emsdk@v15
with:
version: 3.1.43
actions-cache-folder: "emsdk-cache"
actions-cache-folder: "emsdk-cache-b"
- name: Setup Rust (WASM32 Emscripten)
uses: dtolnay/rust-toolchain@master
with:
target: wasm32-unknown-emscripten
toolchain: ${{ env.RUST_TOOLCHAIN }}
toolchain: 1.86.0 # we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues
- name: Rust Cache
uses: Swatinem/rust-cache@v2

View File

@@ -43,11 +43,17 @@ jobs:
- name: Install cargo-tarpaulin
run: cargo install cargo-tarpaulin
# 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 tarpaulin --out Html --output-dir coverage
run: |
cargo tarpaulin \
--out Lcov \
--output-dir coverage \
--rustflags="-C link-arg=-lz"
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
with:
files: ./coverage/tarpaulin-report.html
files: ./coverage/lcov.info
format: lcov
allow-empty: false

View File

@@ -4,7 +4,7 @@ on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.86.0
RUST_TOOLCHAIN: 1.88.0
jobs:
test:
@@ -19,6 +19,7 @@ jobs:
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: clippy, rustfmt
- name: Rust Cache
uses: Swatinem/rust-cache@v2

View File

@@ -1,3 +1,4 @@
#![allow(dead_code)]
//! Cross-platform asset loading abstraction.
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
@@ -5,7 +6,6 @@ use std::borrow::Cow;
use std::io;
use thiserror::Error;
#[allow(dead_code)]
#[derive(Error, Debug)]
pub enum AssetError {
#[error("IO error: {0}")]

View File

@@ -11,6 +11,7 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
///
/// This struct is responsible for initializing the audio device, loading sounds,
/// and playing them.
#[allow(dead_code)]
pub struct Audio {
_mixer_context: mixer::Sdl2MixerContext,
sounds: Vec<Chunk>,
@@ -18,6 +19,12 @@ pub struct Audio {
muted: bool,
}
impl Default for Audio {
fn default() -> Self {
Self::new()
}
}
impl Audio {
/// Creates a new `Audio` instance.
pub fn new() -> Self {
@@ -57,6 +64,7 @@ impl Audio {
}
/// Plays the "eat" sound effect.
#[allow(dead_code)]
pub fn eat(&mut self) {
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
match mixer::Channel(0).play(chunk, 0) {
@@ -86,3 +94,115 @@ impl Audio {
self.muted
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Once;
static INIT: Once = Once::new();
fn init_sdl() -> Result<(), String> {
INIT.call_once(|| {
if let Err(e) = sdl2::init() {
eprintln!("Failed to initialize SDL2: {}", e);
}
});
Ok(())
}
#[test]
fn test_sound_assets_array() {
assert_eq!(SOUND_ASSETS.len(), 4);
assert_eq!(SOUND_ASSETS[0], Asset::Wav1);
assert_eq!(SOUND_ASSETS[1], Asset::Wav2);
assert_eq!(SOUND_ASSETS[2], Asset::Wav3);
assert_eq!(SOUND_ASSETS[3], Asset::Wav4);
}
#[test]
fn test_audio_asset_paths() {
// Test that all sound assets have valid paths
for asset in SOUND_ASSETS.iter() {
let path = asset.path();
assert!(!path.is_empty());
assert!(path.contains("sound/waka/"));
assert!(path.ends_with(".ogg"));
}
}
// Only run SDL2-dependent tests if SDL2 initialization succeeds
#[test]
fn test_audio_basic_functionality() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
// Test basic audio creation
let audio = Audio::new();
assert_eq!(audio.is_muted(), false);
assert_eq!(audio.next_sound_index, 0);
assert_eq!(audio.sounds.len(), 4);
}
#[test]
fn test_audio_mute_functionality() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let mut audio = Audio::new();
// Test mute/unmute
assert_eq!(audio.is_muted(), false);
audio.set_mute(true);
assert_eq!(audio.is_muted(), true);
audio.set_mute(false);
assert_eq!(audio.is_muted(), false);
}
#[test]
fn test_audio_sound_rotation() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let mut audio = Audio::new();
let initial_index = audio.next_sound_index;
// Test sound rotation
for i in 0..4 {
audio.eat();
assert_eq!(audio.next_sound_index, (initial_index + i + 1) % 4);
}
assert_eq!(audio.next_sound_index, initial_index);
}
#[test]
fn test_audio_sound_index_bounds() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let audio = Audio::new();
assert!(audio.next_sound_index < audio.sounds.len());
}
#[test]
fn test_audio_default_impl() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let audio = Audio::default();
assert_eq!(audio.is_muted(), false);
assert_eq!(audio.next_sound_index, 0);
assert_eq!(audio.sounds.len(), 4);
}
}

View File

@@ -77,3 +77,197 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
"#..........................#",
"############################",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_loop_time() {
// 60 FPS = 16.67ms per frame
let expected_nanos = (1_000_000_000.0 / 60.0) as u64;
assert_eq!(LOOP_TIME.as_nanos() as u64, expected_nanos);
}
#[test]
fn test_cell_size() {
assert_eq!(CELL_SIZE, 8);
}
#[test]
fn test_board_cell_size() {
assert_eq!(BOARD_CELL_SIZE.x, 28);
assert_eq!(BOARD_CELL_SIZE.y, 31);
}
#[test]
fn test_scale() {
assert_eq!(SCALE, 2.6);
}
#[test]
fn test_board_cell_offset() {
assert_eq!(BOARD_CELL_OFFSET.x, 0);
assert_eq!(BOARD_CELL_OFFSET.y, 3);
}
#[test]
fn test_board_pixel_offset() {
let expected = UVec2::new(0 * CELL_SIZE, 3 * CELL_SIZE);
assert_eq!(BOARD_PIXEL_OFFSET, expected);
assert_eq!(BOARD_PIXEL_OFFSET.x, 0);
assert_eq!(BOARD_PIXEL_OFFSET.y, 24); // 3 * 8
}
#[test]
fn test_board_pixel_size() {
let expected = UVec2::new(28 * CELL_SIZE, 31 * CELL_SIZE);
assert_eq!(BOARD_PIXEL_SIZE, expected);
assert_eq!(BOARD_PIXEL_SIZE.x, 224); // 28 * 8
assert_eq!(BOARD_PIXEL_SIZE.y, 248); // 31 * 8
}
#[test]
fn test_canvas_size() {
let expected = UVec2::new((28 + 0) * CELL_SIZE, (31 + 3) * CELL_SIZE);
assert_eq!(CANVAS_SIZE, expected);
assert_eq!(CANVAS_SIZE.x, 224); // (28 + 0) * 8
assert_eq!(CANVAS_SIZE.y, 272); // (31 + 3) * 8
}
#[test]
fn test_map_tile_variants() {
assert_ne!(MapTile::Empty, MapTile::Wall);
assert_ne!(MapTile::Pellet, MapTile::PowerPellet);
assert_ne!(MapTile::StartingPosition(0), MapTile::StartingPosition(1));
assert_ne!(MapTile::Tunnel, MapTile::Empty);
}
#[test]
fn test_map_tile_starting_position() {
let pos0 = MapTile::StartingPosition(0);
let pos1 = MapTile::StartingPosition(1);
let pos0_clone = MapTile::StartingPosition(0);
assert_eq!(pos0, pos0_clone);
assert_ne!(pos0, pos1);
}
#[test]
fn test_map_tile_debug() {
let tile = MapTile::Wall;
let debug_str = format!("{:?}", tile);
assert!(!debug_str.is_empty());
}
#[test]
fn test_map_tile_clone() {
let original = MapTile::StartingPosition(5);
let cloned = original;
assert_eq!(original, cloned);
}
#[test]
fn test_raw_board_dimensions() {
assert_eq!(RAW_BOARD.len(), BOARD_CELL_SIZE.y as usize);
assert_eq!(RAW_BOARD.len(), 31);
for row in RAW_BOARD.iter() {
assert_eq!(row.len(), BOARD_CELL_SIZE.x as usize);
assert_eq!(row.len(), 28);
}
}
#[test]
fn test_raw_board_boundaries() {
// First row should be all walls
assert!(RAW_BOARD[0].chars().all(|c| c == '#'));
// Last row should be all walls
let last_row = RAW_BOARD[RAW_BOARD.len() - 1];
assert!(last_row.chars().all(|c| c == '#'));
// First and last character of each row should be walls (except tunnel rows and rows with spaces)
for (i, row) in RAW_BOARD.iter().enumerate() {
if i != 14 && !row.starts_with(' ') {
// Skip tunnel row and rows that start with spaces
assert_eq!(row.chars().next().unwrap(), '#');
assert_eq!(row.chars().last().unwrap(), '#');
}
}
}
#[test]
fn test_raw_board_tunnel_row() {
// Row 14 should have tunnel characters 'T' at the edges
let tunnel_row = RAW_BOARD[14];
assert_eq!(tunnel_row.chars().next().unwrap(), 'T');
assert_eq!(tunnel_row.chars().last().unwrap(), 'T');
}
#[test]
fn test_raw_board_power_pellets() {
// Power pellets are represented by 'o'
let mut power_pellet_count = 0;
for row in RAW_BOARD.iter() {
power_pellet_count += row.chars().filter(|&c| c == 'o').count();
}
assert_eq!(power_pellet_count, 4); // Should have exactly 4 power pellets
}
#[test]
fn test_raw_board_starting_position() {
// Should have a starting position '0' for Pac-Man
let mut found_starting_position = false;
for row in RAW_BOARD.iter() {
if row.contains('0') {
found_starting_position = true;
break;
}
}
assert!(found_starting_position);
}
#[test]
fn test_raw_board_ghost_house() {
// The ghost house area should be present (the == characters)
let mut found_ghost_house = false;
for row in RAW_BOARD.iter() {
if row.contains("==") {
found_ghost_house = true;
break;
}
}
assert!(found_ghost_house);
}
#[test]
fn test_raw_board_symmetry() {
// The board should be roughly symmetrical
let mid_point = RAW_BOARD[0].len() / 2;
for row in RAW_BOARD.iter() {
let left_half = &row[..mid_point];
let right_half = &row[mid_point..];
// Check that the halves are symmetrical (accounting for the center column)
assert_eq!(left_half.len(), right_half.len());
}
}
#[test]
fn test_constants_consistency() {
// Verify that derived constants are calculated correctly
let calculated_pixel_offset = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
assert_eq!(BOARD_PIXEL_OFFSET, calculated_pixel_offset);
let calculated_pixel_size = UVec2::new(BOARD_CELL_SIZE.x * CELL_SIZE, BOARD_CELL_SIZE.y * CELL_SIZE);
assert_eq!(BOARD_PIXEL_SIZE, calculated_pixel_size);
let calculated_canvas_size = UVec2::new(
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE,
);
assert_eq!(CANVAS_SIZE, calculated_canvas_size);
}
}

View File

@@ -18,7 +18,7 @@ impl Direction {
}
}
pub fn to_ivec2(&self) -> IVec2 {
pub fn as_ivec2(&self) -> IVec2 {
(*self).into()
}
}
@@ -35,3 +35,67 @@ impl From<Direction> for IVec2 {
}
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_direction_opposite() {
assert_eq!(Direction::Up.opposite(), Direction::Down);
assert_eq!(Direction::Down.opposite(), Direction::Up);
assert_eq!(Direction::Left.opposite(), Direction::Right);
assert_eq!(Direction::Right.opposite(), Direction::Left);
}
#[test]
fn test_direction_as_ivec2() {
assert_eq!(Direction::Up.as_ivec2(), -IVec2::Y);
assert_eq!(Direction::Down.as_ivec2(), IVec2::Y);
assert_eq!(Direction::Left.as_ivec2(), -IVec2::X);
assert_eq!(Direction::Right.as_ivec2(), IVec2::X);
}
#[test]
fn test_direction_from_ivec2() {
assert_eq!(IVec2::from(Direction::Up), -IVec2::Y);
assert_eq!(IVec2::from(Direction::Down), IVec2::Y);
assert_eq!(IVec2::from(Direction::Left), -IVec2::X);
assert_eq!(IVec2::from(Direction::Right), IVec2::X);
}
#[test]
fn test_directions_constant() {
assert_eq!(DIRECTIONS.len(), 4);
assert!(DIRECTIONS.contains(&Direction::Up));
assert!(DIRECTIONS.contains(&Direction::Down));
assert!(DIRECTIONS.contains(&Direction::Left));
assert!(DIRECTIONS.contains(&Direction::Right));
}
#[test]
fn test_direction_equality() {
assert_eq!(Direction::Up, Direction::Up);
assert_ne!(Direction::Up, Direction::Down);
assert_ne!(Direction::Left, Direction::Right);
}
#[test]
fn test_direction_clone() {
let dir = Direction::Up;
let cloned = dir;
assert_eq!(dir, cloned);
}
#[test]
fn test_direction_hash() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(Direction::Up, "up");
map.insert(Direction::Down, "down");
assert_eq!(map.get(&Direction::Up), Some(&"up"));
assert_eq!(map.get(&Direction::Down), Some(&"down"));
assert_eq!(map.get(&Direction::Left), None);
}
}

View File

@@ -28,7 +28,7 @@ pub struct Node {
/// Each field contains an optional edge leading in that direction.
/// This structure is used to represent the adjacency list for each node,
/// providing O(1) access to edges in any cardinal direction.
#[derive(Debug)]
#[derive(Debug, Default)]
pub struct Intersection {
/// Edge leading upward from this node, if it exists.
pub up: Option<Edge>,
@@ -40,17 +40,6 @@ pub struct Intersection {
pub right: Option<Edge>,
}
impl Default for Intersection {
fn default() -> Self {
Self {
up: None,
down: None,
left: None,
right: None,
}
}
}
impl Intersection {
/// Returns an iterator over all edges from this intersection.
///
@@ -253,6 +242,7 @@ pub enum Position {
},
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node.
pub fn is_at_node(&self) -> bool {
@@ -260,6 +250,7 @@ impl Position {
}
/// Returns the `NodeId` of the current or most recently departed node.
#[allow(clippy::wrong_self_convention)]
pub fn from_node_id(&self) -> NodeId {
match self {
Position::AtNode(id) => *id,
@@ -268,6 +259,7 @@ impl Position {
}
/// Returns the `NodeId` of the destination node, if currently on an edge.
#[allow(clippy::wrong_self_convention)]
pub fn to_node_id(&self) -> Option<NodeId> {
match self {
Position::AtNode(_) => None,

View File

@@ -1,13 +1,13 @@
use glam::Vec2;
use glam::{UVec2, Vec2};
use crate::constants::BOARD_PIXEL_OFFSET;
use crate::entity::direction::Direction;
use crate::entity::graph::{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::keyboard::Keycode;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
@@ -29,15 +29,21 @@ impl Pacman {
Direction::Right => "pacman/right",
};
let moving_tiles = vec![
SpriteAtlas::get_tile(&atlas, &format!("{}_a.png", moving_prefix)).unwrap(),
SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap(),
SpriteAtlas::get_tile(&atlas, "pacman/full.png").unwrap(),
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(),
];
let stopped_tiles = vec![SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap()];
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
textures.insert(direction, AnimatedTexture::new(moving_tiles, 0.08));
stopped_textures.insert(direction, AnimatedTexture::new(stopped_tiles, 0.1));
textures.insert(
direction,
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"),
);
stopped_textures.insert(
direction,
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
);
}
Self {
@@ -71,15 +77,14 @@ impl Pacman {
Position::BetweenNodes { from, to, traversed } => {
let from_pos = graph.get_node(from).unwrap().position;
let to_pos = graph.get_node(to).unwrap().position;
let weight = from_pos.distance(to_pos);
from_pos.lerp(to_pos, traversed / weight)
from_pos.lerp(to_pos, traversed / from_pos.distance(to_pos))
}
}
}
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
let dest = Rect::new(pixel_pos.x - 8, pixel_pos.y - 8, 16, 16);
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
let is_stopped = self.traverser.position.is_stopped();
if is_stopped {

View File

@@ -36,7 +36,6 @@ pub struct Game {
atlas: SpriteAtlas,
map_texture: AtlasTile,
text_texture: TextTexture,
debug_text_texture: TextTexture,
// Audio
pub audio: Audio,
@@ -71,9 +70,9 @@ impl Game {
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let text_texture = TextTexture::new(1.0);
let debug_text_texture = TextTexture::new(0.5);
let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
Game {
score: 0,
map,
@@ -81,7 +80,6 @@ impl Game {
debug_mode: false,
map_texture,
text_texture,
debug_text_texture,
audio,
atlas,
}
@@ -92,7 +90,6 @@ impl Game {
if keycode == Keycode::M {
self.audio.set_mute(!self.audio.is_muted());
return;
}
}
@@ -122,7 +119,6 @@ impl Game {
}
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
let score_text = self.score.to_string();
let lives = 3;
let score_text = format!("{:02}", self.score);
let x_offset = 4;

51
src/helpers.rs Normal file
View File

@@ -0,0 +1,51 @@
use glam::{IVec2, UVec2};
use sdl2::rect::Rect;
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
Rect::new(
pixel_pos.x - size.x as i32 / 2,
pixel_pos.y - size.y as i32 / 2,
size.x,
size.y,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_centered_with_size_basic() {
let rect = centered_with_size(IVec2::new(100, 100), UVec2::new(50, 30));
assert_eq!(rect.origin(), (75, 85));
assert_eq!(rect.size(), (50, 30));
}
#[test]
fn test_centered_with_size_odd_dimensions() {
let rect = centered_with_size(IVec2::new(50, 50), UVec2::new(51, 31));
assert_eq!(rect.origin(), (25, 35));
assert_eq!(rect.size(), (51, 31));
}
#[test]
fn test_centered_with_size_zero_position() {
let rect = centered_with_size(IVec2::new(0, 0), UVec2::new(100, 100));
assert_eq!(rect.origin(), (-50, -50));
assert_eq!(rect.size(), (100, 100));
}
#[test]
fn test_centered_with_size_negative_position() {
let rect = centered_with_size(IVec2::new(-100, -50), UVec2::new(80, 40));
assert_eq!(rect.origin(), (-140, -70));
assert_eq!(rect.size(), (80, 40));
}
#[test]
fn test_centered_with_size_large_dimensions() {
let rect = centered_with_size(IVec2::new(1000, 1000), UVec2::new(1000, 1000));
assert_eq!(rect.origin(), (500, 500));
assert_eq!(rect.size(), (1000, 1000));
}
}

View File

@@ -7,5 +7,6 @@ pub mod constants;
pub mod emscripten;
pub mod entity;
pub mod game;
pub mod helpers;
pub mod map;
pub mod texture;

View File

@@ -56,6 +56,7 @@ mod constants;
mod emscripten;
mod entity;
mod game;
mod helpers;
mod map;
mod texture;

View File

@@ -81,7 +81,7 @@ impl Map {
// Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
while let Some(source_position) = queue.pop_front() {
for &dir in DIRECTIONS.iter() {
let new_position = source_position + dir.to_ivec2();
let new_position = source_position + dir.as_ivec2();
// Skip if the new position is out of bounds
if new_position.x < 0
@@ -114,7 +114,7 @@ impl Map {
// Connect the new node to the source node
let source_node_id = grid_to_node
.get(&source_position)
.expect(&format!("Source node not found for {source_position}"));
.unwrap_or_else(|| panic!("Source node not found for {source_position}"));
// Connect the new node to the source node
graph
@@ -129,7 +129,7 @@ impl Map {
for dir in DIRECTIONS {
// If the node doesn't have an edge in this direction, look for a neighbor in that direction
if graph.adjacency_list[node_id].get(dir).is_none() {
let neighbor = grid_pos + dir.to_ivec2();
let neighbor = grid_pos + dir.as_ivec2();
// If the neighbor exists, connect the node to it
if let Some(&neighbor_id) = grid_to_node.get(&neighbor) {
graph
@@ -198,10 +198,10 @@ impl Map {
let (house_entrance_node_id, house_entrance_node_position) = {
// Translate the grid positions to the actual node ids
let left_node = grid_to_node
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.to_ivec2()))
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.as_ivec2()))
.expect("Left house door node not found");
let right_node = grid_to_node
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.to_ivec2()))
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.as_ivec2()))
.expect("Right house door node not found");
// Calculate the position of the house node
@@ -230,10 +230,10 @@ impl Map {
// Place the nodes at, above, and below the center position
let center_node_id = graph.add_node(Node { position: center_pos });
let top_node_id = graph.add_node(Node {
position: center_pos + (Direction::Up.to_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
position: center_pos + (Direction::Up.as_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
});
let bottom_node_id = graph.add_node(Node {
position: center_pos + (Direction::Down.to_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
position: center_pos + (Direction::Down.as_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
});
// Connect the center node to the top and bottom nodes
@@ -249,7 +249,7 @@ impl Map {
// Calculate the position of the center line's center node
let center_line_center_position =
house_entrance_node_position + (Direction::Down.to_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
// Create the center line
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position);
@@ -262,13 +262,13 @@ impl Map {
// Create the left line
let (left_center_node_id, _) = create_house_line(
graph,
center_line_center_position + (Direction::Left.to_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
);
// Create the right line
let (right_center_node_id, _) = create_house_line(
graph,
center_line_center_position + (Direction::Right.to_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
);
debug!("Left center node id: {left_center_node_id}");
@@ -300,7 +300,7 @@ impl Map {
Direction::Left,
Node {
position: left_tunnel_entrance_node.position
+ (Direction::Left.to_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
+ (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
},
)
.expect("Failed to connect left tunnel entrance to left tunnel hidden node")
@@ -319,7 +319,7 @@ impl Map {
Direction::Right,
Node {
position: right_tunnel_entrance_node.position
+ (Direction::Right.to_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
+ (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
},
)
.expect("Failed to connect right tunnel entrance to right tunnel hidden node")

View File

@@ -1,10 +1,17 @@
use anyhow::Result;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget};
use thiserror::Error;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
#[derive(Clone)]
#[derive(Error, Debug)]
pub enum AnimatedTextureError {
#[error("Frame duration must be positive, got {0}")]
InvalidFrameDuration(f32),
}
#[derive(Debug, Clone)]
pub struct AnimatedTexture {
tiles: Vec<AtlasTile>,
frame_duration: f32,
@@ -13,13 +20,17 @@ pub struct AnimatedTexture {
}
impl AnimatedTexture {
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Self {
Self {
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Result<Self, AnimatedTextureError> {
if frame_duration <= 0.0 {
return Err(AnimatedTextureError::InvalidFrameDuration(frame_duration));
}
Ok(Self {
tiles,
frame_duration,
current_frame: 0,
time_bank: 0.0,
}
})
}
pub fn tick(&mut self, dt: f32) {
@@ -35,7 +46,148 @@ impl AnimatedTexture {
}
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
let mut tile = self.current_tile().clone();
let mut tile = *self.current_tile();
tile.render(canvas, atlas, dest)
}
// Helper methods for testing
pub fn current_frame(&self) -> usize {
self.current_frame
}
pub fn time_bank(&self) -> f32 {
self.time_bank
}
pub fn frame_duration(&self) -> f32 {
self.frame_duration
}
pub fn tiles_len(&self) -> usize {
self.tiles.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::U16Vec2;
use sdl2::pixels::Color;
impl AtlasTile {
fn mock(id: u32) -> Self {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
}
#[test]
fn test_new_animated_texture() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2), AtlasTile::mock(3)];
let texture = AnimatedTexture::new(tiles.clone(), 0.1).unwrap();
assert_eq!(texture.current_frame(), 0);
assert_eq!(texture.time_bank(), 0.0);
assert_eq!(texture.frame_duration(), 0.1);
assert_eq!(texture.tiles_len(), 3);
}
#[test]
fn test_new_animated_texture_zero_duration() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let result = AnimatedTexture::new(tiles, 0.0);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AnimatedTextureError::InvalidFrameDuration(0.0)));
}
#[test]
fn test_new_animated_texture_negative_duration() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let result = AnimatedTexture::new(tiles, -0.1);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AnimatedTextureError::InvalidFrameDuration(-0.1)
));
}
#[test]
fn test_tick_no_frame_change() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Tick with less than frame duration
texture.tick(0.05);
assert_eq!(texture.current_frame(), 0);
assert_eq!(texture.time_bank(), 0.05);
}
#[test]
fn test_tick_single_frame_change() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Tick with exactly frame duration
texture.tick(0.1);
assert_eq!(texture.current_frame(), 1);
assert_eq!(texture.time_bank(), 0.0);
}
#[test]
fn test_tick_multiple_frame_changes() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2), AtlasTile::mock(3)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Tick with 2.5 frame durations
texture.tick(0.25);
assert_eq!(texture.current_frame(), 2);
assert!((texture.time_bank() - 0.05).abs() < 0.001);
}
#[test]
fn test_tick_wrap_around() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Advance to last frame
texture.tick(0.1);
assert_eq!(texture.current_frame(), 1);
// Advance again to wrap around
texture.tick(0.1);
assert_eq!(texture.current_frame(), 0);
}
#[test]
fn test_current_tile() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Should return first tile initially
assert_eq!(texture.current_tile().color.unwrap().r, 1);
}
#[test]
fn test_current_tile_after_frame_change() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Advance one frame
texture.tick(0.1);
assert_eq!(texture.current_tile().color.unwrap().r, 2);
}
#[test]
fn test_single_tile_animation() {
let tiles = vec![AtlasTile::mock(1)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Should stay on same frame
texture.tick(0.1);
assert_eq!(texture.current_frame(), 0);
assert_eq!(texture.current_tile().color.unwrap().r, 1);
}
}

View File

@@ -1,3 +1,4 @@
#![allow(dead_code)]
use crate::texture::sprite::AtlasTile;
#[derive(Clone)]
@@ -33,4 +34,145 @@ impl BlinkingTexture {
pub fn tile(&self) -> &AtlasTile {
&self.tile
}
// Helper methods for testing
pub fn time_bank(&self) -> f32 {
self.time_bank
}
pub fn blink_duration(&self) -> f32 {
self.blink_duration
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::U16Vec2;
use sdl2::pixels::Color;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
#[test]
fn test_new_blinking_texture() {
let tile = mock_atlas_tile(1);
let texture = BlinkingTexture::new(tile, 0.5);
assert_eq!(texture.is_on(), true);
assert_eq!(texture.time_bank(), 0.0);
assert_eq!(texture.blink_duration(), 0.5);
assert_eq!(texture.tile().color.unwrap().r, 1);
}
#[test]
fn test_tick_no_blink_change() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Tick with less than blink duration
texture.tick(0.25);
assert_eq!(texture.is_on(), true);
assert_eq!(texture.time_bank(), 0.25);
}
#[test]
fn test_tick_single_blink_change() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Tick with exactly blink duration
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
assert_eq!(texture.time_bank(), 0.0);
}
#[test]
fn test_tick_multiple_blink_changes() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// First blink
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
// Second blink (back to on)
texture.tick(0.5);
assert_eq!(texture.is_on(), true);
// Third blink (back to off)
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
}
#[test]
fn test_tick_partial_blink_duration() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Tick with 1.25 blink durations
texture.tick(0.625);
assert_eq!(texture.is_on(), false);
assert_eq!(texture.time_bank(), 0.125);
}
#[test]
fn test_tick_with_zero_duration() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.0);
// Should not cause issues - skip the test if blink_duration is 0
if texture.blink_duration() > 0.0 {
texture.tick(0.1);
assert_eq!(texture.is_on(), true);
}
}
#[test]
fn test_tick_with_negative_duration() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, -0.5);
// Should not cause issues - skip the test if blink_duration is negative
if texture.blink_duration() > 0.0 {
texture.tick(0.1);
assert_eq!(texture.is_on(), true);
}
}
#[test]
fn test_tick_with_negative_delta_time() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Should not cause issues
texture.tick(-0.1);
assert_eq!(texture.is_on(), true);
assert_eq!(texture.time_bank(), -0.1);
}
#[test]
fn test_tile_access() {
let tile = mock_atlas_tile(42);
let texture = BlinkingTexture::new(tile, 0.5);
assert_eq!(texture.tile().color.unwrap().r, 42);
}
#[test]
fn test_clone() {
let tile = mock_atlas_tile(1);
let texture = BlinkingTexture::new(tile, 0.5);
let cloned = texture.clone();
assert_eq!(texture.is_on(), cloned.is_on());
assert_eq!(texture.time_bank(), cloned.time_bank());
assert_eq!(texture.blink_duration(), cloned.blink_duration());
assert_eq!(texture.tile().color.unwrap().r, cloned.tile().color.unwrap().r);
}
}

View File

@@ -54,4 +54,130 @@ impl DirectionalAnimatedTexture {
Ok(())
}
}
// Helper methods for testing
pub fn has_direction(&self, direction: Direction) -> bool {
self.textures.contains_key(&direction)
}
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
self.stopped_textures.contains_key(&direction)
}
pub fn texture_count(&self) -> usize {
self.textures.len()
}
pub fn stopped_texture_count(&self) -> usize {
self.stopped_textures.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::texture::sprite::AtlasTile;
use glam::U16Vec2;
use sdl2::pixels::Color;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
fn mock_animated_texture(id: u32) -> AnimatedTexture {
AnimatedTexture::new(vec![mock_atlas_tile(id)], 0.1).expect("Invalid frame duration")
}
#[test]
fn test_new_directional_animated_texture() {
let mut textures = HashMap::new();
let mut stopped_textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
textures.insert(Direction::Down, mock_animated_texture(2));
stopped_textures.insert(Direction::Up, mock_animated_texture(3));
stopped_textures.insert(Direction::Down, mock_animated_texture(4));
let texture = DirectionalAnimatedTexture::new(textures, stopped_textures);
assert_eq!(texture.texture_count(), 2);
assert_eq!(texture.stopped_texture_count(), 2);
assert!(texture.has_direction(Direction::Up));
assert!(texture.has_direction(Direction::Down));
assert!(!texture.has_direction(Direction::Left));
assert!(texture.has_stopped_direction(Direction::Up));
assert!(texture.has_stopped_direction(Direction::Down));
assert!(!texture.has_stopped_direction(Direction::Left));
}
#[test]
fn test_tick() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
textures.insert(Direction::Down, mock_animated_texture(2));
let mut texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
// Should not panic
texture.tick(0.1);
assert_eq!(texture.texture_count(), 2);
}
#[test]
fn test_empty_texture() {
let texture = DirectionalAnimatedTexture::new(HashMap::new(), HashMap::new());
assert_eq!(texture.texture_count(), 0);
assert_eq!(texture.stopped_texture_count(), 0);
assert!(!texture.has_direction(Direction::Up));
assert!(!texture.has_stopped_direction(Direction::Up));
}
#[test]
fn test_partial_directions() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
assert_eq!(texture.texture_count(), 1);
assert!(texture.has_direction(Direction::Up));
assert!(!texture.has_direction(Direction::Down));
assert!(!texture.has_direction(Direction::Left));
assert!(!texture.has_direction(Direction::Right));
}
#[test]
fn test_clone() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
let cloned = texture.clone();
assert_eq!(texture.texture_count(), cloned.texture_count());
assert_eq!(texture.stopped_texture_count(), cloned.stopped_texture_count());
assert_eq!(texture.has_direction(Direction::Up), cloned.has_direction(Direction::Up));
}
#[test]
fn test_all_directions() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
textures.insert(Direction::Down, mock_animated_texture(2));
textures.insert(Direction::Left, mock_animated_texture(3));
textures.insert(Direction::Right, mock_animated_texture(4));
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
assert_eq!(texture.texture_count(), 4);
assert!(texture.has_direction(Direction::Up));
assert!(texture.has_direction(Direction::Down));
assert!(texture.has_direction(Direction::Left));
assert!(texture.has_direction(Direction::Right));
}
}

View File

@@ -49,6 +49,16 @@ impl AtlasTile {
canvas.copy(&atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
Ok(())
}
// Helper methods for testing
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
Self { pos, size, color }
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
pub struct SpriteAtlas {
@@ -76,15 +86,276 @@ impl SpriteAtlas {
})
}
#[allow(dead_code)]
pub fn set_color(&mut self, color: Color) {
self.default_color = Some(color);
}
#[allow(dead_code)]
pub fn texture(&self) -> &Texture<'static> {
&self.texture
}
// Helper methods for testing
pub fn tiles_count(&self) -> usize {
self.tiles.len()
}
pub fn has_tile(&self, name: &str) -> bool {
self.tiles.contains_key(name)
}
pub fn default_color(&self) -> Option<Color> {
self.default_color
}
}
/// Converts a `Texture` to a `Texture<'static>` using transmute.
///
/// # Safety
///
/// This function is unsafe because it uses `std::mem::transmute` to change the lifetime
/// of the texture from the original lifetime to `'static`. The caller must ensure that:
///
/// - The original `Texture` will live for the entire duration of the program
/// - No references to the original texture exist that could become invalid
/// - The texture is not dropped while still being used as a `'static` reference
///
/// This is typically used when you have a texture that you know will live for the entire
/// program duration and need to store it in a structure that requires a `'static` lifetime.
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
std::mem::transmute(texture)
}
#[cfg(test)]
mod tests {
use super::*;
use sdl2::pixels::Color;
// Mock texture for testing - we'll use a dummy approach since we can't create real SDL2 textures
fn mock_texture() -> Texture<'static> {
// This is unsafe and only for testing - in real usage this would be a proper texture
unsafe { std::mem::transmute(0usize) }
}
#[test]
fn test_atlas_tile_new() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(32, 32);
let tile = AtlasTile::new(pos, size, None);
assert_eq!(tile.pos, pos);
assert_eq!(tile.size, size);
assert_eq!(tile.color, None);
}
#[test]
fn test_atlas_tile_with_color() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(32, 32);
let color = Color::RGB(255, 0, 0);
let tile = AtlasTile::new(pos, size, None).with_color(color);
assert_eq!(tile.pos, pos);
assert_eq!(tile.size, size);
assert_eq!(tile.color, Some(color));
}
#[test]
fn test_mapper_frame() {
let frame = MapperFrame {
x: 10,
y: 20,
width: 32,
height: 32,
};
assert_eq!(frame.x, 10);
assert_eq!(frame.y, 20);
assert_eq!(frame.width, 32);
assert_eq!(frame.height, 32);
}
#[test]
fn test_atlas_mapper_new() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
let mapper = AtlasMapper { frames };
assert_eq!(mapper.frames.len(), 1);
assert!(mapper.frames.contains_key("test"));
}
#[test]
fn test_sprite_atlas_new() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.tiles_count(), 1);
assert!(atlas.has_tile("test"));
assert_eq!(atlas.default_color(), None);
}
#[test]
fn test_sprite_atlas_get_tile() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 10,
y: 20,
width: 32,
height: 64,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
let tile = atlas.get_tile("test");
assert!(tile.is_some());
let tile = tile.unwrap();
assert_eq!(tile.pos, U16Vec2::new(10, 20));
assert_eq!(tile.size, U16Vec2::new(32, 64));
assert_eq!(tile.color, None);
}
#[test]
fn test_sprite_atlas_get_tile_nonexistent() {
let mapper = AtlasMapper { frames: HashMap::new() };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
let tile = atlas.get_tile("nonexistent");
assert!(tile.is_none());
}
#[test]
fn test_sprite_atlas_set_color() {
let mapper = AtlasMapper { frames: HashMap::new() };
let texture = mock_texture();
let mut atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.default_color(), None);
let color = Color::RGB(255, 0, 0);
atlas.set_color(color);
assert_eq!(atlas.default_color(), Some(color));
}
#[test]
fn test_sprite_atlas_empty() {
let mapper = AtlasMapper { frames: HashMap::new() };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.tiles_count(), 0);
assert!(!atlas.has_tile("any"));
}
#[test]
fn test_sprite_atlas_multiple_tiles() {
let mut frames = HashMap::new();
frames.insert(
"tile1".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
frames.insert(
"tile2".to_string(),
MapperFrame {
x: 32,
y: 0,
width: 64,
height: 64,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.tiles_count(), 2);
assert!(atlas.has_tile("tile1"));
assert!(atlas.has_tile("tile2"));
assert!(!atlas.has_tile("tile3"));
}
#[test]
fn test_atlas_tile_clone() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(32, 32);
let color = Color::RGB(255, 0, 0);
let tile = AtlasTile::new(pos, size, Some(color));
let cloned = tile;
assert_eq!(tile.pos, cloned.pos);
assert_eq!(tile.size, cloned.size);
assert_eq!(tile.color, cloned.color);
}
#[test]
fn test_mapper_frame_clone() {
let frame = MapperFrame {
x: 10,
y: 20,
width: 32,
height: 64,
};
let cloned = frame;
assert_eq!(frame.x, cloned.x);
assert_eq!(frame.y, cloned.y);
assert_eq!(frame.width, cloned.width);
assert_eq!(frame.height, cloned.height);
}
#[test]
fn test_atlas_mapper_clone() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
let mapper = AtlasMapper { frames };
let cloned = mapper.clone();
assert_eq!(mapper.frames.len(), cloned.frames.len());
assert!(mapper.frames.contains_key("test"));
assert!(cloned.frames.contains_key("test"));
}
}

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
//! This module provides text rendering using the texture atlas.
//!
//! The TextTexture system renders text from the atlas using character mapping.
@@ -149,3 +151,228 @@ impl TextTexture {
(8.0 * self.scale) as u32
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
fn create_mock_atlas() -> SpriteAtlas {
let mut frames = HashMap::new();
frames.insert(
"text/A.png".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/1.png".to_string(),
MapperFrame {
x: 8,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/!.png".to_string(),
MapperFrame {
x: 16,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/-.png".to_string(),
MapperFrame {
x: 24,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/_double_quote.png".to_string(),
MapperFrame {
x: 32,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/_forward_slash.png".to_string(),
MapperFrame {
x: 40,
y: 0,
width: 8,
height: 8,
},
);
let mapper = AtlasMapper { frames };
// Note: In real tests, we'd need a proper texture, but for unit tests we can work around this
unsafe { SpriteAtlas::new(std::mem::zeroed(), mapper) }
}
#[test]
fn test_text_texture_new() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.scale(), 1.0);
assert!(text_texture.char_map.is_empty());
}
#[test]
fn test_text_texture_new_with_scale() {
let text_texture = TextTexture::new(2.5);
assert_eq!(text_texture.scale(), 2.5);
}
#[test]
fn test_char_to_tile_name_letters() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name('A'), Some("text/A.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('Z'), Some("text/Z.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('a'), None); // lowercase not supported
}
#[test]
fn test_char_to_tile_name_numbers() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name('0'), Some("text/0.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('9'), Some("text/9.png".to_string()));
}
#[test]
fn test_char_to_tile_name_special_characters() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name('!'), Some("text/!.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('-'), Some("text/-.png".to_string()));
assert_eq!(
text_texture.char_to_tile_name('"'),
Some("text/_double_quote.png".to_string())
);
assert_eq!(
text_texture.char_to_tile_name('/'),
Some("text/_forward_slash.png".to_string())
);
}
#[test]
fn test_char_to_tile_name_unsupported() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name(' '), None);
assert_eq!(text_texture.char_to_tile_name('@'), None);
assert_eq!(text_texture.char_to_tile_name('a'), None);
assert_eq!(text_texture.char_to_tile_name('z'), None);
}
#[test]
fn test_set_scale() {
let mut text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.scale(), 1.0);
text_texture.set_scale(3.0);
assert_eq!(text_texture.scale(), 3.0);
text_texture.set_scale(0.5);
assert_eq!(text_texture.scale(), 0.5);
}
#[test]
fn test_text_width_empty_string() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_width(""), 0);
}
#[test]
fn test_text_width_single_character() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_width("A"), 8); // 8 pixels per character at scale 1.0
}
#[test]
fn test_text_width_multiple_characters() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_width("ABC"), 24); // 3 * 8 = 24 pixels
}
#[test]
fn test_text_width_with_scale() {
let text_texture = TextTexture::new(2.0);
assert_eq!(text_texture.text_width("A"), 16); // 8 * 2 = 16 pixels
assert_eq!(text_texture.text_width("ABC"), 48); // 3 * 16 = 48 pixels
}
#[test]
fn test_text_width_with_unsupported_characters() {
let text_texture = TextTexture::new(1.0);
// Only supported characters should be counted
assert_eq!(text_texture.text_width("A B"), 16); // A and B only, space ignored
assert_eq!(text_texture.text_width("A@B"), 16); // A and B only, @ ignored
}
#[test]
fn test_text_height() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_height(), 8); // 8 pixels per character at scale 1.0
}
#[test]
fn test_text_height_with_scale() {
let text_texture = TextTexture::new(2.0);
assert_eq!(text_texture.text_height(), 16); // 8 * 2 = 16 pixels
}
#[test]
fn test_text_height_with_fractional_scale() {
let text_texture = TextTexture::new(1.5);
assert_eq!(text_texture.text_height(), 12); // 8 * 1.5 = 12 pixels
}
#[test]
fn test_get_char_tile_caching() {
let mut text_texture = TextTexture::new(1.0);
let atlas = create_mock_atlas();
// First call should cache the tile
let tile1 = text_texture.get_char_tile(&atlas, 'A');
assert!(tile1.is_some());
// Second call should use cached tile
let tile2 = text_texture.get_char_tile(&atlas, 'A');
assert!(tile2.is_some());
// Both should be the same tile
assert_eq!(tile1.unwrap().pos, tile2.unwrap().pos);
assert_eq!(tile1.unwrap().size, tile2.unwrap().size);
}
#[test]
fn test_get_char_tile_unsupported_character() {
let mut text_texture = TextTexture::new(1.0);
let atlas = create_mock_atlas();
let tile = text_texture.get_char_tile(&atlas, ' ');
assert!(tile.is_none());
}
#[test]
fn test_get_char_tile_missing_from_atlas() {
let mut text_texture = TextTexture::new(1.0);
let atlas = create_mock_atlas();
// 'B' is not in our mock atlas
let tile = text_texture.get_char_tile(&atlas, 'B');
assert!(tile.is_none());
}
}