From bc759f1ed45d2dae7b6a572806f0a017cbd4bd47 Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 14 Aug 2025 15:06:56 -0500 Subject: [PATCH 01/44] refactor!: begin switching to bevy ECS, all tests broken, all systems broken --- Cargo.lock | 703 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/app.rs | 111 +++--- src/ecs/mod.rs | 134 ++++++++ src/ecs/render.rs | 69 ++++ src/entity/collision.rs | 214 ++++++------ src/entity/direction.rs | 3 +- src/entity/ghost.rs | 448 ++++++++++++------------ src/entity/graph.rs | 5 +- src/entity/item.rs | 206 +++++------ src/entity/pacman.rs | 196 +++++------ src/entity/trait.rs | 196 +++++------ src/entity/traversal.rs | 376 +++++++++----------- src/error.rs | 4 +- src/game/events.rs | 4 +- src/game/mod.rs | 743 +++++++++++++++++++++++----------------- src/game/state.rs | 264 +++++++------- src/input/commands.rs | 2 +- src/input/mod.rs | 35 +- src/lib.rs | 1 + src/main.rs | 1 + src/map/builder.rs | 70 ++-- src/texture/sprite.rs | 1 + 23 files changed, 2373 insertions(+), 1414 deletions(-) create mode 100644 src/ecs/mod.rs create mode 100644 src/ecs/render.rs diff --git a/Cargo.lock b/Cargo.lock index 7faa84e..d66b50f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,202 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-executor" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bevy_ecs" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c2bf6521aae57a0ec3487c4bfb59e36c4a378e834b626a4bea6a885af2fdfe7" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.9.1", + "bumpalo", + "concurrent-queue", + "derive_more", + "disqualified", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "smallvec", + "thiserror", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38748d6f3339175c582d751f410fb60a93baf2286c3deb7efebb0878dce7f413" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052eeebcb8e7e072beea5031b227d9a290f8a7fbbb947573ab6ec81df0fb94be" +dependencies = [ + "parking_lot", + "proc-macro2", + "quote", + "syn", + "toml_edit", +] + +[[package]] +name = "bevy_platform" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7573dc824a1b08b4c93fdbe421c53e1e8188e9ca1dd74a414455fe571facb47" +dependencies = [ + "cfg-if", + "critical-section", + "foldhash", + "hashbrown", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", +] + +[[package]] +name = "bevy_ptr" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7370d0e46b60e071917711d0860721f5347bc958bf325975ae6913a5dfcf01" + +[[package]] +name = "bevy_reflect" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daeb91a63a1a4df00aa58da8cc4ddbd4b9f16ab8bb647c5553eb156ce36fa8c2" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs", + "erased-serde", + "foldhash", + "glam 0.29.3", + "serde", + "smallvec", + "smol_str", + "thiserror", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ddadc55fe16b45faaa54ab2f9cb00548013c74812e8b018aa172387103cce6" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn", + "uuid", +] + +[[package]] +name = "bevy_tasks" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b674242641cab680688fc3b850243b351c1af49d4f3417a576debd6cca8dcf5" +dependencies = [ + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "cfg-if", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless", +] + +[[package]] +name = "bevy_utils" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f7a8905a125d2017e8561beefb7f2f5e67e93ff6324f072ad87c5fd6ec3b99" +dependencies = [ + "bevy_platform", + "thread_local", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -34,6 +224,21 @@ name = "bitflags" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +dependencies = [ + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "c_vec" @@ -47,6 +252,37 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "deprecate-until" version = "0.1.1" @@ -59,12 +295,98 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "disqualified" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd" + +[[package]] +name = "downcast-rs" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -77,17 +399,50 @@ dependencies = [ "wasi", ] +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +dependencies = [ + "serde", +] + [[package]] name = "glam" version = "0.30.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2d1aab06663bdce00d6ca5e5ed586ec8d18033a771906c993a1e3755b368d85" +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "equivalent", + "serde", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] [[package]] name = "heck" @@ -120,6 +475,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -132,6 +497,16 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.20" @@ -153,6 +528,12 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -189,7 +570,8 @@ name = "pacman" version = "0.2.0" dependencies = [ "anyhow", - "glam", + "bevy_ecs", + "glam 0.30.5", "lazy_static", "libc", "once_cell", @@ -210,6 +592,35 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + [[package]] name = "pathfinding" version = "4.14.0" @@ -272,6 +683,21 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -329,6 +755,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.1", +] + [[package]] name = "regex" version = "1.11.1" @@ -379,12 +814,24 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sdl2" version = "0.38.0" @@ -463,12 +910,36 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "portable-atomic", +] + [[package]] name = "spin_sleep" version = "1.3.2" @@ -478,6 +949,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "strum" version = "0.27.2" @@ -537,6 +1014,23 @@ dependencies = [ "once_cell", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tracing" version = "0.1.41" @@ -608,18 +1102,53 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "unicode-ident" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "getrandom", + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -641,6 +1170,87 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.9.1", + "js-sys", + "log", + "serde", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -669,7 +1279,23 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -678,64 +1304,121 @@ version = "0.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 35d3fed..bb4b470 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ smallvec = "1.15.1" strum = "0.27.2" strum_macros = "0.27.2" phf = { version = "0.11", features = ["macros"] } +bevy_ecs = "0.16.1" [profile.release] lto = true diff --git a/src/app.rs b/src/app.rs index 0f03791..826e1bf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,28 +1,20 @@ use std::time::{Duration, Instant}; use glam::Vec2; -use sdl2::event::{Event, WindowEvent}; use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::ttf::Sdl2TtfContext; use sdl2::video::{Window, WindowContext}; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; -use tracing::{error, info, warn}; +use tracing::{error, warn}; use crate::error::{GameError, GameResult}; use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; use crate::game::Game; -use crate::input::commands::GameCommand; -use crate::input::InputSystem; use crate::platform::get_platform; pub struct App { - game: Game, - input_system: InputSystem, - canvas: Canvas, - backbuffer: Texture<'static>, - event_pump: &'static mut EventPump, - + pub game: Game, last_tick: Instant, focused: bool, cursor_pos: Vec2, @@ -54,39 +46,32 @@ impl App { .build() .map_err(|e| GameError::Sdl(e.to_string()))?; - let mut canvas = window - .into_canvas() - .accelerated() - .present_vsync() - .build() - .map_err(|e| GameError::Sdl(e.to_string()))?; + let mut canvas = Box::leak(Box::new( + window + .into_canvas() + .accelerated() + .present_vsync() + .build() + .map_err(|e| GameError::Sdl(e.to_string()))?, + )); canvas .set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) .map_err(|e| GameError::Sdl(e.to_string()))?; - let texture_creator: &'static TextureCreator = Box::leak(Box::new(canvas.texture_creator())); + let texture_creator: &'static mut TextureCreator = Box::leak(Box::new(canvas.texture_creator())); - let mut game = Game::new(texture_creator)?; + let game = Game::new(canvas, texture_creator, event_pump)?; // game.audio.set_mute(cfg!(debug_assertions)); - let mut backbuffer = texture_creator - .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) - .map_err(|e| GameError::Sdl(e.to_string()))?; - backbuffer.set_scale_mode(ScaleMode::Nearest); - // Initial draw - game.draw(&mut canvas, &mut backbuffer) - .map_err(|e| GameError::Sdl(e.to_string()))?; - game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO) - .map_err(|e| GameError::Sdl(e.to_string()))?; + // game.draw(&mut canvas, &mut backbuffer) + // .map_err(|e| GameError::Sdl(e.to_string()))?; + // game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO) + // .map_err(|e| GameError::Sdl(e.to_string()))?; Ok(App { game, - input_system: InputSystem::new(), - canvas, - event_pump, - backbuffer, focused: true, last_tick: Instant::now(), cursor_pos: Vec2::ZERO, @@ -97,34 +82,30 @@ impl App { { let start = Instant::now(); - for event in self.event_pump.poll_iter() { - match event { - Event::Window { win_event, .. } => match win_event { - WindowEvent::FocusGained => { - self.focused = true; - } - WindowEvent::FocusLost => { - self.focused = false; - } - _ => {} - }, - Event::MouseMotion { x, y, .. } => { - // Convert window coordinates to logical coordinates - self.cursor_pos = Vec2::new(x as f32, y as f32); - } - _ => {} - } - - if let Some(command) = self.input_system.handle_event(&event) { - match command { - GameCommand::Exit => { - info!("Exit requested. Exiting..."); - return false; - } - _ => self.game.post_event(command.into()), - } - } - } + // for event in self + // .game + // .world + // .get_non_send_resource_mut::<&'static mut EventPump>() + // .unwrap() + // .poll_iter() + // { + // match event { + // Event::Window { win_event, .. } => match win_event { + // WindowEvent::FocusGained => { + // self.focused = true; + // } + // WindowEvent::FocusLost => { + // self.focused = false; + // } + // _ => {} + // }, + // 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(); self.last_tick = Instant::now(); @@ -135,15 +116,9 @@ impl App { return false; } - if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) { - 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 { let time = LOOP_TIME.saturating_sub(start.elapsed()); diff --git a/src/ecs/mod.rs b/src/ecs/mod.rs new file mode 100644 index 0000000..90f1d92 --- /dev/null +++ b/src/ecs/mod.rs @@ -0,0 +1,134 @@ +//! 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::{directional::DirectionalAnimatedTexture, sprite::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. +#[derive(Component)] +pub struct Renderable { + pub sprite: Sprite, + pub layer: u8, +} + +/// 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 { + 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 { + 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 speed: f32, +} + +#[derive(Bundle)] +pub struct PlayerBundle { + pub player: PlayerControlled, + pub position: Position, + pub velocity: Velocity, + pub sprite: Renderable, +} + +#[derive(Resource)] +pub struct GlobalState { + pub exit: bool, +} + +#[derive(Resource)] +pub struct DeltaTime(pub f32); + +pub mod render; diff --git a/src/ecs/render.rs b/src/ecs/render.rs new file mode 100644 index 0000000..a3d3e2d --- /dev/null +++ b/src/ecs/render.rs @@ -0,0 +1,69 @@ +use crate::ecs::{render, Position, Renderable}; +use crate::entity::graph::Graph; +use crate::error::{EntityError, GameError, TextureError}; +use crate::map::builder::Map; +use crate::texture::sprite::{Sprite, SpriteAtlas}; +use bevy_ecs::entity::Entity; +use bevy_ecs::event::EventWriter; +use bevy_ecs::query::With; +use bevy_ecs::system::{NonSendMut, Query, Res}; +use sdl2::render::{Canvas, Texture}; +use sdl2::video::Window; + +pub struct MapTextureResource(pub Texture<'static>); +pub struct BackbufferResource(pub Texture<'static>); + +pub fn render_system( + mut canvas: NonSendMut<&mut Canvas>, + map_texture: NonSendMut, + mut backbuffer: NonSendMut, + mut atlas: NonSendMut, + map: Res, + renderables: Query<(Entity, &Renderable, &Position)>, + mut errors: EventWriter, +) { + // 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 (_, renderable, position) in renderables.iter() { + let pos = position.get_pixel_pos(&map.graph); + match pos { + Ok(pos) => { + renderable + .sprite + .render(backbuffer_canvas, &mut atlas, pos) + .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(); +} diff --git a/src/entity/collision.rs b/src/entity/collision.rs index 9ca4a28..0401751 100644 --- a/src/entity/collision.rs +++ b/src/entity/collision.rs @@ -1,128 +1,128 @@ -use smallvec::SmallVec; -use std::collections::HashMap; +// use smallvec::SmallVec; +// use std::collections::HashMap; -use crate::entity::{graph::NodeId, traversal::Position}; +// use crate::entity::{graph::NodeId, traversal::Position}; -/// Trait for entities that can participate in collision detection. -pub trait Collidable { - /// Returns the current position of this entity. - fn position(&self) -> Position; +// /// Trait for entities that can participate in collision detection. +// pub trait Collidable { +// /// Returns the current position of this entity. +// fn position(&self) -> Position; - /// Checks if this entity is colliding with another entity. - #[allow(dead_code)] - fn is_colliding_with(&self, other: &dyn Collidable) -> bool { - positions_overlap(&self.position(), &other.position()) - } -} +// /// Checks if this entity is colliding with another entity. +// #[allow(dead_code)] +// fn is_colliding_with(&self, other: &dyn Collidable) -> bool { +// positions_overlap(&self.position(), &other.position()) +// } +// } -/// System for tracking entities by their positions for efficient collision detection. -#[derive(Default)] -pub struct CollisionSystem { - /// Maps node IDs to lists of entity IDs that are at that node - node_entities: HashMap>, - /// Maps entity IDs to their current positions - entity_positions: HashMap, - /// Next available entity ID - next_id: EntityId, -} +// /// System for tracking entities by their positions for efficient collision detection. +// #[derive(Default)] +// pub struct CollisionSystem { +// /// Maps node IDs to lists of entity IDs that are at that node +// node_entities: HashMap>, +// /// Maps entity IDs to their current positions +// entity_positions: HashMap, +// /// Next available entity ID +// next_id: EntityId, +// } -/// Unique identifier for an entity in the collision system -pub type EntityId = u32; +// /// Unique identifier for an entity in the collision system +// pub type EntityId = u32; -impl CollisionSystem { - /// Registers an entity with the collision system and returns its ID - pub fn register_entity(&mut self, position: Position) -> EntityId { - let id = self.next_id; - self.next_id += 1; +// impl CollisionSystem { +// /// Registers an entity with the collision system and returns its ID +// pub fn register_entity(&mut self, position: Position) -> EntityId { +// let id = self.next_id; +// self.next_id += 1; - self.entity_positions.insert(id, position); - self.update_node_entities(id, position); +// self.entity_positions.insert(id, position); +// self.update_node_entities(id, position); - id - } +// id +// } - /// Updates an entity's position - pub fn update_position(&mut self, entity_id: EntityId, new_position: Position) { - if let Some(old_position) = self.entity_positions.get(&entity_id) { - // Remove from old nodes - self.remove_from_nodes(entity_id, *old_position); - } +// /// Updates an entity's position +// pub fn update_position(&mut self, entity_id: EntityId, new_position: Position) { +// if let Some(old_position) = self.entity_positions.get(&entity_id) { +// // Remove from old nodes +// self.remove_from_nodes(entity_id, *old_position); +// } - // Update position and add to new nodes - self.entity_positions.insert(entity_id, new_position); - self.update_node_entities(entity_id, new_position); - } +// // Update position and add to new nodes +// self.entity_positions.insert(entity_id, new_position); +// self.update_node_entities(entity_id, new_position); +// } - /// Removes an entity from the collision system - #[allow(dead_code)] - pub fn remove_entity(&mut self, entity_id: EntityId) { - if let Some(position) = self.entity_positions.remove(&entity_id) { - self.remove_from_nodes(entity_id, position); - } - } +// /// Removes an entity from the collision system +// #[allow(dead_code)] +// pub fn remove_entity(&mut self, entity_id: EntityId) { +// if let Some(position) = self.entity_positions.remove(&entity_id) { +// self.remove_from_nodes(entity_id, position); +// } +// } - /// Gets all entity IDs at a specific node - pub fn entities_at_node(&self, node: NodeId) -> &[EntityId] { - self.node_entities.get(&node).map(|v| v.as_slice()).unwrap_or(&[]) - } +// /// Gets all entity IDs at a specific node +// pub fn entities_at_node(&self, node: NodeId) -> &[EntityId] { +// self.node_entities.get(&node).map(|v| v.as_slice()).unwrap_or(&[]) +// } - /// Gets all entity IDs that could collide with an entity at the given position - pub fn potential_collisions(&self, position: &Position) -> Vec { - let mut collisions = Vec::new(); - let nodes = get_nodes(position); +// /// Gets all entity IDs that could collide with an entity at the given position +// pub fn potential_collisions(&self, position: &Position) -> Vec { +// let mut collisions = Vec::new(); +// let nodes = get_nodes(position); - for node in nodes { - collisions.extend(self.entities_at_node(node)); - } +// for node in nodes { +// collisions.extend(self.entities_at_node(node)); +// } - // Remove duplicates - collisions.sort_unstable(); - collisions.dedup(); - collisions - } +// // Remove duplicates +// collisions.sort_unstable(); +// collisions.dedup(); +// collisions +// } - /// Updates the node_entities map when an entity's position changes - fn update_node_entities(&mut self, entity_id: EntityId, position: Position) { - let nodes = get_nodes(&position); - for node in nodes { - self.node_entities.entry(node).or_default().push(entity_id); - } - } +// /// Updates the node_entities map when an entity's position changes +// fn update_node_entities(&mut self, entity_id: EntityId, position: Position) { +// let nodes = get_nodes(&position); +// for node in nodes { +// self.node_entities.entry(node).or_default().push(entity_id); +// } +// } - /// Removes an entity from all nodes it was previously at - fn remove_from_nodes(&mut self, entity_id: EntityId, position: Position) { - let nodes = get_nodes(&position); - for node in nodes { - if let Some(entities) = self.node_entities.get_mut(&node) { - entities.retain(|&id| id != entity_id); - if entities.is_empty() { - self.node_entities.remove(&node); - } - } - } - } -} +// /// Removes an entity from all nodes it was previously at +// fn remove_from_nodes(&mut self, entity_id: EntityId, position: Position) { +// let nodes = get_nodes(&position); +// for node in nodes { +// if let Some(entities) = self.node_entities.get_mut(&node) { +// entities.retain(|&id| id != entity_id); +// if entities.is_empty() { +// self.node_entities.remove(&node); +// } +// } +// } +// } +// } -/// Checks if two positions overlap (entities are at the same location). -fn positions_overlap(a: &Position, b: &Position) -> bool { - let a_nodes = get_nodes(a); - let b_nodes = get_nodes(b); +// /// Checks if two positions overlap (entities are at the same location). +// fn positions_overlap(a: &Position, b: &Position) -> bool { +// let a_nodes = get_nodes(a); +// let b_nodes = get_nodes(b); - // Check if any nodes overlap - a_nodes.iter().any(|a_node| b_nodes.contains(a_node)) +// // Check if any nodes overlap +// a_nodes.iter().any(|a_node| b_nodes.contains(a_node)) - // TODO: More complex overlap detection, the above is a simple check, but it could become an early filter for more precise calculations later -} +// // TODO: More complex overlap detection, the above is a simple check, but it could become an early filter for more precise calculations later +// } -/// Gets all nodes that an entity is currently at or between. -fn get_nodes(pos: &Position) -> SmallVec<[NodeId; 2]> { - let mut nodes = SmallVec::new(); - match pos { - Position::AtNode(node) => nodes.push(*node), - Position::BetweenNodes { from, to, .. } => { - nodes.push(*from); - nodes.push(*to); - } - } - nodes -} +// /// Gets all nodes that an entity is currently at or between. +// fn get_nodes(pos: &Position) -> SmallVec<[NodeId; 2]> { +// let mut nodes = SmallVec::new(); +// match pos { +// Position::AtNode(node) => nodes.push(*node), +// Position::BetweenNodes { from, to, .. } => { +// nodes.push(*from); +// nodes.push(*to); +// } +// } +// nodes +// } diff --git a/src/entity/direction.rs b/src/entity/direction.rs index b6466f9..f981076 100644 --- a/src/entity/direction.rs +++ b/src/entity/direction.rs @@ -1,12 +1,13 @@ use glam::IVec2; /// The four cardinal directions. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[repr(usize)] pub enum Direction { Up, Down, Left, + #[default] Right, } diff --git a/src/entity/ghost.rs b/src/entity/ghost.rs index 0b3ec0e..7638d11 100644 --- a/src/entity/ghost.rs +++ b/src/entity/ghost.rs @@ -1,254 +1,254 @@ -//! Ghost entity implementation. -//! -//! This module contains the ghost character logic, including movement, -//! animation, and rendering. Ghosts move through the game graph using -//! a traverser and display directional animated textures. +// //! Ghost entity implementation. +// //! +// //! This module contains the ghost character logic, including movement, +// //! animation, and rendering. Ghosts move through the game graph using +// //! a traverser and display directional animated textures. -use pathfinding::prelude::dijkstra; -use rand::prelude::*; -use smallvec::SmallVec; -use tracing::error; +// use pathfinding::prelude::dijkstra; +// use rand::prelude::*; +// use smallvec::SmallVec; +// use tracing::error; -use crate::entity::{ - collision::Collidable, - direction::Direction, - graph::{Edge, EdgePermissions, Graph, NodeId}, - r#trait::Entity, - traversal::Traverser, -}; -use crate::texture::animated::AnimatedTexture; -use crate::texture::directional::DirectionalAnimatedTexture; -use crate::texture::sprite::SpriteAtlas; +// use crate::entity::{ +// collision::Collidable, +// direction::Direction, +// graph::{Edge, EdgePermissions, Graph, NodeId}, +// r#trait::Entity, +// traversal::Traverser, +// }; +// use crate::texture::animated::AnimatedTexture; +// use crate::texture::directional::DirectionalAnimatedTexture; +// use crate::texture::sprite::SpriteAtlas; -use crate::error::{EntityError, GameError, GameResult, TextureError}; +// use crate::error::{EntityError, GameError, GameResult, TextureError}; -/// Determines if a ghost can traverse a given edge. -/// -/// Ghosts can move through edges that allow all entities or ghost-only edges. -fn can_ghost_traverse(edge: Edge) -> bool { - matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly) -} +// /// Determines if a ghost can traverse a given edge. +// /// +// /// Ghosts can move through edges that allow all entities or ghost-only edges. +// fn can_ghost_traverse(edge: Edge) -> bool { +// matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly) +// } -/// The four classic ghost types. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GhostType { - Blinky, - Pinky, - Inky, - Clyde, -} +// /// The four classic ghost types. +// #[derive(Debug, Clone, Copy, PartialEq, Eq)] +// pub enum GhostType { +// Blinky, +// Pinky, +// Inky, +// Clyde, +// } -impl GhostType { - /// Returns the ghost type name for atlas lookups. - pub fn as_str(self) -> &'static str { - match self { - GhostType::Blinky => "blinky", - GhostType::Pinky => "pinky", - GhostType::Inky => "inky", - GhostType::Clyde => "clyde", - } - } +// impl GhostType { +// /// Returns the ghost type name for atlas lookups. +// pub fn as_str(self) -> &'static str { +// match self { +// GhostType::Blinky => "blinky", +// GhostType::Pinky => "pinky", +// GhostType::Inky => "inky", +// GhostType::Clyde => "clyde", +// } +// } - /// Returns the base movement speed for this ghost type. - pub fn base_speed(self) -> f32 { - match self { - GhostType::Blinky => 1.0, - GhostType::Pinky => 0.95, - GhostType::Inky => 0.9, - GhostType::Clyde => 0.85, - } - } -} +// /// Returns the base movement speed for this ghost type. +// pub fn base_speed(self) -> f32 { +// match self { +// GhostType::Blinky => 1.0, +// GhostType::Pinky => 0.95, +// GhostType::Inky => 0.9, +// GhostType::Clyde => 0.85, +// } +// } +// } -/// A ghost entity that roams the game world. -/// -/// Ghosts move through the game world using a graph-based navigation system -/// and display directional animated sprites. They randomly choose directions -/// at each intersection. -pub struct Ghost { - /// Handles movement through the game graph - pub traverser: Traverser, - /// The type of ghost (affects appearance and speed) - pub ghost_type: GhostType, - /// Manages directional animated textures for different movement states - texture: DirectionalAnimatedTexture, - /// Current movement speed - speed: f32, -} +// /// A ghost entity that roams the game world. +// /// +// /// Ghosts move through the game world using a graph-based navigation system +// /// and display directional animated sprites. They randomly choose directions +// /// at each intersection. +// pub struct Ghost { +// /// Handles movement through the game graph +// pub traverser: Traverser, +// /// The type of ghost (affects appearance and speed) +// pub ghost_type: GhostType, +// /// Manages directional animated textures for different movement states +// texture: DirectionalAnimatedTexture, +// /// Current movement speed +// speed: f32, +// } -impl Entity for Ghost { - fn traverser(&self) -> &Traverser { - &self.traverser - } +// impl Entity for Ghost { +// fn traverser(&self) -> &Traverser { +// &self.traverser +// } - fn traverser_mut(&mut self) -> &mut Traverser { - &mut self.traverser - } +// fn traverser_mut(&mut self) -> &mut Traverser { +// &mut self.traverser +// } - fn texture(&self) -> &DirectionalAnimatedTexture { - &self.texture - } +// fn texture(&self) -> &DirectionalAnimatedTexture { +// &self.texture +// } - fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture { - &mut self.texture - } +// fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture { +// &mut self.texture +// } - fn speed(&self) -> f32 { - self.speed - } +// fn speed(&self) -> f32 { +// self.speed +// } - fn can_traverse(&self, edge: Edge) -> bool { - can_ghost_traverse(edge) - } +// fn can_traverse(&self, edge: Edge) -> bool { +// can_ghost_traverse(edge) +// } - fn tick(&mut self, dt: f32, graph: &Graph) { - // Choose random direction when at a node - if self.traverser.position.is_at_node() { - self.choose_random_direction(graph); - } +// fn tick(&mut self, dt: f32, graph: &Graph) { +// // Choose random direction when at a node +// if self.traverser.position.is_at_node() { +// self.choose_random_direction(graph); +// } - if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) { - error!("Ghost movement error: {}", e); - } - self.texture.tick(dt); - } -} +// if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) { +// error!("Ghost movement error: {}", e); +// } +// self.texture.tick(dt); +// } +// } -impl Ghost { - /// Creates a new ghost instance at the specified starting node. - /// - /// Sets up animated textures for all four directions with moving and stopped states. - /// The moving animation cycles through two sprite variants. - pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult { - let mut textures = [None, None, None, None]; - let mut stopped_textures = [None, None, None, None]; +// impl Ghost { +// /// Creates a new ghost instance at the specified starting node. +// /// +// /// Sets up animated textures for all four directions with moving and stopped states. +// /// The moving animation cycles through two sprite variants. +// pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult { +// let mut textures = [None, None, None, None]; +// let mut stopped_textures = [None, None, None, None]; - for direction in Direction::DIRECTIONS { - let moving_prefix = match direction { - Direction::Up => "up", - Direction::Down => "down", - Direction::Left => "left", - Direction::Right => "right", - }; - let moving_tiles = vec![ - SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) - .ok_or_else(|| { - GameError::Texture(TextureError::AtlasTileNotFound(format!( - "ghost/{}/{}_{}.png", - ghost_type.as_str(), - moving_prefix, - "a" - ))) - })?, - SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")) - .ok_or_else(|| { - GameError::Texture(TextureError::AtlasTileNotFound(format!( - "ghost/{}/{}_{}.png", - ghost_type.as_str(), - moving_prefix, - "b" - ))) - })?, - ]; +// for direction in Direction::DIRECTIONS { +// let moving_prefix = match direction { +// Direction::Up => "up", +// Direction::Down => "down", +// Direction::Left => "left", +// Direction::Right => "right", +// }; +// let moving_tiles = vec![ +// SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) +// .ok_or_else(|| { +// GameError::Texture(TextureError::AtlasTileNotFound(format!( +// "ghost/{}/{}_{}.png", +// ghost_type.as_str(), +// moving_prefix, +// "a" +// ))) +// })?, +// SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")) +// .ok_or_else(|| { +// GameError::Texture(TextureError::AtlasTileNotFound(format!( +// "ghost/{}/{}_{}.png", +// ghost_type.as_str(), +// moving_prefix, +// "b" +// ))) +// })?, +// ]; - let stopped_tiles = - vec![ - SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) - .ok_or_else(|| { - GameError::Texture(TextureError::AtlasTileNotFound(format!( - "ghost/{}/{}_{}.png", - ghost_type.as_str(), - moving_prefix, - "a" - ))) - })?, - ]; +// let stopped_tiles = +// vec![ +// SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) +// .ok_or_else(|| { +// GameError::Texture(TextureError::AtlasTileNotFound(format!( +// "ghost/{}/{}_{}.png", +// ghost_type.as_str(), +// moving_prefix, +// "a" +// ))) +// })?, +// ]; - textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?); - stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); - } +// textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?); +// stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); +// } - Ok(Self { - traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse), - ghost_type, - texture: DirectionalAnimatedTexture::new(textures, stopped_textures), - speed: ghost_type.base_speed(), - }) - } +// Ok(Self { +// traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse), +// ghost_type, +// texture: DirectionalAnimatedTexture::new(textures, stopped_textures), +// speed: ghost_type.base_speed(), +// }) +// } - /// Chooses a random available direction at the current intersection. - fn choose_random_direction(&mut self, graph: &Graph) { - let current_node = self.traverser.position.from_node_id(); - let intersection = &graph.adjacency_list[current_node]; +// /// Chooses a random available direction at the current intersection. +// fn choose_random_direction(&mut self, graph: &Graph) { +// let current_node = self.traverser.position.from_node_id(); +// let intersection = &graph.adjacency_list[current_node]; - // Collect all available directions - let mut available_directions = SmallVec::<[_; 4]>::new(); - for direction in Direction::DIRECTIONS { - if let Some(edge) = intersection.get(direction) { - if can_ghost_traverse(edge) { - available_directions.push(direction); - } - } - } - // Choose a random direction (avoid reversing unless necessary) - if !available_directions.is_empty() { - let mut rng = SmallRng::from_os_rng(); +// // Collect all available directions +// let mut available_directions = SmallVec::<[_; 4]>::new(); +// for direction in Direction::DIRECTIONS { +// if let Some(edge) = intersection.get(direction) { +// if can_ghost_traverse(edge) { +// available_directions.push(direction); +// } +// } +// } +// // Choose a random direction (avoid reversing unless necessary) +// if !available_directions.is_empty() { +// let mut rng = SmallRng::from_os_rng(); - // Filter out the opposite direction if possible, but allow it if we have limited options - let opposite = self.traverser.direction.opposite(); - let filtered_directions: Vec<_> = available_directions - .iter() - .filter(|&&dir| dir != opposite || available_directions.len() <= 2) - .collect(); +// // Filter out the opposite direction if possible, but allow it if we have limited options +// let opposite = self.traverser.direction.opposite(); +// let filtered_directions: Vec<_> = available_directions +// .iter() +// .filter(|&&dir| dir != opposite || available_directions.len() <= 2) +// .collect(); - if let Some(&random_direction) = filtered_directions.choose(&mut rng) { - self.traverser.set_next_direction(*random_direction); - } - } - } +// if let Some(&random_direction) = filtered_directions.choose(&mut rng) { +// self.traverser.set_next_direction(*random_direction); +// } +// } +// } - /// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm. - /// - /// Returns a vector of NodeIds representing the path, or an error if pathfinding fails. - /// The path includes the current node and the target node. - pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult> { - let start_node = self.traverser.position.from_node_id(); +// /// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm. +// /// +// /// Returns a vector of NodeIds representing the path, or an error if pathfinding fails. +// /// The path includes the current node and the target node. +// pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult> { +// let start_node = self.traverser.position.from_node_id(); - // Use Dijkstra's algorithm to find the shortest path - let result = dijkstra( - &start_node, - |&node_id| { - // Get all edges from the current node - graph.adjacency_list[node_id] - .edges() - .filter(|edge| can_ghost_traverse(*edge)) - .map(|edge| (edge.target, (edge.distance * 100.0) as u32)) - .collect::>() - }, - |&node_id| node_id == target, - ); +// // Use Dijkstra's algorithm to find the shortest path +// let result = dijkstra( +// &start_node, +// |&node_id| { +// // Get all edges from the current node +// graph.adjacency_list[node_id] +// .edges() +// .filter(|edge| can_ghost_traverse(*edge)) +// .map(|edge| (edge.target, (edge.distance * 100.0) as u32)) +// .collect::>() +// }, +// |&node_id| node_id == target, +// ); - result.map(|(path, _cost)| path).ok_or_else(|| { - GameError::Entity(EntityError::PathfindingFailed(format!( - "No path found from node {} to target {}", - start_node, target - ))) - }) - } +// result.map(|(path, _cost)| path).ok_or_else(|| { +// GameError::Entity(EntityError::PathfindingFailed(format!( +// "No path found from node {} to target {}", +// start_node, target +// ))) +// }) +// } - /// Returns the ghost's color for debug rendering. - pub fn debug_color(&self) -> sdl2::pixels::Color { - match self.ghost_type { - GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red - GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink - GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan - GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange - } - } -} +// /// Returns the ghost's color for debug rendering. +// pub fn debug_color(&self) -> sdl2::pixels::Color { +// match self.ghost_type { +// GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red +// GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink +// GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan +// GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange +// } +// } +// } -impl Collidable for Ghost { - fn position(&self) -> crate::entity::traversal::Position { - self.traverser.position - } -} +// impl Collidable for Ghost { +// fn position(&self) -> crate::entity::traversal::Position { +// self.traverser.position +// } +// } diff --git a/src/entity/graph.rs b/src/entity/graph.rs index b5867d8..e19f60f 100644 --- a/src/entity/graph.rs +++ b/src/entity/graph.rs @@ -1,9 +1,8 @@ use glam::Vec2; -use super::direction::Direction; +use crate::ecs::NodeId; -/// A unique identifier for a node, represented by its index in the graph's storage. -pub type NodeId = usize; +use super::direction::Direction; /// Defines who can traverse a given edge. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/src/entity/item.rs b/src/entity/item.rs index 8d9788e..1d662a9 100644 --- a/src/entity/item.rs +++ b/src/entity/item.rs @@ -1,117 +1,117 @@ -use crate::{ - constants, - entity::{collision::Collidable, graph::Graph}, - error::{EntityError, GameResult}, - texture::sprite::{Sprite, SpriteAtlas}, -}; -use sdl2::render::{Canvas, RenderTarget}; -use strum_macros::{EnumCount, EnumIter}; +// use crate::{ +// constants, +// entity::{collision::Collidable, graph::Graph}, +// error::{EntityError, GameResult}, +// texture::sprite::{Sprite, SpriteAtlas}, +// }; +// use sdl2::render::{Canvas, RenderTarget}; +// use strum_macros::{EnumCount, EnumIter}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ItemType { - Pellet, - Energizer, - #[allow(dead_code)] - Fruit { - kind: FruitKind, - }, -} +// #[derive(Debug, Clone, Copy, PartialEq, Eq)] +// pub enum ItemType { +// Pellet, +// Energizer, +// #[allow(dead_code)] +// Fruit { +// kind: FruitKind, +// }, +// } -impl ItemType { - pub fn get_score(self) -> u32 { - match self { - ItemType::Pellet => 10, - ItemType::Energizer => 50, - ItemType::Fruit { kind } => kind.get_score(), - } - } -} +// impl ItemType { +// pub fn get_score(self) -> u32 { +// match self { +// ItemType::Pellet => 10, +// ItemType::Energizer => 50, +// ItemType::Fruit { kind } => kind.get_score(), +// } +// } +// } -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, EnumCount)] -#[allow(dead_code)] -pub enum FruitKind { - Apple, - Strawberry, - Orange, - Melon, - Bell, - Key, - Galaxian, -} +// #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, EnumCount)] +// #[allow(dead_code)] +// pub enum FruitKind { +// Apple, +// Strawberry, +// Orange, +// Melon, +// Bell, +// Key, +// Galaxian, +// } -impl FruitKind { - #[allow(dead_code)] - pub fn index(self) -> u8 { - match self { - FruitKind::Apple => 0, - FruitKind::Strawberry => 1, - FruitKind::Orange => 2, - FruitKind::Melon => 3, - FruitKind::Bell => 4, - FruitKind::Key => 5, - FruitKind::Galaxian => 6, - } - } +// impl FruitKind { +// #[allow(dead_code)] +// pub fn index(self) -> u8 { +// match self { +// FruitKind::Apple => 0, +// FruitKind::Strawberry => 1, +// FruitKind::Orange => 2, +// FruitKind::Melon => 3, +// FruitKind::Bell => 4, +// FruitKind::Key => 5, +// FruitKind::Galaxian => 6, +// } +// } - pub fn get_score(self) -> u32 { - match self { - FruitKind::Apple => 100, - FruitKind::Strawberry => 300, - FruitKind::Orange => 500, - FruitKind::Melon => 700, - FruitKind::Bell => 1000, - FruitKind::Key => 2000, - FruitKind::Galaxian => 3000, - } - } -} +// pub fn get_score(self) -> u32 { +// match self { +// FruitKind::Apple => 100, +// FruitKind::Strawberry => 300, +// FruitKind::Orange => 500, +// FruitKind::Melon => 700, +// FruitKind::Bell => 1000, +// FruitKind::Key => 2000, +// FruitKind::Galaxian => 3000, +// } +// } +// } -pub struct Item { - pub node_index: usize, - pub item_type: ItemType, - pub sprite: Sprite, - pub collected: bool, -} +// pub struct Item { +// pub node_index: usize, +// pub item_type: ItemType, +// pub sprite: Sprite, +// pub collected: bool, +// } -impl Item { - pub fn new(node_index: usize, item_type: ItemType, sprite: Sprite) -> Self { - Self { - node_index, - item_type, - sprite, - collected: false, - } - } +// impl Item { +// pub fn new(node_index: usize, item_type: ItemType, sprite: Sprite) -> Self { +// Self { +// node_index, +// item_type, +// sprite, +// collected: false, +// } +// } - pub fn is_collected(&self) -> bool { - self.collected - } +// pub fn is_collected(&self) -> bool { +// self.collected +// } - pub fn collect(&mut self) { - self.collected = true; - } +// pub fn collect(&mut self) { +// self.collected = true; +// } - pub fn get_score(&self) -> u32 { - self.item_type.get_score() - } +// pub fn get_score(&self) -> u32 { +// self.item_type.get_score() +// } - pub fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> { - if self.collected { - return Ok(()); - } +// pub fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> { +// if self.collected { +// return Ok(()); +// } - let node = graph - .get_node(self.node_index) - .ok_or(EntityError::NodeNotFound(self.node_index))?; - let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2(); +// let node = graph +// .get_node(self.node_index) +// .ok_or(EntityError::NodeNotFound(self.node_index))?; +// let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2(); - self.sprite.render(canvas, atlas, position)?; - Ok(()) - } -} +// self.sprite.render(canvas, atlas, position)?; +// Ok(()) +// } +// } -impl Collidable for Item { - fn position(&self) -> crate::entity::traversal::Position { - crate::entity::traversal::Position::AtNode(self.node_index) - } -} +// impl Collidable for Item { +// fn position(&self) -> crate::entity::traversal::Position { +// crate::entity::traversal::Position::AtNode(self.node_index) +// } +// } diff --git a/src/entity/pacman.rs b/src/entity/pacman.rs index a1b2c8e..d881968 100644 --- a/src/entity/pacman.rs +++ b/src/entity/pacman.rs @@ -1,115 +1,115 @@ -//! Pac-Man entity implementation. -//! -//! This module contains the main player character logic, including movement, -//! animation, and rendering. Pac-Man moves through the game graph using -//! a traverser and displays directional animated textures. +// //! Pac-Man entity implementation. +// //! +// //! This module contains the main player character logic, including movement, +// //! animation, and rendering. Pac-Man moves through the game graph using +// //! a traverser and displays directional animated textures. -use crate::entity::{ - collision::Collidable, - direction::Direction, - graph::{Edge, EdgePermissions, Graph, NodeId}, - r#trait::Entity, - traversal::Traverser, -}; -use crate::texture::animated::AnimatedTexture; -use crate::texture::directional::DirectionalAnimatedTexture; -use crate::texture::sprite::SpriteAtlas; -use tracing::error; +// use crate::entity::{ +// collision::Collidable, +// direction::Direction, +// graph::{Edge, EdgePermissions, Graph, NodeId}, +// r#trait::Entity, +// traversal::Traverser, +// }; +// use crate::texture::animated::AnimatedTexture; +// use crate::texture::directional::DirectionalAnimatedTexture; +// use crate::texture::sprite::SpriteAtlas; +// use tracing::error; -use crate::error::{GameError, GameResult, TextureError}; +// use crate::error::{GameError, GameResult, TextureError}; -/// Determines if Pac-Man can traverse a given edge. -/// -/// Pac-Man can only move through edges that allow all entities. -fn can_pacman_traverse(edge: Edge) -> bool { - matches!(edge.permissions, EdgePermissions::All) -} +// /// Determines if Pac-Man can traverse a given edge. +// /// +// /// Pac-Man can only move through edges that allow all entities. +// fn can_pacman_traverse(edge: Edge) -> bool { +// matches!(edge.permissions, EdgePermissions::All) +// } -/// The main player character entity. -/// -/// Pac-Man moves through the game world using a graph-based navigation system -/// and displays directional animated sprites based on movement state. -pub struct Pacman { - /// Handles movement through the game graph - pub traverser: Traverser, - /// Manages directional animated textures for different movement states - texture: DirectionalAnimatedTexture, -} +// /// The main player character entity. +// /// +// /// Pac-Man moves through the game world using a graph-based navigation system +// /// and displays directional animated sprites based on movement state. +// pub struct Pacman { +// /// Handles movement through the game graph +// pub traverser: Traverser, +// /// Manages directional animated textures for different movement states +// texture: DirectionalAnimatedTexture, +// } -impl Entity for Pacman { - fn traverser(&self) -> &Traverser { - &self.traverser - } +// impl Entity for Pacman { +// fn traverser(&self) -> &Traverser { +// &self.traverser +// } - fn traverser_mut(&mut self) -> &mut Traverser { - &mut self.traverser - } +// fn traverser_mut(&mut self) -> &mut Traverser { +// &mut self.traverser +// } - fn texture(&self) -> &DirectionalAnimatedTexture { - &self.texture - } +// fn texture(&self) -> &DirectionalAnimatedTexture { +// &self.texture +// } - fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture { - &mut self.texture - } +// fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture { +// &mut self.texture +// } - fn speed(&self) -> f32 { - 1.125 - } +// fn speed(&self) -> f32 { +// 1.125 +// } - fn can_traverse(&self, edge: Edge) -> bool { - can_pacman_traverse(edge) - } +// fn can_traverse(&self, edge: Edge) -> bool { +// can_pacman_traverse(edge) +// } - fn tick(&mut self, dt: f32, graph: &Graph) { - if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) { - error!("Pac-Man movement error: {}", e); - } - self.texture.tick(dt); - } -} +// fn tick(&mut self, dt: f32, graph: &Graph) { +// if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) { +// error!("Pac-Man movement error: {}", e); +// } +// self.texture.tick(dt); +// } +// } -impl Pacman { - /// Creates a new Pac-Man instance at the specified starting node. - /// - /// Sets up animated textures for all four directions with moving and stopped states. - /// The moving animation cycles through open mouth, closed mouth, and full sprites. - pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult { - let mut textures = [None, None, None, None]; - let mut stopped_textures = [None, None, None, None]; +// impl Pacman { +// /// Creates a new Pac-Man instance at the specified starting node. +// /// +// /// Sets up animated textures for all four directions with moving and stopped states. +// /// The moving animation cycles through open mouth, closed mouth, and full sprites. +// pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult { +// let mut textures = [None, None, None, None]; +// let mut stopped_textures = [None, None, None, None]; - for direction in Direction::DIRECTIONS { - let moving_prefix = match direction { - Direction::Up => "pacman/up", - Direction::Down => "pacman/down", - Direction::Left => "pacman/left", - Direction::Right => "pacman/right", - }; - let moving_tiles = vec![ - SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")) - .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?, - SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")) - .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?, - SpriteAtlas::get_tile(atlas, "pacman/full.png") - .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, - ]; +// for direction in Direction::DIRECTIONS { +// let moving_prefix = match direction { +// Direction::Up => "pacman/up", +// Direction::Down => "pacman/down", +// Direction::Left => "pacman/left", +// Direction::Right => "pacman/right", +// }; +// let moving_tiles = vec![ +// SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")) +// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?, +// SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")) +// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?, +// SpriteAtlas::get_tile(atlas, "pacman/full.png") +// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, +// ]; - let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")) - .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?]; +// let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")) +// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?]; - textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?); - stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); - } +// textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?); +// stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); +// } - Ok(Self { - traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse), - texture: DirectionalAnimatedTexture::new(textures, stopped_textures), - }) - } -} +// Ok(Self { +// traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse), +// texture: DirectionalAnimatedTexture::new(textures, stopped_textures), +// }) +// } +// } -impl Collidable for Pacman { - fn position(&self) -> crate::entity::traversal::Position { - self.traverser.position - } -} +// impl Collidable for Pacman { +// fn position(&self) -> crate::entity::traversal::Position { +// self.traverser.position +// } +// } diff --git a/src/entity/trait.rs b/src/entity/trait.rs index e45495f..b0f07e8 100644 --- a/src/entity/trait.rs +++ b/src/entity/trait.rs @@ -1,114 +1,114 @@ -//! Entity trait for common movement and rendering functionality. -//! -//! This module defines a trait that captures the shared behavior between -//! different game entities like Ghosts and Pac-Man, including movement, -//! rendering, and position calculations. +// //! Entity trait for common movement and rendering functionality. +// //! +// //! This module defines a trait that captures the shared behavior between +// //! different game entities like Ghosts and Pac-Man, including movement, +// //! rendering, and position calculations. -use glam::Vec2; -use sdl2::render::{Canvas, RenderTarget}; +// use glam::Vec2; +// use sdl2::render::{Canvas, RenderTarget}; -use crate::entity::direction::Direction; -use crate::entity::graph::{Edge, Graph, NodeId}; -use crate::entity::traversal::{Position, Traverser}; -use crate::error::{EntityError, GameError, GameResult, TextureError}; -use crate::texture::directional::DirectionalAnimatedTexture; -use crate::texture::sprite::SpriteAtlas; +// use crate::entity::direction::Direction; +// use crate::entity::graph::{Edge, Graph, NodeId}; +// use crate::entity::traversal::{Position, Traverser}; +// use crate::error::{EntityError, GameError, GameResult, TextureError}; +// use crate::texture::directional::DirectionalAnimatedTexture; +// use crate::texture::sprite::SpriteAtlas; -/// Trait defining common functionality for game entities that move through the graph. -/// -/// This trait provides a unified interface for entities that: -/// - Move through the game graph using a traverser -/// - Render using directional animated textures -/// - Have position calculations and movement speed -#[allow(dead_code)] -pub trait Entity { - /// Returns a reference to the entity's traverser for movement control. - fn traverser(&self) -> &Traverser; +// /// Trait defining common functionality for game entities that move through the graph. +// /// +// /// This trait provides a unified interface for entities that: +// /// - Move through the game graph using a traverser +// /// - Render using directional animated textures +// /// - Have position calculations and movement speed +// #[allow(dead_code)] +// pub trait Entity { +// /// Returns a reference to the entity's traverser for movement control. +// fn traverser(&self) -> &Traverser; - /// Returns a mutable reference to the entity's traverser for movement control. - fn traverser_mut(&mut self) -> &mut Traverser; +// /// Returns a mutable reference to the entity's traverser for movement control. +// fn traverser_mut(&mut self) -> &mut Traverser; - /// Returns a reference to the entity's directional animated texture. - fn texture(&self) -> &DirectionalAnimatedTexture; +// /// Returns a reference to the entity's directional animated texture. +// fn texture(&self) -> &DirectionalAnimatedTexture; - /// Returns a mutable reference to the entity's directional animated texture. - fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture; +// /// Returns a mutable reference to the entity's directional animated texture. +// fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture; - /// Returns the movement speed multiplier for this entity. - fn speed(&self) -> f32; +// /// Returns the movement speed multiplier for this entity. +// fn speed(&self) -> f32; - /// Determines if this entity can traverse a given edge. - fn can_traverse(&self, edge: Edge) -> bool; +// /// Determines if this entity can traverse a given edge. +// fn can_traverse(&self, edge: Edge) -> bool; - /// Updates the entity's position and animation state. - /// - /// This method advances movement through the graph and updates texture animation. - fn tick(&mut self, dt: f32, graph: &Graph); +// /// Updates the entity's position and animation state. +// /// +// /// This method advances movement through the graph and updates texture animation. +// fn tick(&mut self, dt: f32, graph: &Graph); - /// 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. - fn get_pixel_pos(&self, graph: &Graph) -> GameResult { - let pos = match self.traverser().position { - 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, to })?; - from_node.position + (to_node.position - from_node.position) * (traversed / edge.distance) - } - }; +// /// 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. +// fn get_pixel_pos(&self, graph: &Graph) -> GameResult { +// let pos = match self.traverser().position { +// 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, 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, - )) - } +// Ok(Vec2::new( +// pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32, +// pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32, +// )) +// } - /// Returns the current node ID that the entity is at or moving towards. - /// - /// If the entity is at a node, returns that node ID. - /// If the entity is between nodes, returns the node it's moving towards. - fn current_node_id(&self) -> NodeId { - match self.traverser().position { - Position::AtNode(node_id) => node_id, - Position::BetweenNodes { to, .. } => to, - } - } +// /// Returns the current node ID that the entity is at or moving towards. +// /// +// /// If the entity is at a node, returns that node ID. +// /// If the entity is between nodes, returns the node it's moving towards. +// fn current_node_id(&self) -> NodeId { +// match self.traverser().position { +// Position::AtNode(node_id) => node_id, +// Position::BetweenNodes { to, .. } => to, +// } +// } - /// Sets the next direction for the entity to take. - /// - /// The direction is buffered and will be applied at the next opportunity, - /// typically when the entity reaches a new node. - fn set_next_direction(&mut self, direction: Direction) { - self.traverser_mut().set_next_direction(direction); - } +// /// Sets the next direction for the entity to take. +// /// +// /// The direction is buffered and will be applied at the next opportunity, +// /// typically when the entity reaches a new node. +// fn set_next_direction(&mut self, direction: Direction) { +// self.traverser_mut().set_next_direction(direction); +// } - /// Renders the entity at its current position. - /// - /// Draws the appropriate directional sprite based on the entity's - /// current movement state and direction. - fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> { - let pixel_pos = self.get_pixel_pos(graph)?; - let dest = crate::helpers::centered_with_size( - glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32), - glam::UVec2::new(16, 16), - ); +// /// Renders the entity at its current position. +// /// +// /// Draws the appropriate directional sprite based on the entity's +// /// current movement state and direction. +// fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> { +// let pixel_pos = self.get_pixel_pos(graph)?; +// let dest = crate::helpers::centered_with_size( +// glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32), +// glam::UVec2::new(16, 16), +// ); - if self.traverser().position.is_stopped() { - self.texture() - .render_stopped(canvas, atlas, dest, self.traverser().direction) - .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?; - } else { - self.texture() - .render(canvas, atlas, dest, self.traverser().direction) - .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?; - } +// if self.traverser().position.is_stopped() { +// self.texture() +// .render_stopped(canvas, atlas, dest, self.traverser().direction) +// .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?; +// } else { +// self.texture() +// .render(canvas, atlas, dest, self.traverser().direction) +// .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?; +// } - Ok(()) - } -} +// Ok(()) +// } +// } diff --git a/src/entity/traversal.rs b/src/entity/traversal.rs index 372d37d..273303e 100644 --- a/src/entity/traversal.rs +++ b/src/entity/traversal.rs @@ -1,229 +1,181 @@ -use tracing::error; +// use tracing::error; -use crate::error::GameResult; +// use crate::error::GameResult; -use super::direction::Direction; -use super::graph::{Edge, Graph, NodeId}; +// use super::direction::Direction; +// use super::graph::{Edge, Graph, NodeId}; -/// 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(Debug, PartialEq, Clone, Copy)] -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, - }, -} +// /// Manages an entity's movement through the graph. +// /// +// /// A `Traverser` encapsulates the state of an entity's position and direction, +// /// providing a way to advance along the graph's paths based on a given distance. +// /// It also handles direction changes, buffering the next intended direction. +// pub struct Traverser { +// /// The current position of the traverser in the graph. +// pub position: Position, +// /// The current direction of movement. +// pub direction: Direction, +// /// Buffered direction change with remaining frame count for timing. +// /// +// /// The `u8` value represents the number of frames remaining before +// /// the buffered direction expires. This allows for responsive controls +// /// by storing direction changes for a limited time. +// pub next_direction: Option<(Direction, u8)>, +// } -#[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(_)) - } +// impl Traverser { +// /// Creates a new traverser starting at the given node ID. +// /// +// /// The traverser will immediately attempt to start moving in the initial direction. +// pub fn new(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self +// where +// F: Fn(Edge) -> bool, +// { +// let mut traverser = Traverser { +// position: Position::AtNode(start_node), +// direction: initial_direction, +// next_direction: Some((initial_direction, 1)), +// }; - /// 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, - } - } +// // This will kickstart the traverser into motion +// if let Err(e) = traverser.advance(graph, 0.0, can_traverse) { +// error!("Traverser initialization error: {}", e); +// } - /// Returns the `NodeId` of the destination node, if currently on an edge. - #[allow(clippy::wrong_self_convention)] - pub fn to_node_id(&self) -> Option { - match self { - Position::AtNode(_) => None, - Position::BetweenNodes { to, .. } => Some(*to), - } - } +// traverser +// } - /// Returns `true` if the traverser is stopped at a node. - pub fn is_stopped(&self) -> bool { - matches!(self, Position::AtNode(_)) - } -} +// /// Sets the next direction for the traverser to take. +// /// +// /// The direction is buffered and will be applied at the next opportunity, +// /// typically when the traverser reaches a new node. This allows for responsive +// /// controls, as the new direction is stored for a limited time. +// pub fn set_next_direction(&mut self, new_direction: Direction) { +// if self.direction != new_direction { +// self.next_direction = Some((new_direction, 30)); +// } +// } -/// Manages an entity's movement through the graph. -/// -/// A `Traverser` encapsulates the state of an entity's position and direction, -/// providing a way to advance along the graph's paths based on a given distance. -/// It also handles direction changes, buffering the next intended direction. -pub struct Traverser { - /// The current position of the traverser in the graph. - pub position: Position, - /// The current direction of movement. - pub direction: Direction, - /// Buffered direction change with remaining frame count for timing. - /// - /// The `u8` value represents the number of frames remaining before - /// the buffered direction expires. This allows for responsive controls - /// by storing direction changes for a limited time. - pub next_direction: Option<(Direction, u8)>, -} +// /// Advances the traverser along the graph by a specified distance. +// /// +// /// This method updates the traverser's position based on its current state +// /// and the distance to travel. +// /// +// /// - If at a node, it checks for a buffered direction to start moving. +// /// - If between nodes, it moves along the current edge. +// /// - If it reaches a node, it attempts to transition to a new edge based on +// /// the buffered direction or by continuing straight. +// /// - If no valid move is possible, it stops at the node. +// /// +// /// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction). +// pub fn advance(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()> +// where +// F: Fn(Edge) -> bool, +// { +// // Decrement the remaining frames for the next direction +// if let Some((direction, remaining)) = self.next_direction { +// if remaining > 0 { +// self.next_direction = Some((direction, remaining - 1)); +// } else { +// self.next_direction = None; +// } +// } -impl Traverser { - /// Creates a new traverser starting at the given node ID. - /// - /// The traverser will immediately attempt to start moving in the initial direction. - pub fn new(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self - where - F: Fn(Edge) -> bool, - { - let mut traverser = Traverser { - position: Position::AtNode(start_node), - direction: initial_direction, - next_direction: Some((initial_direction, 1)), - }; +// match self.position { +// Position::AtNode(node_id) => { +// // We're not moving, but a buffered direction is available. +// if let Some((next_direction, _)) = self.next_direction { +// if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) { +// if can_traverse(edge) { +// // Start moving in that direction +// self.position = Position::BetweenNodes { +// from: node_id, +// to: edge.target, +// traversed: distance.max(0.0), +// }; +// self.direction = next_direction; +// } else { +// return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( +// format!( +// "Cannot traverse edge from {} to {} in direction {:?}", +// node_id, edge.target, next_direction +// ), +// ))); +// } +// } else { +// return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( +// format!("No edge found in direction {:?} from node {}", next_direction, node_id), +// ))); +// } - // This will kickstart the traverser into motion - if let Err(e) = traverser.advance(graph, 0.0, can_traverse) { - error!("Traverser initialization error: {}", e); - } +// self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it +// } +// } +// 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 Ok(()); +// } - traverser - } +// let edge = graph.find_edge(from, to).ok_or_else(|| { +// crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!( +// "Inconsistent state: Traverser is on a non-existent edge from {} to {}.", +// from, to +// ))) +// })?; - /// Sets the next direction for the traverser to take. - /// - /// The direction is buffered and will be applied at the next opportunity, - /// typically when the traverser reaches a new node. This allows for responsive - /// controls, as the new direction is stored for a limited time. - pub fn set_next_direction(&mut self, new_direction: Direction) { - if self.direction != new_direction { - self.next_direction = Some((new_direction, 30)); - } - } +// let new_traversed = traversed + distance; - /// Advances the traverser along the graph by a specified distance. - /// - /// This method updates the traverser's position based on its current state - /// and the distance to travel. - /// - /// - If at a node, it checks for a buffered direction to start moving. - /// - If between nodes, it moves along the current edge. - /// - If it reaches a node, it attempts to transition to a new edge based on - /// the buffered direction or by continuing straight. - /// - If no valid move is possible, it stops at the node. - /// - /// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction). - pub fn advance(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()> - where - F: Fn(Edge) -> bool, - { - // Decrement the remaining frames for the next direction - if let Some((direction, remaining)) = self.next_direction { - if remaining > 0 { - self.next_direction = Some((direction, remaining - 1)); - } else { - self.next_direction = None; - } - } +// if new_traversed < edge.distance { +// // Still on the same edge, just update the distance. +// self.position = Position::BetweenNodes { +// from, +// to, +// traversed: new_traversed, +// }; +// } else { +// let overflow = new_traversed - edge.distance; +// let mut moved = false; - match self.position { - Position::AtNode(node_id) => { - // We're not moving, but a buffered direction is available. - if let Some((next_direction, _)) = self.next_direction { - if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) { - if can_traverse(edge) { - // Start moving in that direction - self.position = Position::BetweenNodes { - from: node_id, - to: edge.target, - traversed: distance.max(0.0), - }; - self.direction = next_direction; - } else { - return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( - format!( - "Cannot traverse edge from {} to {} in direction {:?}", - node_id, edge.target, next_direction - ), - ))); - } - } else { - return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( - format!("No edge found in direction {:?} from node {}", next_direction, node_id), - ))); - } +// // If we buffered a direction, try to find an edge in that direction +// if let Some((next_dir, _)) = self.next_direction { +// if let Some(edge) = graph.find_edge_in_direction(to, next_dir) { +// if can_traverse(edge) { +// self.position = Position::BetweenNodes { +// from: to, +// to: edge.target, +// traversed: overflow, +// }; - self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it - } - } - 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 Ok(()); - } +// self.direction = next_dir; // Remember our new direction +// self.next_direction = None; // Consume the buffered direction +// moved = true; +// } +// } +// } - let edge = graph.find_edge(from, to).ok_or_else(|| { - crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!( - "Inconsistent state: Traverser is on a non-existent edge from {} to {}.", - from, to - ))) - })?; +// // If we didn't move, try to continue in the current direction +// if !moved { +// if let Some(edge) = graph.find_edge_in_direction(to, self.direction) { +// if can_traverse(edge) { +// self.position = Position::BetweenNodes { +// from: to, +// to: edge.target, +// traversed: overflow, +// }; +// } else { +// self.position = Position::AtNode(to); +// self.next_direction = None; +// } +// } else { +// self.position = Position::AtNode(to); +// self.next_direction = None; +// } +// } +// } +// } +// } - let new_traversed = traversed + distance; - - if new_traversed < edge.distance { - // Still on the same edge, just update the distance. - self.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, _)) = self.next_direction { - if let Some(edge) = graph.find_edge_in_direction(to, next_dir) { - if can_traverse(edge) { - self.position = Position::BetweenNodes { - from: to, - to: edge.target, - traversed: overflow, - }; - - self.direction = next_dir; // Remember our new direction - self.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) = graph.find_edge_in_direction(to, self.direction) { - if can_traverse(edge) { - self.position = Position::BetweenNodes { - from: to, - to: edge.target, - traversed: overflow, - }; - } else { - self.position = Position::AtNode(to); - self.next_direction = None; - } - } else { - self.position = Position::AtNode(to); - self.next_direction = None; - } - } - } - } - } - - Ok(()) - } -} +// Ok(()) +// } +// } diff --git a/src/error.rs b/src/error.rs index 2a85b60..3c577d4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,11 +5,13 @@ use std::io; +use bevy_ecs::event::Event; + /// Main error type for the Pac-Man game. /// /// This is the primary error type that should be used in public APIs. /// It can represent any error that can occur during game operation. -#[derive(thiserror::Error, Debug)] +#[derive(thiserror::Error, Debug, Event)] pub enum GameError { #[error("Asset error: {0}")] Asset(#[from] AssetError), diff --git a/src/game/events.rs b/src/game/events.rs index b0e5351..f8a41f0 100644 --- a/src/game/events.rs +++ b/src/game/events.rs @@ -1,6 +1,8 @@ +use bevy_ecs::event::Event; + use crate::input::commands::GameCommand; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Event)] pub enum GameEvent { Command(GameCommand), } diff --git a/src/game/mod.rs b/src/game/mod.rs index d53467d..350e3ac 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -1,24 +1,37 @@ //! This module contains the main game logic and state. -use rand::{rngs::SmallRng, Rng, SeedableRng}; -use sdl2::pixels::Color; -use sdl2::render::{Canvas, Texture, TextureCreator}; -use sdl2::video::WindowContext; +include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); -use crate::entity::r#trait::Entity; -use crate::error::GameResult; +use crate::constants::CANVAS_SIZE; +use crate::ecs::render::{render_system, BackbufferResource, MapTextureResource}; +use crate::ecs::{DeltaTime, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity}; +use crate::entity::direction::Direction; +use crate::entity::{graph, traversal}; +use crate::error::{GameError, GameResult, TextureError}; +use crate::input::commands::GameCommand; +use crate::map::builder::Map; +use crate::texture::animated::AnimatedTexture; +use crate::texture::directional::DirectionalAnimatedTexture; +use crate::texture::sprite::Sprite; +use bevy_ecs::event::EventRegistry; +use bevy_ecs::observer::Trigger; +use bevy_ecs::schedule::IntoScheduleConfigs; +use bevy_ecs::system::{Commands, ResMut}; +use bevy_ecs::{schedule::Schedule, world::World}; +use sdl2::image::LoadTexture; +use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; +use sdl2::video::{Window, WindowContext}; +use sdl2::EventPump; -use crate::entity::{ - collision::{Collidable, CollisionSystem, EntityId}, - ghost::{Ghost, GhostType}, - pacman::Pacman, +use crate::asset::{get_asset_bytes, Asset}; +use crate::input::{handle_input, Bindings}; +use crate::map::render::MapRenderer; +use crate::{ + constants, + texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas}, }; -use crate::map::render::MapRenderer; -use crate::{constants, texture::sprite::SpriteAtlas}; - use self::events::GameEvent; -use self::state::GameState; pub mod events; pub mod state; @@ -28,361 +41,471 @@ pub mod state; /// It contains the game's state and logic, and is responsible for /// handling user input, updating the game state, and rendering the game. pub struct Game { - state: state::GameState, + pub world: World, + pub schedule: Schedule, } impl Game { - pub fn new(texture_creator: &'static TextureCreator) -> GameResult { - let state = GameState::new(texture_creator)?; + pub fn new( + canvas: &'static mut Canvas, + texture_creator: &'static mut TextureCreator, + event_pump: &'static mut EventPump, + ) -> GameResult { + let mut world = World::default(); + let mut schedule = Schedule::default(); - Ok(Game { state }) - } + EventRegistry::register_event::(&mut world); + EventRegistry::register_event::(&mut world); - pub fn post_event(&mut self, event: GameEvent) { - self.state.event_queue.push_back(event); - } + let mut backbuffer = texture_creator + .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) + .map_err(|e| GameError::Sdl(e.to_string()))?; + backbuffer.set_scale_mode(ScaleMode::Nearest); - fn handle_command(&mut self, command: crate::input::commands::GameCommand) { - use crate::input::commands::GameCommand; - match command { - GameCommand::MovePlayer(direction) => { - self.state.pacman.set_next_direction(direction); + let mut map_texture = texture_creator + .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) + .map_err(|e| GameError::Sdl(e.to_string()))?; + map_texture.set_scale_mode(ScaleMode::Nearest); + + // Load atlas and create map texture + let atlas_bytes = get_asset_bytes(Asset::Atlas)?; + let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { + if e.to_string().contains("format") || e.to_string().contains("unsupported") { + GameError::Texture(crate::error::TextureError::InvalidFormat(format!( + "Unsupported texture format: {e}" + ))) + } else { + GameError::Texture(crate::error::TextureError::LoadFailed(e.to_string())) } - GameCommand::ToggleDebug => { - self.toggle_debug_mode(); - } - GameCommand::MuteAudio => { - let is_muted = self.state.audio.is_muted(); - self.state.audio.set_mute(!is_muted); - } - GameCommand::ResetLevel => { - if let Err(e) = self.reset_game_state() { - tracing::error!("Failed to reset game state: {}", e); + })?; + + let atlas_mapper = AtlasMapper { + frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(), + }; + let mut atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); + + // Create map tiles + let mut map_tiles = Vec::with_capacity(35); + for i in 0..35 { + let tile_name = format!("maze/tiles/{}.png", i); + let tile = atlas.get_tile(&tile_name).unwrap(); + map_tiles.push(tile); + } + + // Render map to texture + canvas + .with_texture_canvas(&mut map_texture, |map_canvas| { + MapRenderer::render_map(map_canvas, &mut atlas, &mut map_tiles); + }) + .map_err(|e| GameError::Sdl(e.to_string()))?; + + let map = Map::new(constants::RAW_BOARD)?; + let pacman_start_node = map.start_positions.pacman; + let player = PlayerBundle { + player: PlayerControlled, + position: Position::AtNode(pacman_start_node), + velocity: Velocity::default(), + sprite: Renderable { + sprite: Sprite::new( + SpriteAtlas::get_tile(&atlas, "pacman/full.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, + ), + layer: 0, + }, + }; + + let mut textures = [None, None, None, None]; + let mut stopped_textures = [None, None, None, None]; + + for direction in Direction::DIRECTIONS { + let moving_prefix = match direction { + Direction::Up => "pacman/up", + Direction::Down => "pacman/down", + Direction::Left => "pacman/left", + Direction::Right => "pacman/right", + }; + let moving_tiles = vec![ + SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_a.png")) + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?, + SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png")) + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?, + SpriteAtlas::get_tile(&atlas, "pacman/full.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, + ]; + + let stopped_tiles = vec![SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png")) + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?]; + + textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?); + stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); + } + + world.insert_non_send_resource(atlas); + world.insert_non_send_resource(event_pump); + world.insert_non_send_resource(canvas); + world.insert_non_send_resource(BackbufferResource(backbuffer)); + world.insert_non_send_resource(MapTextureResource(map_texture)); + + world.insert_resource(map); + world.insert_resource(GlobalState { exit: false }); + world.insert_resource(Bindings::default()); + world.insert_resource(DeltaTime(0f32)); + + world.add_observer(|event: Trigger, mut state: ResMut| match *event { + GameEvent::Command(command) => match command { + GameCommand::Exit => { + state.exit = true; } - } - GameCommand::TogglePause => { - self.state.paused = !self.state.paused; - } - GameCommand::Exit => {} - } + _ => {} + }, + }); + + schedule.add_systems((handle_input, render_system).chain()); + + // Spawn player + world.spawn(player); + + Ok(Game { world, schedule }) } - fn process_events(&mut self) { - while let Some(event) = self.state.event_queue.pop_front() { - match event { - GameEvent::Command(command) => self.handle_command(command), - } - } - } + // fn handle_command(&mut self, command: crate::input::commands::GameCommand) { + // use crate::input::commands::GameCommand; + // match command { + // GameCommand::MovePlayer(direction) => { + // self.state.pacman.set_next_direction(direction); + // } + // GameCommand::ToggleDebug => { + // self.toggle_debug_mode(); + // } + // GameCommand::MuteAudio => { + // let is_muted = self.state.audio.is_muted(); + // self.state.audio.set_mute(!is_muted); + // } + // GameCommand::ResetLevel => { + // if let Err(e) = self.reset_game_state() { + // tracing::error!("Failed to reset game state: {}", e); + // } + // } + // GameCommand::TogglePause => { + // self.state.paused = !self.state.paused; + // } + // GameCommand::Exit => {} + // } + // } - /// Resets the game state, randomizing ghost positions and resetting Pac-Man - fn reset_game_state(&mut self) -> GameResult<()> { - let pacman_start_node = self.state.map.start_positions.pacman; - self.state.pacman = Pacman::new(&self.state.map.graph, pacman_start_node, &self.state.atlas)?; + // fn process_events(&mut self) { + // while let Some(event) = self.state.event_queue.pop_front() { + // match event { + // GameEvent::Command(command) => self.handle_command(command), + // } + // } + // } - // Reset items - self.state.items = self.state.map.generate_items(&self.state.atlas)?; + // /// Resets the game state, randomizing ghost positions and resetting Pac-Man + // fn reset_game_state(&mut self) -> GameResult<()> { + // let pacman_start_node = self.state.map.start_positions.pacman; + // self.state.pacman = Pacman::new(&self.state.map.graph, pacman_start_node, &self.state.atlas)?; - // Randomize ghost positions - let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; - let mut rng = SmallRng::from_os_rng(); + // // Reset items + // self.state.items = self.state.map.generate_items(&self.state.atlas)?; - for (i, ghost) in self.state.ghosts.iter_mut().enumerate() { - let random_node = rng.random_range(0..self.state.map.graph.node_count()); - *ghost = Ghost::new(&self.state.map.graph, random_node, ghost_types[i], &self.state.atlas)?; - } + // // Randomize ghost positions + // let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; + // let mut rng = SmallRng::from_os_rng(); - // Reset collision system - self.state.collision_system = CollisionSystem::default(); + // for (i, ghost) in self.state.ghosts.iter_mut().enumerate() { + // let random_node = rng.random_range(0..self.state.map.graph.node_count()); + // *ghost = Ghost::new(&self.state.map.graph, random_node, ghost_types[i], &self.state.atlas)?; + // } - // Re-register Pac-Man - self.state.pacman_id = self.state.collision_system.register_entity(self.state.pacman.position()); + // // Reset collision system + // self.state.collision_system = CollisionSystem::default(); - // Re-register items - self.state.item_ids.clear(); - for item in &self.state.items { - let item_id = self.state.collision_system.register_entity(item.position()); - self.state.item_ids.push(item_id); - } + // // Re-register Pac-Man + // self.state.pacman_id = self.state.collision_system.register_entity(self.state.pacman.position()); - // Re-register ghosts - self.state.ghost_ids.clear(); - for ghost in &self.state.ghosts { - let ghost_id = self.state.collision_system.register_entity(ghost.position()); - self.state.ghost_ids.push(ghost_id); - } + // // Re-register items + // self.state.item_ids.clear(); + // for item in &self.state.items { + // let item_id = self.state.collision_system.register_entity(item.position()); + // self.state.item_ids.push(item_id); + // } - Ok(()) - } + // // Re-register ghosts + // self.state.ghost_ids.clear(); + // for ghost in &self.state.ghosts { + // let ghost_id = self.state.collision_system.register_entity(ghost.position()); + // self.state.ghost_ids.push(ghost_id); + // } + + // Ok(()) + // } /// Ticks the game state. /// /// Returns true if the game should exit. pub fn tick(&mut self, dt: f32) -> bool { - // Process any events that have been posted (such as unpausing) - self.process_events(); + self.world.insert_resource(DeltaTime(dt)); - // If the game is paused, we don't need to do anything beyond returning - if self.state.paused { - return false; - } + // Run all systems + self.schedule.run(&mut self.world); - self.state.pacman.tick(dt, &self.state.map.graph); + let state = self + .world + .get_resource::() + .expect("GlobalState could not be acquired"); - // Update all ghosts - for ghost in &mut self.state.ghosts { - ghost.tick(dt, &self.state.map.graph); - } + return state.exit; - // Update collision system positions - self.update_collision_positions(); + // // Process any events that have been posted (such as unpausing) + // self.process_events(); - // Check for collisions - self.check_collisions(); + // // If the game is paused, we don't need to do anything beyond returning + // if self.state.paused { + // return false; + // } - false + // self.schedule.run(&mut self.world); + + // self.state.pacman.tick(dt, &self.state.map.graph); + + // // Update all ghosts + // for ghost in &mut self.state.ghosts { + // ghost.tick(dt, &self.state.map.graph); + // } + + // // Update collision system positions + // self.update_collision_positions(); + + // // Check for collisions + // self.check_collisions(); } - /// Toggles the debug mode on and off. - /// - /// When debug mode is enabled, the game will render additional information - /// that is useful for debugging, such as the collision grid and entity paths. - pub fn toggle_debug_mode(&mut self) { - self.state.debug_mode = !self.state.debug_mode; - } + // /// Toggles the debug mode on and off. + // /// + // /// When debug mode is enabled, the game will render additional information + // /// that is useful for debugging, such as the collision grid and entity paths. + // pub fn toggle_debug_mode(&mut self) { + // self.state.debug_mode = !self.state.debug_mode; + // } - fn update_collision_positions(&mut self) { - // Update Pac-Man's position - self.state - .collision_system - .update_position(self.state.pacman_id, self.state.pacman.position()); + // fn update_collision_positions(&mut self) { + // // Update Pac-Man's position + // self.state + // .collision_system + // .update_position(self.state.pacman_id, self.state.pacman.position()); - // Update ghost positions - for (ghost, &ghost_id) in self.state.ghosts.iter().zip(&self.state.ghost_ids) { - self.state.collision_system.update_position(ghost_id, ghost.position()); - } - } + // // Update ghost positions + // for (ghost, &ghost_id) in self.state.ghosts.iter().zip(&self.state.ghost_ids) { + // self.state.collision_system.update_position(ghost_id, ghost.position()); + // } + // } - fn check_collisions(&mut self) { - // Check Pac-Man vs Items - let potential_collisions = self - .state - .collision_system - .potential_collisions(&self.state.pacman.position()); + // fn check_collisions(&mut self) { + // // Check Pac-Man vs Items + // let potential_collisions = self + // .state + // .collision_system + // .potential_collisions(&self.state.pacman.position()); - for entity_id in potential_collisions { - if entity_id != self.state.pacman_id { - // Check if this is an item collision - if let Some(item_index) = self.find_item_by_id(entity_id) { - let item = &mut self.state.items[item_index]; - if !item.is_collected() { - item.collect(); - self.state.score += item.get_score(); - self.state.audio.eat(); + // for entity_id in potential_collisions { + // if entity_id != self.state.pacman_id { + // // Check if this is an item collision + // if let Some(item_index) = self.find_item_by_id(entity_id) { + // let item = &mut self.state.items[item_index]; + // if !item.is_collected() { + // item.collect(); + // self.state.score += item.get_score(); + // self.state.audio.eat(); - // Handle energizer effects - if matches!(item.item_type, crate::entity::item::ItemType::Energizer) { - // TODO: Make ghosts frightened - tracing::info!("Energizer collected! Ghosts should become frightened."); - } - } - } + // // Handle energizer effects + // if matches!(item.item_type, crate::entity::item::ItemType::Energizer) { + // // TODO: Make ghosts frightened + // tracing::info!("Energizer collected! Ghosts should become frightened."); + // } + // } + // } - // Check if this is a ghost collision - if let Some(_ghost_index) = self.find_ghost_by_id(entity_id) { - // TODO: Handle Pac-Man being eaten by ghost - tracing::info!("Pac-Man collided with ghost!"); - } - } - } - } + // // Check if this is a ghost collision + // if let Some(_ghost_index) = self.find_ghost_by_id(entity_id) { + // // TODO: Handle Pac-Man being eaten by ghost + // tracing::info!("Pac-Man collided with ghost!"); + // } + // } + // } + // } - fn find_item_by_id(&self, entity_id: EntityId) -> Option { - self.state.item_ids.iter().position(|&id| id == entity_id) - } + // fn find_item_by_id(&self, entity_id: EntityId) -> Option { + // self.state.item_ids.iter().position(|&id| id == entity_id) + // } - fn find_ghost_by_id(&self, entity_id: EntityId) -> Option { - self.state.ghost_ids.iter().position(|&id| id == entity_id) - } + // fn find_ghost_by_id(&self, entity_id: EntityId) -> Option { + // self.state.ghost_ids.iter().position(|&id| id == entity_id) + // } - pub fn draw(&mut self, canvas: &mut Canvas, backbuffer: &mut Texture) -> GameResult<()> { - // Only render the map texture once and cache it - if !self.state.map_rendered { - let mut map_texture = self - .state - .texture_creator - .create_texture_target(None, constants::CANVAS_SIZE.x, constants::CANVAS_SIZE.y) - .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; + // pub fn draw(&mut self, canvas: &mut Canvas, backbuffer: &mut Texture) -> GameResult<()> { + // // Only render the map texture once and cache it + // if !self.state.map_rendered { + // let mut map_texture = self + // .state + // .texture_creator + // .create_texture_target(None, constants::CANVAS_SIZE.x, constants::CANVAS_SIZE.y) + // .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; - canvas - .with_texture_canvas(&mut map_texture, |map_canvas| { - let mut map_tiles = Vec::with_capacity(35); - for i in 0..35 { - let tile_name = format!("maze/tiles/{}.png", i); - let tile = SpriteAtlas::get_tile(&self.state.atlas, &tile_name).unwrap(); - map_tiles.push(tile); - } - MapRenderer::render_map(map_canvas, &mut self.state.atlas, &mut map_tiles); - }) - .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; - self.state.map_texture = Some(map_texture); - self.state.map_rendered = true; - } + // canvas + // .with_texture_canvas(&mut map_texture, |map_canvas| { + // let mut map_tiles = Vec::with_capacity(35); + // for i in 0..35 { + // let tile_name = format!("maze/tiles/{}.png", i); + // let tile = SpriteAtlas::get_tile(&self.state.atlas, &tile_name).unwrap(); + // map_tiles.push(tile); + // } + // MapRenderer::render_map(map_canvas, &mut self.state.atlas, &mut map_tiles); + // }) + // .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; + // self.state.map_texture = Some(map_texture); + // self.state.map_rendered = true; + // } - canvas - .with_texture_canvas(backbuffer, |canvas| { - canvas.set_draw_color(Color::BLACK); - canvas.clear(); - if let Some(ref map_texture) = self.state.map_texture { - canvas.copy(map_texture, None, None).unwrap(); - } + // canvas.set_draw_color(Color::BLACK); + // canvas.clear(); + // if let Some(ref map_texture) = self.state.map_texture { + // canvas.copy(map_texture, None, None).unwrap(); + // } - // Render all items - for item in &self.state.items { - if let Err(e) = item.render(canvas, &mut self.state.atlas, &self.state.map.graph) { - tracing::error!("Failed to render item: {}", e); - } - } + // // Render all items + // for item in &self.state.items { + // if let Err(e) = item.render(canvas, &mut self.state.atlas, &self.state.map.graph) { + // tracing::error!("Failed to render item: {}", e); + // } + // } - // Render all ghosts - for ghost in &self.state.ghosts { - if let Err(e) = ghost.render(canvas, &mut self.state.atlas, &self.state.map.graph) { - tracing::error!("Failed to render ghost: {}", e); - } - } + // // Render all ghosts + // for ghost in &self.state.ghosts { + // if let Err(e) = ghost.render(canvas, &mut self.state.atlas, &self.state.map.graph) { + // tracing::error!("Failed to render ghost: {}", e); + // } + // } - if let Err(e) = self.state.pacman.render(canvas, &mut self.state.atlas, &self.state.map.graph) { - tracing::error!("Failed to render pacman: {}", e); - } - }) - .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; + // if let Err(e) = self.state.pacman.render(canvas, &mut self.state.atlas, &self.state.map.graph) { + // tracing::error!("Failed to render pacman: {}", e); + // } - Ok(()) - } + // if self.state.debug_mode { + // if let Err(e) = + // self.state + // .map + // .debug_render_with_cursor(canvas, &mut self.state.text_texture, &mut self.state.atlas, cursor_pos) + // { + // tracing::error!("Failed to render debug cursor: {}", e); + // } + // self.render_pathfinding_debug(canvas)?; + // } + // self.draw_hud(canvas)?; + // canvas.present(); - pub fn present_backbuffer( - &mut self, - canvas: &mut Canvas, - backbuffer: &Texture, - cursor_pos: glam::Vec2, - ) -> GameResult<()> { - canvas - .copy(backbuffer, None, None) - .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; - if self.state.debug_mode { - if let Err(e) = - self.state - .map - .debug_render_with_cursor(canvas, &mut self.state.text_texture, &mut self.state.atlas, cursor_pos) - { - tracing::error!("Failed to render debug cursor: {}", e); - } - self.render_pathfinding_debug(canvas)?; - } - self.draw_hud(canvas)?; - canvas.present(); - Ok(()) - } + // Ok(()) + // } - /// Renders pathfinding debug lines from each ghost to Pac-Man. - /// - /// Each ghost's path is drawn in its respective color with a small offset - /// to prevent overlapping lines. - fn render_pathfinding_debug(&self, canvas: &mut Canvas) -> GameResult<()> { - let pacman_node = self.state.pacman.current_node_id(); + // /// Renders pathfinding debug lines from each ghost to Pac-Man. + // /// + // /// Each ghost's path is drawn in its respective color with a small offset + // /// to prevent overlapping lines. + // fn render_pathfinding_debug(&self, canvas: &mut Canvas) -> GameResult<()> { + // let pacman_node = self.state.pacman.current_node_id(); - for ghost in self.state.ghosts.iter() { - if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) { - if path.len() < 2 { - continue; // Skip if path is too short - } + // for ghost in self.state.ghosts.iter() { + // if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) { + // if path.len() < 2 { + // continue; // Skip if path is too short + // } - // Set the ghost's color - canvas.set_draw_color(ghost.debug_color()); + // // Set the ghost's color + // canvas.set_draw_color(ghost.debug_color()); - // Calculate offset based on ghost index to prevent overlapping lines - // let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0 + // // Calculate offset based on ghost index to prevent overlapping lines + // // let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0 - // Calculate a consistent offset direction for the entire path - // let first_node = self.map.graph.get_node(path[0]).unwrap(); - // let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap(); + // // Calculate a consistent offset direction for the entire path + // // let first_node = self.map.graph.get_node(path[0]).unwrap(); + // // let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap(); - // Use the overall direction from start to end to determine the perpendicular offset - let offset = match ghost.ghost_type { - GhostType::Blinky => glam::Vec2::new(0.25, 0.5), - GhostType::Pinky => glam::Vec2::new(-0.25, -0.25), - GhostType::Inky => glam::Vec2::new(0.5, -0.5), - GhostType::Clyde => glam::Vec2::new(-0.5, 0.25), - } * 5.0; + // // Use the overall direction from start to end to determine the perpendicular offset + // let offset = match ghost.ghost_type { + // GhostType::Blinky => glam::Vec2::new(0.25, 0.5), + // GhostType::Pinky => glam::Vec2::new(-0.25, -0.25), + // GhostType::Inky => glam::Vec2::new(0.5, -0.5), + // GhostType::Clyde => glam::Vec2::new(-0.5, 0.25), + // } * 5.0; - // Calculate offset positions for all nodes using the same perpendicular direction - let mut offset_positions = Vec::new(); - for &node_id in &path { - let node = self - .state - .map - .graph - .get_node(node_id) - .ok_or(crate::error::EntityError::NodeNotFound(node_id))?; - let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - offset_positions.push(pos + offset); - } + // // Calculate offset positions for all nodes using the same perpendicular direction + // let mut offset_positions = Vec::new(); + // for &node_id in &path { + // let node = self + // .state + // .map + // .graph + // .get_node(node_id) + // .ok_or(crate::error::EntityError::NodeNotFound(node_id))?; + // let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); + // offset_positions.push(pos + offset); + // } - // Draw lines between the offset positions - for window in offset_positions.windows(2) { - if let (Some(from), Some(to)) = (window.first(), window.get(1)) { - // Skip if the distance is too far (used for preventing lines between tunnel portals) - if from.distance_squared(*to) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 { - continue; - } + // // Draw lines between the offset positions + // for window in offset_positions.windows(2) { + // if let (Some(from), Some(to)) = (window.first(), window.get(1)) { + // // Skip if the distance is too far (used for preventing lines between tunnel portals) + // if from.distance_squared(*to) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 { + // continue; + // } - // Draw the line - canvas - .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32)) - .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; - } - } - } - } + // // Draw the line + // canvas + // .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32)) + // .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; + // } + // } + // } + // } - Ok(()) - } + // Ok(()) + // } - fn draw_hud(&mut self, canvas: &mut Canvas) -> GameResult<()> { - let lives = 3; - let score_text = format!("{:02}", self.state.score); - let x_offset = 4; - let y_offset = 2; - let lives_offset = 3; - let score_offset = 7 - (score_text.len() as i32); - self.state.text_texture.set_scale(1.0); - if let Err(e) = self.state.text_texture.render( - canvas, - &mut self.state.atlas, - &format!("{lives}UP HIGH SCORE "), - glam::UVec2::new(8 * lives_offset as u32 + x_offset, y_offset), - ) { - tracing::error!("Failed to render HUD text: {}", e); - } - if let Err(e) = self.state.text_texture.render( - canvas, - &mut self.state.atlas, - &score_text, - glam::UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset), - ) { - tracing::error!("Failed to render score text: {}", e); - } + // fn draw_hud(&mut self, canvas: &mut Canvas) -> GameResult<()> { + // let lives = 3; + // let score_text = format!("{:02}", self.state.score); + // let x_offset = 4; + // let y_offset = 2; + // let lives_offset = 3; + // let score_offset = 7 - (score_text.len() as i32); + // self.state.text_texture.set_scale(1.0); + // if let Err(e) = self.state.text_texture.render( + // canvas, + // &mut self.state.atlas, + // &format!("{lives}UP HIGH SCORE "), + // glam::UVec2::new(8 * lives_offset as u32 + x_offset, y_offset), + // ) { + // tracing::error!("Failed to render HUD text: {}", e); + // } + // if let Err(e) = self.state.text_texture.render( + // canvas, + // &mut self.state.atlas, + // &score_text, + // glam::UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset), + // ) { + // tracing::error!("Failed to render score text: {}", e); + // } - // Display FPS information in top-left corner - // let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s); - // self.render_text_on( - // canvas, - // &*texture_creator, - // &fps_text, - // IVec2::new(10, 10), - // Color::RGB(255, 255, 0), // Yellow color for FPS display - // ); + // // Display FPS information in top-left corner + // // let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s); + // // self.render_text_on( + // // canvas, + // // &*texture_creator, + // // &fps_text, + // // IVec2::new(10, 10), + // // Color::RGB(255, 255, 0), // Yellow color for FPS display + // // ); - Ok(()) - } + // Ok(()) + // } } diff --git a/src/game/state.rs b/src/game/state.rs index 9c9da59..14013a5 100644 --- a/src/game/state.rs +++ b/src/game/state.rs @@ -1,153 +1,153 @@ -use std::collections::VecDeque; +// use std::collections::VecDeque; -use sdl2::{ - image::LoadTexture, - render::{Texture, TextureCreator}, - video::WindowContext, -}; -use smallvec::SmallVec; +// use sdl2::{ +// image::LoadTexture, +// render::{Texture, TextureCreator}, +// video::WindowContext, +// }; +// use smallvec::SmallVec; -use crate::{ - asset::{get_asset_bytes, Asset}, - audio::Audio, - constants::RAW_BOARD, - entity::{ - collision::{Collidable, CollisionSystem, EntityId}, - ghost::{Ghost, GhostType}, - item::Item, - pacman::Pacman, - }, - error::{GameError, GameResult, TextureError}, - game::events::GameEvent, - map::builder::Map, - texture::{ - sprite::{AtlasMapper, SpriteAtlas}, - text::TextTexture, - }, -}; +// use crate::{ +// asset::{get_asset_bytes, Asset}, +// audio::Audio, +// constants::RAW_BOARD, +// entity::{ +// collision::{Collidable, CollisionSystem, EntityId}, +// ghost::{Ghost, GhostType}, +// item::Item, +// pacman::Pacman, +// }, +// error::{GameError, GameResult, TextureError}, +// game::events::GameEvent, +// map::builder::Map, +// texture::{ +// sprite::{AtlasMapper, SpriteAtlas}, +// text::TextTexture, +// }, +// }; -include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); +// include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); -/// The `GameState` struct holds all the essential data for the game. -/// -/// This includes the score, map, entities (Pac-Man, ghosts, items), -/// collision system, and rendering resources. By centralizing the game's state, -/// we can cleanly separate it from the game's logic, making it easier to manage -/// and reason about. -pub struct GameState { - pub paused: bool, +// /// The `GameState` struct holds all the essential data for the game. +// /// +// /// This includes the score, map, entities (Pac-Man, ghosts, items), +// /// collision system, and rendering resources. By centralizing the game's state, +// /// we can cleanly separate it from the game's logic, making it easier to manage +// /// and reason about. +// pub struct GameState { +// pub paused: bool, - pub score: u32, - pub map: Map, - pub pacman: Pacman, - pub pacman_id: EntityId, - pub ghosts: SmallVec<[Ghost; 4]>, - pub ghost_ids: SmallVec<[EntityId; 4]>, - pub items: Vec, - pub item_ids: Vec, - pub debug_mode: bool, - pub event_queue: VecDeque, +// pub score: u32, +// pub map: Map, +// pub pacman: Pacman, +// pub pacman_id: EntityId, +// pub ghosts: SmallVec<[Ghost; 4]>, +// pub ghost_ids: SmallVec<[EntityId; 4]>, +// pub items: Vec, +// pub item_ids: Vec, +// pub debug_mode: bool, +// pub event_queue: VecDeque, - // Collision system - pub(crate) collision_system: CollisionSystem, +// // Collision system +// pub(crate) collision_system: CollisionSystem, - // Rendering resources - pub(crate) atlas: SpriteAtlas, - pub(crate) text_texture: TextTexture, +// // Rendering resources +// pub(crate) atlas: SpriteAtlas, +// pub(crate) text_texture: TextTexture, - // Audio - pub audio: Audio, +// // Audio +// pub audio: Audio, - // Map texture pre-rendering - pub(crate) map_texture: Option>, - pub(crate) map_rendered: bool, - pub(crate) texture_creator: &'static TextureCreator, -} +// // Map texture pre-rendering +// pub(crate) map_texture: Option>, +// pub(crate) map_rendered: bool, +// pub(crate) texture_creator: &'static TextureCreator, +// } -impl GameState { - /// Creates a new `GameState` by initializing all the game's data. - /// - /// This function sets up the map, Pac-Man, ghosts, items, collision system, - /// and all rendering resources required to start the game. It returns a `GameResult` - /// to handle any potential errors during initialization. - pub fn new(texture_creator: &'static TextureCreator) -> GameResult { - let map = Map::new(RAW_BOARD)?; +// impl GameState { +// /// Creates a new `GameState` by initializing all the game's data. +// /// +// /// This function sets up the map, Pac-Man, ghosts, items, collision system, +// /// and all rendering resources required to start the game. It returns a `GameResult` +// /// to handle any potential errors during initialization. +// pub fn new(texture_creator: &'static TextureCreator) -> GameResult { +// let map = Map::new(RAW_BOARD)?; - let start_node = map.start_positions.pacman; +// let start_node = map.start_positions.pacman; - let atlas_bytes = get_asset_bytes(Asset::Atlas)?; - let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { - if e.to_string().contains("format") || e.to_string().contains("unsupported") { - GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}"))) - } else { - GameError::Texture(TextureError::LoadFailed(e.to_string())) - } - })?; +// let atlas_bytes = get_asset_bytes(Asset::Atlas)?; +// let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { +// if e.to_string().contains("format") || e.to_string().contains("unsupported") { +// GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}"))) +// } else { +// GameError::Texture(TextureError::LoadFailed(e.to_string())) +// } +// })?; - let atlas_mapper = AtlasMapper { - frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(), - }; - let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); +// let atlas_mapper = AtlasMapper { +// frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(), +// }; +// let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); - let text_texture = TextTexture::new(1.0); - let audio = Audio::new(); - let pacman = Pacman::new(&map.graph, start_node, &atlas)?; +// let text_texture = TextTexture::new(1.0); +// let audio = Audio::new(); +// let pacman = Pacman::new(&map.graph, start_node, &atlas)?; - // Generate items (pellets and energizers) - let items = map.generate_items(&atlas)?; +// // Generate items (pellets and energizers) +// let items = map.generate_items(&atlas)?; - // Initialize collision system - let mut collision_system = CollisionSystem::default(); +// // Initialize collision system +// let mut collision_system = CollisionSystem::default(); - // Register Pac-Man - let pacman_id = collision_system.register_entity(pacman.position()); +// // Register Pac-Man +// let pacman_id = collision_system.register_entity(pacman.position()); - // Register items - let item_ids = items - .iter() - .map(|item| collision_system.register_entity(item.position())) - .collect(); +// // Register items +// let item_ids = items +// .iter() +// .map(|item| collision_system.register_entity(item.position())) +// .collect(); - // Create and register ghosts - let ghosts = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde] - .iter() - .zip( - [ - map.start_positions.blinky, - map.start_positions.pinky, - map.start_positions.inky, - map.start_positions.clyde, - ] - .iter(), - ) - .map(|(ghost_type, start_node)| Ghost::new(&map.graph, *start_node, *ghost_type, &atlas)) - .collect::>>()?; +// // Create and register ghosts +// let ghosts = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde] +// .iter() +// .zip( +// [ +// map.start_positions.blinky, +// map.start_positions.pinky, +// map.start_positions.inky, +// map.start_positions.clyde, +// ] +// .iter(), +// ) +// .map(|(ghost_type, start_node)| Ghost::new(&map.graph, *start_node, *ghost_type, &atlas)) +// .collect::>>()?; - // Register ghosts - let ghost_ids = ghosts - .iter() - .map(|ghost| collision_system.register_entity(ghost.position())) - .collect(); +// // Register ghosts +// let ghost_ids = ghosts +// .iter() +// .map(|ghost| collision_system.register_entity(ghost.position())) +// .collect(); - Ok(Self { - paused: false, - map, - atlas, - pacman, - pacman_id, - ghosts, - ghost_ids, - items, - item_ids, - text_texture, - audio, - score: 0, - debug_mode: false, - collision_system, - map_texture: None, - map_rendered: false, - texture_creator, - event_queue: VecDeque::new(), - }) - } -} +// Ok(Self { +// paused: false, +// map, +// atlas, +// pacman, +// pacman_id, +// ghosts, +// ghost_ids, +// items, +// item_ids, +// text_texture, +// audio, +// score: 0, +// debug_mode: false, +// collision_system, +// map_texture: None, +// map_rendered: false, +// texture_creator, +// event_queue: VecDeque::new(), +// }) +// } +// } diff --git a/src/input/commands.rs b/src/input/commands.rs index d125a0c..81f4757 100644 --- a/src/input/commands.rs +++ b/src/input/commands.rs @@ -3,9 +3,9 @@ use crate::entity::direction::Direction; #[derive(Debug, Clone, Copy)] pub enum GameCommand { MovePlayer(Direction), + Exit, TogglePause, ToggleDebug, MuteAudio, ResetLevel, - Exit, } diff --git a/src/input/mod.rs b/src/input/mod.rs index 5924876..91c414c 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,18 +1,22 @@ use std::collections::HashMap; -use sdl2::{event::Event, keyboard::Keycode}; +use bevy_ecs::{ + resource::Resource, + system::{Commands, NonSendMut, Res}, +}; +use sdl2::{event::Event, keyboard::Keycode, EventPump}; -use crate::{entity::direction::Direction, input::commands::GameCommand}; +use crate::{entity::direction::Direction, game::events::GameEvent, input::commands::GameCommand}; pub mod commands; -#[derive(Debug, Clone, Default)] -pub struct InputSystem { +#[derive(Debug, Clone, Resource)] +pub struct Bindings { key_bindings: HashMap, } -impl InputSystem { - pub fn new() -> Self { +impl Default for Bindings { + fn default() -> Self { let mut key_bindings = HashMap::new(); // Player movement @@ -35,13 +39,22 @@ impl InputSystem { Self { key_bindings } } +} - /// Handles an event and returns a command if one is bound to the event. - pub fn handle_event(&self, event: &Event) -> Option { +pub fn handle_input(bindings: Res, mut commands: Commands, mut pump: NonSendMut<&'static mut EventPump>) { + for event in pump.poll_iter() { match event { - Event::Quit { .. } => Some(GameCommand::Exit), - Event::KeyDown { keycode: Some(key), .. } => self.key_bindings.get(key).copied(), - _ => None, + Event::Quit { .. } => { + commands.trigger(GameEvent::Command(GameCommand::Exit)); + } + Event::KeyDown { keycode: Some(key), .. } => { + let command = bindings.key_bindings.get(&key).copied(); + if let Some(command) = command { + tracing::info!("triggering command: {:?}", command); + commands.trigger(GameEvent::Command(command)); + } + } + _ => {} } } } diff --git a/src/lib.rs b/src/lib.rs index d9374f7..a90eb5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod app; pub mod asset; pub mod audio; pub mod constants; +pub mod ecs; pub mod entity; pub mod error; pub mod game; diff --git a/src/main.rs b/src/main.rs index f293b89..2117321 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod asset; mod audio; mod constants; +mod ecs; mod entity; mod error; mod game; diff --git a/src/map/builder.rs b/src/map/builder.rs index bf0b361..2a23e3d 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -1,12 +1,13 @@ //! Map construction and building functionality. use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD}; +use crate::ecs::NodeId; use crate::entity::direction::Direction; -use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId}; -use crate::entity::item::{Item, ItemType}; +use crate::entity::graph::{EdgePermissions, Graph, Node}; use crate::map::parser::MapTileParser; use crate::map::render::MapRenderer; use crate::texture::sprite::{Sprite, SpriteAtlas}; +use bevy_ecs::resource::Resource; use glam::{IVec2, Vec2}; use sdl2::render::{Canvas, RenderTarget}; use std::collections::{HashMap, VecDeque}; @@ -24,6 +25,7 @@ pub struct NodePositions { } /// The main map structure containing the game board and navigation graph. +#[derive(Resource)] pub struct Map { /// The node map for entity movement. pub graph: Graph, @@ -155,42 +157,42 @@ impl Map { } /// Generates Item entities for pellets and energizers from the parsed map. - pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult> { - // Pre-load sprites to avoid repeated texture lookups - let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png") - .ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?; - let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png") - .ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?; + // pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult> { + // // Pre-load sprites to avoid repeated texture lookups + // let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png") + // .ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?; + // let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png") + // .ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?; - // Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers) - let mut items = Vec::with_capacity(250); + // // Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers) + // let mut items = Vec::with_capacity(250); - // Parse the raw board once - let parsed_map = MapTileParser::parse_board(RAW_BOARD)?; - let map = parsed_map.tiles; + // // Parse the raw board once + // let parsed_map = MapTileParser::parse_board(RAW_BOARD)?; + // let map = parsed_map.tiles; - // Iterate through the map and collect items more efficiently - for (x, row) in map.iter().enumerate() { - for (y, tile) in row.iter().enumerate() { - match tile { - MapTile::Pellet | MapTile::PowerPellet => { - let grid_pos = IVec2::new(x as i32, y as i32); - if let Some(&node_id) = self.grid_to_node.get(&grid_pos) { - let (item_type, sprite) = match tile { - MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)), - MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)), - _ => unreachable!(), // We already filtered for these types - }; - items.push(Item::new(node_id, item_type, sprite)); - } - } - _ => {} - } - } - } + // // Iterate through the map and collect items more efficiently + // for (x, row) in map.iter().enumerate() { + // for (y, tile) in row.iter().enumerate() { + // match tile { + // MapTile::Pellet | MapTile::PowerPellet => { + // let grid_pos = IVec2::new(x as i32, y as i32); + // if let Some(&node_id) = self.grid_to_node.get(&grid_pos) { + // let (item_type, sprite) = match tile { + // MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)), + // MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)), + // _ => unreachable!(), // We already filtered for these types + // }; + // items.push(Item::new(node_id, item_type, sprite)); + // } + // } + // _ => {} + // } + // } + // } - Ok(items) - } + // Ok(items) + // } /// Renders a debug visualization with cursor-based highlighting. /// diff --git a/src/texture/sprite.rs b/src/texture/sprite.rs index b271c9e..41ebb69 100644 --- a/src/texture/sprite.rs +++ b/src/texture/sprite.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use bevy_ecs::resource::Resource; use glam::U16Vec2; use sdl2::pixels::Color; use sdl2::rect::Rect; From b2703186401695731e0665e0f681f92324c06c9e Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 14 Aug 2025 15:44:07 -0500 Subject: [PATCH 02/44] feat: directional rendering, interactivity --- src/ecs/interact.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/ecs/mod.rs | 21 ++++++++++++++++++--- src/ecs/render.rs | 40 +++++++++++++++++++++++++++++++++------- src/game/mod.rs | 34 +++++++++++++++++++--------------- src/input/mod.rs | 7 ++++--- 5 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 src/ecs/interact.rs diff --git a/src/ecs/interact.rs b/src/ecs/interact.rs new file mode 100644 index 0000000..366ff17 --- /dev/null +++ b/src/ecs/interact.rs @@ -0,0 +1,44 @@ +use bevy_ecs::{ + event::{EventReader, EventWriter}, + query::With, + system::{Query, ResMut}, +}; + +use crate::{ + ecs::{GlobalState, PlayerControlled, Velocity}, + error::GameError, + game::events::GameEvent, + input::commands::GameCommand, +}; + +// Handles +pub fn interact_system( + mut events: EventReader, + mut state: ResMut, + mut players: Query<(&PlayerControlled, &mut Velocity)>, + mut errors: EventWriter, +) { + // 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.direction = *direction; + } + GameCommand::Exit => { + state.exit = true; + } + _ => {} + }, + } + } +} diff --git a/src/ecs/mod.rs b/src/ecs/mod.rs index 90f1d92..4ab9508 100644 --- a/src/ecs/mod.rs +++ b/src/ecs/mod.rs @@ -9,7 +9,11 @@ use glam::Vec2; use crate::{ entity::{direction::Direction, graph::Graph, traversal}, error::{EntityError, GameResult}, - texture::{directional::DirectionalAnimatedTexture, sprite::Sprite}, + texture::{ + animated::AnimatedTexture, + directional::DirectionalAnimatedTexture, + sprite::{AtlasTile, Sprite}, + }, }; /// A tag component for entities that are controlled by the player. @@ -17,12 +21,21 @@ use crate::{ 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: Sprite, + pub sprite: AtlasTile, pub layer: u8, } +/// A component for entities that have a directional animated texture. +#[derive(Component)] +pub struct DirectionalAnimated { + pub textures: [Option; 4], + pub stopped_textures: [Option; 4], +} + /// A unique identifier for a node, represented by its index in the graph's storage. pub type NodeId = usize; @@ -112,7 +125,7 @@ impl Position { #[derive(Default, Component)] pub struct Velocity { pub direction: Direction, - pub speed: f32, + pub speed: Option, } #[derive(Bundle)] @@ -121,6 +134,7 @@ pub struct PlayerBundle { pub position: Position, pub velocity: Velocity, pub sprite: Renderable, + pub directional_animated: DirectionalAnimated, } #[derive(Resource)] @@ -131,4 +145,5 @@ pub struct GlobalState { #[derive(Resource)] pub struct DeltaTime(pub f32); +pub mod interact; pub mod render; diff --git a/src/ecs/render.rs b/src/ecs/render.rs index a3d3e2d..1b86530 100644 --- a/src/ecs/render.rs +++ b/src/ecs/render.rs @@ -1,15 +1,36 @@ -use crate::ecs::{render, Position, Renderable}; -use crate::entity::graph::Graph; +use crate::ecs::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity}; use crate::error::{EntityError, GameError, TextureError}; use crate::map::builder::Map; -use crate::texture::sprite::{Sprite, SpriteAtlas}; +use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; -use bevy_ecs::query::With; 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, + mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable)>, + mut errors: EventWriter, +) { + for (velocity, mut texture, mut renderable) in renderables.iter_mut() { + let texture = if velocity.speed.is_none() { + texture.stopped_textures[velocity.direction.as_usize()].as_mut() + } else { + texture.textures[velocity.direction.as_usize()].as_mut() + }; + + if let Some(texture) = texture { + 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>); @@ -19,7 +40,7 @@ pub fn render_system( mut backbuffer: NonSendMut, mut atlas: NonSendMut, map: Res, - renderables: Query<(Entity, &Renderable, &Position)>, + mut renderables: Query<(Entity, &mut Renderable, &Position)>, mut errors: EventWriter, ) { // Clear the main canvas first @@ -40,13 +61,18 @@ pub fn render_system( .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); // Render all entities to the backbuffer - for (_, renderable, position) in renderables.iter() { + 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, pos) + .render(backbuffer_canvas, &mut atlas, dest) .err() .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); } diff --git a/src/game/mod.rs b/src/game/mod.rs index 350e3ac..2df3894 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -3,8 +3,9 @@ include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::CANVAS_SIZE; -use crate::ecs::render::{render_system, BackbufferResource, MapTextureResource}; -use crate::ecs::{DeltaTime, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity}; +use crate::ecs::interact::interact_system; +use crate::ecs::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; +use crate::ecs::{DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity}; use crate::entity::direction::Direction; use crate::entity::{graph, traversal}; use crate::error::{GameError, GameResult, TextureError}; @@ -101,18 +102,6 @@ impl Game { let map = Map::new(constants::RAW_BOARD)?; let pacman_start_node = map.start_positions.pacman; - let player = PlayerBundle { - player: PlayerControlled, - position: Position::AtNode(pacman_start_node), - velocity: Velocity::default(), - sprite: Renderable { - sprite: Sprite::new( - SpriteAtlas::get_tile(&atlas, "pacman/full.png") - .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, - ), - layer: 0, - }, - }; let mut textures = [None, None, None, None]; let mut stopped_textures = [None, None, None, None]; @@ -140,6 +129,21 @@ impl Game { stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); } + let player = PlayerBundle { + player: PlayerControlled, + position: Position::AtNode(pacman_start_node), + velocity: Velocity::default(), + sprite: Renderable { + sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, + layer: 0, + }, + directional_animated: DirectionalAnimated { + textures, + stopped_textures, + }, + }; + world.insert_non_send_resource(atlas); world.insert_non_send_resource(event_pump); world.insert_non_send_resource(canvas); @@ -160,7 +164,7 @@ impl Game { }, }); - schedule.add_systems((handle_input, render_system).chain()); + schedule.add_systems((handle_input, interact_system, directional_render_system, render_system).chain()); // Spawn player world.spawn(player); diff --git a/src/input/mod.rs b/src/input/mod.rs index 91c414c..7d1d480 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use bevy_ecs::{ + event::EventWriter, resource::Resource, system::{Commands, NonSendMut, Res}, }; @@ -41,17 +42,17 @@ impl Default for Bindings { } } -pub fn handle_input(bindings: Res, mut commands: Commands, mut pump: NonSendMut<&'static mut EventPump>) { +pub fn handle_input(bindings: Res, mut writer: EventWriter, mut pump: NonSendMut<&'static mut EventPump>) { for event in pump.poll_iter() { match event { Event::Quit { .. } => { - commands.trigger(GameEvent::Command(GameCommand::Exit)); + writer.write(GameEvent::Command(GameCommand::Exit)); } Event::KeyDown { keycode: Some(key), .. } => { let command = bindings.key_bindings.get(&key).copied(); if let Some(command) = command { tracing::info!("triggering command: {:?}", command); - commands.trigger(GameEvent::Command(command)); + writer.write(GameEvent::Command(command)); } } _ => {} From 0aa056a0ae13b581b4191198f9c4f34bfa75822d Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 14 Aug 2025 18:17:58 -0500 Subject: [PATCH 03/44] feat: ecs keyboard interactions --- src/ecs/interact.rs | 134 ++++++++++++++++- src/ecs/mod.rs | 1 + src/entity/traversal.rs | 314 +++++++++++++++++++--------------------- src/game/mod.rs | 19 ++- 4 files changed, 295 insertions(+), 173 deletions(-) diff --git a/src/ecs/interact.rs b/src/ecs/interact.rs index 366ff17..4f51957 100644 --- a/src/ecs/interact.rs +++ b/src/ecs/interact.rs @@ -1,16 +1,144 @@ use bevy_ecs::{ event::{EventReader, EventWriter}, query::With, - system::{Query, ResMut}, + system::{Query, Res, ResMut}, }; use crate::{ - ecs::{GlobalState, PlayerControlled, Velocity}, - error::GameError, + ecs::{DeltaTime, GlobalState, PlayerControlled, Position, Velocity}, + error::{EntityError, GameError}, game::events::GameEvent, input::commands::GameCommand, + map::builder::Map, }; +pub fn movement_system( + map: Res, + delta_time: Res, + mut entities: Query<(&PlayerControlled, &mut Velocity, &mut Position)>, + mut errors: EventWriter, +) { + for (player, mut velocity, mut position) in entities.iter_mut() { + let distance = velocity.speed.unwrap_or(0.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 edge.permissions.can_traverse(edge) { + // // Start moving in that direction + *position = Position::BetweenNodes { + from: node_id, + to: edge.target, + traversed: distance, + }; + velocity.direction = next_direction; + // } else { + // return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( + // format!( + // "Cannot traverse edge from {} to {} in direction {:?}", + // node_id, edge.target, next_direction + // ), + // ))); + // } + } else { + errors.write( + EntityError::InvalidMovement(format!( + "No edge found in direction {:?} from node {}", + next_direction, node_id + )) + .into(), + ); + } + + velocity.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it + } + } + 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 edge.permissions.can_traverse(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 edge.permissions.can_traverse(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; + } + } + } + } + } + } +} + // Handles pub fn interact_system( mut events: EventReader, diff --git a/src/ecs/mod.rs b/src/ecs/mod.rs index 4ab9508..13160fa 100644 --- a/src/ecs/mod.rs +++ b/src/ecs/mod.rs @@ -125,6 +125,7 @@ impl Position { #[derive(Default, Component)] pub struct Velocity { pub direction: Direction, + pub next_direction: Option<(Direction, u8)>, pub speed: Option, } diff --git a/src/entity/traversal.rs b/src/entity/traversal.rs index 273303e..5b7b46d 100644 --- a/src/entity/traversal.rs +++ b/src/entity/traversal.rs @@ -1,181 +1,161 @@ -// use tracing::error; +use tracing::error; -// use crate::error::GameResult; +use crate::ecs::{NodeId, Position}; +use crate::error::GameResult; -// use super::direction::Direction; -// use super::graph::{Edge, Graph, NodeId}; +use super::direction::Direction; +use super::graph::{Edge, Graph}; -// /// Manages an entity's movement through the graph. -// /// -// /// A `Traverser` encapsulates the state of an entity's position and direction, -// /// providing a way to advance along the graph's paths based on a given distance. -// /// It also handles direction changes, buffering the next intended direction. -// pub struct Traverser { -// /// The current position of the traverser in the graph. -// pub position: Position, -// /// The current direction of movement. -// pub direction: Direction, -// /// Buffered direction change with remaining frame count for timing. -// /// -// /// The `u8` value represents the number of frames remaining before -// /// the buffered direction expires. This allows for responsive controls -// /// by storing direction changes for a limited time. -// pub next_direction: Option<(Direction, u8)>, -// } +/// Manages an entity's movement through the graph. +/// +/// A `Traverser` encapsulates the state of an entity's position and direction, +/// providing a way to advance along the graph's paths based on a given distance. +/// It also handles direction changes, buffering the next intended direction. +pub struct Traverser { + /// The current position of the traverser in the graph. + pub position: Position, + /// The current direction of movement. + pub direction: Direction, + /// Buffered direction change with remaining frame count for timing. + /// + /// The `u8` value represents the number of frames remaining before + /// the buffered direction expires. This allows for responsive controls + /// by storing direction changes for a limited time. + pub next_direction: Option<(Direction, u8)>, +} -// impl Traverser { -// /// Creates a new traverser starting at the given node ID. -// /// -// /// The traverser will immediately attempt to start moving in the initial direction. -// pub fn new(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self -// where -// F: Fn(Edge) -> bool, -// { -// let mut traverser = Traverser { -// position: Position::AtNode(start_node), -// direction: initial_direction, -// next_direction: Some((initial_direction, 1)), -// }; +impl Traverser { + /// Sets the next direction for the traverser to take. + /// + /// The direction is buffered and will be applied at the next opportunity, + /// typically when the traverser reaches a new node. This allows for responsive + /// controls, as the new direction is stored for a limited time. + pub fn set_next_direction(&mut self, new_direction: Direction) { + if self.direction != new_direction { + self.next_direction = Some((new_direction, 30)); + } + } -// // This will kickstart the traverser into motion -// if let Err(e) = traverser.advance(graph, 0.0, can_traverse) { -// error!("Traverser initialization error: {}", e); -// } + /// Advances the traverser along the graph by a specified distance. + /// + /// This method updates the traverser's position based on its current state + /// and the distance to travel. + /// + /// - If at a node, it checks for a buffered direction to start moving. + /// - If between nodes, it moves along the current edge. + /// - If it reaches a node, it attempts to transition to a new edge based on + /// the buffered direction or by continuing straight. + /// - If no valid move is possible, it stops at the node. + /// + /// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction). + pub fn advance(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()> + where + F: Fn(Edge) -> bool, + { + // Decrement the remaining frames for the next direction + if let Some((direction, remaining)) = self.next_direction { + if remaining > 0 { + self.next_direction = Some((direction, remaining - 1)); + } else { + self.next_direction = None; + } + } -// traverser -// } + match self.position { + Position::AtNode(node_id) => { + // We're not moving, but a buffered direction is available. + if let Some((next_direction, _)) = self.next_direction { + if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) { + if can_traverse(edge) { + // Start moving in that direction + self.position = Position::BetweenNodes { + from: node_id, + to: edge.target, + traversed: distance.max(0.0), + }; + self.direction = next_direction; + } else { + return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( + format!( + "Cannot traverse edge from {} to {} in direction {:?}", + node_id, edge.target, next_direction + ), + ))); + } + } else { + return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( + format!("No edge found in direction {:?} from node {}", next_direction, node_id), + ))); + } -// /// Sets the next direction for the traverser to take. -// /// -// /// The direction is buffered and will be applied at the next opportunity, -// /// typically when the traverser reaches a new node. This allows for responsive -// /// controls, as the new direction is stored for a limited time. -// pub fn set_next_direction(&mut self, new_direction: Direction) { -// if self.direction != new_direction { -// self.next_direction = Some((new_direction, 30)); -// } -// } + self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it + } + } + 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 Ok(()); + } -// /// Advances the traverser along the graph by a specified distance. -// /// -// /// This method updates the traverser's position based on its current state -// /// and the distance to travel. -// /// -// /// - If at a node, it checks for a buffered direction to start moving. -// /// - If between nodes, it moves along the current edge. -// /// - If it reaches a node, it attempts to transition to a new edge based on -// /// the buffered direction or by continuing straight. -// /// - If no valid move is possible, it stops at the node. -// /// -// /// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction). -// pub fn advance(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()> -// where -// F: Fn(Edge) -> bool, -// { -// // Decrement the remaining frames for the next direction -// if let Some((direction, remaining)) = self.next_direction { -// if remaining > 0 { -// self.next_direction = Some((direction, remaining - 1)); -// } else { -// self.next_direction = None; -// } -// } + let edge = graph.find_edge(from, to).ok_or_else(|| { + crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!( + "Inconsistent state: Traverser is on a non-existent edge from {} to {}.", + from, to + ))) + })?; -// match self.position { -// Position::AtNode(node_id) => { -// // We're not moving, but a buffered direction is available. -// if let Some((next_direction, _)) = self.next_direction { -// if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) { -// if can_traverse(edge) { -// // Start moving in that direction -// self.position = Position::BetweenNodes { -// from: node_id, -// to: edge.target, -// traversed: distance.max(0.0), -// }; -// self.direction = next_direction; -// } else { -// return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( -// format!( -// "Cannot traverse edge from {} to {} in direction {:?}", -// node_id, edge.target, next_direction -// ), -// ))); -// } -// } else { -// return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( -// format!("No edge found in direction {:?} from node {}", next_direction, node_id), -// ))); -// } + let new_traversed = traversed + distance; -// self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it -// } -// } -// 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 Ok(()); -// } + if new_traversed < edge.distance { + // Still on the same edge, just update the distance. + self.position = Position::BetweenNodes { + from, + to, + traversed: new_traversed, + }; + } else { + let overflow = new_traversed - edge.distance; + let mut moved = false; -// let edge = graph.find_edge(from, to).ok_or_else(|| { -// crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!( -// "Inconsistent state: Traverser is on a non-existent edge from {} to {}.", -// from, to -// ))) -// })?; + // If we buffered a direction, try to find an edge in that direction + if let Some((next_dir, _)) = self.next_direction { + if let Some(edge) = graph.find_edge_in_direction(to, next_dir) { + if can_traverse(edge) { + self.position = Position::BetweenNodes { + from: to, + to: edge.target, + traversed: overflow, + }; -// let new_traversed = traversed + distance; + self.direction = next_dir; // Remember our new direction + self.next_direction = None; // Consume the buffered direction + moved = true; + } + } + } -// if new_traversed < edge.distance { -// // Still on the same edge, just update the distance. -// self.position = Position::BetweenNodes { -// from, -// to, -// traversed: new_traversed, -// }; -// } else { -// let overflow = new_traversed - edge.distance; -// let mut moved = false; + // If we didn't move, try to continue in the current direction + if !moved { + if let Some(edge) = graph.find_edge_in_direction(to, self.direction) { + if can_traverse(edge) { + self.position = Position::BetweenNodes { + from: to, + to: edge.target, + traversed: overflow, + }; + } else { + self.position = Position::AtNode(to); + self.next_direction = None; + } + } else { + self.position = Position::AtNode(to); + self.next_direction = None; + } + } + } + } + } -// // If we buffered a direction, try to find an edge in that direction -// if let Some((next_dir, _)) = self.next_direction { -// if let Some(edge) = graph.find_edge_in_direction(to, next_dir) { -// if can_traverse(edge) { -// self.position = Position::BetweenNodes { -// from: to, -// to: edge.target, -// traversed: overflow, -// }; - -// self.direction = next_dir; // Remember our new direction -// self.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) = graph.find_edge_in_direction(to, self.direction) { -// if can_traverse(edge) { -// self.position = Position::BetweenNodes { -// from: to, -// to: edge.target, -// traversed: overflow, -// }; -// } else { -// self.position = Position::AtNode(to); -// self.next_direction = None; -// } -// } else { -// self.position = Position::AtNode(to); -// self.next_direction = None; -// } -// } -// } -// } -// } - -// Ok(()) -// } -// } + Ok(()) + } +} diff --git a/src/game/mod.rs b/src/game/mod.rs index 2df3894..d07d55a 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -3,7 +3,7 @@ include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::CANVAS_SIZE; -use crate::ecs::interact::interact_system; +use crate::ecs::interact::{interact_system, movement_system}; use crate::ecs::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; use crate::ecs::{DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity}; use crate::entity::direction::Direction; @@ -132,7 +132,11 @@ impl Game { let player = PlayerBundle { player: PlayerControlled, position: Position::AtNode(pacman_start_node), - velocity: Velocity::default(), + velocity: Velocity { + direction: Direction::Up, + next_direction: None, + speed: Some(1.0), + }, sprite: Renderable { sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, @@ -164,7 +168,16 @@ impl Game { }, }); - schedule.add_systems((handle_input, interact_system, directional_render_system, render_system).chain()); + schedule.add_systems( + ( + handle_input, + interact_system, + movement_system, + directional_render_system, + render_system, + ) + .chain(), + ); // Spawn player world.spawn(player); From 70fb2b9503354d45e1af5854fb8c3dba20c7a1fe Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 14 Aug 2025 18:35:23 -0500 Subject: [PATCH 04/44] fix: working movement again with ecs --- src/ecs/interact.rs | 83 ++++++++++++++++++++++----------------------- src/ecs/mod.rs | 2 +- src/ecs/render.rs | 11 +++--- src/game/mod.rs | 2 +- 4 files changed, 49 insertions(+), 49 deletions(-) diff --git a/src/ecs/interact.rs b/src/ecs/interact.rs index 4f51957..2273052 100644 --- a/src/ecs/interact.rs +++ b/src/ecs/interact.rs @@ -1,9 +1,10 @@ use bevy_ecs::{ event::{EventReader, EventWriter}, - query::With, system::{Query, Res, ResMut}, }; +use tracing::debug; +use crate::entity::graph::EdgePermissions; use crate::{ ecs::{DeltaTime, GlobalState, PlayerControlled, Position, Velocity}, error::{EntityError, GameError}, @@ -15,11 +16,11 @@ use crate::{ pub fn movement_system( map: Res, delta_time: Res, - mut entities: Query<(&PlayerControlled, &mut Velocity, &mut Position)>, + mut entities: Query<(&mut PlayerControlled, &mut Velocity, &mut Position)>, mut errors: EventWriter, ) { - for (player, mut velocity, mut position) in entities.iter_mut() { - let distance = velocity.speed.unwrap_or(0.0) * delta_time.0; + 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 { @@ -35,22 +36,16 @@ pub fn movement_system( // 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 edge.permissions.can_traverse(edge) { - // // Start moving in that direction - *position = Position::BetweenNodes { - from: node_id, - to: edge.target, - traversed: distance, - }; - velocity.direction = next_direction; - // } else { - // return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( - // format!( - // "Cannot traverse edge from {} to {} in direction {:?}", - // node_id, edge.target, 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!( @@ -60,8 +55,6 @@ pub fn movement_system( .into(), ); } - - velocity.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it } } Position::BetweenNodes { from, to, traversed } => { @@ -101,33 +94,33 @@ pub fn movement_system( // 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 edge.permissions.can_traverse(edge) { - // *position = Position::BetweenNodes { - // from: to, - // to: edge.target, - // traversed: overflow, - // }; + 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; - // } + 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 edge.permissions.can_traverse(edge) { - *position = Position::BetweenNodes { - from: to, - to: edge.target, - traversed: overflow, - }; - // } else { - // *position = Position::AtNode(to); - // velocity.next_direction = None; - // } + 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; @@ -139,6 +132,10 @@ pub fn movement_system( } } +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, @@ -160,7 +157,7 @@ pub fn interact_system( match event { GameEvent::Command(command) => match command { GameCommand::MovePlayer(direction) => { - velocity.direction = *direction; + velocity.next_direction = Some((*direction, 90)); } GameCommand::Exit => { state.exit = true; diff --git a/src/ecs/mod.rs b/src/ecs/mod.rs index 13160fa..375cdcf 100644 --- a/src/ecs/mod.rs +++ b/src/ecs/mod.rs @@ -126,7 +126,7 @@ impl Position { pub struct Velocity { pub direction: Direction, pub next_direction: Option<(Direction, u8)>, - pub speed: Option, + pub speed: f32, } #[derive(Bundle)] diff --git a/src/ecs/render.rs b/src/ecs/render.rs index 1b86530..84050b7 100644 --- a/src/ecs/render.rs +++ b/src/ecs/render.rs @@ -11,18 +11,21 @@ use sdl2::video::Window; /// Updates the directional animated texture of an entity. pub fn directional_render_system( dt: Res, - mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable)>, + mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable, &Position)>, mut errors: EventWriter, ) { - for (velocity, mut texture, mut renderable) in renderables.iter_mut() { - let texture = if velocity.speed.is_none() { + 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 { - texture.tick(dt.0); + if !stopped { + texture.tick(dt.0); + } renderable.sprite = *texture.current_tile(); } else { errors.write(TextureError::RenderFailed(format!("Entity has no texture")).into()); diff --git a/src/game/mod.rs b/src/game/mod.rs index d07d55a..1633fe3 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -135,7 +135,7 @@ impl Game { velocity: Velocity { direction: Direction::Up, next_direction: None, - speed: Some(1.0), + speed: 1.125, }, sprite: Renderable { sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") From 242da2e2634a5a91bba60feb98c455ead05ca4ac Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 09:17:43 -0500 Subject: [PATCH 05/44] refactor: reorganize ecs components --- src/app.rs | 6 +- src/ecs/components.rs | 138 ++++++++++++++++++++++++++++++++++++++ src/ecs/interact.rs | 132 +----------------------------------- src/ecs/mod.rs | 145 +--------------------------------------- src/ecs/movement.rs | 129 +++++++++++++++++++++++++++++++++++ src/ecs/render.rs | 4 +- src/entity/graph.rs | 2 +- src/entity/traversal.rs | 4 +- src/game/mod.rs | 16 ++--- src/input/mod.rs | 2 +- src/map/builder.rs | 6 +- src/texture/sprite.rs | 1 - 12 files changed, 291 insertions(+), 294 deletions(-) create mode 100644 src/ecs/components.rs create mode 100644 src/ecs/movement.rs diff --git a/src/app.rs b/src/app.rs index 826e1bf..f796513 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,11 @@ use std::time::{Duration, Instant}; use glam::Vec2; -use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; +use sdl2::render::TextureCreator; use sdl2::ttf::Sdl2TtfContext; -use sdl2::video::{Window, WindowContext}; +use sdl2::video::WindowContext; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; -use tracing::{error, warn}; +use tracing::warn; use crate::error::{GameError, GameResult}; diff --git a/src/ecs/components.rs b/src/ecs/components.rs new file mode 100644 index 0000000..3ab2e89 --- /dev/null +++ b/src/ecs/components.rs @@ -0,0 +1,138 @@ +use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; +use glam::Vec2; + +use crate::{ + entity::{direction::Direction, graph::Graph}, + error::{EntityError, GameResult}, + texture::{animated::AnimatedTexture, sprite::AtlasTile}, +}; + +/// 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; 4], + pub stopped_textures: [Option; 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 { + 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 { + 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); diff --git a/src/ecs/interact.rs b/src/ecs/interact.rs index 2273052..8ac4588 100644 --- a/src/ecs/interact.rs +++ b/src/ecs/interact.rs @@ -1,141 +1,15 @@ use bevy_ecs::{ event::{EventReader, EventWriter}, - system::{Query, Res, ResMut}, + system::{Query, ResMut}, }; -use tracing::debug; -use crate::entity::graph::EdgePermissions; use crate::{ - ecs::{DeltaTime, GlobalState, PlayerControlled, Position, Velocity}, - error::{EntityError, GameError}, + ecs::components::{GlobalState, PlayerControlled, Velocity}, + error::GameError, game::events::GameEvent, input::commands::GameCommand, - map::builder::Map, }; -pub fn movement_system( - map: Res, - delta_time: Res, - mut entities: Query<(&mut PlayerControlled, &mut Velocity, &mut Position)>, - mut errors: EventWriter, -) { - 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, diff --git a/src/ecs/mod.rs b/src/ecs/mod.rs index 375cdcf..11f7459 100644 --- a/src/ecs/mod.rs +++ b/src/ecs/mod.rs @@ -3,148 +3,7 @@ //! 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; 4], - pub stopped_textures: [Option; 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 { - 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 { - 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 components; pub mod interact; +pub mod movement; pub mod render; diff --git a/src/ecs/movement.rs b/src/ecs/movement.rs new file mode 100644 index 0000000..10f5a91 --- /dev/null +++ b/src/ecs/movement.rs @@ -0,0 +1,129 @@ +use crate::ecs::components::{DeltaTime, PlayerControlled, Position, Velocity}; +use crate::entity::graph::EdgePermissions; +use crate::error::{EntityError, GameError}; +use crate::map::builder::Map; +use bevy_ecs::event::EventWriter; +use bevy_ecs::system::{Query, Res}; + +fn can_traverse(_player: &mut PlayerControlled, edge: crate::entity::graph::Edge) -> bool { + matches!(edge.permissions, EdgePermissions::All) +} + +pub fn movement_system( + map: Res, + delta_time: Res, + mut entities: Query<(&mut PlayerControlled, &mut Velocity, &mut Position)>, + mut errors: EventWriter, +) { + 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; + } + } + } + } + } + } +} diff --git a/src/ecs/render.rs b/src/ecs/render.rs index 84050b7..56d7db7 100644 --- a/src/ecs/render.rs +++ b/src/ecs/render.rs @@ -1,5 +1,5 @@ -use crate::ecs::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity}; -use crate::error::{EntityError, GameError, TextureError}; +use crate::ecs::components::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity}; +use crate::error::{GameError, TextureError}; use crate::map::builder::Map; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; diff --git a/src/entity/graph.rs b/src/entity/graph.rs index e19f60f..b6dffb5 100644 --- a/src/entity/graph.rs +++ b/src/entity/graph.rs @@ -1,6 +1,6 @@ use glam::Vec2; -use crate::ecs::NodeId; +use crate::ecs::components::NodeId; use super::direction::Direction; diff --git a/src/entity/traversal.rs b/src/entity/traversal.rs index 5b7b46d..1bec8a4 100644 --- a/src/entity/traversal.rs +++ b/src/entity/traversal.rs @@ -1,6 +1,4 @@ -use tracing::error; - -use crate::ecs::{NodeId, Position}; +use crate::ecs::components::Position; use crate::error::GameResult; use super::direction::Direction; diff --git a/src/game/mod.rs b/src/game/mod.rs index 1633fe3..7210fc0 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -3,24 +3,24 @@ include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::CANVAS_SIZE; -use crate::ecs::interact::{interact_system, movement_system}; +use crate::ecs::components::{ + DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, +}; +use crate::ecs::interact::interact_system; +use crate::ecs::movement::movement_system; use crate::ecs::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; -use crate::ecs::{DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity}; use crate::entity::direction::Direction; -use crate::entity::{graph, traversal}; use crate::error::{GameError, GameResult, TextureError}; use crate::input::commands::GameCommand; use crate::map::builder::Map; use crate::texture::animated::AnimatedTexture; -use crate::texture::directional::DirectionalAnimatedTexture; -use crate::texture::sprite::Sprite; use bevy_ecs::event::EventRegistry; use bevy_ecs::observer::Trigger; use bevy_ecs::schedule::IntoScheduleConfigs; -use bevy_ecs::system::{Commands, ResMut}; +use bevy_ecs::system::ResMut; use bevy_ecs::{schedule::Schedule, world::World}; use sdl2::image::LoadTexture; -use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; +use sdl2::render::{Canvas, ScaleMode, TextureCreator}; use sdl2::video::{Window, WindowContext}; use sdl2::EventPump; @@ -29,7 +29,7 @@ use crate::input::{handle_input, Bindings}; use crate::map::render::MapRenderer; use crate::{ constants, - texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas}, + texture::sprite::{AtlasMapper, SpriteAtlas}, }; use self::events::GameEvent; diff --git a/src/input/mod.rs b/src/input/mod.rs index 7d1d480..97ae3cf 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use bevy_ecs::{ event::EventWriter, resource::Resource, - system::{Commands, NonSendMut, Res}, + system::{NonSendMut, Res}, }; use sdl2::{event::Event, keyboard::Keycode, EventPump}; diff --git a/src/map/builder.rs b/src/map/builder.rs index 2a23e3d..49b32e3 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -1,12 +1,12 @@ //! Map construction and building functionality. -use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD}; -use crate::ecs::NodeId; +use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; +use crate::ecs::components::NodeId; use crate::entity::direction::Direction; use crate::entity::graph::{EdgePermissions, Graph, Node}; use crate::map::parser::MapTileParser; use crate::map::render::MapRenderer; -use crate::texture::sprite::{Sprite, SpriteAtlas}; +use crate::texture::sprite::SpriteAtlas; use bevy_ecs::resource::Resource; use glam::{IVec2, Vec2}; use sdl2::render::{Canvas, RenderTarget}; diff --git a/src/texture/sprite.rs b/src/texture/sprite.rs index 41ebb69..b271c9e 100644 --- a/src/texture/sprite.rs +++ b/src/texture/sprite.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use bevy_ecs::resource::Resource; use glam::U16Vec2; use sdl2::pixels::Color; use sdl2::rect::Rect; From 3388d77ec5ec9309188defa3d0b83b82c6e3f9f3 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 09:24:42 -0500 Subject: [PATCH 06/44] refactor: remove all unused/broken tests, remove many now unused types/functions --- src/app.rs | 6 +- src/entity/mod.rs | 1 - src/entity/traversal.rs | 159 ------------------------------------- src/texture/animated.rs | 11 +-- src/texture/directional.rs | 80 ------------------- src/texture/mod.rs | 1 - src/texture/sprite.rs | 27 ------- tests/collision.rs | 119 --------------------------- tests/common/mod.rs | 2 +- tests/directional.rs | 77 ------------------ tests/game.rs | 1 - tests/ghost.rs | 48 ----------- tests/graph.rs | 66 --------------- tests/item.rs | 83 +++++++++---------- tests/map_builder.rs | 97 +++++++++++----------- tests/pacman.rs | 73 ----------------- tests/pathfinding.rs | 120 ---------------------------- tests/sprite.rs | 11 +-- 18 files changed, 92 insertions(+), 890 deletions(-) delete mode 100644 src/entity/traversal.rs delete mode 100644 src/texture/directional.rs delete mode 100644 tests/collision.rs delete mode 100644 tests/directional.rs delete mode 100644 tests/ghost.rs delete mode 100644 tests/pacman.rs delete mode 100644 tests/pathfinding.rs diff --git a/src/app.rs b/src/app.rs index f796513..b81bd1e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,7 @@ pub struct App { pub game: Game, last_tick: Instant, focused: bool, - cursor_pos: Vec2, + _cursor_pos: Vec2, } impl App { @@ -46,7 +46,7 @@ impl App { .build() .map_err(|e| GameError::Sdl(e.to_string()))?; - let mut canvas = Box::leak(Box::new( + let canvas = Box::leak(Box::new( window .into_canvas() .accelerated() @@ -74,7 +74,7 @@ impl App { game, focused: true, last_tick: Instant::now(), - cursor_pos: Vec2::ZERO, + _cursor_pos: Vec2::ZERO, }) } diff --git a/src/entity/mod.rs b/src/entity/mod.rs index d23c3be..d05c62d 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -5,4 +5,3 @@ pub mod graph; pub mod item; pub mod pacman; pub mod r#trait; -pub mod traversal; diff --git a/src/entity/traversal.rs b/src/entity/traversal.rs deleted file mode 100644 index 1bec8a4..0000000 --- a/src/entity/traversal.rs +++ /dev/null @@ -1,159 +0,0 @@ -use crate::ecs::components::Position; -use crate::error::GameResult; - -use super::direction::Direction; -use super::graph::{Edge, Graph}; - -/// Manages an entity's movement through the graph. -/// -/// A `Traverser` encapsulates the state of an entity's position and direction, -/// providing a way to advance along the graph's paths based on a given distance. -/// It also handles direction changes, buffering the next intended direction. -pub struct Traverser { - /// The current position of the traverser in the graph. - pub position: Position, - /// The current direction of movement. - pub direction: Direction, - /// Buffered direction change with remaining frame count for timing. - /// - /// The `u8` value represents the number of frames remaining before - /// the buffered direction expires. This allows for responsive controls - /// by storing direction changes for a limited time. - pub next_direction: Option<(Direction, u8)>, -} - -impl Traverser { - /// Sets the next direction for the traverser to take. - /// - /// The direction is buffered and will be applied at the next opportunity, - /// typically when the traverser reaches a new node. This allows for responsive - /// controls, as the new direction is stored for a limited time. - pub fn set_next_direction(&mut self, new_direction: Direction) { - if self.direction != new_direction { - self.next_direction = Some((new_direction, 30)); - } - } - - /// Advances the traverser along the graph by a specified distance. - /// - /// This method updates the traverser's position based on its current state - /// and the distance to travel. - /// - /// - If at a node, it checks for a buffered direction to start moving. - /// - If between nodes, it moves along the current edge. - /// - If it reaches a node, it attempts to transition to a new edge based on - /// the buffered direction or by continuing straight. - /// - If no valid move is possible, it stops at the node. - /// - /// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction). - pub fn advance(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()> - where - F: Fn(Edge) -> bool, - { - // Decrement the remaining frames for the next direction - if let Some((direction, remaining)) = self.next_direction { - if remaining > 0 { - self.next_direction = Some((direction, remaining - 1)); - } else { - self.next_direction = None; - } - } - - match self.position { - Position::AtNode(node_id) => { - // We're not moving, but a buffered direction is available. - if let Some((next_direction, _)) = self.next_direction { - if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) { - if can_traverse(edge) { - // Start moving in that direction - self.position = Position::BetweenNodes { - from: node_id, - to: edge.target, - traversed: distance.max(0.0), - }; - self.direction = next_direction; - } else { - return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( - format!( - "Cannot traverse edge from {} to {} in direction {:?}", - node_id, edge.target, next_direction - ), - ))); - } - } else { - return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement( - format!("No edge found in direction {:?} from node {}", next_direction, node_id), - ))); - } - - self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it - } - } - 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 Ok(()); - } - - let edge = graph.find_edge(from, to).ok_or_else(|| { - crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!( - "Inconsistent state: Traverser is on a non-existent edge from {} to {}.", - from, to - ))) - })?; - - let new_traversed = traversed + distance; - - if new_traversed < edge.distance { - // Still on the same edge, just update the distance. - self.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, _)) = self.next_direction { - if let Some(edge) = graph.find_edge_in_direction(to, next_dir) { - if can_traverse(edge) { - self.position = Position::BetweenNodes { - from: to, - to: edge.target, - traversed: overflow, - }; - - self.direction = next_dir; // Remember our new direction - self.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) = graph.find_edge_in_direction(to, self.direction) { - if can_traverse(edge) { - self.position = Position::BetweenNodes { - from: to, - to: edge.target, - traversed: overflow, - }; - } else { - self.position = Position::AtNode(to); - self.next_direction = None; - } - } else { - self.position = Position::AtNode(to); - self.next_direction = None; - } - } - } - } - } - - Ok(()) - } -} diff --git a/src/texture/animated.rs b/src/texture/animated.rs index 5bf5e7d..8020797 100644 --- a/src/texture/animated.rs +++ b/src/texture/animated.rs @@ -1,8 +1,5 @@ -use sdl2::rect::Rect; -use sdl2::render::{Canvas, RenderTarget}; - use crate::error::{AnimatedTextureError, GameError, GameResult, TextureError}; -use crate::texture::sprite::{AtlasTile, SpriteAtlas}; +use crate::texture::sprite::AtlasTile; #[derive(Debug, Clone)] pub struct AnimatedTexture { @@ -40,12 +37,6 @@ impl AnimatedTexture { &self.tiles[self.current_frame] } - pub fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, dest: Rect) -> GameResult<()> { - let mut tile = *self.current_tile(); - tile.render(canvas, atlas, dest)?; - Ok(()) - } - /// Returns the current frame index. #[allow(dead_code)] pub fn current_frame(&self) -> usize { diff --git a/src/texture/directional.rs b/src/texture/directional.rs deleted file mode 100644 index ab477c7..0000000 --- a/src/texture/directional.rs +++ /dev/null @@ -1,80 +0,0 @@ -use sdl2::rect::Rect; -use sdl2::render::{Canvas, RenderTarget}; - -use crate::entity::direction::Direction; -use crate::error::GameResult; -use crate::texture::animated::AnimatedTexture; -use crate::texture::sprite::SpriteAtlas; - -#[derive(Clone)] -pub struct DirectionalAnimatedTexture { - textures: [Option; 4], - stopped_textures: [Option; 4], -} - -impl DirectionalAnimatedTexture { - pub fn new(textures: [Option; 4], stopped_textures: [Option; 4]) -> Self { - Self { - textures, - stopped_textures, - } - } - - pub fn tick(&mut self, dt: f32) { - for texture in self.textures.iter_mut().flatten() { - texture.tick(dt); - } - } - - pub fn render( - &self, - canvas: &mut Canvas, - atlas: &mut SpriteAtlas, - dest: Rect, - direction: Direction, - ) -> GameResult<()> { - if let Some(texture) = &self.textures[direction.as_usize()] { - texture.render(canvas, atlas, dest) - } else { - Ok(()) - } - } - - pub fn render_stopped( - &self, - canvas: &mut Canvas, - atlas: &mut SpriteAtlas, - dest: Rect, - direction: Direction, - ) -> GameResult<()> { - if let Some(texture) = &self.stopped_textures[direction.as_usize()] { - texture.render(canvas, atlas, dest) - } else { - Ok(()) - } - } - - /// Returns true if the texture has a direction. - #[allow(dead_code)] - pub fn has_direction(&self, direction: Direction) -> bool { - self.textures[direction.as_usize()].is_some() - } - - /// Returns true if the texture has a stopped direction. - #[allow(dead_code)] - pub fn has_stopped_direction(&self, direction: Direction) -> bool { - self.stopped_textures[direction.as_usize()].is_some() - } - - /// Returns the number of textures. - #[allow(dead_code)] - pub fn texture_count(&self) -> usize { - self.textures.iter().filter(|t| t.is_some()).count() - } - - /// Returns the number of stopped textures. - #[allow(dead_code)] - pub fn stopped_texture_count(&self) -> usize { - self.stopped_textures.iter().filter(|t| t.is_some()).count() - } -} diff --git a/src/texture/mod.rs b/src/texture/mod.rs index 95aeee5..8d58c93 100644 --- a/src/texture/mod.rs +++ b/src/texture/mod.rs @@ -1,5 +1,4 @@ pub mod animated; pub mod blinking; -pub mod directional; pub mod sprite; pub mod text; diff --git a/src/texture/sprite.rs b/src/texture/sprite.rs index b271c9e..6e7111b 100644 --- a/src/texture/sprite.rs +++ b/src/texture/sprite.rs @@ -8,33 +8,6 @@ use std::collections::HashMap; use crate::error::TextureError; -/// A simple sprite for stationary items like pellets and energizers. -#[derive(Clone, Debug)] -pub struct Sprite { - pub atlas_tile: AtlasTile, -} - -impl Sprite { - pub fn new(atlas_tile: AtlasTile) -> Self { - Self { atlas_tile } - } - - pub fn render( - &self, - canvas: &mut Canvas, - atlas: &mut SpriteAtlas, - position: glam::Vec2, - ) -> Result<(), TextureError> { - let dest = crate::helpers::centered_with_size( - glam::IVec2::new(position.x as i32, position.y as i32), - glam::UVec2::new(self.atlas_tile.size.x as u32, self.atlas_tile.size.y as u32), - ); - let mut tile = self.atlas_tile; - tile.render(canvas, atlas, dest)?; - Ok(()) - } -} - #[derive(Clone, Debug, Deserialize)] pub struct AtlasMapper { pub frames: HashMap, diff --git a/tests/collision.rs b/tests/collision.rs deleted file mode 100644 index a7ea569..0000000 --- a/tests/collision.rs +++ /dev/null @@ -1,119 +0,0 @@ -use pacman::entity::collision::{Collidable, CollisionSystem}; -use pacman::entity::traversal::Position; - -struct MockCollidable { - pos: Position, -} - -impl Collidable for MockCollidable { - fn position(&self) -> Position { - self.pos - } -} - -#[test] -fn test_is_colliding_with() { - let entity1 = MockCollidable { - pos: Position::AtNode(1), - }; - let entity2 = MockCollidable { - pos: Position::AtNode(1), - }; - let entity3 = MockCollidable { - pos: Position::AtNode(2), - }; - let entity4 = MockCollidable { - pos: Position::BetweenNodes { - from: 1, - to: 2, - traversed: 0.5, - }, - }; - - assert!(entity1.is_colliding_with(&entity2)); - assert!(!entity1.is_colliding_with(&entity3)); - assert!(entity1.is_colliding_with(&entity4)); - assert!(entity3.is_colliding_with(&entity4)); -} - -#[test] -fn test_collision_system_register_and_query() { - let mut collision_system = CollisionSystem::default(); - - let pos1 = Position::AtNode(1); - let entity1 = collision_system.register_entity(pos1); - - let pos2 = Position::BetweenNodes { - from: 1, - to: 2, - traversed: 0.5, - }; - let entity2 = collision_system.register_entity(pos2); - - let pos3 = Position::AtNode(3); - let entity3 = collision_system.register_entity(pos3); - - // Test entities_at_node - assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]); - assert_eq!(collision_system.entities_at_node(2), &[entity2]); - assert_eq!(collision_system.entities_at_node(3), &[entity3]); - assert_eq!(collision_system.entities_at_node(4), &[] as &[u32]); - - // Test potential_collisions - let mut collisions1 = collision_system.potential_collisions(&pos1); - collisions1.sort_unstable(); - assert_eq!(collisions1, vec![entity1, entity2]); - - let mut collisions2 = collision_system.potential_collisions(&pos2); - collisions2.sort_unstable(); - assert_eq!(collisions2, vec![entity1, entity2]); - - let mut collisions3 = collision_system.potential_collisions(&pos3); - collisions3.sort_unstable(); - assert_eq!(collisions3, vec![entity3]); -} - -#[test] -fn test_collision_system_update() { - let mut collision_system = CollisionSystem::default(); - - let entity1 = collision_system.register_entity(Position::AtNode(1)); - - assert_eq!(collision_system.entities_at_node(1), &[entity1]); - assert_eq!(collision_system.entities_at_node(2), &[] as &[u32]); - - collision_system.update_position(entity1, Position::AtNode(2)); - - assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]); - assert_eq!(collision_system.entities_at_node(2), &[entity1]); - - collision_system.update_position( - entity1, - Position::BetweenNodes { - from: 2, - to: 3, - traversed: 0.1, - }, - ); - - assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]); - assert_eq!(collision_system.entities_at_node(2), &[entity1]); - assert_eq!(collision_system.entities_at_node(3), &[entity1]); -} - -#[test] -fn test_collision_system_remove() { - let mut collision_system = CollisionSystem::default(); - - let entity1 = collision_system.register_entity(Position::AtNode(1)); - let entity2 = collision_system.register_entity(Position::AtNode(1)); - - assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]); - - collision_system.remove_entity(entity1); - - assert_eq!(collision_system.entities_at_node(1), &[entity2]); - - collision_system.remove_entity(entity2); - assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]); -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2cf25af..584bf56 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -2,7 +2,7 @@ use pacman::{ asset::{get_asset_bytes, Asset}, - game::state::ATLAS_FRAMES, + game::ATLAS_FRAMES, texture::sprite::{AtlasMapper, SpriteAtlas}, }; use sdl2::{ diff --git a/tests/directional.rs b/tests/directional.rs deleted file mode 100644 index 11b3707..0000000 --- a/tests/directional.rs +++ /dev/null @@ -1,77 +0,0 @@ -use glam::U16Vec2; -use pacman::entity::direction::Direction; -use pacman::texture::animated::AnimatedTexture; -use pacman::texture::directional::DirectionalAnimatedTexture; -use pacman::texture::sprite::AtlasTile; -use sdl2::pixels::Color; - -fn mock_atlas_tile(id: u32) -> AtlasTile { - AtlasTile { - pos: U16Vec2::new(0, 0), - size: U16Vec2::new(16, 16), - color: Some(Color::RGB(id as u8, 0, 0)), - } -} - -fn mock_animated_texture(id: u32) -> AnimatedTexture { - AnimatedTexture::new(vec![mock_atlas_tile(id)], 0.1).expect("Invalid frame duration") -} - -#[test] -fn test_directional_texture_partial_directions() { - let mut textures = [None, None, None, None]; - textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1)); - - let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]); - - assert_eq!(texture.texture_count(), 1); - assert!(texture.has_direction(Direction::Up)); - assert!(!texture.has_direction(Direction::Down)); - assert!(!texture.has_direction(Direction::Left)); - assert!(!texture.has_direction(Direction::Right)); -} - -#[test] -fn test_directional_texture_all_directions() { - let mut textures = [None, None, None, None]; - let directions = [ - (Direction::Up, 1), - (Direction::Down, 2), - (Direction::Left, 3), - (Direction::Right, 4), - ]; - - for (direction, id) in directions { - textures[direction.as_usize()] = Some(mock_animated_texture(id)); - } - - let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]); - - assert_eq!(texture.texture_count(), 4); - for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] { - assert!(texture.has_direction(*direction)); - } -} - -#[test] -fn test_directional_texture_stopped() { - let mut stopped_textures = [None, None, None, None]; - stopped_textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1)); - - let texture = DirectionalAnimatedTexture::new([None, None, None, None], stopped_textures); - - assert_eq!(texture.stopped_texture_count(), 1); - assert!(texture.has_stopped_direction(Direction::Up)); - assert!(!texture.has_stopped_direction(Direction::Down)); -} - -#[test] -fn test_directional_texture_tick() { - let mut textures = [None, None, None, None]; - textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1)); - let mut texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]); - - // This is a bit of a placeholder, since we can't inspect the inner state easily. - // We're just ensuring the tick method runs without panicking. - texture.tick(0.1); -} diff --git a/tests/game.rs b/tests/game.rs index 6578d74..aa755fd 100644 --- a/tests/game.rs +++ b/tests/game.rs @@ -1,7 +1,6 @@ use pacman::constants::RAW_BOARD; use pacman::map::builder::Map; -mod collision; mod item; #[test] diff --git a/tests/ghost.rs b/tests/ghost.rs deleted file mode 100644 index 0798661..0000000 --- a/tests/ghost.rs +++ /dev/null @@ -1,48 +0,0 @@ -use pacman::entity::ghost::{Ghost, GhostType}; -use pacman::entity::graph::Graph; -use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; -use std::collections::HashMap; - -fn create_test_atlas() -> SpriteAtlas { - let mut frames = HashMap::new(); - let directions = ["up", "down", "left", "right"]; - let ghost_types = ["blinky", "pinky", "inky", "clyde"]; - - for ghost_type in &ghost_types { - for (i, dir) in directions.iter().enumerate() { - frames.insert( - format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"), - MapperFrame { - x: i as u16 * 16, - y: 0, - width: 16, - height: 16, - }, - ); - frames.insert( - format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"), - MapperFrame { - x: i as u16 * 16, - y: 16, - width: 16, - height: 16, - }, - ); - } - } - - let mapper = AtlasMapper { frames }; - let dummy_texture = unsafe { std::mem::zeroed() }; - SpriteAtlas::new(dummy_texture, mapper) -} - -#[test] -fn test_ghost_creation() { - let graph = Graph::new(); - let atlas = create_test_atlas(); - - let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas).unwrap(); - - assert_eq!(ghost.ghost_type, GhostType::Blinky); - assert_eq!(ghost.traverser.position.from_node_id(), 0); -} diff --git a/tests/graph.rs b/tests/graph.rs index 0653931..b9d4477 100644 --- a/tests/graph.rs +++ b/tests/graph.rs @@ -1,6 +1,5 @@ use pacman::entity::direction::Direction; use pacman::entity::graph::{EdgePermissions, Graph, Node}; -use pacman::entity::traversal::{Position, Traverser}; fn create_test_graph() -> Graph { let mut graph = Graph::new(); @@ -150,68 +149,3 @@ fn should_find_edge_between_nodes() { let non_existent_edge = graph.find_edge(0, 99); assert!(non_existent_edge.is_none()); } - -#[test] -fn test_traverser_basic() { - let graph = create_test_graph(); - let mut traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true); - - traverser.set_next_direction(Direction::Up); - assert!(traverser.next_direction.is_some()); - assert_eq!(traverser.next_direction.unwrap().0, Direction::Up); -} - -#[test] -fn test_traverser_advance() { - let graph = create_test_graph(); - let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true); - - traverser.advance(&graph, 5.0, &|_| true).unwrap(); - - match traverser.position { - Position::BetweenNodes { from, to, traversed } => { - assert_eq!(from, 0); - assert_eq!(to, 1); - assert_eq!(traversed, 5.0); - } - _ => panic!("Expected to be between nodes"), - } - - traverser.advance(&graph, 3.0, &|_| true).unwrap(); - - match traverser.position { - Position::BetweenNodes { from, to, traversed } => { - assert_eq!(from, 0); - assert_eq!(to, 1); - assert_eq!(traversed, 8.0); - } - _ => panic!("Expected to be between nodes"), - } -} - -#[test] -fn test_traverser_with_permissions() { - let mut graph = Graph::new(); - let node1 = graph.add_node(Node { - position: glam::Vec2::new(0.0, 0.0), - }); - let node2 = graph.add_node(Node { - position: glam::Vec2::new(16.0, 0.0), - }); - - graph - .add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly) - .unwrap(); - - // Pacman can't traverse ghost-only edges - let mut traverser = Traverser::new(&graph, node1, Direction::Right, &|edge| { - matches!(edge.permissions, EdgePermissions::All) - }); - - traverser - .advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All)) - .unwrap(); - - // Should still be at the node since it can't traverse - assert!(traverser.position.is_at_node()); -} diff --git a/tests/item.rs b/tests/item.rs index 7a881f9..c8f5b99 100644 --- a/tests/item.rs +++ b/tests/item.rs @@ -1,53 +1,46 @@ -use glam::U16Vec2; -use pacman::{ - entity::{ - collision::Collidable, - item::{FruitKind, Item, ItemType}, - }, - texture::sprite::{AtlasTile, Sprite}, -}; -use strum::{EnumCount, IntoEnumIterator}; +// use glam::U16Vec2; +// use pacman::texture::sprite::{AtlasTile, Sprite}; -#[test] -fn test_item_type_get_score() { - assert_eq!(ItemType::Pellet.get_score(), 10); - assert_eq!(ItemType::Energizer.get_score(), 50); +// #[test] +// fn test_item_type_get_score() { +// assert_eq!(ItemType::Pellet.get_score(), 10); +// assert_eq!(ItemType::Energizer.get_score(), 50); - let fruit = ItemType::Fruit { kind: FruitKind::Apple }; - assert_eq!(fruit.get_score(), 100); -} +// let fruit = ItemType::Fruit { kind: FruitKind::Apple }; +// assert_eq!(fruit.get_score(), 100); +// } -#[test] -fn test_fruit_kind_increasing_score() { - // Build a list of fruit kinds, sorted by their index - let mut kinds = FruitKind::iter() - .map(|kind| (kind.index(), kind.get_score())) - .collect::>(); - kinds.sort_unstable_by_key(|(index, _)| *index); +// #[test] +// fn test_fruit_kind_increasing_score() { +// // Build a list of fruit kinds, sorted by their index +// let mut kinds = FruitKind::iter() +// .map(|kind| (kind.index(), kind.get_score())) +// .collect::>(); +// kinds.sort_unstable_by_key(|(index, _)| *index); - assert_eq!(kinds.len(), FruitKind::COUNT); +// assert_eq!(kinds.len(), FruitKind::COUNT); - // Check that the score increases as expected - for window in kinds.windows(2) { - let ((_, prev), (_, next)) = (window[0], window[1]); - assert!(prev < next, "Fruits should have increasing scores, but {prev:?} < {next:?}"); - } -} +// // Check that the score increases as expected +// for window in kinds.windows(2) { +// let ((_, prev), (_, next)) = (window[0], window[1]); +// assert!(prev < next, "Fruits should have increasing scores, but {prev:?} < {next:?}"); +// } +// } -#[test] -fn test_item_creation_and_collection() { - let atlas_tile = AtlasTile { - pos: U16Vec2::new(0, 0), - size: U16Vec2::new(16, 16), - color: None, - }; - let sprite = Sprite::new(atlas_tile); - let mut item = Item::new(0, ItemType::Pellet, sprite); +// #[test] +// fn test_item_creation_and_collection() { +// let atlas_tile = AtlasTile { +// pos: U16Vec2::new(0, 0), +// size: U16Vec2::new(16, 16), +// color: None, +// }; +// let sprite = Sprite::new(atlas_tile); +// let mut item = Item::new(0, ItemType::Pellet, sprite); - assert!(!item.is_collected()); - assert_eq!(item.get_score(), 10); - assert_eq!(item.position().from_node_id(), 0); +// assert!(!item.is_collected()); +// assert_eq!(item.get_score(), 10); +// assert_eq!(item.position().from_node_id(), 0); - item.collect(); - assert!(item.is_collected()); -} +// item.collect(); +// assert!(item.is_collected()); +// } diff --git a/tests/map_builder.rs b/tests/map_builder.rs index 5fb5df7..e67a17a 100644 --- a/tests/map_builder.rs +++ b/tests/map_builder.rs @@ -1,7 +1,6 @@ use glam::Vec2; use pacman::constants::{CELL_SIZE, RAW_BOARD}; use pacman::map::builder::Map; -use sdl2::render::Texture; #[test] fn test_map_creation() { @@ -34,60 +33,60 @@ fn test_map_node_positions() { } } -#[test] -fn test_generate_items() { - use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; - use std::collections::HashMap; +// #[test] +// fn test_generate_items() { +// use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; +// use std::collections::HashMap; - let map = Map::new(RAW_BOARD).unwrap(); +// let map = Map::new(RAW_BOARD).unwrap(); - // Create a minimal atlas for testing - let mut frames = HashMap::new(); - frames.insert( - "maze/pellet.png".to_string(), - MapperFrame { - x: 0, - y: 0, - width: 8, - height: 8, - }, - ); - frames.insert( - "maze/energizer.png".to_string(), - MapperFrame { - x: 8, - y: 0, - width: 8, - height: 8, - }, - ); +// // Create a minimal atlas for testing +// let mut frames = HashMap::new(); +// frames.insert( +// "maze/pellet.png".to_string(), +// MapperFrame { +// x: 0, +// y: 0, +// width: 8, +// height: 8, +// }, +// ); +// frames.insert( +// "maze/energizer.png".to_string(), +// MapperFrame { +// x: 8, +// y: 0, +// width: 8, +// height: 8, +// }, +// ); - let mapper = AtlasMapper { frames }; - let texture = unsafe { std::mem::transmute::>(0usize) }; - let atlas = SpriteAtlas::new(texture, mapper); +// let mapper = AtlasMapper { frames }; +// let texture = unsafe { std::mem::transmute::>(0usize) }; +// let atlas = SpriteAtlas::new(texture, mapper); - let items = map.generate_items(&atlas).unwrap(); +// let items = map.generate_items(&atlas).unwrap(); - // Verify we have items - assert!(!items.is_empty()); +// // Verify we have items +// assert!(!items.is_empty()); - // Count different types - let pellet_count = items - .iter() - .filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Pellet)) - .count(); - let energizer_count = items - .iter() - .filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Energizer)) - .count(); +// // Count different types +// let pellet_count = items +// .iter() +// .filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Pellet)) +// .count(); +// let energizer_count = items +// .iter() +// .filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Energizer)) +// .count(); - // Should have both types - assert_eq!(pellet_count, 240); - assert_eq!(energizer_count, 4); +// // Should have both types +// assert_eq!(pellet_count, 240); +// assert_eq!(energizer_count, 4); - // All items should be uncollected initially - assert!(items.iter().all(|item| !item.is_collected())); +// // All items should be uncollected initially +// assert!(items.iter().all(|item| !item.is_collected())); - // All items should have valid node indices - assert!(items.iter().all(|item| item.node_index < map.graph.node_count())); -} +// // All items should have valid node indices +// assert!(items.iter().all(|item| item.node_index < map.graph.node_count())); +// } diff --git a/tests/pacman.rs b/tests/pacman.rs deleted file mode 100644 index d591215..0000000 --- a/tests/pacman.rs +++ /dev/null @@ -1,73 +0,0 @@ -use pacman::entity::direction::Direction; -use pacman::entity::graph::{Graph, Node}; -use pacman::entity::pacman::Pacman; -use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; -use std::collections::HashMap; - -fn create_test_graph() -> Graph { - let mut graph = Graph::new(); - let node1 = graph.add_node(Node { - position: glam::Vec2::new(0.0, 0.0), - }); - let node2 = graph.add_node(Node { - position: glam::Vec2::new(16.0, 0.0), - }); - let node3 = graph.add_node(Node { - position: glam::Vec2::new(0.0, 16.0), - }); - - graph.connect(node1, node2, false, None, Direction::Right).unwrap(); - graph.connect(node1, node3, false, None, Direction::Down).unwrap(); - - graph -} - -fn create_test_atlas() -> SpriteAtlas { - let mut frames = HashMap::new(); - let directions = ["up", "down", "left", "right"]; - - for (i, dir) in directions.iter().enumerate() { - frames.insert( - format!("pacman/{dir}_a.png"), - MapperFrame { - x: i as u16 * 16, - y: 0, - width: 16, - height: 16, - }, - ); - frames.insert( - format!("pacman/{dir}_b.png"), - MapperFrame { - x: i as u16 * 16, - y: 16, - width: 16, - height: 16, - }, - ); - } - - frames.insert( - "pacman/full.png".to_string(), - MapperFrame { - x: 64, - y: 0, - width: 16, - height: 16, - }, - ); - - let mapper = AtlasMapper { frames }; - let dummy_texture = unsafe { std::mem::zeroed() }; - SpriteAtlas::new(dummy_texture, mapper) -} - -#[test] -fn test_pacman_creation() { - let graph = create_test_graph(); - let atlas = create_test_atlas(); - let pacman = Pacman::new(&graph, 0, &atlas).unwrap(); - - assert!(pacman.traverser.position.is_at_node()); - assert_eq!(pacman.traverser.direction, Direction::Left); -} diff --git a/tests/pathfinding.rs b/tests/pathfinding.rs deleted file mode 100644 index 030bd9a..0000000 --- a/tests/pathfinding.rs +++ /dev/null @@ -1,120 +0,0 @@ -use pacman::entity::direction::Direction; -use pacman::entity::ghost::{Ghost, GhostType}; -use pacman::entity::graph::{Graph, Node}; -use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; -use std::collections::HashMap; - -fn create_test_atlas() -> SpriteAtlas { - let mut frames = HashMap::new(); - let directions = ["up", "down", "left", "right"]; - let ghost_types = ["blinky", "pinky", "inky", "clyde"]; - - for ghost_type in &ghost_types { - for (i, dir) in directions.iter().enumerate() { - frames.insert( - format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"), - MapperFrame { - x: i as u16 * 16, - y: 0, - width: 16, - height: 16, - }, - ); - frames.insert( - format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"), - MapperFrame { - x: i as u16 * 16, - y: 16, - width: 16, - height: 16, - }, - ); - } - } - - let mapper = AtlasMapper { frames }; - let dummy_texture = unsafe { std::mem::zeroed() }; - SpriteAtlas::new(dummy_texture, mapper) -} - -#[test] -fn test_ghost_pathfinding() { - // Create a simple test graph - let mut graph = Graph::new(); - - // Add nodes in a simple line: 0 -> 1 -> 2 - let node0 = graph.add_node(Node { - position: glam::Vec2::new(0.0, 0.0), - }); - let node1 = graph.add_node(Node { - position: glam::Vec2::new(10.0, 0.0), - }); - let node2 = graph.add_node(Node { - position: glam::Vec2::new(20.0, 0.0), - }); - - // Connect the nodes - graph.connect(node0, node1, false, None, Direction::Right).unwrap(); - graph.connect(node1, node2, false, None, Direction::Right).unwrap(); - - // Create a test atlas for the ghost - let atlas = create_test_atlas(); - - // Create a ghost at node 0 - let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap(); - - // Test pathfinding from node 0 to node 2 - let path = ghost.calculate_path_to_target(&graph, node2); - - assert!(path.is_ok()); - let path = path.unwrap(); - assert!( - path == vec![node0, node1, node2] || path == vec![node2, node1, node0], - "Path was not what was expected" - ); -} - -#[test] -fn test_ghost_pathfinding_no_path() { - // Create a test graph with disconnected components - let mut graph = Graph::new(); - - let node0 = graph.add_node(Node { - position: glam::Vec2::new(0.0, 0.0), - }); - let node1 = graph.add_node(Node { - position: glam::Vec2::new(10.0, 0.0), - }); - - // Don't connect the nodes - let atlas = create_test_atlas(); - let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap(); - - // Test pathfinding when no path exists - let path = ghost.calculate_path_to_target(&graph, node1); - - assert!(path.is_err()); -} - -#[test] -fn test_ghost_debug_colors() { - let atlas = create_test_atlas(); - let mut graph = Graph::new(); - let node = graph.add_node(Node { - position: glam::Vec2::new(0.0, 0.0), - }); - - let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas).unwrap(); - let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas).unwrap(); - let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas).unwrap(); - let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas).unwrap(); - - // Test that each ghost has a different debug color - let colors = std::collections::HashSet::from([ - blinky.debug_color(), - pinky.debug_color(), - inky.debug_color(), - clyde.debug_color(), - ]); - assert_eq!(colors.len(), 4, "All ghost colors should be unique"); -} diff --git a/tests/sprite.rs b/tests/sprite.rs index 9da3510..74974ba 100644 --- a/tests/sprite.rs +++ b/tests/sprite.rs @@ -1,5 +1,5 @@ use glam::U16Vec2; -use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame, Sprite, SpriteAtlas}; +use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame, SpriteAtlas}; use sdl2::pixels::Color; use std::collections::HashMap; @@ -92,12 +92,3 @@ fn test_atlas_tile_new_and_with_color() { let tile_with_color = tile.with_color(color); assert_eq!(tile_with_color.color, Some(color)); } - -#[test] -fn test_sprite_new() { - let atlas_tile = AtlasTile::new(U16Vec2::new(0, 0), U16Vec2::new(16, 16), None); - let sprite = Sprite::new(atlas_tile); - - assert_eq!(sprite.atlas_tile.pos, atlas_tile.pos); - assert_eq!(sprite.atlas_tile.size, atlas_tile.size); -} From 2c65048fb0bed5ee646071bd3d3424bda3e2b44f Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 09:27:28 -0500 Subject: [PATCH 07/44] refactor: rename 'ecs' submodule to 'systems' --- src/entity/graph.rs | 2 +- src/game/mod.rs | 12 ++++++------ src/lib.rs | 2 +- src/main.rs | 2 +- src/map/builder.rs | 2 +- src/{ecs => systems}/components.rs | 0 src/{ecs => systems}/interact.rs | 2 +- src/{ecs => systems}/mod.rs | 0 src/{ecs => systems}/movement.rs | 2 +- src/{ecs => systems}/render.rs | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) rename src/{ecs => systems}/components.rs (100%) rename src/{ecs => systems}/interact.rs (94%) rename src/{ecs => systems}/mod.rs (100%) rename src/{ecs => systems}/movement.rs (98%) rename src/{ecs => systems}/render.rs (97%) diff --git a/src/entity/graph.rs b/src/entity/graph.rs index b6dffb5..0bf3021 100644 --- a/src/entity/graph.rs +++ b/src/entity/graph.rs @@ -1,6 +1,6 @@ use glam::Vec2; -use crate::ecs::components::NodeId; +use crate::systems::components::NodeId; use super::direction::Direction; diff --git a/src/game/mod.rs b/src/game/mod.rs index 7210fc0..9b0360b 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -3,16 +3,16 @@ include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::CANVAS_SIZE; -use crate::ecs::components::{ - DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, -}; -use crate::ecs::interact::interact_system; -use crate::ecs::movement::movement_system; -use crate::ecs::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; use crate::entity::direction::Direction; use crate::error::{GameError, GameResult, TextureError}; use crate::input::commands::GameCommand; use crate::map::builder::Map; +use crate::systems::components::{ + DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, +}; +use crate::systems::interact::interact_system; +use crate::systems::movement::movement_system; +use crate::systems::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; use crate::texture::animated::AnimatedTexture; use bevy_ecs::event::EventRegistry; use bevy_ecs::observer::Trigger; diff --git a/src/lib.rs b/src/lib.rs index a90eb5d..6d2f054 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,6 @@ pub mod app; pub mod asset; pub mod audio; pub mod constants; -pub mod ecs; pub mod entity; pub mod error; pub mod game; @@ -12,4 +11,5 @@ pub mod helpers; pub mod input; pub mod map; pub mod platform; +pub mod systems; pub mod texture; diff --git a/src/main.rs b/src/main.rs index 2117321..2b9453d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,6 @@ mod asset; mod audio; mod constants; -mod ecs; mod entity; mod error; mod game; @@ -18,6 +17,7 @@ mod helpers; mod input; mod map; mod platform; +mod systems; mod texture; /// The main entry point of the application. diff --git a/src/map/builder.rs b/src/map/builder.rs index 49b32e3..ca83767 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -1,11 +1,11 @@ //! Map construction and building functionality. use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; -use crate::ecs::components::NodeId; use crate::entity::direction::Direction; use crate::entity::graph::{EdgePermissions, Graph, Node}; use crate::map::parser::MapTileParser; use crate::map::render::MapRenderer; +use crate::systems::components::NodeId; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::resource::Resource; use glam::{IVec2, Vec2}; diff --git a/src/ecs/components.rs b/src/systems/components.rs similarity index 100% rename from src/ecs/components.rs rename to src/systems/components.rs diff --git a/src/ecs/interact.rs b/src/systems/interact.rs similarity index 94% rename from src/ecs/interact.rs rename to src/systems/interact.rs index 8ac4588..3855519 100644 --- a/src/ecs/interact.rs +++ b/src/systems/interact.rs @@ -4,10 +4,10 @@ use bevy_ecs::{ }; use crate::{ - ecs::components::{GlobalState, PlayerControlled, Velocity}, error::GameError, game::events::GameEvent, input::commands::GameCommand, + systems::components::{GlobalState, PlayerControlled, Velocity}, }; // Handles diff --git a/src/ecs/mod.rs b/src/systems/mod.rs similarity index 100% rename from src/ecs/mod.rs rename to src/systems/mod.rs diff --git a/src/ecs/movement.rs b/src/systems/movement.rs similarity index 98% rename from src/ecs/movement.rs rename to src/systems/movement.rs index 10f5a91..7ca5e6d 100644 --- a/src/ecs/movement.rs +++ b/src/systems/movement.rs @@ -1,7 +1,7 @@ -use crate::ecs::components::{DeltaTime, PlayerControlled, Position, Velocity}; use crate::entity::graph::EdgePermissions; use crate::error::{EntityError, GameError}; use crate::map::builder::Map; +use crate::systems::components::{DeltaTime, PlayerControlled, Position, Velocity}; use bevy_ecs::event::EventWriter; use bevy_ecs::system::{Query, Res}; diff --git a/src/ecs/render.rs b/src/systems/render.rs similarity index 97% rename from src/ecs/render.rs rename to src/systems/render.rs index 56d7db7..5f959e4 100644 --- a/src/ecs/render.rs +++ b/src/systems/render.rs @@ -1,6 +1,6 @@ -use crate::ecs::components::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity}; use crate::error::{GameError, TextureError}; use crate::map::builder::Map; +use crate::systems::components::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity}; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; From b9bae99a4c2db86982d8ae3f266e801558ddf7ac Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 09:48:16 -0500 Subject: [PATCH 08/44] refactor: reorganize systems properly, move events to events.rs --- src/{game => }/events.rs | 2 +- src/game/mod.rs | 13 +++++-------- src/input/commands.rs | 11 ----------- src/lib.rs | 2 +- src/main.rs | 2 +- src/systems/{interact.rs => control.rs} | 10 ++++++---- src/{input/mod.rs => systems/input.rs} | 14 +++++++++++--- src/systems/mod.rs | 3 ++- 8 files changed, 27 insertions(+), 30 deletions(-) rename src/{game => }/events.rs (85%) delete mode 100644 src/input/commands.rs rename src/systems/{interact.rs => control.rs} (87%) rename src/{input/mod.rs => systems/input.rs} (88%) diff --git a/src/game/events.rs b/src/events.rs similarity index 85% rename from src/game/events.rs rename to src/events.rs index f8a41f0..90d5285 100644 --- a/src/game/events.rs +++ b/src/events.rs @@ -1,6 +1,6 @@ use bevy_ecs::event::Event; -use crate::input::commands::GameCommand; +use crate::systems::input::GameCommand; #[derive(Debug, Clone, Copy, Event)] pub enum GameEvent { diff --git a/src/game/mod.rs b/src/game/mod.rs index 9b0360b..5c56540 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -5,12 +5,12 @@ include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::CANVAS_SIZE; use crate::entity::direction::Direction; use crate::error::{GameError, GameResult, TextureError}; -use crate::input::commands::GameCommand; +use crate::events::GameEvent; use crate::map::builder::Map; use crate::systems::components::{ DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, }; -use crate::systems::interact::interact_system; +use crate::systems::control::player_system; use crate::systems::movement::movement_system; use crate::systems::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; use crate::texture::animated::AnimatedTexture; @@ -25,16 +25,13 @@ use sdl2::video::{Window, WindowContext}; use sdl2::EventPump; use crate::asset::{get_asset_bytes, Asset}; -use crate::input::{handle_input, Bindings}; use crate::map::render::MapRenderer; +use crate::systems::input::{input_system, Bindings, GameCommand}; use crate::{ constants, texture::sprite::{AtlasMapper, SpriteAtlas}, }; -use self::events::GameEvent; - -pub mod events; pub mod state; /// The `Game` struct is the main entry point for the game. @@ -170,8 +167,8 @@ impl Game { schedule.add_systems( ( - handle_input, - interact_system, + input_system, + player_system, movement_system, directional_render_system, render_system, diff --git a/src/input/commands.rs b/src/input/commands.rs deleted file mode 100644 index 81f4757..0000000 --- a/src/input/commands.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::entity::direction::Direction; - -#[derive(Debug, Clone, Copy)] -pub enum GameCommand { - MovePlayer(Direction), - Exit, - TogglePause, - ToggleDebug, - MuteAudio, - ResetLevel, -} diff --git a/src/lib.rs b/src/lib.rs index 6d2f054..3668b97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,9 +6,9 @@ pub mod audio; pub mod constants; pub mod entity; pub mod error; +pub mod events; pub mod game; pub mod helpers; -pub mod input; pub mod map; pub mod platform; pub mod systems; diff --git a/src/main.rs b/src/main.rs index 2b9453d..907a875 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,9 +12,9 @@ mod constants; mod entity; mod error; +mod events; mod game; mod helpers; -mod input; mod map; mod platform; mod systems; diff --git a/src/systems/interact.rs b/src/systems/control.rs similarity index 87% rename from src/systems/interact.rs rename to src/systems/control.rs index 3855519..b98385e 100644 --- a/src/systems/interact.rs +++ b/src/systems/control.rs @@ -5,13 +5,15 @@ use bevy_ecs::{ use crate::{ error::GameError, - game::events::GameEvent, - input::commands::GameCommand, - systems::components::{GlobalState, PlayerControlled, Velocity}, + events::GameEvent, + systems::{ + components::{GlobalState, PlayerControlled, Velocity}, + input::GameCommand, + }, }; // Handles -pub fn interact_system( +pub fn player_system( mut events: EventReader, mut state: ResMut, mut players: Query<(&PlayerControlled, &mut Velocity)>, diff --git a/src/input/mod.rs b/src/systems/input.rs similarity index 88% rename from src/input/mod.rs rename to src/systems/input.rs index 97ae3cf..44f4df4 100644 --- a/src/input/mod.rs +++ b/src/systems/input.rs @@ -7,9 +7,17 @@ use bevy_ecs::{ }; use sdl2::{event::Event, keyboard::Keycode, EventPump}; -use crate::{entity::direction::Direction, game::events::GameEvent, input::commands::GameCommand}; +use crate::{entity::direction::Direction, events::GameEvent}; -pub mod commands; +#[derive(Debug, Clone, Copy)] +pub enum GameCommand { + MovePlayer(Direction), + Exit, + TogglePause, + ToggleDebug, + MuteAudio, + ResetLevel, +} #[derive(Debug, Clone, Resource)] pub struct Bindings { @@ -42,7 +50,7 @@ impl Default for Bindings { } } -pub fn handle_input(bindings: Res, mut writer: EventWriter, mut pump: NonSendMut<&'static mut EventPump>) { +pub fn input_system(bindings: Res, mut writer: EventWriter, mut pump: NonSendMut<&'static mut EventPump>) { for event in pump.poll_iter() { match event { Event::Quit { .. } => { diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 11f7459..c984ae3 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -4,6 +4,7 @@ //! and resources. pub mod components; -pub mod interact; +pub mod control; +pub mod input; pub mod movement; pub mod render; From 730daed20a9fc1144a4d2d42f81ec5eae4cab7e7 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 10:06:09 -0500 Subject: [PATCH 09/44] feat: entity type for proper edge permission calculations --- src/game/mod.rs | 3 ++- src/systems/components.rs | 11 +++++++++++ src/systems/movement.rs | 22 +++++++++++++--------- src/systems/render.rs | 13 +++++++++++-- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index 5c56540..1991cac 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -8,7 +8,7 @@ use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::systems::components::{ - DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, + DeltaTime, DirectionalAnimated, EntityType, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, }; use crate::systems::control::player_system; use crate::systems::movement::movement_system; @@ -143,6 +143,7 @@ impl Game { textures, stopped_textures, }, + entity_type: EntityType::Player, }; world.insert_non_send_resource(atlas); diff --git a/src/systems/components.rs b/src/systems/components.rs index 3ab2e89..e58e3ae 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -11,6 +11,16 @@ use crate::{ #[derive(Default, Component)] pub struct PlayerControlled; +/// A tag component denoting the type of entity. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EntityType { + Player, + Ghost, + Pellet, + PowerPellet, + Wall, +} + /// A component for entities that have a sprite, with a layer for ordering. /// /// This is intended to be modified by other entities allowing animation. @@ -127,6 +137,7 @@ pub struct PlayerBundle { pub velocity: Velocity, pub sprite: Renderable, pub directional_animated: DirectionalAnimated, + pub entity_type: EntityType, } #[derive(Resource)] diff --git a/src/systems/movement.rs b/src/systems/movement.rs index 7ca5e6d..fc8cffd 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -1,21 +1,25 @@ -use crate::entity::graph::EdgePermissions; +use crate::entity::graph::{Edge, EdgePermissions, Graph}; use crate::error::{EntityError, GameError}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, PlayerControlled, Position, Velocity}; +use crate::systems::components::{DeltaTime, EntityType, Position, Velocity}; use bevy_ecs::event::EventWriter; use bevy_ecs::system::{Query, Res}; -fn can_traverse(_player: &mut PlayerControlled, edge: crate::entity::graph::Edge) -> bool { - matches!(edge.permissions, EdgePermissions::All) +fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { + match entity_type { + EntityType::Player => matches!(edge.permissions, EdgePermissions::All), + EntityType::Ghost => matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly), + _ => matches!(edge.permissions, EdgePermissions::All), + } } pub fn movement_system( map: Res, delta_time: Res, - mut entities: Query<(&mut PlayerControlled, &mut Velocity, &mut Position)>, + mut entities: Query<(&mut Velocity, &mut Position, &EntityType)>, mut errors: EventWriter, ) { - for (mut player, mut velocity, mut position) in entities.iter_mut() { + for (mut velocity, mut position, entity_type) in entities.iter_mut() { let distance = velocity.speed * 60.0 * delta_time.0; // Decrement the remaining frames for the next direction @@ -32,7 +36,7 @@ pub fn movement_system( // 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) { + if can_traverse(*entity_type, edge) { // Start moving in that direction *position = Position::BetweenNodes { from: node_id, @@ -90,7 +94,7 @@ pub fn movement_system( // 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) { + if can_traverse(*entity_type, edge) { *position = Position::BetweenNodes { from: to, to: edge.target, @@ -107,7 +111,7 @@ pub fn movement_system( // 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) { + if can_traverse(*entity_type, edge) { *position = Position::BetweenNodes { from: to, to: edge.target, diff --git a/src/systems/render.rs b/src/systems/render.rs index 5f959e4..27b9727 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -9,6 +9,8 @@ use sdl2::render::{Canvas, Texture}; use sdl2::video::Window; /// Updates the directional animated texture of an entity. +/// +/// This runs before the render system so it can update the sprite based on the current direction of travel, as well as whether the entity is moving. pub fn directional_render_system( dt: Res, mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable, &Position)>, @@ -34,7 +36,10 @@ pub fn directional_render_system( } } +/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource. pub struct MapTextureResource(pub Texture<'static>); + +/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource. pub struct BackbufferResource(pub Texture<'static>); pub fn render_system( @@ -43,7 +48,7 @@ pub fn render_system( mut backbuffer: NonSendMut, mut atlas: NonSendMut, map: Res, - mut renderables: Query<(Entity, &mut Renderable, &Position)>, + renderables: Query<(Entity, &mut Renderable, &Position)>, mut errors: EventWriter, ) { // Clear the main canvas first @@ -64,7 +69,11 @@ pub fn render_system( .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); // Render all entities to the backbuffer - for (_, mut renderable, position) in renderables.iter_mut() { + for (_, mut renderable, position) in renderables + // .iter_mut() + // .sort_by_key::<&mut Renderable, _, _>(|(renderable, renderable, renderable)| renderable.layer) + // .collect() + { let pos = position.get_pixel_pos(&map.graph); match pos { Ok(pos) => { From c5d6ea28e17d9051955f14279719d1011f1ba465 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 11:28:08 -0500 Subject: [PATCH 10/44] fix: discard PlayerControlled tag component --- src/systems/control.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/systems/control.rs b/src/systems/control.rs index b98385e..98b224a 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -1,5 +1,6 @@ use bevy_ecs::{ event::{EventReader, EventWriter}, + query::With, system::{Query, ResMut}, }; @@ -16,14 +17,14 @@ use crate::{ pub fn player_system( mut events: EventReader, mut state: ResMut, - mut players: Query<(&PlayerControlled, &mut Velocity)>, + mut players: Query<&mut Velocity, With>, mut errors: EventWriter, ) { // Get the player's velocity (handling to ensure there is only one player) let mut velocity = match players.single_mut() { - Ok((_, velocity)) => velocity, + Ok(velocity) => velocity, Err(e) => { - errors.write(GameError::InvalidState(format!("Player not found: {}", e)).into()); + errors.write(GameError::InvalidState(format!("No/multiple entities queried for player system: {}", e)).into()); return; } }; From 57d7f75940f5eb7dccd9910dce36f402cc20d372 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 12:21:29 -0500 Subject: [PATCH 11/44] feat: implement generic optimized collision system --- Cargo.lock | 1 + Cargo.toml | 1 + src/events.rs | 15 ++++-- src/game/mod.rs | 100 ++++++++++++++++++++++++++++++-------- src/map/builder.rs | 10 ++++ src/systems/blinking.rs | 27 ++++++++++ src/systems/collision.rs | 47 ++++++++++++++++++ src/systems/components.rs | 49 +++++++++++++++++++ src/systems/control.rs | 13 ++--- src/systems/input.rs | 21 ++------ src/systems/mod.rs | 2 + src/systems/movement.rs | 2 +- 12 files changed, 242 insertions(+), 46 deletions(-) create mode 100644 src/systems/blinking.rs create mode 100644 src/systems/collision.rs diff --git a/Cargo.lock b/Cargo.lock index d66b50f..f55d76a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,6 +571,7 @@ version = "0.2.0" dependencies = [ "anyhow", "bevy_ecs", + "bitflags 2.9.1", "glam 0.30.5", "lazy_static", "libc", diff --git a/Cargo.toml b/Cargo.toml index bb4b470..c977d7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ strum = "0.27.2" strum_macros = "0.27.2" phf = { version = "0.11", features = ["macros"] } bevy_ecs = "0.16.1" +bitflags = "2.9.1" [profile.release] lto = true diff --git a/src/events.rs b/src/events.rs index 90d5285..1f10d62 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,10 +1,19 @@ -use bevy_ecs::event::Event; +use bevy_ecs::prelude::*; -use crate::systems::input::GameCommand; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GameCommand { + Exit, + MovePlayer(crate::entity::direction::Direction), + ToggleDebug, + MuteAudio, + ResetLevel, + TogglePause, +} -#[derive(Debug, Clone, Copy, Event)] +#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] pub enum GameEvent { Command(GameCommand), + Collision(Entity, Entity), } impl From for GameEvent { diff --git a/src/game/mod.rs b/src/game/mod.rs index 1991cac..8e2867e 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -7,28 +7,33 @@ use crate::entity::direction::Direction; use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; -use crate::systems::components::{ - DeltaTime, DirectionalAnimated, EntityType, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, +use crate::systems::blinking::Blinking; +use crate::systems::{ + blinking::blinking_system, + collision::collision_system, + components::{ + Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, + PacmanCollider, PlayerBundle, PlayerControlled, Position, Renderable, Score, ScoreResource, Velocity, + }, + control::player_system, + input::input_system, + movement::movement_system, + render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}, }; -use crate::systems::control::player_system; -use crate::systems::movement::movement_system; -use crate::systems::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; use crate::texture::animated::AnimatedTexture; -use bevy_ecs::event::EventRegistry; -use bevy_ecs::observer::Trigger; use bevy_ecs::schedule::IntoScheduleConfigs; -use bevy_ecs::system::ResMut; -use bevy_ecs::{schedule::Schedule, world::World}; +use bevy_ecs::{event::EventRegistry, observer::Trigger, schedule::Schedule, system::ResMut, world::World}; use sdl2::image::LoadTexture; use sdl2::render::{Canvas, ScaleMode, TextureCreator}; use sdl2::video::{Window, WindowContext}; use sdl2::EventPump; -use crate::asset::{get_asset_bytes, Asset}; -use crate::map::render::MapRenderer; -use crate::systems::input::{input_system, Bindings, GameCommand}; use crate::{ + asset::{get_asset_bytes, Asset}, constants, + events::GameCommand, + map::render::MapRenderer, + systems::input::Bindings, texture::sprite::{AtlasMapper, SpriteAtlas}, }; @@ -138,12 +143,18 @@ impl Game { sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, layer: 0, + visible: true, }, directional_animated: DirectionalAnimated { textures, stopped_textures, }, entity_type: EntityType::Player, + collider: Collider { + size: constants::CELL_SIZE as f32 * 1.25, + layer: CollisionLayer::PACMAN, + }, + pacman_collider: PacmanCollider, }; world.insert_non_send_resource(atlas); @@ -154,23 +165,29 @@ impl Game { world.insert_resource(map); world.insert_resource(GlobalState { exit: false }); + world.insert_resource(ScoreResource(0)); world.insert_resource(Bindings::default()); world.insert_resource(DeltaTime(0f32)); - world.add_observer(|event: Trigger, mut state: ResMut| match *event { - GameEvent::Command(command) => match command { - GameCommand::Exit => { - state.exit = true; - } - _ => {} + world.add_observer( + |event: Trigger, mut state: ResMut, mut score: ResMut| match *event { + GameEvent::Command(command) => match command { + GameCommand::Exit => { + state.exit = true; + } + _ => {} + }, + GameEvent::Collision(a, b) => {} }, - }); + ); schedule.add_systems( ( input_system, player_system, movement_system, + collision_system, + blinking_system, directional_render_system, render_system, ) @@ -180,6 +197,50 @@ impl Game { // Spawn player world.spawn(player); + // Spawn items + let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::(), "maze/pellet.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?; + let energizer_sprite = SpriteAtlas::get_tile(world.non_send_resource::(), "maze/energizer.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/energizer.png".to_string())))?; + + let nodes: Vec<_> = world.resource::().iter_nodes().map(|(id, tile)| (*id, *tile)).collect(); + + for (node_id, tile) in nodes { + let (item_type, score, sprite, size) = match tile { + crate::constants::MapTile::Pellet => (EntityType::Pellet, 10, pellet_sprite, constants::CELL_SIZE as f32 * 0.2), + crate::constants::MapTile::PowerPellet => ( + EntityType::PowerPellet, + 50, + energizer_sprite, + constants::CELL_SIZE as f32 * 0.9, + ), + _ => continue, + }; + + let mut item = world.spawn(ItemBundle { + position: Position::AtNode(node_id), + sprite: Renderable { + sprite, + layer: 1, + visible: true, + }, + entity_type: item_type, + score: Score(score), + collider: Collider { + size, + layer: CollisionLayer::ITEM, + }, + item_collider: ItemCollider, + }); + + if item_type == EntityType::PowerPellet { + item.insert(Blinking { + timer: 0.0, + interval: 0.2, + }); + } + } + Ok(Game { world, schedule }) } @@ -213,7 +274,6 @@ impl Game { // match event { // GameEvent::Command(command) => self.handle_command(command), // } - // } // } // /// Resets the game state, randomizing ghost positions and resetting Pac-Man diff --git a/src/map/builder.rs b/src/map/builder.rs index ca83767..4d39fa4 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -33,6 +33,8 @@ pub struct Map { pub grid_to_node: HashMap, /// A mapping of the starting positions of the entities. pub start_positions: NodePositions, + /// The raw tile data for the map. + tiles: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize], } impl Map { @@ -153,6 +155,14 @@ impl Map { graph, grid_to_node, start_positions, + tiles: map, + }) + } + + pub fn iter_nodes(&self) -> impl Iterator { + self.grid_to_node.iter().map(move |(pos, node_id)| { + let tile = &self.tiles[pos.x as usize][pos.y as usize]; + (node_id, tile) }) } diff --git a/src/systems/blinking.rs b/src/systems/blinking.rs new file mode 100644 index 0000000..d4af5f3 --- /dev/null +++ b/src/systems/blinking.rs @@ -0,0 +1,27 @@ +use bevy_ecs::{ + component::Component, + system::{Query, Res}, +}; + +use crate::systems::components::{DeltaTime, Renderable}; + +#[derive(Component)] +pub struct Blinking { + pub timer: f32, + pub interval: f32, +} + +/// Updates blinking entities by toggling their visibility at regular intervals. +/// +/// This system manages entities that have both `Blinking` and `Renderable` components, +/// accumulating time and toggling visibility when the specified interval is reached. +pub fn blinking_system(time: Res, mut query: Query<(&mut Blinking, &mut Renderable)>) { + for (mut blinking, mut renderable) in query.iter_mut() { + blinking.timer += time.0; + + if blinking.timer >= blinking.interval { + blinking.timer = 0.0; + renderable.visible = !renderable.visible; + } + } +} diff --git a/src/systems/collision.rs b/src/systems/collision.rs new file mode 100644 index 0000000..c5f7334 --- /dev/null +++ b/src/systems/collision.rs @@ -0,0 +1,47 @@ +use bevy_ecs::entity::Entity; +use bevy_ecs::event::EventWriter; +use bevy_ecs::query::With; +use bevy_ecs::system::{Query, Res}; + +use crate::error::GameError; +use crate::events::GameEvent; +use crate::map::builder::Map; +use crate::systems::components::{Collider, ItemCollider, PacmanCollider, Position}; + +pub fn collision_system( + map: Res, + pacman_query: Query<(Entity, &Position, &Collider), With>, + item_query: Query<(Entity, &Position, &Collider), With>, + mut events: EventWriter, + mut errors: EventWriter, +) { + // Check PACMAN × ITEM collisions + for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() { + for (item_entity, item_pos, item_collider) in item_query.iter() { + match (pacman_pos.get_pixel_pos(&map.graph), item_pos.get_pixel_pos(&map.graph)) { + (Ok(pacman_pixel), Ok(item_pixel)) => { + // Calculate the distance between the two entities's precise pixel positions + let distance = pacman_pixel.distance(item_pixel); + // Calculate the distance at which the two entities will collide + let collision_distance = (pacman_collider.size + item_collider.size) / 2.0; + + // If the distance between the two entities is less than the collision distance, then the two entities are colliding + if distance < collision_distance { + events.write(GameEvent::Collision(pacman_entity, item_entity)); + } + } + // Either or both of the pixel positions failed to get, so we need to report the error + (result_a, result_b) => { + for result in [result_a, result_b] { + if let Err(e) = result { + errors.write(GameError::InvalidState(format!( + "Collision system failed to get pixel positions for entities {:?} and {:?}: {}", + pacman_entity, item_entity, e + ))); + } + } + } + } + } + } +} diff --git a/src/systems/components.rs b/src/systems/components.rs index e58e3ae..75635c4 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -1,4 +1,5 @@ use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; +use bitflags::bitflags; use glam::Vec2; use crate::{ @@ -28,6 +29,7 @@ pub enum EntityType { pub struct Renderable { pub sprite: AtlasTile, pub layer: u8, + pub visible: bool, } /// A component for entities that have a directional animated texture. @@ -62,6 +64,10 @@ impl Position { /// /// Converts the graph position to screen coordinates, accounting for /// the board offset and centering the sprite. + /// + /// # Errors + /// + /// Returns an `EntityError` if the node or edge is not found. pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult { let pos = match self { Position::AtNode(node_id) => { @@ -130,6 +136,34 @@ pub struct Velocity { pub speed: f32, } +bitflags! { + #[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct CollisionLayer: u8 { + const PACMAN = 1 << 0; + const GHOST = 1 << 1; + const ITEM = 1 << 2; + } +} + +#[derive(Component)] +pub struct Collider { + pub size: f32, + pub layer: CollisionLayer, +} + +/// Marker components for collision filtering optimization +#[derive(Component)] +pub struct PacmanCollider; + +#[derive(Component)] +pub struct GhostCollider; + +#[derive(Component)] +pub struct ItemCollider; + +#[derive(Component)] +pub struct Score(pub u32); + #[derive(Bundle)] pub struct PlayerBundle { pub player: PlayerControlled, @@ -138,6 +172,18 @@ pub struct PlayerBundle { pub sprite: Renderable, pub directional_animated: DirectionalAnimated, pub entity_type: EntityType, + pub collider: Collider, + pub pacman_collider: PacmanCollider, +} + +#[derive(Bundle)] +pub struct ItemBundle { + pub position: Position, + pub sprite: Renderable, + pub entity_type: EntityType, + pub score: Score, + pub collider: Collider, + pub item_collider: ItemCollider, } #[derive(Resource)] @@ -145,5 +191,8 @@ pub struct GlobalState { pub exit: bool, } +#[derive(Resource)] +pub struct ScoreResource(pub u32); + #[derive(Resource)] pub struct DeltaTime(pub f32); diff --git a/src/systems/control.rs b/src/systems/control.rs index 98b224a..fcfb755 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -1,16 +1,14 @@ use bevy_ecs::{ event::{EventReader, EventWriter}, + prelude::ResMut, query::With, - system::{Query, ResMut}, + system::Query, }; use crate::{ error::GameError, - events::GameEvent, - systems::{ - components::{GlobalState, PlayerControlled, Velocity}, - input::GameCommand, - }, + events::{GameCommand, GameEvent}, + systems::components::{GlobalState, PlayerControlled, Velocity}, }; // Handles @@ -41,6 +39,9 @@ pub fn player_system( } _ => {} }, + GameEvent::Collision(a, b) => { + tracing::info!("Collision between {:?} and {:?}", a, b); + } } } } diff --git a/src/systems/input.rs b/src/systems/input.rs index 44f4df4..1e74558 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -1,23 +1,12 @@ use std::collections::HashMap; -use bevy_ecs::{ - event::EventWriter, - resource::Resource, - system::{NonSendMut, Res}, -}; +use bevy_ecs::{event::EventWriter, prelude::Res, resource::Resource, system::NonSendMut}; use sdl2::{event::Event, keyboard::Keycode, EventPump}; -use crate::{entity::direction::Direction, events::GameEvent}; - -#[derive(Debug, Clone, Copy)] -pub enum GameCommand { - MovePlayer(Direction), - Exit, - TogglePause, - ToggleDebug, - MuteAudio, - ResetLevel, -} +use crate::{ + entity::direction::Direction, + events::{GameCommand, GameEvent}, +}; #[derive(Debug, Clone, Resource)] pub struct Bindings { diff --git a/src/systems/mod.rs b/src/systems/mod.rs index c984ae3..86a6b08 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -3,6 +3,8 @@ //! This module contains all the ECS-related logic, including components, systems, //! and resources. +pub mod blinking; +pub mod collision; pub mod components; pub mod control; pub mod input; diff --git a/src/systems/movement.rs b/src/systems/movement.rs index fc8cffd..0f5d3e2 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -1,4 +1,4 @@ -use crate::entity::graph::{Edge, EdgePermissions, Graph}; +use crate::entity::graph::{Edge, EdgePermissions}; use crate::error::{EntityError, GameError}; use crate::map::builder::Map; use crate::systems::components::{DeltaTime, EntityType, Position, Velocity}; From bd811ee783b9b127cf494a6c557f0d1ab8b9cba2 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 12:30:29 -0500 Subject: [PATCH 12/44] fix: initial next direction for pacman (mitigation) --- src/game/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index 8e2867e..bff78bd 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -135,9 +135,9 @@ impl Game { player: PlayerControlled, position: Position::AtNode(pacman_start_node), velocity: Velocity { - direction: Direction::Up, - next_direction: None, - speed: 1.125, + direction: Direction::Left, + next_direction: Some((Direction::Left, 90)), + speed: 1.15, }, sprite: Renderable { sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") From de86f383bfbd4097c1393ee38c7b85f0b2622334 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 12:50:07 -0500 Subject: [PATCH 13/44] refactor: improve representation of movement system --- src/entity/graph.rs | 30 +++--- src/game/mod.rs | 25 +++-- src/map/builder.rs | 45 +------- src/systems/components.rs | 133 +++++++++++++---------- src/systems/control.rs | 14 +-- src/systems/movement.rs | 217 +++++++++++++++++++++----------------- src/systems/render.rs | 14 +-- tests/graph.rs | 12 +-- 8 files changed, 259 insertions(+), 231 deletions(-) diff --git a/src/entity/graph.rs b/src/entity/graph.rs index 0bf3021..e99ed67 100644 --- a/src/entity/graph.rs +++ b/src/entity/graph.rs @@ -4,14 +4,18 @@ use crate::systems::components::NodeId; use super::direction::Direction; -/// Defines who can traverse a given edge. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum EdgePermissions { - /// Anyone can use this edge. - #[default] - All, - /// Only ghosts can use this edge. - GhostsOnly, +use bitflags::bitflags; + +bitflags! { + /// Defines who can traverse a given edge using flags for fast checking. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] + pub struct TraversalFlags: u8 { + const PACMAN = 1 << 0; + const GHOST = 1 << 1; + + /// Convenience flag for edges that all entities can use + const ALL = Self::PACMAN.bits() | Self::GHOST.bits(); + } } /// Represents a directed edge from one node to another with a given weight (e.g., distance). @@ -24,7 +28,7 @@ pub struct Edge { /// The cardinal direction of this edge. pub direction: Direction, /// Defines who is allowed to traverse this edge. - pub permissions: EdgePermissions, + pub traversal_flags: TraversalFlags, } /// Represents a node in the graph, defined by its position. @@ -132,8 +136,8 @@ impl Graph { return Err("To node does not exist."); } - let edge_a = self.add_edge(from, to, replace, distance, direction, EdgePermissions::default()); - let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), EdgePermissions::default()); + let edge_a = self.add_edge(from, to, replace, distance, direction, TraversalFlags::ALL); + let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), TraversalFlags::ALL); if edge_a.is_err() && edge_b.is_err() { return Err("Failed to connect nodes in both directions."); @@ -161,7 +165,7 @@ impl Graph { replace: bool, distance: Option, direction: Direction, - permissions: EdgePermissions, + traversal_flags: TraversalFlags, ) -> Result<(), &'static str> { let edge = Edge { target: to, @@ -180,7 +184,7 @@ impl Graph { } }, direction, - permissions, + traversal_flags, }; if from >= self.adjacency_list.len() { diff --git a/src/game/mod.rs b/src/game/mod.rs index bff78bd..25d7ec6 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -12,8 +12,8 @@ use crate::systems::{ blinking::blinking_system, collision::collision_system, components::{ - Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, - PacmanCollider, PlayerBundle, PlayerControlled, Position, Renderable, Score, ScoreResource, Velocity, + Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, Movable, + MovementState, PacmanCollider, PlayerBundle, PlayerControlled, Position, Renderable, Score, ScoreResource, }, control::player_system, input::input_system, @@ -133,11 +133,15 @@ impl Game { let player = PlayerBundle { player: PlayerControlled, - position: Position::AtNode(pacman_start_node), - velocity: Velocity { - direction: Direction::Left, - next_direction: Some((Direction::Left, 90)), + position: Position { + node: pacman_start_node, + edge_progress: None, + }, + movement_state: MovementState::Stopped, + movable: Movable { speed: 1.15, + current_direction: Direction::Left, + requested_direction: Some(Direction::Left), // Start moving left immediately }, sprite: Renderable { sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") @@ -170,14 +174,14 @@ impl Game { world.insert_resource(DeltaTime(0f32)); world.add_observer( - |event: Trigger, mut state: ResMut, mut score: ResMut| match *event { + |event: Trigger, mut state: ResMut, _score: ResMut| match *event { GameEvent::Command(command) => match command { GameCommand::Exit => { state.exit = true; } _ => {} }, - GameEvent::Collision(a, b) => {} + GameEvent::Collision(_a, _b) => {} }, ); @@ -218,7 +222,10 @@ impl Game { }; let mut item = world.spawn(ItemBundle { - position: Position::AtNode(node_id), + position: Position { + node: node_id, + edge_progress: None, + }, sprite: Renderable { sprite, layer: 1, diff --git a/src/map/builder.rs b/src/map/builder.rs index 4d39fa4..880d244 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -1,8 +1,7 @@ //! Map construction and building functionality. - use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::entity::direction::Direction; -use crate::entity::graph::{EdgePermissions, Graph, Node}; +use crate::entity::graph::{Graph, Node, TraversalFlags}; use crate::map::parser::MapTileParser; use crate::map::render::MapRenderer; use crate::systems::components::NodeId; @@ -166,44 +165,6 @@ impl Map { }) } - /// Generates Item entities for pellets and energizers from the parsed map. - // pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult> { - // // Pre-load sprites to avoid repeated texture lookups - // let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png") - // .ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?; - // let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png") - // .ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?; - - // // Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers) - // let mut items = Vec::with_capacity(250); - - // // Parse the raw board once - // let parsed_map = MapTileParser::parse_board(RAW_BOARD)?; - // let map = parsed_map.tiles; - - // // Iterate through the map and collect items more efficiently - // for (x, row) in map.iter().enumerate() { - // for (y, tile) in row.iter().enumerate() { - // match tile { - // MapTile::Pellet | MapTile::PowerPellet => { - // let grid_pos = IVec2::new(x as i32, y as i32); - // if let Some(&node_id) = self.grid_to_node.get(&grid_pos) { - // let (item_type, sprite) = match tile { - // MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)), - // MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)), - // _ => unreachable!(), // We already filtered for these types - // }; - // items.push(Item::new(node_id, item_type, sprite)); - // } - // } - // _ => {} - // } - // } - // } - - // Ok(items) - // } - /// Renders a debug visualization with cursor-based highlighting. /// /// This function provides interactive debugging by highlighting the nearest node @@ -304,7 +265,7 @@ impl Map { false, None, Direction::Down, - EdgePermissions::GhostsOnly, + TraversalFlags::GHOST, ) .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only entrance to house: {e}")))?; @@ -315,7 +276,7 @@ impl Map { false, None, Direction::Up, - EdgePermissions::GhostsOnly, + TraversalFlags::GHOST, ) .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only exit from house: {e}")))?; diff --git a/src/systems/components.rs b/src/systems/components.rs index 75635c4..e114d36 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -3,7 +3,10 @@ use bitflags::bitflags; use glam::Vec2; use crate::{ - entity::{direction::Direction, graph::Graph}, + entity::{ + direction::Direction, + graph::{Graph, TraversalFlags}, + }, error::{EntityError, GameResult}, texture::{animated::AnimatedTexture, sprite::AtlasTile}, }; @@ -22,6 +25,17 @@ pub enum EntityType { Wall, } +impl EntityType { + /// Returns the traversal flags for this entity type. + pub fn traversal_flags(&self) -> TraversalFlags { + match self { + EntityType::Player => TraversalFlags::PACMAN, + EntityType::Ghost => TraversalFlags::GHOST, + _ => TraversalFlags::empty(), // Static entities don't traverse + } + } +} + /// A component for entities that have a sprite, with a layer for ordering. /// /// This is intended to be modified by other entities allowing animation. @@ -42,21 +56,36 @@ pub struct DirectionalAnimated { /// 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. +/// Progress along an edge between two nodes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EdgeProgress { + pub target_node: NodeId, + /// Progress from 0.0 (at source node) to 1.0 (at target node) + pub progress: f32, +} + +/// Pure spatial position component - works for both static and dynamic entities. #[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, - }, +pub struct Position { + /// The current/primary node this entity is at or traveling from + pub node: NodeId, + /// If Some, entity is traveling between nodes. If None, entity is stationary at node. + pub edge_progress: Option, +} + +/// Explicit movement state - only for entities that can move. +#[derive(Component, Debug, Clone, Copy, PartialEq)] +pub enum MovementState { + Stopped, + Moving { direction: Direction }, +} + +/// Movement capability and parameters - only for entities that can move. +#[derive(Component, Debug, Clone, Copy)] +pub struct Movable { + pub speed: f32, + pub current_direction: Direction, + pub requested_direction: Option, } impl Position { @@ -69,18 +98,26 @@ impl Position { /// /// Returns an `EntityError` if the node or edge is not found. pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult { - let pos = match self { - Position::AtNode(node_id) => { - let node = graph.get_node(*node_id).ok_or(EntityError::NodeNotFound(*node_id))?; + let pos = match &self.edge_progress { + None => { + // Entity is stationary at a node + let node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; 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) + Some(edge_progress) => { + // Entity is traveling between nodes + let from_node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; + let to_node = graph + .get_node(edge_progress.target_node) + .ok_or(EntityError::NodeNotFound(edge_progress.target_node))?; + + // For zero-distance edges (tunnels), progress >= 1.0 means we're at the target + if edge_progress.progress >= 1.0 { + to_node.position + } else { + // Interpolate position based on progress + from_node.position + (to_node.position - from_node.position) * edge_progress.progress + } } }; @@ -93,49 +130,36 @@ impl Position { impl Default for Position { fn default() -> Self { - Position::AtNode(0) + Position { + node: 0, + edge_progress: None, + } } } #[allow(dead_code)] impl Position { - /// Returns `true` if the position is exactly at a node. + /// Returns `true` if the position is exactly at a node (not traveling). pub fn is_at_node(&self) -> bool { - matches!(self, Position::AtNode(_)) + self.edge_progress.is_none() } - /// 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 current node (source of travel if moving). + pub fn current_node(&self) -> NodeId { + self.node } - /// Returns the `NodeId` of the destination node, if currently on an edge. - #[allow(clippy::wrong_self_convention)] - pub fn to_node_id(&self) -> Option { - match self { - Position::AtNode(_) => None, - Position::BetweenNodes { to, .. } => Some(*to), - } + /// Returns the `NodeId` of the destination node, if currently traveling. + pub fn target_node(&self) -> Option { + self.edge_progress.as_ref().map(|ep| ep.target_node) } - /// Returns `true` if the traverser is stopped at a node. - pub fn is_stopped(&self) -> bool { - matches!(self, Position::AtNode(_)) + /// Returns `true` if the entity is traveling between nodes. + pub fn is_moving(&self) -> bool { + self.edge_progress.is_some() } } -/// 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, -} - bitflags! { #[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CollisionLayer: u8 { @@ -168,7 +192,8 @@ pub struct Score(pub u32); pub struct PlayerBundle { pub player: PlayerControlled, pub position: Position, - pub velocity: Velocity, + pub movement_state: MovementState, + pub movable: Movable, pub sprite: Renderable, pub directional_animated: DirectionalAnimated, pub entity_type: EntityType, diff --git a/src/systems/control.rs b/src/systems/control.rs index fcfb755..765d947 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -8,19 +8,19 @@ use bevy_ecs::{ use crate::{ error::GameError, events::{GameCommand, GameEvent}, - systems::components::{GlobalState, PlayerControlled, Velocity}, + systems::components::{GlobalState, Movable, PlayerControlled}, }; -// Handles +// Handles player input and control pub fn player_system( mut events: EventReader, mut state: ResMut, - mut players: Query<&mut Velocity, With>, + mut players: Query<&mut Movable, With>, mut errors: EventWriter, ) { - // Get the player's velocity (handling to ensure there is only one player) - let mut velocity = match players.single_mut() { - Ok(velocity) => velocity, + // Get the player's movable component (ensuring there is only one player) + let mut movable = match players.single_mut() { + Ok(movable) => movable, Err(e) => { errors.write(GameError::InvalidState(format!("No/multiple entities queried for player system: {}", e)).into()); return; @@ -32,7 +32,7 @@ pub fn player_system( match event { GameEvent::Command(command) => match command { GameCommand::MovePlayer(direction) => { - velocity.next_direction = Some((*direction, 90)); + movable.requested_direction = Some(*direction); } GameCommand::Exit => { state.exit = true; diff --git a/src/systems/movement.rs b/src/systems/movement.rs index 0f5d3e2..87581f1 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -1,131 +1,160 @@ -use crate::entity::graph::{Edge, EdgePermissions}; +use crate::entity::graph::Edge; use crate::error::{EntityError, GameError}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, EntityType, Position, Velocity}; +use crate::systems::components::{DeltaTime, EdgeProgress, EntityType, Movable, MovementState, Position}; use bevy_ecs::event::EventWriter; use bevy_ecs::system::{Query, Res}; fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { - match entity_type { - EntityType::Player => matches!(edge.permissions, EdgePermissions::All), - EntityType::Ghost => matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly), - _ => matches!(edge.permissions, EdgePermissions::All), - } + let entity_flags = entity_type.traversal_flags(); + edge.traversal_flags.contains(entity_flags) } pub fn movement_system( map: Res, delta_time: Res, - mut entities: Query<(&mut Velocity, &mut Position, &EntityType)>, + mut entities: Query<(&mut MovementState, &mut Movable, &mut Position, &EntityType)>, mut errors: EventWriter, ) { - for (mut velocity, mut position, entity_type) in entities.iter_mut() { - let distance = velocity.speed * 60.0 * delta_time.0; + for (mut movement_state, mut movable, mut position, entity_type) in entities.iter_mut() { + let distance = movable.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) { + match *movement_state { + MovementState::Stopped => { + // Check if we have a requested direction to start moving + if let Some(requested_direction) = movable.requested_direction { + if let Some(edge) = map.graph.find_edge_in_direction(position.node, requested_direction) { if can_traverse(*entity_type, edge) { - // Start moving in that direction - *position = Position::BetweenNodes { - from: node_id, - to: edge.target, - traversed: distance, + // Start moving in the requested direction + let progress = if edge.distance > 0.0 { + distance / edge.distance + } else { + // Zero-distance edge (tunnels) - immediately teleport + tracing::debug!("Entity entering tunnel from node {} to node {}", position.node, edge.target); + 1.0 + }; + + position.edge_progress = Some(EdgeProgress { + target_node: edge.target, + progress, + }); + movable.current_direction = requested_direction; + movable.requested_direction = None; + *movement_state = MovementState::Moving { + direction: requested_direction, }; - 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 + requested_direction, position.node )) .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; - } + MovementState::Moving { direction } => { + // Continue moving or handle node transitions + let current_node = position.node; + if let Some(edge_progress) = &mut position.edge_progress { + // Extract target node before mutable operations + let target_node = edge_progress.target_node; - let edge = map - .graph - .find_edge(from, to) - .ok_or_else(|| { + // Get the current edge for distance calculation + let edge = map.graph.find_edge(current_node, target_node); + + if let Some(edge) = edge { + // Update progress along the edge + if edge.distance > 0.0 { + edge_progress.progress += distance / edge.distance; + } else { + // Zero-distance edge (tunnels) - immediately complete + edge_progress.progress = 1.0; + } + + if edge_progress.progress >= 1.0 { + // Reached the target node + let overflow = if edge.distance > 0.0 { + (edge_progress.progress - 1.0) * edge.distance + } else { + // Zero-distance edge - use remaining distance for overflow + distance + }; + position.node = target_node; + position.edge_progress = None; + + let mut continued_moving = false; + + // Try to use requested direction first + if let Some(requested_direction) = movable.requested_direction { + if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, requested_direction) { + if can_traverse(*entity_type, next_edge) { + let next_progress = if next_edge.distance > 0.0 { + overflow / next_edge.distance + } else { + // Zero-distance edge - immediately complete + 1.0 + }; + + position.edge_progress = Some(EdgeProgress { + target_node: next_edge.target, + progress: next_progress, + }); + movable.current_direction = requested_direction; + movable.requested_direction = None; + *movement_state = MovementState::Moving { + direction: requested_direction, + }; + continued_moving = true; + } + } + } + + // If no requested direction or it failed, try to continue in current direction + if !continued_moving { + if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) { + if can_traverse(*entity_type, next_edge) { + let next_progress = if next_edge.distance > 0.0 { + overflow / next_edge.distance + } else { + // Zero-distance edge - immediately complete + 1.0 + }; + + position.edge_progress = Some(EdgeProgress { + target_node: next_edge.target, + progress: next_progress, + }); + // Keep current direction and movement state + continued_moving = true; + } + } + } + + // If we couldn't continue moving, stop + if !continued_moving { + *movement_state = MovementState::Stopped; + movable.requested_direction = None; + } + } + } else { + // Edge not found - this is an inconsistent state errors.write( EntityError::InvalidMovement(format!( - "Inconsistent state: Traverser is on a non-existent edge from {} to {}.", - from, to + "Inconsistent state: Moving on non-existent edge from {} to {}", + current_node, target_node )) .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, - }; + *movement_state = MovementState::Stopped; + position.edge_progress = None; + } } 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(*entity_type, 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(*entity_type, 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; - } - } + // Movement state says moving but no edge progress - this shouldn't happen + errors.write(EntityError::InvalidMovement("Entity in Moving state but no edge progress".to_string()).into()); + *movement_state = MovementState::Stopped; } } } diff --git a/src/systems/render.rs b/src/systems/render.rs index 27b9727..8b02ebf 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -1,6 +1,6 @@ use crate::error::{GameError, TextureError}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity}; +use crate::systems::components::{DeltaTime, DirectionalAnimated, Movable, MovementState, Position, Renderable}; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; @@ -13,15 +13,17 @@ use sdl2::video::Window; /// This runs before the render system so it can update the sprite based on the current direction of travel, as well as whether the entity is moving. pub fn directional_render_system( dt: Res, - mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable, &Position)>, + mut renderables: Query<(&MovementState, &Movable, &mut DirectionalAnimated, &mut Renderable)>, mut errors: EventWriter, ) { - for (velocity, mut texture, mut renderable, position) in renderables.iter_mut() { - let stopped = matches!(position, Position::AtNode(_)); + for (movement_state, movable, mut texture, mut renderable) in renderables.iter_mut() { + let stopped = matches!(movement_state, MovementState::Stopped); + let current_direction = movable.current_direction; + let texture = if stopped { - texture.stopped_textures[velocity.direction.as_usize()].as_mut() + texture.stopped_textures[current_direction.as_usize()].as_mut() } else { - texture.textures[velocity.direction.as_usize()].as_mut() + texture.textures[current_direction.as_usize()].as_mut() }; if let Some(texture) = texture { diff --git a/tests/graph.rs b/tests/graph.rs index b9d4477..bb888c3 100644 --- a/tests/graph.rs +++ b/tests/graph.rs @@ -1,5 +1,5 @@ use pacman::entity::direction::Direction; -use pacman::entity::graph::{EdgePermissions, Graph, Node}; +use pacman::entity::graph::{Graph, Node, TraversalFlags}; fn create_test_graph() -> Graph { let mut graph = Graph::new(); @@ -78,11 +78,11 @@ fn test_graph_edge_permissions() { }); graph - .add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly) + .add_edge(node1, node2, false, None, Direction::Right, TraversalFlags::GHOST) .unwrap(); let edge = graph.find_edge_in_direction(node1, Direction::Right).unwrap(); - assert_eq!(edge.permissions, EdgePermissions::GhostsOnly); + assert_eq!(edge.traversal_flags, TraversalFlags::GHOST); } #[test] @@ -118,21 +118,21 @@ fn should_error_on_negative_edge_distance() { position: glam::Vec2::new(16.0, 0.0), }); - let result = graph.add_edge(node1, node2, false, Some(-1.0), Direction::Right, EdgePermissions::All); + let result = graph.add_edge(node1, node2, false, Some(-1.0), Direction::Right, TraversalFlags::ALL); assert!(result.is_err()); } #[test] fn should_error_on_duplicate_edge_without_replace() { let mut graph = create_test_graph(); - let result = graph.add_edge(0, 1, false, None, Direction::Right, EdgePermissions::All); + let result = graph.add_edge(0, 1, false, None, Direction::Right, TraversalFlags::ALL); assert!(result.is_err()); } #[test] fn should_allow_replacing_an_edge() { let mut graph = create_test_graph(); - let result = graph.add_edge(0, 1, true, Some(42.0), Direction::Right, EdgePermissions::All); + let result = graph.add_edge(0, 1, true, Some(42.0), Direction::Right, TraversalFlags::ALL); assert!(result.is_ok()); let edge = graph.find_edge(0, 1).unwrap(); From a531228b958f086e4848fc9d13ba2b8be1a04deb Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 13:04:39 -0500 Subject: [PATCH 14/44] chore: update thiserror & phf crates --- Cargo.lock | 46 ++++++++++++++++------------------------------ Cargo.toml | 6 +++--- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f55d76a..34c0916 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,7 +578,7 @@ dependencies = [ "once_cell", "pathfinding", "phf", - "rand 0.9.2", + "rand", "sdl2", "serde", "serde_json", @@ -638,29 +638,30 @@ dependencies = [ [[package]] name = "phf" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ "phf_macros", "phf_shared", + "serde", ] [[package]] name = "phf_generator" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" dependencies = [ + "fastrand", "phf_shared", - "rand 0.8.5", ] [[package]] name = "phf_macros" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" dependencies = [ "phf_generator", "phf_shared", @@ -671,9 +672,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" dependencies = [ "siphasher", ] @@ -723,30 +724,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_core 0.9.3", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.9.3" @@ -987,18 +973,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index c977d7e..ba45bf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ spin_sleep = "1.3.2" rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] } pathfinding = "4.14" once_cell = "1.21.3" -thiserror = "2.0" +thiserror = "2.0.14" anyhow = "1.0" glam = { version = "0.30.5", features = [] } serde = { version = "1.0.219", features = ["derive"] } @@ -23,7 +23,7 @@ serde_json = "1.0.142" smallvec = "1.15.1" strum = "0.27.2" strum_macros = "0.27.2" -phf = { version = "0.11", features = ["macros"] } +phf = { version = "0.12.1", features = ["macros"] } bevy_ecs = "0.16.1" bitflags = "2.9.1" @@ -64,4 +64,4 @@ libc = "0.2.175" [build-dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -phf = { version = "0.11", features = ["macros"] } +phf = { version = "0.12.1", features = ["macros"] } From 7f95c0233ef78e82632177b591508a50601ca38a Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 13:05:03 -0500 Subject: [PATCH 15/44] refactor: move position/movement related components into systems/movement --- src/entity/graph.rs | 2 +- src/game/mod.rs | 5 +- src/map/builder.rs | 2 +- src/systems/collision.rs | 3 +- src/systems/components.rs | 116 +------------------------------------- src/systems/control.rs | 3 +- src/systems/movement.rs | 116 +++++++++++++++++++++++++++++++++++++- src/systems/render.rs | 3 +- 8 files changed, 126 insertions(+), 124 deletions(-) diff --git a/src/entity/graph.rs b/src/entity/graph.rs index e99ed67..c83d98c 100644 --- a/src/entity/graph.rs +++ b/src/entity/graph.rs @@ -1,6 +1,6 @@ use glam::Vec2; -use crate::systems::components::NodeId; +use crate::systems::movement::NodeId; use super::direction::Direction; diff --git a/src/game/mod.rs b/src/game/mod.rs index 25d7ec6..0aed0bb 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -8,12 +8,13 @@ use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::systems::blinking::Blinking; +use crate::systems::movement::{Movable, MovementState, Position}; use crate::systems::{ blinking::blinking_system, collision::collision_system, components::{ - Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, Movable, - MovementState, PacmanCollider, PlayerBundle, PlayerControlled, Position, Renderable, Score, ScoreResource, + Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, + PacmanCollider, PlayerBundle, PlayerControlled, Renderable, Score, ScoreResource, }, control::player_system, input::input_system, diff --git a/src/map/builder.rs b/src/map/builder.rs index 880d244..fe1a9d5 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -4,7 +4,7 @@ use crate::entity::direction::Direction; use crate::entity::graph::{Graph, Node, TraversalFlags}; use crate::map::parser::MapTileParser; use crate::map::render::MapRenderer; -use crate::systems::components::NodeId; +use crate::systems::movement::NodeId; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::resource::Resource; use glam::{IVec2, Vec2}; diff --git a/src/systems/collision.rs b/src/systems/collision.rs index c5f7334..cd291a7 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -6,7 +6,8 @@ use bevy_ecs::system::{Query, Res}; use crate::error::GameError; use crate::events::GameEvent; use crate::map::builder::Map; -use crate::systems::components::{Collider, ItemCollider, PacmanCollider, Position}; +use crate::systems::components::{Collider, ItemCollider, PacmanCollider}; +use crate::systems::movement::Position; pub fn collision_system( map: Res, diff --git a/src/systems/components.rs b/src/systems/components.rs index e114d36..847f033 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -1,13 +1,9 @@ use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; use bitflags::bitflags; -use glam::Vec2; use crate::{ - entity::{ - direction::Direction, - graph::{Graph, TraversalFlags}, - }, - error::{EntityError, GameResult}, + entity::graph::TraversalFlags, + systems::movement::{Movable, MovementState, Position}, texture::{animated::AnimatedTexture, sprite::AtlasTile}, }; @@ -22,7 +18,6 @@ pub enum EntityType { Ghost, Pellet, PowerPellet, - Wall, } impl EntityType { @@ -53,113 +48,6 @@ pub struct DirectionalAnimated { pub stopped_textures: [Option; 4], } -/// A unique identifier for a node, represented by its index in the graph's storage. -pub type NodeId = usize; - -/// Progress along an edge between two nodes. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct EdgeProgress { - pub target_node: NodeId, - /// Progress from 0.0 (at source node) to 1.0 (at target node) - pub progress: f32, -} - -/// Pure spatial position component - works for both static and dynamic entities. -#[derive(Component, Debug, Copy, Clone, PartialEq)] -pub struct Position { - /// The current/primary node this entity is at or traveling from - pub node: NodeId, - /// If Some, entity is traveling between nodes. If None, entity is stationary at node. - pub edge_progress: Option, -} - -/// Explicit movement state - only for entities that can move. -#[derive(Component, Debug, Clone, Copy, PartialEq)] -pub enum MovementState { - Stopped, - Moving { direction: Direction }, -} - -/// Movement capability and parameters - only for entities that can move. -#[derive(Component, Debug, Clone, Copy)] -pub struct Movable { - pub speed: f32, - pub current_direction: Direction, - pub requested_direction: Option, -} - -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. - /// - /// # Errors - /// - /// Returns an `EntityError` if the node or edge is not found. - pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult { - let pos = match &self.edge_progress { - None => { - // Entity is stationary at a node - let node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; - node.position - } - Some(edge_progress) => { - // Entity is traveling between nodes - let from_node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; - let to_node = graph - .get_node(edge_progress.target_node) - .ok_or(EntityError::NodeNotFound(edge_progress.target_node))?; - - // For zero-distance edges (tunnels), progress >= 1.0 means we're at the target - if edge_progress.progress >= 1.0 { - to_node.position - } else { - // Interpolate position based on progress - from_node.position + (to_node.position - from_node.position) * edge_progress.progress - } - } - }; - - 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 { - node: 0, - edge_progress: None, - } - } -} - -#[allow(dead_code)] -impl Position { - /// Returns `true` if the position is exactly at a node (not traveling). - pub fn is_at_node(&self) -> bool { - self.edge_progress.is_none() - } - - /// Returns the `NodeId` of the current node (source of travel if moving). - pub fn current_node(&self) -> NodeId { - self.node - } - - /// Returns the `NodeId` of the destination node, if currently traveling. - pub fn target_node(&self) -> Option { - self.edge_progress.as_ref().map(|ep| ep.target_node) - } - - /// Returns `true` if the entity is traveling between nodes. - pub fn is_moving(&self) -> bool { - self.edge_progress.is_some() - } -} - bitflags! { #[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CollisionLayer: u8 { diff --git a/src/systems/control.rs b/src/systems/control.rs index 765d947..63b41fd 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -8,7 +8,8 @@ use bevy_ecs::{ use crate::{ error::GameError, events::{GameCommand, GameEvent}, - systems::components::{GlobalState, Movable, PlayerControlled}, + systems::components::{GlobalState, PlayerControlled}, + systems::movement::Movable, }; // Handles player input and control diff --git a/src/systems/movement.rs b/src/systems/movement.rs index 87581f1..9abe9e0 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -1,9 +1,119 @@ -use crate::entity::graph::Edge; -use crate::error::{EntityError, GameError}; +use crate::entity::graph::Graph; +use crate::entity::{direction::Direction, graph::Edge}; +use crate::error::{EntityError, GameError, GameResult}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, EdgeProgress, EntityType, Movable, MovementState, Position}; +use crate::systems::components::{DeltaTime, EntityType}; +use bevy_ecs::component::Component; use bevy_ecs::event::EventWriter; use bevy_ecs::system::{Query, Res}; +use glam::Vec2; + +/// A unique identifier for a node, represented by its index in the graph's storage. +pub type NodeId = usize; + +/// Progress along an edge between two nodes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EdgeProgress { + pub target_node: NodeId, + /// Progress from 0.0 (at source node) to 1.0 (at target node) + pub progress: f32, +} + +/// Pure spatial position component - works for both static and dynamic entities. +#[derive(Component, Debug, Copy, Clone, PartialEq)] +pub struct Position { + /// The current/primary node this entity is at or traveling from + pub node: NodeId, + /// If Some, entity is traveling between nodes. If None, entity is stationary at node. + pub edge_progress: Option, +} + +/// Explicit movement state - only for entities that can move. +#[derive(Component, Debug, Clone, Copy, PartialEq)] +pub enum MovementState { + Stopped, + Moving { direction: Direction }, +} + +/// Movement capability and parameters - only for entities that can move. +#[derive(Component, Debug, Clone, Copy)] +pub struct Movable { + pub speed: f32, + pub current_direction: Direction, + pub requested_direction: Option, +} + +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. + /// + /// # Errors + /// + /// Returns an `EntityError` if the node or edge is not found. + pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult { + let pos = match &self.edge_progress { + None => { + // Entity is stationary at a node + let node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; + node.position + } + Some(edge_progress) => { + // Entity is traveling between nodes + let from_node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; + let to_node = graph + .get_node(edge_progress.target_node) + .ok_or(EntityError::NodeNotFound(edge_progress.target_node))?; + + // For zero-distance edges (tunnels), progress >= 1.0 means we're at the target + if edge_progress.progress >= 1.0 { + to_node.position + } else { + // Interpolate position based on progress + from_node.position + (to_node.position - from_node.position) * edge_progress.progress + } + } + }; + + 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 { + node: 0, + edge_progress: None, + } + } +} + +#[allow(dead_code)] +impl Position { + /// Returns `true` if the position is exactly at a node (not traveling). + pub fn is_at_node(&self) -> bool { + self.edge_progress.is_none() + } + + /// Returns the `NodeId` of the current node (source of travel if moving). + pub fn current_node(&self) -> NodeId { + self.node + } + + /// Returns the `NodeId` of the destination node, if currently traveling. + pub fn target_node(&self) -> Option { + self.edge_progress.as_ref().map(|ep| ep.target_node) + } + + /// Returns `true` if the entity is traveling between nodes. + pub fn is_moving(&self) -> bool { + self.edge_progress.is_some() + } +} fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { let entity_flags = entity_type.traversal_flags(); diff --git a/src/systems/render.rs b/src/systems/render.rs index 8b02ebf..c5d8847 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -1,6 +1,7 @@ use crate::error::{GameError, TextureError}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, DirectionalAnimated, Movable, MovementState, Position, Renderable}; +use crate::systems::components::{DeltaTime, DirectionalAnimated, Renderable}; +use crate::systems::movement::{Movable, MovementState, Position}; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; From 02a98c9f32517c344577dc6e89736a78ab71bfd2 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 13:05:52 -0500 Subject: [PATCH 16/44] chore: remove unnecessary log, simplify match to if let --- src/systems/control.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/systems/control.rs b/src/systems/control.rs index 63b41fd..35bfdd2 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -30,8 +30,8 @@ pub fn player_system( // Handle events for event in events.read() { - match event { - GameEvent::Command(command) => match command { + if let GameEvent::Command(command) = event { + match command { GameCommand::MovePlayer(direction) => { movable.requested_direction = Some(*direction); } @@ -39,9 +39,6 @@ pub fn player_system( state.exit = true; } _ => {} - }, - GameEvent::Collision(a, b) => { - tracing::info!("Collision between {:?} and {:?}", a, b); } } } From 8c95ecc54795c4d62320a057c6f6a4f8ed207697 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 13:15:56 -0500 Subject: [PATCH 17/44] feat: add profiling --- Cargo.lock | 1 + Cargo.toml | 5 +++-- src/app.rs | 25 +++++++++++++------------ src/game/mod.rs | 16 +++++++++------- src/systems/mod.rs | 1 + src/systems/profiling.rs | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 21 deletions(-) create mode 100644 src/systems/profiling.rs diff --git a/Cargo.lock b/Cargo.lock index 34c0916..9399b7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,6 +576,7 @@ dependencies = [ "lazy_static", "libc", "once_cell", + "parking_lot", "pathfinding", "phf", "rand", diff --git a/Cargo.toml b/Cargo.toml index ba45bf8..2f5454a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tracing = { version = "0.1.40", features = ["max_level_debug", "release_max_level_debug"]} +tracing = { version = "0.1.41", features = ["max_level_debug", "release_max_level_debug"]} tracing-error = "0.2.0" tracing-subscriber = {version = "0.3.17", features = ["env-filter"]} lazy_static = "1.5.0" @@ -17,7 +17,7 @@ pathfinding = "4.14" once_cell = "1.21.3" thiserror = "2.0.14" anyhow = "1.0" -glam = { version = "0.30.5", features = [] } +glam = "0.30.5" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" smallvec = "1.15.1" @@ -26,6 +26,7 @@ strum_macros = "0.27.2" phf = { version = "0.12.1", features = ["macros"] } bevy_ecs = "0.16.1" bitflags = "2.9.1" +parking_lot = "0.12.3" [profile.release] lto = true diff --git a/src/app.rs b/src/app.rs index b81bd1e..3fb6021 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,13 +5,14 @@ use sdl2::render::TextureCreator; use sdl2::ttf::Sdl2TtfContext; use sdl2::video::WindowContext; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; -use tracing::warn; +use tracing::{field, info, warn}; use crate::error::{GameError, GameResult}; use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; use crate::game::Game; use crate::platform::get_platform; +use crate::systems::profiling::SystemTimings; pub struct App { pub game: Game, @@ -64,12 +65,6 @@ impl App { let game = Game::new(canvas, texture_creator, event_pump)?; // game.audio.set_mute(cfg!(debug_assertions)); - // Initial draw - // game.draw(&mut canvas, &mut backbuffer) - // .map_err(|e| GameError::Sdl(e.to_string()))?; - // game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO) - // .map_err(|e| GameError::Sdl(e.to_string()))?; - Ok(App { game, focused: true, @@ -116,17 +111,23 @@ impl App { return false; } - // if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) { - // error!("Failed to draw game: {}", e); - // } + // Show timings if the loop took more than 25% of the loop time + let show_timings = start.elapsed() > (LOOP_TIME / 4); + if show_timings || true { + if let Some(timings) = self.game.world.get_resource::() { + let mut timings = timings.timings.lock(); + let total = timings.values().sum::(); + info!("Total: {:?}, Timings: {:?}", total, field::debug(&timings)); + timings.clear(); + } + } + // Sleep if we still have time left if start.elapsed() < LOOP_TIME { let time = LOOP_TIME.saturating_sub(start.elapsed()); if time != Duration::ZERO { get_platform().sleep(time, self.focused); } - } else { - warn!("Game loop behind schedule by: {:?}", start.elapsed() - LOOP_TIME); } true diff --git a/src/game/mod.rs b/src/game/mod.rs index 0aed0bb..9a29d3a 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -19,6 +19,7 @@ use crate::systems::{ control::player_system, input::input_system, movement::movement_system, + profiling::{profile, SystemTimings}, render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}, }; use crate::texture::animated::AnimatedTexture; @@ -171,6 +172,7 @@ impl Game { world.insert_resource(map); world.insert_resource(GlobalState { exit: false }); world.insert_resource(ScoreResource(0)); + world.insert_resource(SystemTimings::default()); world.insert_resource(Bindings::default()); world.insert_resource(DeltaTime(0f32)); @@ -188,13 +190,13 @@ impl Game { schedule.add_systems( ( - input_system, - player_system, - movement_system, - collision_system, - blinking_system, - directional_render_system, - render_system, + profile("input", input_system), + profile("player", player_system), + profile("movement", movement_system), + profile("collision", collision_system), + profile("blinking", blinking_system), + profile("directional_render", directional_render_system), + profile("render", render_system), ) .chain(), ); diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 86a6b08..abdbe9a 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -9,4 +9,5 @@ pub mod components; pub mod control; pub mod input; pub mod movement; +pub mod profiling; pub mod render; diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs new file mode 100644 index 0000000..0f3995d --- /dev/null +++ b/src/systems/profiling.rs @@ -0,0 +1,32 @@ +use bevy_ecs::prelude::Resource; +use bevy_ecs::system::{IntoSystem, System}; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::time::Duration; + +#[derive(Resource, Default, Debug)] +pub struct SystemTimings { + pub timings: Mutex>, +} + +pub fn profile(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World) +where + S: IntoSystem<(), (), M> + 'static, +{ + let mut system: S::System = IntoSystem::into_system(system); + let mut is_initialized = false; + move |world: &mut bevy_ecs::world::World| { + if !is_initialized { + system.initialize(world); + is_initialized = true; + } + + let start = std::time::Instant::now(); + system.run((), world); + let duration = start.elapsed(); + + if let Some(mut timings) = world.get_resource_mut::() { + timings.timings.lock().insert(name, duration); + } + } +} From e96b3159d728157e4aeb88ba1607c004126f4014 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 13:46:57 -0500 Subject: [PATCH 18/44] fix: disable vsync --- src/app.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 3fb6021..227b0c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -51,7 +51,6 @@ impl App { window .into_canvas() .accelerated() - .present_vsync() .build() .map_err(|e| GameError::Sdl(e.to_string()))?, )); From 2f0c734d1395b0481d4be2886dbff2445da0ddab Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 14:15:18 -0500 Subject: [PATCH 19/44] feat: only present/render canvas when renderables change --- src/game/mod.rs | 4 ++-- src/map/render.rs | 2 +- src/systems/render.rs | 26 +++++++++++++++----------- src/texture/sprite.rs | 6 +++--- src/texture/text.rs | 6 +++--- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index 9a29d3a..a8d992d 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -100,7 +100,7 @@ impl Game { // Render map to texture canvas .with_texture_canvas(&mut map_texture, |map_canvas| { - MapRenderer::render_map(map_canvas, &mut atlas, &mut map_tiles); + MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles); }) .map_err(|e| GameError::Sdl(e.to_string()))?; @@ -196,10 +196,10 @@ impl Game { profile("collision", collision_system), profile("blinking", blinking_system), profile("directional_render", directional_render_system), - profile("render", render_system), ) .chain(), ); + schedule.add_systems(profile("render", render_system)); // Spawn player world.spawn(player); diff --git a/src/map/render.rs b/src/map/render.rs index aa16f66..cf86766 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -19,7 +19,7 @@ impl MapRenderer { /// /// This function draws the static map texture to the screen at the correct /// position and scale. - pub fn render_map(canvas: &mut Canvas, atlas: &mut SpriteAtlas, map_tiles: &mut [AtlasTile]) { + pub fn render_map(canvas: &mut Canvas, atlas: &mut SpriteAtlas, map_tiles: &[AtlasTile]) { for (y, row) in TILE_MAP.iter().enumerate() { for (x, &tile_index) in row.iter().enumerate() { let mut tile = map_tiles[tile_index]; diff --git a/src/systems/render.rs b/src/systems/render.rs index c5d8847..1e73c66 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -5,6 +5,7 @@ use crate::systems::movement::{Movable, MovementState, Position}; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; +use bevy_ecs::prelude::{Changed, Or, RemovedComponents}; use bevy_ecs::system::{NonSendMut, Query, Res}; use sdl2::render::{Canvas, Texture}; use sdl2::video::Window; @@ -31,7 +32,10 @@ pub fn directional_render_system( if !stopped { texture.tick(dt.0); } - renderable.sprite = *texture.current_tile(); + let new_tile = *texture.current_tile(); + if renderable.sprite != new_tile { + renderable.sprite = new_tile; + } } else { errors.write(TextureError::RenderFailed(format!("Entity has no texture")).into()); continue; @@ -51,9 +55,14 @@ pub fn render_system( mut backbuffer: NonSendMut, mut atlas: NonSendMut, map: Res, - renderables: Query<(Entity, &mut Renderable, &Position)>, + renderables: Query<(Entity, &Renderable, &Position)>, + changed_renderables: Query<(), Or<(Changed, Changed)>>, + removed_renderables: RemovedComponents, mut errors: EventWriter, ) { + if changed_renderables.is_empty() && removed_renderables.is_empty() { + return; + } // Clear the main canvas first canvas.set_draw_color(sdl2::pixels::Color::BLACK); canvas.clear(); @@ -66,17 +75,12 @@ pub fn render_system( 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())); + if let Err(e) = backbuffer_canvas.copy(&map_texture.0, None, None) { + errors.write(TextureError::RenderFailed(e.to_string()).into()); + } // Render all entities to the backbuffer - for (_, mut renderable, position) in renderables - // .iter_mut() - // .sort_by_key::<&mut Renderable, _, _>(|(renderable, renderable, renderable)| renderable.layer) - // .collect() - { + for (_, renderable, position) in renderables.iter() { let pos = position.get_pixel_pos(&map.graph); match pos { Ok(pos) => { diff --git a/src/texture/sprite.rs b/src/texture/sprite.rs index 6e7111b..d3ec876 100644 --- a/src/texture/sprite.rs +++ b/src/texture/sprite.rs @@ -21,7 +21,7 @@ pub struct MapperFrame { pub height: u16, } -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq)] pub struct AtlasTile { pub pos: U16Vec2, pub size: U16Vec2, @@ -30,7 +30,7 @@ pub struct AtlasTile { impl AtlasTile { pub fn render( - &mut self, + &self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, dest: Rect, @@ -41,7 +41,7 @@ impl AtlasTile { } pub fn render_with_color( - &mut self, + &self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, dest: Rect, diff --git a/src/texture/text.rs b/src/texture/text.rs index c6059dc..abea508 100644 --- a/src/texture/text.rs +++ b/src/texture/text.rs @@ -103,9 +103,9 @@ impl TextTexture { &self.char_map } - pub fn get_tile(&mut self, c: char, atlas: &mut SpriteAtlas) -> Result> { + pub fn get_tile(&mut self, c: char, atlas: &mut SpriteAtlas) -> Result> { if self.char_map.contains_key(&c) { - return Ok(self.char_map.get_mut(&c)); + return Ok(self.char_map.get(&c)); } if let Some(tile_name) = char_to_tile_name(c) { @@ -113,7 +113,7 @@ impl TextTexture { .get_tile(&tile_name) .ok_or(GameError::Texture(TextureError::AtlasTileNotFound(tile_name)))?; self.char_map.insert(c, tile); - Ok(self.char_map.get_mut(&c)) + Ok(self.char_map.get(&c)) } else { Ok(None) } From b88895e82f21c7b4bd49c188666a51d22780a970 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 14:19:39 -0500 Subject: [PATCH 20/44] feat: separate dirty rendering with flag resource --- src/game/mod.rs | 7 ++++--- src/systems/components.rs | 3 +++ src/systems/render.rs | 21 ++++++++++++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index a8d992d..ac1d83d 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -14,13 +14,13 @@ use crate::systems::{ collision::collision_system, components::{ Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, - PacmanCollider, PlayerBundle, PlayerControlled, Renderable, Score, ScoreResource, + PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, Score, ScoreResource, }, control::player_system, input::input_system, movement::movement_system, profiling::{profile, SystemTimings}, - render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}, + render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource}, }; use crate::texture::animated::AnimatedTexture; use bevy_ecs::schedule::IntoScheduleConfigs; @@ -175,6 +175,7 @@ impl Game { world.insert_resource(SystemTimings::default()); world.insert_resource(Bindings::default()); world.insert_resource(DeltaTime(0f32)); + world.insert_resource(RenderDirty::default()); world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| match *event { @@ -199,7 +200,7 @@ impl Game { ) .chain(), ); - schedule.add_systems(profile("render", render_system)); + schedule.add_systems((profile("dirty_render", dirty_render_system), profile("render", render_system)).chain()); // Spawn player world.spawn(player); diff --git a/src/systems/components.rs b/src/systems/components.rs index 847f033..2df22a0 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -109,3 +109,6 @@ pub struct ScoreResource(pub u32); #[derive(Resource)] pub struct DeltaTime(pub f32); + +#[derive(Resource, Default)] +pub struct RenderDirty(pub bool); diff --git a/src/systems/render.rs b/src/systems/render.rs index 1e73c66..23d64ea 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -1,15 +1,25 @@ use crate::error::{GameError, TextureError}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, DirectionalAnimated, Renderable}; +use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable}; use crate::systems::movement::{Movable, MovementState, Position}; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; use bevy_ecs::prelude::{Changed, Or, RemovedComponents}; -use bevy_ecs::system::{NonSendMut, Query, Res}; +use bevy_ecs::system::{NonSendMut, Query, Res, ResMut}; use sdl2::render::{Canvas, Texture}; use sdl2::video::Window; +pub fn dirty_render_system( + mut dirty: ResMut, + changed_renderables: Query<(), Or<(Changed, Changed)>>, + removed_renderables: RemovedComponents, +) { + if !changed_renderables.is_empty() || !removed_renderables.is_empty() { + dirty.0 = true; + } +} + /// Updates the directional animated texture of an entity. /// /// This runs before the render system so it can update the sprite based on the current direction of travel, as well as whether the entity is moving. @@ -55,12 +65,11 @@ pub fn render_system( mut backbuffer: NonSendMut, mut atlas: NonSendMut, map: Res, + mut dirty: ResMut, renderables: Query<(Entity, &Renderable, &Position)>, - changed_renderables: Query<(), Or<(Changed, Changed)>>, - removed_renderables: RemovedComponents, mut errors: EventWriter, ) { - if changed_renderables.is_empty() && removed_renderables.is_empty() { + if !dirty.0 { return; } // Clear the main canvas first @@ -111,4 +120,6 @@ pub fn render_system( .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); canvas.present(); + + dirty.0 = false; } From 5f0ee87dd9e46db6c0473f7275af17e6f65b3b1d Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 15:05:54 -0500 Subject: [PATCH 21/44] feat: better profiling statistics, less spammy --- Cargo.lock | 14 ++++++++ Cargo.toml | 2 ++ src/app.rs | 30 +++++++++++----- src/systems/profiling.rs | 78 +++++++++++++++++++++++++++++++++++++--- tests/profiling.rs | 40 +++++++++++++++++++++ 5 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 tests/profiling.rs diff --git a/Cargo.lock b/Cargo.lock index 9399b7b..a7c5f2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,6 +528,12 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "micromap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18c087666f377f857b49564f8791b481260c67825d6b337e1e38ddf54a985a88" + [[package]] name = "nonmax" version = "0.5.5" @@ -575,6 +581,7 @@ dependencies = [ "glam 0.30.5", "lazy_static", "libc", + "micromap", "once_cell", "parking_lot", "pathfinding", @@ -588,6 +595,7 @@ dependencies = [ "strum", "strum_macros", "thiserror", + "thousands", "tracing", "tracing-error", "tracing-subscriber", @@ -992,6 +1000,12 @@ dependencies = [ "syn", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "thread_local" version = "1.1.7" diff --git a/Cargo.toml b/Cargo.toml index 2f5454a..e73321e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ phf = { version = "0.12.1", features = ["macros"] } bevy_ecs = "0.16.1" bitflags = "2.9.1" parking_lot = "0.12.3" +micromap = "0.1.0" +thousands = "0.2.0" [profile.release] lto = true diff --git a/src/app.rs b/src/app.rs index 227b0c3..850bd34 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,8 @@ use sdl2::render::TextureCreator; use sdl2::ttf::Sdl2TtfContext; use sdl2::video::WindowContext; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; -use tracing::{field, info, warn}; +use thousands::Separable; +use tracing::info; use crate::error::{GameError, GameResult}; @@ -16,6 +17,7 @@ use crate::systems::profiling::SystemTimings; pub struct App { pub game: Game, + last_timings: Instant, last_tick: Instant, focused: bool, _cursor_pos: Vec2, @@ -68,6 +70,7 @@ impl App { game, focused: true, last_tick: Instant::now(), + last_timings: Instant::now() - Duration::from_secs_f32(0.5), _cursor_pos: Vec2::ZERO, }) } @@ -110,15 +113,26 @@ impl App { return false; } - // Show timings if the loop took more than 25% of the loop time - let show_timings = start.elapsed() > (LOOP_TIME / 4); - if show_timings || true { + if self.last_timings.elapsed() > Duration::from_secs(1) { + // Show timing statistics over the last 90 frames if let Some(timings) = self.game.world.get_resource::() { - let mut timings = timings.timings.lock(); - let total = timings.values().sum::(); - info!("Total: {:?}, Timings: {:?}", total, field::debug(&timings)); - timings.clear(); + let stats = timings.get_stats(); + let (total_avg, total_std) = timings.get_total_stats(); + + let mut individual_timings = String::new(); + for (name, (avg, std_dev)) in stats.iter() { + individual_timings.push_str(&format!("{}={:?} ± {:?} ", name, avg, std_dev)); + } + + let effective_fps = match 1.0 / total_avg.as_secs_f64() { + f if f > 100.0 => (f as u32).separate_with_commas(), + f if f < 10.0 => format!("{:.1} FPS", f), + f => format!("{:.0} FPS", f), + }; + + info!("({effective_fps}) {total_avg:?} ± {total_std:?} ({individual_timings})"); } + self.last_timings = Instant::now(); } // Sleep if we still have time left diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 0f3995d..bc03662 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -1,12 +1,82 @@ use bevy_ecs::prelude::Resource; use bevy_ecs::system::{IntoSystem, System}; +use micromap::Map; use parking_lot::Mutex; -use std::collections::HashMap; +use std::collections::VecDeque; use std::time::Duration; +const TIMING_WINDOW_SIZE: usize = 90; // 1.5 seconds at 60 FPS + #[derive(Resource, Default, Debug)] pub struct SystemTimings { - pub timings: Mutex>, + pub timings: Mutex, 15>>, +} + +impl SystemTimings { + pub fn add_timing(&self, name: &'static str, duration: Duration) { + let mut timings = self.timings.lock(); + let queue = timings.entry(name).or_insert_with(VecDeque::new); + + queue.push_back(duration); + if queue.len() > TIMING_WINDOW_SIZE { + queue.pop_front(); + } + } + + pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), 10> { + let timings = self.timings.lock(); + let mut stats = Map::new(); + + for (name, queue) in timings.iter() { + if queue.is_empty() { + continue; + } + + let durations: Vec = queue.iter().map(|d| d.as_secs_f64() * 1000.0).collect(); + let count = durations.len() as f64; + + let sum: f64 = durations.iter().sum(); + let mean = sum / count; + + let variance = durations.iter().map(|x| (x - mean).powi(2)).sum::() / count; + let std_dev = variance.sqrt(); + + stats.insert( + *name, + ( + Duration::from_secs_f64(mean / 1000.0), + Duration::from_secs_f64(std_dev / 1000.0), + ), + ); + } + + stats + } + + pub fn get_total_stats(&self) -> (Duration, Duration) { + let timings = self.timings.lock(); + let mut all_durations = Vec::new(); + + for queue in timings.values() { + all_durations.extend(queue.iter().map(|d| d.as_secs_f64() * 1000.0)); + } + + if all_durations.is_empty() { + return (Duration::ZERO, Duration::ZERO); + } + + let count = all_durations.len() as f64; + let sum: f64 = all_durations.iter().sum(); + let mean = sum / count; + + let variance = all_durations.iter().map(|x| (x - mean).powi(2)).sum::() / count; + let std_dev = variance.sqrt(); + + ( + Duration::from_secs_f64(mean / 1000.0), + Duration::from_secs_f64(std_dev / 1000.0), + ) + } } pub fn profile(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World) @@ -25,8 +95,8 @@ where system.run((), world); let duration = start.elapsed(); - if let Some(mut timings) = world.get_resource_mut::() { - timings.timings.lock().insert(name, duration); + if let Some(timings) = world.get_resource::() { + timings.add_timing(name, duration); } } } diff --git a/tests/profiling.rs b/tests/profiling.rs new file mode 100644 index 0000000..471772c --- /dev/null +++ b/tests/profiling.rs @@ -0,0 +1,40 @@ +use pacman::systems::profiling::SystemTimings; +use std::time::Duration; + +#[test] +fn test_timing_statistics() { + let timings = SystemTimings::default(); + + // Add some test data + timings.add_timing("test_system", Duration::from_millis(10)); + timings.add_timing("test_system", Duration::from_millis(12)); + timings.add_timing("test_system", Duration::from_millis(8)); + + let stats = timings.get_stats(); + let (avg, std_dev) = stats.get("test_system").unwrap(); + + // Average should be 10ms, standard deviation should be small + assert!((avg.as_millis() as f64 - 10.0).abs() < 1.0); + assert!(std_dev.as_millis() > 0); + + let (total_avg, total_std) = timings.get_total_stats(); + assert!((total_avg.as_millis() as f64 - 10.0).abs() < 1.0); + assert!(total_std.as_millis() > 0); +} + +#[test] +fn test_window_size_limit() { + let timings = SystemTimings::default(); + + // Add more than 90 timings to test window size limit + for i in 0..100 { + timings.add_timing("test_system", Duration::from_millis(i)); + } + + let stats = timings.get_stats(); + let (avg, _) = stats.get("test_system").unwrap(); + + // Should only keep the last 90 values, so average should be around 55ms + // (average of 10-99) + assert!((avg.as_millis() as f64 - 55.0).abs() < 5.0); +} From 8ce2af89c8d76ead620ceab848039a6dce5539c5 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 15:10:09 -0500 Subject: [PATCH 22/44] fix: add visibility check to rendering implementation --- src/systems/input.rs | 1 - src/systems/render.rs | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/systems/input.rs b/src/systems/input.rs index 1e74558..ab62cce 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -48,7 +48,6 @@ pub fn input_system(bindings: Res, mut writer: EventWriter, Event::KeyDown { keycode: Some(key), .. } => { let command = bindings.key_bindings.get(&key).copied(); if let Some(command) = command { - tracing::info!("triggering command: {:?}", command); writer.write(GameEvent::Command(command)); } } diff --git a/src/systems/render.rs b/src/systems/render.rs index 23d64ea..f533acc 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -90,6 +90,10 @@ pub fn render_system( // Render all entities to the backbuffer for (_, renderable, position) in renderables.iter() { + if !renderable.visible { + continue; + } + let pos = position.get_pixel_pos(&map.graph); match pos { Ok(pos) => { From a8b83b8e2b5f6f99c5591a39bea03fed7541c168 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 16:20:24 -0500 Subject: [PATCH 23/44] feat: high resolution debug rendering --- src/entity/graph.rs | 13 ++++ src/game/mod.rs | 44 +++++++++++-- src/systems/control.rs | 5 ++ src/systems/debug.rs | 143 +++++++++++++++++++++++++++++++++++++++++ src/systems/mod.rs | 1 + src/systems/render.rs | 16 +---- 6 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 src/systems/debug.rs diff --git a/src/entity/graph.rs b/src/entity/graph.rs index c83d98c..c5416cd 100644 --- a/src/entity/graph.rs +++ b/src/entity/graph.rs @@ -223,6 +223,19 @@ impl Graph { self.nodes.len() } + /// Returns an iterator over all nodes in the graph. + pub fn nodes(&self) -> impl Iterator { + self.nodes.iter() + } + + /// Returns an iterator over all edges in the graph. + pub fn edges(&self) -> impl Iterator + '_ { + self.adjacency_list + .iter() + .enumerate() + .flat_map(|(node_id, intersection)| intersection.edges().map(move |edge| (node_id, edge))) + } + /// Finds a specific edge from a source node to a target node. pub fn find_edge(&self, from: NodeId, to: NodeId) -> Option { self.adjacency_list.get(from)?.edges().find(|edge| edge.target == to) diff --git a/src/game/mod.rs b/src/game/mod.rs index ac1d83d..53e1aff 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -17,6 +17,7 @@ use crate::systems::{ PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, Score, ScoreResource, }, control::player_system, + debug::{debug_render_system, DebugState, DebugTextureResource}, input::input_system, movement::movement_system, profiling::{profile, SystemTimings}, @@ -24,7 +25,14 @@ use crate::systems::{ }; use crate::texture::animated::AnimatedTexture; use bevy_ecs::schedule::IntoScheduleConfigs; -use bevy_ecs::{event::EventRegistry, observer::Trigger, schedule::Schedule, system::ResMut, world::World}; +use bevy_ecs::system::NonSendMut; +use bevy_ecs::{ + event::EventRegistry, + observer::Trigger, + schedule::Schedule, + system::{Res, ResMut}, + world::World, +}; use sdl2::image::LoadTexture; use sdl2::render::{Canvas, ScaleMode, TextureCreator}; use sdl2::video::{Window, WindowContext}; @@ -72,6 +80,13 @@ impl Game { .map_err(|e| GameError::Sdl(e.to_string()))?; map_texture.set_scale_mode(ScaleMode::Nearest); + // Create debug texture at output resolution for crisp debug rendering + let output_size = canvas.output_size().unwrap(); + let mut debug_texture = texture_creator + .create_texture_target(None, output_size.0, output_size.1) + .map_err(|e| GameError::Sdl(e.to_string()))?; + debug_texture.set_scale_mode(ScaleMode::Nearest); + // Load atlas and create map texture let atlas_bytes = get_asset_bytes(Asset::Atlas)?; let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { @@ -157,7 +172,7 @@ impl Game { }, entity_type: EntityType::Player, collider: Collider { - size: constants::CELL_SIZE as f32 * 1.25, + size: constants::CELL_SIZE as f32 * 1.1, layer: CollisionLayer::PACMAN, }, pacman_collider: PacmanCollider, @@ -168,6 +183,7 @@ impl Game { world.insert_non_send_resource(canvas); world.insert_non_send_resource(BackbufferResource(backbuffer)); world.insert_non_send_resource(MapTextureResource(map_texture)); + world.insert_non_send_resource(DebugTextureResource(debug_texture)); world.insert_resource(map); world.insert_resource(GlobalState { exit: false }); @@ -176,6 +192,7 @@ impl Game { world.insert_resource(Bindings::default()); world.insert_resource(DeltaTime(0f32)); world.insert_resource(RenderDirty::default()); + world.insert_resource(DebugState::default()); world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| match *event { @@ -188,7 +205,6 @@ impl Game { GameEvent::Collision(_a, _b) => {} }, ); - schedule.add_systems( ( profile("input", input_system), @@ -197,11 +213,29 @@ impl Game { profile("collision", collision_system), profile("blinking", blinking_system), profile("directional_render", directional_render_system), + profile("dirty_render", dirty_render_system), + profile("render", render_system), + profile("debug_render", debug_render_system), + profile( + "present", + |mut canvas: NonSendMut<&mut Canvas>, + backbuffer: NonSendMut, + debug_state: Res, + mut dirty: ResMut| { + if dirty.0 { + // Only copy backbuffer to main canvas if debug rendering is off + // (debug rendering draws directly to main canvas) + if *debug_state == DebugState::Off { + canvas.copy(&backbuffer.0, None, None).unwrap(); + } + dirty.0 = false; + canvas.present(); + } + }, + ), ) .chain(), ); - schedule.add_systems((profile("dirty_render", dirty_render_system), profile("render", render_system)).chain()); - // Spawn player world.spawn(player); diff --git a/src/systems/control.rs b/src/systems/control.rs index 35bfdd2..bc4ef1f 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -9,6 +9,7 @@ use crate::{ error::GameError, events::{GameCommand, GameEvent}, systems::components::{GlobalState, PlayerControlled}, + systems::debug::DebugState, systems::movement::Movable, }; @@ -16,6 +17,7 @@ use crate::{ pub fn player_system( mut events: EventReader, mut state: ResMut, + mut debug_state: ResMut, mut players: Query<&mut Movable, With>, mut errors: EventWriter, ) { @@ -38,6 +40,9 @@ pub fn player_system( GameCommand::Exit => { state.exit = true; } + GameCommand::ToggleDebug => { + *debug_state = debug_state.next(); + } _ => {} } } diff --git a/src/systems/debug.rs b/src/systems/debug.rs new file mode 100644 index 0000000..d4d291b --- /dev/null +++ b/src/systems/debug.rs @@ -0,0 +1,143 @@ +//! Debug rendering system +use crate::constants::BOARD_PIXEL_OFFSET; +use crate::map::builder::Map; +use crate::systems::components::Collider; +use crate::systems::movement::Position; +use crate::systems::render::BackbufferResource; +use bevy_ecs::prelude::*; +use sdl2::pixels::Color; +use sdl2::rect::Rect; +use sdl2::render::{Canvas, Texture}; +use sdl2::video::Window; + +#[derive(Resource, Default, Debug, Copy, Clone, PartialEq)] +pub enum DebugState { + #[default] + Off, + Graph, + Collision, +} + +impl DebugState { + pub fn next(&self) -> Self { + match self { + DebugState::Off => DebugState::Graph, + DebugState::Graph => DebugState::Collision, + DebugState::Collision => DebugState::Off, + } + } +} + +/// Resource to hold the debug texture for persistent rendering +pub struct DebugTextureResource(pub Texture<'static>); + +/// Transforms a position from logical canvas coordinates to output canvas coordinates +fn transform_position(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { + let scale_x = output_size.0 as f32 / logical_size.0 as f32; + let scale_y = output_size.1 as f32 / logical_size.1 as f32; + let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio + + let x = (pos.0 * scale) as i32; + let y = (pos.1 * scale) as i32; + (x, y) +} + +/// Transforms a position from logical canvas coordinates to output canvas coordinates (with board offset) +fn transform_position_with_offset(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { + let scale_x = output_size.0 as f32 / logical_size.0 as f32; + let scale_y = output_size.1 as f32 / logical_size.1 as f32; + let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio + + let x = ((pos.0 + BOARD_PIXEL_OFFSET.x as f32) * scale) as i32; + let y = ((pos.1 + BOARD_PIXEL_OFFSET.y as f32) * scale) as i32; + (x, y) +} + +/// Transforms a size from logical canvas coordinates to output canvas coordinates +fn transform_size(size: f32, output_size: (u32, u32), logical_size: (u32, u32)) -> u32 { + let scale_x = output_size.0 as f32 / logical_size.0 as f32; + let scale_y = output_size.1 as f32 / logical_size.1 as f32; + let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio + + (size * scale) as u32 +} + +pub fn debug_render_system( + mut canvas: NonSendMut<&mut Canvas>, + backbuffer: NonSendMut, + mut debug_texture: NonSendMut, + debug_state: Res, + map: Res, + colliders: Query<(&Collider, &Position)>, +) { + if *debug_state == DebugState::Off { + return; + } + + // Get canvas sizes for coordinate transformation + let output_size = canvas.output_size().unwrap(); + let logical_size = canvas.logical_size(); + + // Copy the current backbuffer to the debug texture + canvas + .with_texture_canvas(&mut debug_texture.0, |debug_canvas| { + // Clear the debug canvas + debug_canvas.set_draw_color(Color::BLACK); + debug_canvas.clear(); + + // Copy the backbuffer to the debug canvas + debug_canvas.copy(&backbuffer.0, None, None).unwrap(); + }) + .unwrap(); + + // Draw debug info on the high-resolution debug texture + canvas + .with_texture_canvas(&mut debug_texture.0, |debug_canvas| match *debug_state { + DebugState::Graph => { + debug_canvas.set_draw_color(Color::RED); + for (start_node, end_node) in map.graph.edges() { + let start_node = map.graph.get_node(start_node).unwrap().position; + let end_node = map.graph.get_node(end_node.target).unwrap().position; + + // Transform positions using common method + let (start_x, start_y) = + transform_position_with_offset((start_node.x, start_node.y), output_size, logical_size); + let (end_x, end_y) = transform_position_with_offset((end_node.x, end_node.y), output_size, logical_size); + + debug_canvas.draw_line((start_x, start_y), (end_x, end_y)).unwrap(); + } + + debug_canvas.set_draw_color(Color::BLUE); + for node in map.graph.nodes() { + let pos = node.position; + + // Transform position using common method + let (x, y) = transform_position_with_offset((pos.x, pos.y), output_size, logical_size); + let size = transform_size(4.0, output_size, logical_size); + + debug_canvas + .fill_rect(Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size)) + .unwrap(); + } + } + DebugState::Collision => { + debug_canvas.set_draw_color(Color::GREEN); + for (collider, position) in colliders.iter() { + let pos = position.get_pixel_pos(&map.graph).unwrap(); + + // Transform position and size using common methods + let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size); + let size = transform_size(collider.size, output_size, logical_size); + + // Center the collision box on the entity + let rect = Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size); + debug_canvas.draw_rect(rect).unwrap(); + } + } + _ => {} + }) + .unwrap(); + + // Draw the debug texture directly onto the main canvas at full resolution + canvas.copy(&debug_texture.0, None, None).unwrap(); +} diff --git a/src/systems/mod.rs b/src/systems/mod.rs index abdbe9a..d1f59db 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -7,6 +7,7 @@ pub mod blinking; pub mod collision; pub mod components; pub mod control; +pub mod debug; pub mod input; pub mod movement; pub mod profiling; diff --git a/src/systems/render.rs b/src/systems/render.rs index f533acc..ac2bd6b 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -65,17 +65,13 @@ pub fn render_system( mut backbuffer: NonSendMut, mut atlas: NonSendMut, map: Res, - mut dirty: ResMut, + dirty: Res, renderables: Query<(Entity, &Renderable, &Position)>, mut errors: EventWriter, ) { if !dirty.0 { return; } - // 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| { @@ -116,14 +112,4 @@ pub fn render_system( }) .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(); - - dirty.0 = false; } From 3086453c7b165c3750dc4cfde182e76306b16319 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 16:25:42 -0500 Subject: [PATCH 24/44] chore: adjust collider sizes --- src/game/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index 53e1aff..eebdfa0 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -172,7 +172,7 @@ impl Game { }, entity_type: EntityType::Player, collider: Collider { - size: constants::CELL_SIZE as f32 * 1.1, + size: constants::CELL_SIZE as f32 * 1.375, layer: CollisionLayer::PACMAN, }, pacman_collider: PacmanCollider, @@ -249,12 +249,12 @@ impl Game { for (node_id, tile) in nodes { let (item_type, score, sprite, size) = match tile { - crate::constants::MapTile::Pellet => (EntityType::Pellet, 10, pellet_sprite, constants::CELL_SIZE as f32 * 0.2), + crate::constants::MapTile::Pellet => (EntityType::Pellet, 10, pellet_sprite, constants::CELL_SIZE as f32 * 0.4), crate::constants::MapTile::PowerPellet => ( EntityType::PowerPellet, 50, energizer_sprite, - constants::CELL_SIZE as f32 * 0.9, + constants::CELL_SIZE as f32 * 0.95, ), _ => continue, }; From 31193160a913e4b65e2d3e43e7e23c06b257eb46 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 17:52:16 -0500 Subject: [PATCH 25/44] feat: debug text rendering of statistics, formatting with tests --- Cargo.lock | 30 ++++++++ Cargo.toml | 2 + src/app.rs | 27 ------- src/game/mod.rs | 2 +- src/systems/debug.rs | 120 ++++++++++++++++++++---------- src/systems/formatting.rs | 151 ++++++++++++++++++++++++++++++++++++++ src/systems/mod.rs | 1 + src/systems/profiling.rs | 31 +++++++- tests/formatting.rs | 135 ++++++++++++++++++++++++++++++++++ tests/profiling.rs | 26 +++---- 10 files changed, 446 insertions(+), 79 deletions(-) create mode 100644 src/systems/formatting.rs create mode 100644 tests/formatting.rs diff --git a/Cargo.lock b/Cargo.lock index a7c5f2a..39c00fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,6 +316,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "disqualified" version = "1.0.0" @@ -559,6 +565,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num-width" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faede9396d7883a8c9c989e0b53c984bf770defb5cb8ed6c345b4c0566cf32b9" + [[package]] name = "once_cell" version = "1.21.3" @@ -582,10 +594,12 @@ dependencies = [ "lazy_static", "libc", "micromap", + "num-width", "once_cell", "parking_lot", "pathfinding", "phf", + "pretty_assertions", "rand", "sdl2", "serde", @@ -709,6 +723,16 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1429,3 +1453,9 @@ checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags 2.9.1", ] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml index e73321e..d29ec2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ bitflags = "2.9.1" parking_lot = "0.12.3" micromap = "0.1.0" thousands = "0.2.0" +pretty_assertions = "1.4.1" +num-width = "0.1.0" [profile.release] lto = true diff --git a/src/app.rs b/src/app.rs index 850bd34..34c0897 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,19 +5,15 @@ use sdl2::render::TextureCreator; use sdl2::ttf::Sdl2TtfContext; use sdl2::video::WindowContext; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; -use thousands::Separable; -use tracing::info; use crate::error::{GameError, GameResult}; use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; use crate::game::Game; use crate::platform::get_platform; -use crate::systems::profiling::SystemTimings; pub struct App { pub game: Game, - last_timings: Instant, last_tick: Instant, focused: bool, _cursor_pos: Vec2, @@ -70,7 +66,6 @@ impl App { game, focused: true, last_tick: Instant::now(), - last_timings: Instant::now() - Duration::from_secs_f32(0.5), _cursor_pos: Vec2::ZERO, }) } @@ -113,28 +108,6 @@ impl App { return false; } - if self.last_timings.elapsed() > Duration::from_secs(1) { - // Show timing statistics over the last 90 frames - if let Some(timings) = self.game.world.get_resource::() { - let stats = timings.get_stats(); - let (total_avg, total_std) = timings.get_total_stats(); - - let mut individual_timings = String::new(); - for (name, (avg, std_dev)) in stats.iter() { - individual_timings.push_str(&format!("{}={:?} ± {:?} ", name, avg, std_dev)); - } - - let effective_fps = match 1.0 / total_avg.as_secs_f64() { - f if f > 100.0 => (f as u32).separate_with_commas(), - f if f < 10.0 => format!("{:.1} FPS", f), - f => format!("{:.0} FPS", f), - }; - - info!("({effective_fps}) {total_avg:?} ± {total_std:?} ({individual_timings})"); - } - self.last_timings = Instant::now(); - } - // Sleep if we still have time left if start.elapsed() < LOOP_TIME { let time = LOOP_TIME.saturating_sub(start.elapsed()); diff --git a/src/game/mod.rs b/src/game/mod.rs index eebdfa0..7086b1d 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -222,7 +222,7 @@ impl Game { backbuffer: NonSendMut, debug_state: Res, mut dirty: ResMut| { - if dirty.0 { + if dirty.0 || *debug_state != DebugState::Off { // Only copy backbuffer to main canvas if debug rendering is off // (debug rendering draws directly to main canvas) if *debug_state == DebugState::Off { diff --git a/src/systems/debug.rs b/src/systems/debug.rs index d4d291b..495890c 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -3,12 +3,13 @@ use crate::constants::BOARD_PIXEL_OFFSET; use crate::map::builder::Map; use crate::systems::components::Collider; use crate::systems::movement::Position; +use crate::systems::profiling::SystemTimings; use crate::systems::render::BackbufferResource; use bevy_ecs::prelude::*; use sdl2::pixels::Color; use sdl2::rect::Rect; -use sdl2::render::{Canvas, Texture}; -use sdl2::video::Window; +use sdl2::render::{Canvas, Texture, TextureCreator}; +use sdl2::video::{Window, WindowContext}; #[derive(Resource, Default, Debug, Copy, Clone, PartialEq)] pub enum DebugState { @@ -62,11 +63,48 @@ fn transform_size(size: f32, output_size: (u32, u32), logical_size: (u32, u32)) (size * scale) as u32 } +/// Renders timing information in the top-left corner of the screen +fn render_timing_display( + canvas: &mut Canvas, + texture_creator: &mut TextureCreator, + timings: &SystemTimings, +) { + // Get TTF context + let ttf_context = sdl2::ttf::init().unwrap(); + + // Load font + let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap(); + + // Format timing information using the formatting module + let timing_text = timings.format_timing_display(); + + // Split text by newlines and render each line separately + let lines: Vec<&str> = timing_text.lines().collect(); + let line_height = 14; // Approximate line height for 12pt font + let padding = 10; + + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + + // Render each line + let surface = font.render(line).blended(Color::RGBA(255, 255, 255, 200)).unwrap(); + let texture = texture_creator.create_texture_from_surface(&surface).unwrap(); + + // Position each line below the previous one + let y_pos = padding + (i * line_height) as i32; + let dest = Rect::new(padding, y_pos, texture.query().width, texture.query().height); + canvas.copy(&texture, None, dest).unwrap(); + } +} + pub fn debug_render_system( mut canvas: NonSendMut<&mut Canvas>, backbuffer: NonSendMut, mut debug_texture: NonSendMut, debug_state: Res, + timings: Res, map: Res, colliders: Query<(&Collider, &Position)>, ) { @@ -90,51 +128,59 @@ pub fn debug_render_system( }) .unwrap(); + // Get texture creator before entering the closure to avoid borrowing conflicts + let mut texture_creator = canvas.texture_creator(); + // Draw debug info on the high-resolution debug texture canvas - .with_texture_canvas(&mut debug_texture.0, |debug_canvas| match *debug_state { - DebugState::Graph => { - debug_canvas.set_draw_color(Color::RED); - for (start_node, end_node) in map.graph.edges() { - let start_node = map.graph.get_node(start_node).unwrap().position; - let end_node = map.graph.get_node(end_node.target).unwrap().position; + .with_texture_canvas(&mut debug_texture.0, |debug_canvas| { + match *debug_state { + DebugState::Graph => { + debug_canvas.set_draw_color(Color::RED); + for (start_node, end_node) in map.graph.edges() { + let start_node = map.graph.get_node(start_node).unwrap().position; + let end_node = map.graph.get_node(end_node.target).unwrap().position; - // Transform positions using common method - let (start_x, start_y) = - transform_position_with_offset((start_node.x, start_node.y), output_size, logical_size); - let (end_x, end_y) = transform_position_with_offset((end_node.x, end_node.y), output_size, logical_size); + // Transform positions using common method + let (start_x, start_y) = + transform_position_with_offset((start_node.x, start_node.y), output_size, logical_size); + let (end_x, end_y) = transform_position_with_offset((end_node.x, end_node.y), output_size, logical_size); - debug_canvas.draw_line((start_x, start_y), (end_x, end_y)).unwrap(); + debug_canvas.draw_line((start_x, start_y), (end_x, end_y)).unwrap(); + } + + debug_canvas.set_draw_color(Color::BLUE); + for node in map.graph.nodes() { + let pos = node.position; + + // Transform position using common method + let (x, y) = transform_position_with_offset((pos.x, pos.y), output_size, logical_size); + let size = transform_size(4.0, output_size, logical_size); + + debug_canvas + .fill_rect(Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size)) + .unwrap(); + } } + DebugState::Collision => { + debug_canvas.set_draw_color(Color::GREEN); + for (collider, position) in colliders.iter() { + let pos = position.get_pixel_pos(&map.graph).unwrap(); - debug_canvas.set_draw_color(Color::BLUE); - for node in map.graph.nodes() { - let pos = node.position; + // Transform position and size using common methods + let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size); + let size = transform_size(collider.size, output_size, logical_size); - // Transform position using common method - let (x, y) = transform_position_with_offset((pos.x, pos.y), output_size, logical_size); - let size = transform_size(4.0, output_size, logical_size); - - debug_canvas - .fill_rect(Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size)) - .unwrap(); + // Center the collision box on the entity + let rect = Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size); + debug_canvas.draw_rect(rect).unwrap(); + } } + _ => {} } - DebugState::Collision => { - debug_canvas.set_draw_color(Color::GREEN); - for (collider, position) in colliders.iter() { - let pos = position.get_pixel_pos(&map.graph).unwrap(); - // Transform position and size using common methods - let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size); - let size = transform_size(collider.size, output_size, logical_size); - - // Center the collision box on the entity - let rect = Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size); - debug_canvas.draw_rect(rect).unwrap(); - } - } - _ => {} + // Render timing information in the top-left corner + render_timing_display(debug_canvas, &mut texture_creator, &timings); }) .unwrap(); diff --git a/src/systems/formatting.rs b/src/systems/formatting.rs new file mode 100644 index 0000000..a47f92d --- /dev/null +++ b/src/systems/formatting.rs @@ -0,0 +1,151 @@ +use num_width::NumberWidth; +use std::time::Duration; + +/// Formats timing data into a vector of strings with proper alignment +pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) -> String { + if timing_data.is_empty() { + return String::new(); + } + + // Helper to split a duration into a integer, decimal, and unit + fn get_value(duration: &Duration) -> (u64, u32, &'static str) { + let (int, decimal, unit) = match duration { + // if greater than 1 second, return as seconds + n if n >= &Duration::from_secs(1) => { + let secs = n.as_secs(); + let decimal = n.as_millis() as u64 % 1000; + (secs, decimal as u32, "s") + } + // if greater than 1 millisecond, return as milliseconds + n if n >= &Duration::from_millis(1) => { + let ms = n.as_millis() as u64; + let decimal = n.as_micros() as u64 % 1000; + (ms, decimal as u32, "ms") + } + // if greater than 1 microsecond, return as microseconds + n if n >= &Duration::from_micros(1) => { + let us = n.as_micros() as u64; + let decimal = n.as_nanos() as u64 % 1000; + (us, decimal as u32, "µs") + } + // otherwise, return as nanoseconds + n => { + let ns = n.as_nanos() as u64; + (ns, 0, "ns") + } + }; + + (int, decimal, unit) + } + + struct Entry { + name: String, + avg_int: u64, + avg_decimal: u32, + avg_unit: &'static str, + std_int: u64, + std_decimal: u32, + std_unit: &'static str, + } + + let entries = timing_data + .iter() + .map(|(name, avg, std_dev)| { + let (avg_int, avg_decimal, avg_unit) = get_value(&avg); + let (std_int, std_decimal, std_unit) = get_value(&std_dev); + + Entry { + name: name.clone(), + avg_int, + avg_decimal, + avg_unit, + std_int, + std_decimal, + std_unit, + } + }) + .collect::>(); + + let max_name_width = entries.iter().map(|e| e.name.len() as usize).max().unwrap_or(0); + let max_avg_int_width = entries.iter().map(|e| e.avg_int.width() as usize).max().unwrap_or(0); + let max_avg_decimal_width = entries + .iter() + .map(|e| e.avg_decimal.width() as usize) + .max() + .unwrap_or(0) + .max(3); + let max_std_int_width = entries.iter().map(|e| e.std_int.width() as usize).max().unwrap_or(0); + let max_std_decimal_width = entries + .iter() + .map(|e| e.std_decimal.width() as usize) + .max() + .unwrap_or(0) + .max(3); + + let mut output_lines = Vec::new(); + + // Format each line using the calculated max widths for alignment + for Entry { + name, + avg_int, + avg_decimal, + avg_unit, + std_int, + std_decimal, + std_unit, + } in entries.iter() + { + // Add exactly 4 spaces of padding before each number + let avg_padding = " ".repeat(4); + let std_padding = " ".repeat(4); + + output_lines.push(format!( + "{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal: = result.lines().collect(); + + // Verify we have the expected number of lines + assert_eq!(lines.len(), 6); + + let expected = r#" +total : 1.234ms ± 570.0 µs +input : 120.0 µs ± 45.0 µs +player : 456.0 µs ± 123.0 µs +movement : 789.0 µs ± 234.0 µs +render : 12.0 µs ± 3.0 µs +debug : 460.0 ns ± 557.0 ns +"# + .trim(); + + for (line, expected_line) in lines.iter().zip(expected.lines()) { + assert_eq!(*line, expected_line); + } + + // Print the result for manual inspection + println!("Formatted output:"); + println!("{}", result); + } +} diff --git a/src/systems/mod.rs b/src/systems/mod.rs index d1f59db..0c4fa03 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -8,6 +8,7 @@ pub mod collision; pub mod components; pub mod control; pub mod debug; +pub mod formatting; pub mod input; pub mod movement; pub mod profiling; diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index bc03662..5b9804d 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -4,8 +4,9 @@ use micromap::Map; use parking_lot::Mutex; use std::collections::VecDeque; use std::time::Duration; +use thousands::Separable; -const TIMING_WINDOW_SIZE: usize = 90; // 1.5 seconds at 60 FPS +const TIMING_WINDOW_SIZE: usize = 30; #[derive(Resource, Default, Debug)] pub struct SystemTimings { @@ -77,6 +78,34 @@ impl SystemTimings { Duration::from_secs_f64(std_dev / 1000.0), ) } + + pub fn format_timing_display(&self) -> String { + let stats = self.get_stats(); + let (total_avg, total_std) = self.get_total_stats(); + + let effective_fps = match 1.0 / total_avg.as_secs_f64() { + f if f > 100.0 => (f as u32).separate_with_commas(), + f if f < 10.0 => format!("{:.1} FPS", f), + f => format!("{:.0} FPS", f), + }; + + // Collect timing data for formatting + let mut timing_data = Vec::new(); + + // Add total stats + timing_data.push((effective_fps, total_avg, total_std)); + + // Add top 5 most expensive systems + let mut sorted_stats: Vec<_> = stats.iter().collect(); + sorted_stats.sort_by(|a, b| b.1 .0.cmp(&a.1 .0)); + + for (name, (avg, std_dev)) in sorted_stats.iter().take(5) { + timing_data.push((name.to_string(), *avg, *std_dev)); + } + + // Use the formatting module to format the data + crate::systems::formatting::format_timing_display(timing_data) + } } pub fn profile(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World) diff --git a/tests/formatting.rs b/tests/formatting.rs new file mode 100644 index 0000000..a33cc7c --- /dev/null +++ b/tests/formatting.rs @@ -0,0 +1,135 @@ +use pacman::systems::formatting::format_timing_display; +use std::time::Duration; + +#[test] +fn test_basic_formatting() { + let timing_data = vec![ + ("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567)), + ("input".to_string(), Duration::from_micros(123), Duration::from_micros(45)), + ("player".to_string(), Duration::from_micros(456), Duration::from_micros(123)), + ("movement".to_string(), Duration::from_micros(789), Duration::from_micros(234)), + ("render".to_string(), Duration::from_micros(12), Duration::from_micros(3)), + ("debug".to_string(), Duration::from_nanos(1000000), Duration::from_nanos(1000)), + ]; + + let result = format_timing_display(timing_data); + println!("Basic formatting test:"); + println!("{}", result); + println!(); +} + +#[test] +fn test_desired_format() { + // This test represents the exact format you want to achieve + let timing_data = vec![ + ("total".to_string(), Duration::from_micros(1230), Duration::from_micros(570)), + ("input".to_string(), Duration::from_micros(120), Duration::from_micros(50)), + ("player".to_string(), Duration::from_micros(460), Duration::from_micros(120)), + ("movement".to_string(), Duration::from_micros(790), Duration::from_micros(230)), + ("render".to_string(), Duration::from_micros(10), Duration::from_micros(3)), + ("debug".to_string(), Duration::from_nanos(1000000), Duration::from_nanos(1000)), + ]; + + let result = format_timing_display(timing_data); + println!("Desired format test:"); + println!("{}", result); + println!(); + + // Expected output should look like: + // total : 1.23 ms ± 0.57 ms + // input : 0.12 ms ± 0.05 ms + // player : 0.46 ms ± 0.12 ms + // movement : 0.79 ms ± 0.23 ms + // render : 0.01 ms ± 0.003ms + // debug : 0.001ms ± 0.000ms +} + +#[test] +fn test_mixed_units() { + let timing_data = vec![ + ("60 FPS".to_string(), Duration::from_millis(16), Duration::from_micros(500)), + ( + "fast_system".to_string(), + Duration::from_nanos(500000), + Duration::from_nanos(100000), + ), + ( + "medium_system".to_string(), + Duration::from_micros(2500), + Duration::from_micros(500), + ), + ("slow_system".to_string(), Duration::from_millis(5), Duration::from_millis(1)), + ]; + + let result = format_timing_display(timing_data); + println!("Mixed units test:"); + println!("{}", result); + println!(); +} + +#[test] +fn test_trailing_zeros() { + let timing_data = vec![ + ("60 FPS".to_string(), Duration::from_micros(1000), Duration::from_micros(500)), + ("exact_ms".to_string(), Duration::from_millis(1), Duration::from_micros(100)), + ("exact_us".to_string(), Duration::from_micros(1), Duration::from_nanos(100000)), + ("exact_ns".to_string(), Duration::from_nanos(1000), Duration::from_nanos(100)), + ]; + + let result = format_timing_display(timing_data); + println!("Trailing zeros test:"); + println!("{}", result); + println!(); +} + +#[test] +fn test_edge_cases() { + let timing_data = vec![ + ("60 FPS".to_string(), Duration::from_nanos(1), Duration::from_nanos(1)), + ("very_small".to_string(), Duration::from_nanos(100), Duration::from_nanos(50)), + ("very_large".to_string(), Duration::from_secs(1), Duration::from_millis(100)), + ("zero_time".to_string(), Duration::ZERO, Duration::ZERO), + ]; + + let result = format_timing_display(timing_data); + println!("Edge cases test:"); + println!("{}", result); + println!(); +} + +#[test] +fn test_variable_name_lengths() { + let timing_data = vec![ + ("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567)), + ("a".to_string(), Duration::from_micros(123), Duration::from_micros(45)), + ( + "very_long_system_name".to_string(), + Duration::from_micros(456), + Duration::from_micros(123), + ), + ("medium".to_string(), Duration::from_micros(789), Duration::from_micros(234)), + ]; + + let result = format_timing_display(timing_data); + println!("Variable name lengths test:"); + println!("{}", result); + println!(); +} + +#[test] +fn test_empty_input() { + let timing_data = vec![]; + let result = format_timing_display(timing_data); + assert_eq!(result, ""); + println!("Empty input test: PASS"); +} + +#[test] +fn test_single_entry() { + let timing_data = vec![("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567))]; + + let result = format_timing_display(timing_data); + println!("Single entry test:"); + println!("{}", result); + println!(); +} diff --git a/tests/profiling.rs b/tests/profiling.rs index 471772c..9b8d727 100644 --- a/tests/profiling.rs +++ b/tests/profiling.rs @@ -22,19 +22,19 @@ fn test_timing_statistics() { assert!(total_std.as_millis() > 0); } -#[test] -fn test_window_size_limit() { - let timings = SystemTimings::default(); +// #[test] +// fn test_window_size_limit() { +// let timings = SystemTimings::default(); - // Add more than 90 timings to test window size limit - for i in 0..100 { - timings.add_timing("test_system", Duration::from_millis(i)); - } +// // Add more than 90 timings to test window size limit +// for i in 0..100 { +// timings.add_timing("test_system", Duration::from_millis(i)); +// } - let stats = timings.get_stats(); - let (avg, _) = stats.get("test_system").unwrap(); +// let stats = timings.get_stats(); +// let (avg, _) = stats.get("test_system").unwrap(); - // Should only keep the last 90 values, so average should be around 55ms - // (average of 10-99) - assert!((avg.as_millis() as f64 - 55.0).abs() < 5.0); -} +// // Should only keep the last 90 values, so average should be around 55ms +// // (average of 10-99) +// assert!((avg.as_millis() as f64 - 55.0).abs() < 5.0); +// } From 635418a4da51bf5475d3ac5ad4cc92447eb8b3da Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 18:06:25 -0500 Subject: [PATCH 26/44] refactor: use stack allocated circular buffer, use RwLock+Mutex for concurrent system timing access --- Cargo.lock | 7 ++++++ Cargo.toml | 1 + src/systems/formatting.rs | 4 ---- src/systems/profiling.rs | 45 ++++++++++++++++++++++++++++----------- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39c00fe..a19f311 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -252,6 +252,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "circular-buffer" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bdce1da528cadbac4654b5632bfcd8c6c63e25b1d42cea919a95958790b51d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -590,6 +596,7 @@ dependencies = [ "anyhow", "bevy_ecs", "bitflags 2.9.1", + "circular-buffer", "glam 0.30.5", "lazy_static", "libc", diff --git a/Cargo.toml b/Cargo.toml index d29ec2a..2999cd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ micromap = "0.1.0" thousands = "0.2.0" pretty_assertions = "1.4.1" num-width = "0.1.0" +circular-buffer = "1.1.0" [profile.release] lto = true diff --git a/src/systems/formatting.rs b/src/systems/formatting.rs index a47f92d..d101f90 100644 --- a/src/systems/formatting.rs +++ b/src/systems/formatting.rs @@ -95,10 +95,6 @@ pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) -> std_unit, } in entries.iter() { - // Add exactly 4 spaces of padding before each number - let avg_padding = " ".repeat(4); - let std_padding = " ".repeat(4); - output_lines.push(format!( "{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal:, 15>>, + /// Map of system names to a queue of durations, using a circular buffer. + /// + /// Uses a RwLock to allow multiple readers for the HashMap, and a Mutex on the circular buffer for exclusive access. + /// This is probably overkill, but it's fun to play with. + /// + /// Also, we use a micromap::Map as the number of systems is generally quite small. + /// Just make sure to set the capacity appropriately, or it will panic. + pub timings: RwLock>, 10>>, } impl SystemTimings { pub fn add_timing(&self, name: &'static str, duration: Duration) { - let mut timings = self.timings.lock(); - let queue = timings.entry(name).or_insert_with(VecDeque::new); + // acquire a upgradable read lock + let mut timings = self.timings.upgradable_read(); - queue.push_back(duration); - if queue.len() > TIMING_WINDOW_SIZE { - queue.pop_front(); + // happy path, the name is already in the map (no need to mutate the hashmap) + if timings.contains_key(name) { + let queue = timings + .get(name) + .expect("System name not found in map after contains_key check"); + let mut queue = queue.lock(); + + queue.push_back(duration); + return; } + + // otherwise, acquire a write lock and insert a new queue + timings.with_upgraded(|timings| { + let queue = timings.entry(name).or_insert_with(|| Mutex::new(CircularBuffer::new())); + queue.lock().push_back(duration); + }); } pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), 10> { - let timings = self.timings.lock(); + let timings = self.timings.read(); let mut stats = Map::new(); for (name, queue) in timings.iter() { - if queue.is_empty() { + if queue.lock().is_empty() { continue; } - let durations: Vec = queue.iter().map(|d| d.as_secs_f64() * 1000.0).collect(); + let durations: Vec = queue.lock().iter().map(|d| d.as_secs_f64() * 1000.0).collect(); let count = durations.len() as f64; let sum: f64 = durations.iter().sum(); @@ -55,11 +74,11 @@ impl SystemTimings { } pub fn get_total_stats(&self) -> (Duration, Duration) { - let timings = self.timings.lock(); + let timings = self.timings.read(); let mut all_durations = Vec::new(); for queue in timings.values() { - all_durations.extend(queue.iter().map(|d| d.as_secs_f64() * 1000.0)); + all_durations.extend(queue.lock().iter().map(|d| d.as_secs_f64() * 1000.0)); } if all_durations.is_empty() { From 0133dd5329bb0267b2e85e0f9638408608bb1611 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 18:39:39 -0500 Subject: [PATCH 27/44] feat: add background for text contrast to debug window --- src/systems/debug.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/systems/debug.rs b/src/systems/debug.rs index 495890c..e4b5337 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -80,9 +80,37 @@ fn render_timing_display( // Split text by newlines and render each line separately let lines: Vec<&str> = timing_text.lines().collect(); + if lines.is_empty() { + return; + } let line_height = 14; // Approximate line height for 12pt font let padding = 10; + // Calculate background dimensions + let max_width = lines + .iter() + .filter(|&&l| !l.is_empty()) // Don't consider empty lines for width + .map(|line| font.size_of(line).unwrap().0) + .max() + .unwrap_or(0); + + // Only draw background if there is text to display + if max_width > 0 { + let total_height = (lines.len() as u32) * line_height as u32; + let bg_padding = 5; + + // Draw background + let bg_rect = Rect::new( + padding - bg_padding, + padding - bg_padding, + max_width + (bg_padding * 2) as u32, + total_height + bg_padding as u32, + ); + canvas.set_blend_mode(sdl2::render::BlendMode::Blend); + canvas.set_draw_color(Color::RGBA(40, 40, 40, 180)); + canvas.fill_rect(bg_rect).unwrap(); + } + for (i, line) in lines.iter().enumerate() { if line.is_empty() { continue; From 80930ddd351ef19ad13d75c121b6a0ed1e5ad8bb Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 18:40:24 -0500 Subject: [PATCH 28/44] fix: use const MAX_SYSTEMS to ensure micromap maps are aligned in size --- src/systems/profiling.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 730da86..b635aea 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -6,6 +6,9 @@ use parking_lot::{Mutex, RwLock}; use std::time::Duration; use thousands::Separable; +/// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic. +const MAX_SYSTEMS: usize = 11; +/// The number of durations to keep in the circular buffer. const TIMING_WINDOW_SIZE: usize = 30; #[derive(Resource, Default, Debug)] @@ -17,7 +20,7 @@ pub struct SystemTimings { /// /// Also, we use a micromap::Map as the number of systems is generally quite small. /// Just make sure to set the capacity appropriately, or it will panic. - pub timings: RwLock>, 10>>, + pub timings: RwLock>, MAX_SYSTEMS>>, } impl SystemTimings { @@ -43,7 +46,7 @@ impl SystemTimings { }); } - pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), 10> { + pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), MAX_SYSTEMS> { let timings = self.timings.read(); let mut stats = Map::new(); From 4d397bba5fff756f1e145fe2ab777fc3172020df Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 18:41:08 -0500 Subject: [PATCH 29/44] feat: item collection system, score mutations --- src/game/mod.rs | 2 ++ src/systems/item.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/systems/mod.rs | 1 + 3 files changed, 46 insertions(+) create mode 100644 src/systems/item.rs diff --git a/src/game/mod.rs b/src/game/mod.rs index 7086b1d..98a3403 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -19,6 +19,7 @@ use crate::systems::{ control::player_system, debug::{debug_render_system, DebugState, DebugTextureResource}, input::input_system, + item::item_system, movement::movement_system, profiling::{profile, SystemTimings}, render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource}, @@ -211,6 +212,7 @@ impl Game { profile("player", player_system), profile("movement", movement_system), profile("collision", collision_system), + profile("item", item_system), profile("blinking", blinking_system), profile("directional_render", directional_render_system), profile("dirty_render", dirty_render_system), diff --git a/src/systems/item.rs b/src/systems/item.rs new file mode 100644 index 0000000..d2cd91a --- /dev/null +++ b/src/systems/item.rs @@ -0,0 +1,43 @@ +use bevy_ecs::{event::EventReader, prelude::*, query::With, system::Query}; + +use crate::{ + events::GameEvent, + systems::components::{EntityType, ItemCollider, PacmanCollider, ScoreResource}, +}; + +pub fn item_system( + mut commands: Commands, + mut collision_events: EventReader, + mut score: ResMut, + pacman_query: Query>, + item_query: Query<(Entity, &EntityType), With>, +) { + for event in collision_events.read() { + if let GameEvent::Collision(entity1, entity2) = event { + // Check if one is Pacman and the other is an item + let (_pacman_entity, item_entity) = if pacman_query.get(*entity1).is_ok() && item_query.get(*entity2).is_ok() { + (*entity1, *entity2) + } else if pacman_query.get(*entity2).is_ok() && item_query.get(*entity1).is_ok() { + (*entity2, *entity1) + } else { + continue; + }; + + // Get the item type and update score + if let Ok((item_ent, entity_type)) = item_query.get(item_entity) { + match entity_type { + EntityType::Pellet => { + score.0 += 10; + } + EntityType::PowerPellet => { + score.0 += 50; + } + _ => continue, + } + + // Remove the collected item + commands.entity(item_ent).despawn(); + } + } + } +} diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 0c4fa03..87530f1 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -10,6 +10,7 @@ pub mod control; pub mod debug; pub mod formatting; pub mod input; +pub mod item; pub mod movement; pub mod profiling; pub mod render; From f1935ad0169199a3970ec0e1ed62faee5aa96ab1 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 19:06:43 -0500 Subject: [PATCH 30/44] refactor: use smallvec instead of collect string, explicit formatting, accumulator fold --- src/systems/formatting.rs | 183 ++++++++++++++------------------------ src/systems/profiling.rs | 2 +- 2 files changed, 70 insertions(+), 115 deletions(-) diff --git a/src/systems/formatting.rs b/src/systems/formatting.rs index d101f90..9f417d2 100644 --- a/src/systems/formatting.rs +++ b/src/systems/formatting.rs @@ -1,41 +1,43 @@ use num_width::NumberWidth; -use std::time::Duration; +use smallvec::SmallVec; +use std::{iter, time::Duration}; + +// Helper to split a duration into a integer, decimal, and unit +fn get_value(duration: &Duration) -> (u64, u32, &'static str) { + let (int, decimal, unit) = match duration { + // if greater than 1 second, return as seconds + n if n >= &Duration::from_secs(1) => { + let secs = n.as_secs(); + let decimal = n.as_millis() as u64 % 1000; + (secs, decimal as u32, "s") + } + // if greater than 1 millisecond, return as milliseconds + n if n >= &Duration::from_millis(1) => { + let ms = n.as_millis() as u64; + let decimal = n.as_micros() as u64 % 1000; + (ms, decimal as u32, "ms") + } + // if greater than 1 microsecond, return as microseconds + n if n >= &Duration::from_micros(1) => { + let us = n.as_micros() as u64; + let decimal = n.as_nanos() as u64 % 1000; + (us, decimal as u32, "µs") + } + // otherwise, return as nanoseconds + n => { + let ns = n.as_nanos() as u64; + (ns, 0, "ns") + } + }; + + (int, decimal, unit) +} /// Formats timing data into a vector of strings with proper alignment -pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) -> String { - if timing_data.is_empty() { - return String::new(); - } - - // Helper to split a duration into a integer, decimal, and unit - fn get_value(duration: &Duration) -> (u64, u32, &'static str) { - let (int, decimal, unit) = match duration { - // if greater than 1 second, return as seconds - n if n >= &Duration::from_secs(1) => { - let secs = n.as_secs(); - let decimal = n.as_millis() as u64 % 1000; - (secs, decimal as u32, "s") - } - // if greater than 1 millisecond, return as milliseconds - n if n >= &Duration::from_millis(1) => { - let ms = n.as_millis() as u64; - let decimal = n.as_micros() as u64 % 1000; - (ms, decimal as u32, "ms") - } - // if greater than 1 microsecond, return as microseconds - n if n >= &Duration::from_micros(1) => { - let us = n.as_micros() as u64; - let decimal = n.as_nanos() as u64 % 1000; - (us, decimal as u32, "µs") - } - // otherwise, return as nanoseconds - n => { - let ns = n.as_nanos() as u64; - (ns, 0, "ns") - } - }; - - (int, decimal, unit) +pub fn format_timing_display(timing_data: impl IntoIterator) -> SmallVec<[String; 12]> { + let mut iter = timing_data.into_iter().peekable(); + if iter.peek().is_none() { + return SmallVec::new(); } struct Entry { @@ -48,8 +50,7 @@ pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) -> std_unit: &'static str, } - let entries = timing_data - .iter() + let mut entries = iter .map(|(name, avg, std_dev)| { let (avg_int, avg_decimal, avg_unit) = get_value(&avg); let (std_int, std_decimal, std_unit) = get_value(&std_dev); @@ -64,84 +65,38 @@ pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) -> std_unit, } }) - .collect::>(); + .collect::>(); - let max_name_width = entries.iter().map(|e| e.name.len() as usize).max().unwrap_or(0); - let max_avg_int_width = entries.iter().map(|e| e.avg_int.width() as usize).max().unwrap_or(0); - let max_avg_decimal_width = entries + let (max_name_width, max_avg_int_width, max_avg_decimal_width, max_std_int_width, max_std_decimal_width) = entries .iter() - .map(|e| e.avg_decimal.width() as usize) - .max() - .unwrap_or(0) - .max(3); - let max_std_int_width = entries.iter().map(|e| e.std_int.width() as usize).max().unwrap_or(0); - let max_std_decimal_width = entries - .iter() - .map(|e| e.std_decimal.width() as usize) - .max() - .unwrap_or(0) - .max(3); + .fold((0, 0, 3, 0, 3), |(name_w, avg_int_w, avg_dec_w, std_int_w, std_dec_w), e| { + ( + name_w.max(e.name.len()), + avg_int_w.max(e.avg_int.width() as usize), + avg_dec_w.max(e.avg_decimal.width() as usize), + std_int_w.max(e.std_int.width() as usize), + std_dec_w.max(e.std_decimal.width() as usize), + ) + }); - let mut output_lines = Vec::new(); - - // Format each line using the calculated max widths for alignment - for Entry { - name, - avg_int, - avg_decimal, - avg_unit, - std_int, - std_decimal, - std_unit, - } in entries.iter() - { - output_lines.push(format!( - "{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal: = result.lines().collect(); - - // Verify we have the expected number of lines - assert_eq!(lines.len(), 6); - - let expected = r#" -total : 1.234ms ± 570.0 µs -input : 120.0 µs ± 45.0 µs -player : 456.0 µs ± 123.0 µs -movement : 789.0 µs ± 234.0 µs -render : 12.0 µs ± 3.0 µs -debug : 460.0 ns ± 557.0 ns -"# - .trim(); - - for (line, expected_line) in lines.iter().zip(expected.lines()) { - assert_eq!(*line, expected_line); - } - - // Print the result for manual inspection - println!("Formatted output:"); - println!("{}", result); - } + entries.iter().map(|e| { + format!( + "{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal:>() } diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index b635aea..5ddac4d 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -126,7 +126,7 @@ impl SystemTimings { } // Use the formatting module to format the data - crate::systems::formatting::format_timing_display(timing_data) + crate::systems::formatting::format_timing_display(timing_data).join("\n") } } From 6af25af5f3f958138031455565598a01cc272ff5 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 19:39:59 -0500 Subject: [PATCH 31/44] test: better formatting tests, alignment-based --- tests/formatting.rs | 206 ++++++++++++++++++-------------------------- 1 file changed, 84 insertions(+), 122 deletions(-) diff --git a/tests/formatting.rs b/tests/formatting.rs index a33cc7c..e0949ad 100644 --- a/tests/formatting.rs +++ b/tests/formatting.rs @@ -1,135 +1,97 @@ +use itertools::izip; use pacman::systems::formatting::format_timing_display; +use smallvec::SmallVec; use std::time::Duration; -#[test] -fn test_basic_formatting() { - let timing_data = vec![ - ("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567)), - ("input".to_string(), Duration::from_micros(123), Duration::from_micros(45)), +use pretty_assertions::assert_eq; + +fn get_timing_data() -> Vec<(String, Duration, Duration)> { + vec![ + ("total".to_string(), Duration::from_micros(1234), Duration::from_micros(570)), + ("input".to_string(), Duration::from_micros(120), Duration::from_micros(45)), ("player".to_string(), Duration::from_micros(456), Duration::from_micros(123)), ("movement".to_string(), Duration::from_micros(789), Duration::from_micros(234)), ("render".to_string(), Duration::from_micros(12), Duration::from_micros(3)), - ("debug".to_string(), Duration::from_nanos(1000000), Duration::from_nanos(1000)), - ]; + ("debug".to_string(), Duration::from_nanos(460), Duration::from_nanos(557)), + ] +} - let result = format_timing_display(timing_data); - println!("Basic formatting test:"); - println!("{}", result); - println!(); +fn get_formatted_output() -> impl IntoIterator { + format_timing_display(get_timing_data()) } #[test] -fn test_desired_format() { - // This test represents the exact format you want to achieve - let timing_data = vec![ - ("total".to_string(), Duration::from_micros(1230), Duration::from_micros(570)), - ("input".to_string(), Duration::from_micros(120), Duration::from_micros(50)), - ("player".to_string(), Duration::from_micros(460), Duration::from_micros(120)), - ("movement".to_string(), Duration::from_micros(790), Duration::from_micros(230)), - ("render".to_string(), Duration::from_micros(10), Duration::from_micros(3)), - ("debug".to_string(), Duration::from_nanos(1000000), Duration::from_nanos(1000)), - ]; +fn test_formatting_alignment() { + let mut colon_positions = vec![]; + let mut first_decimal_positions = vec![]; + let mut second_decimal_positions = vec![]; + let mut first_unit_positions = vec![]; + let mut second_unit_positions = vec![]; - let result = format_timing_display(timing_data); - println!("Desired format test:"); - println!("{}", result); - println!(); + get_formatted_output().into_iter().for_each(|line| { + let (mut got_decimal, mut got_unit) = (false, false); + for (i, char) in line.chars().enumerate() { + match char { + ':' => colon_positions.push(i), + '.' => { + if got_decimal { + second_decimal_positions.push(i); + } else { + first_decimal_positions.push(i); + } + got_decimal = true; + } + 's' => { + if got_unit { + first_unit_positions.push(i); + } else { + second_unit_positions.push(i); + got_unit = true; + } + } + _ => {} + } + } + }); - // Expected output should look like: - // total : 1.23 ms ± 0.57 ms - // input : 0.12 ms ± 0.05 ms - // player : 0.46 ms ± 0.12 ms - // movement : 0.79 ms ± 0.23 ms - // render : 0.01 ms ± 0.003ms - // debug : 0.001ms ± 0.000ms -} - -#[test] -fn test_mixed_units() { - let timing_data = vec![ - ("60 FPS".to_string(), Duration::from_millis(16), Duration::from_micros(500)), - ( - "fast_system".to_string(), - Duration::from_nanos(500000), - Duration::from_nanos(100000), - ), - ( - "medium_system".to_string(), - Duration::from_micros(2500), - Duration::from_micros(500), - ), - ("slow_system".to_string(), Duration::from_millis(5), Duration::from_millis(1)), - ]; - - let result = format_timing_display(timing_data); - println!("Mixed units test:"); - println!("{}", result); - println!(); -} - -#[test] -fn test_trailing_zeros() { - let timing_data = vec![ - ("60 FPS".to_string(), Duration::from_micros(1000), Duration::from_micros(500)), - ("exact_ms".to_string(), Duration::from_millis(1), Duration::from_micros(100)), - ("exact_us".to_string(), Duration::from_micros(1), Duration::from_nanos(100000)), - ("exact_ns".to_string(), Duration::from_nanos(1000), Duration::from_nanos(100)), - ]; - - let result = format_timing_display(timing_data); - println!("Trailing zeros test:"); - println!("{}", result); - println!(); -} - -#[test] -fn test_edge_cases() { - let timing_data = vec![ - ("60 FPS".to_string(), Duration::from_nanos(1), Duration::from_nanos(1)), - ("very_small".to_string(), Duration::from_nanos(100), Duration::from_nanos(50)), - ("very_large".to_string(), Duration::from_secs(1), Duration::from_millis(100)), - ("zero_time".to_string(), Duration::ZERO, Duration::ZERO), - ]; - - let result = format_timing_display(timing_data); - println!("Edge cases test:"); - println!("{}", result); - println!(); -} - -#[test] -fn test_variable_name_lengths() { - let timing_data = vec![ - ("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567)), - ("a".to_string(), Duration::from_micros(123), Duration::from_micros(45)), - ( - "very_long_system_name".to_string(), - Duration::from_micros(456), - Duration::from_micros(123), - ), - ("medium".to_string(), Duration::from_micros(789), Duration::from_micros(234)), - ]; - - let result = format_timing_display(timing_data); - println!("Variable name lengths test:"); - println!("{}", result); - println!(); -} - -#[test] -fn test_empty_input() { - let timing_data = vec![]; - let result = format_timing_display(timing_data); - assert_eq!(result, ""); - println!("Empty input test: PASS"); -} - -#[test] -fn test_single_entry() { - let timing_data = vec![("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567))]; - - let result = format_timing_display(timing_data); - println!("Single entry test:"); - println!("{}", result); - println!(); + // Assert that all positions were found + assert_eq!( + vec![ + &colon_positions, + &first_decimal_positions, + &second_decimal_positions, + &first_unit_positions, + &second_unit_positions + ] + .iter() + .all(|p| p.len() == 6), + true + ); + + // Assert that all positions are the same + assert!( + colon_positions.iter().all(|&p| p == colon_positions[0]), + "colon positions are not the same {:?}", + colon_positions + ); + assert!( + first_decimal_positions.iter().all(|&p| p == first_decimal_positions[0]), + "first decimal positions are not the same {:?}", + first_decimal_positions + ); + assert!( + second_decimal_positions.iter().all(|&p| p == second_decimal_positions[0]), + "second decimal positions are not the same {:?}", + second_decimal_positions + ); + assert!( + first_unit_positions.iter().all(|&p| p == first_unit_positions[0]), + "first unit positions are not the same {:?}", + first_unit_positions + ); + assert!( + second_unit_positions.iter().all(|&p| p == second_unit_positions[0]), + "second unit positions are not the same {:?}", + second_unit_positions + ); } From e8944598cc8ce64a403bb7fc2a9d48d3e968ac4d Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 19:48:30 -0500 Subject: [PATCH 32/44] chore: fix clippy warnings --- src/game/mod.rs | 154 ++++---------------------------------- src/systems/components.rs | 5 -- src/systems/control.rs | 5 +- src/systems/formatting.rs | 4 +- src/systems/movement.rs | 18 ++--- src/systems/render.rs | 6 +- tests/formatting.rs | 4 +- 7 files changed, 31 insertions(+), 165 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index 98a3403..dd5e99b 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -13,8 +13,8 @@ use crate::systems::{ blinking::blinking_system, collision::collision_system, components::{ - Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, - PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, Score, ScoreResource, + Collider, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, PacmanCollider, + PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, }, control::player_system, debug::{debug_render_system, DebugState, DebugTextureResource}, @@ -174,7 +174,6 @@ impl Game { entity_type: EntityType::Player, collider: Collider { size: constants::CELL_SIZE as f32 * 1.375, - layer: CollisionLayer::PACMAN, }, pacman_collider: PacmanCollider, }; @@ -196,14 +195,10 @@ impl Game { world.insert_resource(DebugState::default()); world.add_observer( - |event: Trigger, mut state: ResMut, _score: ResMut| match *event { - GameEvent::Command(command) => match command { - GameCommand::Exit => { - state.exit = true; - } - _ => {} - }, - GameEvent::Collision(_a, _b) => {} + |event: Trigger, mut state: ResMut, _score: ResMut| { + if matches!(*event, GameEvent::Command(GameCommand::Exit)) { + state.exit = true; + } }, ); schedule.add_systems( @@ -238,6 +233,7 @@ impl Game { ) .chain(), ); + // Spawn player world.spawn(player); @@ -250,14 +246,11 @@ impl Game { let nodes: Vec<_> = world.resource::().iter_nodes().map(|(id, tile)| (*id, *tile)).collect(); for (node_id, tile) in nodes { - let (item_type, score, sprite, size) = match tile { - crate::constants::MapTile::Pellet => (EntityType::Pellet, 10, pellet_sprite, constants::CELL_SIZE as f32 * 0.4), - crate::constants::MapTile::PowerPellet => ( - EntityType::PowerPellet, - 50, - energizer_sprite, - constants::CELL_SIZE as f32 * 0.95, - ), + let (item_type, sprite, size) = match tile { + crate::constants::MapTile::Pellet => (EntityType::Pellet, pellet_sprite, constants::CELL_SIZE as f32 * 0.4), + crate::constants::MapTile::PowerPellet => { + (EntityType::PowerPellet, energizer_sprite, constants::CELL_SIZE as f32 * 0.95) + } _ => continue, }; @@ -272,11 +265,7 @@ impl Game { visible: true, }, entity_type: item_type, - score: Score(score), - collider: Collider { - size, - layer: CollisionLayer::ITEM, - }, + collider: Collider { size }, item_collider: ItemCollider, }); @@ -291,78 +280,6 @@ impl Game { Ok(Game { world, schedule }) } - // fn handle_command(&mut self, command: crate::input::commands::GameCommand) { - // use crate::input::commands::GameCommand; - // match command { - // GameCommand::MovePlayer(direction) => { - // self.state.pacman.set_next_direction(direction); - // } - // GameCommand::ToggleDebug => { - // self.toggle_debug_mode(); - // } - // GameCommand::MuteAudio => { - // let is_muted = self.state.audio.is_muted(); - // self.state.audio.set_mute(!is_muted); - // } - // GameCommand::ResetLevel => { - // if let Err(e) = self.reset_game_state() { - // tracing::error!("Failed to reset game state: {}", e); - // } - // } - // GameCommand::TogglePause => { - // self.state.paused = !self.state.paused; - // } - // GameCommand::Exit => {} - // } - // } - - // fn process_events(&mut self) { - // while let Some(event) = self.state.event_queue.pop_front() { - // match event { - // GameEvent::Command(command) => self.handle_command(command), - // } - // } - - // /// Resets the game state, randomizing ghost positions and resetting Pac-Man - // fn reset_game_state(&mut self) -> GameResult<()> { - // let pacman_start_node = self.state.map.start_positions.pacman; - // self.state.pacman = Pacman::new(&self.state.map.graph, pacman_start_node, &self.state.atlas)?; - - // // Reset items - // self.state.items = self.state.map.generate_items(&self.state.atlas)?; - - // // Randomize ghost positions - // let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; - // let mut rng = SmallRng::from_os_rng(); - - // for (i, ghost) in self.state.ghosts.iter_mut().enumerate() { - // let random_node = rng.random_range(0..self.state.map.graph.node_count()); - // *ghost = Ghost::new(&self.state.map.graph, random_node, ghost_types[i], &self.state.atlas)?; - // } - - // // Reset collision system - // self.state.collision_system = CollisionSystem::default(); - - // // Re-register Pac-Man - // self.state.pacman_id = self.state.collision_system.register_entity(self.state.pacman.position()); - - // // Re-register items - // self.state.item_ids.clear(); - // for item in &self.state.items { - // let item_id = self.state.collision_system.register_entity(item.position()); - // self.state.item_ids.push(item_id); - // } - - // // Re-register ghosts - // self.state.ghost_ids.clear(); - // for ghost in &self.state.ghosts { - // let ghost_id = self.state.collision_system.register_entity(ghost.position()); - // self.state.ghost_ids.push(ghost_id); - // } - - // Ok(()) - // } - /// Ticks the game state. /// /// Returns true if the game should exit. @@ -377,52 +294,9 @@ impl Game { .get_resource::() .expect("GlobalState could not be acquired"); - return state.exit; - - // // Process any events that have been posted (such as unpausing) - // self.process_events(); - - // // If the game is paused, we don't need to do anything beyond returning - // if self.state.paused { - // return false; - // } - - // self.schedule.run(&mut self.world); - - // self.state.pacman.tick(dt, &self.state.map.graph); - - // // Update all ghosts - // for ghost in &mut self.state.ghosts { - // ghost.tick(dt, &self.state.map.graph); - // } - - // // Update collision system positions - // self.update_collision_positions(); - - // // Check for collisions - // self.check_collisions(); + state.exit } - // /// Toggles the debug mode on and off. - // /// - // /// When debug mode is enabled, the game will render additional information - // /// that is useful for debugging, such as the collision grid and entity paths. - // pub fn toggle_debug_mode(&mut self) { - // self.state.debug_mode = !self.state.debug_mode; - // } - - // fn update_collision_positions(&mut self) { - // // Update Pac-Man's position - // self.state - // .collision_system - // .update_position(self.state.pacman_id, self.state.pacman.position()); - - // // Update ghost positions - // for (ghost, &ghost_id) in self.state.ghosts.iter().zip(&self.state.ghost_ids) { - // self.state.collision_system.update_position(ghost_id, ghost.position()); - // } - // } - // fn check_collisions(&mut self) { // // Check Pac-Man vs Items // let potential_collisions = self diff --git a/src/systems/components.rs b/src/systems/components.rs index 2df22a0..e6705fc 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -60,7 +60,6 @@ bitflags! { #[derive(Component)] pub struct Collider { pub size: f32, - pub layer: CollisionLayer, } /// Marker components for collision filtering optimization @@ -73,9 +72,6 @@ pub struct GhostCollider; #[derive(Component)] pub struct ItemCollider; -#[derive(Component)] -pub struct Score(pub u32); - #[derive(Bundle)] pub struct PlayerBundle { pub player: PlayerControlled, @@ -94,7 +90,6 @@ pub struct ItemBundle { pub position: Position, pub sprite: Renderable, pub entity_type: EntityType, - pub score: Score, pub collider: Collider, pub item_collider: ItemCollider, } diff --git a/src/systems/control.rs b/src/systems/control.rs index bc4ef1f..7329730 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -25,7 +25,10 @@ pub fn player_system( let mut movable = match players.single_mut() { Ok(movable) => movable, Err(e) => { - errors.write(GameError::InvalidState(format!("No/multiple entities queried for player system: {}", e)).into()); + errors.write(GameError::InvalidState(format!( + "No/multiple entities queried for player system: {}", + e + ))); return; } }; diff --git a/src/systems/formatting.rs b/src/systems/formatting.rs index 9f417d2..bc88528 100644 --- a/src/systems/formatting.rs +++ b/src/systems/formatting.rs @@ -1,6 +1,6 @@ use num_width::NumberWidth; use smallvec::SmallVec; -use std::{iter, time::Duration}; +use std::time::Duration; // Helper to split a duration into a integer, decimal, and unit fn get_value(duration: &Duration) -> (u64, u32, &'static str) { @@ -50,7 +50,7 @@ pub fn format_timing_display(timing_data: impl IntoIterator Self { - Position { - node: 0, - edge_progress: None, - } - } -} - #[allow(dead_code)] impl Position { /// Returns `true` if the position is exactly at a node (not traveling). diff --git a/src/systems/render.rs b/src/systems/render.rs index ac2bd6b..4590187 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -10,6 +10,7 @@ use bevy_ecs::system::{NonSendMut, Query, Res, ResMut}; use sdl2::render::{Canvas, Texture}; use sdl2::video::Window; +#[allow(clippy::type_complexity)] pub fn dirty_render_system( mut dirty: ResMut, changed_renderables: Query<(), Or<(Changed, Changed)>>, @@ -47,7 +48,7 @@ pub fn directional_render_system( renderable.sprite = new_tile; } } else { - errors.write(TextureError::RenderFailed(format!("Entity has no texture")).into()); + errors.write(TextureError::RenderFailed("Entity has no texture".to_string()).into()); continue; } } @@ -59,6 +60,7 @@ pub struct MapTextureResource(pub Texture<'static>); /// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource. pub struct BackbufferResource(pub Texture<'static>); +#[allow(clippy::too_many_arguments)] pub fn render_system( mut canvas: NonSendMut<&mut Canvas>, map_texture: NonSendMut, @@ -105,7 +107,7 @@ pub fn render_system( .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); } Err(e) => { - errors.write(e.into()); + errors.write(e); } } } diff --git a/tests/formatting.rs b/tests/formatting.rs index e0949ad..c008aec 100644 --- a/tests/formatting.rs +++ b/tests/formatting.rs @@ -1,6 +1,4 @@ -use itertools::izip; use pacman::systems::formatting::format_timing_display; -use smallvec::SmallVec; use std::time::Duration; use pretty_assertions::assert_eq; @@ -56,7 +54,7 @@ fn test_formatting_alignment() { // Assert that all positions were found assert_eq!( - vec![ + [ &colon_positions, &first_decimal_positions, &second_decimal_positions, From 342f378860f5d47a954d87cd6610ae1df464e62a Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 20:07:30 -0500 Subject: [PATCH 33/44] fix: use renderable layer properly, sorting entities before presenting --- src/systems/render.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/systems/render.rs b/src/systems/render.rs index 4590187..50f5827 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -87,7 +87,11 @@ pub fn render_system( } // Render all entities to the backbuffer - for (_, renderable, position) in renderables.iter() { + for (_, renderable, position) in renderables + .iter() + .sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer) + .rev() + { if !renderable.visible { continue; } From fa12611c69a1eaaa6112e5f9bc52e431b7c496e3 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 20:28:47 -0500 Subject: [PATCH 34/44] feat: ecs audio system --- src/game/mod.rs | 10 +++++++- src/systems/audio.rs | 54 +++++++++++++++++++++++++++++++++++++++ src/systems/components.rs | 9 +++++++ src/systems/item.rs | 8 +++++- src/systems/mod.rs | 1 + src/systems/profiling.rs | 2 +- 6 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/systems/audio.rs diff --git a/src/game/mod.rs b/src/game/mod.rs index dd5e99b..3876384 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -10,10 +10,11 @@ use crate::map::builder::Map; use crate::systems::blinking::Blinking; use crate::systems::movement::{Movable, MovementState, Position}; use crate::systems::{ + audio::{audio_system, AudioEvent, AudioResource}, blinking::blinking_system, collision::collision_system, components::{ - Collider, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, PacmanCollider, + AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, }, control::player_system, @@ -70,6 +71,7 @@ impl Game { EventRegistry::register_event::(&mut world); EventRegistry::register_event::(&mut world); + EventRegistry::register_event::(&mut world); let mut backbuffer = texture_creator .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) @@ -88,6 +90,9 @@ impl Game { .map_err(|e| GameError::Sdl(e.to_string()))?; debug_texture.set_scale_mode(ScaleMode::Nearest); + // Initialize audio system + let audio = crate::audio::Audio::new(); + // Load atlas and create map texture let atlas_bytes = get_asset_bytes(Asset::Atlas)?; let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { @@ -184,6 +189,7 @@ impl Game { world.insert_non_send_resource(BackbufferResource(backbuffer)); world.insert_non_send_resource(MapTextureResource(map_texture)); world.insert_non_send_resource(DebugTextureResource(debug_texture)); + world.insert_non_send_resource(AudioResource(audio)); world.insert_resource(map); world.insert_resource(GlobalState { exit: false }); @@ -193,6 +199,7 @@ impl Game { world.insert_resource(DeltaTime(0f32)); world.insert_resource(RenderDirty::default()); world.insert_resource(DebugState::default()); + world.insert_resource(AudioState::default()); world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| { @@ -208,6 +215,7 @@ impl Game { profile("movement", movement_system), profile("collision", collision_system), profile("item", item_system), + profile("audio", audio_system), profile("blinking", blinking_system), profile("directional_render", directional_render_system), profile("dirty_render", dirty_render_system), diff --git a/src/systems/audio.rs b/src/systems/audio.rs new file mode 100644 index 0000000..915cc83 --- /dev/null +++ b/src/systems/audio.rs @@ -0,0 +1,54 @@ +//! Audio system for handling sound playback in the Pac-Man game. +//! +//! This module provides an ECS-based audio system that integrates with SDL2_mixer +//! for playing sound effects. The system uses NonSendMut resources to handle SDL2's +//! main-thread requirements while maintaining Bevy ECS compatibility. + +use bevy_ecs::{ + event::{Event, EventReader, EventWriter}, + system::{NonSendMut, ResMut}, +}; + +use crate::{audio::Audio, error::GameError, systems::components::AudioState}; + +/// Events for triggering audio playback +#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioEvent { + /// Play the "eat" sound when Pac-Man consumes a pellet + PlayEat, +} + +/// Non-send resource wrapper for SDL2 audio system +/// +/// This wrapper is needed because SDL2 audio components are not Send, +/// but Bevy ECS requires Send for regular resources. Using NonSendMut +/// allows us to use SDL2 audio on the main thread while integrating +/// with the ECS system. +pub struct AudioResource(pub Audio); + +/// System that processes audio events and plays sounds +pub fn audio_system( + mut audio: NonSendMut, + mut audio_state: ResMut, + mut audio_events: EventReader, + _errors: EventWriter, +) { + // Set mute state if it has changed + if audio.0.is_muted() != audio_state.muted { + audio.0.set_mute(audio_state.muted); + } + + // Process audio events + for event in audio_events.read() { + match event { + AudioEvent::PlayEat => { + if !audio.0.is_disabled() && !audio_state.muted { + audio.0.eat(); + // Update the sound index for cycling through sounds + audio_state.sound_index = (audio_state.sound_index + 1) % 4; + // 4 eat sounds available + } + } + } + } +} diff --git a/src/systems/components.rs b/src/systems/components.rs index e6705fc..f80d22d 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -107,3 +107,12 @@ pub struct DeltaTime(pub f32); #[derive(Resource, Default)] pub struct RenderDirty(pub bool); + +/// Resource for tracking audio state +#[derive(Resource, Debug, Clone, Default)] +pub struct AudioState { + /// Whether audio is currently muted + pub muted: bool, + /// Current sound index for cycling through eat sounds + pub sound_index: usize, +} diff --git a/src/systems/item.rs b/src/systems/item.rs index d2cd91a..9aff5de 100644 --- a/src/systems/item.rs +++ b/src/systems/item.rs @@ -2,7 +2,10 @@ use bevy_ecs::{event::EventReader, prelude::*, query::With, system::Query}; use crate::{ events::GameEvent, - systems::components::{EntityType, ItemCollider, PacmanCollider, ScoreResource}, + systems::{ + audio::AudioEvent, + components::{EntityType, ItemCollider, PacmanCollider, ScoreResource}, + }, }; pub fn item_system( @@ -11,6 +14,7 @@ pub fn item_system( mut score: ResMut, pacman_query: Query>, item_query: Query<(Entity, &EntityType), With>, + mut events: EventWriter, ) { for event in collision_events.read() { if let GameEvent::Collision(entity1, entity2) = event { @@ -37,6 +41,8 @@ pub fn item_system( // Remove the collected item commands.entity(item_ent).despawn(); + + events.write(AudioEvent::PlayEat); } } } diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 87530f1..b065230 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -3,6 +3,7 @@ //! This module contains all the ECS-related logic, including components, systems, //! and resources. +pub mod audio; pub mod blinking; pub mod collision; pub mod components; diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 5ddac4d..51f7f11 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -7,7 +7,7 @@ use std::time::Duration; use thousands::Separable; /// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic. -const MAX_SYSTEMS: usize = 11; +const MAX_SYSTEMS: usize = 12; /// The number of durations to keep in the circular buffer. const TIMING_WINDOW_SIZE: usize = 30; From e0a15c1ca8cf108c144c175e019649f7fd19b61d Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 20:30:41 -0500 Subject: [PATCH 35/44] feat: implement audio muting functionality --- src/systems/control.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/systems/control.rs b/src/systems/control.rs index 7329730..84efa83 100644 --- a/src/systems/control.rs +++ b/src/systems/control.rs @@ -8,7 +8,7 @@ use bevy_ecs::{ use crate::{ error::GameError, events::{GameCommand, GameEvent}, - systems::components::{GlobalState, PlayerControlled}, + systems::components::{AudioState, GlobalState, PlayerControlled}, systems::debug::DebugState, systems::movement::Movable, }; @@ -18,6 +18,7 @@ pub fn player_system( mut events: EventReader, mut state: ResMut, mut debug_state: ResMut, + mut audio_state: ResMut, mut players: Query<&mut Movable, With>, mut errors: EventWriter, ) { @@ -46,6 +47,10 @@ pub fn player_system( GameCommand::ToggleDebug => { *debug_state = debug_state.next(); } + GameCommand::MuteAudio => { + audio_state.muted = !audio_state.muted; + tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" }); + } _ => {} } } From 3d0bc66e407947b4938b918eb6892f1b0e87db7c Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 20:38:18 -0500 Subject: [PATCH 36/44] feat: ghosts system --- src/game/mod.rs | 121 +++++++++++++++++++++++++++++++++++++- src/systems/components.rs | 73 +++++++++++++++++++++++ src/systems/ghost.rs | 77 ++++++++++++++++++++++++ src/systems/mod.rs | 1 + src/systems/profiling.rs | 2 +- 5 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 src/systems/ghost.rs diff --git a/src/game/mod.rs b/src/game/mod.rs index 3876384..1fafca6 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -14,11 +14,13 @@ use crate::systems::{ blinking::blinking_system, collision::collision_system, components::{ - AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, PacmanCollider, - PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, + AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GhostBehavior, GhostBundle, GhostCollider, GhostType, + GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, + ScoreResource, }, control::player_system, debug::{debug_render_system, DebugState, DebugTextureResource}, + ghost::ghost_ai_system, input::input_system, item::item_system, movement::movement_system, @@ -212,6 +214,7 @@ impl Game { ( profile("input", input_system), profile("player", player_system), + profile("ghost_ai", ghost_ai_system), profile("movement", movement_system), profile("collision", collision_system), profile("item", item_system), @@ -245,6 +248,9 @@ impl Game { // Spawn player world.spawn(player); + // Spawn ghosts + Self::spawn_ghosts(&mut world)?; + // Spawn items let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::(), "maze/pellet.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?; @@ -288,6 +294,117 @@ impl Game { Ok(Game { world, schedule }) } + /// Spawns all four ghosts at their starting positions with appropriate textures. + fn spawn_ghosts(world: &mut World) -> GameResult<()> { + // Extract the data we need first to avoid borrow conflicts + let ghost_start_positions = { + let map = world.resource::(); + [ + (GhostType::Blinky, map.start_positions.blinky), + (GhostType::Pinky, map.start_positions.pinky), + (GhostType::Inky, map.start_positions.inky), + (GhostType::Clyde, map.start_positions.clyde), + ] + }; + + for (ghost_type, start_node) in ghost_start_positions { + // Create the ghost bundle in a separate scope to manage borrows + let ghost = { + let atlas = world.non_send_resource::(); + + // Create directional animated textures for the ghost + let mut textures = [None, None, None, None]; + let mut stopped_textures = [None, None, None, None]; + + for direction in Direction::DIRECTIONS { + let moving_prefix = match direction { + Direction::Up => "up", + Direction::Down => "down", + Direction::Left => "left", + Direction::Right => "right", + }; + + let moving_tiles = vec![ + SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) + .ok_or_else(|| { + GameError::Texture(TextureError::AtlasTileNotFound(format!( + "ghost/{}/{}_{}.png", + ghost_type.as_str(), + moving_prefix, + "a" + ))) + })?, + SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")) + .ok_or_else(|| { + GameError::Texture(TextureError::AtlasTileNotFound(format!( + "ghost/{}/{}_{}.png", + ghost_type.as_str(), + moving_prefix, + "b" + ))) + })?, + ]; + + let stopped_tiles = vec![SpriteAtlas::get_tile( + atlas, + &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"), + ) + .ok_or_else(|| { + GameError::Texture(TextureError::AtlasTileNotFound(format!( + "ghost/{}/{}_{}.png", + ghost_type.as_str(), + moving_prefix, + "a" + ))) + })?]; + + textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?); + stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); + } + + GhostBundle { + ghost_type, + ghost_behavior: GhostBehavior::default(), + position: Position { + node: start_node, + edge_progress: None, + }, + movement_state: MovementState::Stopped, + movable: Movable { + speed: ghost_type.base_speed(), + current_direction: Direction::Left, + requested_direction: Some(Direction::Left), // Start with some movement + }, + sprite: Renderable { + sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else( + || { + GameError::Texture(TextureError::AtlasTileNotFound(format!( + "ghost/{}/left_a.png", + ghost_type.as_str() + ))) + }, + )?, + layer: 0, + visible: true, + }, + directional_animated: DirectionalAnimated { + textures, + stopped_textures, + }, + entity_type: EntityType::Ghost, + collider: Collider { + size: crate::constants::CELL_SIZE as f32 * 1.375, + }, + ghost_collider: GhostCollider, + } + }; + + world.spawn(ghost); + } + + Ok(()) + } + /// Ticks the game state. /// /// Returns true if the game should exit. diff --git a/src/systems/components.rs b/src/systems/components.rs index f80d22d..91cec2b 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -11,6 +11,65 @@ use crate::{ #[derive(Default, Component)] pub struct PlayerControlled; +/// The four classic ghost types. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub enum GhostType { + Blinky, + Pinky, + Inky, + Clyde, +} + +impl GhostType { + /// Returns the ghost type name for atlas lookups. + pub fn as_str(self) -> &'static str { + match self { + GhostType::Blinky => "blinky", + GhostType::Pinky => "pinky", + GhostType::Inky => "inky", + GhostType::Clyde => "clyde", + } + } + + /// Returns the base movement speed for this ghost type. + pub fn base_speed(self) -> f32 { + match self { + GhostType::Blinky => 1.0, + GhostType::Pinky => 0.95, + GhostType::Inky => 0.9, + GhostType::Clyde => 0.85, + } + } + + /// Returns the ghost's color for debug rendering. + pub fn debug_color(&self) -> sdl2::pixels::Color { + match self { + GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red + GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink + GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan + GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange + } + } +} + +/// Ghost AI behavior component - controls randomized movement decisions. +#[derive(Component)] +pub struct GhostBehavior { + /// Timer for making new direction decisions + pub decision_timer: f32, + /// Interval between direction decisions (in seconds) + pub decision_interval: f32, +} + +impl Default for GhostBehavior { + fn default() -> Self { + Self { + decision_timer: 0.0, + decision_interval: 0.5, // Make decisions every half second + } + } +} + /// A tag component denoting the type of entity. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EntityType { @@ -94,6 +153,20 @@ pub struct ItemBundle { pub item_collider: ItemCollider, } +#[derive(Bundle)] +pub struct GhostBundle { + pub ghost_type: GhostType, + pub ghost_behavior: GhostBehavior, + pub position: Position, + pub movement_state: MovementState, + pub movable: Movable, + pub sprite: Renderable, + pub directional_animated: DirectionalAnimated, + pub entity_type: EntityType, + pub collider: Collider, + pub ghost_collider: GhostCollider, +} + #[derive(Resource)] pub struct GlobalState { pub exit: bool, diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs new file mode 100644 index 0000000..74139fc --- /dev/null +++ b/src/systems/ghost.rs @@ -0,0 +1,77 @@ +use bevy_ecs::system::{Query, Res}; +use rand::prelude::*; +use smallvec::SmallVec; + +use crate::{ + entity::direction::Direction, + map::builder::Map, + systems::{ + components::{DeltaTime, EntityType, GhostBehavior, GhostType}, + movement::{Movable, Position}, + }, +}; + +/// Ghost AI system that handles randomized movement decisions. +/// +/// This system runs on all ghosts and makes periodic decisions about +/// which direction to move in when they reach intersections. +pub fn ghost_ai_system( + map: Res, + delta_time: Res, + mut ghosts: Query<(&mut GhostBehavior, &mut Movable, &Position, &EntityType, &GhostType)>, +) { + for (mut ghost_behavior, mut movable, position, entity_type, _ghost_type) in ghosts.iter_mut() { + // Only process ghosts + if *entity_type != EntityType::Ghost { + continue; + } + + // Update decision timer + ghost_behavior.decision_timer += delta_time.0; + + // Check if we should make a new direction decision + let should_decide = ghost_behavior.decision_timer >= ghost_behavior.decision_interval; + let at_intersection = position.is_at_node(); + + if should_decide && at_intersection { + choose_random_direction(&map, &mut movable, position); + ghost_behavior.decision_timer = 0.0; + } + } +} + +/// Chooses a random available direction for a ghost at an intersection. +/// +/// This function mirrors the behavior from the old ghost implementation, +/// preferring not to reverse direction unless it's the only option. +fn choose_random_direction(map: &Map, movable: &mut Movable, position: &Position) { + let current_node = position.current_node(); + let intersection = &map.graph.adjacency_list[current_node]; + + // Collect all available directions that ghosts can traverse + let mut available_directions = SmallVec::<[Direction; 4]>::new(); + for direction in Direction::DIRECTIONS { + if let Some(edge) = intersection.get(direction) { + // Check if ghosts can traverse this edge + if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) { + available_directions.push(direction); + } + } + } + + // Choose a random direction (avoid reversing unless necessary) + if !available_directions.is_empty() { + let mut rng = SmallRng::from_os_rng(); + + // Filter out the opposite direction if possible, but allow it if we have limited options + let opposite = movable.current_direction.opposite(); + let filtered_directions: Vec<_> = available_directions + .iter() + .filter(|&&dir| dir != opposite || available_directions.len() <= 2) + .collect(); + + if let Some(&random_direction) = filtered_directions.choose(&mut rng) { + movable.requested_direction = Some(*random_direction); + } + } +} diff --git a/src/systems/mod.rs b/src/systems/mod.rs index b065230..a85e74b 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -10,6 +10,7 @@ pub mod components; pub mod control; pub mod debug; pub mod formatting; +pub mod ghost; pub mod input; pub mod item; pub mod movement; diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 51f7f11..955fbc4 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -7,7 +7,7 @@ use std::time::Duration; use thousands::Separable; /// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic. -const MAX_SYSTEMS: usize = 12; +const MAX_SYSTEMS: usize = 13; /// The number of durations to keep in the circular buffer. const TIMING_WINDOW_SIZE: usize = 30; From 514a4471628bcc05f49e58aa92c99cba1471089f Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 15 Aug 2025 20:52:48 -0500 Subject: [PATCH 37/44] refactor: use strum::EnumCount for const compile time system mapping --- src/game/mod.rs | 29 +++++++++++---------- src/systems/debug.rs | 10 ++----- src/systems/formatting.rs | 9 +++++-- src/systems/ghost.rs | 2 +- src/systems/profiling.rs | 55 ++++++++++++++++++++++++++++++--------- tests/profiling.rs | 10 +++---- 6 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index 1fafca6..895d951 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -9,6 +9,7 @@ use crate::events::GameEvent; use crate::map::builder::Map; use crate::systems::blinking::Blinking; use crate::systems::movement::{Movable, MovementState, Position}; +use crate::systems::profiling::SystemId; use crate::systems::{ audio::{audio_system, AudioEvent, AudioResource}, blinking::blinking_system, @@ -20,7 +21,7 @@ use crate::systems::{ }, control::player_system, debug::{debug_render_system, DebugState, DebugTextureResource}, - ghost::ghost_ai_system, + ghost::ghost_system, input::input_system, item::item_system, movement::movement_system, @@ -212,20 +213,20 @@ impl Game { ); schedule.add_systems( ( - profile("input", input_system), - profile("player", player_system), - profile("ghost_ai", ghost_ai_system), - profile("movement", movement_system), - profile("collision", collision_system), - profile("item", item_system), - profile("audio", audio_system), - profile("blinking", blinking_system), - profile("directional_render", directional_render_system), - profile("dirty_render", dirty_render_system), - profile("render", render_system), - profile("debug_render", debug_render_system), + profile(SystemId::Input, input_system), + profile(SystemId::Player, player_system), + profile(SystemId::Ghost, ghost_system), + profile(SystemId::Movement, movement_system), + profile(SystemId::Collision, collision_system), + profile(SystemId::Item, item_system), + profile(SystemId::Audio, audio_system), + profile(SystemId::Blinking, blinking_system), + profile(SystemId::DirectionalRender, directional_render_system), + profile(SystemId::DirtyRender, dirty_render_system), + profile(SystemId::Render, render_system), + profile(SystemId::DebugRender, debug_render_system), profile( - "present", + SystemId::Present, |mut canvas: NonSendMut<&mut Canvas>, backbuffer: NonSendMut, debug_state: Res, diff --git a/src/systems/debug.rs b/src/systems/debug.rs index e4b5337..428add0 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -76,20 +76,14 @@ fn render_timing_display( let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap(); // Format timing information using the formatting module - let timing_text = timings.format_timing_display(); - - // Split text by newlines and render each line separately - let lines: Vec<&str> = timing_text.lines().collect(); - if lines.is_empty() { - return; - } + let lines = timings.format_timing_display(); let line_height = 14; // Approximate line height for 12pt font let padding = 10; // Calculate background dimensions let max_width = lines .iter() - .filter(|&&l| !l.is_empty()) // Don't consider empty lines for width + .filter(|l| !l.is_empty()) // Don't consider empty lines for width .map(|line| font.size_of(line).unwrap().0) .max() .unwrap_or(0); diff --git a/src/systems/formatting.rs b/src/systems/formatting.rs index bc88528..1b4e93e 100644 --- a/src/systems/formatting.rs +++ b/src/systems/formatting.rs @@ -1,6 +1,9 @@ use num_width::NumberWidth; use smallvec::SmallVec; use std::time::Duration; +use strum::EnumCount; + +use crate::systems::profiling::SystemId; // Helper to split a duration into a integer, decimal, and unit fn get_value(duration: &Duration) -> (u64, u32, &'static str) { @@ -34,7 +37,9 @@ fn get_value(duration: &Duration) -> (u64, u32, &'static str) { } /// Formats timing data into a vector of strings with proper alignment -pub fn format_timing_display(timing_data: impl IntoIterator) -> SmallVec<[String; 12]> { +pub fn format_timing_display( + timing_data: impl IntoIterator, +) -> SmallVec<[String; SystemId::COUNT]> { let mut iter = timing_data.into_iter().peekable(); if iter.peek().is_none() { return SmallVec::new(); @@ -98,5 +103,5 @@ pub fn format_timing_display(timing_data: impl IntoIterator>() + }).collect::>() } diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index 74139fc..65bd29f 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -15,7 +15,7 @@ use crate::{ /// /// This system runs on all ghosts and makes periodic decisions about /// which direction to move in when they reach intersections. -pub fn ghost_ai_system( +pub fn ghost_system( map: Res, delta_time: Res, mut ghosts: Query<(&mut GhostBehavior, &mut Movable, &Position, &EntityType, &GhostType)>, diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 955fbc4..94227a4 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -3,14 +3,43 @@ use bevy_ecs::system::{IntoSystem, System}; use circular_buffer::CircularBuffer; use micromap::Map; use parking_lot::{Mutex, RwLock}; +use smallvec::SmallVec; +use std::fmt::Display; use std::time::Duration; +use strum::EnumCount; +use strum_macros::{EnumCount, IntoStaticStr}; use thousands::Separable; +use crate::systems::formatting; + /// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic. -const MAX_SYSTEMS: usize = 13; +const MAX_SYSTEMS: usize = SystemId::COUNT; /// The number of durations to keep in the circular buffer. const TIMING_WINDOW_SIZE: usize = 30; +#[derive(EnumCount, IntoStaticStr, Debug, PartialEq, Eq, Hash, Copy, Clone)] +pub enum SystemId { + Input, + Player, + Ghost, + Movement, + Audio, + Blinking, + DirectionalRender, + DirtyRender, + Render, + DebugRender, + Present, + Collision, + Item, +} + +impl Display for SystemId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", Into::<&'static str>::into(self).to_ascii_lowercase()) + } +} + #[derive(Resource, Default, Debug)] pub struct SystemTimings { /// Map of system names to a queue of durations, using a circular buffer. @@ -20,18 +49,18 @@ pub struct SystemTimings { /// /// Also, we use a micromap::Map as the number of systems is generally quite small. /// Just make sure to set the capacity appropriately, or it will panic. - pub timings: RwLock>, MAX_SYSTEMS>>, + pub timings: RwLock>, MAX_SYSTEMS>>, } impl SystemTimings { - pub fn add_timing(&self, name: &'static str, duration: Duration) { + pub fn add_timing(&self, id: SystemId, duration: Duration) { // acquire a upgradable read lock let mut timings = self.timings.upgradable_read(); // happy path, the name is already in the map (no need to mutate the hashmap) - if timings.contains_key(name) { + if timings.contains_key(&id) { let queue = timings - .get(name) + .get(&id) .expect("System name not found in map after contains_key check"); let mut queue = queue.lock(); @@ -41,16 +70,16 @@ impl SystemTimings { // otherwise, acquire a write lock and insert a new queue timings.with_upgraded(|timings| { - let queue = timings.entry(name).or_insert_with(|| Mutex::new(CircularBuffer::new())); + let queue = timings.entry(id).or_insert_with(|| Mutex::new(CircularBuffer::new())); queue.lock().push_back(duration); }); } - pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), MAX_SYSTEMS> { + pub fn get_stats(&self) -> Map { let timings = self.timings.read(); let mut stats = Map::new(); - for (name, queue) in timings.iter() { + for (id, queue) in timings.iter() { if queue.lock().is_empty() { continue; } @@ -65,7 +94,7 @@ impl SystemTimings { let std_dev = variance.sqrt(); stats.insert( - *name, + *id, ( Duration::from_secs_f64(mean / 1000.0), Duration::from_secs_f64(std_dev / 1000.0), @@ -101,7 +130,7 @@ impl SystemTimings { ) } - pub fn format_timing_display(&self) -> String { + pub fn format_timing_display(&self) -> SmallVec<[String; SystemId::COUNT]> { let stats = self.get_stats(); let (total_avg, total_std) = self.get_total_stats(); @@ -126,11 +155,11 @@ impl SystemTimings { } // Use the formatting module to format the data - crate::systems::formatting::format_timing_display(timing_data).join("\n") + formatting::format_timing_display(timing_data) } } -pub fn profile(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World) +pub fn profile(id: SystemId, system: S) -> impl FnMut(&mut bevy_ecs::world::World) where S: IntoSystem<(), (), M> + 'static, { @@ -147,7 +176,7 @@ where let duration = start.elapsed(); if let Some(timings) = world.get_resource::() { - timings.add_timing(name, duration); + timings.add_timing(id, duration); } } } diff --git a/tests/profiling.rs b/tests/profiling.rs index 9b8d727..d453cc5 100644 --- a/tests/profiling.rs +++ b/tests/profiling.rs @@ -1,4 +1,4 @@ -use pacman::systems::profiling::SystemTimings; +use pacman::systems::profiling::{SystemId, SystemTimings}; use std::time::Duration; #[test] @@ -6,12 +6,12 @@ fn test_timing_statistics() { let timings = SystemTimings::default(); // Add some test data - timings.add_timing("test_system", Duration::from_millis(10)); - timings.add_timing("test_system", Duration::from_millis(12)); - timings.add_timing("test_system", Duration::from_millis(8)); + timings.add_timing(SystemId::PlayerControls, Duration::from_millis(10)); + timings.add_timing(SystemId::PlayerControls, Duration::from_millis(12)); + timings.add_timing(SystemId::PlayerControls, Duration::from_millis(8)); let stats = timings.get_stats(); - let (avg, std_dev) = stats.get("test_system").unwrap(); + let (avg, std_dev) = stats.get(&SystemId::PlayerControls).unwrap(); // Average should be 10ms, standard deviation should be small assert!((avg.as_millis() as f64 - 10.0).abs() < 1.0); From 78300bdf9cd4254fd19f1aa18a02be7c264e7710 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 16 Aug 2025 11:44:10 -0500 Subject: [PATCH 38/44] feat: rewrite movement systems separately for player/ghosts --- src/game/mod.rs | 58 ++---- src/systems/collision.rs | 5 +- src/systems/components.rs | 59 ++---- src/systems/control.rs | 58 ------ src/systems/debug.rs | 2 +- src/systems/ghost.rs | 70 +++++-- src/systems/mod.rs | 2 +- src/systems/movement.rs | 421 +++++++++++++++++++++----------------- src/systems/player.rs | 143 +++++++++++++ src/systems/profiling.rs | 3 +- src/systems/render.rs | 12 +- 11 files changed, 476 insertions(+), 357 deletions(-) delete mode 100644 src/systems/control.rs create mode 100644 src/systems/player.rs diff --git a/src/game/mod.rs b/src/game/mod.rs index 895d951..2d95212 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -8,23 +8,22 @@ use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::systems::blinking::Blinking; -use crate::systems::movement::{Movable, MovementState, Position}; +use crate::systems::movement::{BufferedDirection, Position, Velocity}; +use crate::systems::player::player_movement_system; use crate::systems::profiling::SystemId; use crate::systems::{ audio::{audio_system, AudioEvent, AudioResource}, blinking::blinking_system, collision::collision_system, components::{ - AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GhostBehavior, GhostBundle, GhostCollider, GhostType, - GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, - ScoreResource, + AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Ghost, GhostBundle, GhostCollider, GlobalState, + ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, }, - control::player_system, debug::{debug_render_system, DebugState, DebugTextureResource}, - ghost::ghost_system, + ghost::ghost_movement_system, input::input_system, item::item_system, - movement::movement_system, + player::player_control_system, profiling::{profile, SystemTimings}, render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource}, }; @@ -159,16 +158,12 @@ impl Game { let player = PlayerBundle { player: PlayerControlled, - position: Position { - node: pacman_start_node, - edge_progress: None, - }, - movement_state: MovementState::Stopped, - movable: Movable { + position: Position::Stopped { node: pacman_start_node }, + velocity: Velocity { speed: 1.15, - current_direction: Direction::Left, - requested_direction: Some(Direction::Left), // Start moving left immediately + direction: Direction::Left, }, + buffered_direction: BufferedDirection::None, sprite: Renderable { sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, @@ -214,9 +209,9 @@ impl Game { schedule.add_systems( ( profile(SystemId::Input, input_system), - profile(SystemId::Player, player_system), - profile(SystemId::Ghost, ghost_system), - profile(SystemId::Movement, movement_system), + profile(SystemId::PlayerControls, player_control_system), + profile(SystemId::PlayerMovement, player_movement_system), + profile(SystemId::Ghost, ghost_movement_system), profile(SystemId::Collision, collision_system), profile(SystemId::Item, item_system), profile(SystemId::Audio, audio_system), @@ -270,10 +265,7 @@ impl Game { }; let mut item = world.spawn(ItemBundle { - position: Position { - node: node_id, - edge_progress: None, - }, + position: Position::Stopped { node: node_id }, sprite: Renderable { sprite, layer: 1, @@ -301,10 +293,10 @@ impl Game { let ghost_start_positions = { let map = world.resource::(); [ - (GhostType::Blinky, map.start_positions.blinky), - (GhostType::Pinky, map.start_positions.pinky), - (GhostType::Inky, map.start_positions.inky), - (GhostType::Clyde, map.start_positions.clyde), + (Ghost::Blinky, map.start_positions.blinky), + (Ghost::Pinky, map.start_positions.pinky), + (Ghost::Inky, map.start_positions.inky), + (Ghost::Clyde, map.start_positions.clyde), ] }; @@ -364,17 +356,11 @@ impl Game { } GhostBundle { - ghost_type, - ghost_behavior: GhostBehavior::default(), - position: Position { - node: start_node, - edge_progress: None, - }, - movement_state: MovementState::Stopped, - movable: Movable { + ghost: ghost_type, + position: Position::Stopped { node: start_node }, + velocity: Velocity { speed: ghost_type.base_speed(), - current_direction: Direction::Left, - requested_direction: Some(Direction::Left), // Start with some movement + direction: Direction::Left, }, sprite: Renderable { sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else( diff --git a/src/systems/collision.rs b/src/systems/collision.rs index cd291a7..dc3c180 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -19,7 +19,10 @@ pub fn collision_system( // Check PACMAN × ITEM collisions for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() { for (item_entity, item_pos, item_collider) in item_query.iter() { - match (pacman_pos.get_pixel_pos(&map.graph), item_pos.get_pixel_pos(&map.graph)) { + match ( + pacman_pos.get_pixel_position(&map.graph), + item_pos.get_pixel_position(&map.graph), + ) { (Ok(pacman_pixel), Ok(item_pixel)) => { // Calculate the distance between the two entities's precise pixel positions let distance = pacman_pixel.distance(item_pixel); diff --git a/src/systems/components.rs b/src/systems/components.rs index 91cec2b..5a9d4d7 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -3,7 +3,7 @@ use bitflags::bitflags; use crate::{ entity::graph::TraversalFlags, - systems::movement::{Movable, MovementState, Position}, + systems::movement::{BufferedDirection, Position, Velocity}, texture::{animated::AnimatedTexture, sprite::AtlasTile}, }; @@ -11,61 +11,42 @@ use crate::{ #[derive(Default, Component)] pub struct PlayerControlled; -/// The four classic ghost types. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] -pub enum GhostType { +pub enum Ghost { Blinky, Pinky, Inky, Clyde, } -impl GhostType { +impl Ghost { /// Returns the ghost type name for atlas lookups. pub fn as_str(self) -> &'static str { match self { - GhostType::Blinky => "blinky", - GhostType::Pinky => "pinky", - GhostType::Inky => "inky", - GhostType::Clyde => "clyde", + Ghost::Blinky => "blinky", + Ghost::Pinky => "pinky", + Ghost::Inky => "inky", + Ghost::Clyde => "clyde", } } /// Returns the base movement speed for this ghost type. pub fn base_speed(self) -> f32 { match self { - GhostType::Blinky => 1.0, - GhostType::Pinky => 0.95, - GhostType::Inky => 0.9, - GhostType::Clyde => 0.85, + Ghost::Blinky => 1.0, + Ghost::Pinky => 0.95, + Ghost::Inky => 0.9, + Ghost::Clyde => 0.85, } } /// Returns the ghost's color for debug rendering. pub fn debug_color(&self) -> sdl2::pixels::Color { match self { - GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red - GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink - GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan - GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange - } - } -} - -/// Ghost AI behavior component - controls randomized movement decisions. -#[derive(Component)] -pub struct GhostBehavior { - /// Timer for making new direction decisions - pub decision_timer: f32, - /// Interval between direction decisions (in seconds) - pub decision_interval: f32, -} - -impl Default for GhostBehavior { - fn default() -> Self { - Self { - decision_timer: 0.0, - decision_interval: 0.5, // Make decisions every half second + Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red + Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink + Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan + Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange } } } @@ -135,8 +116,8 @@ pub struct ItemCollider; pub struct PlayerBundle { pub player: PlayerControlled, pub position: Position, - pub movement_state: MovementState, - pub movable: Movable, + pub velocity: Velocity, + pub buffered_direction: BufferedDirection, pub sprite: Renderable, pub directional_animated: DirectionalAnimated, pub entity_type: EntityType, @@ -155,11 +136,9 @@ pub struct ItemBundle { #[derive(Bundle)] pub struct GhostBundle { - pub ghost_type: GhostType, - pub ghost_behavior: GhostBehavior, + pub ghost: Ghost, pub position: Position, - pub movement_state: MovementState, - pub movable: Movable, + pub velocity: Velocity, pub sprite: Renderable, pub directional_animated: DirectionalAnimated, pub entity_type: EntityType, diff --git a/src/systems/control.rs b/src/systems/control.rs deleted file mode 100644 index 84efa83..0000000 --- a/src/systems/control.rs +++ /dev/null @@ -1,58 +0,0 @@ -use bevy_ecs::{ - event::{EventReader, EventWriter}, - prelude::ResMut, - query::With, - system::Query, -}; - -use crate::{ - error::GameError, - events::{GameCommand, GameEvent}, - systems::components::{AudioState, GlobalState, PlayerControlled}, - systems::debug::DebugState, - systems::movement::Movable, -}; - -// Handles player input and control -pub fn player_system( - mut events: EventReader, - mut state: ResMut, - mut debug_state: ResMut, - mut audio_state: ResMut, - mut players: Query<&mut Movable, With>, - mut errors: EventWriter, -) { - // Get the player's movable component (ensuring there is only one player) - let mut movable = match players.single_mut() { - Ok(movable) => movable, - Err(e) => { - errors.write(GameError::InvalidState(format!( - "No/multiple entities queried for player system: {}", - e - ))); - return; - } - }; - - // Handle events - for event in events.read() { - if let GameEvent::Command(command) = event { - match command { - GameCommand::MovePlayer(direction) => { - movable.requested_direction = Some(*direction); - } - GameCommand::Exit => { - state.exit = true; - } - GameCommand::ToggleDebug => { - *debug_state = debug_state.next(); - } - GameCommand::MuteAudio => { - audio_state.muted = !audio_state.muted; - tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" }); - } - _ => {} - } - } - } -} diff --git a/src/systems/debug.rs b/src/systems/debug.rs index 428add0..f106a23 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -187,7 +187,7 @@ pub fn debug_render_system( DebugState::Collision => { debug_canvas.set_draw_color(Color::GREEN); for (collider, position) in colliders.iter() { - let pos = position.get_pixel_pos(&map.graph).unwrap(); + let pos = position.get_pixel_position(&map.graph).unwrap(); // Transform position and size using common methods let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size); diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index 65bd29f..e477d27 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -3,11 +3,11 @@ use rand::prelude::*; use smallvec::SmallVec; use crate::{ - entity::direction::Direction, + entity::{direction::Direction, graph::Edge}, map::builder::Map, systems::{ - components::{DeltaTime, EntityType, GhostBehavior, GhostType}, - movement::{Movable, Position}, + components::{DeltaTime, Ghost}, + movement::{Position, Velocity}, }, }; @@ -15,27 +15,55 @@ use crate::{ /// /// This system runs on all ghosts and makes periodic decisions about /// which direction to move in when they reach intersections. -pub fn ghost_system( +pub fn ghost_movement_system( map: Res, delta_time: Res, - mut ghosts: Query<(&mut GhostBehavior, &mut Movable, &Position, &EntityType, &GhostType)>, + mut ghosts: Query<(&mut Ghost, &mut Velocity, &mut Position)>, ) { - for (mut ghost_behavior, mut movable, position, entity_type, _ghost_type) in ghosts.iter_mut() { - // Only process ghosts - if *entity_type != EntityType::Ghost { - continue; - } + for (mut ghost, mut velocity, mut position) in ghosts.iter_mut() { + let mut distance = velocity.speed * 60.0 * delta_time.0; + loop { + match *position { + Position::Stopped { node: current_node } => { + let intersection = &map.graph.adjacency_list[current_node]; + let opposite = velocity.direction.opposite(); - // Update decision timer - ghost_behavior.decision_timer += delta_time.0; + let mut non_opposite_options: SmallVec<[Edge; 3]> = SmallVec::new(); - // Check if we should make a new direction decision - let should_decide = ghost_behavior.decision_timer >= ghost_behavior.decision_interval; - let at_intersection = position.is_at_node(); + // Collect all available directions that ghosts can traverse + for edge in Direction::DIRECTIONS.iter().flat_map(|d| intersection.get(*d)) { + if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) { + if edge.direction != opposite { + non_opposite_options.push(edge); + } + } + } - if should_decide && at_intersection { - choose_random_direction(&map, &mut movable, position); - ghost_behavior.decision_timer = 0.0; + let new_edge: Edge = if non_opposite_options.is_empty() { + if let Some(edge) = intersection.get(opposite) { + edge + } else { + break; + } + } else { + *non_opposite_options.choose(&mut SmallRng::from_os_rng()).unwrap() + }; + + velocity.direction = new_edge.direction; + *position = Position::Moving { + from: current_node, + to: new_edge.target, + remaining_distance: new_edge.distance, + }; + } + Position::Moving { .. } => { + if let Some(overflow) = position.tick(distance) { + distance = overflow; + } else { + break; + } + } + } } } } @@ -44,7 +72,7 @@ pub fn ghost_system( /// /// This function mirrors the behavior from the old ghost implementation, /// preferring not to reverse direction unless it's the only option. -fn choose_random_direction(map: &Map, movable: &mut Movable, position: &Position) { +fn choose_random_direction(map: &Map, velocity: &mut Velocity, position: &Position) { let current_node = position.current_node(); let intersection = &map.graph.adjacency_list[current_node]; @@ -64,14 +92,14 @@ fn choose_random_direction(map: &Map, movable: &mut Movable, position: &Position let mut rng = SmallRng::from_os_rng(); // Filter out the opposite direction if possible, but allow it if we have limited options - let opposite = movable.current_direction.opposite(); + let opposite = velocity.direction.opposite(); let filtered_directions: Vec<_> = available_directions .iter() .filter(|&&dir| dir != opposite || available_directions.len() <= 2) .collect(); if let Some(&random_direction) = filtered_directions.choose(&mut rng) { - movable.requested_direction = Some(*random_direction); + velocity.direction = *random_direction; } } } diff --git a/src/systems/mod.rs b/src/systems/mod.rs index a85e74b..fab80f4 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -7,12 +7,12 @@ pub mod audio; pub mod blinking; pub mod collision; pub mod components; -pub mod control; pub mod debug; pub mod formatting; pub mod ghost; pub mod input; pub mod item; pub mod movement; +pub mod player; pub mod profiling; pub mod render; diff --git a/src/systems/movement.rs b/src/systems/movement.rs index 3367ea0..ae34c28 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -2,48 +2,44 @@ use crate::entity::graph::Graph; use crate::entity::{direction::Direction, graph::Edge}; use crate::error::{EntityError, GameError, GameResult}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, EntityType}; +use crate::systems::components::{DeltaTime, EntityType, PlayerControlled}; use bevy_ecs::component::Component; use bevy_ecs::event::EventWriter; +use bevy_ecs::query::With; use bevy_ecs::system::{Query, Res}; use glam::Vec2; /// A unique identifier for a node, represented by its index in the graph's storage. pub type NodeId = usize; -/// Progress along an edge between two nodes. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct EdgeProgress { - pub target_node: NodeId, - /// Progress from 0.0 (at source node) to 1.0 (at target node) - pub progress: f32, +/// A component that represents the speed and cardinal direction of an entity. +/// Speed is static, only applied when the entity has an edge to traverse. +/// Direction is dynamic, but is controlled externally. +#[derive(Component, Debug, Copy, Clone, PartialEq)] +pub struct Velocity { + pub speed: f32, + pub direction: Direction, +} + +/// A component that represents a direction change that is only remembered for a period of time. +/// This is used to allow entities to change direction before they reach their current target node (which consumes their buffered direction). +#[derive(Component, Debug, Copy, Clone, PartialEq)] +pub enum BufferedDirection { + None, + Some { direction: Direction, remaining_time: f32 }, } /// Pure spatial position component - works for both static and dynamic entities. -#[derive(Component, Debug, Copy, Clone, PartialEq, Default)] -pub struct Position { - /// The current/primary node this entity is at or traveling from - pub node: NodeId, - /// If Some, entity is traveling between nodes. If None, entity is stationary at node. - pub edge_progress: Option, -} - -/// Explicit movement state - only for entities that can move. -#[derive(Component, Debug, Clone, Copy, PartialEq, Default)] -pub enum MovementState { - #[default] - Stopped, - Moving { - direction: Direction, +#[derive(Component, Debug, Copy, Clone, PartialEq)] +pub enum Position { + Stopped { + node: NodeId, + }, + Moving { + from: NodeId, + to: NodeId, + remaining_distance: f32, }, -} - -/// Movement capability and parameters - only for entities that can move. -#[derive(Component, Debug, Clone, Copy)] -pub struct Movable { - pub speed: f32, - pub current_direction: Direction, - pub requested_direction: Option, } impl Position { @@ -55,26 +51,32 @@ impl Position { /// # Errors /// /// Returns an `EntityError` if the node or edge is not found. - pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult { - let pos = match &self.edge_progress { - None => { + pub fn get_pixel_position(&self, graph: &Graph) -> GameResult { + let pos = match &self { + Position::Stopped { node } => { // Entity is stationary at a node - let node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; + let node = graph.get_node(*node).ok_or(EntityError::NodeNotFound(*node))?; node.position } - Some(edge_progress) => { + Position::Moving { + from, + to, + remaining_distance, + } => { // Entity is traveling between nodes - let from_node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; - let to_node = graph - .get_node(edge_progress.target_node) - .ok_or(EntityError::NodeNotFound(edge_progress.target_node))?; + 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 })?; // For zero-distance edges (tunnels), progress >= 1.0 means we're at the target - if edge_progress.progress >= 1.0 { + if edge.distance == 0.0 { to_node.position } else { // Interpolate position based on progress - from_node.position + (to_node.position - from_node.position) * edge_progress.progress + let progress = 1.0 - (*remaining_distance / edge.distance); + from_node.position.lerp(to_node.position, progress) } } }; @@ -84,183 +86,218 @@ impl Position { pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32, )) } -} -#[allow(dead_code)] -impl Position { + /// Moves the position by a given distance towards it's current target node. + /// + /// Returns the overflow distance, if any. + pub fn tick(&mut self, distance: f32) -> Option { + if distance <= 0.0 || self.is_at_node() { + return None; + } + + match self { + Position::Moving { + to, remaining_distance, .. + } => { + // If the remaining distance is less than or equal the distance, we'll reach the target + if *remaining_distance <= distance { + let overflow: Option = if *remaining_distance != distance { + Some(distance - *remaining_distance) + } else { + None + }; + *self = Position::Stopped { node: *to }; + + return overflow; + } + + *remaining_distance -= distance; + + None + } + _ => unreachable!(), + } + } + /// Returns `true` if the position is exactly at a node (not traveling). pub fn is_at_node(&self) -> bool { - self.edge_progress.is_none() + matches!(self, Position::Stopped { .. }) } /// Returns the `NodeId` of the current node (source of travel if moving). pub fn current_node(&self) -> NodeId { - self.node + match self { + Position::Stopped { node } => *node, + Position::Moving { from, .. } => *from, + } } /// Returns the `NodeId` of the destination node, if currently traveling. pub fn target_node(&self) -> Option { - self.edge_progress.as_ref().map(|ep| ep.target_node) + match self { + Position::Stopped { .. } => None, + Position::Moving { to, .. } => Some(*to), + } } /// Returns `true` if the entity is traveling between nodes. pub fn is_moving(&self) -> bool { - self.edge_progress.is_some() + matches!(self, Position::Moving { .. }) } } -fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { - let entity_flags = entity_type.traversal_flags(); - edge.traversal_flags.contains(entity_flags) -} +// pub fn movement_system( +// map: Res, +// delta_time: Res, +// mut entities: Query<(&mut Position, &mut Movable, &EntityType)>, +// mut errors: EventWriter, +// ) { +// for (mut position, mut movable, entity_type) in entities.iter_mut() { +// let distance = movable.speed * 60.0 * delta_time.0; -pub fn movement_system( - map: Res, - delta_time: Res, - mut entities: Query<(&mut MovementState, &mut Movable, &mut Position, &EntityType)>, - mut errors: EventWriter, -) { - for (mut movement_state, mut movable, mut position, entity_type) in entities.iter_mut() { - let distance = movable.speed * 60.0 * delta_time.0; +// match *position { +// Position::Stopped { .. } => { +// // Check if we have a requested direction to start moving +// if let Some(requested_direction) = movable.requested_direction { +// if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), requested_direction) { +// if can_traverse(*entity_type, edge) { +// // Start moving in the requested direction +// let progress = if edge.distance > 0.0 { +// distance / edge.distance +// } else { +// // Zero-distance edge (tunnels) - immediately teleport +// tracing::debug!( +// "Entity entering tunnel from node {} to node {}", +// position.current_node(), +// edge.target +// ); +// 1.0 +// }; - match *movement_state { - MovementState::Stopped => { - // Check if we have a requested direction to start moving - if let Some(requested_direction) = movable.requested_direction { - if let Some(edge) = map.graph.find_edge_in_direction(position.node, requested_direction) { - if can_traverse(*entity_type, edge) { - // Start moving in the requested direction - let progress = if edge.distance > 0.0 { - distance / edge.distance - } else { - // Zero-distance edge (tunnels) - immediately teleport - tracing::debug!("Entity entering tunnel from node {} to node {}", position.node, edge.target); - 1.0 - }; +// *position = Position::Moving { +// from: position.current_node(), +// to: edge.target, +// remaining_distance: progress, +// }; +// movable.current_direction = requested_direction; +// movable.requested_direction = None; +// } +// } else { +// errors.write( +// EntityError::InvalidMovement(format!( +// "No edge found in direction {:?} from node {}", +// requested_direction, +// position.current_node() +// )) +// .into(), +// ); +// } +// } +// } +// Position::Moving { +// from, +// to, +// remaining_distance, +// } => { +// // Continue moving or handle node transitions +// let current_node = *from; +// if let Some(edge) = map.graph.find_edge(current_node, *to) { +// // Extract target node before mutable operations +// let target_node = *to; - position.edge_progress = Some(EdgeProgress { - target_node: edge.target, - progress, - }); - movable.current_direction = requested_direction; - movable.requested_direction = None; - *movement_state = MovementState::Moving { - direction: requested_direction, - }; - } - } else { - errors.write( - EntityError::InvalidMovement(format!( - "No edge found in direction {:?} from node {}", - requested_direction, position.node - )) - .into(), - ); - } - } - } - MovementState::Moving { direction } => { - // Continue moving or handle node transitions - let current_node = position.node; - if let Some(edge_progress) = &mut position.edge_progress { - // Extract target node before mutable operations - let target_node = edge_progress.target_node; +// // Get the current edge for distance calculation +// let edge = map.graph.find_edge(current_node, target_node); - // Get the current edge for distance calculation - let edge = map.graph.find_edge(current_node, target_node); +// if let Some(edge) = edge { +// // Update progress along the edge +// if edge.distance > 0.0 { +// *remaining_distance += distance / edge.distance; +// } else { +// // Zero-distance edge (tunnels) - immediately complete +// *remaining_distance = 1.0; +// } - if let Some(edge) = edge { - // Update progress along the edge - if edge.distance > 0.0 { - edge_progress.progress += distance / edge.distance; - } else { - // Zero-distance edge (tunnels) - immediately complete - edge_progress.progress = 1.0; - } +// if *remaining_distance >= 1.0 { +// // Reached the target node +// let overflow = if edge.distance > 0.0 { +// (*remaining_distance - 1.0) * edge.distance +// } else { +// // Zero-distance edge - use remaining distance for overflow +// distance +// }; +// *position = Position::Stopped { node: target_node }; - if edge_progress.progress >= 1.0 { - // Reached the target node - let overflow = if edge.distance > 0.0 { - (edge_progress.progress - 1.0) * edge.distance - } else { - // Zero-distance edge - use remaining distance for overflow - distance - }; - position.node = target_node; - position.edge_progress = None; +// let mut continued_moving = false; - let mut continued_moving = false; +// // Try to use requested direction first +// if let Some(requested_direction) = movable.requested_direction { +// if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, requested_direction) { +// if can_traverse(*entity_type, next_edge) { +// let next_progress = if next_edge.distance > 0.0 { +// overflow / next_edge.distance +// } else { +// // Zero-distance edge - immediately complete +// 1.0 +// }; - // Try to use requested direction first - if let Some(requested_direction) = movable.requested_direction { - if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, requested_direction) { - if can_traverse(*entity_type, next_edge) { - let next_progress = if next_edge.distance > 0.0 { - overflow / next_edge.distance - } else { - // Zero-distance edge - immediately complete - 1.0 - }; +// *position = Position::Moving { +// from: position.current_node(), +// to: next_edge.target, +// remaining_distance: next_progress, +// }; +// movable.current_direction = requested_direction; +// movable.requested_direction = None; +// continued_moving = true; +// } +// } +// } - position.edge_progress = Some(EdgeProgress { - target_node: next_edge.target, - progress: next_progress, - }); - movable.current_direction = requested_direction; - movable.requested_direction = None; - *movement_state = MovementState::Moving { - direction: requested_direction, - }; - continued_moving = true; - } - } - } +// // If no requested direction or it failed, try to continue in current direction +// if !continued_moving { +// if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) { +// if can_traverse(*entity_type, next_edge) { +// let next_progress = if next_edge.distance > 0.0 { +// overflow / next_edge.distance +// } else { +// // Zero-distance edge - immediately complete +// 1.0 +// }; - // If no requested direction or it failed, try to continue in current direction - if !continued_moving { - if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) { - if can_traverse(*entity_type, next_edge) { - let next_progress = if next_edge.distance > 0.0 { - overflow / next_edge.distance - } else { - // Zero-distance edge - immediately complete - 1.0 - }; +// *position = Position::Moving { +// from: position.current_node(), +// to: next_edge.target, +// remaining_distance: next_progress, +// }; +// // Keep current direction and movement state +// continued_moving = true; +// } +// } +// } - position.edge_progress = Some(EdgeProgress { - target_node: next_edge.target, - progress: next_progress, - }); - // Keep current direction and movement state - continued_moving = true; - } - } - } - - // If we couldn't continue moving, stop - if !continued_moving { - *movement_state = MovementState::Stopped; - movable.requested_direction = None; - } - } - } else { - // Edge not found - this is an inconsistent state - errors.write( - EntityError::InvalidMovement(format!( - "Inconsistent state: Moving on non-existent edge from {} to {}", - current_node, target_node - )) - .into(), - ); - *movement_state = MovementState::Stopped; - position.edge_progress = None; - } - } else { - // Movement state says moving but no edge progress - this shouldn't happen - errors.write(EntityError::InvalidMovement("Entity in Moving state but no edge progress".to_string()).into()); - *movement_state = MovementState::Stopped; - } - } - } - } -} +// // If we couldn't continue moving, stop +// if !continued_moving { +// *movement_state = MovementState::Stopped; +// movable.requested_direction = None; +// } +// } +// } else { +// // Edge not found - this is an inconsistent state +// errors.write( +// EntityError::InvalidMovement(format!( +// "Inconsistent state: Moving on non-existent edge from {} to {}", +// current_node, target_node +// )) +// .into(), +// ); +// *movement_state = MovementState::Stopped; +// position.edge_progress = None; +// } +// } else { +// // Movement state says moving but no edge progress - this shouldn't happen +// errors.write(EntityError::InvalidMovement("Entity in Moving state but no edge progress".to_string()).into()); +// *movement_state = MovementState::Stopped; +// } +// } +// } +// } +// } diff --git a/src/systems/player.rs b/src/systems/player.rs new file mode 100644 index 0000000..2f6b758 --- /dev/null +++ b/src/systems/player.rs @@ -0,0 +1,143 @@ +use bevy_ecs::{ + event::{EventReader, EventWriter}, + prelude::ResMut, + query::With, + system::{Query, Res}, +}; + +use crate::{ + entity::graph::Edge, + error::GameError, + events::{GameCommand, GameEvent}, + map::builder::Map, + systems::{ + components::{AudioState, DeltaTime, EntityType, GlobalState, PlayerControlled}, + debug::DebugState, + movement::{BufferedDirection, Position, Velocity}, + }, +}; + +// Handles player input and control +pub fn player_control_system( + mut events: EventReader, + mut state: ResMut, + mut debug_state: ResMut, + mut audio_state: ResMut, + mut players: Query<(&mut BufferedDirection), With>, + mut errors: EventWriter, +) { + // Get the player's movable component (ensuring there is only one player) + let mut buffered_direction = match players.single_mut() { + Ok(buffered_direction) => buffered_direction, + Err(e) => { + errors.write(GameError::InvalidState(format!( + "No/multiple entities queried for player system: {}", + e + ))); + return; + } + }; + + // Handle events + for event in events.read() { + if let GameEvent::Command(command) = event { + match command { + GameCommand::MovePlayer(direction) => { + *buffered_direction = BufferedDirection::Some { + direction: *direction, + remaining_time: 0.25, + }; + } + GameCommand::Exit => { + state.exit = true; + } + GameCommand::ToggleDebug => { + *debug_state = debug_state.next(); + } + GameCommand::MuteAudio => { + audio_state.muted = !audio_state.muted; + tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" }); + } + _ => {} + } + } + } +} + +fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { + let entity_flags = entity_type.traversal_flags(); + edge.traversal_flags.contains(entity_flags) +} + +pub fn player_movement_system( + map: Res, + delta_time: Res, + mut entities: Query<(&mut Position, &mut Velocity, &mut BufferedDirection), With>, + mut errors: EventWriter, +) { + for (mut position, mut velocity, mut buffered_direction) in entities.iter_mut() { + // Decrement the buffered direction remaining time + if let BufferedDirection::Some { + direction, + remaining_time, + } = *buffered_direction + { + if remaining_time <= 0.0 { + *buffered_direction = BufferedDirection::None; + } else { + *buffered_direction = BufferedDirection::Some { + direction, + remaining_time: remaining_time - delta_time.0, + }; + } + } + + let mut distance = velocity.speed * 60.0 * delta_time.0; + + loop { + match *position { + Position::Stopped { .. } => { + // If there is a buffered direction, travel it's edge first if available. + if let BufferedDirection::Some { direction, .. } = *buffered_direction { + // If there's no edge in that direction, ignore the buffered direction. + if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), direction) { + // If there is an edge in that direction (and it's traversable), start moving towards it and consume the buffered direction. + if can_traverse(EntityType::Player, edge) { + velocity.direction = edge.direction; + *position = Position::Moving { + from: position.current_node(), + to: edge.target, + remaining_distance: edge.distance, + }; + *buffered_direction = BufferedDirection::None; + } + } + } + + // If there is no buffered direction (or it's not yet valid), continue in the current direction. + if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), velocity.direction) { + if can_traverse(EntityType::Player, edge) { + velocity.direction = edge.direction; + *position = Position::Moving { + from: position.current_node(), + to: edge.target, + remaining_distance: edge.distance, + }; + } + } else { + // No edge in our current direction either, erase the buffered direction and stop. + *buffered_direction = BufferedDirection::None; + break; + } + } + Position::Moving { .. } => { + if let Some(overflow) = position.tick(distance) { + distance = overflow; + } else { + break; + } + } + } + } + } +} diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 94227a4..ce5c188 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -20,7 +20,7 @@ const TIMING_WINDOW_SIZE: usize = 30; #[derive(EnumCount, IntoStaticStr, Debug, PartialEq, Eq, Hash, Copy, Clone)] pub enum SystemId { Input, - Player, + PlayerControls, Ghost, Movement, Audio, @@ -32,6 +32,7 @@ pub enum SystemId { Present, Collision, Item, + PlayerMovement, } impl Display for SystemId { diff --git a/src/systems/render.rs b/src/systems/render.rs index 50f5827..84b54c8 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -1,7 +1,7 @@ use crate::error::{GameError, TextureError}; use crate::map::builder::Map; use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable}; -use crate::systems::movement::{Movable, MovementState, Position}; +use crate::systems::movement::{Position, Velocity}; use crate::texture::sprite::SpriteAtlas; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; @@ -26,12 +26,12 @@ pub fn dirty_render_system( /// This runs before the render system so it can update the sprite based on the current direction of travel, as well as whether the entity is moving. pub fn directional_render_system( dt: Res, - mut renderables: Query<(&MovementState, &Movable, &mut DirectionalAnimated, &mut Renderable)>, + mut renderables: Query<(&Position, &Velocity, &mut DirectionalAnimated, &mut Renderable)>, mut errors: EventWriter, ) { - for (movement_state, movable, mut texture, mut renderable) in renderables.iter_mut() { - let stopped = matches!(movement_state, MovementState::Stopped); - let current_direction = movable.current_direction; + for (position, velocity, mut texture, mut renderable) in renderables.iter_mut() { + let stopped = matches!(position, Position::Stopped { .. }); + let current_direction = velocity.direction; let texture = if stopped { texture.stopped_textures[current_direction.as_usize()].as_mut() @@ -96,7 +96,7 @@ pub fn render_system( continue; } - let pos = position.get_pixel_pos(&map.graph); + let pos = position.get_pixel_position(&map.graph); match pos { Ok(pos) => { let dest = crate::helpers::centered_with_size( From 2140fbec1b018271e114035936434b6e3f45e047 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 16 Aug 2025 11:57:09 -0500 Subject: [PATCH 39/44] fix: allow key holddown --- src/systems/ghost.rs | 48 ++++------------------------- src/systems/input.rs | 67 ++++++++++++++++++++++++++++++++++++++--- src/systems/movement.rs | 9 ++---- src/systems/player.rs | 4 +-- 4 files changed, 72 insertions(+), 56 deletions(-) diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index e477d27..bd96b17 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -18,9 +18,9 @@ use crate::{ pub fn ghost_movement_system( map: Res, delta_time: Res, - mut ghosts: Query<(&mut Ghost, &mut Velocity, &mut Position)>, + mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position)>, ) { - for (mut ghost, mut velocity, mut position) in ghosts.iter_mut() { + for (_ghost, mut velocity, mut position) in ghosts.iter_mut() { let mut distance = velocity.speed * 60.0 * delta_time.0; loop { match *position { @@ -32,10 +32,10 @@ pub fn ghost_movement_system( // Collect all available directions that ghosts can traverse for edge in Direction::DIRECTIONS.iter().flat_map(|d| intersection.get(*d)) { - if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) { - if edge.direction != opposite { - non_opposite_options.push(edge); - } + if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) + && edge.direction != opposite + { + non_opposite_options.push(edge); } } @@ -67,39 +67,3 @@ pub fn ghost_movement_system( } } } - -/// Chooses a random available direction for a ghost at an intersection. -/// -/// This function mirrors the behavior from the old ghost implementation, -/// preferring not to reverse direction unless it's the only option. -fn choose_random_direction(map: &Map, velocity: &mut Velocity, position: &Position) { - let current_node = position.current_node(); - let intersection = &map.graph.adjacency_list[current_node]; - - // Collect all available directions that ghosts can traverse - let mut available_directions = SmallVec::<[Direction; 4]>::new(); - for direction in Direction::DIRECTIONS { - if let Some(edge) = intersection.get(direction) { - // Check if ghosts can traverse this edge - if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) { - available_directions.push(direction); - } - } - } - - // Choose a random direction (avoid reversing unless necessary) - if !available_directions.is_empty() { - let mut rng = SmallRng::from_os_rng(); - - // Filter out the opposite direction if possible, but allow it if we have limited options - let opposite = velocity.direction.opposite(); - let filtered_directions: Vec<_> = available_directions - .iter() - .filter(|&&dir| dir != opposite || available_directions.len() <= 2) - .collect(); - - if let Some(&random_direction) = filtered_directions.choose(&mut rng) { - velocity.direction = *random_direction; - } - } -} diff --git a/src/systems/input.rs b/src/systems/input.rs index ab62cce..0620539 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -1,6 +1,10 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; -use bevy_ecs::{event::EventWriter, prelude::Res, resource::Resource, system::NonSendMut}; +use bevy_ecs::{ + event::EventWriter, + resource::Resource, + system::{NonSendMut, ResMut}, +}; use sdl2::{event::Event, keyboard::Keycode, EventPump}; use crate::{ @@ -11,6 +15,8 @@ use crate::{ #[derive(Debug, Clone, Resource)] pub struct Bindings { key_bindings: HashMap, + movement_keys: HashSet, + last_movement_key: Option, } impl Default for Bindings { @@ -35,23 +41,74 @@ impl Default for Bindings { key_bindings.insert(Keycode::Escape, GameCommand::Exit); key_bindings.insert(Keycode::Q, GameCommand::Exit); - Self { key_bindings } + let movement_keys = HashSet::from([ + Keycode::W, + Keycode::A, + Keycode::S, + Keycode::D, + Keycode::Up, + Keycode::Down, + Keycode::Left, + Keycode::Right, + ]); + + Self { + key_bindings, + movement_keys, + last_movement_key: None, + } } } -pub fn input_system(bindings: Res, mut writer: EventWriter, mut pump: NonSendMut<&'static mut EventPump>) { +pub fn input_system( + mut bindings: ResMut, + mut writer: EventWriter, + mut pump: NonSendMut<&'static mut EventPump>, +) { + let mut movement_key_pressed = false; + for event in pump.poll_iter() { match event { Event::Quit { .. } => { writer.write(GameEvent::Command(GameCommand::Exit)); } - Event::KeyDown { keycode: Some(key), .. } => { + Event::KeyUp { + repeat: false, + keycode: Some(key), + .. + } => { + // If the last movement key was released, then forget it. + if let Some(last_movement_key) = bindings.last_movement_key { + if last_movement_key == key { + bindings.last_movement_key = None; + } + } + } + Event::KeyDown { + keycode: Some(key), + repeat: false, + .. + } => { let command = bindings.key_bindings.get(&key).copied(); if let Some(command) = command { writer.write(GameEvent::Command(command)); } + + if bindings.movement_keys.contains(&key) { + movement_key_pressed = true; + bindings.last_movement_key = Some(key); + } } _ => {} } } + + if let Some(last_movement_key) = bindings.last_movement_key { + if !movement_key_pressed { + let command = bindings.key_bindings.get(&last_movement_key).copied(); + if let Some(command) = command { + writer.write(GameEvent::Command(command)); + } + } + } } diff --git a/src/systems/movement.rs b/src/systems/movement.rs index ae34c28..0b9c522 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -1,12 +1,7 @@ +use crate::entity::direction::Direction; use crate::entity::graph::Graph; -use crate::entity::{direction::Direction, graph::Edge}; -use crate::error::{EntityError, GameError, GameResult}; -use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, EntityType, PlayerControlled}; +use crate::error::{EntityError, GameResult}; use bevy_ecs::component::Component; -use bevy_ecs::event::EventWriter; -use bevy_ecs::query::With; -use bevy_ecs::system::{Query, Res}; use glam::Vec2; /// A unique identifier for a node, represented by its index in the graph's storage. diff --git a/src/systems/player.rs b/src/systems/player.rs index 2f6b758..6673c4a 100644 --- a/src/systems/player.rs +++ b/src/systems/player.rs @@ -23,7 +23,7 @@ pub fn player_control_system( mut state: ResMut, mut debug_state: ResMut, mut audio_state: ResMut, - mut players: Query<(&mut BufferedDirection), With>, + mut players: Query<&mut BufferedDirection, With>, mut errors: EventWriter, ) { // Get the player's movable component (ensuring there is only one player) @@ -73,7 +73,7 @@ pub fn player_movement_system( map: Res, delta_time: Res, mut entities: Query<(&mut Position, &mut Velocity, &mut BufferedDirection), With>, - mut errors: EventWriter, + // mut errors: EventWriter, ) { for (mut position, mut velocity, mut buffered_direction) in entities.iter_mut() { // Decrement the buffered direction remaining time From 90adaf9e84ad17eb033c056964e5d1b6c86313ff Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 16 Aug 2025 12:26:24 -0500 Subject: [PATCH 40/44] feat: add cursor-based node highlighting for debug --- src/game/mod.rs | 3 +- src/systems/debug.rs | 78 +++++++++++++++++++++++++++++++++++------ src/systems/input.rs | 6 ++++ src/systems/movement.rs | 13 ------- 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/src/game/mod.rs b/src/game/mod.rs index 2d95212..0ac0568 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -47,7 +47,7 @@ use crate::{ constants, events::GameCommand, map::render::MapRenderer, - systems::input::Bindings, + systems::{debug::CursorPosition, input::Bindings}, texture::sprite::{AtlasMapper, SpriteAtlas}, }; @@ -198,6 +198,7 @@ impl Game { world.insert_resource(RenderDirty::default()); world.insert_resource(DebugState::default()); world.insert_resource(AudioState::default()); + world.insert_resource(CursorPosition::default()); world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| { diff --git a/src/systems/debug.rs b/src/systems/debug.rs index f106a23..6897679 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -1,4 +1,6 @@ //! Debug rendering system +use std::cmp::Ordering; + use crate::constants::BOARD_PIXEL_OFFSET; use crate::map::builder::Map; use crate::systems::components::Collider; @@ -6,11 +8,15 @@ use crate::systems::movement::Position; use crate::systems::profiling::SystemTimings; use crate::systems::render::BackbufferResource; use bevy_ecs::prelude::*; +use glam::Vec2; use sdl2::pixels::Color; use sdl2::rect::Rect; use sdl2::render::{Canvas, Texture, TextureCreator}; use sdl2::video::{Window, WindowContext}; +#[derive(Resource, Default, Debug, Copy, Clone)] +pub struct CursorPosition(pub Vec2); + #[derive(Resource, Default, Debug, Copy, Clone, PartialEq)] pub enum DebugState { #[default] @@ -36,10 +42,13 @@ pub struct DebugTextureResource(pub Texture<'static>); fn transform_position(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { let scale_x = output_size.0 as f32 / logical_size.0 as f32; let scale_y = output_size.1 as f32 / logical_size.1 as f32; - let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio + let scale = scale_x.min(scale_y); - let x = (pos.0 * scale) as i32; - let y = (pos.1 * scale) as i32; + let offset_x = (output_size.0 as f32 - logical_size.0 as f32 * scale) / 2.0; + let offset_y = (output_size.1 as f32 - logical_size.1 as f32 * scale) / 2.0; + + let x = (pos.0 * scale + offset_x) as i32; + let y = (pos.1 * scale + offset_y) as i32; (x, y) } @@ -47,10 +56,13 @@ fn transform_position(pos: (f32, f32), output_size: (u32, u32), logical_size: (u fn transform_position_with_offset(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { let scale_x = output_size.0 as f32 / logical_size.0 as f32; let scale_y = output_size.1 as f32 / logical_size.1 as f32; - let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio + let scale = scale_x.min(scale_y); - let x = ((pos.0 + BOARD_PIXEL_OFFSET.x as f32) * scale) as i32; - let y = ((pos.1 + BOARD_PIXEL_OFFSET.y as f32) * scale) as i32; + let offset_x = (output_size.0 as f32 - logical_size.0 as f32 * scale) / 2.0; + let offset_y = (output_size.1 as f32 - logical_size.1 as f32 * scale) / 2.0; + + let x = ((pos.0 + BOARD_PIXEL_OFFSET.x as f32) * scale + offset_x) as i32; + let y = ((pos.1 + BOARD_PIXEL_OFFSET.y as f32) * scale + offset_y) as i32; (x, y) } @@ -129,6 +141,7 @@ pub fn debug_render_system( timings: Res, map: Res, colliders: Query<(&Collider, &Position)>, + cursor: Res, ) { if *debug_state == DebugState::Off { return; @@ -153,28 +166,49 @@ pub fn debug_render_system( // Get texture creator before entering the closure to avoid borrowing conflicts let mut texture_creator = canvas.texture_creator(); + let cursor_world_pos = cursor.0 - BOARD_PIXEL_OFFSET.as_vec2(); + // Draw debug info on the high-resolution debug texture canvas .with_texture_canvas(&mut debug_texture.0, |debug_canvas| { match *debug_state { DebugState::Graph => { + // Find the closest node to the cursor + + let closest_node = map + .graph + .nodes() + .map(|node| node.position.distance(cursor_world_pos)) + .enumerate() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less)) + .map(|(id, _)| id); + debug_canvas.set_draw_color(Color::RED); for (start_node, end_node) in map.graph.edges() { - let start_node = map.graph.get_node(start_node).unwrap().position; + let start_node_model = map.graph.get_node(start_node).unwrap(); let end_node = map.graph.get_node(end_node.target).unwrap().position; // Transform positions using common method - let (start_x, start_y) = - transform_position_with_offset((start_node.x, start_node.y), output_size, logical_size); + let (start_x, start_y) = transform_position_with_offset( + (start_node_model.position.x, start_node_model.position.y), + output_size, + logical_size, + ); let (end_x, end_y) = transform_position_with_offset((end_node.x, end_node.y), output_size, logical_size); debug_canvas.draw_line((start_x, start_y), (end_x, end_y)).unwrap(); } - debug_canvas.set_draw_color(Color::BLUE); - for node in map.graph.nodes() { + for (id, node) in map.graph.nodes().enumerate() { let pos = node.position; + // Set color based on whether the node is the closest to the cursor + if Some(id) == closest_node { + debug_canvas.set_draw_color(Color::YELLOW); + } else { + debug_canvas.set_draw_color(Color::BLUE); + } + // Transform position using common method let (x, y) = transform_position_with_offset((pos.x, pos.y), output_size, logical_size); let size = transform_size(4.0, output_size, logical_size); @@ -201,6 +235,28 @@ pub fn debug_render_system( _ => {} } + // Render node ID if a node is highlighted + if let DebugState::Graph = *debug_state { + if let Some(closest_node_id) = map + .graph + .nodes() + .map(|node| node.position.distance(cursor_world_pos)) + .enumerate() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less)) + .map(|(id, _)| id) + { + let node = map.graph.get_node(closest_node_id).unwrap(); + let (x, y) = transform_position_with_offset((node.position.x, node.position.y), output_size, logical_size); + + let ttf_context = sdl2::ttf::init().unwrap(); + let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap(); + let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap(); + let texture = texture_creator.create_texture_from_surface(&surface).unwrap(); + let dest = Rect::new(x + 10, y - 5, texture.query().width, texture.query().height); + debug_canvas.copy(&texture, None, dest).unwrap(); + } + } + // Render timing information in the top-left corner render_timing_display(debug_canvas, &mut texture_creator, &timings); }) diff --git a/src/systems/input.rs b/src/systems/input.rs index 0620539..44d4762 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -5,8 +5,10 @@ use bevy_ecs::{ resource::Resource, system::{NonSendMut, ResMut}, }; +use glam::Vec2; use sdl2::{event::Event, keyboard::Keycode, EventPump}; +use crate::systems::debug::CursorPosition; use crate::{ entity::direction::Direction, events::{GameCommand, GameEvent}, @@ -64,6 +66,7 @@ pub fn input_system( mut bindings: ResMut, mut writer: EventWriter, mut pump: NonSendMut<&'static mut EventPump>, + mut cursor: ResMut, ) { let mut movement_key_pressed = false; @@ -72,6 +75,9 @@ pub fn input_system( Event::Quit { .. } => { writer.write(GameEvent::Command(GameCommand::Exit)); } + Event::MouseMotion { x, y, .. } => { + cursor.0 = Vec2::new(x as f32, y as f32); + } Event::KeyUp { repeat: false, keycode: Some(key), diff --git a/src/systems/movement.rs b/src/systems/movement.rs index 0b9c522..60195b4 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -126,19 +126,6 @@ impl Position { Position::Moving { from, .. } => *from, } } - - /// Returns the `NodeId` of the destination node, if currently traveling. - pub fn target_node(&self) -> Option { - match self { - Position::Stopped { .. } => None, - Position::Moving { to, .. } => Some(*to), - } - } - - /// Returns `true` if the entity is traveling between nodes. - pub fn is_moving(&self) -> bool { - matches!(self, Position::Moving { .. }) - } } // pub fn movement_system( From f940f01d9b94025f17622c3339cb8f309f9bff1d Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 16 Aug 2025 13:41:15 -0500 Subject: [PATCH 41/44] refactor: optimize debug system, remove redundant code & tests --- src/entity/graph.rs | 5 -- src/game/mod.rs | 2 +- src/map/builder.rs | 17 ----- src/map/render.rs | 113 +------------------------------ src/systems/debug.rs | 141 ++++++++++++++------------------------- src/systems/input.rs | 31 +++++++-- tests/debug_rendering.rs | 34 ---------- tests/game.rs | 12 ---- tests/graph.rs | 4 +- tests/map_builder.rs | 2 +- 10 files changed, 82 insertions(+), 279 deletions(-) delete mode 100644 tests/debug_rendering.rs delete mode 100644 tests/game.rs diff --git a/src/entity/graph.rs b/src/entity/graph.rs index c5416cd..27e138b 100644 --- a/src/entity/graph.rs +++ b/src/entity/graph.rs @@ -218,11 +218,6 @@ impl Graph { self.nodes.get(id) } - /// Returns the total number of nodes in the graph. - pub fn node_count(&self) -> usize { - self.nodes.len() - } - /// Returns an iterator over all nodes in the graph. pub fn nodes(&self) -> impl Iterator { self.nodes.iter() diff --git a/src/game/mod.rs b/src/game/mod.rs index 0ac0568..22e7c9d 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -47,7 +47,7 @@ use crate::{ constants, events::GameCommand, map::render::MapRenderer, - systems::{debug::CursorPosition, input::Bindings}, + systems::input::{Bindings, CursorPosition}, texture::sprite::{AtlasMapper, SpriteAtlas}, }; diff --git a/src/map/builder.rs b/src/map/builder.rs index fe1a9d5..2841230 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -3,12 +3,9 @@ use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::entity::direction::Direction; use crate::entity::graph::{Graph, Node, TraversalFlags}; use crate::map::parser::MapTileParser; -use crate::map::render::MapRenderer; use crate::systems::movement::NodeId; -use crate::texture::sprite::SpriteAtlas; use bevy_ecs::resource::Resource; use glam::{IVec2, Vec2}; -use sdl2::render::{Canvas, RenderTarget}; use std::collections::{HashMap, VecDeque}; use tracing::debug; @@ -165,20 +162,6 @@ impl Map { }) } - /// Renders a debug visualization with cursor-based highlighting. - /// - /// This function provides interactive debugging by highlighting the nearest node - /// to the cursor, showing its ID, and highlighting its connections. - pub fn debug_render_with_cursor( - &self, - canvas: &mut Canvas, - text_renderer: &mut crate::texture::text::TextTexture, - atlas: &mut SpriteAtlas, - cursor_pos: glam::Vec2, - ) -> GameResult<()> { - MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos) - } - /// Builds the house structure in the graph. fn build_house( graph: &mut Graph, diff --git a/src/map/render.rs b/src/map/render.rs index cf86766..8016d4c 100644 --- a/src/map/render.rs +++ b/src/map/render.rs @@ -3,14 +3,10 @@ use crate::constants::{BOARD_CELL_OFFSET, CELL_SIZE}; use crate::map::layout::TILE_MAP; use crate::texture::sprite::{AtlasTile, SpriteAtlas}; -use crate::texture::text::TextTexture; -use glam::Vec2; use sdl2::pixels::Color; -use sdl2::rect::{Point, Rect}; +use sdl2::rect::Rect; use sdl2::render::{Canvas, RenderTarget}; -use crate::error::{EntityError, GameError, GameResult}; - /// Handles rendering operations for the map. pub struct MapRenderer; @@ -37,111 +33,4 @@ impl MapRenderer { } } } - - /// Renders a debug visualization with cursor-based highlighting. - /// - /// This function provides interactive debugging by highlighting the nearest node - /// to the cursor, showing its ID, and highlighting its connections. - pub fn debug_render_with_cursor( - graph: &crate::entity::graph::Graph, - canvas: &mut Canvas, - text_renderer: &mut TextTexture, - atlas: &mut SpriteAtlas, - cursor_pos: Vec2, - ) -> GameResult<()> { - // Find the nearest node to the cursor - let nearest_node = Self::find_nearest_node(graph, cursor_pos); - - // Draw all connections in blue - canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections - for i in 0..graph.node_count() { - let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?; - let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - - for edge in graph.adjacency_list[i].edges() { - let end_pos = graph - .get_node(edge.target) - .ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))? - .position - + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - canvas - .draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32)) - .map_err(|e| GameError::Sdl(e.to_string()))?; - } - } - - // Draw all nodes in green - canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes - for i in 0..graph.node_count() { - let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?; - let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - - canvas - .fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32))) - .map_err(|e| GameError::Sdl(e.to_string()))?; - } - - // Highlight connections from the nearest node in bright blue - if let Some(nearest_id) = nearest_node { - let nearest_pos = graph - .get_node(nearest_id) - .ok_or(GameError::Entity(EntityError::NodeNotFound(nearest_id)))? - .position - + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - - canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections - for edge in graph.adjacency_list[nearest_id].edges() { - let end_pos = graph - .get_node(edge.target) - .ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))? - .position - + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - canvas - .draw_line( - (nearest_pos.x as i32, nearest_pos.y as i32), - (end_pos.x as i32, end_pos.y as i32), - ) - .map_err(|e| GameError::Sdl(e.to_string()))?; - } - - // Highlight the nearest node in bright green - canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node - canvas - .fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32))) - .map_err(|e| GameError::Sdl(e.to_string()))?; - - // Draw node ID text (small, offset to top right) - text_renderer.set_scale(0.5); // Small text - let id_text = format!("#{nearest_id}"); - let text_pos = glam::UVec2::new( - (nearest_pos.x + 4.0) as u32, // Offset to the right - (nearest_pos.y - 6.0) as u32, // Offset to the top - ); - if let Err(e) = text_renderer.render(canvas, atlas, &id_text, text_pos) { - tracing::error!("Failed to render node ID text: {}", e); - } - } - - Ok(()) - } - - /// Finds the nearest node to the given cursor position. - pub fn find_nearest_node(graph: &crate::entity::graph::Graph, cursor_pos: Vec2) -> Option { - let mut nearest_id = None; - let mut nearest_distance = f32::INFINITY; - - for i in 0..graph.node_count() { - if let Some(node) = graph.get_node(i) { - let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - let distance = cursor_pos.distance(node_pos); - - if distance < nearest_distance { - nearest_distance = distance; - nearest_id = Some(i); - } - } - } - - nearest_id - } } diff --git a/src/systems/debug.rs b/src/systems/debug.rs index 6897679..cf699ac 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -4,19 +4,17 @@ use std::cmp::Ordering; use crate::constants::BOARD_PIXEL_OFFSET; use crate::map::builder::Map; use crate::systems::components::Collider; +use crate::systems::input::CursorPosition; use crate::systems::movement::Position; use crate::systems::profiling::SystemTimings; use crate::systems::render::BackbufferResource; use bevy_ecs::prelude::*; -use glam::Vec2; +use glam::{IVec2, UVec2, Vec2}; use sdl2::pixels::Color; -use sdl2::rect::Rect; +use sdl2::rect::{Point, Rect}; use sdl2::render::{Canvas, Texture, TextureCreator}; use sdl2::video::{Window, WindowContext}; -#[derive(Resource, Default, Debug, Copy, Clone)] -pub struct CursorPosition(pub Vec2); - #[derive(Resource, Default, Debug, Copy, Clone, PartialEq)] pub enum DebugState { #[default] @@ -38,41 +36,9 @@ impl DebugState { /// Resource to hold the debug texture for persistent rendering pub struct DebugTextureResource(pub Texture<'static>); -/// Transforms a position from logical canvas coordinates to output canvas coordinates -fn transform_position(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { - let scale_x = output_size.0 as f32 / logical_size.0 as f32; - let scale_y = output_size.1 as f32 / logical_size.1 as f32; - let scale = scale_x.min(scale_y); - - let offset_x = (output_size.0 as f32 - logical_size.0 as f32 * scale) / 2.0; - let offset_y = (output_size.1 as f32 - logical_size.1 as f32 * scale) / 2.0; - - let x = (pos.0 * scale + offset_x) as i32; - let y = (pos.1 * scale + offset_y) as i32; - (x, y) -} - /// Transforms a position from logical canvas coordinates to output canvas coordinates (with board offset) -fn transform_position_with_offset(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { - let scale_x = output_size.0 as f32 / logical_size.0 as f32; - let scale_y = output_size.1 as f32 / logical_size.1 as f32; - let scale = scale_x.min(scale_y); - - let offset_x = (output_size.0 as f32 - logical_size.0 as f32 * scale) / 2.0; - let offset_y = (output_size.1 as f32 - logical_size.1 as f32 * scale) / 2.0; - - let x = ((pos.0 + BOARD_PIXEL_OFFSET.x as f32) * scale + offset_x) as i32; - let y = ((pos.1 + BOARD_PIXEL_OFFSET.y as f32) * scale + offset_y) as i32; - (x, y) -} - -/// Transforms a size from logical canvas coordinates to output canvas coordinates -fn transform_size(size: f32, output_size: (u32, u32), logical_size: (u32, u32)) -> u32 { - let scale_x = output_size.0 as f32 / logical_size.0 as f32; - let scale_y = output_size.1 as f32 / logical_size.1 as f32; - let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio - - (size * scale) as u32 +fn transform_position_with_offset(pos: Vec2, scale: f32) -> IVec2 { + ((pos + BOARD_PIXEL_OFFSET.as_vec2()) * scale).as_ivec2() } /// Renders timing information in the top-left corner of the screen @@ -133,6 +99,7 @@ fn render_timing_display( } } +#[allow(clippy::too_many_arguments)] pub fn debug_render_system( mut canvas: NonSendMut<&mut Canvas>, backbuffer: NonSendMut, @@ -146,10 +113,8 @@ pub fn debug_render_system( if *debug_state == DebugState::Off { return; } - - // Get canvas sizes for coordinate transformation - let output_size = canvas.output_size().unwrap(); - let logical_size = canvas.logical_size(); + let scale = + (UVec2::from(canvas.output_size().unwrap()).as_vec2() / UVec2::from(canvas.logical_size()).as_vec2()).min_element(); // Copy the current backbuffer to the debug texture canvas @@ -166,7 +131,10 @@ pub fn debug_render_system( // Get texture creator before entering the closure to avoid borrowing conflicts let mut texture_creator = canvas.texture_creator(); - let cursor_world_pos = cursor.0 - BOARD_PIXEL_OFFSET.as_vec2(); + let cursor_world_pos = match *cursor { + CursorPosition::None => None, + CursorPosition::Some { position, .. } => Some(position - BOARD_PIXEL_OFFSET.as_vec2()), + }; // Draw debug info on the high-resolution debug texture canvas @@ -175,13 +143,16 @@ pub fn debug_render_system( DebugState::Graph => { // Find the closest node to the cursor - let closest_node = map - .graph - .nodes() - .map(|node| node.position.distance(cursor_world_pos)) - .enumerate() - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less)) - .map(|(id, _)| id); + let closest_node = if let Some(cursor_world_pos) = cursor_world_pos { + map.graph + .nodes() + .map(|node| node.position.distance(cursor_world_pos)) + .enumerate() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less)) + .map(|(id, _)| id) + } else { + None + }; debug_canvas.set_draw_color(Color::RED); for (start_node, end_node) in map.graph.edges() { @@ -189,34 +160,45 @@ pub fn debug_render_system( let end_node = map.graph.get_node(end_node.target).unwrap().position; // Transform positions using common method - let (start_x, start_y) = transform_position_with_offset( - (start_node_model.position.x, start_node_model.position.y), - output_size, - logical_size, - ); - let (end_x, end_y) = transform_position_with_offset((end_node.x, end_node.y), output_size, logical_size); + let start = transform_position_with_offset(start_node_model.position, scale); + let end = transform_position_with_offset(end_node, scale); - debug_canvas.draw_line((start_x, start_y), (end_x, end_y)).unwrap(); + debug_canvas + .draw_line(Point::from((start.x, start.y)), Point::from((end.x, end.y))) + .unwrap(); } for (id, node) in map.graph.nodes().enumerate() { let pos = node.position; // Set color based on whether the node is the closest to the cursor - if Some(id) == closest_node { - debug_canvas.set_draw_color(Color::YELLOW); + debug_canvas.set_draw_color(if Some(id) == closest_node { + Color::YELLOW } else { - debug_canvas.set_draw_color(Color::BLUE); - } + Color::BLUE + }); // Transform position using common method - let (x, y) = transform_position_with_offset((pos.x, pos.y), output_size, logical_size); - let size = transform_size(4.0, output_size, logical_size); + let pos = transform_position_with_offset(pos, scale); + let size = (3.0 * scale) as u32; debug_canvas - .fill_rect(Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size)) + .fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size)) .unwrap(); } + + // Render node ID if a node is highlighted + if let Some(closest_node_id) = closest_node { + let node = map.graph.get_node(closest_node_id).unwrap(); + let pos = transform_position_with_offset(node.position, scale); + + let ttf_context = sdl2::ttf::init().unwrap(); + let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap(); + let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap(); + let texture = texture_creator.create_texture_from_surface(&surface).unwrap(); + let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height); + debug_canvas.copy(&texture, None, dest).unwrap(); + } } DebugState::Collision => { debug_canvas.set_draw_color(Color::GREEN); @@ -224,39 +206,16 @@ pub fn debug_render_system( let pos = position.get_pixel_position(&map.graph).unwrap(); // Transform position and size using common methods - let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size); - let size = transform_size(collider.size, output_size, logical_size); + let pos = (pos * scale).as_ivec2(); + let size = (collider.size * scale) as u32; - // Center the collision box on the entity - let rect = Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size); + let rect = Rect::from_center(Point::from((pos.x, pos.y)), size, size); debug_canvas.draw_rect(rect).unwrap(); } } _ => {} } - // Render node ID if a node is highlighted - if let DebugState::Graph = *debug_state { - if let Some(closest_node_id) = map - .graph - .nodes() - .map(|node| node.position.distance(cursor_world_pos)) - .enumerate() - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less)) - .map(|(id, _)| id) - { - let node = map.graph.get_node(closest_node_id).unwrap(); - let (x, y) = transform_position_with_offset((node.position.x, node.position.y), output_size, logical_size); - - let ttf_context = sdl2::ttf::init().unwrap(); - let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap(); - let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap(); - let texture = texture_creator.create_texture_from_surface(&surface).unwrap(); - let dest = Rect::new(x + 10, y - 5, texture.query().width, texture.query().height); - debug_canvas.copy(&texture, None, dest).unwrap(); - } - } - // Render timing information in the top-left corner render_timing_display(debug_canvas, &mut texture_creator, &timings); }) diff --git a/src/systems/input.rs b/src/systems/input.rs index 44d4762..4ec59ca 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -3,18 +3,28 @@ use std::collections::{HashMap, HashSet}; use bevy_ecs::{ event::EventWriter, resource::Resource, - system::{NonSendMut, ResMut}, + system::{NonSendMut, Res, ResMut}, }; use glam::Vec2; use sdl2::{event::Event, keyboard::Keycode, EventPump}; -use crate::systems::debug::CursorPosition; +use crate::systems::components::DeltaTime; use crate::{ entity::direction::Direction, events::{GameCommand, GameEvent}, }; -#[derive(Debug, Clone, Resource)] +#[derive(Resource, Default, Debug, Copy, Clone)] +pub enum CursorPosition { + #[default] + None, + Some { + position: Vec2, + remaining_time: f32, + }, +} + +#[derive(Resource, Debug, Clone)] pub struct Bindings { key_bindings: HashMap, movement_keys: HashSet, @@ -63,12 +73,14 @@ impl Default for Bindings { } pub fn input_system( + delta_time: Res, mut bindings: ResMut, mut writer: EventWriter, mut pump: NonSendMut<&'static mut EventPump>, mut cursor: ResMut, ) { let mut movement_key_pressed = false; + let mut cursor_seen = false; for event in pump.poll_iter() { match event { @@ -76,7 +88,11 @@ pub fn input_system( writer.write(GameEvent::Command(GameCommand::Exit)); } Event::MouseMotion { x, y, .. } => { - cursor.0 = Vec2::new(x as f32, y as f32); + *cursor = CursorPosition::Some { + position: Vec2::new(x as f32, y as f32), + remaining_time: 0.20, + }; + cursor_seen = true; } Event::KeyUp { repeat: false, @@ -117,4 +133,11 @@ pub fn input_system( } } } + + if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) { + *remaining_time -= delta_time.0; + if *remaining_time <= 0.0 { + *cursor = CursorPosition::None; + } + } } diff --git a/tests/debug_rendering.rs b/tests/debug_rendering.rs deleted file mode 100644 index a91dea4..0000000 --- a/tests/debug_rendering.rs +++ /dev/null @@ -1,34 +0,0 @@ -use glam::Vec2; -use pacman::entity::graph::{Graph, Node}; -use pacman::map::render::MapRenderer; - -#[test] -fn test_find_nearest_node() { - let mut graph = Graph::new(); - - // Add some test nodes - let node1 = graph.add_node(Node { - position: Vec2::new(10.0, 10.0), - }); - let node2 = graph.add_node(Node { - position: Vec2::new(50.0, 50.0), - }); - let node3 = graph.add_node(Node { - position: Vec2::new(100.0, 100.0), - }); - - // Test cursor near node1 - let cursor_pos = Vec2::new(12.0, 8.0); - let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos); - assert_eq!(nearest, Some(node1)); - - // Test cursor near node2 - let cursor_pos = Vec2::new(45.0, 55.0); - let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos); - assert_eq!(nearest, Some(node2)); - - // Test cursor near node3 - let cursor_pos = Vec2::new(98.0, 102.0); - let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos); - assert_eq!(nearest, Some(node3)); -} diff --git a/tests/game.rs b/tests/game.rs deleted file mode 100644 index aa755fd..0000000 --- a/tests/game.rs +++ /dev/null @@ -1,12 +0,0 @@ -use pacman::constants::RAW_BOARD; -use pacman::map::builder::Map; - -mod item; - -#[test] -fn test_game_map_creation() { - let map = Map::new(RAW_BOARD).unwrap(); - - assert!(map.graph.node_count() > 0); - assert!(!map.grid_to_node.is_empty()); -} diff --git a/tests/graph.rs b/tests/graph.rs index bb888c3..7f4fc06 100644 --- a/tests/graph.rs +++ b/tests/graph.rs @@ -29,7 +29,7 @@ fn test_graph_basic_operations() { position: glam::Vec2::new(16.0, 0.0), }); - assert_eq!(graph.node_count(), 2); + assert_eq!(graph.nodes().count(), 2); assert!(graph.get_node(node1).is_some()); assert!(graph.get_node(node2).is_some()); assert!(graph.get_node(999).is_none()); @@ -102,7 +102,7 @@ fn should_add_connected_node() { ) .unwrap(); - assert_eq!(graph.node_count(), 2); + assert_eq!(graph.nodes().count(), 2); let edge = graph.find_edge(node1, node2); assert!(edge.is_some()); assert_eq!(edge.unwrap().direction, Direction::Right); diff --git a/tests/map_builder.rs b/tests/map_builder.rs index e67a17a..33423d0 100644 --- a/tests/map_builder.rs +++ b/tests/map_builder.rs @@ -6,7 +6,7 @@ use pacman::map::builder::Map; fn test_map_creation() { let map = Map::new(RAW_BOARD).unwrap(); - assert!(map.graph.node_count() > 0); + assert!(map.graph.nodes().count() > 0); assert!(!map.grid_to_node.is_empty()); // Check that some connections were made From 313ca4f3e64789e071399d7e012942d1e23d7392 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 16 Aug 2025 14:17:28 -0500 Subject: [PATCH 42/44] fix: proper font loading, cross platform assets, better platform independent trait implementation, conditional modules --- assets/{site => game}/TerminalVector.ttf | Bin src/asset.rs | 6 +- src/game/mod.rs | 93 ++++++++++++----------- src/platform/desktop.rs | 9 ++- src/platform/emscripten.rs | 6 +- src/platform/mod.rs | 17 ++--- src/systems/debug.rs | 17 ++--- tests/common/mod.rs | 2 +- web.build.ts | 21 ++--- 9 files changed, 89 insertions(+), 82 deletions(-) rename assets/{site => game}/TerminalVector.ttf (100%) diff --git a/assets/site/TerminalVector.ttf b/assets/game/TerminalVector.ttf similarity index 100% rename from assets/site/TerminalVector.ttf rename to assets/game/TerminalVector.ttf diff --git a/src/asset.rs b/src/asset.rs index 27c22de..545f96f 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -11,7 +11,8 @@ pub enum Asset { Wav2, Wav3, Wav4, - Atlas, + AtlasImage, + Font, } impl Asset { @@ -23,7 +24,8 @@ impl Asset { Wav2 => "sound/waka/2.ogg", Wav3 => "sound/waka/3.ogg", Wav4 => "sound/waka/4.ogg", - Atlas => "atlas.png", + AtlasImage => "atlas.png", + Font => "TerminalVector.ttf", } } } diff --git a/src/game/mod.rs b/src/game/mod.rs index 22e7c9d..a87bad5 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -19,7 +19,7 @@ use crate::systems::{ AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Ghost, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, }, - debug::{debug_render_system, DebugState, DebugTextureResource}, + debug::{debug_render_system, DebugFontResource, DebugState, DebugTextureResource}, ghost::ghost_movement_system, input::input_system, item::item_system, @@ -28,17 +28,14 @@ use crate::systems::{ render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource}, }; use crate::texture::animated::AnimatedTexture; -use bevy_ecs::schedule::IntoScheduleConfigs; -use bevy_ecs::system::NonSendMut; -use bevy_ecs::{ - event::EventRegistry, - observer::Trigger, - schedule::Schedule, - system::{Res, ResMut}, - world::World, -}; +use bevy_ecs::event::EventRegistry; +use bevy_ecs::observer::Trigger; +use bevy_ecs::schedule::Schedule; +use bevy_ecs::system::{NonSendMut, Res, ResMut}; +use bevy_ecs::world::World; use sdl2::image::LoadTexture; use sdl2::render::{Canvas, ScaleMode, TextureCreator}; +use sdl2::rwops::RWops; use sdl2::video::{Window, WindowContext}; use sdl2::EventPump; @@ -70,6 +67,7 @@ impl Game { ) -> GameResult { let mut world = World::default(); let mut schedule = Schedule::default(); + let ttf_context = Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?)); EventRegistry::register_event::(&mut world); EventRegistry::register_event::(&mut world); @@ -92,11 +90,18 @@ impl Game { .map_err(|e| GameError::Sdl(e.to_string()))?; debug_texture.set_scale_mode(ScaleMode::Nearest); + let font_data = get_asset_bytes(Asset::Font)?; + let static_font_data: &'static [u8] = Box::leak(font_data.to_vec().into_boxed_slice()); + let font_asset = RWops::from_bytes(static_font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?; + let debug_font = ttf_context + .load_font_from_rwops(font_asset, 12) + .map_err(|e| GameError::Sdl(e.to_string()))?; + // Initialize audio system let audio = crate::audio::Audio::new(); // Load atlas and create map texture - let atlas_bytes = get_asset_bytes(Asset::Atlas)?; + let atlas_bytes = get_asset_bytes(Asset::AtlasImage)?; let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { if e.to_string().contains("format") || e.to_string().contains("unsupported") { GameError::Texture(crate::error::TextureError::InvalidFormat(format!( @@ -187,6 +192,7 @@ impl Game { world.insert_non_send_resource(BackbufferResource(backbuffer)); world.insert_non_send_resource(MapTextureResource(map_texture)); world.insert_non_send_resource(DebugTextureResource(debug_texture)); + world.insert_non_send_resource(DebugFontResource(debug_font)); world.insert_non_send_resource(AudioResource(audio)); world.insert_resource(map); @@ -207,40 +213,37 @@ impl Game { } }, ); - schedule.add_systems( - ( - profile(SystemId::Input, input_system), - profile(SystemId::PlayerControls, player_control_system), - profile(SystemId::PlayerMovement, player_movement_system), - profile(SystemId::Ghost, ghost_movement_system), - profile(SystemId::Collision, collision_system), - profile(SystemId::Item, item_system), - profile(SystemId::Audio, audio_system), - profile(SystemId::Blinking, blinking_system), - profile(SystemId::DirectionalRender, directional_render_system), - profile(SystemId::DirtyRender, dirty_render_system), - profile(SystemId::Render, render_system), - profile(SystemId::DebugRender, debug_render_system), - profile( - SystemId::Present, - |mut canvas: NonSendMut<&mut Canvas>, - backbuffer: NonSendMut, - debug_state: Res, - mut dirty: ResMut| { - if dirty.0 || *debug_state != DebugState::Off { - // Only copy backbuffer to main canvas if debug rendering is off - // (debug rendering draws directly to main canvas) - if *debug_state == DebugState::Off { - canvas.copy(&backbuffer.0, None, None).unwrap(); - } - dirty.0 = false; - canvas.present(); + schedule.add_systems(( + profile(SystemId::Input, input_system), + profile(SystemId::PlayerControls, player_control_system), + profile(SystemId::PlayerMovement, player_movement_system), + profile(SystemId::Ghost, ghost_movement_system), + profile(SystemId::Collision, collision_system), + profile(SystemId::Item, item_system), + profile(SystemId::Audio, audio_system), + profile(SystemId::Blinking, blinking_system), + profile(SystemId::DirectionalRender, directional_render_system), + profile(SystemId::DirtyRender, dirty_render_system), + profile(SystemId::Render, render_system), + profile(SystemId::DebugRender, debug_render_system), + profile( + SystemId::Present, + |mut canvas: NonSendMut<&mut Canvas>, + backbuffer: NonSendMut, + debug_state: Res, + mut dirty: ResMut| { + if dirty.0 || *debug_state != DebugState::Off { + // Only copy backbuffer to main canvas if debug rendering is off + // (debug rendering draws directly to main canvas) + if *debug_state == DebugState::Off { + canvas.copy(&backbuffer.0, None, None).unwrap(); } - }, - ), - ) - .chain(), - ); + dirty.0 = false; + canvas.present(); + } + }, + ), + )); // Spawn player world.spawn(player); @@ -288,7 +291,7 @@ impl Game { Ok(Game { world, schedule }) } - /// Spawns all four ghosts at their starting positions with appropriate textures. + /// Spowns all four ghosts at their starting positions with appropriate textures. fn spawn_ghosts(world: &mut World) -> GameResult<()> { // Extract the data we need first to avoid borrow conflicts let ghost_start_positions = { diff --git a/src/platform/desktop.rs b/src/platform/desktop.rs index bc1b370..656df20 100644 --- a/src/platform/desktop.rs +++ b/src/platform/desktop.rs @@ -5,12 +5,12 @@ use std::time::Duration; use crate::asset::Asset; use crate::error::{AssetError, PlatformError}; -use crate::platform::Platform; +use crate::platform::CommonPlatform; /// Desktop platform implementation. -pub struct DesktopPlatform; +pub struct Platform; -impl Platform for DesktopPlatform { +impl CommonPlatform for Platform { fn sleep(&self, duration: Duration, focused: bool) { if focused { spin_sleep::sleep(duration); @@ -75,7 +75,8 @@ impl Platform for DesktopPlatform { Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))), Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))), Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))), - Asset::Atlas => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))), + Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))), + Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))), } } } diff --git a/src/platform/emscripten.rs b/src/platform/emscripten.rs index d4fb791..3bb409a 100644 --- a/src/platform/emscripten.rs +++ b/src/platform/emscripten.rs @@ -5,12 +5,12 @@ use std::time::Duration; use crate::asset::Asset; use crate::error::{AssetError, PlatformError}; -use crate::platform::Platform; +use crate::platform::CommonPlatform; /// Emscripten platform implementation. -pub struct EmscriptenPlatform; +pub struct Platform; -impl Platform for EmscriptenPlatform { +impl CommonPlatform for Platform { fn sleep(&self, duration: Duration, _focused: bool) { unsafe { emscripten_sleep(duration.as_millis() as u32); diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 0a58975..4466e4f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -5,11 +5,13 @@ use crate::error::{AssetError, PlatformError}; use std::borrow::Cow; use std::time::Duration; -pub mod desktop; -pub mod emscripten; +#[cfg(not(target_os = "emscripten"))] +mod desktop; +#[cfg(target_os = "emscripten")] +mod emscripten; /// Platform abstraction trait that defines cross-platform functionality. -pub trait Platform { +pub trait CommonPlatform { /// Sleep for the specified duration using platform-appropriate method. fn sleep(&self, duration: Duration, focused: bool); @@ -32,17 +34,14 @@ pub trait Platform { /// Get the current platform implementation. #[allow(dead_code)] -pub fn get_platform() -> &'static dyn Platform { - static DESKTOP: desktop::DesktopPlatform = desktop::DesktopPlatform; - static EMSCRIPTEN: emscripten::EmscriptenPlatform = emscripten::EmscriptenPlatform; - +pub fn get_platform() -> &'static dyn CommonPlatform { #[cfg(not(target_os = "emscripten"))] { - &DESKTOP + &desktop::Platform } #[cfg(target_os = "emscripten")] { - &EMSCRIPTEN + &emscripten::Platform } } diff --git a/src/systems/debug.rs b/src/systems/debug.rs index cf699ac..53edcdd 100644 --- a/src/systems/debug.rs +++ b/src/systems/debug.rs @@ -13,6 +13,7 @@ use glam::{IVec2, UVec2, Vec2}; use sdl2::pixels::Color; use sdl2::rect::{Point, Rect}; use sdl2::render::{Canvas, Texture, TextureCreator}; +use sdl2::ttf::Font; use sdl2::video::{Window, WindowContext}; #[derive(Resource, Default, Debug, Copy, Clone, PartialEq)] @@ -36,6 +37,9 @@ impl DebugState { /// Resource to hold the debug texture for persistent rendering pub struct DebugTextureResource(pub Texture<'static>); +/// Resource to hold the debug font +pub struct DebugFontResource(pub Font<'static, 'static>); + /// Transforms a position from logical canvas coordinates to output canvas coordinates (with board offset) fn transform_position_with_offset(pos: Vec2, scale: f32) -> IVec2 { ((pos + BOARD_PIXEL_OFFSET.as_vec2()) * scale).as_ivec2() @@ -46,13 +50,8 @@ fn render_timing_display( canvas: &mut Canvas, texture_creator: &mut TextureCreator, timings: &SystemTimings, + font: &Font, ) { - // Get TTF context - let ttf_context = sdl2::ttf::init().unwrap(); - - // Load font - let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap(); - // Format timing information using the formatting module let lines = timings.format_timing_display(); let line_height = 14; // Approximate line height for 12pt font @@ -104,6 +103,7 @@ pub fn debug_render_system( mut canvas: NonSendMut<&mut Canvas>, backbuffer: NonSendMut, mut debug_texture: NonSendMut, + debug_font: NonSendMut, debug_state: Res, timings: Res, map: Res, @@ -130,6 +130,7 @@ pub fn debug_render_system( // Get texture creator before entering the closure to avoid borrowing conflicts let mut texture_creator = canvas.texture_creator(); + let font = &debug_font.0; let cursor_world_pos = match *cursor { CursorPosition::None => None, @@ -192,8 +193,6 @@ pub fn debug_render_system( let node = map.graph.get_node(closest_node_id).unwrap(); let pos = transform_position_with_offset(node.position, scale); - let ttf_context = sdl2::ttf::init().unwrap(); - let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap(); let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap(); let texture = texture_creator.create_texture_from_surface(&surface).unwrap(); let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height); @@ -217,7 +216,7 @@ pub fn debug_render_system( } // Render timing information in the top-left corner - render_timing_display(debug_canvas, &mut texture_creator, &timings); + render_timing_display(debug_canvas, &mut texture_creator, &timings, font); }) .unwrap(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 584bf56..092ef9e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -28,7 +28,7 @@ pub fn setup_sdl() -> Result<(Canvas, TextureCreator, Sdl pub fn create_atlas(canvas: &mut sdl2::render::Canvas) -> SpriteAtlas { let texture_creator = canvas.texture_creator(); - let atlas_bytes = get_asset_bytes(Asset::Atlas).unwrap(); + let atlas_bytes = get_asset_bytes(Asset::AtlasImage).unwrap(); let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap(); let texture: Texture<'static> = unsafe { std::mem::transmute(texture) }; diff --git a/web.build.ts b/web.build.ts index fd45ad8..c6967fc 100644 --- a/web.build.ts +++ b/web.build.ts @@ -1,7 +1,7 @@ import { $ } from "bun"; import { existsSync, promises as fs } from "fs"; import { platform } from "os"; -import { dirname, join, relative, resolve } from "path"; +import { basename, dirname, join, relative, resolve } from "path"; import { match, P } from "ts-pattern"; import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; @@ -79,16 +79,19 @@ async function build(release: boolean, env: Record | null) { // The files to copy into 'dist' const files = [ - ...["index.html", "favicon.ico", "build.css", "TerminalVector.ttf"].map( - (file) => ({ - src: join(siteFolder, file), - dest: join(dist, file), - optional: false, - }) - ), + ...[ + "index.html", + "favicon.ico", + "build.css", + "../game/TerminalVector.ttf", + ].map((file) => ({ + src: resolve(join(siteFolder, file)), + dest: join(dist, basename(file)), + optional: false, + })), ...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({ src: join(outputFolder, file), - dest: join(dist, file.split("/").pop() || file), + dest: join(dist, basename(file)), optional: false, })), { From d47d70ff5bbac58ae01620a81d20846c035a37d1 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 16 Aug 2025 14:37:14 -0500 Subject: [PATCH 43/44] refactor: remove dead code, move direction & graph into 'map' module --- src/entity/collision.rs | 128 ---------------- src/entity/ghost.rs | 254 ------------------------------- src/entity/item.rs | 117 -------------- src/entity/mod.rs | 7 - src/entity/pacman.rs | 115 -------------- src/entity/trait.rs | 114 -------------- src/error.rs | 15 +- src/events.rs | 6 +- src/{game/mod.rs => game.rs} | 4 +- src/game/state.rs | 153 ------------------- src/helpers.rs | 10 -- src/lib.rs | 2 - src/main.rs | 2 - src/map/builder.rs | 4 +- src/{entity => map}/direction.rs | 0 src/{entity => map}/graph.rs | 0 src/map/mod.rs | 2 + src/systems/components.rs | 3 +- src/systems/ghost.rs | 11 +- src/systems/input.rs | 2 +- src/systems/movement.rs | 4 +- src/systems/player.rs | 2 +- src/systems/render.rs | 8 +- tests/direction.rs | 2 +- tests/graph.rs | 4 +- tests/helpers.rs | 19 --- tests/item.rs | 46 ------ 27 files changed, 31 insertions(+), 1003 deletions(-) delete mode 100644 src/entity/collision.rs delete mode 100644 src/entity/ghost.rs delete mode 100644 src/entity/item.rs delete mode 100644 src/entity/mod.rs delete mode 100644 src/entity/pacman.rs delete mode 100644 src/entity/trait.rs rename src/{game/mod.rs => game.rs} (99%) delete mode 100644 src/game/state.rs delete mode 100644 src/helpers.rs rename src/{entity => map}/direction.rs (100%) rename src/{entity => map}/graph.rs (100%) delete mode 100644 tests/helpers.rs delete mode 100644 tests/item.rs diff --git a/src/entity/collision.rs b/src/entity/collision.rs deleted file mode 100644 index 0401751..0000000 --- a/src/entity/collision.rs +++ /dev/null @@ -1,128 +0,0 @@ -// use smallvec::SmallVec; -// use std::collections::HashMap; - -// use crate::entity::{graph::NodeId, traversal::Position}; - -// /// Trait for entities that can participate in collision detection. -// pub trait Collidable { -// /// Returns the current position of this entity. -// fn position(&self) -> Position; - -// /// Checks if this entity is colliding with another entity. -// #[allow(dead_code)] -// fn is_colliding_with(&self, other: &dyn Collidable) -> bool { -// positions_overlap(&self.position(), &other.position()) -// } -// } - -// /// System for tracking entities by their positions for efficient collision detection. -// #[derive(Default)] -// pub struct CollisionSystem { -// /// Maps node IDs to lists of entity IDs that are at that node -// node_entities: HashMap>, -// /// Maps entity IDs to their current positions -// entity_positions: HashMap, -// /// Next available entity ID -// next_id: EntityId, -// } - -// /// Unique identifier for an entity in the collision system -// pub type EntityId = u32; - -// impl CollisionSystem { -// /// Registers an entity with the collision system and returns its ID -// pub fn register_entity(&mut self, position: Position) -> EntityId { -// let id = self.next_id; -// self.next_id += 1; - -// self.entity_positions.insert(id, position); -// self.update_node_entities(id, position); - -// id -// } - -// /// Updates an entity's position -// pub fn update_position(&mut self, entity_id: EntityId, new_position: Position) { -// if let Some(old_position) = self.entity_positions.get(&entity_id) { -// // Remove from old nodes -// self.remove_from_nodes(entity_id, *old_position); -// } - -// // Update position and add to new nodes -// self.entity_positions.insert(entity_id, new_position); -// self.update_node_entities(entity_id, new_position); -// } - -// /// Removes an entity from the collision system -// #[allow(dead_code)] -// pub fn remove_entity(&mut self, entity_id: EntityId) { -// if let Some(position) = self.entity_positions.remove(&entity_id) { -// self.remove_from_nodes(entity_id, position); -// } -// } - -// /// Gets all entity IDs at a specific node -// pub fn entities_at_node(&self, node: NodeId) -> &[EntityId] { -// self.node_entities.get(&node).map(|v| v.as_slice()).unwrap_or(&[]) -// } - -// /// Gets all entity IDs that could collide with an entity at the given position -// pub fn potential_collisions(&self, position: &Position) -> Vec { -// let mut collisions = Vec::new(); -// let nodes = get_nodes(position); - -// for node in nodes { -// collisions.extend(self.entities_at_node(node)); -// } - -// // Remove duplicates -// collisions.sort_unstable(); -// collisions.dedup(); -// collisions -// } - -// /// Updates the node_entities map when an entity's position changes -// fn update_node_entities(&mut self, entity_id: EntityId, position: Position) { -// let nodes = get_nodes(&position); -// for node in nodes { -// self.node_entities.entry(node).or_default().push(entity_id); -// } -// } - -// /// Removes an entity from all nodes it was previously at -// fn remove_from_nodes(&mut self, entity_id: EntityId, position: Position) { -// let nodes = get_nodes(&position); -// for node in nodes { -// if let Some(entities) = self.node_entities.get_mut(&node) { -// entities.retain(|&id| id != entity_id); -// if entities.is_empty() { -// self.node_entities.remove(&node); -// } -// } -// } -// } -// } - -// /// Checks if two positions overlap (entities are at the same location). -// fn positions_overlap(a: &Position, b: &Position) -> bool { -// let a_nodes = get_nodes(a); -// let b_nodes = get_nodes(b); - -// // Check if any nodes overlap -// a_nodes.iter().any(|a_node| b_nodes.contains(a_node)) - -// // TODO: More complex overlap detection, the above is a simple check, but it could become an early filter for more precise calculations later -// } - -// /// Gets all nodes that an entity is currently at or between. -// fn get_nodes(pos: &Position) -> SmallVec<[NodeId; 2]> { -// let mut nodes = SmallVec::new(); -// match pos { -// Position::AtNode(node) => nodes.push(*node), -// Position::BetweenNodes { from, to, .. } => { -// nodes.push(*from); -// nodes.push(*to); -// } -// } -// nodes -// } diff --git a/src/entity/ghost.rs b/src/entity/ghost.rs deleted file mode 100644 index 7638d11..0000000 --- a/src/entity/ghost.rs +++ /dev/null @@ -1,254 +0,0 @@ -// //! Ghost entity implementation. -// //! -// //! This module contains the ghost character logic, including movement, -// //! animation, and rendering. Ghosts move through the game graph using -// //! a traverser and display directional animated textures. - -// use pathfinding::prelude::dijkstra; -// use rand::prelude::*; -// use smallvec::SmallVec; -// use tracing::error; - -// use crate::entity::{ -// collision::Collidable, -// direction::Direction, -// graph::{Edge, EdgePermissions, Graph, NodeId}, -// r#trait::Entity, -// traversal::Traverser, -// }; -// use crate::texture::animated::AnimatedTexture; -// use crate::texture::directional::DirectionalAnimatedTexture; -// use crate::texture::sprite::SpriteAtlas; - -// use crate::error::{EntityError, GameError, GameResult, TextureError}; - -// /// Determines if a ghost can traverse a given edge. -// /// -// /// Ghosts can move through edges that allow all entities or ghost-only edges. -// fn can_ghost_traverse(edge: Edge) -> bool { -// matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly) -// } - -// /// The four classic ghost types. -// #[derive(Debug, Clone, Copy, PartialEq, Eq)] -// pub enum GhostType { -// Blinky, -// Pinky, -// Inky, -// Clyde, -// } - -// impl GhostType { -// /// Returns the ghost type name for atlas lookups. -// pub fn as_str(self) -> &'static str { -// match self { -// GhostType::Blinky => "blinky", -// GhostType::Pinky => "pinky", -// GhostType::Inky => "inky", -// GhostType::Clyde => "clyde", -// } -// } - -// /// Returns the base movement speed for this ghost type. -// pub fn base_speed(self) -> f32 { -// match self { -// GhostType::Blinky => 1.0, -// GhostType::Pinky => 0.95, -// GhostType::Inky => 0.9, -// GhostType::Clyde => 0.85, -// } -// } -// } - -// /// A ghost entity that roams the game world. -// /// -// /// Ghosts move through the game world using a graph-based navigation system -// /// and display directional animated sprites. They randomly choose directions -// /// at each intersection. -// pub struct Ghost { -// /// Handles movement through the game graph -// pub traverser: Traverser, -// /// The type of ghost (affects appearance and speed) -// pub ghost_type: GhostType, -// /// Manages directional animated textures for different movement states -// texture: DirectionalAnimatedTexture, -// /// Current movement speed -// speed: f32, -// } - -// impl Entity for Ghost { -// fn traverser(&self) -> &Traverser { -// &self.traverser -// } - -// fn traverser_mut(&mut self) -> &mut Traverser { -// &mut self.traverser -// } - -// fn texture(&self) -> &DirectionalAnimatedTexture { -// &self.texture -// } - -// fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture { -// &mut self.texture -// } - -// fn speed(&self) -> f32 { -// self.speed -// } - -// fn can_traverse(&self, edge: Edge) -> bool { -// can_ghost_traverse(edge) -// } - -// fn tick(&mut self, dt: f32, graph: &Graph) { -// // Choose random direction when at a node -// if self.traverser.position.is_at_node() { -// self.choose_random_direction(graph); -// } - -// if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) { -// error!("Ghost movement error: {}", e); -// } -// self.texture.tick(dt); -// } -// } - -// impl Ghost { -// /// Creates a new ghost instance at the specified starting node. -// /// -// /// Sets up animated textures for all four directions with moving and stopped states. -// /// The moving animation cycles through two sprite variants. -// pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult { -// let mut textures = [None, None, None, None]; -// let mut stopped_textures = [None, None, None, None]; - -// for direction in Direction::DIRECTIONS { -// let moving_prefix = match direction { -// Direction::Up => "up", -// Direction::Down => "down", -// Direction::Left => "left", -// Direction::Right => "right", -// }; -// let moving_tiles = vec![ -// SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) -// .ok_or_else(|| { -// GameError::Texture(TextureError::AtlasTileNotFound(format!( -// "ghost/{}/{}_{}.png", -// ghost_type.as_str(), -// moving_prefix, -// "a" -// ))) -// })?, -// SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")) -// .ok_or_else(|| { -// GameError::Texture(TextureError::AtlasTileNotFound(format!( -// "ghost/{}/{}_{}.png", -// ghost_type.as_str(), -// moving_prefix, -// "b" -// ))) -// })?, -// ]; - -// let stopped_tiles = -// vec![ -// SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) -// .ok_or_else(|| { -// GameError::Texture(TextureError::AtlasTileNotFound(format!( -// "ghost/{}/{}_{}.png", -// ghost_type.as_str(), -// moving_prefix, -// "a" -// ))) -// })?, -// ]; - -// textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?); -// stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); -// } - -// Ok(Self { -// traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse), -// ghost_type, -// texture: DirectionalAnimatedTexture::new(textures, stopped_textures), -// speed: ghost_type.base_speed(), -// }) -// } - -// /// Chooses a random available direction at the current intersection. -// fn choose_random_direction(&mut self, graph: &Graph) { -// let current_node = self.traverser.position.from_node_id(); -// let intersection = &graph.adjacency_list[current_node]; - -// // Collect all available directions -// let mut available_directions = SmallVec::<[_; 4]>::new(); -// for direction in Direction::DIRECTIONS { -// if let Some(edge) = intersection.get(direction) { -// if can_ghost_traverse(edge) { -// available_directions.push(direction); -// } -// } -// } -// // Choose a random direction (avoid reversing unless necessary) -// if !available_directions.is_empty() { -// let mut rng = SmallRng::from_os_rng(); - -// // Filter out the opposite direction if possible, but allow it if we have limited options -// let opposite = self.traverser.direction.opposite(); -// let filtered_directions: Vec<_> = available_directions -// .iter() -// .filter(|&&dir| dir != opposite || available_directions.len() <= 2) -// .collect(); - -// if let Some(&random_direction) = filtered_directions.choose(&mut rng) { -// self.traverser.set_next_direction(*random_direction); -// } -// } -// } - -// /// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm. -// /// -// /// Returns a vector of NodeIds representing the path, or an error if pathfinding fails. -// /// The path includes the current node and the target node. -// pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult> { -// let start_node = self.traverser.position.from_node_id(); - -// // Use Dijkstra's algorithm to find the shortest path -// let result = dijkstra( -// &start_node, -// |&node_id| { -// // Get all edges from the current node -// graph.adjacency_list[node_id] -// .edges() -// .filter(|edge| can_ghost_traverse(*edge)) -// .map(|edge| (edge.target, (edge.distance * 100.0) as u32)) -// .collect::>() -// }, -// |&node_id| node_id == target, -// ); - -// result.map(|(path, _cost)| path).ok_or_else(|| { -// GameError::Entity(EntityError::PathfindingFailed(format!( -// "No path found from node {} to target {}", -// start_node, target -// ))) -// }) -// } - -// /// Returns the ghost's color for debug rendering. -// pub fn debug_color(&self) -> sdl2::pixels::Color { -// match self.ghost_type { -// GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red -// GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink -// GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan -// GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange -// } -// } -// } - -// impl Collidable for Ghost { -// fn position(&self) -> crate::entity::traversal::Position { -// self.traverser.position -// } -// } diff --git a/src/entity/item.rs b/src/entity/item.rs deleted file mode 100644 index 1d662a9..0000000 --- a/src/entity/item.rs +++ /dev/null @@ -1,117 +0,0 @@ -// use crate::{ -// constants, -// entity::{collision::Collidable, graph::Graph}, -// error::{EntityError, GameResult}, -// texture::sprite::{Sprite, SpriteAtlas}, -// }; -// use sdl2::render::{Canvas, RenderTarget}; -// use strum_macros::{EnumCount, EnumIter}; - -// #[derive(Debug, Clone, Copy, PartialEq, Eq)] -// pub enum ItemType { -// Pellet, -// Energizer, -// #[allow(dead_code)] -// Fruit { -// kind: FruitKind, -// }, -// } - -// impl ItemType { -// pub fn get_score(self) -> u32 { -// match self { -// ItemType::Pellet => 10, -// ItemType::Energizer => 50, -// ItemType::Fruit { kind } => kind.get_score(), -// } -// } -// } - -// #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, EnumCount)] -// #[allow(dead_code)] -// pub enum FruitKind { -// Apple, -// Strawberry, -// Orange, -// Melon, -// Bell, -// Key, -// Galaxian, -// } - -// impl FruitKind { -// #[allow(dead_code)] -// pub fn index(self) -> u8 { -// match self { -// FruitKind::Apple => 0, -// FruitKind::Strawberry => 1, -// FruitKind::Orange => 2, -// FruitKind::Melon => 3, -// FruitKind::Bell => 4, -// FruitKind::Key => 5, -// FruitKind::Galaxian => 6, -// } -// } - -// pub fn get_score(self) -> u32 { -// match self { -// FruitKind::Apple => 100, -// FruitKind::Strawberry => 300, -// FruitKind::Orange => 500, -// FruitKind::Melon => 700, -// FruitKind::Bell => 1000, -// FruitKind::Key => 2000, -// FruitKind::Galaxian => 3000, -// } -// } -// } - -// pub struct Item { -// pub node_index: usize, -// pub item_type: ItemType, -// pub sprite: Sprite, -// pub collected: bool, -// } - -// impl Item { -// pub fn new(node_index: usize, item_type: ItemType, sprite: Sprite) -> Self { -// Self { -// node_index, -// item_type, -// sprite, -// collected: false, -// } -// } - -// pub fn is_collected(&self) -> bool { -// self.collected -// } - -// pub fn collect(&mut self) { -// self.collected = true; -// } - -// pub fn get_score(&self) -> u32 { -// self.item_type.get_score() -// } - -// pub fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> { -// if self.collected { -// return Ok(()); -// } - -// let node = graph -// .get_node(self.node_index) -// .ok_or(EntityError::NodeNotFound(self.node_index))?; -// let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2(); - -// self.sprite.render(canvas, atlas, position)?; -// Ok(()) -// } -// } - -// impl Collidable for Item { -// fn position(&self) -> crate::entity::traversal::Position { -// crate::entity::traversal::Position::AtNode(self.node_index) -// } -// } diff --git a/src/entity/mod.rs b/src/entity/mod.rs deleted file mode 100644 index d05c62d..0000000 --- a/src/entity/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod collision; -pub mod direction; -pub mod ghost; -pub mod graph; -pub mod item; -pub mod pacman; -pub mod r#trait; diff --git a/src/entity/pacman.rs b/src/entity/pacman.rs deleted file mode 100644 index d881968..0000000 --- a/src/entity/pacman.rs +++ /dev/null @@ -1,115 +0,0 @@ -// //! Pac-Man entity implementation. -// //! -// //! This module contains the main player character logic, including movement, -// //! animation, and rendering. Pac-Man moves through the game graph using -// //! a traverser and displays directional animated textures. - -// use crate::entity::{ -// collision::Collidable, -// direction::Direction, -// graph::{Edge, EdgePermissions, Graph, NodeId}, -// r#trait::Entity, -// traversal::Traverser, -// }; -// use crate::texture::animated::AnimatedTexture; -// use crate::texture::directional::DirectionalAnimatedTexture; -// use crate::texture::sprite::SpriteAtlas; -// use tracing::error; - -// use crate::error::{GameError, GameResult, TextureError}; - -// /// Determines if Pac-Man can traverse a given edge. -// /// -// /// Pac-Man can only move through edges that allow all entities. -// fn can_pacman_traverse(edge: Edge) -> bool { -// matches!(edge.permissions, EdgePermissions::All) -// } - -// /// The main player character entity. -// /// -// /// Pac-Man moves through the game world using a graph-based navigation system -// /// and displays directional animated sprites based on movement state. -// pub struct Pacman { -// /// Handles movement through the game graph -// pub traverser: Traverser, -// /// Manages directional animated textures for different movement states -// texture: DirectionalAnimatedTexture, -// } - -// impl Entity for Pacman { -// fn traverser(&self) -> &Traverser { -// &self.traverser -// } - -// fn traverser_mut(&mut self) -> &mut Traverser { -// &mut self.traverser -// } - -// fn texture(&self) -> &DirectionalAnimatedTexture { -// &self.texture -// } - -// fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture { -// &mut self.texture -// } - -// fn speed(&self) -> f32 { -// 1.125 -// } - -// fn can_traverse(&self, edge: Edge) -> bool { -// can_pacman_traverse(edge) -// } - -// fn tick(&mut self, dt: f32, graph: &Graph) { -// if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) { -// error!("Pac-Man movement error: {}", e); -// } -// self.texture.tick(dt); -// } -// } - -// impl Pacman { -// /// Creates a new Pac-Man instance at the specified starting node. -// /// -// /// Sets up animated textures for all four directions with moving and stopped states. -// /// The moving animation cycles through open mouth, closed mouth, and full sprites. -// pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult { -// let mut textures = [None, None, None, None]; -// let mut stopped_textures = [None, None, None, None]; - -// for direction in Direction::DIRECTIONS { -// let moving_prefix = match direction { -// Direction::Up => "pacman/up", -// Direction::Down => "pacman/down", -// Direction::Left => "pacman/left", -// Direction::Right => "pacman/right", -// }; -// let moving_tiles = vec![ -// SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")) -// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?, -// SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")) -// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?, -// SpriteAtlas::get_tile(atlas, "pacman/full.png") -// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, -// ]; - -// let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")) -// .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?]; - -// textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?); -// stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); -// } - -// Ok(Self { -// traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse), -// texture: DirectionalAnimatedTexture::new(textures, stopped_textures), -// }) -// } -// } - -// impl Collidable for Pacman { -// fn position(&self) -> crate::entity::traversal::Position { -// self.traverser.position -// } -// } diff --git a/src/entity/trait.rs b/src/entity/trait.rs deleted file mode 100644 index b0f07e8..0000000 --- a/src/entity/trait.rs +++ /dev/null @@ -1,114 +0,0 @@ -// //! Entity trait for common movement and rendering functionality. -// //! -// //! This module defines a trait that captures the shared behavior between -// //! different game entities like Ghosts and Pac-Man, including movement, -// //! rendering, and position calculations. - -// use glam::Vec2; -// use sdl2::render::{Canvas, RenderTarget}; - -// use crate::entity::direction::Direction; -// use crate::entity::graph::{Edge, Graph, NodeId}; -// use crate::entity::traversal::{Position, Traverser}; -// use crate::error::{EntityError, GameError, GameResult, TextureError}; -// use crate::texture::directional::DirectionalAnimatedTexture; -// use crate::texture::sprite::SpriteAtlas; - -// /// Trait defining common functionality for game entities that move through the graph. -// /// -// /// This trait provides a unified interface for entities that: -// /// - Move through the game graph using a traverser -// /// - Render using directional animated textures -// /// - Have position calculations and movement speed -// #[allow(dead_code)] -// pub trait Entity { -// /// Returns a reference to the entity's traverser for movement control. -// fn traverser(&self) -> &Traverser; - -// /// Returns a mutable reference to the entity's traverser for movement control. -// fn traverser_mut(&mut self) -> &mut Traverser; - -// /// Returns a reference to the entity's directional animated texture. -// fn texture(&self) -> &DirectionalAnimatedTexture; - -// /// Returns a mutable reference to the entity's directional animated texture. -// fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture; - -// /// Returns the movement speed multiplier for this entity. -// fn speed(&self) -> f32; - -// /// Determines if this entity can traverse a given edge. -// fn can_traverse(&self, edge: Edge) -> bool; - -// /// Updates the entity's position and animation state. -// /// -// /// This method advances movement through the graph and updates texture animation. -// fn tick(&mut self, dt: f32, graph: &Graph); - -// /// 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. -// fn get_pixel_pos(&self, graph: &Graph) -> GameResult { -// let pos = match self.traverser().position { -// 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, 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, -// )) -// } - -// /// Returns the current node ID that the entity is at or moving towards. -// /// -// /// If the entity is at a node, returns that node ID. -// /// If the entity is between nodes, returns the node it's moving towards. -// fn current_node_id(&self) -> NodeId { -// match self.traverser().position { -// Position::AtNode(node_id) => node_id, -// Position::BetweenNodes { to, .. } => to, -// } -// } - -// /// Sets the next direction for the entity to take. -// /// -// /// The direction is buffered and will be applied at the next opportunity, -// /// typically when the entity reaches a new node. -// fn set_next_direction(&mut self, direction: Direction) { -// self.traverser_mut().set_next_direction(direction); -// } - -// /// Renders the entity at its current position. -// /// -// /// Draws the appropriate directional sprite based on the entity's -// /// current movement state and direction. -// fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> { -// let pixel_pos = self.get_pixel_pos(graph)?; -// let dest = crate::helpers::centered_with_size( -// glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32), -// glam::UVec2::new(16, 16), -// ); - -// if self.traverser().position.is_stopped() { -// self.texture() -// .render_stopped(canvas, atlas, dest, self.traverser().direction) -// .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?; -// } else { -// self.texture() -// .render(canvas, atlas, dest, self.traverser().direction) -// .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?; -// } - -// Ok(()) -// } -// } diff --git a/src/error.rs b/src/error.rs index 3c577d4..c559160 100644 --- a/src/error.rs +++ b/src/error.rs @@ -31,9 +31,6 @@ pub enum GameError { #[error("Entity error: {0}")] Entity(#[from] EntityError), - #[error("Game state error: {0}")] - GameState(#[from] GameStateError), - #[error("SDL error: {0}")] Sdl(String), @@ -51,6 +48,8 @@ pub enum GameError { pub enum AssetError { #[error("IO error: {0}")] Io(#[from] io::Error), + + #[allow(dead_code)] #[error("Asset not found: {0}")] NotFound(String), } @@ -109,18 +108,8 @@ pub enum EntityError { #[error("Edge not found: from {from} to {to}")] EdgeNotFound { from: usize, to: usize }, - - #[error("Invalid movement: {0}")] - InvalidMovement(String), - - #[error("Pathfinding failed: {0}")] - PathfindingFailed(String), } -/// Errors related to game state operations. -#[derive(thiserror::Error, Debug)] -pub enum GameStateError {} - /// Errors related to map operations. #[derive(thiserror::Error, Debug)] pub enum MapError { diff --git a/src/events.rs b/src/events.rs index 1f10d62..cb821dd 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,9 +1,11 @@ -use bevy_ecs::prelude::*; +use bevy_ecs::{entity::Entity, event::Event}; + +use crate::map::direction::Direction; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GameCommand { Exit, - MovePlayer(crate::entity::direction::Direction), + MovePlayer(Direction), ToggleDebug, MuteAudio, ResetLevel, diff --git a/src/game/mod.rs b/src/game.rs similarity index 99% rename from src/game/mod.rs rename to src/game.rs index a87bad5..fa150b5 100644 --- a/src/game/mod.rs +++ b/src/game.rs @@ -3,10 +3,10 @@ include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::CANVAS_SIZE; -use crate::entity::direction::Direction; use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; +use crate::map::direction::Direction; use crate::systems::blinking::Blinking; use crate::systems::movement::{BufferedDirection, Position, Velocity}; use crate::systems::player::player_movement_system; @@ -48,8 +48,6 @@ use crate::{ texture::sprite::{AtlasMapper, SpriteAtlas}, }; -pub mod state; - /// The `Game` struct is the main entry point for the game. /// /// It contains the game's state and logic, and is responsible for diff --git a/src/game/state.rs b/src/game/state.rs deleted file mode 100644 index 14013a5..0000000 --- a/src/game/state.rs +++ /dev/null @@ -1,153 +0,0 @@ -// use std::collections::VecDeque; - -// use sdl2::{ -// image::LoadTexture, -// render::{Texture, TextureCreator}, -// video::WindowContext, -// }; -// use smallvec::SmallVec; - -// use crate::{ -// asset::{get_asset_bytes, Asset}, -// audio::Audio, -// constants::RAW_BOARD, -// entity::{ -// collision::{Collidable, CollisionSystem, EntityId}, -// ghost::{Ghost, GhostType}, -// item::Item, -// pacman::Pacman, -// }, -// error::{GameError, GameResult, TextureError}, -// game::events::GameEvent, -// map::builder::Map, -// texture::{ -// sprite::{AtlasMapper, SpriteAtlas}, -// text::TextTexture, -// }, -// }; - -// include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); - -// /// The `GameState` struct holds all the essential data for the game. -// /// -// /// This includes the score, map, entities (Pac-Man, ghosts, items), -// /// collision system, and rendering resources. By centralizing the game's state, -// /// we can cleanly separate it from the game's logic, making it easier to manage -// /// and reason about. -// pub struct GameState { -// pub paused: bool, - -// pub score: u32, -// pub map: Map, -// pub pacman: Pacman, -// pub pacman_id: EntityId, -// pub ghosts: SmallVec<[Ghost; 4]>, -// pub ghost_ids: SmallVec<[EntityId; 4]>, -// pub items: Vec, -// pub item_ids: Vec, -// pub debug_mode: bool, -// pub event_queue: VecDeque, - -// // Collision system -// pub(crate) collision_system: CollisionSystem, - -// // Rendering resources -// pub(crate) atlas: SpriteAtlas, -// pub(crate) text_texture: TextTexture, - -// // Audio -// pub audio: Audio, - -// // Map texture pre-rendering -// pub(crate) map_texture: Option>, -// pub(crate) map_rendered: bool, -// pub(crate) texture_creator: &'static TextureCreator, -// } - -// impl GameState { -// /// Creates a new `GameState` by initializing all the game's data. -// /// -// /// This function sets up the map, Pac-Man, ghosts, items, collision system, -// /// and all rendering resources required to start the game. It returns a `GameResult` -// /// to handle any potential errors during initialization. -// pub fn new(texture_creator: &'static TextureCreator) -> GameResult { -// let map = Map::new(RAW_BOARD)?; - -// let start_node = map.start_positions.pacman; - -// let atlas_bytes = get_asset_bytes(Asset::Atlas)?; -// let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { -// if e.to_string().contains("format") || e.to_string().contains("unsupported") { -// GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}"))) -// } else { -// GameError::Texture(TextureError::LoadFailed(e.to_string())) -// } -// })?; - -// let atlas_mapper = AtlasMapper { -// frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(), -// }; -// let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); - -// let text_texture = TextTexture::new(1.0); -// let audio = Audio::new(); -// let pacman = Pacman::new(&map.graph, start_node, &atlas)?; - -// // Generate items (pellets and energizers) -// let items = map.generate_items(&atlas)?; - -// // Initialize collision system -// let mut collision_system = CollisionSystem::default(); - -// // Register Pac-Man -// let pacman_id = collision_system.register_entity(pacman.position()); - -// // Register items -// let item_ids = items -// .iter() -// .map(|item| collision_system.register_entity(item.position())) -// .collect(); - -// // Create and register ghosts -// let ghosts = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde] -// .iter() -// .zip( -// [ -// map.start_positions.blinky, -// map.start_positions.pinky, -// map.start_positions.inky, -// map.start_positions.clyde, -// ] -// .iter(), -// ) -// .map(|(ghost_type, start_node)| Ghost::new(&map.graph, *start_node, *ghost_type, &atlas)) -// .collect::>>()?; - -// // Register ghosts -// let ghost_ids = ghosts -// .iter() -// .map(|ghost| collision_system.register_entity(ghost.position())) -// .collect(); - -// Ok(Self { -// paused: false, -// map, -// atlas, -// pacman, -// pacman_id, -// ghosts, -// ghost_ids, -// items, -// item_ids, -// text_texture, -// audio, -// score: 0, -// debug_mode: false, -// collision_system, -// map_texture: None, -// map_rendered: false, -// texture_creator, -// event_queue: VecDeque::new(), -// }) -// } -// } diff --git a/src/helpers.rs b/src/helpers.rs deleted file mode 100644 index 4205194..0000000 --- a/src/helpers.rs +++ /dev/null @@ -1,10 +0,0 @@ -use glam::{IVec2, UVec2}; -use sdl2::rect::Rect; - -pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect { - // Ensure the position doesn't cause integer overflow when centering - let x = pixel_pos.x.saturating_sub(size.x as i32 / 2); - let y = pixel_pos.y.saturating_sub(size.y as i32 / 2); - - Rect::new(x, y, size.x, size.y) -} diff --git a/src/lib.rs b/src/lib.rs index 3668b97..e63462d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,11 +4,9 @@ pub mod app; pub mod asset; pub mod audio; pub mod constants; -pub mod entity; pub mod error; pub mod events; pub mod game; -pub mod helpers; pub mod map; pub mod platform; pub mod systems; diff --git a/src/main.rs b/src/main.rs index 907a875..5c31e3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,11 +10,9 @@ mod asset; mod audio; mod constants; -mod entity; mod error; mod events; mod game; -mod helpers; mod map; mod platform; mod systems; diff --git a/src/map/builder.rs b/src/map/builder.rs index 2841230..1b2d993 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -1,7 +1,7 @@ //! Map construction and building functionality. use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; -use crate::entity::direction::Direction; -use crate::entity::graph::{Graph, Node, TraversalFlags}; +use crate::map::direction::Direction; +use crate::map::graph::{Graph, Node, TraversalFlags}; use crate::map::parser::MapTileParser; use crate::systems::movement::NodeId; use bevy_ecs::resource::Resource; diff --git a/src/entity/direction.rs b/src/map/direction.rs similarity index 100% rename from src/entity/direction.rs rename to src/map/direction.rs diff --git a/src/entity/graph.rs b/src/map/graph.rs similarity index 100% rename from src/entity/graph.rs rename to src/map/graph.rs diff --git a/src/map/mod.rs b/src/map/mod.rs index 8f2e2bc..e104058 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -1,6 +1,8 @@ //! This module defines the game map and provides functions for interacting with it. pub mod builder; +pub mod direction; +pub mod graph; pub mod layout; pub mod parser; pub mod render; diff --git a/src/systems/components.rs b/src/systems/components.rs index 5a9d4d7..ab86cb4 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -2,7 +2,7 @@ use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; use bitflags::bitflags; use crate::{ - entity::graph::TraversalFlags, + map::graph::TraversalFlags, systems::movement::{BufferedDirection, Position, Velocity}, texture::{animated::AnimatedTexture, sprite::AtlasTile}, }; @@ -41,6 +41,7 @@ impl Ghost { } /// Returns the ghost's color for debug rendering. + #[allow(dead_code)] pub fn debug_color(&self) -> sdl2::pixels::Color { match self { Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index bd96b17..60aaf47 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -3,8 +3,11 @@ use rand::prelude::*; use smallvec::SmallVec; use crate::{ - entity::{direction::Direction, graph::Edge}, - map::builder::Map, + map::{ + builder::Map, + direction::Direction, + graph::{Edge, TraversalFlags}, + }, systems::{ components::{DeltaTime, Ghost}, movement::{Position, Velocity}, @@ -32,9 +35,7 @@ pub fn ghost_movement_system( // Collect all available directions that ghosts can traverse for edge in Direction::DIRECTIONS.iter().flat_map(|d| intersection.get(*d)) { - if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) - && edge.direction != opposite - { + if edge.traversal_flags.contains(TraversalFlags::GHOST) && edge.direction != opposite { non_opposite_options.push(edge); } } diff --git a/src/systems/input.rs b/src/systems/input.rs index 4ec59ca..f63702d 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -10,8 +10,8 @@ use sdl2::{event::Event, keyboard::Keycode, EventPump}; use crate::systems::components::DeltaTime; use crate::{ - entity::direction::Direction, events::{GameCommand, GameEvent}, + map::direction::Direction, }; #[derive(Resource, Default, Debug, Copy, Clone)] diff --git a/src/systems/movement.rs b/src/systems/movement.rs index 60195b4..e5c3767 100644 --- a/src/systems/movement.rs +++ b/src/systems/movement.rs @@ -1,6 +1,6 @@ -use crate::entity::direction::Direction; -use crate::entity::graph::Graph; use crate::error::{EntityError, GameResult}; +use crate::map::direction::Direction; +use crate::map::graph::Graph; use bevy_ecs::component::Component; use glam::Vec2; diff --git a/src/systems/player.rs b/src/systems/player.rs index 6673c4a..fac3e18 100644 --- a/src/systems/player.rs +++ b/src/systems/player.rs @@ -6,10 +6,10 @@ use bevy_ecs::{ }; use crate::{ - entity::graph::Edge, error::GameError, events::{GameCommand, GameEvent}, map::builder::Map, + map::graph::Edge, systems::{ components::{AudioState, DeltaTime, EntityType, GlobalState, PlayerControlled}, debug::DebugState, diff --git a/src/systems/render.rs b/src/systems/render.rs index 84b54c8..20d5ac9 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -7,6 +7,7 @@ use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; use bevy_ecs::prelude::{Changed, Or, RemovedComponents}; use bevy_ecs::system::{NonSendMut, Query, Res, ResMut}; +use sdl2::rect::{Point, Rect}; use sdl2::render::{Canvas, Texture}; use sdl2::video::Window; @@ -99,9 +100,10 @@ pub fn render_system( let pos = position.get_pixel_position(&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), + let dest = Rect::from_center( + Point::from((pos.x as i32, pos.y as i32)), + renderable.sprite.size.x as u32, + renderable.sprite.size.y as u32, ); renderable diff --git a/tests/direction.rs b/tests/direction.rs index a7e36b2..6e9f1a0 100644 --- a/tests/direction.rs +++ b/tests/direction.rs @@ -1,5 +1,5 @@ use glam::IVec2; -use pacman::entity::direction::*; +use pacman::map::direction::*; #[test] fn test_direction_opposite() { diff --git a/tests/graph.rs b/tests/graph.rs index 7f4fc06..04e78d6 100644 --- a/tests/graph.rs +++ b/tests/graph.rs @@ -1,5 +1,5 @@ -use pacman::entity::direction::Direction; -use pacman::entity::graph::{Graph, Node, TraversalFlags}; +use pacman::map::direction::Direction; +use pacman::map::graph::{Graph, Node, TraversalFlags}; fn create_test_graph() -> Graph { let mut graph = Graph::new(); diff --git a/tests/helpers.rs b/tests/helpers.rs deleted file mode 100644 index 6831dff..0000000 --- a/tests/helpers.rs +++ /dev/null @@ -1,19 +0,0 @@ -use glam::{IVec2, UVec2}; -use pacman::helpers::centered_with_size; - -#[test] -fn test_centered_with_size() { - let test_cases = [ - ((100, 100), (50, 30), (75, 85)), - ((50, 50), (51, 31), (25, 35)), - ((0, 0), (100, 100), (-50, -50)), - ((-100, -50), (80, 40), (-140, -70)), - ((1000, 1000), (1000, 1000), (500, 500)), - ]; - - for ((pos_x, pos_y), (size_x, size_y), (expected_x, expected_y)) in test_cases { - let rect = centered_with_size(IVec2::new(pos_x, pos_y), UVec2::new(size_x, size_y)); - assert_eq!(rect.origin(), (expected_x, expected_y)); - assert_eq!(rect.size(), (size_x, size_y)); - } -} diff --git a/tests/item.rs b/tests/item.rs deleted file mode 100644 index c8f5b99..0000000 --- a/tests/item.rs +++ /dev/null @@ -1,46 +0,0 @@ -// use glam::U16Vec2; -// use pacman::texture::sprite::{AtlasTile, Sprite}; - -// #[test] -// fn test_item_type_get_score() { -// assert_eq!(ItemType::Pellet.get_score(), 10); -// assert_eq!(ItemType::Energizer.get_score(), 50); - -// let fruit = ItemType::Fruit { kind: FruitKind::Apple }; -// assert_eq!(fruit.get_score(), 100); -// } - -// #[test] -// fn test_fruit_kind_increasing_score() { -// // Build a list of fruit kinds, sorted by their index -// let mut kinds = FruitKind::iter() -// .map(|kind| (kind.index(), kind.get_score())) -// .collect::>(); -// kinds.sort_unstable_by_key(|(index, _)| *index); - -// assert_eq!(kinds.len(), FruitKind::COUNT); - -// // Check that the score increases as expected -// for window in kinds.windows(2) { -// let ((_, prev), (_, next)) = (window[0], window[1]); -// assert!(prev < next, "Fruits should have increasing scores, but {prev:?} < {next:?}"); -// } -// } - -// #[test] -// fn test_item_creation_and_collection() { -// let atlas_tile = AtlasTile { -// pos: U16Vec2::new(0, 0), -// size: U16Vec2::new(16, 16), -// color: None, -// }; -// let sprite = Sprite::new(atlas_tile); -// let mut item = Item::new(0, ItemType::Pellet, sprite); - -// assert!(!item.is_collected()); -// assert_eq!(item.get_score(), 10); -// assert_eq!(item.position().from_node_id(), 0); - -// item.collect(); -// assert!(item.is_collected()); -// } From 7a02d6b0b5a4f42fbba5537280bc6053aabfa6b9 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 16 Aug 2025 15:12:25 -0500 Subject: [PATCH 44/44] chore: add cargo checks to pre-commit --- .pre-commit-config.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6365a9d..e58e742 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,15 @@ repos: language: system types: [rust] pass_filenames: false + - id: cargo-check + name: cargo check + entry: cargo check --all-targets + language: system + types_or: [rust, cargo, cargo-lock] + pass_filenames: false + - id: cargo-check-wasm + name: cargo check for wasm32-unknown-emscripten + entry: cargo check --all-targets --target=wasm32-unknown-emscripten + language: system + types_or: [rust, cargo, cargo-lock] + pass_filenames: false