Compare commits

..

5 Commits

63 changed files with 1916 additions and 503 deletions

1121
assets/game/atlas.json Normal file
View File

File diff suppressed because it is too large Load Diff

BIN
assets/game/atlas.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
assets/unpacked/text/!.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

BIN
assets/unpacked/text/-.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B

BIN
assets/unpacked/text/0.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

BIN
assets/unpacked/text/1.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

BIN
assets/unpacked/text/2.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

BIN
assets/unpacked/text/3.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

BIN
assets/unpacked/text/4.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

BIN
assets/unpacked/text/5.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

BIN
assets/unpacked/text/6.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

BIN
assets/unpacked/text/7.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

BIN
assets/unpacked/text/8.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

BIN
assets/unpacked/text/9.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

BIN
assets/unpacked/text/A.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

BIN
assets/unpacked/text/B.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

BIN
assets/unpacked/text/C.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

BIN
assets/unpacked/text/D.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

BIN
assets/unpacked/text/E.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

BIN
assets/unpacked/text/F.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

BIN
assets/unpacked/text/G.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

BIN
assets/unpacked/text/H.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

BIN
assets/unpacked/text/I.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

BIN
assets/unpacked/text/J.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

BIN
assets/unpacked/text/K.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

BIN
assets/unpacked/text/L.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 B

BIN
assets/unpacked/text/M.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

BIN
assets/unpacked/text/N.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

BIN
assets/unpacked/text/O.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

BIN
assets/unpacked/text/P.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

BIN
assets/unpacked/text/Q.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

BIN
assets/unpacked/text/R.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

BIN
assets/unpacked/text/S.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

BIN
assets/unpacked/text/T.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

BIN
assets/unpacked/text/U.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

BIN
assets/unpacked/text/V.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

BIN
assets/unpacked/text/W.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

BIN
assets/unpacked/text/X.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

BIN
assets/unpacked/text/Y.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

BIN
assets/unpacked/text/Z.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@@ -5,6 +5,7 @@ use std::borrow::Cow;
use std::io;
use thiserror::Error;
#[allow(dead_code)]
#[derive(Error, Debug)]
pub enum AssetError {
#[error("IO error: {0}")]
@@ -28,6 +29,7 @@ pub enum Asset {
}
impl Asset {
#[allow(dead_code)]
pub fn path(&self) -> &str {
use Asset::*;
match self {

View File

@@ -1,23 +1,26 @@
//! This module contains all the constants used in the game.
/// The width of the game board, in cells.
pub const BOARD_WIDTH: u32 = 28;
/// The height of the game board, in cells.
pub const BOARD_HEIGHT: u32 = 31;
use glam::UVec2;
/// The size of each cell, in pixels.
pub const CELL_SIZE: u32 = 24;
pub const CELL_SIZE: u32 = 8;
/// The size of the game board, in cells.
pub const BOARD_CELL_SIZE: UVec2 = UVec2::new(28, 31);
/// The offset of the game board from the top-left corner of the window, in
/// cells.
pub const BOARD_OFFSET: (u32, u32) = (0, 3);
/// The scale factor for the window (integer zoom)
pub const SCALE: f32 = 2.6;
/// The width of the window, in pixels.
pub const WINDOW_WIDTH: u32 = CELL_SIZE * BOARD_WIDTH;
/// The height of the window, in pixels.
///
/// The map texture is 6 cells taller than the grid (3 above, 3 below), so we
/// add 6 to the board height to get the window height.
pub const WINDOW_HEIGHT: u32 = CELL_SIZE * (BOARD_HEIGHT + 6);
/// The offset of the game board from the top-left corner of the window, in cells.
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,
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE,
);
/// An enum representing the different types of tiles on the map.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -89,7 +92,7 @@ impl FruitType {
}
/// The raw layout of the game board, as a 2D array of characters.
pub const RAW_BOARD: [&str; BOARD_HEIGHT as usize] = [
pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
"############################",
"#............##............#",
"#.####.#####.##.#####.####.#",

View File

@@ -1,6 +1,6 @@
//! Debug rendering utilities for Pac-Man.
use crate::{
constants::{MapTile, BOARD_HEIGHT, BOARD_WIDTH},
constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE},
entity::blinky::Blinky,
map::Map,
};
@@ -22,13 +22,13 @@ impl DebugRenderer {
let position = Map::cell_to_pixel(cell);
canvas.set_draw_color(color);
canvas
.draw_rect(sdl2::rect::Rect::new(position.x, position.y, 24, 24))
.draw_rect(sdl2::rect::Rect::new(position.x, position.y, CELL_SIZE, CELL_SIZE))
.expect("Could not draw rectangle");
}
pub fn draw_debug_grid(canvas: &mut Canvas<Window>, map: &Map, pacman_cell: UVec2) {
for x in 0..BOARD_WIDTH {
for y in 0..BOARD_HEIGHT {
for x in 0..BOARD_CELL_SIZE.x {
for y in 0..BOARD_CELL_SIZE.y {
let tile = map.get_tile(IVec2::new(x as i32, y as i32)).unwrap_or(MapTile::Empty);
let cell = UVec2::new(x, y);
let mut color = None;

View File

@@ -1,15 +1,15 @@
use std::cell::RefCell;
use std::rc::Rc;
use sdl2::render::{Canvas, Texture};
use sdl2::video::Window;
use crate::entity::direction::Direction;
use crate::entity::ghost::{Ghost, GhostMode, GhostType};
use crate::entity::pacman::Pacman;
use crate::entity::{Entity, Moving, Renderable, StaticEntity};
use crate::map::Map;
use crate::texture::sprite::SpriteAtlas;
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
pub struct Blinky {
ghost: Ghost,
@@ -18,13 +18,12 @@ pub struct Blinky {
impl Blinky {
pub fn new(
starting_position: UVec2,
body_texture: Texture<'_>,
eyes_texture: Texture<'_>,
atlas: Rc<RefCell<SpriteAtlas>>,
map: Rc<RefCell<Map>>,
pacman: Rc<RefCell<Pacman>>,
) -> Blinky {
Blinky {
ghost: Ghost::new(GhostType::Blinky, starting_position, body_texture, eyes_texture, map, pacman),
ghost: Ghost::new(GhostType::Blinky, starting_position, atlas, map, pacman),
}
}
@@ -51,14 +50,14 @@ impl Entity for Blinky {
}
impl Renderable for Blinky {
fn render(&self, canvas: &mut Canvas<Window>) {
self.ghost.render(canvas);
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
self.ghost.render(canvas)
}
}
impl Moving for Blinky {
fn move_forward(&mut self) {
self.ghost.move_forward();
fn tick_movement(&mut self) {
self.ghost.tick_movement();
}
fn update_cell_position(&mut self) {
self.ghost.update_cell_position();

View File

@@ -1,13 +1,12 @@
//! Edible entity for Pac-Man: pellets, power pellets, and fruits.
use crate::constants::{FruitType, MapTile, BOARD_HEIGHT, BOARD_WIDTH};
use crate::entity::direction::Direction;
use crate::constants::{FruitType, MapTile, BOARD_CELL_SIZE};
use crate::entity::{Entity, Renderable, StaticEntity};
use crate::map::Map;
use crate::texture::atlas::AtlasTexture;
use crate::texture::animated::AnimatedTexture;
use crate::texture::blinking::BlinkingTexture;
use crate::texture::FrameDrawn;
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::{render::Canvas, video::Window};
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
@@ -19,8 +18,8 @@ pub enum EdibleKind {
}
pub enum EdibleSprite {
Pellet(Rc<Box<dyn FrameDrawn>>),
PowerPellet(Rc<RefCell<BlinkingTexture>>),
Pellet(AnimatedTexture),
PowerPellet(BlinkingTexture),
}
pub struct Edible {
@@ -30,7 +29,7 @@ pub struct Edible {
}
impl Edible {
pub fn new_pellet(cell_position: UVec2, sprite: Rc<Box<dyn FrameDrawn>>) -> Self {
pub fn new_pellet(cell_position: UVec2, sprite: AnimatedTexture) -> Self {
let pixel_position = Map::cell_to_pixel(cell_position);
Edible {
base: StaticEntity::new(pixel_position, cell_position),
@@ -38,7 +37,7 @@ impl Edible {
sprite: EdibleSprite::Pellet(sprite),
}
}
pub fn new_power_pellet(cell_position: UVec2, sprite: Rc<RefCell<BlinkingTexture>>) -> Self {
pub fn new_power_pellet(cell_position: UVec2, sprite: BlinkingTexture) -> Self {
let pixel_position = Map::cell_to_pixel(cell_position);
Edible {
base: StaticEntity::new(pixel_position, cell_position),
@@ -49,7 +48,7 @@ impl Edible {
/// Checks collision with Pac-Man (or any entity)
pub fn collide(&self, pacman: &dyn Entity) -> bool {
self.base.is_colliding(pacman)
self.base.cell_position == pacman.base().cell_position
}
}
@@ -60,11 +59,26 @@ impl Entity for Edible {
}
impl Renderable for Edible {
fn render(&self, canvas: &mut Canvas<Window>) {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
let pos = self.base.pixel_position;
match &self.sprite {
EdibleSprite::Pellet(sprite) => sprite.render(canvas, pos, Direction::Right, Some(0)),
EdibleSprite::PowerPellet(sprite) => sprite.borrow().render(canvas, pos, Direction::Right, Some(0)),
let dest = match &mut self.sprite {
EdibleSprite::Pellet(sprite) => {
let tile = sprite.current_tile();
let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2);
let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2);
sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32)
}
EdibleSprite::PowerPellet(sprite) => {
let tile = sprite.animation.current_tile();
let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2);
let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2);
sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32)
}
};
match &mut self.sprite {
EdibleSprite::Pellet(sprite) => sprite.render(canvas, dest),
EdibleSprite::PowerPellet(sprite) => sprite.render(canvas, dest),
}
}
}
@@ -72,20 +86,20 @@ impl Renderable for Edible {
/// Reconstruct all edibles from the original map layout
pub fn reconstruct_edibles(
map: Rc<RefCell<Map>>,
pellet_sprite: Rc<Box<dyn FrameDrawn>>,
power_pellet_sprite: Rc<RefCell<BlinkingTexture>>,
_fruit_sprite: Rc<Box<dyn FrameDrawn>>,
pellet_sprite: AnimatedTexture,
power_pellet_sprite: BlinkingTexture,
_fruit_sprite: AnimatedTexture,
) -> Vec<Edible> {
let mut edibles = Vec::new();
for x in 0..BOARD_WIDTH {
for y in 0..BOARD_HEIGHT {
for x in 0..BOARD_CELL_SIZE.x {
for y in 0..BOARD_CELL_SIZE.y {
let tile = map.borrow().get_tile(IVec2::new(x as i32, y as i32));
match tile {
Some(MapTile::Pellet) => {
edibles.push(Edible::new_pellet(UVec2::new(x, y), Rc::clone(&pellet_sprite)));
edibles.push(Edible::new_pellet(UVec2::new(x, y), pellet_sprite.clone()));
}
Some(MapTile::PowerPellet) => {
edibles.push(Edible::new_power_pellet(UVec2::new(x, y), Rc::clone(&power_pellet_sprite)));
edibles.push(Edible::new_power_pellet(UVec2::new(x, y), power_pellet_sprite.clone()));
}
// Fruits can be added here if you have fruit positions
_ => {}

View File

@@ -2,18 +2,21 @@ use rand::rngs::SmallRng;
use rand::Rng;
use rand::SeedableRng;
use crate::constants::{MapTile, BOARD_WIDTH};
use crate::constants::MapTile;
use crate::constants::BOARD_CELL_SIZE;
use crate::entity::direction::Direction;
use crate::entity::pacman::Pacman;
use crate::entity::speed::SimpleTickModulator;
use crate::entity::{Entity, MovableEntity, Moving, Renderable};
use crate::map::Map;
use crate::modulation::{SimpleTickModulator, TickModulator};
use crate::texture::animated::AnimatedAtlasTexture;
use crate::texture::atlas::{texture_to_static, AtlasTexture};
use crate::texture::FrameDrawn;
use crate::texture::{
animated::AnimatedTexture, blinking::BlinkingTexture, directional::DirectionalAnimatedTexture, get_atlas_tile,
sprite::SpriteAtlas,
};
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::pixels::Color;
use sdl2::render::Texture;
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
@@ -63,8 +66,9 @@ pub struct Ghost {
pub ghost_type: GhostType,
/// Reference to Pac-Man for targeting
pub pacman: Rc<RefCell<Pacman>>,
pub body_sprite: AnimatedAtlasTexture,
pub eyes_sprite: AnimatedAtlasTexture,
pub texture: DirectionalAnimatedTexture,
pub frightened_texture: BlinkingTexture,
pub eyes_texture: DirectionalAnimatedTexture,
}
impl Ghost {
@@ -72,43 +76,63 @@ impl Ghost {
pub fn new(
ghost_type: GhostType,
starting_position: UVec2,
body_texture: Texture<'_>,
eyes_texture: Texture<'_>,
atlas: Rc<RefCell<SpriteAtlas>>,
map: Rc<RefCell<Map>>,
pacman: Rc<RefCell<Pacman>>,
) -> Ghost {
let color = ghost_type.color();
let mut body_sprite = AnimatedAtlasTexture::new(
unsafe { texture_to_static(body_texture) },
8,
2,
32,
32,
Some(IVec2::new(-4, -4)),
);
body_sprite.set_color_modulation(color.r, color.g, color.b);
let pixel_position = Map::cell_to_pixel(starting_position);
let name = match ghost_type {
GhostType::Blinky => "blinky",
GhostType::Pinky => "pinky",
GhostType::Inky => "inky",
GhostType::Clyde => "clyde",
};
let get = |dir: &str, suffix: &str| get_atlas_tile(&atlas, &format!("ghost/{}/{}_{}.png", name, dir, suffix));
let texture = DirectionalAnimatedTexture::new(
vec![get("up", "a"), get("up", "b")],
vec![get("down", "a"), get("down", "b")],
vec![get("left", "a"), get("left", "b")],
vec![get("right", "a"), get("right", "b")],
25,
);
let frightened_texture = BlinkingTexture::new(
AnimatedTexture::new(
vec![
get_atlas_tile(&atlas, "ghost/frightened/blue_a.png"),
get_atlas_tile(&atlas, "ghost/frightened/blue_b.png"),
],
10,
),
45,
15,
);
let eyes_get = |dir: &str| get_atlas_tile(&atlas, &format!("ghost/eyes/{}.png", dir));
let eyes_texture = DirectionalAnimatedTexture::new(
vec![eyes_get("up")],
vec![eyes_get("down")],
vec![eyes_get("left")],
vec![eyes_get("right")],
0,
);
Ghost {
base: MovableEntity::new(
pixel_position,
starting_position,
Direction::Left,
3,
SimpleTickModulator::new(1.0),
SimpleTickModulator::new(0.9375),
map,
),
mode: GhostMode::Chase,
ghost_type,
pacman,
body_sprite,
eyes_sprite: AnimatedAtlasTexture::new(
unsafe { texture_to_static(eyes_texture) },
1,
4,
32,
32,
Some((-4, -4).into()),
),
texture,
frightened_texture,
eyes_texture,
}
}
@@ -190,8 +214,8 @@ impl Ghost {
// Tunnel wrap: if currently in a tunnel, add the opposite exit as a neighbor
if let Some(MapTile::Tunnel) = tile {
if p.x == 0 {
successors.push((UVec2::new(BOARD_WIDTH - 2, p.y), 1));
} else if p.x == BOARD_WIDTH - 1 {
successors.push((UVec2::new(BOARD_CELL_SIZE.x - 2, p.y), 1));
} else if p.x == BOARD_CELL_SIZE.x - 1 {
successors.push((UVec2::new(1, p.y), 1));
}
}
@@ -220,13 +244,13 @@ impl Ghost {
self.mode = new_mode;
self.base.speed = match new_mode {
GhostMode::Chase => 3,
GhostMode::Scatter => 2,
GhostMode::Frightened => 2,
GhostMode::Eyes => 7,
GhostMode::House => 0,
};
self.base.speed.set_speed(match new_mode {
GhostMode::Chase => 0.9375,
GhostMode::Scatter => 0.85,
GhostMode::Frightened => 0.7,
GhostMode::Eyes => 1.5,
GhostMode::House => 0f32,
});
if should_reverse {
self.base.set_direction_if_valid(self.base.direction.opposite());
@@ -238,10 +262,8 @@ impl Ghost {
// For now, do nothing in the house
return;
}
if self.base.is_grid_aligned() {
self.base.update_cell_position();
if !self.base.handle_tunnel() {
// Pathfinding logic (only if not in tunnel)
let target_tile = self.get_target_tile();
@@ -265,26 +287,20 @@ impl Ghost {
}
}
}
// Don't move if the next tile is a wall
if self.base.is_wall_ahead(None) {
return;
}
}
if self.base.modulation.next() {
self.base.move_forward();
if self.base.is_grid_aligned() {
self.base.update_cell_position();
}
}
self.base.tick(); // Handles wall collision and movement
self.texture.tick();
self.frightened_texture.tick();
self.eyes_texture.tick();
}
}
impl Moving for Ghost {
fn move_forward(&mut self) {
self.base.move_forward();
fn tick_movement(&mut self) {
self.base.tick_movement();
}
fn tick(&mut self) {
self.base.tick();
}
fn update_cell_position(&mut self) {
self.base.update_cell_position();
@@ -307,20 +323,26 @@ impl Moving for Ghost {
}
impl Renderable for Ghost {
fn render(&self, canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
let pos = self.base.base.pixel_position;
self.body_sprite.render(canvas, pos, Direction::Right, None);
// Inline the eye_frame logic here
let eye_frame = if self.mode == GhostMode::Frightened {
4 // Frightened frame
} else {
match self.base.direction {
Direction::Right => 0,
Direction::Up => 1,
Direction::Left => 2,
Direction::Down => 3,
let dir = self.base.direction;
match self.mode {
GhostMode::Frightened => {
let tile = self.frightened_texture.animation.current_tile();
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
self.frightened_texture.render(canvas, dest)
}
};
self.eyes_sprite.render(canvas, pos, Direction::Right, Some(eye_frame));
GhostMode::Eyes => {
let tile = self.eyes_texture.up.get(0).unwrap();
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
self.eyes_texture.render(canvas, dest, dir)
}
_ => {
let tile = self.texture.up.get(0).unwrap();
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
self.texture.render(canvas, dest, dir)
}
}
}
}

View File

@@ -3,14 +3,16 @@ pub mod direction;
pub mod edible;
pub mod ghost;
pub mod pacman;
pub mod speed;
use crate::{
constants::{MapTile, BOARD_OFFSET, BOARD_WIDTH, CELL_SIZE},
entity::direction::Direction,
constants::{MapTile, BOARD_CELL_OFFSET, BOARD_CELL_SIZE, CELL_SIZE},
entity::{direction::Direction, speed::SimpleTickModulator},
map::Map,
modulation::SimpleTickModulator,
};
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
@@ -29,7 +31,19 @@ pub trait Entity {
/// A trait for entities that can move and interact with the map.
pub trait Moving {
fn move_forward(&mut self);
fn tick(&mut self) {
self.base_tick();
}
fn base_tick(&mut self) {
if self.is_grid_aligned() {
self.on_grid_aligned();
}
self.tick_movement();
}
/// Called when the entity is grid-aligned. Default does nothing.
fn on_grid_aligned(&mut self) {}
/// Handles movement and wall collision. Default uses tick logic from MovableEntity.
fn tick_movement(&mut self);
fn update_cell_position(&mut self);
fn next_cell(&self, direction: Option<Direction>) -> IVec2;
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool;
@@ -38,6 +52,22 @@ pub trait Moving {
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool;
}
/// Trait for entities that support queued direction changes.
pub trait QueuedDirection: Moving {
fn next_direction(&self) -> Option<Direction>;
fn set_next_direction(&mut self, dir: Option<Direction>);
/// Handles a requested direction change if possible.
fn handle_direction_change(&mut self) -> bool {
if let Some(next_direction) = self.next_direction() {
if self.set_direction_if_valid(next_direction) {
self.set_next_direction(None);
return true;
}
}
false
}
}
/// A struct for static (non-moving) entities with position only.
pub struct StaticEntity {
pub pixel_position: IVec2,
@@ -57,8 +87,7 @@ impl StaticEntity {
pub struct MovableEntity {
pub base: StaticEntity,
pub direction: Direction,
pub speed: u32,
pub modulation: SimpleTickModulator,
pub speed: SimpleTickModulator,
pub in_tunnel: bool,
pub map: Rc<RefCell<Map>>,
}
@@ -68,15 +97,13 @@ impl MovableEntity {
pixel_position: IVec2,
cell_position: UVec2,
direction: Direction,
speed: u32,
modulation: SimpleTickModulator,
speed: SimpleTickModulator,
map: Rc<RefCell<Map>>,
) -> Self {
Self {
base: StaticEntity::new(pixel_position, cell_position),
direction,
speed,
modulation,
in_tunnel: false,
map,
}
@@ -98,19 +125,28 @@ impl Entity for MovableEntity {
}
impl Moving for MovableEntity {
fn move_forward(&mut self) {
let speed = self.speed as i32;
match self.direction {
Direction::Right => self.base.pixel_position.x += speed,
Direction::Left => self.base.pixel_position.x -= speed,
Direction::Up => self.base.pixel_position.y -= speed,
Direction::Down => self.base.pixel_position.y += speed,
fn tick_movement(&mut self) {
if self.speed.next() {
if !self.is_wall_ahead(None) {
match self.direction {
Direction::Right => self.base.pixel_position.x += 1,
Direction::Left => self.base.pixel_position.x -= 1,
Direction::Up => self.base.pixel_position.y -= 1,
Direction::Down => self.base.pixel_position.y += 1,
}
if self.is_grid_aligned() {
self.update_cell_position();
}
}
}
if self.is_grid_aligned() {
self.update_cell_position();
}
}
fn update_cell_position(&mut self) {
self.base.cell_position = UVec2::new(
(self.base.pixel_position.x as u32 / CELL_SIZE) - BOARD_OFFSET.0,
(self.base.pixel_position.y as u32 / CELL_SIZE) - BOARD_OFFSET.1,
(self.base.pixel_position.x as u32 / CELL_SIZE) - BOARD_CELL_OFFSET.x,
(self.base.pixel_position.y as u32 / CELL_SIZE) - BOARD_CELL_OFFSET.y,
);
}
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
@@ -122,32 +158,23 @@ impl Moving for MovableEntity {
matches!(self.map.borrow().get_tile(next_cell), Some(MapTile::Wall))
}
fn handle_tunnel(&mut self) -> bool {
if !self.in_tunnel {
let current_tile = self
.map
.borrow()
.get_tile(IVec2::new(self.base.cell_position.x as i32, self.base.cell_position.y as i32));
if matches!(current_tile, Some(MapTile::Tunnel)) {
self.in_tunnel = true;
}
let x = self.base.cell_position.x;
let at_left_tunnel = x == 0;
let at_right_tunnel = x == BOARD_CELL_SIZE.x - 1;
if !at_left_tunnel && !at_right_tunnel {
return false;
}
if self.in_tunnel {
if self.base.cell_position.x == 0 {
self.base.cell_position.x = BOARD_WIDTH - 2;
self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position);
self.in_tunnel = false;
true
} else if self.base.cell_position.x == BOARD_WIDTH - 1 {
self.base.cell_position.x = 1;
self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position);
self.in_tunnel = false;
true
} else {
true
}
} else {
false
return true;
}
let new_x = if at_left_tunnel { BOARD_CELL_SIZE.x - 2 } else { 1 };
self.base.cell_position.x = new_x;
self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position);
self.in_tunnel = true;
true
}
fn is_grid_aligned(&self) -> bool {
self.internal_position() == UVec2::ZERO
@@ -172,5 +199,5 @@ impl Entity for StaticEntity {
/// A trait for entities that can be rendered to the screen.
pub trait Renderable {
fn render(&self, canvas: &mut sdl2::render::Canvas<sdl2::video::Window>);
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()>;
}

View File

@@ -1,22 +1,17 @@
//! This module defines the Pac-Man entity, including its behavior and rendering.
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
use sdl2::{
render::{Canvas, Texture},
video::Window,
};
use crate::{
entity::{direction::Direction, Entity, MovableEntity, Moving, Renderable, StaticEntity},
entity::speed::SimpleTickModulator,
entity::{direction::Direction, Entity, MovableEntity, Moving, QueuedDirection, Renderable, StaticEntity},
map::Map,
modulation::{SimpleTickModulator, TickModulator},
texture::animated::AnimatedAtlasTexture,
texture::FrameDrawn,
texture::{animated::AnimatedTexture, directional::DirectionalAnimatedTexture, get_atlas_tile, sprite::SpriteAtlas},
};
use glam::{IVec2, UVec2};
/// The Pac-Man entity.
pub struct Pacman {
/// Shared movement and position fields.
@@ -25,7 +20,9 @@ pub struct Pacman {
pub next_direction: Option<Direction>,
/// Whether Pac-Man is currently stopped.
pub stopped: bool,
pub sprite: AnimatedAtlasTexture,
pub skip_move_tick: bool,
pub texture: DirectionalAnimatedTexture,
pub death_animation: AnimatedTexture,
}
impl Entity for Pacman {
@@ -35,8 +32,12 @@ impl Entity for Pacman {
}
impl Moving for Pacman {
fn move_forward(&mut self) {
self.base.move_forward();
fn tick_movement(&mut self) {
if self.skip_move_tick {
self.skip_move_tick = false;
return;
}
self.base.tick_movement();
}
fn update_cell_position(&mut self) {
self.base.update_cell_position();
@@ -56,48 +57,61 @@ impl Moving for Pacman {
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
self.base.set_direction_if_valid(new_direction)
}
fn on_grid_aligned(&mut self) {
Pacman::update_cell_position(self);
if !<Pacman as Moving>::handle_tunnel(self) {
<Pacman as QueuedDirection>::handle_direction_change(self);
if !self.stopped && <Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = true;
} else if self.stopped && !<Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = false;
}
}
}
}
impl QueuedDirection for Pacman {
fn next_direction(&self) -> Option<Direction> {
self.next_direction
}
fn set_next_direction(&mut self, dir: Option<Direction>) {
self.next_direction = dir;
}
}
impl Pacman {
/// Creates a new `Pacman` instance.
pub fn new(starting_position: UVec2, atlas: Texture<'_>, map: Rc<RefCell<Map>>) -> Pacman {
pub fn new(starting_position: UVec2, atlas: Rc<RefCell<SpriteAtlas>>, map: Rc<RefCell<Map>>) -> Pacman {
let pixel_position = Map::cell_to_pixel(starting_position);
let get = |name: &str| get_atlas_tile(&atlas, name);
Pacman {
base: MovableEntity::new(
pixel_position,
starting_position,
Direction::Right,
3,
SimpleTickModulator::new(1.0),
SimpleTickModulator::new(1f32),
map,
),
next_direction: None,
stopped: false,
sprite: AnimatedAtlasTexture::new(
unsafe { crate::texture::atlas::texture_to_static(atlas) },
2,
3,
32,
32,
Some(IVec2::new(-4, -4)),
skip_move_tick: false,
texture: DirectionalAnimatedTexture::new(
vec![get("pacman/up_a.png"), get("pacman/up_b.png"), get("pacman/full.png")],
vec![get("pacman/down_a.png"), get("pacman/down_b.png"), get("pacman/full.png")],
vec![get("pacman/left_a.png"), get("pacman/left_b.png"), get("pacman/full.png")],
vec![get("pacman/right_a.png"), get("pacman/right_b.png"), get("pacman/full.png")],
8,
),
death_animation: AnimatedTexture::new(
(0..=10)
.map(|i| get_atlas_tile(&atlas, &format!("pacman/death/{}.png", i)))
.collect(),
5,
),
}
}
/// Handles a requested direction change.
fn handle_direction_change(&mut self) -> bool {
match self.next_direction {
None => return false,
Some(next_direction) => {
if <Pacman as Moving>::set_direction_if_valid(self, next_direction) {
self.next_direction = None;
return true;
}
}
}
false
}
/// Returns the internal position of Pac-Man, rounded down to the nearest even number.
fn internal_position_even(&self) -> UVec2 {
let pos = self.base.internal_position();
@@ -105,35 +119,25 @@ impl Pacman {
}
pub fn tick(&mut self) {
let can_change = self.internal_position_even() == UVec2::ZERO;
if can_change {
<Pacman as Moving>::update_cell_position(self);
if !<Pacman as Moving>::handle_tunnel(self) {
self.handle_direction_change();
if !self.stopped && <Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = true;
} else if self.stopped && !<Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = false;
}
}
}
if !self.stopped && self.base.modulation.next() {
<Pacman as Moving>::move_forward(self);
if self.internal_position_even() == UVec2::ZERO {
<Pacman as Moving>::update_cell_position(self);
}
}
<Pacman as Moving>::tick(self);
self.texture.tick();
}
}
impl Renderable for Pacman {
fn render(&self, canvas: &mut Canvas<Window>) {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
let pos = self.base.base.pixel_position;
let dir = self.base.direction;
// Center the 16x16 sprite on the 8x8 cell by offsetting by -4
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, 16, 16);
if self.stopped {
self.sprite.render(canvas, pos, dir, Some(2));
// When stopped, show the full sprite (mouth open)
self.texture.render_stopped(canvas, dest, dir)?;
} else {
self.sprite.render(canvas, pos, dir, None);
self.texture.render(canvas, dest, dir)?;
}
return Ok(());
}
}

View File

@@ -22,34 +22,35 @@ pub trait TickModulator {
fn new(percent: f32) -> Self;
/// Returns whether or not the operation should be performed on this tick.
fn next(&mut self) -> bool;
fn set_speed(&mut self, speed: f32);
}
/// A simple tick modulator that skips every Nth tick.
pub struct SimpleTickModulator {
tick_count: u32,
ticks_left: u32,
accumulator: f32,
pixels_per_tick: f32,
}
// TODO: Add tests for the tick modulator to ensure that it is working correctly.
// TODO: Look into average precision and binary code modulation strategies to see
// if they would be a better fit for this use case.
impl TickModulator for SimpleTickModulator {
fn new(percent: f32) -> Self {
let ticks_required: u32 = (1f32 / (1f32 - percent)).round() as u32;
SimpleTickModulator {
tick_count: ticks_required,
ticks_left: ticks_required,
impl SimpleTickModulator {
pub fn new(pixels_per_tick: f32) -> Self {
Self {
accumulator: 0f32,
pixels_per_tick: pixels_per_tick * 0.47,
}
}
fn next(&mut self) -> bool {
if self.ticks_left == 0 {
self.ticks_left = self.tick_count;
return false;
pub fn set_speed(&mut self, pixels_per_tick: f32) {
self.pixels_per_tick = pixels_per_tick;
}
pub fn next(&mut self) -> bool {
self.accumulator += self.pixels_per_tick;
if self.accumulator >= 1f32 {
self.accumulator -= 1f32;
true
} else {
false
}
self.ticks_left -= 1;
true
}
}

View File

@@ -3,15 +3,15 @@ use std::cell::RefCell;
use std::ops::Not;
use std::rc::Rc;
use glam::{IVec2, UVec2};
use anyhow::Result;
use glam::UVec2;
use rand::rngs::SmallRng;
use rand::seq::IteratorRandom;
use rand::SeedableRng;
use sdl2::image::LoadTexture;
use sdl2::keyboard::Keycode;
use sdl2::render::{Texture, TextureCreator};
use sdl2::rwops::RWops;
use sdl2::ttf::Font;
use sdl2::video::WindowContext;
use sdl2::{pixels::Color, render::Canvas, video::Window};
@@ -25,150 +25,92 @@ use crate::entity::edible::{reconstruct_edibles, Edible, EdibleKind};
use crate::entity::pacman::Pacman;
use crate::entity::Renderable;
use crate::map::Map;
use crate::texture::atlas::{texture_to_static, AtlasTexture};
use crate::texture::animated::AnimatedTexture;
use crate::texture::blinking::BlinkingTexture;
use crate::texture::FrameDrawn;
use crate::texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas};
use crate::texture::text::TextTexture;
use crate::texture::{get_atlas_tile, sprite};
/// The main game state.
///
/// This struct contains all the information necessary to run the game, including
/// the canvas, textures, fonts, game objects, and the current score.
/// Contains all the information necessary to run the game, including
/// the game state, rendering resources, and audio.
pub struct Game {
canvas: &'static mut Canvas<Window>,
map_texture: Texture<'static>,
pellet_texture: Rc<Box<dyn FrameDrawn>>,
power_pellet_texture: Rc<RefCell<BlinkingTexture>>,
font: Font<'static, 'static>,
// Game state
pacman: Rc<RefCell<Pacman>>,
map: Rc<RefCell<Map>>,
debug_mode: DebugMode,
score: u32,
pub audio: Audio,
blinky: Blinky,
edibles: Vec<Edible>,
map: Rc<RefCell<Map>>,
score: u32,
debug_mode: DebugMode,
// FPS tracking
fps_1s: f64,
fps_10s: f64,
// Rendering resources
atlas: Rc<RefCell<SpriteAtlas>>,
map_texture: AtlasTile,
text_texture: TextTexture,
// Audio
pub audio: Audio,
}
impl Game {
/// Creates a new `Game` instance.
///
/// # Arguments
///
/// * `canvas` - The SDL canvas to render to.
/// * `texture_creator` - The SDL texture creator.
/// * `ttf_context` - The SDL TTF context.
/// * `_audio_subsystem` - The SDL audio subsystem (currently unused).
pub fn new(
canvas: &'static mut Canvas<Window>,
texture_creator: &TextureCreator<WindowContext>,
ttf_context: &sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &sdl2::AudioSubsystem,
) -> Game {
let map = Rc::new(RefCell::new(Map::new(RAW_BOARD)));
// Load Pacman texture from asset API
let pacman_bytes = get_asset_bytes(Asset::Pacman).expect("Failed to load asset");
let pacman_atlas = texture_creator
.load_texture_bytes(&pacman_bytes)
.expect("Could not load pacman texture from asset API");
let pacman = Rc::new(RefCell::new(Pacman::new(UVec2::new(1, 1), pacman_atlas, Rc::clone(&map))));
// Load ghost textures
let ghost_body_bytes = get_asset_bytes(Asset::GhostBody).expect("Failed to load asset");
let ghost_body = texture_creator
.load_texture_bytes(&ghost_body_bytes)
.expect("Could not load ghost body texture from asset API");
let ghost_eyes_bytes = get_asset_bytes(Asset::GhostEyes).expect("Failed to load asset");
let ghost_eyes = texture_creator
.load_texture_bytes(&ghost_eyes_bytes)
.expect("Could not load ghost eyes texture from asset API");
// Create Blinky
let blinky = Blinky::new(
UVec2::new(13, 11), // Starting position just above ghost house
ghost_body,
ghost_eyes,
let atlas_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset");
let atlas_texture = unsafe {
let texture = texture_creator
.load_texture_bytes(&atlas_bytes)
.expect("Could not load atlas texture from asset API");
sprite::texture_to_static(texture)
};
let atlas_json = get_asset_bytes(Asset::AtlasJson).expect("Failed to load asset");
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json).expect("Could not parse atlas JSON");
let atlas = Rc::new(RefCell::new(SpriteAtlas::new(atlas_texture, atlas_mapper)));
let pacman = Rc::new(RefCell::new(Pacman::new(
UVec2::new(1, 1),
Rc::clone(&atlas),
Rc::clone(&map),
Rc::clone(&pacman),
);
// Load pellet texture from asset API
let pellet_bytes = get_asset_bytes(Asset::Pellet).expect("Failed to load asset");
let power_pellet_bytes = get_asset_bytes(Asset::Energizer).expect("Failed to load asset");
let pellet_texture: Rc<Box<dyn FrameDrawn>> = Rc::new(Box::new(AtlasTexture::new(
unsafe {
texture_to_static(
texture_creator
.load_texture_bytes(&pellet_bytes)
.expect("Could not load pellet texture from asset API"),
)
},
1,
24,
24,
None,
)));
let power_pellet_texture = Rc::new(RefCell::new(BlinkingTexture::new(
texture_creator
.load_texture_bytes(&power_pellet_bytes)
.expect("Could not load power pellet texture from asset API"),
1,
24,
24,
None,
30, // on_ticks
9, // off_ticks
)));
// Load map texture from asset API
let map_bytes = get_asset_bytes(Asset::Map).expect("Failed to load asset");
let mut map_texture = texture_creator
.load_texture_bytes(&map_bytes)
.expect("Could not load map texture from asset API");
map_texture.set_color_mod(0, 0, 255);
let map_texture = unsafe { texture_to_static(map_texture) };
let blinky = Blinky::new(UVec2::new(13, 11), Rc::clone(&atlas), Rc::clone(&map), Rc::clone(&pacman));
let map_texture = get_atlas_tile(&atlas, "maze/full.png");
let edibles = reconstruct_edibles(
Rc::clone(&map),
Rc::clone(&pellet_texture),
Rc::clone(&power_pellet_texture),
Rc::clone(&pellet_texture), // placeholder for fruit sprite
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/pellet.png")], 0),
BlinkingTexture::new(
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/energizer.png")], 0),
17,
17,
),
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "edible/cherry.png")], 0),
);
// Load font from asset API
let font = {
let font_bytes = get_asset_bytes(Asset::FontKonami).expect("Failed to load asset").into_owned();
let font_bytes_static: &'static [u8] = Box::leak(font_bytes.into_boxed_slice());
let font_rwops = RWops::from_bytes(font_bytes_static).expect("Failed to create RWops for font");
// Leak the ttf_context to get a 'static lifetime
let ttf_context_static: &'static sdl2::ttf::Sdl2TtfContext = unsafe { std::mem::transmute(ttf_context) };
ttf_context_static
.load_font_from_rwops(font_rwops, 24)
.expect("Could not load font from asset API")
};
let text_texture = TextTexture::new(Rc::clone(&atlas), 1.0);
let audio = Audio::new();
Game {
canvas,
pacman,
debug_mode: DebugMode::None,
map,
map_texture,
pellet_texture,
power_pellet_texture,
font,
score: 0,
audio,
blinky,
edibles,
map,
score: 0,
debug_mode: DebugMode::None,
atlas,
map_texture,
text_texture,
audio,
fps_1s: 0.0,
fps_10s: 0.0,
}
}
/// Handles a keyboard event.
///
/// # Arguments
///
/// * `keycode` - The keycode of the key that was pressed.
pub fn keyboard_event(&mut self, keycode: Keycode) {
// Change direction
let direction = Direction::from_keycode(keycode);
@@ -209,6 +151,12 @@ impl Game {
self.score += points;
}
/// Updates the FPS tracking values.
pub fn update_fps(&mut self, fps_1s: f64, fps_10s: f64) {
self.fps_1s = fps_1s;
self.fps_10s = fps_10s;
}
/// Resets the game to its initial state.
pub fn reset(&mut self) {
// Reset the map to restore all pellets
@@ -249,22 +197,34 @@ impl Game {
self.edibles = reconstruct_edibles(
Rc::clone(&self.map),
Rc::clone(&self.pellet_texture),
Rc::clone(&self.power_pellet_texture),
Rc::clone(&self.pellet_texture), // placeholder for fruit sprite
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/pellet.png")], 0),
BlinkingTexture::new(
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/energizer.png")], 0),
12,
12,
),
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "edible/cherry.png")], 0),
);
}
/// Advances the game by one tick.
pub fn tick(&mut self) {
// Advance animation frames for Pacman and Blinky
self.pacman.borrow_mut().sprite.tick();
self.blinky.body_sprite.tick();
self.blinky.eyes_sprite.tick();
// Advance blinking for power pellets
self.power_pellet_texture.borrow_mut().tick();
self.tick_entities();
self.handle_edible_collisions();
self.tick_entities();
}
fn tick_entities(&mut self) {
self.pacman.borrow_mut().tick();
self.blinky.tick();
for edible in self.edibles.iter_mut() {
if let EdibleKind::PowerPellet = edible.kind {
if let crate::entity::edible::EdibleSprite::PowerPellet(texture) = &mut edible.sprite {
texture.tick();
}
}
}
}
fn handle_edible_collisions(&mut self) {
let pacman = self.pacman.borrow();
let mut eaten_indices = vec![];
for (i, edible) in self.edibles.iter().enumerate() {
@@ -272,7 +232,7 @@ impl Game {
eaten_indices.push(i);
}
}
drop(pacman); // Release immutable borrow before mutably borrowing self
drop(pacman);
for &i in eaten_indices.iter().rev() {
let edible = &self.edibles[i];
match edible.kind {
@@ -290,94 +250,84 @@ impl Game {
}
}
self.edibles.remove(i);
// Set Pac-Man to skip the next movement tick
self.pacman.borrow_mut().skip_move_tick = true;
}
self.pacman.borrow_mut().tick();
self.blinky.tick();
}
/// Draws the entire game to the canvas.
pub fn draw(&mut self) {
// Clear the screen (black)
self.canvas.set_draw_color(Color::RGB(0, 0, 0));
self.canvas.clear();
// Render the map
self.canvas
.copy(&self.map_texture, None, None)
.expect("Could not render texture on canvas");
// Render all edibles
for edible in &self.edibles {
edible.render(self.canvas);
}
// Render Pac-Man
self.pacman.borrow().render(self.canvas);
// Render ghost
self.blinky.render(self.canvas);
// Render score
self.render_ui();
// Draw the debug grid
match self.debug_mode {
DebugMode::Grid => {
DebugRenderer::draw_debug_grid(self.canvas, &self.map.borrow(), self.pacman.borrow().base.base.cell_position);
let next_cell = <Pacman as crate::entity::Moving>::next_cell(&*self.pacman.borrow(), None);
DebugRenderer::draw_next_cell(self.canvas, &self.map.borrow(), next_cell.as_uvec2());
}
DebugMode::ValidPositions => {
DebugRenderer::draw_valid_positions(self.canvas, &mut self.map.borrow_mut());
}
DebugMode::Pathfinding => {
DebugRenderer::draw_pathfinding(self.canvas, &self.blinky, &self.map.borrow());
}
DebugMode::None => {}
}
// Present the canvas
self.canvas.present();
/// Draws the entire game to the canvas using a backbuffer.
pub fn draw(&mut self, window_canvas: &mut Canvas<Window>, backbuffer: &mut Texture) -> Result<()> {
window_canvas
.with_texture_canvas(backbuffer, |texture_canvas| {
let this = self as *mut Self;
let this = unsafe { &mut *this };
texture_canvas.set_draw_color(Color::BLACK);
texture_canvas.clear();
this.map.borrow_mut().render(texture_canvas, &mut this.map_texture);
for edible in this.edibles.iter_mut() {
let _ = edible.render(texture_canvas);
}
let _ = this.pacman.borrow_mut().render(texture_canvas);
let _ = this.blinky.render(texture_canvas);
match this.debug_mode {
DebugMode::Grid => {
DebugRenderer::draw_debug_grid(
texture_canvas,
&this.map.borrow(),
this.pacman.borrow().base.base.cell_position,
);
let next_cell = <Pacman as crate::entity::Moving>::next_cell(&*this.pacman.borrow(), None);
DebugRenderer::draw_next_cell(texture_canvas, &this.map.borrow(), next_cell.as_uvec2());
}
DebugMode::ValidPositions => {
DebugRenderer::draw_valid_positions(texture_canvas, &mut this.map.borrow_mut());
}
DebugMode::Pathfinding => {
DebugRenderer::draw_pathfinding(texture_canvas, &this.blinky, &this.map.borrow());
}
DebugMode::None => {}
}
})
.map_err(|e| anyhow::anyhow!(format!("Failed to render to backbuffer: {e}")))
}
pub fn present_backbuffer(&mut self, canvas: &mut Canvas<Window>, backbuffer: &Texture) -> Result<()> {
canvas.set_draw_color(Color::BLACK);
canvas.clear();
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
self.render_ui_on(canvas);
canvas.present();
Ok(())
}
/// Renders the user interface, including the score and lives.
fn render_ui(&mut self) {
fn render_ui_on<C: sdl2::render::RenderTarget>(&mut self, canvas: &mut sdl2::render::Canvas<C>) {
let lives = 3;
let score_text = format!("{:02}", self.score);
let x_offset = 12;
let x_offset = 4;
let y_offset = 2;
let lives_offset = 3;
let score_offset = 7 - (score_text.len() as i32);
let gap_offset = 6;
// Render the score and high score
self.render_text(
self.text_texture.set_scale(1.0);
let _ = self.text_texture.render(
canvas,
&format!("{lives}UP HIGH SCORE "),
IVec2::new(24 * lives_offset + x_offset, y_offset),
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
Color::WHITE,
);
self.render_text(
let _ = self.text_texture.render(
canvas,
&score_text,
IVec2::new(24 * score_offset + x_offset, 24 + y_offset + gap_offset),
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
Color::WHITE,
);
}
/// Renders text to the screen at the given position.
fn render_text(&mut self, text: &str, position: IVec2, color: Color) {
let surface = self.font.render(text).blended(color).expect("Could not render text surface");
let texture_creator = self.canvas.texture_creator();
let texture = texture_creator
.create_texture_from_surface(&surface)
.expect("Could not create texture from surface");
let query = texture.query();
let dst_rect = sdl2::rect::Rect::new(position.x, position.y, query.width, query.height);
self.canvas
.copy(&texture, None, Some(dst_rect))
.expect("Could not render text texture");
// Display FPS information in top-left corner
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);
// self.render_text_on(
// canvas,
// &*texture_creator,
// &fps_text,
// IVec2::new(10, 10),
// Color::RGB(255, 255, 0), // Yellow color for FPS display
// );
}
}

View File

@@ -1,6 +1,6 @@
#![windows_subsystem = "windows"]
use crate::constants::{WINDOW_HEIGHT, WINDOW_WIDTH};
use crate::constants::{CANVAS_SIZE, SCALE};
use crate::game::Game;
use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode;
@@ -62,7 +62,6 @@ mod entity;
mod game;
mod helper;
mod map;
mod modulation;
mod texture;
#[cfg(not(target_os = "emscripten"))]
@@ -75,16 +74,6 @@ fn sleep(value: Duration) {
emscripten::emscripten::sleep(value.as_millis() as u32);
}
#[cfg(target_os = "emscripten")]
fn now() -> std::time::Instant {
std::time::Instant::now() + std::time::Duration::from_millis(emscripten::emscripten::now() as u64)
}
#[cfg(not(target_os = "emscripten"))]
fn now() -> std::time::Instant {
std::time::Instant::now()
}
/// The main entry point of the application.
///
/// This function initializes SDL, the window, the game state, and then enters
@@ -101,6 +90,9 @@ pub fn main() {
let audio_subsystem = sdl_context.audio().unwrap();
let ttf_context = sdl2::ttf::init().unwrap();
// Set nearest-neighbor scaling for pixelated rendering
sdl2::hint::set("SDL_RENDER_SCALE_QUALITY", "nearest");
// Setup tracing
let subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten")))
@@ -111,7 +103,12 @@ pub fn main() {
tracing::subscriber::set_global_default(subscriber).expect("Could not set global default");
let window = video_subsystem
.window("Pac-Man", WINDOW_WIDTH, WINDOW_HEIGHT)
.window(
"Pac-Man",
(CANVAS_SIZE.x as f32 * SCALE).round() as u32,
(CANVAS_SIZE.y as f32 * SCALE).round() as u32,
)
.resizable()
.position_centered()
.build()
.expect("Could not initialize window");
@@ -119,18 +116,29 @@ pub fn main() {
let mut canvas = window.into_canvas().build().expect("Could not build canvas");
canvas
.set_logical_size(WINDOW_WIDTH, WINDOW_HEIGHT)
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.expect("Could not set logical size");
let texture_creator = canvas.texture_creator();
let canvas_static: &'static mut sdl2::render::Canvas<sdl2::video::Window> = Box::leak(Box::new(canvas));
let mut game = Game::new(canvas_static, &texture_creator, &ttf_context, &audio_subsystem);
let texture_creator_static: &'static sdl2::render::TextureCreator<sdl2::video::WindowContext> =
Box::leak(Box::new(texture_creator));
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem);
game.audio.set_mute(cfg!(debug_assertions));
// Create a backbuffer texture for drawing
let mut backbuffer = texture_creator_static
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.expect("Could not create backbuffer texture");
let mut event_pump = sdl_context.event_pump().expect("Could not get SDL EventPump");
// Initial draw and tick
game.draw();
if let Err(e) = game.draw(&mut canvas, &mut backbuffer) {
eprintln!("Initial draw failed: {}", e);
}
if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) {
eprintln!("Initial present failed: {}", e);
}
game.tick();
// The target time for each frame of the game loop (60 FPS).
@@ -140,13 +148,54 @@ pub fn main() {
// Whether the window is currently shown.
let mut shown = false;
event!(
tracing::Level::INFO,
"Starting game loop ({:.3}ms)",
loop_time.as_secs_f32() * 1000.0
);
let mut main_loop = || {
// FPS tracking
let mut frame_times_1s = Vec::new();
let mut frame_times_10s = Vec::new();
let mut last_frame_time = Instant::now();
event!(tracing::Level::INFO, "Starting game loop ({:?})", loop_time);
let mut main_loop = move || {
let start = Instant::now();
let current_frame_time = Instant::now();
let frame_duration = current_frame_time.duration_since(last_frame_time);
last_frame_time = current_frame_time;
// Update FPS tracking
frame_times_1s.push(frame_duration);
frame_times_10s.push(frame_duration);
// Keep only last 1 second of data (assuming 60 FPS = ~60 frames)
while frame_times_1s.len() > 60 {
frame_times_1s.remove(0);
}
// Keep only last 10 seconds of data
while frame_times_10s.len() > 600 {
frame_times_10s.remove(0);
}
// Calculate FPS averages
let fps_1s = if !frame_times_1s.is_empty() {
let total_time: Duration = frame_times_1s.iter().sum();
if total_time > Duration::ZERO {
frame_times_1s.len() as f64 / total_time.as_secs_f64()
} else {
0.0
}
} else {
0.0
};
let fps_10s = if !frame_times_10s.is_empty() {
let total_time: Duration = frame_times_10s.iter().sum();
if total_time > Duration::ZERO {
frame_times_10s.len() as f64 / total_time.as_secs_f64()
} else {
0.0
}
} else {
0.0
};
// TODO: Fix key repeat delay issues by using a queue for keyboard events.
// This would allow for instant key repeat without being affected by the
@@ -191,9 +240,17 @@ pub fn main() {
// statistic gathering and other background tasks.
if !paused {
game.tick();
game.draw();
if let Err(e) = game.draw(&mut canvas, &mut backbuffer) {
eprintln!("Failed to draw game: {}", e);
}
if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) {
eprintln!("Failed to present backbuffer: {}", e);
}
}
// Update game with FPS data
game.update_fps(fps_1s, fps_10s);
if start.elapsed() < loop_time {
let time = loop_time.saturating_sub(start.elapsed());
if time != Duration::ZERO {

View File

@@ -3,10 +3,13 @@ use rand::rngs::SmallRng;
use rand::seq::IteratorRandom;
use rand::SeedableRng;
use crate::constants::{MapTile, BOARD_OFFSET, CELL_SIZE};
use crate::constants::{BOARD_HEIGHT, BOARD_WIDTH};
use crate::constants::{MapTile, BOARD_CELL_OFFSET, BOARD_CELL_SIZE, BOARD_PIXEL_OFFSET, BOARD_PIXEL_SIZE, CELL_SIZE};
use crate::texture::sprite::AtlasTile;
use glam::{IVec2, UVec2};
use once_cell::sync::OnceCell;
use sdl2::rect::Rect;
use sdl2::render::Canvas;
use sdl2::video::Window;
use std::collections::{HashSet, VecDeque};
/// The game map.
@@ -15,9 +18,9 @@ use std::collections::{HashSet, VecDeque};
/// the original map, which can be used to reset the map to its initial state.
pub struct Map {
/// The current state of the map.
current: [[MapTile; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize],
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
/// The default state of the map.
default: [[MapTile; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize],
default: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
}
impl Map {
@@ -26,11 +29,11 @@ impl Map {
/// # Arguments
///
/// * `raw_board` - A 2D array of characters representing the board layout.
pub fn new(raw_board: [&str; BOARD_HEIGHT as usize]) -> Map {
let mut map = [[MapTile::Empty; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize];
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map {
let mut map = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
for (y, line) in raw_board.iter().enumerate().take(BOARD_HEIGHT as usize) {
for (x, character) in line.chars().enumerate().take(BOARD_WIDTH as usize) {
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
let tile = match character {
'#' => MapTile::Wall,
'.' => MapTile::Pellet,
@@ -54,8 +57,8 @@ impl Map {
/// Resets the map to its original state.
pub fn reset(&mut self) {
// Restore the map to its original state
for (x, col) in self.current.iter_mut().enumerate().take(BOARD_WIDTH as usize) {
for (y, cell) in col.iter_mut().enumerate().take(BOARD_HEIGHT as usize) {
for (x, col) in self.current.iter_mut().enumerate().take(BOARD_CELL_SIZE.x as usize) {
for (y, cell) in col.iter_mut().enumerate().take(BOARD_CELL_SIZE.y as usize) {
*cell = self.default[x][y];
}
}
@@ -70,7 +73,7 @@ impl Map {
let x = cell.x as usize;
let y = cell.y as usize;
if x >= BOARD_WIDTH as usize || y >= BOARD_HEIGHT as usize {
if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize {
return None;
}
@@ -87,7 +90,7 @@ impl Map {
let x = cell.x as usize;
let y = cell.y as usize;
if x >= BOARD_WIDTH as usize || y >= BOARD_HEIGHT as usize {
if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize {
return false;
}
@@ -101,7 +104,10 @@ impl Map {
///
/// * `cell` - The cell coordinates, in grid coordinates.
pub fn cell_to_pixel(cell: UVec2) -> IVec2 {
IVec2::new((cell.x * CELL_SIZE) as i32, ((cell.y + BOARD_OFFSET.1) * CELL_SIZE) as i32)
IVec2::new(
(cell.x * CELL_SIZE) as i32,
((cell.y + BOARD_CELL_OFFSET.y) * CELL_SIZE) as i32,
)
}
/// Returns a reference to a cached vector of all valid playable positions in the maze.
@@ -114,8 +120,8 @@ impl Map {
}
// Find a random starting pellet
let mut pellet_positions = vec![];
for (x, col) in self.current.iter().enumerate().take(BOARD_WIDTH as usize) {
for (y, &cell) in col.iter().enumerate().take(BOARD_HEIGHT as usize) {
for (x, col) in self.current.iter().enumerate().take(BOARD_CELL_SIZE.x as usize) {
for (y, &cell) in col.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
match cell {
Pellet | PowerPellet => pellet_positions.push(UVec2::new(x as u32, y as u32)),
_ => {}
@@ -141,7 +147,7 @@ impl Map {
Empty | Pellet | PowerPellet => {
for offset in [IVec2::new(-1, 0), IVec2::new(1, 0), IVec2::new(0, -1), IVec2::new(0, 1)] {
let neighbor = (pos.as_ivec2() + offset).as_uvec2();
if neighbor.x < BOARD_WIDTH && neighbor.y < BOARD_HEIGHT {
if neighbor.x < BOARD_CELL_SIZE.x && neighbor.y < BOARD_CELL_SIZE.y {
let neighbor_tile = self.current[neighbor.x as usize][neighbor.y as usize];
if matches!(neighbor_tile, Empty | Pellet | PowerPellet) {
queue.push_back(neighbor);
@@ -156,4 +162,15 @@ impl Map {
result.sort_unstable_by_key(|v| (v.x, v.y));
CACHE.get_or_init(|| result)
}
/// Renders the map to the given canvas using the provided map texture.
pub fn render(&self, canvas: &mut Canvas<Window>, map_texture: &mut AtlasTile) {
let dest = Rect::new(
BOARD_PIXEL_OFFSET.x as i32,
BOARD_PIXEL_OFFSET.y as i32,
BOARD_PIXEL_SIZE.x,
BOARD_PIXEL_SIZE.y,
);
let _ = map_texture.render(canvas, dest);
}
}

View File

@@ -1,6 +1,5 @@
//! This module provides a simple animation and atlas system for textures.
use anyhow::Result;
use glam::IVec2;
use sdl2::render::WindowCanvas;
use crate::texture::sprite::AtlasTile;
@@ -35,15 +34,15 @@ impl AnimatedTexture {
self.ticker += 1;
}
pub fn current_tile(&self) -> &AtlasTile {
pub fn current_tile(&mut self) -> &mut AtlasTile {
if self.ticks_per_frame == 0 {
return &self.frames[0];
return &mut self.frames[0];
}
let frame_index = (self.ticker / self.ticks_per_frame) as usize % self.frames.len();
&self.frames[frame_index]
&mut self.frames[frame_index]
}
pub fn render(&self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
let tile = self.current_tile();
tile.render(canvas, dest)
}

View File

@@ -1,6 +1,5 @@
//! A texture that blinks on/off for a specified number of ticks.
use anyhow::Result;
use glam::IVec2;
use sdl2::render::WindowCanvas;
use crate::texture::animated::AnimatedTexture;
@@ -39,7 +38,7 @@ impl BlinkingTexture {
}
/// Renders the blinking texture.
pub fn render(&self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
if self.visible {
self.animation.render(canvas, dest)
} else {

View File

@@ -2,7 +2,6 @@
use crate::entity::direction::Direction;
use crate::texture::sprite::AtlasTile;
use anyhow::Result;
use glam::IVec2;
use sdl2::render::WindowCanvas;
pub struct DirectionalAnimatedTexture {
@@ -38,14 +37,28 @@ impl DirectionalAnimatedTexture {
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> {
let frames = match direction {
Direction::Up => &self.up,
Direction::Down => &self.down,
Direction::Left => &self.left,
Direction::Right => &self.right,
Direction::Up => &mut self.up,
Direction::Down => &mut self.down,
Direction::Left => &mut self.left,
Direction::Right => &mut self.right,
};
let frame_index = (self.ticker / self.ticks_per_frame) as usize % frames.len();
let tile = &frames[frame_index];
let tile = &mut frames[frame_index];
tile.render(canvas, dest)
}
pub fn render_stopped(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> {
let frames = match direction {
Direction::Up => &mut self.up,
Direction::Down => &mut self.down,
Direction::Left => &mut self.left,
Direction::Right => &mut self.right,
};
// Show the last frame (full sprite) when stopped
let tile = &mut frames[1];
tile.render(canvas, dest)
}

View File

@@ -1,16 +1,14 @@
use glam::IVec2;
use sdl2::{render::Canvas, video::Window};
use std::cell::RefCell;
use std::rc::Rc;
use crate::entity::direction::Direction;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
pub mod animated;
pub mod blinking;
pub mod directional;
pub mod sprite;
pub mod text;
pub fn get_atlas_tile(atlas: &Rc<SpriteAtlas>, name: &str) -> AtlasTile {
pub fn get_atlas_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> AtlasTile {
SpriteAtlas::get_tile(atlas, name).unwrap_or_else(|| panic!("Could not find tile {}", name))
}

View File

@@ -1,8 +1,10 @@
use anyhow::Result;
use glam::U16Vec2;
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::{Texture, WindowCanvas};
use sdl2::render::{Canvas, RenderTarget, Texture};
use serde::Deserialize;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
@@ -21,15 +23,30 @@ pub struct MapperFrame {
#[derive(Clone)]
pub struct AtlasTile {
pub atlas: Rc<SpriteAtlas>,
pub atlas: Rc<RefCell<SpriteAtlas>>,
pub pos: U16Vec2,
pub size: U16Vec2,
pub color: Option<Color>,
}
impl AtlasTile {
pub fn render(&self, canvas: &mut WindowCanvas, dest: Rect) -> Result<()> {
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect) -> Result<()> {
let color = self
.color
.unwrap_or(self.atlas.borrow().default_color.unwrap_or(Color::WHITE));
self.render_with_color(canvas, dest, color)
}
pub fn render_with_color<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect, color: Color) -> Result<()> {
let src = Rect::new(self.pos.x as i32, self.pos.y as i32, self.size.x as u32, self.size.y as u32);
canvas.copy(&self.atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
let mut atlas = self.atlas.borrow_mut();
if atlas.last_modulation != Some(color) {
atlas.texture.set_color_mod(color.r, color.g, color.b);
atlas.last_modulation = Some(color);
}
canvas.copy(&atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
Ok(())
}
}
@@ -37,6 +54,8 @@ impl AtlasTile {
pub struct SpriteAtlas {
texture: Texture<'static>,
tiles: HashMap<String, MapperFrame>,
default_color: Option<Color>,
last_modulation: Option<Color>,
}
impl SpriteAtlas {
@@ -44,16 +63,28 @@ impl SpriteAtlas {
Self {
texture,
tiles: mapper.frames,
default_color: None,
last_modulation: None,
}
}
pub fn get_tile(atlas: &Rc<SpriteAtlas>, name: &str) -> Option<AtlasTile> {
atlas.tiles.get(name).map(|frame| AtlasTile {
atlas: atlas.clone(),
pub fn get_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> Option<AtlasTile> {
let atlas_ref = atlas.borrow();
atlas_ref.tiles.get(name).map(|frame| AtlasTile {
atlas: Rc::clone(atlas),
pos: U16Vec2::new(frame.x, frame.y),
size: U16Vec2::new(frame.width, frame.height),
color: None,
})
}
pub fn set_color(&mut self, color: Color) {
self.default_color = Some(color);
}
pub fn texture(&self) -> &Texture<'static> {
&self.texture
}
}
pub unsafe fn texture_to_static<'a>(texture: Texture<'a>) -> Texture<'static> {

156
src/texture/text.rs Normal file
View File

@@ -0,0 +1,156 @@
//! This module provides text rendering using the texture atlas.
//!
//! The TextTexture system renders text from the atlas using character mapping.
//! It supports a subset of characters with special handling for characters that
//! can't be used in filenames.
//!
//! # Example Usage
//!
//! ```rust
//! use crate::texture::text::TextTexture;
//! use std::rc::Rc;
//!
//! // Create a text texture with 1.0 scale (8x8 pixels per character)
//! let mut text_renderer = TextTexture::new(atlas.clone(), 1.0);
//!
//! // Render text at position (100, 50)
//! text_renderer.render(canvas, "PAC-MAN", glam::UVec2::new(100, 50))?;
//!
//! // Change scale for larger text
//! text_renderer.set_scale(2.0);
//! text_renderer.render(canvas, "SCORE: 1000", glam::UVec2::new(50, 100))?;
//!
//! // Calculate text width for positioning
//! let width = text_renderer.text_width("GAME OVER");
//! let height = text_renderer.text_height();
//! ```
//!
//! # Supported Characters
//!
//! - Letters: A-Z, a-z
//! - Numbers: 0-9
//! - Common symbols: ! ? . , : ; - _ ( ) [ ] { } < > = + * / \ | & @ # $ % ^ ~ ` ' "
//! - Space character
//!
//! # Character Mapping
//!
//! Most characters use their literal name (e.g., "A.png", "1.png").
//! Special characters use alternative names:
//! - `"` → "text/_double_quote.png"
//! - `'` → "text/_single_quote.png"
//! - `\` → "text/\\backslash.png"
//! - ` ` (space) → "text/space.png"
//!
//! # Memory Optimization
//!
//! The system caches character tiles in a HashMap to avoid repeated
//! atlas lookups. Only tiles for used characters are stored in memory.
use anyhow::Result;
use glam::UVec2;
use sdl2::pixels::Color;
use sdl2::render::{Canvas, RenderTarget};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
/// A text texture that renders characters from the atlas.
pub struct TextTexture {
atlas: Rc<RefCell<SpriteAtlas>>,
char_map: HashMap<char, AtlasTile>,
scale: f32,
}
impl TextTexture {
/// Creates a new text texture with the given atlas and scale.
pub fn new(atlas: Rc<RefCell<SpriteAtlas>>, scale: f32) -> Self {
Self {
atlas,
char_map: HashMap::new(),
scale,
}
}
/// Maps a character to its atlas tile, handling special characters.
fn get_char_tile(&mut self, c: char) -> Option<AtlasTile> {
if let Some(tile) = self.char_map.get(&c) {
return Some(tile.clone());
}
let tile_name = self.char_to_tile_name(c)?;
let tile = SpriteAtlas::get_tile(&self.atlas, &tile_name)?;
self.char_map.insert(c, tile.clone());
Some(tile)
}
/// 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' => format!("text/{}.png", c),
// Numbers 0-9
'0'..='9' => format!("text/{}.png", c),
// 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)
}
/// Renders a string of text at the given position.
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, text: &str, position: UVec2, color: Color) -> Result<()> {
let mut x_offset = 0;
let char_width = (8.0 * self.scale) as u32;
let char_height = (8.0 * self.scale) as u32;
for c in text.chars() {
if let Some(mut tile) = self.get_char_tile(c) {
let dest = sdl2::rect::Rect::new((position.x + x_offset) as i32, position.y as i32, char_width, char_height);
tile.render(canvas, dest)?;
}
// Always advance x_offset for all characters (including spaces)
x_offset += char_width;
}
Ok(())
}
/// Sets the scale for text rendering.
pub fn set_scale(&mut self, scale: f32) {
self.scale = scale;
}
/// Gets the current scale.
pub fn scale(&self) -> f32 {
self.scale
}
/// Calculates the width of a string in pixels at the current scale.
pub fn text_width(&mut self, text: &str) -> u32 {
let char_width = (8.0 * self.scale) as u32;
let mut width = 0;
for c in text.chars() {
if self.char_to_tile_name(c).is_some() {
width += char_width;
}
}
width
}
/// Calculates the height of text in pixels at the current scale.
pub fn text_height(&self) -> u32 {
(8.0 * self.scale) as u32
}
}