From 090f5d4a6d96c6108a8d5c355471a13ccffa0719 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 6 Mar 2026 23:03:09 +0100 Subject: [PATCH] Add 2D endless runner game with pickups and coyote time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Platformer endless runner: fixed player x, world scrolls left - Logarithmic speed curve: initial + factor * ln(1 + t / time_scale) - Enemies stomped from above; side/bottom contact kills player - Procedural level generation with StdRng seeded from SystemTime - Object pooling via Vec::retain + frontier-based generator - Coyote time: grace window after leaving platform edge - Data-driven pickup system with trait-based effects (ActiveEffect) - Invulnerability, JumpBoost, ScoreMultiplier — extend via config - Score accumulates with per-effect multiplier; stomp bonuses scaled - Game over screen with score, best score, restart prompt - HUD: score, speed bar, active effect timer bars Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + Cargo.lock | 636 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 8 + src/config.rs | 129 ++++++++++ src/effects.rs | 124 +++++++++ src/enemy.rs | 34 +++ src/game.rs | 143 +++++++++++ src/level_gen.rs | 109 ++++++++ src/main.rs | 30 +++ src/pickup.rs | 71 ++++++ src/platform.rs | 29 +++ src/player.rs | 80 ++++++ src/world.rs | 357 ++++++++++++++++++++++++++ 13 files changed, 1751 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/config.rs create mode 100644 src/effects.rs create mode 100644 src/enemy.rs create mode 100644 src/game.rs create mode 100644 src/level_gen.rs create mode 100644 src/main.rs create mode 100644 src/pickup.rs create mode 100644 src/platform.rs create mode 100644 src/player.rs create mode 100644 src/world.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fcac067 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,636 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "endless_runner" +version = "0.1.0" +dependencies = [ + "rand", + "raylib", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "raylib" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5c54335590d1b6e6fbdbccee09dafdfd76a1111fc3c709eca949e71e81f7a8a" +dependencies = [ + "cfg-if", + "paste", + "raylib-sys", + "seq-macro", + "thiserror", +] + +[[package]] +name = "raylib-sys" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ce5adc950b042db67f1f78f24f7e76563652ce24db032afe9ca9e534d8b7a13" +dependencies = [ + "bindgen", + "cc", + "cmake", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..438e6ea --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "endless_runner" +version = "0.1.0" +edition = "2024" + +[dependencies] +rand = "0.10.0" +raylib = { version = "5.5.1", features = ["wayland"] } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9814ba1 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,129 @@ +use crate::pickup::{PickupDef, PickupEffectType}; + +/// All game parameters in one place — change here to tune the game. +/// Pickup types are also registered here; add a new `PickupDef` entry to extend. +#[derive(Clone)] +pub struct Config { + pub screen_width: i32, + pub screen_height: i32, + + // Player + pub player_x: f32, + pub player_width: f32, + pub player_height: f32, + pub gravity: f32, + pub jump_velocity: f32, + pub stomp_bounce_velocity: f32, + /// Grace window (seconds) after leaving a platform where jumping is still allowed. + pub coyote_time: f32, + + // Speed curve: speed(t) = initial + factor * ln(1 + t / time_scale) + pub initial_speed: f32, + pub speed_log_factor: f32, + pub speed_time_scale: f32, + + // Platforms + pub platform_height: f32, + pub platform_min_width: f32, + pub platform_max_width: f32, + pub platform_gap_min: f32, + pub platform_gap_max: f32, + pub platform_y_min: f32, + pub platform_y_max: f32, + pub platform_y_max_delta: f32, + + // Starting platform + pub start_platform_y: f32, + + // Enemies + pub enemy_width: f32, + pub enemy_height: f32, + pub enemy_spawn_chance: f32, + pub enemy_min_platform_width: f32, + + // Pickups + pub pickup_size: f32, + pub pickup_spawn_chance: f32, + pub pickup_hover_gap: f32, // pixels above platform surface + pub pickup_defs: Vec, + + // Scoring + pub enemy_stomp_bonus: f32, + pub score_per_second: f32, +} + +impl Config { + /// Current scroll speed given elapsed game time. + pub fn scroll_speed(&self, elapsed: f32) -> f32 { + self.initial_speed + + self.speed_log_factor * (1.0 + elapsed / self.speed_time_scale).ln() + } +} + +impl Default for Config { + fn default() -> Self { + let screen_height = 720; + let start_platform_y = screen_height as f32 - 110.0; + Self { + screen_width: 1280, + screen_height, + + player_x: 200.0, + player_width: 38.0, + player_height: 48.0, + gravity: 1900.0, + jump_velocity: -800.0, + stomp_bounce_velocity: -550.0, + coyote_time: 0.12, + + initial_speed: 280.0, + speed_log_factor: 150.0, + speed_time_scale: 30.0, + + platform_height: 20.0, + platform_min_width: 130.0, + platform_max_width: 360.0, + platform_gap_min: 60.0, + platform_gap_max: 210.0, + platform_y_min: 260.0, + platform_y_max: start_platform_y, + platform_y_max_delta: 130.0, + + start_platform_y, + + enemy_width: 40.0, + enemy_height: 40.0, + enemy_spawn_chance: 0.35, + enemy_min_platform_width: 120.0, + + pickup_size: 28.0, + pickup_spawn_chance: 0.28, + pickup_hover_gap: 12.0, + + // ── Register new pickup types here ──────────────────────────────── + pickup_defs: vec![ + PickupDef { + label: "INVINCIBLE", + color: (255, 200, 50), + duration: 5.0, + effect: PickupEffectType::Invulnerability, + }, + PickupDef { + label: "JUMP BOOST", + color: (80, 180, 255), + duration: 6.0, + effect: PickupEffectType::JumpBoost { factor: 1.45 }, + }, + PickupDef { + label: "2X SCORE", + color: (180, 80, 255), + duration: 8.0, + effect: PickupEffectType::ScoreMultiplier { factor: 2.0 }, + }, + ], + + enemy_stomp_bonus: 100.0, + score_per_second: 80.0, + } + } +} diff --git a/src/effects.rs b/src/effects.rs new file mode 100644 index 0000000..07ac61e --- /dev/null +++ b/src/effects.rs @@ -0,0 +1,124 @@ +use raylib::prelude::Color; + +/// Behaviour that an active pickup effect applies each frame. +/// Add a new impl here to introduce a new effect type — no other +/// code changes required beyond registering it in `pickup.rs`. +pub trait ActiveEffect { + /// Tick the effect. Returns `false` when the effect has expired. + fn update(&mut self, dt: f32) -> bool; + + fn label(&self) -> &'static str; + fn tint(&self) -> Color; + + /// Fraction of lifetime remaining (1.0 = just collected, 0.0 = expired). + fn progress(&self) -> f32; + + // --- per-system queries (default = no-op) --- + fn is_invulnerable(&self) -> bool { + false + } + fn jump_multiplier(&self) -> f32 { + 1.0 + } + fn score_multiplier(&self) -> f32 { + 1.0 + } +} + +// ── Concrete effects ────────────────────────────────────────────────────────── + +pub struct InvulnerabilityEffect { + timer: f32, + total: f32, +} + +impl InvulnerabilityEffect { + pub fn new(duration: f32) -> Self { + Self { timer: duration, total: duration } + } +} + +impl ActiveEffect for InvulnerabilityEffect { + fn update(&mut self, dt: f32) -> bool { + self.timer -= dt; + self.timer > 0.0 + } + fn label(&self) -> &'static str { + "INVINCIBLE" + } + fn tint(&self) -> Color { + Color::new(255, 200, 50, 255) + } + fn progress(&self) -> f32 { + (self.timer / self.total).max(0.0) + } + fn is_invulnerable(&self) -> bool { + true + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +pub struct JumpBoostEffect { + timer: f32, + total: f32, + factor: f32, +} + +impl JumpBoostEffect { + pub fn new(duration: f32, factor: f32) -> Self { + Self { timer: duration, total: duration, factor } + } +} + +impl ActiveEffect for JumpBoostEffect { + fn update(&mut self, dt: f32) -> bool { + self.timer -= dt; + self.timer > 0.0 + } + fn label(&self) -> &'static str { + "JUMP BOOST" + } + fn tint(&self) -> Color { + Color::new(80, 180, 255, 255) + } + fn progress(&self) -> f32 { + (self.timer / self.total).max(0.0) + } + fn jump_multiplier(&self) -> f32 { + self.factor + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +pub struct ScoreMultiplierEffect { + timer: f32, + total: f32, + factor: f32, +} + +impl ScoreMultiplierEffect { + pub fn new(duration: f32, factor: f32) -> Self { + Self { timer: duration, total: duration, factor } + } +} + +impl ActiveEffect for ScoreMultiplierEffect { + fn update(&mut self, dt: f32) -> bool { + self.timer -= dt; + self.timer > 0.0 + } + fn label(&self) -> &'static str { + "2X SCORE" + } + fn tint(&self) -> Color { + Color::new(180, 80, 255, 255) + } + fn progress(&self) -> f32 { + (self.timer / self.total).max(0.0) + } + fn score_multiplier(&self) -> f32 { + self.factor + } +} diff --git a/src/enemy.rs b/src/enemy.rs new file mode 100644 index 0000000..06c8e63 --- /dev/null +++ b/src/enemy.rs @@ -0,0 +1,34 @@ +#[derive(Clone)] +pub struct Enemy { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub alive: bool, +} + +impl Enemy { + pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { x, y, width, height, alive: true } + } + + pub fn scroll(&mut self, speed: f32, dt: f32) { + self.x -= speed * dt; + } + + pub fn is_off_screen(&self) -> bool { + self.x + self.width < 0.0 + } + + pub fn top(&self) -> f32 { + self.y + } + + pub fn bottom(&self) -> f32 { + self.y + self.height + } + + pub fn right(&self) -> f32 { + self.x + self.width + } +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 0000000..a2a8a88 --- /dev/null +++ b/src/game.rs @@ -0,0 +1,143 @@ +use raylib::prelude::*; + +use crate::{config::Config, world::World}; + +pub enum GameState { + Playing, + GameOver, +} + +pub struct Game { + cfg: Config, + state: GameState, + pub world: World, + high_score: u64, +} + +impl Game { + pub fn new(cfg: Config) -> Self { + let world = World::new(&cfg); + Self { + state: GameState::Playing, + world, + high_score: 0, + cfg, + } + } + + pub fn update(&mut self, rl: &RaylibHandle, dt: f32) { + match self.state { + GameState::Playing => { + let jump = rl.is_key_pressed(KeyboardKey::KEY_SPACE) + || rl.is_key_pressed(KeyboardKey::KEY_UP) + || rl.is_key_pressed(KeyboardKey::KEY_W); + + self.world.update(dt, jump, &self.cfg); + + if !self.world.player.alive { + if self.world.score > self.high_score { + self.high_score = self.world.score; + } + self.state = GameState::GameOver; + } + } + + GameState::GameOver => { + if rl.is_key_pressed(KeyboardKey::KEY_R) + || rl.is_key_pressed(KeyboardKey::KEY_SPACE) + || rl.is_key_pressed(KeyboardKey::KEY_ENTER) + { + self.world = World::new(&self.cfg); + self.state = GameState::Playing; + } + } + } + } + + pub fn render(&self, d: &mut RaylibDrawHandle) { + self.world.render(d, &self.cfg); + self.draw_hud(d); + + if let GameState::GameOver = self.state { + self.draw_game_over(d); + } + } + + fn draw_hud(&self, d: &mut RaylibDrawHandle) { + let score_text = format!("Score {:>6}", self.world.score); + d.draw_text(&score_text, 16, 14, 28, Color::WHITE); + + let speed = self.cfg.scroll_speed(self.world.elapsed); + let speed_text = format!("Speed {:.0} px/s", speed); + let sw = self.cfg.screen_width; + let tw = d.measure_text(&speed_text, 22); + d.draw_text(&speed_text, sw - tw - 16, 16, 22, Color::new(160, 200, 255, 255)); + + // Speed bar + let bar_w = 160; + let bar_h = 8; + let bar_x = sw - bar_w - 16; + let bar_y = 44; + let max_speed = self.cfg.initial_speed + self.cfg.speed_log_factor * 3.0; + let fill = ((speed / max_speed).min(1.0) * bar_w as f32) as i32; + d.draw_rectangle(bar_x, bar_y, bar_w, bar_h, Color::new(50, 50, 80, 255)); + d.draw_rectangle(bar_x, bar_y, fill, bar_h, Color::new(80, 180, 255, 255)); + + // Active effect timer bars + self.world.draw_active_effects_hud(d, &self.cfg); + } + + fn draw_game_over(&self, d: &mut RaylibDrawHandle) { + let sw = self.cfg.screen_width; + let sh = self.cfg.screen_height; + + // Dim overlay + d.draw_rectangle(0, 0, sw, sh, Color::new(0, 0, 0, 160)); + + // Panel + let panel_w = 520; + let panel_h = 340; + let panel_x = (sw - panel_w) / 2; + let panel_y = (sh - panel_h) / 2; + d.draw_rectangle(panel_x, panel_y, panel_w, panel_h, Color::new(20, 22, 44, 240)); + d.draw_rectangle_lines(panel_x, panel_y, panel_w, panel_h, Color::new(80, 100, 200, 255)); + + // Title + let title = "GAME OVER"; + let ts = 56; + let tw = d.measure_text(title, ts); + d.draw_text(title, (sw - tw) / 2, panel_y + 30, ts, Color::new(220, 60, 60, 255)); + + // Score + let score_str = format!("Score: {}", self.world.score); + let ss = 32; + let sw2 = d.measure_text(&score_str, ss); + d.draw_text(&score_str, (sw - sw2) / 2, panel_y + 120, ss, Color::WHITE); + + // High score + let hi_str = format!("Best: {}", self.high_score); + let hs = 28; + let hw = d.measure_text(&hi_str, hs); + d.draw_text(&hi_str, (sw - hw) / 2, panel_y + 170, hs, Color::GOLD); + + // Separator + d.draw_line( + panel_x + 40, + panel_y + 215, + panel_x + panel_w - 40, + panel_y + 215, + Color::new(60, 70, 130, 255), + ); + + // Restart prompt + let prompt = "SPACE / R / ENTER to restart"; + let ps = 22; + let pw = d.measure_text(prompt, ps); + // Blinking effect based on elapsed time (we don't have time here, so just static) + d.draw_text(prompt, (sw - pw) / 2, panel_y + 240, ps, Color::new(160, 200, 255, 255)); + + let ctrl = "SPACE / W / UP to jump"; + let cw = d.measure_text(ctrl, 18); + d.draw_text(ctrl, (sw - cw) / 2, panel_y + 285, 18, Color::new(100, 130, 180, 255)); + } +} diff --git a/src/level_gen.rs b/src/level_gen.rs new file mode 100644 index 0000000..83390da --- /dev/null +++ b/src/level_gen.rs @@ -0,0 +1,109 @@ +use rand::{RngExt, SeedableRng, rngs::StdRng}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{config::Config, enemy::Enemy, pickup::Pickup, platform::Platform}; + +pub struct LevelGenerator { + rng: StdRng, + /// Screen-space X of the right edge of the last generated platform. + frontier_x: f32, + /// Y of the last generated platform (for smooth height transitions). + frontier_y: f32, +} + +impl LevelGenerator { + pub fn new(cfg: &Config) -> Self { + Self { + rng: StdRng::seed_from_u64( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0xDEAD_BEEF), + ), + frontier_x: cfg.screen_width as f32 * 0.65, + frontier_y: cfg.start_platform_y, + } + } + + /// Move frontier left with the world scroll so new platforms land + /// at the correct screen position. + pub fn scroll(&mut self, speed: f32, dt: f32) { + self.frontier_x -= speed * dt; + } + + /// Generate platforms until the frontier is at least one screen-width + /// past the right edge. + pub fn generate_if_needed( + &mut self, + cfg: &Config, + platforms: &mut Vec, + enemies: &mut Vec, + pickups: &mut Vec, + ) { + let target = cfg.screen_width as f32 + 300.0; + while self.frontier_x < target { + self.place_next(cfg, platforms, enemies, pickups); + } + } + + fn place_next( + &mut self, + cfg: &Config, + platforms: &mut Vec, + enemies: &mut Vec, + pickups: &mut Vec, + ) { + let gap: f32 = self.rng.random_range(cfg.platform_gap_min..cfg.platform_gap_max); + let width: f32 = self.rng.random_range(cfg.platform_min_width..cfg.platform_max_width); + + let dy: f32 = self.rng.random_range(-cfg.platform_y_max_delta..cfg.platform_y_max_delta); + let y = (self.frontier_y + dy) + .max(cfg.platform_y_min) + .min(cfg.platform_y_max); + + let x = self.frontier_x + gap; + + platforms.push(Platform::new(x, y, width, cfg.platform_height)); + + let mut has_enemy = false; + + if width >= cfg.enemy_min_platform_width + && self.rng.random::() < cfg.enemy_spawn_chance + { + let margin = 15.0; + let max_offset = width - cfg.enemy_width - margin * 2.0; + if max_offset > 0.0 { + let ex = x + margin + self.rng.random_range(0.0..max_offset); + let ey = y - cfg.enemy_height; + enemies.push(Enemy::new(ex, ey, cfg.enemy_width, cfg.enemy_height)); + has_enemy = true; + } + } + + if !cfg.pickup_defs.is_empty() && self.rng.random::() < cfg.pickup_spawn_chance { + let def_index = self.rng.random_range(0..cfg.pickup_defs.len()); + + let margin = 20.0; + let px_min = x + margin; + let px_max = x + width - cfg.pickup_size - margin; + + if px_max > px_min { + let mut px: f32 = self.rng.random_range(px_min..px_max); + + if has_enemy { + let enemy_cx = x + width * 0.5; + if (px - enemy_cx).abs() < cfg.enemy_width + cfg.pickup_size { + px = (px + cfg.enemy_width + cfg.pickup_size) + .min(x + width - cfg.pickup_size - margin); + } + } + + let py = y - cfg.pickup_size - cfg.pickup_hover_gap; + pickups.push(Pickup::new(px, py, cfg.pickup_size, def_index)); + } + } + + self.frontier_x = x + width; + self.frontier_y = y; + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..50908b6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,30 @@ +mod config; +mod effects; +mod enemy; +mod game; +mod level_gen; +mod pickup; +mod platform; +mod player; +mod world; + +fn main() { + let cfg = config::Config::default(); + + let (mut rl, thread) = raylib::init() + .size(cfg.screen_width, cfg.screen_height) + .title("Endless Runner") + .build(); + + rl.set_target_fps(60); + + let mut game = game::Game::new(cfg); + + while !rl.window_should_close() { + let dt = rl.get_frame_time().min(0.05); // cap dt to avoid spiral of death + game.update(&rl, dt); + + let mut d = rl.begin_drawing(&thread); + game.render(&mut d); + } +} diff --git a/src/pickup.rs b/src/pickup.rs new file mode 100644 index 0000000..6c0b22d --- /dev/null +++ b/src/pickup.rs @@ -0,0 +1,71 @@ +use crate::effects::{ActiveEffect, InvulnerabilityEffect, JumpBoostEffect, ScoreMultiplierEffect}; + +/// Which gameplay effect this pickup grants. +/// Add a new variant here (and a matching `ActiveEffect` impl) to extend. +#[derive(Clone)] +pub enum PickupEffectType { + Invulnerability, + JumpBoost { factor: f32 }, + ScoreMultiplier { factor: f32 }, +} + +/// Data-driven definition of a pickup type — lives entirely in `Config`. +#[derive(Clone)] +pub struct PickupDef { + pub label: &'static str, + /// RGB colour used for the pickup icon and effect HUD bar. + pub color: (u8, u8, u8), + pub duration: f32, + pub effect: PickupEffectType, +} + +impl PickupDef { + /// Instantiate the corresponding active effect. + pub fn create_effect(&self) -> Box { + match self.effect { + PickupEffectType::Invulnerability => { + Box::new(InvulnerabilityEffect::new(self.duration)) + } + PickupEffectType::JumpBoost { factor } => { + Box::new(JumpBoostEffect::new(self.duration, factor)) + } + PickupEffectType::ScoreMultiplier { factor } => { + Box::new(ScoreMultiplierEffect::new(self.duration, factor)) + } + } + } +} + +// ── In-world pickup instance ────────────────────────────────────────────────── + +pub struct Pickup { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + /// Index into `Config::pickup_defs`. + pub def_index: usize, + pub collected: bool, +} + +impl Pickup { + pub fn new(x: f32, y: f32, size: f32, def_index: usize) -> Self { + Self { x, y, width: size, height: size, def_index, collected: false } + } + + pub fn scroll(&mut self, speed: f32, dt: f32) { + self.x -= speed * dt; + } + + pub fn is_off_screen(&self) -> bool { + self.x + self.width < 0.0 + } + + pub fn right(&self) -> f32 { + self.x + self.width + } + + pub fn bottom(&self) -> f32 { + self.y + self.height + } +} diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..e55147d --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,29 @@ +#[derive(Clone)] +pub struct Platform { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Platform { + pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { x, y, width, height } + } + + pub fn scroll(&mut self, speed: f32, dt: f32) { + self.x -= speed * dt; + } + + pub fn is_off_screen(&self) -> bool { + self.x + self.width < 0.0 + } + + pub fn top(&self) -> f32 { + self.y + } + + pub fn right(&self) -> f32 { + self.x + self.width + } +} diff --git a/src/player.rs b/src/player.rs new file mode 100644 index 0000000..ab5006c --- /dev/null +++ b/src/player.rs @@ -0,0 +1,80 @@ +use crate::config::Config; + +pub struct Player { + pub x: f32, + pub y: f32, + pub prev_y: f32, + pub vy: f32, + pub width: f32, + pub height: f32, + pub on_ground: bool, + pub alive: bool, + /// Countdown from `coyote_time` to 0. Positive value = jump still allowed. + pub coyote_timer: f32, +} + +impl Player { + pub fn new(cfg: &Config) -> Self { + let y = cfg.start_platform_y - cfg.player_height; + Self { + x: cfg.player_x, + y, + prev_y: y, + vy: 0.0, + width: cfg.player_width, + height: cfg.player_height, + on_ground: true, + alive: true, + coyote_timer: cfg.coyote_time, + } + } + + /// Apply gravity and integrate position. Must be called before collision resolution. + pub fn update(&mut self, dt: f32, cfg: &Config) { + self.prev_y = self.y; + + // Refresh coyote window while grounded; tick it down while airborne. + if self.on_ground { + self.coyote_timer = cfg.coyote_time; + } else { + self.coyote_timer = (self.coyote_timer - dt).max(0.0); + } + + self.vy += cfg.gravity * dt; + self.y += self.vy * dt; + + // Reset ground flag; platform collision will set it back. + self.on_ground = false; + + if self.y > cfg.screen_height as f32 + 50.0 { + self.alive = false; + } + } + + /// Jump if the coyote window is open (on ground OR just left a platform). + /// `jump_multiplier` comes from active effects. + pub fn jump(&mut self, cfg: &Config, jump_multiplier: f32) { + if self.coyote_timer > 0.0 { + self.vy = cfg.jump_velocity * jump_multiplier; + self.coyote_timer = 0.0; // consume the window + self.on_ground = false; + } + } + + pub fn stomp_bounce(&mut self, cfg: &Config) { + self.vy = cfg.stomp_bounce_velocity; + self.on_ground = false; + } + + pub fn bottom(&self) -> f32 { + self.y + self.height + } + + pub fn prev_bottom(&self) -> f32 { + self.prev_y + self.height + } + + pub fn right(&self) -> f32 { + self.x + self.width + } +} diff --git a/src/world.rs b/src/world.rs new file mode 100644 index 0000000..7ec9a99 --- /dev/null +++ b/src/world.rs @@ -0,0 +1,357 @@ +use raylib::prelude::*; + +use crate::{ + config::Config, + effects::ActiveEffect, + enemy::Enemy, + level_gen::LevelGenerator, + pickup::Pickup, + platform::Platform, + player::Player, +}; + +pub struct World { + pub player: Player, + pub platforms: Vec, + pub enemies: Vec, + pub pickups: Vec, + pub active_effects: Vec>, + pub score: u64, + pub elapsed: f32, + score_f: f32, + level_gen: LevelGenerator, +} + +impl World { + pub fn new(cfg: &Config) -> Self { + let start_platform = Platform::new( + 0.0, + cfg.start_platform_y, + cfg.screen_width as f32 * 0.65, + cfg.platform_height, + ); + + let mut platforms = vec![start_platform]; + let mut enemies = Vec::new(); + let mut pickups = Vec::new(); + let mut level_gen = LevelGenerator::new(cfg); + level_gen.generate_if_needed(cfg, &mut platforms, &mut enemies, &mut pickups); + + Self { + player: Player::new(cfg), + platforms, + enemies, + pickups, + active_effects: Vec::new(), + score: 0, + score_f: 0.0, + elapsed: 0.0, + level_gen, + } + } + + pub fn update(&mut self, dt: f32, jump_pressed: bool, cfg: &Config) { + self.elapsed += dt; + let speed = cfg.scroll_speed(self.elapsed); + + // 1. Physics + self.player.update(dt, cfg); + + // 2. Scroll world + for p in &mut self.platforms { + p.scroll(speed, dt); + } + for e in &mut self.enemies { + e.scroll(speed, dt); + } + for pk in &mut self.pickups { + pk.scroll(speed, dt); + } + self.level_gen.scroll(speed, dt); + + // 3. Resolve collisions (sets on_ground / alive) + self.resolve_platform_collisions(); + self.resolve_enemy_collisions(cfg); + self.collect_pickups(cfg); + + // 4. Jump input — uses coyote_timer set during update, and effect multiplier + if jump_pressed { + let mult = self.jump_multiplier(); + self.player.jump(cfg, mult); + } + + // 5. Tick active effects; remove expired ones + self.active_effects.retain_mut(|e| e.update(dt)); + + // 6. Recycle off-screen objects + self.platforms.retain(|p| !p.is_off_screen()); + self.enemies.retain(|e| !e.is_off_screen() && e.alive); + self.pickups.retain(|pk| !pk.is_off_screen() && !pk.collected); + + // 7. Generate new content ahead + self.level_gen.generate_if_needed( + cfg, + &mut self.platforms, + &mut self.enemies, + &mut self.pickups, + ); + + // 8. Accumulate score with current multiplier + let mult = self.score_multiplier(); + self.score_f += cfg.score_per_second * mult * dt; + self.score = self.score_f as u64; + } + + // ── Effect queries ──────────────────────────────────────────────────────── + + pub fn is_invulnerable(&self) -> bool { + self.active_effects.iter().any(|e| e.is_invulnerable()) + } + + fn jump_multiplier(&self) -> f32 { + self.active_effects.iter().fold(1.0_f32, |acc, e| acc * e.jump_multiplier()) + } + + fn score_multiplier(&self) -> f32 { + self.active_effects.iter().fold(1.0_f32, |acc, e| acc * e.score_multiplier()) + } + + // ── Collision resolution ────────────────────────────────────────────────── + + fn resolve_platform_collisions(&mut self) { + if !self.player.alive { + return; + } + let ph = self.player.height; + let px = self.player.x; + + let prev_bottom = self.player.prev_bottom(); + let curr_bottom = self.player.bottom(); + + for platform in &self.platforms { + let overlaps_x = self.player.right() > platform.x + 4.0 + && px < platform.right() - 4.0; + if !overlaps_x { + continue; + } + + // Top-landing: bottom crossed platform surface this frame while falling. + if self.player.vy >= 0.0 + && prev_bottom <= platform.top() + 1.0 + && curr_bottom >= platform.top() + { + self.player.y = platform.top() - ph; + self.player.vy = 0.0; + self.player.on_ground = true; + break; + } + } + } + + fn resolve_enemy_collisions(&mut self, cfg: &Config) { + if !self.player.alive { + return; + } + let px = self.player.x; + let prev_bottom = self.player.prev_bottom(); + let curr_bottom = self.player.bottom(); + let player_top = self.player.y; + // Precompute effect queries before the mutable borrow of self.enemies. + let invulnerable = self.is_invulnerable(); + let score_mult = self.score_multiplier(); + + for enemy in &mut self.enemies { + if !enemy.alive { + continue; + } + + let overlaps_x = self.player.right() > enemy.x && px < enemy.right(); + if !overlaps_x { + continue; + } + + let overlaps_y = curr_bottom > enemy.top() && player_top < enemy.bottom(); + if !overlaps_y { + continue; + } + + // Stomp: player was above enemy top and crosses it while falling. + let stomp = self.player.vy > 0.0 + && prev_bottom <= enemy.top() + 8.0 + && curr_bottom >= enemy.top(); + + if stomp { + enemy.alive = false; + self.player.stomp_bounce(cfg); + self.score_f += cfg.enemy_stomp_bonus * score_mult; + self.score = self.score_f as u64; + } else if !invulnerable { + self.player.alive = false; + return; + } + // invulnerable + non-stomp → pass through + } + } + + fn collect_pickups(&mut self, cfg: &Config) { + if !self.player.alive { + return; + } + let px = self.player.x; + let curr_bottom = self.player.bottom(); + let player_top = self.player.y; + + for pickup in &mut self.pickups { + if pickup.collected { + continue; + } + + let overlaps_x = self.player.right() > pickup.x && px < pickup.right(); + let overlaps_y = curr_bottom > pickup.y && player_top < pickup.bottom(); + + if overlaps_x && overlaps_y { + pickup.collected = true; + let effect = cfg.pickup_defs[pickup.def_index].create_effect(); + self.active_effects.push(effect); + } + } + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + pub fn render(&self, d: &mut RaylibDrawHandle, cfg: &Config) { + // Sky + d.draw_rectangle(0, 0, cfg.screen_width, cfg.screen_height, + Color::new(22, 24, 46, 255)); + d.draw_rectangle(0, cfg.screen_height / 2, cfg.screen_width, cfg.screen_height / 2, + Color::new(18, 20, 38, 255)); + + self.draw_platforms(d); + self.draw_pickups(d, cfg); + self.draw_enemies(d); + self.draw_player(d); + } + + /// Draw effect timer bars; called from game HUD so it layers above the world. + pub fn draw_active_effects_hud(&self, d: &mut RaylibDrawHandle, cfg: &Config) { + let bar_w = 180; + let bar_h = 14; + let x0 = 16; + let mut y = 58; + + for effect in &self.active_effects { + let tint = effect.tint(); + let (r, g, b, _) = (tint.r, tint.g, tint.b, tint.a); + + // Background + d.draw_rectangle(x0, y, bar_w, bar_h, Color::new(30, 30, 55, 200)); + // Fill + let fill = (effect.progress() * bar_w as f32) as i32; + d.draw_rectangle(x0, y, fill, bar_h, Color::new(r, g, b, 200)); + // Border + d.draw_rectangle_lines(x0, y, bar_w, bar_h, Color::new(r, g, b, 160)); + // Label + let lbl = effect.label(); + d.draw_text(lbl, x0 + 4, y + 1, 11, + Color::new(cfg.screen_width as u8, 255, 255, 255)); // reuse white + d.draw_text(lbl, x0 + 4, y + 1, 11, Color::WHITE); + + y += bar_h + 4; + } + } + + fn draw_platforms(&self, d: &mut RaylibDrawHandle) { + for p in &self.platforms { + d.draw_rectangle(p.x as i32, p.y as i32, p.width as i32, p.height as i32, + Color::new(58, 160, 72, 255)); + d.draw_rectangle(p.x as i32, p.y as i32, p.width as i32, 4, + Color::new(100, 220, 110, 255)); + d.draw_rectangle(p.x as i32, (p.y + p.height - 4.0) as i32, p.width as i32, 4, + Color::new(38, 110, 50, 255)); + } + } + + fn draw_pickups(&self, d: &mut RaylibDrawHandle, cfg: &Config) { + let s = cfg.pickup_size as i32; + // Animate a gentle bob using elapsed time + let bob = (self.elapsed * 3.0).sin() * 3.0; + + for pickup in &self.pickups { + if pickup.collected { + continue; + } + + let def = &cfg.pickup_defs[pickup.def_index]; + let (r, g, b) = def.color; + let x = pickup.x as i32; + let y = (pickup.y + bob) as i32; + + // Glow shadow + d.draw_rectangle(x - 2, y - 2, s + 4, s + 4, Color::new(r, g, b, 60)); + // Body + d.draw_rectangle(x, y, s, s, Color::new(r, g, b, 220)); + // Shine + d.draw_rectangle(x + 3, y + 3, s / 3, s / 3, Color::new(255, 255, 255, 100)); + // Border + d.draw_rectangle_lines(x, y, s, s, Color::WHITE); + // First letter of label + let letter = &def.label[..1]; + let lw = d.measure_text(letter, 16); + d.draw_text(letter, x + (s - lw) / 2, y + (s - 16) / 2, 16, Color::WHITE); + } + } + + fn draw_enemies(&self, d: &mut RaylibDrawHandle) { + for e in &self.enemies { + if !e.alive { + continue; + } + d.draw_rectangle(e.x as i32, e.y as i32, e.width as i32, e.height as i32, + Color::new(210, 50, 50, 255)); + + let eye_y = (e.y + e.height * 0.28) as i32; + d.draw_circle((e.x + e.width * 0.28) as i32, eye_y, 5.0, Color::WHITE); + d.draw_circle((e.x + e.width * 0.72) as i32, eye_y, 5.0, Color::WHITE); + d.draw_circle((e.x + e.width * 0.28) as i32 + 1, eye_y + 1, 2.5, Color::BLACK); + d.draw_circle((e.x + e.width * 0.72) as i32 + 1, eye_y + 1, 2.5, Color::BLACK); + + let bx = e.x as i32; + let bw = e.width as i32; + let by = eye_y - 10; + d.draw_line(bx + 4, by - 2, bx + bw / 2 - 2, by + 4, Color::new(140, 20, 20, 255)); + d.draw_line(bx + bw / 2 + 2, by + 4, bx + bw - 4, by - 2, Color::new(140, 20, 20, 255)); + } + } + + fn draw_player(&self, d: &mut RaylibDrawHandle) { + let p = &self.player; + if !p.alive { + return; + } + + let x = p.x as i32; + let y = p.y as i32; + let w = p.width as i32; + let h = p.height as i32; + + // Invulnerability pulsing outline + if self.is_invulnerable() { + let pulse = ((self.elapsed * 8.0).sin() * 0.5 + 0.5) as f32; + let alpha = (150.0 + pulse * 105.0) as u8; + d.draw_rectangle(x - 3, y - 3, w + 6, h + 6, Color::new(255, 200, 50, alpha)); + } + + let body_color = if p.on_ground { + Color::new(60, 120, 220, 255) + } else { + Color::new(80, 160, 255, 255) + }; + d.draw_rectangle(x, y + h / 4, w, h * 3 / 4, body_color); + d.draw_rectangle(x + 4, y, w - 8, h / 2, Color::new(255, 210, 140, 255)); + d.draw_rectangle(x + 8, y + 8, 6, 6, Color::WHITE); + d.draw_rectangle(x + w - 14, y + 8, 6, 6, Color::WHITE); + d.draw_rectangle(x + 10, y + 10, 3, 3, Color::BLACK); + d.draw_rectangle(x + w - 12, y + 10, 3, 3, Color::BLACK); + d.draw_rectangle(x, y + h / 4, w, 5, Color::new(220, 60, 60, 255)); + } +}