Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a6182cb85 | |||
| a1d37a1a0b | |||
| 9066b2cdbc | |||
| 238b5aac6a | |||
| 8e5ec9fef0 |
1121
assets/game/atlas.json
Normal file
BIN
assets/game/atlas.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
assets/unpacked/text/!.png
Normal file
|
After Width: | Height: | Size: 100 B |
BIN
assets/unpacked/text/-.png
Normal file
|
After Width: | Height: | Size: 86 B |
BIN
assets/unpacked/text/0.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/1.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
assets/unpacked/text/2.png
Normal file
|
After Width: | Height: | Size: 112 B |
BIN
assets/unpacked/text/3.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/4.png
Normal file
|
After Width: | Height: | Size: 115 B |
BIN
assets/unpacked/text/5.png
Normal file
|
After Width: | Height: | Size: 114 B |
BIN
assets/unpacked/text/6.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/7.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/8.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
assets/unpacked/text/9.png
Normal file
|
After Width: | Height: | Size: 111 B |
BIN
assets/unpacked/text/A.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/B.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
assets/unpacked/text/C.png
Normal file
|
After Width: | Height: | Size: 113 B |
BIN
assets/unpacked/text/D.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/E.png
Normal file
|
After Width: | Height: | Size: 102 B |
BIN
assets/unpacked/text/F.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/G.png
Normal file
|
After Width: | Height: | Size: 110 B |
BIN
assets/unpacked/text/H.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/I.png
Normal file
|
After Width: | Height: | Size: 95 B |
BIN
assets/unpacked/text/J.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/K.png
Normal file
|
After Width: | Height: | Size: 111 B |
BIN
assets/unpacked/text/L.png
Normal file
|
After Width: | Height: | Size: 93 B |
BIN
assets/unpacked/text/M.png
Normal file
|
After Width: | Height: | Size: 117 B |
BIN
assets/unpacked/text/N.png
Normal file
|
After Width: | Height: | Size: 118 B |
BIN
assets/unpacked/text/O.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
assets/unpacked/text/P.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/Q.png
Normal file
|
After Width: | Height: | Size: 118 B |
BIN
assets/unpacked/text/R.png
Normal file
|
After Width: | Height: | Size: 113 B |
BIN
assets/unpacked/text/S.png
Normal file
|
After Width: | Height: | Size: 114 B |
BIN
assets/unpacked/text/T.png
Normal file
|
After Width: | Height: | Size: 90 B |
BIN
assets/unpacked/text/U.png
Normal file
|
After Width: | Height: | Size: 101 B |
BIN
assets/unpacked/text/V.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/W.png
Normal file
|
After Width: | Height: | Size: 114 B |
BIN
assets/unpacked/text/X.png
Normal file
|
After Width: | Height: | Size: 126 B |
BIN
assets/unpacked/text/Y.png
Normal file
|
After Width: | Height: | Size: 107 B |
BIN
assets/unpacked/text/Z.png
Normal file
|
After Width: | Height: | Size: 107 B |
BIN
assets/unpacked/text/_copyright.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/_double_quote.png
Normal file
|
After Width: | Height: | Size: 91 B |
BIN
assets/unpacked/text/_forward_slash.png
Normal file
|
After Width: | Height: | Size: 77 B |
BIN
assets/unpacked/text/blank.png
Normal file
|
After Width: | Height: | Size: 72 B |
@@ -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 {
|
||||
|
||||
@@ -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] = [
|
||||
"############################",
|
||||
"#............##............#",
|
||||
"#.####.#####.##.#####.####.#",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
_ => {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()>;
|
||||
}
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
348
src/game.rs
@@ -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
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
105
src/main.rs
@@ -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 {
|
||||
|
||||
49
src/map.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||