Compare commits

..

55 Commits

Author SHA1 Message Date
b2ad8e7afe ci: add permisions for deployment job 2025-06-17 11:54:13 -05:00
799d5d85e8 ci: add action-based deploy with artifacts 2025-06-17 11:54:13 -05:00
9730d02da5 ci: fix build-wasm execution permissions 2025-06-17 11:54:13 -05:00
f634beffee fix(wasm): remove unnecessary emscripten looping 2025-06-17 11:54:13 -05:00
d15dbe3982 ci: prepare proper build script, move script into /scripts, move index.html into /assets 2025-06-17 11:54:13 -05:00
de5cddd9b6 fix(wasm): wasm32-unknown-emscripten compiler flags 2025-06-17 11:54:13 -05:00
e3f37ab48e ci: target proper version of Emscripten (1.39.20) 2025-06-17 11:54:13 -05:00
3dd8d5aff7 chore: increase stat reporting period to 60 seconds 2025-06-17 11:54:13 -05:00
ad084d1cd8 feat: add pausing functionality, clean up statistic calculations 2025-06-17 11:54:13 -05:00
852e54f1bf chore: rust-fmt entire project 2025-06-17 11:54:13 -05:00
a62ddab9af fix: prevent changing direction into walls 2025-06-17 11:54:13 -05:00
50d0bc7d5f refactor: simplify keyboard event direction change logic in Game 2025-06-17 11:54:13 -05:00
2c6045aa1b feat: add map struct, overhaul stored map representation 2025-06-17 11:54:13 -05:00
bf8370ef35 feat: sprite frame pinning, conditional on stopped PacMan 2025-06-17 11:54:13 -05:00
c71b6d69ab fix: always use tracing, provide timing info 2s in + every 60s first 2025-06-17 11:54:13 -05:00
a7e87c18a3 feat: pacman next cell debug func 2025-06-17 11:54:13 -05:00
95298fbc00 feat: keycode to direction utility function 2025-06-17 11:54:13 -05:00
fe18eafbaf chore: doc & expose TickModulator trait, rename speed to tick 2025-06-17 11:54:13 -05:00
60eaa428ac reformat: improve AnimatedTexture API with paused animation abilities 2025-06-17 11:54:13 -05:00
18eaeee19e fix: compile time removal of tracing below WARN on release builds 2025-06-17 11:54:13 -05:00
b3c1a30a74 feat: tracing, sleep timing calculations, use spin_sleeper for accurate sleeps on Windows 2025-06-17 11:52:08 -05:00
0d76c6528b docs: add DLL instructions to README, expand .gitignore 2025-06-17 11:51:57 -05:00
da98b54216 feat: wall collisions 2025-06-17 11:51:49 -05:00
6ce3a5ce79 feat: speed modulation to implement precise speed decrease despite integers 2025-06-17 11:51:40 -05:00
b987599f10 reformat: general, target conditional module 2025-06-17 11:51:35 -05:00
786fbb5002 feat: change starting position of PacMan, draw current PacMan on grid 2025-06-17 11:51:26 -05:00
422535c00d feat: direction propagation, change direction at precise times 2025-06-17 11:51:21 -05:00
0120abe806 feat: add optional offset to AnimatedTexture 2025-06-17 11:51:17 -05:00
e61930c08a reformat: default debug off, conditional debug grid rendering, remove unused redraw() 2025-06-17 11:51:02 -05:00
f7ff9f5290 chore: delete TextureManager 2025-06-17 11:50:55 -05:00
de29dc6711 chore: remove unused tick timing 2025-06-17 11:50:49 -05:00
c90f221c73 feat: speed property to PacMan 2025-06-17 11:50:44 -05:00
841943e121 fix: frame flashing in sprite tick 2025-06-17 11:50:37 -05:00
83d665123c feat: smooth back-forth sprite frame ticks, sprite rotation 2025-06-17 11:50:32 -05:00
ffc21c8622 reformat: strict tick timing with lag prints 2025-06-17 11:50:26 -05:00
b46a51bc76 reformat: drop TextureManager based sprite rendering, directly hold Textures 2025-06-17 11:50:18 -05:00
443afb1223 feat: new PacMan entity, entity trait implementation 2025-06-17 11:50:04 -05:00
724878dc17 feat: atlas texture, animated sprite management 2025-06-17 11:49:47 -05:00
274404b9ea feat: entity trait, direction enum (util) 2025-06-17 11:49:43 -05:00
2214a5541f feat: game loop, texture ownership, tick & draw with grid rendering 2025-06-17 11:49:40 -05:00
64de5fb732 feat: texture manager, texture loading 2025-06-17 11:49:33 -05:00
4a4e6e40a9 feat: constants & board preprocessing with lazy_static 2025-06-17 11:49:17 -05:00
ccde1b6538 feat: current canvas, window & mainloop 2025-06-17 11:49:08 -05:00
d90785a61a docs: implementation document 2025-06-17 11:48:59 -05:00
227c603ffe chore: project name & add lazy_static 2025-06-17 11:48:53 -05:00
361ff95f7f feat: new assets 2025-06-17 11:48:43 -05:00
f3fe1b783b docs: expand feature target details, experimental ideas 2025-06-17 11:48:34 -05:00
570cee0f62 feat: window events & canvas rendering 2025-06-17 11:48:26 -05:00
5b6fd34a6f docs: update installation instructions, remove DLLs 2025-06-17 11:48:16 -05:00
973fa08ac9 ci: better build scripts 2025-06-17 11:48:09 -05:00
4a1f565f72 feat(wasm): add index.html 2025-06-17 11:48:01 -05:00
2ddf2611d9 ci: add wasm32 cargo flags 2025-06-17 11:47:52 -05:00
b3a3664578 feat: game loop 2025-06-17 11:47:43 -05:00
2b667bb6a2 ci: add deploy workflow + build script 2025-06-17 11:47:25 -05:00
9a88e71202 chore: .idea folder in .gitignore 2025-06-17 11:47:18 -05:00
36 changed files with 1280 additions and 28 deletions

8
.cargo/config.toml Normal file
View File

@@ -0,0 +1,8 @@
[target.wasm32-unknown-emscripten]
rustflags = [
"-O", "-C", "link-args=-O2 --profiling",
#"-C", "link-args=-O3 --closure 1",
"-C", "link-args=-sASYNCIFY -sALLOW_MEMORY_GROWTH=1",
"-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sSDL2_IMAGE_FORMATS=['png']",
"-C", "link-args=--preload-file assets/ -lidbfs.js",
]

38
.github/workflows/deploy.yaml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Github Pages
on: [push]
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
steps:
- uses: actions/checkout@v2 # repo checkout
- uses: mymindstorm/setup-emsdk@v11 # setup emscripten toolchain
with:
version: 1.39.20
- uses: actions-rs/toolchain@v1 # get rust toolchain for wasm
with:
toolchain: stable
target: wasm32-unknown-emscripten
override: true
# TODO: Update to v2
- name: Rust Cache # cache the rust build artefacts
uses: Swatinem/rust-cache@v1
- name: Build # build
run: ./scripts/build-wasm.sh
- name: Upload Artifact
uses: actions/upload-pages-artifact@v2
with:
path: './dist/'
retention-days: 7
- name: Deploy
uses: actions/deploy-pages@v2

3
.gitignore vendored
View File

@@ -1 +1,4 @@
/target
/dist
.idea
*.dll

283
Cargo.lock generated
View File

@@ -3,10 +3,12 @@
version = 3
[[package]]
name = "Pac-Man"
version = "0.1.0"
name = "aho-corasick"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
dependencies = [
"sdl2",
"memchr",
]
[[package]]
@@ -33,6 +35,129 @@ version = "0.2.147"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
[[package]]
name = "log"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "matchers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
dependencies = [
"regex-automata 0.1.10",
]
[[package]]
name = "memchr"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
dependencies = [
"overload",
"winapi",
]
[[package]]
name = "once_cell"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "pacman"
version = "0.1.0"
dependencies = [
"lazy_static",
"sdl2",
"spin_sleep",
"tracing",
"tracing-error",
"tracing-subscriber",
]
[[package]]
name = "pin-project-lite"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
[[package]]
name = "proc-macro2"
version = "1.0.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.3.3",
"regex-syntax 0.7.4",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax 0.6.29",
]
[[package]]
name = "regex-automata"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax 0.7.4",
]
[[package]]
name = "regex-syntax"
version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
[[package]]
name = "sdl2"
version = "0.35.2"
@@ -56,8 +181,160 @@ dependencies = [
"version-compare",
]
[[package]]
name = "sharded-slab"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31"
dependencies = [
"lazy_static",
]
[[package]]
name = "smallvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
[[package]]
name = "spin_sleep"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cafa7900db085f4354dbc7025e25d7a839a14360ea13b5fc4fd717f2d3b23134"
dependencies = [
"once_cell",
"winapi",
]
[[package]]
name = "syn"
version = "2.0.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "718fa2415bcb8d8bd775917a1bf12a7931b6dfa890753378538118181e0cb398"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thread_local"
version = "1.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152"
dependencies = [
"cfg-if",
"once_cell",
]
[[package]]
name = "tracing"
version = "0.1.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
dependencies = [
"cfg-if",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-log"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922"
dependencies = [
"lazy_static",
"log",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
[[package]]
name = "valuable"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "version-compare"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

View File

@@ -1,9 +1,14 @@
[package]
name = "Pac-Man"
name = "pacman"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
lazy_static = "1.4.0"
sdl2 = { version = "0.35", features = ["image", "ttf", "mixer"] }
spin_sleep = "1.1.1"
tracing = { version = "0.1.37", features = ["max_level_debug", "release_max_level_warn"]}
tracing-error = "0.2.0"
tracing-subscriber = {version = "0.3.17", features = ["env-filter"]}

35
IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,35 @@
# Implementation
A document detailing the implementation the project from rendering, to game logic, to build systems.
## Rendering
1. Map
- May require procedural text generation later on (cacheable?)
2. Pacman
3. Ghosts
- Requires colors
4. Items
5. Interface
- Requires fonts
## Grid System
1. How does the grid system work?
The grid is 28 x 36 (although, the map texture is 28 x 37), and each cell is 24x24 (pixels).
Many of the walls in the map texture only occupy a portion of the cell, so some items are able to render across multiple cells.
24x24 assets include pellets, the energizer, and the map itself ()
2. What constraints must be enforced on Ghosts and PacMan?
3. How do movement transitions work?
All entities store a precise position, and a direction. This position is only used for animation, rendering, and collision purposes. Otherwise, a separate 'cell position' (which is 24 times less precise, owing to the fact that it is based on the entity's position within the grid).
When an entity is transitioning between cells, movement directions are acknowledged, but won't take effect until the next cell has been entered completely.
4. Between transitions, how does collision detection work?
It appears the original implementation used cell-level detection.
I worry this may be prone to division errors. Make sure to use rounding (50% >=).

View File

@@ -2,11 +2,75 @@
If the title doesn't clue you in, I'm remaking Pac-Man with SDL and Rust.
The project is *extremely* early in development, but check back in a week, and maybe I'll have something cool to look
The project is _extremely_ early in development, but check back in a week, and maybe I'll have something cool to look
at.
## Feature Targets
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors.
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly.
- Online demo, playable in a browser.
- Online demo, playable in a browser.
- Automatic build system, with releases for Windows, Linux, and Mac & Web-Assembly.
- Debug tooling
- Game state visualization
- Game speed controls + pausing
- Log tracing
- Performance details
## Experimental Ideas
- Perfected Ghost Algorithms
- More than 4 ghosts
- Custom Level Generation
- Multi-map tunnelling
## Installation
Besides SDL2, the following extensions are required: Image, Mixer, and TTF.
### Ubuntu
On Ubuntu, you can install the required packages with the following command:
```
sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
```
### Windows
On Windows, installation requires either building from source (not covered), or downloading the pre-built binaries.
The latest releases can be found here:
- [SDL2](https://github.com/libsdl-org/SDL/releases/latest/)
- [SDL2_image](https://github.com/libsdl-org/SDL_image/releases/latest/)
- [SDL2_mixer](https://github.com/libsdl-org/SDL_mixer/releases/latest/)
- [SDL2_ttf](https://github.com/libsdl-org/SDL_ttf/releases/latest/)
Download each for your architecture, and locate the appropriately named DLL within. Move said DLL to root of this project.
In total, you should have the following DLLs in the root of the project:
- SDL2.dll
- SDL2_mixer.dll
- SDL2_ttf.dll
- SDL2_image.dll
- libpngX-X.dll
- Not sure on what specific version is to be used, or if naming matters. `libpng16-16.dll` is what I had used.
- zlib1.dll
## Building
To build the project, run the following command:
```
cargo build
```
During development, you can easily run the project with:
```
cargo run
cargo run -q # Quiet mode, no logging
cargo run --release # Release mode, optimized
```

BIN
SDL2.dll
View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

BIN
assets/24/energizer.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

BIN
assets/24/pellet.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

BIN
assets/32/fruit.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/32/game_over.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/32/ghost_body.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 B

BIN
assets/32/ghost_eyes.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

BIN
assets/32/life.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

BIN
assets/32/pacman.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

BIN
assets/door.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

21
assets/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
var Module = {
'locateFile': function (path, prefix) {
if (path.endsWith(".data")) {
return prefix + "deps/" + path;
}
return prefix + path;
},
'canvas': document.getElementById('canvas'),
};
</script>
<script src="pacman.js"></script>
</body>
</html>

7
build.ps1 Normal file
View File

@@ -0,0 +1,7 @@
& cargo build --target=wasm32-unknown-emscripten --release
mkdir -p dist -Force
cp ./target/wasm32-unknown-emscripten/release/Pac_Man.wasm ./dist
cp ./target/wasm32-unknown-emscripten/release/Pac-Man.js ./dist
cp index.html dist

11
scripts/build-wasm.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -eux
cargo build --target=wasm32-unknown-emscripten --release
mkdir -p dist
cp target/wasm32-unknown-emscripten/release/pacman.wasm dist
cp target/wasm32-unknown-emscripten/release/pacman.js dist
cp target/wasm32-unknown-emscripten/release/deps/pacman.data dist/deps
cp assets/index.html dist

3
serve.ps1 Normal file
View File

@@ -0,0 +1,3 @@
& ./build.ps1
cd ./dist
python -m http.server

131
src/animation.rs Normal file
View File

@@ -0,0 +1,131 @@
use sdl2::{
rect::Rect,
render::{Canvas, Texture},
video::Window,
};
use crate::direction::Direction;
pub struct AnimatedTexture<'a> {
raw_texture: Texture<'a>,
ticker: u32,
reversed: bool,
offset: (i32, i32),
ticks_per_frame: u32,
frame_count: u32,
frame_width: u32,
frame_height: u32,
}
impl<'a> AnimatedTexture<'a> {
pub fn new(
texture: Texture<'a>,
ticks_per_frame: u32,
frame_count: u32,
frame_width: u32,
frame_height: u32,
offset: Option<(i32, i32)>,
) -> Self {
AnimatedTexture {
raw_texture: texture,
ticker: 0,
reversed: false,
ticks_per_frame,
frame_count,
frame_width,
frame_height,
offset: offset.unwrap_or((0, 0)),
}
}
// Get the current frame number
fn current_frame(&self) -> u32 {
self.ticker / self.ticks_per_frame
}
// Move to the next frame. If we are at the end of the animation, reverse the direction
pub fn tick(&mut self) {
if self.reversed {
self.ticker -= 1;
if self.ticker == 0 {
self.reversed = !self.reversed;
}
} else {
self.ticker += 1;
if self.ticker + 1 == self.ticks_per_frame * self.frame_count {
self.reversed = !self.reversed;
}
}
}
// Calculate the frame rect (portion of the texture to render) for the given frame.
fn get_frame_rect(&self, frame: u32) -> Rect {
if frame >= self.frame_count {
panic!("Frame {} is out of bounds for this texture", frame);
}
Rect::new(
frame as i32 * self.frame_width as i32,
0,
self.frame_width,
self.frame_height,
)
}
pub fn render(
&mut self,
canvas: &mut Canvas<Window>,
position: (i32, i32),
direction: Direction,
) {
self.render_static(canvas, position, direction, Some(self.current_frame()));
self.tick();
}
// Functions like render, but only ticks the animation until the given frame is reached.
pub fn render_until(
&mut self,
canvas: &mut Canvas<Window>,
position: (i32, i32),
direction: Direction,
frame: u32,
) {
let current = self.current_frame();
self.render_static(canvas, position, direction, Some(current));
if frame != current {
self.tick();
}
}
// Renders a specific frame of the animation. Defaults to the current frame.
pub fn render_static(
&mut self,
canvas: &mut Canvas<Window>,
position: (i32, i32),
direction: Direction,
frame: Option<u32>,
) {
let frame_rect = self.get_frame_rect(frame.unwrap_or(self.current_frame()));
let position_rect = Rect::new(
position.0 + self.offset.0,
position.1 + self.offset.1,
self.frame_width,
self.frame_height,
);
canvas
.copy_ex(
&self.raw_texture,
Some(frame_rect),
Some(position_rect),
direction.angle(),
None,
false,
false,
)
.expect("Could not render texture on canvas");
}
}

0
src/board.rs Normal file
View File

51
src/constants.rs Normal file
View File

@@ -0,0 +1,51 @@
pub const BOARD_WIDTH: u32 = 28;
pub const BOARD_HEIGHT: u32 = 31; // Adjusted to fit map texture?
pub const CELL_SIZE: u32 = 24;
pub const BOARD_OFFSET: (u32, u32) = (0, 3); // Relative cell offset for where map text / grid starts
pub const WINDOW_WIDTH: u32 = CELL_SIZE * BOARD_WIDTH;
pub const WINDOW_HEIGHT: u32 = CELL_SIZE * (BOARD_HEIGHT + 6); // Map texture is 6 cells taller (3 above, 3 below) than the grid
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum MapTile {
Empty,
Wall,
Pellet,
PowerPellet,
StartingPosition(u8),
}
pub const RAW_BOARD: [&str; BOARD_HEIGHT as usize] = [
"############################",
"#............##............#",
"#.####.#####.##.#####.####.#",
"#o####.#####.##.#####.####o#",
"#.####.#####.##.#####.####.#",
"#..........................#",
"#.####.##.########.##.####.#",
"#.####.##.########.##.####.#",
"#......##....##....##......#",
"######.##### ## #####.######",
" #.##### ## #####.# ",
" #.## 1 ##.# ",
" #.## ###==### ##.# ",
"######.## # # ##.######",
" . #2 3 4 # . ",
"######.## # # ##.######",
" #.## ######## ##.# ",
" #.## ##.# ",
" #.## ######## ##.# ",
"######.## ######## ##.######",
"#............##............#",
"#.####.#####.##.#####.####.#",
"#.####.#####.##.#####.####.#",
"#o..##.......0 .......##..o#",
"###.##.##.########.##.##.###",
"###.##.##.########.##.##.###",
"#......##....##....##......#",
"#.##########.##.##########.#",
"#.##########.##.##########.#",
"#..........................#",
"############################",
];

43
src/direction.rs Normal file
View File

@@ -0,0 +1,43 @@
use sdl2::keyboard::Keycode;
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum Direction {
Up,
Down,
Left,
Right,
}
impl Direction {
pub fn angle(&self) -> f64 {
match self {
Direction::Right => 0f64,
Direction::Down => 90f64,
Direction::Left => 180f64,
Direction::Up => 270f64,
}
}
pub fn offset(&self) -> (i32, i32) {
match self {
Direction::Right => (1, 0),
Direction::Down => (0, 1),
Direction::Left => (-1, 0),
Direction::Up => (0, -1),
}
}
pub fn from_keycode(keycode: Keycode) -> Option<Direction> {
match keycode {
Keycode::D => Some(Direction::Right),
Keycode::Right => Some(Direction::Right),
Keycode::A => Some(Direction::Left),
Keycode::Left => Some(Direction::Left),
Keycode::W => Some(Direction::Up),
Keycode::Up => Some(Direction::Up),
Keycode::S => Some(Direction::Down),
Keycode::Down => Some(Direction::Down),
_ => None,
}
}
}

49
src/emscripten.rs Normal file
View File

@@ -0,0 +1,49 @@
// taken from https://github.com/Gigoteur/PX8/blob/master/src/px8/emscripten.rs
#[cfg(target_os = "emscripten")]
pub mod emscripten {
use std::cell::RefCell;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_float, c_int, c_void};
use std::ptr::null_mut;
#[allow(non_camel_case_types)]
type em_callback_func = unsafe extern "C" fn();
extern "C" {
// void emscripten_set_main_loop(em_callback_func func, int fps, int simulate_infinite_loop)
pub fn emscripten_set_main_loop(
func: em_callback_func,
fps: c_int,
simulate_infinite_loop: c_int,
);
pub fn emscripten_cancel_main_loop();
pub fn emscripten_pause_main_loop();
pub fn emscripten_get_now() -> c_float;
}
thread_local!(static MAIN_LOOP_CALLBACK: RefCell<*mut c_void> = RefCell::new(null_mut()));
pub fn set_main_loop_callback<F>(callback: F)
where
F: FnMut(),
{
MAIN_LOOP_CALLBACK.with(|log| {
*log.borrow_mut() = &callback as *const _ as *mut c_void;
});
unsafe {
emscripten_set_main_loop(wrapper::<F>, -1, 1);
}
unsafe extern "C" fn wrapper<F>()
where
F: FnMut(),
{
MAIN_LOOP_CALLBACK.with(|z| {
let closure = *z.borrow_mut() as *mut F;
(*closure)();
});
}
}
}

11
src/entity.rs Normal file
View File

@@ -0,0 +1,11 @@
pub trait Entity {
// Returns true if the entity is colliding with the other entity
fn is_colliding(&self, other: &dyn Entity) -> bool;
// Returns the absolute position of the entity
fn position(&self) -> (i32, i32);
// Returns the cell position of the entity (XY position within the grid)
fn cell_position(&self) -> (u32, u32);
fn internal_position(&self) -> (u32, u32);
// Tick the entity (move it, perform collision checks, etc)
fn tick(&mut self);
}

121
src/game.rs Normal file
View File

@@ -0,0 +1,121 @@
use std::rc::Rc;
use sdl2::image::LoadTexture;
use sdl2::keyboard::Keycode;
use sdl2::render::{Texture, TextureCreator};
use sdl2::video::WindowContext;
use sdl2::{pixels::Color, render::Canvas, video::Window};
use tracing::event;
use crate::constants::{MapTile, BOARD_HEIGHT, BOARD_WIDTH, RAW_BOARD};
use crate::direction::Direction;
use crate::entity::Entity;
use crate::map::Map;
use crate::pacman::Pacman;
pub struct Game<'a> {
canvas: &'a mut Canvas<Window>,
map_texture: Texture<'a>,
pacman: Pacman<'a>,
map: Rc<Map>,
debug: bool,
}
impl Game<'_> {
pub fn new<'a>(
canvas: &'a mut Canvas<Window>,
texture_creator: &'a TextureCreator<WindowContext>,
) -> Game<'a> {
let map = Rc::new(Map::new(RAW_BOARD));
let pacman_atlas = texture_creator
.load_texture("assets/32/pacman.png")
.expect("Could not load pacman texture");
let pacman = Pacman::new((1, 1), pacman_atlas, Rc::clone(&map));
Game {
canvas,
pacman: pacman,
debug: false,
map: map,
map_texture: texture_creator
.load_texture("assets/map.png")
.expect("Could not load pacman texture"),
}
}
pub fn keyboard_event(&mut self, keycode: Keycode) {
// Change direction
let direction = Direction::from_keycode(keycode);
self.pacman.next_direction = direction;
// Toggle debug mode
if keycode == Keycode::Space {
self.debug = !self.debug;
}
}
pub fn tick(&mut self) {
self.pacman.tick();
}
pub fn draw(&mut self) {
// Clear the screen (black)
self.canvas.set_draw_color(Color::RGB(0, 0, 0));
self.canvas.clear();
self.canvas
.copy(&self.map_texture, None, None)
.expect("Could not render texture on canvas");
// Render the pacman
self.pacman.render(self.canvas);
// Draw a grid
if self.debug {
for x in 0..BOARD_WIDTH {
for y in 0..BOARD_HEIGHT {
let tile = self
.map
.get_tile((x as i32, y as i32))
.unwrap_or(MapTile::Empty);
let mut color = None;
if (x, y) == self.pacman.cell_position() {
self.draw_cell((x, y), Color::CYAN);
} else {
color = match tile {
MapTile::Empty => None,
MapTile::Wall => Some(Color::BLUE),
MapTile::Pellet => Some(Color::RED),
MapTile::PowerPellet => Some(Color::MAGENTA),
MapTile::StartingPosition(_) => Some(Color::GREEN),
};
}
if let Some(color) = color {
self.draw_cell((x, y), color);
}
}
}
// Draw the next cell
let next_cell = self.pacman.next_cell(None);
self.draw_cell((next_cell.0 as u32, next_cell.1 as u32), Color::YELLOW);
}
self.canvas.present();
}
fn draw_cell(&mut self, cell: (u32, u32), color: Color) {
let position = Map::cell_to_pixel(cell);
self.canvas.set_draw_color(color);
self.canvas
.draw_rect(sdl2::rect::Rect::new(
position.0 as i32,
position.1 as i32,
24,
24,
))
.expect("Could not draw rectangle");
}
}

15
src/helper.rs Normal file
View File

@@ -0,0 +1,15 @@
pub fn is_adjacent(a: (u32, u32), b: (u32, u32), diagonal: bool) -> bool {
let (ax, ay) = a;
let (bx, by) = b;
if diagonal {
(ax == bx && (ay == by + 1 || ay == by - 1))
|| (ay == by && (ax == bx + 1 || ax == bx - 1))
|| (ax == bx + 1 && ay == by + 1)
|| (ax == bx + 1 && ay == by - 1)
|| (ax == bx - 1 && ay == by + 1)
|| (ax == bx - 1 && ay == by - 1)
} else {
(ax == bx && (ay == by + 1 || ay == by - 1))
|| (ay == by && (ax == bx + 1 || ax == bx - 1))
}
}

View File

@@ -1,45 +1,155 @@
use sdl2::pixels::Color;
use crate::constants::{WINDOW_HEIGHT, WINDOW_WIDTH};
use crate::game::Game;
use sdl2::event::Event;
use sdl2::image::LoadTexture;
use sdl2::keyboard::{Keycode, Mod};
use std::time::Duration;
use crate::constants::{WINDOW_WIDTH, WINDOW_HEIGHT};
use sdl2::keyboard::Keycode;
use spin_sleep::sleep;
use std::time::{Duration, Instant};
use tracing::event;
use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt;
mod animation;
mod constants;
mod board;
mod direction;
mod entity;
mod game;
mod map;
mod modulation;
mod pacman;
#[cfg(target_os = "emscripten")]
mod emscripten;
pub fn main() {
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem.window("Pac-Man", WINDOW_WIDTH, WINDOW_HEIGHT)
// Setup tracing
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.finish()
.with(ErrorLayer::default());
tracing::subscriber::set_global_default(subscriber).expect("Could not set global default");
let window = video_subsystem
.window("Pac-Man", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.build()
.expect("Could not initialize window");
let mut canvas = window.into_canvas().build().expect("Could not build canvas");
let texture_creator= canvas.texture_creator();
let mut canvas = window
.into_canvas()
.accelerated()
.build()
.expect("Could not build canvas");
let map_texture = texture_creator.load_texture("assets/map.png").expect("Could not load pacman texture");
canvas.copy(&map_texture, None, None).expect("Could not render texture on canvas");
canvas
.set_logical_size(WINDOW_WIDTH, WINDOW_HEIGHT)
.expect("Could not set logical size");
let mut event_pump = sdl_context.event_pump().expect("Could not get SDL EventPump");
'main: loop {
let texture_creator = canvas.texture_creator();
let mut game = Game::new(&mut canvas, &texture_creator);
let mut event_pump = sdl_context
.event_pump()
.expect("Could not get SDL EventPump");
// Initial draw and tick
game.draw();
game.tick();
let loop_time = Duration::from_secs(1) / 60;
let mut tick_no = 0u32;
// The start of a period of time over which we average the frame time.
let mut last_averaging_time = Instant::now();
let mut sleep_time = Duration::ZERO;
let mut paused = false;
event!(
tracing::Level::INFO,
"Starting game loop ({:.3}ms)",
loop_time.as_secs_f32() * 1000.0
);
let mut main_loop = || {
let start = Instant::now();
// TODO: Fix key repeat delay issues by using VecDeque for instant key repeat
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. } |
Event::KeyDown { keycode: Some(Keycode::Q), .. } => {
break 'main;
// Handle quitting keys or window close
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
..
} => {
event!(tracing::Level::INFO, "Exit requested. Exiting...");
return false;
}
Event::KeyDown {
keycode: Some(Keycode::P),
..
} => {
paused = !paused;
event!(
tracing::Level::INFO,
"{}",
if paused { "Paused" } else { "Unpaused" }
);
}
Event::KeyDown { keycode, .. } => {
game.keyboard_event(keycode.unwrap());
}
event @ Event::KeyDown { .. } => {
println!("{:?}", event);
},
_ => {}
}
}
canvas.present();
::std::thread::sleep(Duration::from_millis(10));
// TODO: Proper pausing implementation that does not interfere with statistic gathering
if !paused {
game.tick();
game.draw();
}
if start.elapsed() < loop_time {
let time = loop_time - start.elapsed();
sleep(time);
sleep_time += time;
} else {
event!(
tracing::Level::WARN,
"Game loop behind schedule by: {:?}",
start.elapsed() - loop_time
);
}
tick_no += 1;
const PERIOD: u32 = 60 * 60;
let tick_mod = tick_no % PERIOD;
if tick_mod % PERIOD == 0 {
let average_fps = PERIOD as f32 / last_averaging_time.elapsed().as_secs_f32();
let average_sleep = sleep_time / PERIOD;
let average_process = loop_time - average_sleep;
event!(
tracing::Level::DEBUG,
"Timing Averages [fps={}] [sleep={:?}] [process={:?}]",
average_fps,
average_sleep,
average_process
);
sleep_time = Duration::ZERO;
last_averaging_time = Instant::now();
}
true
};
loop {
if !main_loop() {
break;
}
}
}
}

59
src/map.rs Normal file
View File

@@ -0,0 +1,59 @@
use crate::constants::MapTile;
use crate::constants::{BOARD_HEIGHT, BOARD_WIDTH};
pub struct Map {
inner: [[MapTile; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize],
}
impl Map {
pub fn new(raw_board: [&str; BOARD_HEIGHT as usize]) -> Map {
let mut inner = [[MapTile::Empty; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize];
for y in 0..BOARD_HEIGHT as usize {
let line = raw_board[y];
for x in 0..BOARD_WIDTH as usize {
if x >= line.len() {
break;
}
let i = (y * (BOARD_WIDTH as usize) + x) as usize;
let character = line
.chars()
.nth(x as usize)
.unwrap_or_else(|| panic!("Could not get character at {} = ({}, {})", i, x, y));
let tile = match character {
'#' => MapTile::Wall,
'.' => MapTile::Pellet,
'o' => MapTile::PowerPellet,
' ' => MapTile::Empty,
c @ '0' | c @ '1' | c @ '2' | c @ '3' | c @ '4' => {
MapTile::StartingPosition(c.to_digit(10).unwrap() as u8)
}
'=' => MapTile::Empty,
_ => panic!("Unknown character in board: {}", character),
};
inner[x as usize][y as usize] = tile;
}
}
Map { inner: inner }
}
pub fn get_tile(&self, cell: (i32, i32)) -> Option<MapTile> {
let x = cell.0 as usize;
let y = cell.1 as usize;
if x >= BOARD_WIDTH as usize || y >= BOARD_HEIGHT as usize {
return None;
}
Some(self.inner[x][y])
}
pub fn cell_to_pixel(cell: (u32, u32)) -> (i32, i32) {
((cell.0 as i32) * 24, ((cell.1 + 3) as i32) * 24)
}
}

48
src/modulation.rs Normal file
View File

@@ -0,0 +1,48 @@
/// A tick modulator allows you to slow down operations by a percentage.
///
/// Unfortunately, switching to floating point numbers for entities can induce floating point errors, slow down calculations
/// and make the game less deterministic. This is why we use a speed modulator instead.
/// Additionally, with small integers, lowering the speed by a percentage is not possible. For example, if we have a speed of 2,
/// and we want to slow it down by 10%, we would need to slow it down by 0.2. However, since we are using integers, we can't.
/// The only amount you can slow it down by is 1, which is 50% of the speed.
///
/// The basic principle of the Speed Modulator is to instead 'skip' movement ticks every now and then.
/// At 60 ticks per second, skips could happen several times per second, or once every few seconds.
/// Whatever it be, as long as the tick rate is high enough, the human eye will not be able to tell the difference.
///
/// For example, if we want to slow down the speed by 10%, we would need to skip every 10th tick.
pub trait TickModulator {
fn new(percent: f32) -> Self;
fn next(&mut self) -> bool;
}
pub struct SimpleTickModulator {
tick_count: u32,
ticks_left: u32,
}
// TODO: Add tests
// TODO: Look into average precision, binary code modulation strategy
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,
}
}
fn next(&mut self) -> bool {
self.ticks_left -= 1;
// Return whether or not we should skip this tick
if self.ticks_left == 0 {
// We've reached the tick to skip, reset the counter
self.ticks_left = self.tick_count;
false
} else {
true
}
}
}

142
src/pacman.rs Normal file
View File

@@ -0,0 +1,142 @@
use std::rc::Rc;
use sdl2::{
render::{Canvas, Texture},
video::Window,
};
use tracing::event;
use crate::{
animation::AnimatedTexture,
constants::MapTile,
constants::{BOARD_OFFSET, CELL_SIZE},
direction::Direction,
entity::Entity,
map::Map,
modulation::{SimpleTickModulator, TickModulator},
};
pub struct Pacman<'a> {
// Absolute position on the board (precise)
pub position: (i32, i32),
pub direction: Direction,
pub next_direction: Option<Direction>,
pub stopped: bool,
map: Rc<Map>,
speed: u32,
modulation: SimpleTickModulator,
sprite: AnimatedTexture<'a>,
}
impl Pacman<'_> {
pub fn new<'a>(starting_position: (u32, u32), atlas: Texture<'a>, map: Rc<Map>) -> Pacman<'a> {
Pacman {
position: Map::cell_to_pixel(starting_position),
direction: Direction::Right,
next_direction: None,
speed: 2,
map,
stopped: false,
modulation: SimpleTickModulator::new(0.9333),
sprite: AnimatedTexture::new(atlas, 4, 3, 32, 32, Some((-4, -4))),
}
}
pub fn render(&mut self, canvas: &mut Canvas<Window>) {
// When stopped, render the last frame of the animation
if self.stopped {
self.sprite
.render_until(canvas, self.position, self.direction, 2);
} else {
self.sprite.render(canvas, self.position, self.direction);
}
}
pub fn next_cell(&self, direction: Option<Direction>) -> (i32, i32) {
let (x, y) = direction.unwrap_or(self.direction).offset();
let cell = self.cell_position();
(cell.0 as i32 + x, cell.1 as i32 + y)
}
fn handle_requested_direction(&mut self) {
if self.next_direction.is_none() {
return;
}
if self.next_direction.unwrap() == self.direction {
self.next_direction = None;
return;
}
let proposed_next_cell = self.next_cell(self.next_direction);
let proposed_next_tile = self
.map
.get_tile(proposed_next_cell)
.unwrap_or(MapTile::Empty);
if proposed_next_tile != MapTile::Wall {
self.direction = self.next_direction.unwrap();
self.next_direction = None;
}
}
}
impl Entity for Pacman<'_> {
fn is_colliding(&self, other: &dyn Entity) -> bool {
let (x, y) = self.position();
let (other_x, other_y) = other.position();
x == other_x && y == other_y
}
fn position(&self) -> (i32, i32) {
self.position
}
fn cell_position(&self) -> (u32, u32) {
let (x, y) = self.position;
(
(x as u32 / CELL_SIZE) - BOARD_OFFSET.0,
(y as u32 / CELL_SIZE) - BOARD_OFFSET.1,
)
}
fn internal_position(&self) -> (u32, u32) {
let (x, y) = self.position();
(x as u32 % CELL_SIZE, y as u32 % CELL_SIZE)
}
fn tick(&mut self) {
let can_change = self.internal_position() == (0, 0);
if can_change {
self.handle_requested_direction();
let next = self.next_cell(None);
let next_tile = self.map.get_tile(next).unwrap_or(MapTile::Empty);
if !self.stopped && next_tile == MapTile::Wall {
event!(tracing::Level::DEBUG, "Wall collision. Stopping.");
self.stopped = true;
} else if self.stopped && next_tile != MapTile::Wall {
event!(tracing::Level::DEBUG, "Wall collision resolved. Moving.");
self.stopped = false;
}
}
if !self.stopped && self.modulation.next() {
let speed = self.speed as i32;
match self.direction {
Direction::Right => {
self.position.0 += speed;
}
Direction::Left => {
self.position.0 -= speed;
}
Direction::Up => {
self.position.1 -= speed;
}
Direction::Down => {
self.position.1 += speed;
}
}
}
}
}