Compare commits

..

1 Commits

Author SHA1 Message Date
68cbbd56f2 test: add tests for collision, items, directional, sprite
enum macros for FruitKind
2025-08-12 00:44:19 -05:00
139 changed files with 2601 additions and 4326 deletions

View File

@@ -1,5 +1,2 @@
[profile.default] [profile.default]
fail-fast = false fail-fast = false
[profile.coverage]
status-level = "none"

View File

@@ -30,7 +30,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Setup Rust Toolchain - name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -64,16 +64,15 @@ jobs:
run: cargo build --release run: cargo build --release
- name: Acquire Package Version - name: Acquire Package Version
id: get_version shell: bash
shell: bash # required to prevent Windows runners from failing
run: | run: |
set -euo pipefail # exit on error PACKAGE_VERSION=$(cargo metadata --format-version 1 --no-deps | jq '.packages[0].version' -r)
echo "version=$(cargo metadata --format-version 1 --no-deps | jq '.packages[0].version' -r)" >> $GITHUB_OUTPUT echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $GITHUB_ENV
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: "pacman-${{ steps.get_version.outputs.version }}-${{ matrix.target }}" name: "pacman-${{ env.PACKAGE_VERSION }}-${{ matrix.target }}"
path: ./target/release/${{ matrix.artifact_name }} path: ./target/release/${{ matrix.artifact_name }}
retention-days: 7 retention-days: 7
if-no-files-found: error if-no-files-found: error
@@ -84,13 +83,10 @@ jobs:
permissions: permissions:
pages: write pages: write
id-token: write id-token: write
# concurrency group is used to prevent multiple page deployments from being attempted at the same time
concurrency:
group: ${{ github.workflow }}-wasm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Setup Emscripten SDK - name: Setup Emscripten SDK
uses: pyodide/setup-emsdk@v15 uses: pyodide/setup-emsdk@v15
@@ -102,7 +98,7 @@ jobs:
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
target: wasm32-unknown-emscripten target: wasm32-unknown-emscripten
toolchain: 1.86.0 toolchain: 1.86.0 # we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -42,39 +42,15 @@ jobs:
- uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov
- uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@nextest
- uses: taiki-e/install-action@just
# Note: We manually link zlib. This should be synchronized with the flags set for Linux in .cargo/config.toml.
- name: Generate coverage report - name: Generate coverage report
run: | run: |
just coverage cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest
- name: Download Coveralls CLI
run: |
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin
- name: Upload coverage to Coveralls - name: Upload coverage to Coveralls
env: uses: coverallsapp/github-action@v2
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} with:
run: | files: ./lcov.info
if [ ! -f "lcov.info" ]; then format: lcov
echo "Error: lcov.info file not found. Coverage generation may have failed." allow-empty: false
exit 1
fi
for i in {1..10}; do
echo "Attempt $i: Uploading coverage to Coveralls..."
if coveralls -n report lcov.info; then
echo "Successfully uploaded coverage report."
exit 0
fi
if [ $i -lt 10 ]; then
delay=$((2**i))
echo "Attempt $i failed. Retrying in $delay seconds..."
sleep $delay
fi
done
echo "Failed to upload coverage report after 10 attempts."
exit 1

View File

@@ -12,7 +12,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master

16
.gitignore vendored
View File

@@ -1,17 +1,7 @@
# IDE, Other files
.vscode
.idea
rust-sdl2-emscripten/
# Build files
target/ target/
dist/ dist/
emsdk/ emsdk/
.idea
# Site build f iles rust-sdl2-emscripten/
tailwindcss-*
assets/site/build.css assets/site/build.css
tailwindcss-*
# Coverage reports
lcov.info
coverage.html

813
Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -15,16 +15,14 @@ spin_sleep = "1.3.2"
rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] } rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] }
pathfinding = "4.14" pathfinding = "4.14"
once_cell = "1.21.3" once_cell = "1.21.3"
thiserror = "2.0" thiserror = "1.0"
anyhow = "1.0" anyhow = "1.0"
glam = { version = "0.30.5", features = [] } glam = { version = "0.30.4", features = [] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142" serde_json = "1.0.141"
smallvec = "1.15.1" smallvec = "1.15.1"
strum = "0.27.2" strum = "0.27.2"
strum_macros = "0.27.2" strum_macros = "0.27.2"
phf = { version = "0.11", features = ["macros"] }
bevy_ecs = "0.16.1"
[profile.release] [profile.release]
lto = true lto = true
@@ -58,9 +56,4 @@ x86_64-apple-darwin = { triplet = "x64-osx" }
aarch64-apple-darwin = { triplet = "arm64-osx" } aarch64-apple-darwin = { triplet = "arm64-osx" }
[target.'cfg(target_os = "emscripten")'.dependencies] [target.'cfg(target_os = "emscripten")'.dependencies]
libc = "0.2.175" libc = "0.2.16"
[build-dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
phf = { version = "0.11", features = ["macros"] }

View File

@@ -1,33 +0,0 @@
set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Regex to exclude files from coverage report, double escapes for Justfile + CLI
# You can use src\\\\..., but the filename alone is acceptable too
coverage_exclude_pattern := "src\\\\app.rs|audio.rs|src\\\\error.rs|platform\\\\emscripten.rs"
# !!! --ignore-filename-regex should be used on both reports & coverage testing
# !!! --remap-path-prefix prevents the absolute path from being used in the generated report
# Generate HTML report (for humans, source line inspection)
html: coverage
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--html \
--open
# Display report (for humans)
report-coverage: coverage
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
# Run & generate report (for CI)
coverage:
cargo llvm-cov \
--lcov \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--output-path lcov.info \
--profile coverage \
--no-fail-fast nextest

View File

@@ -1,6 +1,6 @@
# Pac-Man # Pac-Man
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![If you're seeing this, Coveralls.io is broken again and it's not my fault.][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits] [![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg [badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg [badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
@@ -72,8 +72,6 @@ I wanted to hit a log of goals and features, making it a 'perfect' project that
Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process. Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process.
- We use rustc 1.86.0 for the build, due to bulk-memory-opt related issues on wasm32-unknown-emscripten.
- Technically, we could probably use stable or even nightly on desktop targets, but using different versions for different targets is a pain, mainly because of clippy warnings changing between versions.
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg. - Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
- For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git` - For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git`
- The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead. - The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead.

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -34,30 +34,6 @@ command = [
need_stdout = true need_stdout = true
analyzer = "nextest" analyzer = "nextest"
[jobs.coverage]
command = [
"just", "report-coverage"
]
need_stdout = true
ignored_lines = [
"info:",
"\\s+Compiling",
"test result: ok",
"^\\s*$",
"running \\d+ test",
"Nextest run ID",
"[─]+",
"test.+ok",
"PASS|START",
"Starting \\d+ test",
"\\s*#",
"\\s*Finished.+in \\d+",
"\\s*Summary\\s+\\[",
"\\s*Blocking",
"Finished report saved to"
]
on_change_strategy = "wait_then_restart"
[jobs.doc] [jobs.doc]
command = ["cargo", "doc", "--no-deps"] command = ["cargo", "doc", "--no-deps"]
need_stdout = false need_stdout = false
@@ -83,4 +59,3 @@ c = "job:clippy"
alt-c = "job:check" alt-c = "job:check"
ctrl-alt-c = "job:check-all" ctrl-alt-c = "job:check-all"
shift-c = "job:clippy-all" shift-c = "job:clippy-all"
f = "job:coverage"

View File

@@ -1,50 +0,0 @@
use std::collections::HashMap;
use std::env;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct AtlasMapper {
frames: HashMap<String, MapperFrame>,
}
#[derive(Copy, Clone, Debug, Deserialize)]
struct MapperFrame {
x: u16,
y: u16,
width: u16,
height: u16,
}
fn main() {
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("atlas_data.rs");
let mut file = BufWriter::new(File::create(&path).unwrap());
let atlas_json = include_str!("./assets/game/atlas.json");
let atlas_mapper: AtlasMapper = serde_json::from_str(atlas_json).unwrap();
writeln!(&mut file, "use phf::phf_map;").unwrap();
writeln!(&mut file, "use crate::texture::sprite::MapperFrame;").unwrap();
writeln!(
&mut file,
"pub static ATLAS_FRAMES: phf::Map<&'static str, MapperFrame> = phf_map! {{"
)
.unwrap();
for (name, frame) in atlas_mapper.frames {
writeln!(
&mut file,
" \"{}\" => MapperFrame {{ x: {}, y: {}, width: {}, height: {} }},",
name, frame.x, frame.y, frame.width, frame.height
)
.unwrap();
}
writeln!(&mut file, "}};").unwrap();
println!("cargo:rerun-if-changed=assets/game/atlas.json");
}

View File

@@ -1,4 +1,3 @@
[toolchain] [toolchain]
# we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues on wasm32-unknown-emscripten
channel = "1.86.0" channel = "1.86.0"
components = ["rustfmt", "llvm-tools-preview", "clippy"] components = ["rustfmt", "llvm-tools-preview", "clippy"]

View File

@@ -1,11 +1,12 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use glam::Vec2; use glam::Vec2;
use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; use sdl2::EventPump;
use tracing::{error, warn}; use tracing::{error, event};
use crate::error::{GameError, GameResult}; use crate::error::{GameError, GameResult};
@@ -13,28 +14,26 @@ use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game; use crate::game::Game;
use crate::platform::get_platform; use crate::platform::get_platform;
pub struct App { pub struct App<'a> {
pub game: Game, game: Game,
canvas: Canvas<Window>,
event_pump: EventPump,
backbuffer: Texture<'a>,
paused: bool,
last_tick: Instant, last_tick: Instant,
focused: bool,
cursor_pos: Vec2, cursor_pos: Vec2,
} }
impl App { impl App<'_> {
pub fn new() -> GameResult<Self> { pub fn new() -> GameResult<Self> {
let sdl_context: &'static Sdl = Box::leak(Box::new(sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?));
let video_subsystem: &'static VideoSubsystem =
Box::leak(Box::new(sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?));
let _audio_subsystem: &'static AudioSubsystem =
Box::leak(Box::new(sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?));
let _ttf_context: &'static Sdl2TtfContext =
Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?));
let event_pump: &'static mut EventPump =
Box::leak(Box::new(sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?));
// Initialize platform-specific console // Initialize platform-specific console
get_platform().init_console()?; get_platform().init_console()?;
let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let window = video_subsystem let window = video_subsystem
.window( .window(
"Pac-Man", "Pac-Man",
@@ -46,33 +45,35 @@ impl App {
.build() .build()
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
let mut canvas = Box::leak(Box::new( let mut canvas = window.into_canvas().build().map_err(|e| GameError::Sdl(e.to_string()))?;
window
.into_canvas()
.accelerated()
.present_vsync()
.build()
.map_err(|e| GameError::Sdl(e.to_string()))?,
));
canvas canvas
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) .set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
let texture_creator: &'static mut TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator())); let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator()));
let game = Game::new(canvas, texture_creator, event_pump)?; let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem)?;
// game.audio.set_mute(cfg!(debug_assertions)); game.audio.set_mute(cfg!(debug_assertions));
let mut backbuffer = texture_creator_static
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest);
let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
// Initial draw // Initial draw
// game.draw(&mut canvas, &mut backbuffer) game.draw(&mut canvas, &mut backbuffer)
// .map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
// game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO) game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)
// .map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(App { Ok(Self {
game, game,
focused: true, canvas,
event_pump,
backbuffer,
paused: false,
last_tick: Instant::now(), last_tick: Instant::now(),
cursor_pos: Vec2::ZERO, cursor_pos: Vec2::ZERO,
}) })
@@ -82,51 +83,78 @@ impl App {
{ {
let start = Instant::now(); let start = Instant::now();
// for event in self for event in self.event_pump.poll_iter() {
// .game match event {
// .world Event::Window { win_event, .. } => match win_event {
// .get_non_send_resource_mut::<&'static mut EventPump>() WindowEvent::Hidden => {
// .unwrap() event!(tracing::Level::DEBUG, "Window hidden");
// .poll_iter() }
// { WindowEvent::Shown => {
// match event { event!(tracing::Level::DEBUG, "Window shown");
// Event::Window { win_event, .. } => match win_event { }
// WindowEvent::FocusGained => { _ => {}
// self.focused = true; },
// } // It doesn't really make sense to have this available in the browser
// WindowEvent::FocusLost => { #[cfg(not(target_os = "emscripten"))]
// self.focused = false; Event::Quit { .. }
// } | Event::KeyDown {
// _ => {} keycode: Some(Keycode::Escape) | Some(Keycode::Q),
// }, ..
// Event::MouseMotion { x, y, .. } => { } => {
// // Convert window coordinates to logical coordinates event!(tracing::Level::INFO, "Exit requested. Exiting...");
// self.cursor_pos = Vec2::new(x as f32, y as f32); return false;
// } }
// _ => {} Event::KeyDown {
// } keycode: Some(Keycode::P),
// } ..
} => {
self.paused = !self.paused;
event!(tracing::Level::INFO, "{}", if self.paused { "Paused" } else { "Unpaused" });
}
Event::KeyDown {
keycode: Some(Keycode::Space),
..
} => {
self.game.debug_mode = !self.game.debug_mode;
}
Event::KeyDown { keycode: Some(key), .. } => {
self.game.keyboard_event(key);
}
Event::MouseMotion { x, y, .. } => {
// Convert window coordinates to logical coordinates
self.cursor_pos = Vec2::new(x as f32, y as f32);
}
_ => {}
}
}
let dt = self.last_tick.elapsed().as_secs_f32(); let dt = self.last_tick.elapsed().as_secs_f32();
self.last_tick = Instant::now(); self.last_tick = Instant::now();
let exit = self.game.tick(dt); if !self.paused {
self.game.tick(dt);
if exit { if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
return false; error!("Failed to draw game: {}", e);
}
if let Err(e) = self
.game
.present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos)
{
error!("Failed to present backbuffer: {}", e);
}
} }
// if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
// error!("Failed to draw game: {}", e);
// }
if start.elapsed() < LOOP_TIME { if start.elapsed() < LOOP_TIME {
let time = LOOP_TIME.saturating_sub(start.elapsed()); let time = LOOP_TIME.saturating_sub(start.elapsed());
if time != Duration::ZERO { if time != Duration::ZERO {
get_platform().sleep(time, self.focused); get_platform().sleep(time);
} }
} else { } else {
warn!("Game loop behind schedule by: {:?}", start.elapsed() - LOOP_TIME); event!(
tracing::Level::WARN,
"Game loop behind schedule by: {:?}",
start.elapsed() - LOOP_TIME
);
} }
true true

View File

@@ -3,15 +3,28 @@
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem. //! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
use std::borrow::Cow; use std::borrow::Cow;
use strum_macros::EnumIter; use std::io;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)] #[derive(Error, Debug)]
pub enum AssetError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Asset not found: {0}")]
NotFound(String),
#[error("Invalid asset format: {0}")]
InvalidFormat(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Asset { pub enum Asset {
Wav1, Wav1,
Wav2, Wav2,
Wav3, Wav3,
Wav4, Wav4,
Atlas, Atlas,
AtlasJson,
// Add more as needed
} }
impl Asset { impl Asset {
@@ -24,16 +37,15 @@ impl Asset {
Wav3 => "sound/waka/3.ogg", Wav3 => "sound/waka/3.ogg",
Wav4 => "sound/waka/4.ogg", Wav4 => "sound/waka/4.ogg",
Atlas => "atlas.png", Atlas => "atlas.png",
AtlasJson => "atlas.json",
} }
} }
} }
mod imp { mod imp {
use super::*; use super::*;
use crate::error::AssetError;
use crate::platform::get_platform; use crate::platform::get_platform;
/// Returns the raw bytes of the given asset.
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> { pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
get_platform().get_asset_bytes(asset) get_platform().get_asset_bytes(asset)
} }

View File

@@ -18,6 +18,8 @@ pub const SCALE: f32 = 2.6;
pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3); 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. /// 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); 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. /// The size of the canvas, in pixels.
pub const CANVAS_SIZE: UVec2 = UVec2::new( pub const CANVAS_SIZE: UVec2 = UVec2::new(
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE, (BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,

View File

@@ -1,169 +0,0 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
system::{Query, Res, ResMut},
};
use tracing::debug;
use crate::entity::graph::EdgePermissions;
use crate::{
ecs::{DeltaTime, GlobalState, PlayerControlled, Position, Velocity},
error::{EntityError, GameError},
game::events::GameEvent,
input::commands::GameCommand,
map::builder::Map,
};
pub fn movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut PlayerControlled, &mut Velocity, &mut Position)>,
mut errors: EventWriter<GameError>,
) {
for (mut player, mut velocity, mut position) in entities.iter_mut() {
let distance = velocity.speed * 60.0 * delta_time.0;
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = velocity.next_direction {
if remaining > 0 {
velocity.next_direction = Some((direction, remaining - 1));
} else {
velocity.next_direction = None;
}
}
match *position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(&mut player, edge) {
// Start moving in that direction
*position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance,
};
velocity.direction = next_direction;
velocity.next_direction = None;
}
} else {
errors.write(
EntityError::InvalidMovement(format!(
"No edge found in direction {:?} from node {}",
next_direction, node_id
))
.into(),
);
}
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return;
}
let edge = map
.graph
.find_edge(from, to)
.ok_or_else(|| {
errors.write(
EntityError::InvalidMovement(format!(
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
))
.into(),
);
return;
})
.unwrap();
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
*position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(to, next_dir) {
if can_traverse(&mut player, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
velocity.direction = next_dir; // Remember our new direction
velocity.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = map.graph.find_edge_in_direction(to, velocity.direction) {
if can_traverse(&mut player, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
}
}
}
}
}
}
fn can_traverse(_player: &mut PlayerControlled, edge: crate::entity::graph::Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All)
}
// Handles
pub fn interact_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut players: Query<(&PlayerControlled, &mut Velocity)>,
mut errors: EventWriter<GameError>,
) {
// Get the player's velocity (handling to ensure there is only one player)
let mut velocity = match players.single_mut() {
Ok((_, velocity)) => velocity,
Err(e) => {
errors.write(GameError::InvalidState(format!("Player not found: {}", e)).into());
return;
}
};
// Handle events
for event in events.read() {
match event {
GameEvent::Command(command) => match command {
GameCommand::MovePlayer(direction) => {
velocity.next_direction = Some((*direction, 90));
}
GameCommand::Exit => {
state.exit = true;
}
_ => {}
},
}
}
}

View File

@@ -1,150 +0,0 @@
//! The Entity-Component-System (ECS) module.
//!
//! This module contains all the ECS-related logic, including components, systems,
//! and resources.
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
use glam::Vec2;
use crate::{
entity::{direction::Direction, graph::Graph, traversal},
error::{EntityError, GameResult},
texture::{
animated::AnimatedTexture,
directional::DirectionalAnimatedTexture,
sprite::{AtlasTile, Sprite},
},
};
/// A tag component for entities that are controlled by the player.
#[derive(Default, Component)]
pub struct PlayerControlled;
/// A component for entities that have a sprite, with a layer for ordering.
///
/// This is intended to be modified by other entities allowing animation.
#[derive(Component)]
pub struct Renderable {
pub sprite: AtlasTile,
pub layer: u8,
}
/// A component for entities that have a directional animated texture.
#[derive(Component)]
pub struct DirectionalAnimated {
pub textures: [Option<AnimatedTexture>; 4],
pub stopped_textures: [Option<AnimatedTexture>; 4],
}
/// A unique identifier for a node, represented by its index in the graph's storage.
pub type NodeId = usize;
/// Represents the current position of an entity traversing the graph.
///
/// This enum allows for precise tracking of whether an entity is exactly at a node
/// or moving along an edge between two nodes.
#[derive(Component, Debug, Copy, Clone, PartialEq)]
pub enum Position {
/// The traverser is located exactly at a node.
AtNode(NodeId),
/// The traverser is on an edge between two nodes.
BetweenNodes {
from: NodeId,
to: NodeId,
/// The floating-point distance traversed along the edge from the `from` node.
traversed: f32,
},
}
impl Position {
/// Calculates the current pixel position in the game world.
///
/// Converts the graph position to screen coordinates, accounting for
/// the board offset and centering the sprite.
pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self {
Position::AtNode(node_id) => {
let node = graph.get_node(*node_id).ok_or(EntityError::NodeNotFound(*node_id))?;
node.position
}
Position::BetweenNodes { from, to, traversed } => {
let from_node = graph.get_node(*from).ok_or(EntityError::NodeNotFound(*from))?;
let to_node = graph.get_node(*to).ok_or(EntityError::NodeNotFound(*to))?;
let edge = graph
.find_edge(*from, *to)
.ok_or(EntityError::EdgeNotFound { from: *from, to: *to })?;
from_node.position + (to_node.position - from_node.position) * (traversed / edge.distance)
}
};
Ok(Vec2::new(
pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32,
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
))
}
}
impl Default for Position {
fn default() -> Self {
Position::AtNode(0)
}
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node.
pub fn is_at_node(&self) -> bool {
matches!(self, Position::AtNode(_))
}
/// 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,
Position::BetweenNodes { from, .. } => *from,
}
}
/// 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,
Position::BetweenNodes { to, .. } => Some(*to),
}
}
/// Returns `true` if the traverser is stopped at a node.
pub fn is_stopped(&self) -> bool {
matches!(self, Position::AtNode(_))
}
}
/// A component for entities that have a velocity, with a direction and speed.
#[derive(Default, Component)]
pub struct Velocity {
pub direction: Direction,
pub next_direction: Option<(Direction, u8)>,
pub speed: f32,
}
#[derive(Bundle)]
pub struct PlayerBundle {
pub player: PlayerControlled,
pub position: Position,
pub velocity: Velocity,
pub sprite: Renderable,
pub directional_animated: DirectionalAnimated,
}
#[derive(Resource)]
pub struct GlobalState {
pub exit: bool,
}
#[derive(Resource)]
pub struct DeltaTime(pub f32);
pub mod interact;
pub mod render;

View File

@@ -1,98 +0,0 @@
use crate::ecs::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity};
use crate::error::{EntityError, GameError, TextureError};
use crate::map::builder::Map;
use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{NonSendMut, Query, Res};
use sdl2::render::{Canvas, Texture};
use sdl2::video::Window;
/// Updates the directional animated texture of an entity.
pub fn directional_render_system(
dt: Res<DeltaTime>,
mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable, &Position)>,
mut errors: EventWriter<GameError>,
) {
for (velocity, mut texture, mut renderable, position) in renderables.iter_mut() {
let stopped = matches!(position, Position::AtNode(_));
let texture = if stopped {
texture.stopped_textures[velocity.direction.as_usize()].as_mut()
} else {
texture.textures[velocity.direction.as_usize()].as_mut()
};
if let Some(texture) = texture {
if !stopped {
texture.tick(dt.0);
}
renderable.sprite = *texture.current_tile();
} else {
errors.write(TextureError::RenderFailed(format!("Entity has no texture")).into());
continue;
}
}
}
pub struct MapTextureResource(pub Texture<'static>);
pub struct BackbufferResource(pub Texture<'static>);
pub fn render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
map_texture: NonSendMut<MapTextureResource>,
mut backbuffer: NonSendMut<BackbufferResource>,
mut atlas: NonSendMut<SpriteAtlas>,
map: Res<Map>,
mut renderables: Query<(Entity, &mut Renderable, &Position)>,
mut errors: EventWriter<GameError>,
) {
// Clear the main canvas first
canvas.set_draw_color(sdl2::pixels::Color::BLACK);
canvas.clear();
// Render to backbuffer
canvas
.with_texture_canvas(&mut backbuffer.0, |backbuffer_canvas| {
// Clear the backbuffer
backbuffer_canvas.set_draw_color(sdl2::pixels::Color::BLACK);
backbuffer_canvas.clear();
// Copy the pre-rendered map texture to the backbuffer
backbuffer_canvas
.copy(&map_texture.0, None, None)
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
// Render all entities to the backbuffer
for (_, mut renderable, position) in renderables.iter_mut() {
let pos = position.get_pixel_pos(&map.graph);
match pos {
Ok(pos) => {
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(pos.x as i32, pos.y as i32),
glam::UVec2::new(renderable.sprite.size.x as u32, renderable.sprite.size.y as u32),
);
renderable
.sprite
.render(backbuffer_canvas, &mut atlas, dest)
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
}
Err(e) => {
errors.write(e.into());
}
}
}
})
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
// Copy backbuffer to main canvas and present
canvas
.copy(&backbuffer.0, None, None)
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.present();
}

Some files were not shown because too many files have changed in this diff Show More