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 diff --git a/Cargo.lock b/Cargo.lock index 7faa84e..a19f311 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,43 @@ 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" +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 +301,104 @@ 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 = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[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 +411,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 +487,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 +509,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 +540,18 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -172,6 +571,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" @@ -189,13 +594,20 @@ name = "pacman" version = "0.2.0" dependencies = [ "anyhow", - "glam", + "bevy_ecs", + "bitflags 2.9.1", + "circular-buffer", + "glam 0.30.5", "lazy_static", "libc", + "micromap", + "num-width", "once_cell", + "parking_lot", "pathfinding", "phf", - "rand 0.9.2", + "pretty_assertions", + "rand", "sdl2", "serde", "serde_json", @@ -204,12 +616,42 @@ dependencies = [ "strum", "strum_macros", "thiserror", + "thousands", "tracing", "tracing-error", "tracing-subscriber", "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" @@ -226,29 +668,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", @@ -259,9 +702,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", ] @@ -272,6 +715,31 @@ 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 = "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" @@ -296,30 +764,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" @@ -329,6 +782,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 +841,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 +937,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 +976,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" @@ -509,24 +1013,30 @@ 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", "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" @@ -537,6 +1047,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 +1135,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 +1203,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 +1312,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 +1337,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" @@ -744,3 +1460,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 35d3fed..2999cd7 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" @@ -15,15 +15,23 @@ 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 = [] } +glam = "0.30.5" serde = { version = "1.0.219", features = ["derive"] } 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" +parking_lot = "0.12.3" +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 @@ -62,4 +70,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"] } 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/app.rs b/src/app.rs index 0f03791..34c0897 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,31 +1,22 @@ use std::time::{Duration, Instant}; use glam::Vec2; -use sdl2::event::{Event, WindowEvent}; -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, info, 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, + _cursor_pos: Vec2, } impl App { @@ -54,42 +45,28 @@ 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 canvas = Box::leak(Box::new( + window + .into_canvas() + .accelerated() + .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()))?; - Ok(App { game, - input_system: InputSystem::new(), - canvas, - event_pump, - backbuffer, focused: true, last_tick: Instant::now(), - cursor_pos: Vec2::ZERO, + _cursor_pos: Vec2::ZERO, }) } @@ -97,34 +74,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,23 +108,12 @@ 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); - } - + // 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/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/entity/collision.rs b/src/entity/collision.rs deleted file mode 100644 index 9ca4a28..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 0b3ec0e..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 8d9788e..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 d23c3be..0000000 --- a/src/entity/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod collision; -pub mod direction; -pub mod ghost; -pub mod graph; -pub mod item; -pub mod pacman; -pub mod r#trait; -pub mod traversal; diff --git a/src/entity/pacman.rs b/src/entity/pacman.rs deleted file mode 100644 index a1b2c8e..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 e45495f..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/entity/traversal.rs b/src/entity/traversal.rs deleted file mode 100644 index 372d37d..0000000 --- a/src/entity/traversal.rs +++ /dev/null @@ -1,229 +0,0 @@ -use tracing::error; - -use crate::error::GameResult; - -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, - }, -} - -#[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(_)) - } -} - -/// 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)), - }; - - // This will kickstart the traverser into motion - if let Err(e) = traverser.advance(graph, 0.0, can_traverse) { - error!("Traverser initialization error: {}", e); - } - - 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/error.rs b/src/error.rs index 2a85b60..c559160 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), @@ -29,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), @@ -49,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), } @@ -107,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 new file mode 100644 index 0000000..cb821dd --- /dev/null +++ b/src/events.rs @@ -0,0 +1,25 @@ +use bevy_ecs::{entity::Entity, event::Event}; + +use crate::map::direction::Direction; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GameCommand { + Exit, + MovePlayer(Direction), + ToggleDebug, + MuteAudio, + ResetLevel, + TogglePause, +} + +#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] +pub enum GameEvent { + Command(GameCommand), + Collision(Entity, Entity), +} + +impl From for GameEvent { + fn from(command: GameCommand) -> Self { + GameEvent::Command(command) + } +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 0000000..fa150b5 --- /dev/null +++ b/src/game.rs @@ -0,0 +1,621 @@ +//! This module contains the main game logic and state. + +include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); + +use crate::constants::CANVAS_SIZE; +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; +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, Ghost, GhostBundle, GhostCollider, GlobalState, + ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, + }, + debug::{debug_render_system, DebugFontResource, DebugState, DebugTextureResource}, + ghost::ghost_movement_system, + input::input_system, + item::item_system, + player::player_control_system, + profiling::{profile, SystemTimings}, + render::{directional_render_system, dirty_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::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; + +use crate::{ + asset::{get_asset_bytes, Asset}, + constants, + events::GameCommand, + map::render::MapRenderer, + systems::input::{Bindings, CursorPosition}, + texture::sprite::{AtlasMapper, SpriteAtlas}, +}; + +/// The `Game` struct is the main entry point for the game. +/// +/// 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 { + pub world: World, + pub schedule: Schedule, +} + +impl Game { + 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(); + 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); + EventRegistry::register_event::(&mut world); + + 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); + + 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); + + // 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); + + 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::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!( + "Unsupported texture format: {e}" + ))) + } else { + GameError::Texture(crate::error::TextureError::LoadFailed(e.to_string())) + } + })?; + + 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, &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 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)?); + } + + let player = PlayerBundle { + player: PlayerControlled, + position: Position::Stopped { node: pacman_start_node }, + velocity: Velocity { + speed: 1.15, + 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())))?, + layer: 0, + visible: true, + }, + directional_animated: DirectionalAnimated { + textures, + stopped_textures, + }, + entity_type: EntityType::Player, + collider: Collider { + size: constants::CELL_SIZE as f32 * 1.375, + }, + pacman_collider: PacmanCollider, + }; + + 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_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); + 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)); + 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| { + if matches!(*event, GameEvent::Command(GameCommand::Exit)) { + state.exit = true; + } + }, + ); + 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(); + } + }, + ), + )); + + // 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())))?; + 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, 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, + }; + + let mut item = world.spawn(ItemBundle { + position: Position::Stopped { node: node_id }, + sprite: Renderable { + sprite, + layer: 1, + visible: true, + }, + entity_type: item_type, + collider: Collider { size }, + item_collider: ItemCollider, + }); + + if item_type == EntityType::PowerPellet { + item.insert(Blinking { + timer: 0.0, + interval: 0.2, + }); + } + } + + Ok(Game { world, schedule }) + } + + /// 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 = { + let map = world.resource::(); + [ + (Ghost::Blinky, map.start_positions.blinky), + (Ghost::Pinky, map.start_positions.pinky), + (Ghost::Inky, map.start_positions.inky), + (Ghost::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: ghost_type, + position: Position::Stopped { node: start_node }, + velocity: Velocity { + speed: ghost_type.base_speed(), + direction: Direction::Left, + }, + 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. + pub fn tick(&mut self, dt: f32) -> bool { + self.world.insert_resource(DeltaTime(dt)); + + // Run all systems + self.schedule.run(&mut self.world); + + let state = self + .world + .get_resource::() + .expect("GlobalState could not be acquired"); + + state.exit + } + + // 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(); + + // // 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!"); + // } + // } + // } + // } + + // 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) + // } + + // 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.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 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); + // } + + // 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(()) + // } + + // /// 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 + // } + + // // 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 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; + + // // 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 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(()) + // } + + // 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 + // // ); + + // Ok(()) + // } +} diff --git a/src/game/events.rs b/src/game/events.rs deleted file mode 100644 index b0e5351..0000000 --- a/src/game/events.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::input::commands::GameCommand; - -#[derive(Debug, Clone, Copy)] -pub enum GameEvent { - Command(GameCommand), -} - -impl From for GameEvent { - fn from(command: GameCommand) -> Self { - GameEvent::Command(command) - } -} diff --git a/src/game/mod.rs b/src/game/mod.rs deleted file mode 100644 index d53467d..0000000 --- a/src/game/mod.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! 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; - -use crate::entity::r#trait::Entity; -use crate::error::GameResult; - -use crate::entity::{ - collision::{Collidable, CollisionSystem, EntityId}, - ghost::{Ghost, GhostType}, - pacman::Pacman, -}; - -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; - -/// The `Game` struct is the main entry point for the game. -/// -/// 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, -} - -impl Game { - pub fn new(texture_creator: &'static TextureCreator) -> GameResult { - let state = GameState::new(texture_creator)?; - - Ok(Game { state }) - } - - pub fn post_event(&mut self, event: GameEvent) { - self.state.event_queue.push_back(event); - } - - 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. - pub fn tick(&mut self, dt: f32) -> bool { - // 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.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(); - - false - } - - /// 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 - .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(); - - // 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!"); - } - } - } - } - - 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) - } - - 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(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(); - } - - // 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); - } - } - - 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()))?; - - Ok(()) - } - - 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(()) - } - - /// 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 - } - - // 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 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; - - // 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 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(()) - } - - 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 - // ); - - Ok(()) - } -} diff --git a/src/game/state.rs b/src/game/state.rs deleted file mode 100644 index 9c9da59..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/input/commands.rs b/src/input/commands.rs deleted file mode 100644 index d125a0c..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), - TogglePause, - ToggleDebug, - MuteAudio, - ResetLevel, - Exit, -} diff --git a/src/input/mod.rs b/src/input/mod.rs deleted file mode 100644 index 5924876..0000000 --- a/src/input/mod.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::collections::HashMap; - -use sdl2::{event::Event, keyboard::Keycode}; - -use crate::{entity::direction::Direction, input::commands::GameCommand}; - -pub mod commands; - -#[derive(Debug, Clone, Default)] -pub struct InputSystem { - key_bindings: HashMap, -} - -impl InputSystem { - pub fn new() -> Self { - let mut key_bindings = HashMap::new(); - - // Player movement - key_bindings.insert(Keycode::Up, GameCommand::MovePlayer(Direction::Up)); - key_bindings.insert(Keycode::W, GameCommand::MovePlayer(Direction::Up)); - key_bindings.insert(Keycode::Down, GameCommand::MovePlayer(Direction::Down)); - key_bindings.insert(Keycode::S, GameCommand::MovePlayer(Direction::Down)); - key_bindings.insert(Keycode::Left, GameCommand::MovePlayer(Direction::Left)); - key_bindings.insert(Keycode::A, GameCommand::MovePlayer(Direction::Left)); - key_bindings.insert(Keycode::Right, GameCommand::MovePlayer(Direction::Right)); - key_bindings.insert(Keycode::D, GameCommand::MovePlayer(Direction::Right)); - - // Game actions - key_bindings.insert(Keycode::P, GameCommand::TogglePause); - key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug); - key_bindings.insert(Keycode::M, GameCommand::MuteAudio); - key_bindings.insert(Keycode::R, GameCommand::ResetLevel); - key_bindings.insert(Keycode::Escape, GameCommand::Exit); - key_bindings.insert(Keycode::Q, GameCommand::Exit); - - 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 { - match event { - Event::Quit { .. } => Some(GameCommand::Exit), - Event::KeyDown { keycode: Some(key), .. } => self.key_bindings.get(key).copied(), - _ => None, - } - } -} diff --git a/src/lib.rs b/src/lib.rs index d9374f7..e63462d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,11 +4,10 @@ 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 input; pub mod map; pub mod platform; +pub mod systems; pub mod texture; diff --git a/src/main.rs b/src/main.rs index f293b89..5c31e3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,13 +10,12 @@ mod asset; mod audio; mod constants; -mod entity; mod error; +mod events; mod game; -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 bf0b361..1b2d993 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -1,14 +1,11 @@ //! Map construction and building functionality. - -use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD}; -use crate::entity::direction::Direction; -use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId}; -use crate::entity::item::{Item, ItemType}; +use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; +use crate::map::direction::Direction; +use crate::map::graph::{Graph, Node, TraversalFlags}; use crate::map::parser::MapTileParser; -use crate::map::render::MapRenderer; -use crate::texture::sprite::{Sprite, SpriteAtlas}; +use crate::systems::movement::NodeId; +use bevy_ecs::resource::Resource; use glam::{IVec2, Vec2}; -use sdl2::render::{Canvas, RenderTarget}; use std::collections::{HashMap, VecDeque}; use tracing::debug; @@ -24,6 +21,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, @@ -31,6 +29,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 { @@ -151,59 +151,15 @@ impl Map { graph, grid_to_node, start_positions, + tiles: 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 - /// 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) + 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) + }) } /// Builds the house structure in the graph. @@ -292,7 +248,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}")))?; @@ -303,7 +259,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/entity/direction.rs b/src/map/direction.rs similarity index 94% rename from src/entity/direction.rs rename to src/map/direction.rs index b6466f9..f981076 100644 --- a/src/entity/direction.rs +++ b/src/map/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/graph.rs b/src/map/graph.rs similarity index 87% rename from src/entity/graph.rs rename to src/map/graph.rs index b5867d8..27e138b 100644 --- a/src/entity/graph.rs +++ b/src/map/graph.rs @@ -1,18 +1,21 @@ use glam::Vec2; +use crate::systems::movement::NodeId; + use super::direction::Direction; -/// A unique identifier for a node, represented by its index in the graph's storage. -pub type NodeId = usize; +use bitflags::bitflags; -/// 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, +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). @@ -25,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. @@ -133,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."); @@ -162,7 +165,7 @@ impl Graph { replace: bool, distance: Option, direction: Direction, - permissions: EdgePermissions, + traversal_flags: TraversalFlags, ) -> Result<(), &'static str> { let edge = Edge { target: to, @@ -181,7 +184,7 @@ impl Graph { } }, direction, - permissions, + traversal_flags, }; if from >= self.adjacency_list.len() { @@ -215,9 +218,17 @@ 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() + } + + /// 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. 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/map/render.rs b/src/map/render.rs index aa16f66..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; @@ -19,7 +15,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]; @@ -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/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/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/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..dc3c180 --- /dev/null +++ b/src/systems/collision.rs @@ -0,0 +1,51 @@ +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}; +use crate::systems::movement::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_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); + // 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 new file mode 100644 index 0000000..ab86cb4 --- /dev/null +++ b/src/systems/components.rs @@ -0,0 +1,171 @@ +use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; +use bitflags::bitflags; + +use crate::{ + map::graph::TraversalFlags, + systems::movement::{BufferedDirection, Position, Velocity}, + texture::{animated::AnimatedTexture, sprite::AtlasTile}, +}; + +/// A tag component for entities that are controlled by the player. +#[derive(Default, Component)] +pub struct PlayerControlled; + +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ghost { + Blinky, + Pinky, + Inky, + Clyde, +} + +impl Ghost { + /// Returns the ghost type name for atlas lookups. + pub fn as_str(self) -> &'static str { + match self { + 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 { + Ghost::Blinky => 1.0, + Ghost::Pinky => 0.95, + Ghost::Inky => 0.9, + Ghost::Clyde => 0.85, + } + } + + /// 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 + 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 + } + } +} + +/// A tag component denoting the type of entity. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EntityType { + Player, + Ghost, + Pellet, + PowerPellet, +} + +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. +#[derive(Component)] +pub struct Renderable { + pub sprite: AtlasTile, + pub layer: u8, + pub visible: bool, +} + +/// A component for entities that have a directional animated texture. +#[derive(Component)] +pub struct DirectionalAnimated { + pub textures: [Option; 4], + pub stopped_textures: [Option; 4], +} + +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, +} + +/// Marker components for collision filtering optimization +#[derive(Component)] +pub struct PacmanCollider; + +#[derive(Component)] +pub struct GhostCollider; + +#[derive(Component)] +pub struct ItemCollider; + +#[derive(Bundle)] +pub struct PlayerBundle { + pub player: PlayerControlled, + pub position: Position, + pub velocity: Velocity, + pub buffered_direction: BufferedDirection, + 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 collider: Collider, + pub item_collider: ItemCollider, +} + +#[derive(Bundle)] +pub struct GhostBundle { + pub ghost: Ghost, + pub position: Position, + pub velocity: Velocity, + 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, +} + +#[derive(Resource)] +pub struct ScoreResource(pub u32); + +#[derive(Resource)] +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/debug.rs b/src/systems/debug.rs new file mode 100644 index 0000000..53edcdd --- /dev/null +++ b/src/systems/debug.rs @@ -0,0 +1,225 @@ +//! Debug rendering system +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::{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)] +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>); + +/// 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() +} + +/// Renders timing information in the top-left corner of the screen +fn render_timing_display( + canvas: &mut Canvas, + texture_creator: &mut TextureCreator, + timings: &SystemTimings, + font: &Font, +) { + // Format timing information using the formatting module + 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 + .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; + } + + // 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(); + } +} + +#[allow(clippy::too_many_arguments)] +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, + colliders: Query<(&Collider, &Position)>, + cursor: Res, +) { + if *debug_state == DebugState::Off { + return; + } + 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 + .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(); + + // 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, + CursorPosition::Some { position, .. } => Some(position - 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 = 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() { + 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 = transform_position_with_offset(start_node_model.position, scale); + let end = transform_position_with_offset(end_node, scale); + + 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 + debug_canvas.set_draw_color(if Some(id) == closest_node { + Color::YELLOW + } else { + Color::BLUE + }); + + // Transform position using common method + let pos = transform_position_with_offset(pos, scale); + let size = (3.0 * scale) as u32; + + debug_canvas + .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 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); + for (collider, position) in colliders.iter() { + let pos = position.get_pixel_position(&map.graph).unwrap(); + + // Transform position and size using common methods + let pos = (pos * scale).as_ivec2(); + let size = (collider.size * scale) as u32; + + let rect = Rect::from_center(Point::from((pos.x, pos.y)), 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, font); + }) + .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/formatting.rs b/src/systems/formatting.rs new file mode 100644 index 0000000..1b4e93e --- /dev/null +++ b/src/systems/formatting.rs @@ -0,0 +1,107 @@ +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) { + 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: impl IntoIterator, +) -> SmallVec<[String; SystemId::COUNT]> { + let mut iter = timing_data.into_iter().peekable(); + if iter.peek().is_none() { + return SmallVec::new(); + } + + 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 = 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, max_avg_int_width, max_avg_decimal_width, max_std_int_width, max_std_decimal_width) = entries + .iter() + .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), + ) + }); + + entries.iter().map(|e| { + format!( + "{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal:>() +} diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs new file mode 100644 index 0000000..60aaf47 --- /dev/null +++ b/src/systems/ghost.rs @@ -0,0 +1,70 @@ +use bevy_ecs::system::{Query, Res}; +use rand::prelude::*; +use smallvec::SmallVec; + +use crate::{ + map::{ + builder::Map, + direction::Direction, + graph::{Edge, TraversalFlags}, + }, + systems::{ + components::{DeltaTime, Ghost}, + movement::{Position, Velocity}, + }, +}; + +/// 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_movement_system( + map: Res, + delta_time: Res, + mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position)>, +) { + for (_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(); + + let mut non_opposite_options: SmallVec<[Edge; 3]> = SmallVec::new(); + + // 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(TraversalFlags::GHOST) && edge.direction != opposite { + non_opposite_options.push(edge); + } + } + + 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; + } + } + } + } + } +} diff --git a/src/systems/input.rs b/src/systems/input.rs new file mode 100644 index 0000000..f63702d --- /dev/null +++ b/src/systems/input.rs @@ -0,0 +1,143 @@ +use std::collections::{HashMap, HashSet}; + +use bevy_ecs::{ + event::EventWriter, + resource::Resource, + system::{NonSendMut, Res, ResMut}, +}; +use glam::Vec2; +use sdl2::{event::Event, keyboard::Keycode, EventPump}; + +use crate::systems::components::DeltaTime; +use crate::{ + events::{GameCommand, GameEvent}, + map::direction::Direction, +}; + +#[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, + last_movement_key: Option, +} + +impl Default for Bindings { + fn default() -> Self { + let mut key_bindings = HashMap::new(); + + // Player movement + key_bindings.insert(Keycode::Up, GameCommand::MovePlayer(Direction::Up)); + key_bindings.insert(Keycode::W, GameCommand::MovePlayer(Direction::Up)); + key_bindings.insert(Keycode::Down, GameCommand::MovePlayer(Direction::Down)); + key_bindings.insert(Keycode::S, GameCommand::MovePlayer(Direction::Down)); + key_bindings.insert(Keycode::Left, GameCommand::MovePlayer(Direction::Left)); + key_bindings.insert(Keycode::A, GameCommand::MovePlayer(Direction::Left)); + key_bindings.insert(Keycode::Right, GameCommand::MovePlayer(Direction::Right)); + key_bindings.insert(Keycode::D, GameCommand::MovePlayer(Direction::Right)); + + // Game actions + key_bindings.insert(Keycode::P, GameCommand::TogglePause); + key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug); + key_bindings.insert(Keycode::M, GameCommand::MuteAudio); + key_bindings.insert(Keycode::R, GameCommand::ResetLevel); + key_bindings.insert(Keycode::Escape, GameCommand::Exit); + key_bindings.insert(Keycode::Q, GameCommand::Exit); + + 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( + 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 { + Event::Quit { .. } => { + writer.write(GameEvent::Command(GameCommand::Exit)); + } + Event::MouseMotion { x, y, .. } => { + *cursor = CursorPosition::Some { + position: Vec2::new(x as f32, y as f32), + remaining_time: 0.20, + }; + cursor_seen = true; + } + 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)); + } + } + } + + 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/src/systems/item.rs b/src/systems/item.rs new file mode 100644 index 0000000..9aff5de --- /dev/null +++ b/src/systems/item.rs @@ -0,0 +1,49 @@ +use bevy_ecs::{event::EventReader, prelude::*, query::With, system::Query}; + +use crate::{ + events::GameEvent, + systems::{ + audio::AudioEvent, + 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>, + mut events: EventWriter, +) { + 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(); + + events.write(AudioEvent::PlayEat); + } + } + } +} diff --git a/src/systems/mod.rs b/src/systems/mod.rs new file mode 100644 index 0000000..fab80f4 --- /dev/null +++ b/src/systems/mod.rs @@ -0,0 +1,18 @@ +//! The Entity-Component-System (ECS) module. +//! +//! 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; +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 new file mode 100644 index 0000000..e5c3767 --- /dev/null +++ b/src/systems/movement.rs @@ -0,0 +1,285 @@ +use crate::error::{EntityError, GameResult}; +use crate::map::direction::Direction; +use crate::map::graph::Graph; +use bevy_ecs::component::Component; +use glam::Vec2; + +/// A unique identifier for a node, represented by its index in the graph's storage. +pub type NodeId = usize; + +/// 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)] +pub enum Position { + Stopped { + node: NodeId, + }, + Moving { + from: NodeId, + to: NodeId, + remaining_distance: 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. + /// + /// # Errors + /// + /// Returns an `EntityError` if the node or edge is not found. + 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(*node).ok_or(EntityError::NodeNotFound(*node))?; + node.position + } + Position::Moving { + from, + to, + remaining_distance, + } => { + // Entity is traveling between nodes + 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.distance == 0.0 { + to_node.position + } else { + // Interpolate position based on progress + let progress = 1.0 - (*remaining_distance / edge.distance); + from_node.position.lerp(to_node.position, progress) + } + } + }; + + Ok(Vec2::new( + pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32, + pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32, + )) + } + + /// 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 { + matches!(self, Position::Stopped { .. }) + } + + /// Returns the `NodeId` of the current node (source of travel if moving). + pub fn current_node(&self) -> NodeId { + match self { + Position::Stopped { node } => *node, + Position::Moving { from, .. } => *from, + } + } +} + +// 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; + +// 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 +// }; + +// *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; + +// // 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 *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 }; + +// 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 = 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; +// } +// } +// } + +// // 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; +// } +// } +// } + +// // 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..fac3e18 --- /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::{ + error::GameError, + events::{GameCommand, GameEvent}, + map::builder::Map, + map::graph::Edge, + 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 new file mode 100644 index 0000000..ce5c188 --- /dev/null +++ b/src/systems/profiling.rs @@ -0,0 +1,183 @@ +use bevy_ecs::prelude::Resource; +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 = 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, + PlayerControls, + Ghost, + Movement, + Audio, + Blinking, + DirectionalRender, + DirtyRender, + Render, + DebugRender, + Present, + Collision, + Item, + PlayerMovement, +} + +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. + /// + /// 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>, MAX_SYSTEMS>>, +} + +impl SystemTimings { + 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(&id) { + let queue = timings + .get(&id) + .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(id).or_insert_with(|| Mutex::new(CircularBuffer::new())); + queue.lock().push_back(duration); + }); + } + + pub fn get_stats(&self) -> Map { + let timings = self.timings.read(); + let mut stats = Map::new(); + + for (id, queue) in timings.iter() { + if queue.lock().is_empty() { + continue; + } + + 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(); + let mean = sum / count; + + let variance = durations.iter().map(|x| (x - mean).powi(2)).sum::() / count; + let std_dev = variance.sqrt(); + + stats.insert( + *id, + ( + 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.read(); + let mut all_durations = Vec::new(); + + for queue in timings.values() { + all_durations.extend(queue.lock().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 format_timing_display(&self) -> SmallVec<[String; SystemId::COUNT]> { + 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 + formatting::format_timing_display(timing_data) + } +} + +pub fn profile(id: SystemId, 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(timings) = world.get_resource::() { + timings.add_timing(id, duration); + } + } +} diff --git a/src/systems/render.rs b/src/systems/render.rs new file mode 100644 index 0000000..20d5ac9 --- /dev/null +++ b/src/systems/render.rs @@ -0,0 +1,123 @@ +use crate::error::{GameError, TextureError}; +use crate::map::builder::Map; +use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable}; +use crate::systems::movement::{Position, Velocity}; +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, ResMut}; +use sdl2::rect::{Point, Rect}; +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)>>, + 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. +pub fn directional_render_system( + dt: Res, + mut renderables: Query<(&Position, &Velocity, &mut DirectionalAnimated, &mut Renderable)>, + mut errors: EventWriter, +) { + 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() + } else { + texture.textures[current_direction.as_usize()].as_mut() + }; + + if let Some(texture) = texture { + if !stopped { + texture.tick(dt.0); + } + let new_tile = *texture.current_tile(); + if renderable.sprite != new_tile { + renderable.sprite = new_tile; + } + } else { + errors.write(TextureError::RenderFailed("Entity has no texture".to_string()).into()); + continue; + } + } +} + +/// 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>); + +#[allow(clippy::too_many_arguments)] +pub fn render_system( + mut canvas: NonSendMut<&mut Canvas>, + map_texture: NonSendMut, + mut backbuffer: NonSendMut, + mut atlas: NonSendMut, + map: Res, + dirty: Res, + renderables: Query<(Entity, &Renderable, &Position)>, + mut errors: EventWriter, +) { + if !dirty.0 { + return; + } + // 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 + 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 (_, renderable, position) in renderables + .iter() + .sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer) + .rev() + { + if !renderable.visible { + continue; + } + + let pos = position.get_pixel_position(&map.graph); + match pos { + Ok(pos) => { + 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 + .sprite + .render(backbuffer_canvas, &mut atlas, dest) + .err() + .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); + } + Err(e) => { + errors.write(e); + } + } + } + }) + .err() + .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); +} 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..d3ec876 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, @@ -48,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, @@ -57,7 +30,7 @@ pub struct AtlasTile { impl AtlasTile { pub fn render( - &mut self, + &self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, dest: Rect, @@ -68,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) } 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..092ef9e 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::{ @@ -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/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/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/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/formatting.rs b/tests/formatting.rs new file mode 100644 index 0000000..c008aec --- /dev/null +++ b/tests/formatting.rs @@ -0,0 +1,95 @@ +use pacman::systems::formatting::format_timing_display; +use std::time::Duration; + +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(460), Duration::from_nanos(557)), + ] +} + +fn get_formatted_output() -> impl IntoIterator { + format_timing_display(get_timing_data()) +} + +#[test] +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![]; + + 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; + } + } + _ => {} + } + } + }); + + // Assert that all positions were found + assert_eq!( + [ + &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 + ); +} diff --git a/tests/game.rs b/tests/game.rs deleted file mode 100644 index 6578d74..0000000 --- a/tests/game.rs +++ /dev/null @@ -1,13 +0,0 @@ -use pacman::constants::RAW_BOARD; -use pacman::map::builder::Map; - -mod collision; -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/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..04e78d6 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}; +use pacman::map::direction::Direction; +use pacman::map::graph::{Graph, Node, TraversalFlags}; fn create_test_graph() -> Graph { let mut graph = Graph::new(); @@ -30,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()); @@ -79,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] @@ -103,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); @@ -119,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(); @@ -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/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 7a881f9..0000000 --- a/tests/item.rs +++ /dev/null @@ -1,53 +0,0 @@ -use glam::U16Vec2; -use pacman::{ - entity::{ - collision::Collidable, - item::{FruitKind, Item, ItemType}, - }, - texture::sprite::{AtlasTile, Sprite}, -}; -use strum::{EnumCount, IntoEnumIterator}; - -#[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()); -} diff --git a/tests/map_builder.rs b/tests/map_builder.rs index 5fb5df7..33423d0 100644 --- a/tests/map_builder.rs +++ b/tests/map_builder.rs @@ -1,13 +1,12 @@ use glam::Vec2; use pacman::constants::{CELL_SIZE, RAW_BOARD}; use pacman::map::builder::Map; -use sdl2::render::Texture; #[test] 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 @@ -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/profiling.rs b/tests/profiling.rs new file mode 100644 index 0000000..d453cc5 --- /dev/null +++ b/tests/profiling.rs @@ -0,0 +1,40 @@ +use pacman::systems::profiling::{SystemId, SystemTimings}; +use std::time::Duration; + +#[test] +fn test_timing_statistics() { + let timings = SystemTimings::default(); + + // Add some test data + 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(&SystemId::PlayerControls).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); +// } 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); -} 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, })), {