Add 2D endless runner game with pickups and coyote time

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:03:09 +01:00
commit 090f5d4a6d
13 changed files with 1751 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

636
Cargo.lock generated Normal file
View File

@@ -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"

8
Cargo.toml Normal file
View File

@@ -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"] }

129
src/config.rs Normal file
View File

@@ -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<PickupDef>,
// 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,
}
}
}

124
src/effects.rs Normal file
View File

@@ -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
}
}

34
src/enemy.rs Normal file
View File

@@ -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
}
}

143
src/game.rs Normal file
View File

@@ -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));
}
}

109
src/level_gen.rs Normal file
View File

@@ -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<Platform>,
enemies: &mut Vec<Enemy>,
pickups: &mut Vec<Pickup>,
) {
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<Platform>,
enemies: &mut Vec<Enemy>,
pickups: &mut Vec<Pickup>,
) {
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::<f32>() < 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::<f32>() < 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;
}
}

30
src/main.rs Normal file
View File

@@ -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);
}
}

71
src/pickup.rs Normal file
View File

@@ -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<dyn ActiveEffect> {
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
}
}

29
src/platform.rs Normal file
View File

@@ -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
}
}

80
src/player.rs Normal file
View File

@@ -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
}
}

357
src/world.rs Normal file
View File

@@ -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<Platform>,
pub enemies: Vec<Enemy>,
pub pickups: Vec<Pickup>,
pub active_effects: Vec<Box<dyn ActiveEffect>>,
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));
}
}