Compare commits

..

11 Commits

19 changed files with 127 additions and 52 deletions

View File

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

View File

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

View File

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

View File

@@ -43,11 +43,17 @@ jobs:
- name: Install cargo-tarpaulin
run: cargo install cargo-tarpaulin
# Note: We manually link zlib. This should be synchronized with the flags set for Linux in .cargo/config.toml.
- name: Generate coverage report
run: cargo tarpaulin --out Html --output-dir coverage
run: |
cargo tarpaulin \
--out Lcov \
--output-dir coverage \
--rustflags="-C link-arg=-lz"
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
with:
files: ./coverage/tarpaulin-report.html
files: ./coverage/lcov.info
format: lcov
allow-empty: false

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
///
/// This struct is responsible for initializing the audio device, loading sounds,
/// and playing them.
#[allow(dead_code)]
pub struct Audio {
_mixer_context: mixer::Sdl2MixerContext,
sounds: Vec<Chunk>,
@@ -18,6 +19,12 @@ pub struct Audio {
muted: bool,
}
impl Default for Audio {
fn default() -> Self {
Self::new()
}
}
impl Audio {
/// Creates a new `Audio` instance.
pub fn new() -> Self {
@@ -57,6 +64,7 @@ impl Audio {
}
/// Plays the "eat" sound effect.
#[allow(dead_code)]
pub fn eat(&mut self) {
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
match mixer::Channel(0).play(chunk, 0) {

View File

@@ -18,7 +18,7 @@ impl Direction {
}
}
pub fn to_ivec2(&self) -> IVec2 {
pub fn as_ivec2(&self) -> IVec2 {
(*self).into()
}
}

View File

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

View File

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

View File

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

51
src/helpers.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ impl AnimatedTexture {
}
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
let mut tile = self.current_tile().clone();
let mut tile = *self.current_tile();
tile.render(canvas, atlas, dest)
}
}

View File

@@ -1,3 +1,4 @@
#![allow(dead_code)]
use crate::texture::sprite::AtlasTile;
#[derive(Clone)]

View File

@@ -76,15 +76,30 @@ impl SpriteAtlas {
})
}
#[allow(dead_code)]
pub fn set_color(&mut self, color: Color) {
self.default_color = Some(color);
}
#[allow(dead_code)]
pub fn texture(&self) -> &Texture<'static> {
&self.texture
}
}
/// Converts a `Texture` to a `Texture<'static>` using transmute.
///
/// # Safety
///
/// This function is unsafe because it uses `std::mem::transmute` to change the lifetime
/// of the texture from the original lifetime to `'static`. The caller must ensure that:
///
/// - The original `Texture` will live for the entire duration of the program
/// - No references to the original texture exist that could become invalid
/// - The texture is not dropped while still being used as a `'static` reference
///
/// This is typically used when you have a texture that you know will live for the entire
/// program duration and need to store it in a structure that requires a `'static` lifetime.
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
std::mem::transmute(texture)
}

View File

@@ -1,3 +1,5 @@
#![allow(dead_code)]
//! This module provides text rendering using the texture atlas.
//!
//! The TextTexture system renders text from the atlas using character mapping.