diff --git a/Makefile b/Makefile index 7585e8e..c0949aa 100644 --- a/Makefile +++ b/Makefile @@ -128,9 +128,9 @@ web-serve: web @echo "" @echo "Serving at http://localhost:$(WEB_PORT)/jnr.html" @echo "Ctrl+C to stop." - @python3 -m http.server $(WEB_PORT) --directory dist-web 2>/dev/null || \ - python -m http.server $(WEB_PORT) --directory dist-web 2>/dev/null || \ - echo "Python not found." + @python3 -m http.server $(WEB_PORT) --directory dist-web || \ + python -m http.server $(WEB_PORT) --directory dist-web || \ + echo "Error: Python not found. Install python3 to serve." # ── Windows cross-compilation ─────────────────── WIN_DIST := dist-win64 diff --git a/TODO.md b/TODO.md index 56e5d6e..3f6472a 100644 --- a/TODO.md +++ b/TODO.md @@ -67,3 +67,41 @@ blue-white flare, 18 particles mixed into regular burst) and `particle_emit_jetpack_boost_trail()` (blue sparks + pale blue wisps, 3 particles/frame). Both activate only when `jetpack_boost_timer > 0`. Burst fires on dash start, trail emits each frame during dash. + +## ~~Pause menu~~ ✓ +Implemented: extracted bitmap font from `editor.c` into shared `engine/font` +module (`font.h`/`font.c`). Added `MODE_PAUSED` game state to `main.c` with +semi-transparent overlay, menu items (Resume / Restart / Quit), up/down +navigation, confirm with jump/enter. Restart reloads file-based levels or +regenerates procedural levels. + +## ~~Laser turret hazard~~ ✓ +Implemented: `ENT_LASER_TURRET` entity with state machine (IDLE → CHARGING → +FIRING → COOLDOWN). Beam uses per-pixel raycast via `tilemap_is_solid()`. +Player damage via point-to-line distance check. Rendering with +`SDL_RenderDrawLine` and perpendicular offset for beam thickness. +Two variants: `laser_turret` (fixed, aims left) and `laser_turret_track` +(rotates toward player at 1.5 rad/s during idle/charge, locks on fire). +Both registered in entity registry with editor icons. + +## ~~New enemies: Charger and Spawner~~ ✓ +Implemented in `enemy.h`/`enemy.c`: +- **Charger** — Ground patrol → ALERT (0.5 s telegraph) → CHARGE (150 px/s + rush) → STUNNED (0.8 s on wall hit, reverses). 2 HP. Detects player in + horizontal line-of-sight within 200 px. +- **Spawner** — Stationary. Spawns grunts every 4.5 s (max 3 alive via + `count_alive_grunts()`). Pulses before spawn. 3 HP, destructible. Purple + color scheme. Both registered in entity registry with editor icons. + +## ~~Mars campaign~~ ✓ +Implemented: two handcrafted levels plus procedural generator support. +- **mars01.lvl** (250×23, Mars Surface): low gravity (370), wind, wide-open + red terrain, charger + grunt enemies, spacecraft intro. New + `mars_tileset.png` and `PARALLAX_STYLE_MARS` (salmon sky, red mesas, dust). +- **mars02.lvl** (40×46, Mars Base): normal gravity (700), tall vertical + corridors with narrow passages, turrets, laser turrets (fixed + tracking), + spawners, chargers, grunts. Victory exit at bottom. +- Generator: `THEME_MARS_SURFACE` / `THEME_MARS_BASE` with per-theme gravity, + bg color, parallax style, tileset path, segment probabilities, and height + zone assignment. Mars themes added to procedural progression (6 options). +- Moon campaign now chains to Mars: moon03 → mars01 → mars02 → victory. diff --git a/assets/levels/mars01.lvl b/assets/levels/mars01.lvl new file mode 100644 index 0000000..fe266df --- /dev/null +++ b/assets/levels/mars01.lvl @@ -0,0 +1,62 @@ +# Mars Surface - Red Dusty Plains +# ================================ +# First Mars level: low gravity, wide-open terrain with wind. +# Spacecraft landing intro from moon. Charger enemies patrol +# the dusty landscape. Gun pickup midway through. + +TILESET assets/tiles/mars_tileset.png +SIZE 250 23 +SPAWN 3 18 +GRAVITY 370 +WIND 25 +BG_COLOR 30 12 8 +PARALLAX_STYLE 5 +MUSIC assets/sounds/algardalgar.ogg + +ENTITY spacecraft 1 14 + +# Charger patrols across flat sections +ENTITY charger 40 18 +ENTITY charger 75 18 +ENTITY charger 120 14 +ENTITY charger 165 18 +ENTITY charger 200 18 + +# Grunts near structures +ENTITY grunt 55 18 +ENTITY grunt 140 18 + +# Health and gun pickups +ENTITY powerup_hp 90 15 +ENTITY powerup_gun 130 12 + +EXIT 246 17 2 3 assets/levels/mars02.lvl + +# Tile definitions (Mars tileset) +TILEDEF 1 0 0 1 +TILEDEF 2 1 0 1 +TILEDEF 3 2 0 1 +TILEDEF 4 0 1 2 + +LAYER collision +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 2 1 1 1 2 1 1 1 1 2 1 1 1 1 1 1 1 1 2 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 2 1 1 1 1 1 1 1 2 1 1 1 2 1 1 1 1 0 0 0 0 0 1 2 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 2 1 1 2 1 1 2 1 1 1 1 1 0 0 0 0 0 1 2 1 1 1 1 1 2 1 1 1 1 2 1 1 2 1 1 1 1 1 1 1 1 2 1 1 1 1 1 2 1 1 1 1 1 1 2 1 1 1 2 1 1 1 2 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 2 1 1 2 1 1 1 1 1 1 1 2 1 1 1 1 2 1 1 1 1 1 1 1 1 1 +1 3 1 1 3 1 1 3 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 3 1 1 3 1 1 3 1 0 0 0 0 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 3 1 3 1 0 0 0 0 0 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 1 1 3 1 1 3 1 3 1 1 3 1 0 0 0 0 0 0 1 1 3 1 1 3 1 1 3 1 1 1 1 3 1 1 3 1 3 1 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 1 3 1 1 3 1 1 3 0 0 0 0 0 1 3 1 1 3 1 3 1 1 1 3 1 1 3 1 1 1 3 1 1 3 1 3 1 1 1 3 1 1 3 1 1 3 1 3 1 1 1 3 1 3 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 1 3 1 1 3 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 1 3 1 1 3 1 3 1 1 3 1 3 +3 1 3 3 1 3 3 1 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 1 3 3 1 3 3 1 3 0 0 0 0 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 1 3 1 3 0 0 0 0 0 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 3 3 1 3 3 1 3 1 3 3 1 3 0 0 0 0 0 0 3 3 1 3 3 1 3 3 1 3 3 3 3 1 3 3 1 3 1 3 3 1 3 3 3 1 3 1 3 3 3 1 3 3 3 1 3 3 1 3 3 1 3 3 1 0 0 0 0 0 3 1 3 3 1 3 1 3 3 3 1 3 3 1 3 3 3 1 3 3 1 3 1 3 3 3 1 3 3 1 3 3 1 3 1 3 3 3 1 3 1 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 3 1 3 3 1 3 3 1 3 1 3 3 3 1 3 1 3 3 3 1 3 1 3 3 3 1 3 3 1 3 3 1 3 1 3 3 1 3 1 diff --git a/assets/levels/mars02.lvl b/assets/levels/mars02.lvl new file mode 100644 index 0000000..a18febb --- /dev/null +++ b/assets/levels/mars02.lvl @@ -0,0 +1,108 @@ +# Mars Base - Underground Corridors +# ================================= +# Second Mars level: normal gravity, tall vertical layout. +# Narrow corridors with 90-degree turns, heavy enemy presence. +# Spacecraft intro from mars surface. Laser turrets, spawners, +# turrets, and chargers guard the base interior. + +TILESET assets/tiles/mars_tileset.png +SIZE 40 46 +SPAWN 3 7 +GRAVITY 700 +BG_COLOR 20 10 6 +PARALLAX_STYLE 5 +MUSIC assets/sounds/algardalgar.ogg + +ENTITY spacecraft 1 3 + +# Upper zone - entry corridors (rows 0-17) +ENTITY turret 18 6 +ENTITY laser_turret 30 6 +ENTITY grunt 10 8 +ENTITY grunt 25 8 +ENTITY charger 14 16 +ENTITY powerup_hp 20 14 + +# Mid zone - vertical shaft and crossover (rows 17-30) +ENTITY laser_turret_track 6 20 +ENTITY spawner 34 20 +ENTITY turret 22 24 +ENTITY grunt 28 24 +ENTITY charger 10 28 +ENTITY powerup_jet 18 22 + +# Lower zone - base core (rows 30-43) +ENTITY laser_turret 15 32 +ENTITY laser_turret_track 32 36 +ENTITY spawner 8 38 +ENTITY turret 28 38 +ENTITY charger 20 42 +ENTITY grunt 35 42 +ENTITY powerup_hp 24 34 +ENTITY powerup_gun 12 42 + +# Exit at bottom right — victory (end of Mars campaign) +EXIT 36 40 2 3 + +# Tile definitions (Mars tileset) +TILEDEF 1 0 0 1 +TILEDEF 2 1 0 1 +TILEDEF 3 2 0 1 +TILEDEF 4 0 1 2 + +# Collision layer (40 wide x 46 tall) +# Layout: narrow corridors with vertical shafts connecting zones +# W = wall (1), P = platform (4), 0 = air +# +# Upper zone (rows 0-17): entry from top-left, horizontal corridor +# with a drop into the shaft system +# Mid zone (rows 17-30): vertical shaft on the left, rooms on right +# Lower zone (rows 30-43): base core, final gauntlet +# Bottom rows (44-45): solid floor +LAYER collision +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 1 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 0 4 4 4 0 0 0 0 4 4 4 0 0 0 0 4 4 4 0 0 0 0 0 0 1 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 +1 4 4 4 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 4 4 4 0 0 0 0 0 0 4 4 4 0 1 0 0 0 4 4 4 0 0 0 0 1 0 0 0 0 1 +1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 1 1 1 1 1 1 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 4 4 4 0 0 0 1 0 0 0 0 4 4 4 0 0 0 0 0 0 4 4 4 1 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 1 1 1 0 0 0 1 1 1 1 0 0 0 1 1 1 1 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 1 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 4 4 4 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 1 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 +1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 diff --git a/assets/levels/moon03.lvl b/assets/levels/moon03.lvl index 1981af2..05668ad 100644 --- a/assets/levels/moon03.lvl +++ b/assets/levels/moon03.lvl @@ -22,7 +22,7 @@ ENTITY asteroid 125 0 # Gun powerup near the exit — the player finally gets armed ENTITY powerup_gun 130 18 -EXIT 146 17 2 3 assets/levels/level01.lvl +EXIT 146 17 2 3 assets/levels/mars01.lvl # Tile definitions TILEDEF 1 0 0 1 diff --git a/assets/tiles/mars_tileset.png b/assets/tiles/mars_tileset.png new file mode 100644 index 0000000..0c915a7 Binary files /dev/null and b/assets/tiles/mars_tileset.png differ diff --git a/src/engine/entity.h b/src/engine/entity.h index ad06bd0..b0bb999 100644 --- a/src/engine/entity.h +++ b/src/engine/entity.h @@ -28,6 +28,9 @@ typedef enum EntityType { ENT_DRONE, ENT_ASTEROID, ENT_SPACECRAFT, + ENT_LASER_TURRET, + ENT_ENEMY_CHARGER, + ENT_SPAWNER, ENT_TYPE_COUNT } EntityType; diff --git a/src/engine/font.c b/src/engine/font.c new file mode 100644 index 0000000..c54e0dc --- /dev/null +++ b/src/engine/font.c @@ -0,0 +1,121 @@ +#include "engine/font.h" +#include + +/* ═══════════════════════════════════════════════════ + * Minimal 4x7 bitmap font + * + * Each character is 4 pixels wide, 7 pixels tall. + * Stored as 7 rows of 4 bits (packed in a uint32_t). + * Covers ASCII 32-95 (space through underscore). + * Lowercase maps to uppercase automatically. + * ═══════════════════════════════════════════════════ */ + +/* 4-bit rows packed: row0 in bits 24-27, row1 in 20-23, etc. + * Bit order: MSB = leftmost pixel. */ +static const uint32_t s_font_glyphs[64] = { + /* */ 0x000000, + /* ! */ 0x4444404, + /* " */ 0xAA0000, + /* # */ 0xAFAFA0, + /* $ */ 0x4E6E40, + /* % */ 0x924924, + /* & */ 0x4A4AC0, + /* ' */ 0x440000, + /* ( */ 0x248840, + /* ) */ 0x842240, + /* * */ 0xA4A000, + /* + */ 0x04E400, + /* , */ 0x000048, + /* - */ 0x00E000, + /* . */ 0x000040, + /* / */ 0x224880, + /* 0 */ 0x6999960, + /* 1 */ 0x2622620, + /* 2 */ 0x6912460, + /* 3 */ 0x6921960, + /* 4 */ 0x2AAF220, + /* 5 */ 0xF88E1E0, + /* 6 */ 0x688E960, + /* 7 */ 0xF112440, + /* 8 */ 0x6966960, + /* 9 */ 0x6997120, + /* : */ 0x040400, + /* ; */ 0x040480, + /* < */ 0x248420, + /* = */ 0x0E0E00, + /* > */ 0x842480, + /* ? */ 0x6920400, + /* @ */ 0x69B9860, + /* A */ 0x699F990, + /* B */ 0xE99E9E0, + /* C */ 0x6988960, + /* D */ 0xE999E00, + /* E */ 0xF8E8F00, + /* F */ 0xF8E8800, + /* G */ 0x698B960, + /* H */ 0x99F9900, + /* I */ 0xE444E00, + /* J */ 0x7111960, + /* K */ 0x9ACA900, + /* L */ 0x8888F00, + /* M */ 0x9FF9900, + /* N */ 0x9DDB900, + /* O */ 0x6999600, + /* P */ 0xE99E800, + /* Q */ 0x6999A70, + /* R */ 0xE99EA90, + /* S */ 0x698E960, + /* T */ 0xF444400, + /* U */ 0x9999600, + /* V */ 0x999A400, + /* W */ 0x999FF90, + /* X */ 0x996690, + /* Y */ 0x996440, + /* Z */ 0xF12480, + /* [ */ 0x688860, + /* \ */ 0x884220, + /* ] */ 0x622260, + /* ^ */ 0x4A0000, + /* _ */ 0x00000F, +}; + +void font_draw_char(SDL_Renderer *r, char ch, int x, int y, SDL_Color col) { + int idx = 0; + if (ch >= 'a' && ch <= 'z') ch -= 32; /* to uppercase */ + if (ch >= 32 && ch <= 95) idx = ch - 32; + else return; + + uint32_t glyph = s_font_glyphs[idx]; + SDL_SetRenderDrawColor(r, col.r, col.g, col.b, col.a); + + for (int row = 0; row < FONT_H; row++) { + int shift = (FONT_H - 1 - row) * 4; + int bits = (glyph >> shift) & 0xF; + for (int col_bit = 0; col_bit < FONT_W; col_bit++) { + if (bits & (1 << (FONT_W - 1 - col_bit))) { + SDL_RenderDrawPoint(r, x + col_bit, y + row); + } + } + } +} + +void font_draw_text(SDL_Renderer *r, const char *text, int x, int y, SDL_Color col) { + while (*text) { + font_draw_char(r, *text, x, y, col); + x += FONT_W + FONT_SPACING; + text++; + } +} + +void font_draw_text_centered(SDL_Renderer *r, const char *text, int y, + int total_width, SDL_Color col) { + int w = font_text_width(text); + int x = (total_width - w) / 2; + font_draw_text(r, text, x, y, col); +} + +int font_text_width(const char *text) { + int len = (int)strlen(text); + if (len == 0) return 0; + return len * (FONT_W + FONT_SPACING) - FONT_SPACING; +} diff --git a/src/engine/font.h b/src/engine/font.h new file mode 100644 index 0000000..2f5d826 --- /dev/null +++ b/src/engine/font.h @@ -0,0 +1,32 @@ +#ifndef JNR_FONT_H +#define JNR_FONT_H + +#include + +/* ═══════════════════════════════════════════════════ + * Minimal 4x7 bitmap font + * + * Each character is 4 pixels wide, 7 pixels tall. + * Covers ASCII 32-95 (space through underscore). + * Lowercase maps to uppercase automatically. + * ═══════════════════════════════════════════════════ */ + +#define FONT_W 4 +#define FONT_H 7 +#define FONT_SPACING 1 /* 1px gap between characters */ + +/* Draw a single character at pixel position (x, y). */ +void font_draw_char(SDL_Renderer *r, char ch, int x, int y, SDL_Color col); + +/* Draw a null-terminated string at pixel position (x, y). */ +void font_draw_text(SDL_Renderer *r, const char *text, int x, int y, SDL_Color col); + +/* Draw text centered horizontally on the given y, within a region of + * total_width pixels starting at x=0. */ +void font_draw_text_centered(SDL_Renderer *r, const char *text, int y, + int total_width, SDL_Color col); + +/* Return the pixel width of a string (without trailing spacing). */ +int font_text_width(const char *text); + +#endif /* JNR_FONT_H */ diff --git a/src/engine/parallax.c b/src/engine/parallax.c index 27f885a..0ab8b21 100644 --- a/src/engine/parallax.c +++ b/src/engine/parallax.c @@ -900,6 +900,156 @@ static void generate_moon_near(Parallax *p, SDL_Renderer *renderer) { p->near_layer.owns_texture = true; } +/* ── Mars: salmon sky, red mesas, dust ──────────────── */ + +static void generate_mars_far(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(91); + + /* Salmon/butterscotch sky gradient (upper half) */ + for (int y = 0; y < h / 2; y++) { + float t = (float)y / (float)(h / 2); + uint8_t r = clamp_u8((int)(60 + 40 * t)); + uint8_t g = clamp_u8((int)(25 + 20 * t)); + uint8_t b = clamp_u8((int)(15 + 10 * t)); + SDL_SetRenderDrawColor(renderer, r, g, b, (uint8_t)(20 + (int)(30 * t))); + SDL_Rect row = {0, y, w, 1}; + SDL_RenderFillRect(renderer, &row); + } + + /* Dim stars visible through thin atmosphere */ + for (int i = 0; i < 30; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h * 0.35f); + uint8_t bright = (uint8_t)(50 + (int)(randf() * 60)); + SDL_SetRenderDrawColor(renderer, bright, clamp_u8(bright - 15), + clamp_u8(bright - 30), (uint8_t)(60 + (int)(randf() * 40))); + SDL_Rect dot = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &dot); + } + + /* Distant mesa/mountain silhouette — flat-topped with vertical cliffs. */ + int base_y = (int)(h * 0.65f); + for (int x = 0; x < w; x++) { + float t = (float)x / (float)w; + /* Mesa profile: flat plateaus interrupted by steep drops. */ + float mesa = sinf(t * 6.28f * 1.5f + 0.8f) * 12.0f; + float ridge = sinf(t * 6.28f * 4.1f + 2.0f) * 6.0f; + float detail = sinf(t * 6.28f * 9.7f) * 3.0f; + /* Flatten tops: clamp positive values to create plateaus */ + float profile = mesa + ridge + detail; + if (profile > 8.0f) profile = 8.0f + (profile - 8.0f) * 0.2f; + int peak = base_y - (int)profile; + if (peak > base_y + 5) peak = base_y + 5; + + for (int y = peak; y < h; y++) { + int depth = y - peak; + /* Dark reddish-brown silhouette */ + uint8_t r = clamp_u8(25 + depth / 4); + uint8_t g = clamp_u8(10 + depth / 8); + uint8_t b = clamp_u8(8 + depth / 10); + uint8_t a = (uint8_t)(depth < 2 ? 100 : 160); + SDL_SetRenderDrawColor(renderer, r, g, b, a); + SDL_Rect px = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &px); + } + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->far_layer.texture = tex; + p->far_layer.tex_w = w; + p->far_layer.tex_h = h; + p->far_layer.scroll_x = 0.03f; + p->far_layer.scroll_y = 0.03f; + p->far_layer.active = true; + p->far_layer.owns_texture = true; +} + +static void generate_mars_near(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(203); + + /* Dust haze bands in warm reds and oranges */ + typedef struct { uint8_t r, g, b; } DustColor; + DustColor dust_palette[] = { + {100, 40, 20}, /* rust */ + { 80, 30, 15}, /* dark rust */ + { 90, 50, 25}, /* sandy rust */ + {110, 45, 30}, /* bright rust */ + { 70, 35, 35}, /* muted red */ + }; + int dust_count = sizeof(dust_palette) / sizeof(dust_palette[0]); + + for (int band = 0; band < 5; band++) { + float cy = randf() * h * 0.6f + h * 0.2f; + DustColor col = dust_palette[band % dust_count]; + int blobs = 15 + (int)(randf() * 20); + for (int b = 0; b < blobs; b++) { + int bx = (int)(randf() * w); + int by = (int)(cy + (randf() - 0.5f) * 60.0f); + int bw = 20 + (int)(randf() * 40); + int bh = 4 + (int)(randf() * 8); + uint8_t br = clamp_u8(col.r + (int)(randf() * 20 - 10)); + uint8_t bg = clamp_u8(col.g + (int)(randf() * 15 - 7)); + uint8_t bb = clamp_u8(col.b + (int)(randf() * 15 - 7)); + SDL_SetRenderDrawColor(renderer, br, bg, bb, + (uint8_t)(5 + (int)(randf() * 10))); + SDL_Rect rect = {bx - bw / 2, by - bh / 2, bw, bh}; + SDL_RenderFillRect(renderer, &rect); + } + } + + /* Scattered dust particles */ + for (int i = 0; i < 50; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h); + DustColor col = dust_palette[(int)(randf() * dust_count)]; + SDL_SetRenderDrawColor(renderer, col.r, col.g, col.b, + (uint8_t)(20 + (int)(randf() * 30))); + SDL_Rect dot = {x, y, 1 + (int)(randf() * 2), 1}; + SDL_RenderFillRect(renderer, &dot); + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->near_layer.texture = tex; + p->near_layer.tex_w = w; + p->near_layer.tex_h = h; + p->near_layer.scroll_x = 0.10f; + p->near_layer.scroll_y = 0.06f; + p->near_layer.active = true; + p->near_layer.owns_texture = true; +} + /* ── Themed parallax dispatcher ─────────────────────── */ void parallax_generate_themed(Parallax *p, SDL_Renderer *renderer, ParallaxStyle style) { @@ -920,6 +1070,10 @@ void parallax_generate_themed(Parallax *p, SDL_Renderer *renderer, ParallaxStyle generate_moon_far(p, renderer); generate_moon_near(p, renderer); break; + case PARALLAX_STYLE_MARS: + generate_mars_far(p, renderer); + generate_mars_near(p, renderer); + break; case PARALLAX_STYLE_DEFAULT: default: parallax_generate_stars(p, renderer); diff --git a/src/engine/parallax.h b/src/engine/parallax.h index 35f7d8f..5d2ae27 100644 --- a/src/engine/parallax.h +++ b/src/engine/parallax.h @@ -55,6 +55,7 @@ typedef enum ParallaxStyle { PARALLAX_STYLE_INTERIOR, /* indoor base: panels, pipes, structural */ PARALLAX_STYLE_DEEP_SPACE, /* space station windows: vivid stars */ PARALLAX_STYLE_MOON, /* moon surface: craters, grey terrain */ + PARALLAX_STYLE_MARS, /* Mars: salmon sky, red mesas, dust */ } ParallaxStyle; /* Generate both layers with a unified style */ diff --git a/src/game/editor.c b/src/game/editor.c index 17b3685..458263c 100644 --- a/src/game/editor.c +++ b/src/game/editor.c @@ -5,6 +5,7 @@ #include "engine/renderer.h" #include "engine/assets.h" #include "engine/camera.h" +#include "engine/font.h" #include "config.h" #include #include @@ -104,124 +105,7 @@ void editor_load_vfs_file(const char *path) { #endif /* __EMSCRIPTEN__ */ -/* ═══════════════════════════════════════════════════ - * Minimal 4x6 bitmap font - * - * Each character is 4 pixels wide, 6 pixels tall. - * Stored as 6 rows of 4 bits (packed in a uint32_t). - * Covers ASCII 32-95 (space through underscore). - * Lowercase maps to uppercase automatically. - * ═══════════════════════════════════════════════════ */ - -#define FONT_W 4 -#define FONT_H 7 - -/* 4-bit rows packed: row0 in bits 20-23, row1 in 16-19, etc. - * Bit order: MSB = leftmost pixel */ -static const uint32_t s_font_glyphs[64] = { - /* */ 0x000000, - /* ! */ 0x4444404, - /* " */ 0xAA0000, - /* # */ 0xAFAFA0, - /* $ */ 0x4E6E40, /* simplified $ */ - /* % */ 0x924924, /* simplified % */ - /* & */ 0x4A4AC0, - /* ' */ 0x440000, - /* ( */ 0x248840, - /* ) */ 0x842240, - /* * */ 0xA4A000, - /* + */ 0x04E400, - /* , */ 0x000048, - /* - */ 0x00E000, - /* . */ 0x000040, - /* / */ 0x224880, - /* 0 */ 0x6999960, - /* 1 */ 0x2622620, - /* 2 */ 0x6912460, - /* 3 */ 0x6921960, - /* 4 */ 0x2AAF220, - /* 5 */ 0xF88E1E0, - /* 6 */ 0x688E960, - /* 7 */ 0xF112440, - /* 8 */ 0x6966960, - /* 9 */ 0x6997120, - /* : */ 0x040400, - /* ; */ 0x040480, - /* < */ 0x248420, - /* = */ 0x0E0E00, - /* > */ 0x842480, - /* ? */ 0x6920400, - /* @ */ 0x69B9860, - /* A */ 0x699F990, - /* B */ 0xE99E9E0, - /* C */ 0x6988960, - /* D */ 0xE999E00, - /* E */ 0xF8E8F00, /* simplified E/F overlap */ - /* F */ 0xF8E8800, - /* G */ 0x698B960, - /* H */ 0x99F9900, - /* I */ 0xE444E00, - /* J */ 0x7111960, - /* K */ 0x9ACA900, - /* L */ 0x8888F00, - /* M */ 0x9FF9900, - /* N */ 0x9DDB900, - /* O */ 0x6999600, - /* P */ 0xE99E800, - /* Q */ 0x6999A70, - /* R */ 0xE99EA90, - /* S */ 0x698E960, /* reuse from earlier; close enough */ - /* T */ 0xF444400, - /* U */ 0x9999600, - /* V */ 0x999A400, - /* W */ 0x999FF90, /* simplified W */ - /* X */ 0x996690, /* simplified X */ - /* Y */ 0x996440, - /* Z */ 0xF12480, /* simplified Z */ - /* [ */ 0x688860, - /* \ */ 0x884220, - /* ] */ 0x622260, - /* ^ */ 0x4A0000, - /* _ */ 0x00000F, -}; - -static void draw_char(SDL_Renderer *r, char ch, int x, int y, SDL_Color col) { - int idx = 0; - if (ch >= 'a' && ch <= 'z') ch -= 32; /* to uppercase */ - if (ch >= 32 && ch <= 95) idx = ch - 32; - else return; - - uint32_t glyph = s_font_glyphs[idx]; - SDL_SetRenderDrawColor(r, col.r, col.g, col.b, col.a); - - for (int row = 0; row < FONT_H; row++) { - /* Extract 4 bits for this row */ - int shift = (FONT_H - 1 - row) * 4; - int bits = (glyph >> shift) & 0xF; - for (int col_bit = 0; col_bit < FONT_W; col_bit++) { - if (bits & (1 << (FONT_W - 1 - col_bit))) { - SDL_RenderDrawPoint(r, x + col_bit, y + row); - } - } - } -} - -static void draw_text(SDL_Renderer *r, const char *text, int x, int y, SDL_Color col) { - while (*text) { - draw_char(r, *text, x, y, col); - x += FONT_W + 1; /* 1px spacing */ - text++; - } -} - -/* Unused for now but useful for future centered text layouts */ -#if 0 -static int text_width(const char *text) { - int len = (int)strlen(text); - if (len == 0) return 0; - return len * (FONT_W + 1) - 1; -} -#endif +/* Bitmap font provided by engine/font.h (included above). */ /* ═══════════════════════════════════════════════════ * 8x8 pixel mini-icons for the entity palette @@ -263,6 +147,14 @@ static const uint64_t s_icon_bitmaps[ICON_COUNT] = { 0x001C3E7F7F3E1C00ULL, /* ICON_SPACECRAFT: ship */ 0x0018183C7E7E2400ULL, + /* ICON_LASER: laser turret (box with beam line) */ + 0x003C3C18187E0000ULL, + /* ICON_LASER_TRACK: tracking laser (box with rotating beam) */ + 0x003C3C1818660000ULL, + /* ICON_CHARGER: arrow/charging creature */ + 0x0018187E7E181800ULL, + /* ICON_SPAWNER: pulsing core with dots */ + 0x24003C3C3C002400ULL, }; static void draw_icon(SDL_Renderer *r, EditorIcon icon, @@ -1429,7 +1321,7 @@ void editor_render(Editor *ed, float interpolation) { draw_icon(r, (EditorIcon)reg->icon, (int)sp.x + 1, (int)sp.y + 1, COL_TEXT); } else if (reg && reg->display[0] && zw >= 6) { - draw_char(r, reg->display[0], (int)sp.x + 1, (int)sp.y + 1, COL_TEXT); + font_draw_char(r, reg->display[0], (int)sp.x + 1, (int)sp.y + 1, COL_TEXT); } } @@ -1441,7 +1333,7 @@ void editor_render(Editor *ed, float interpolation) { SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); SDL_Rect sr = {(int)sp.x, (int)sp.y, (int)(zs + 0.5f), (int)(zs + 0.5f)}; SDL_RenderDrawRect(r, &sr); - draw_text(r, "SP", (int)sp.x + 1, (int)sp.y + 1, COL_SPAWN); + font_draw_text(r, "SP", (int)sp.x + 1, (int)sp.y + 1, COL_SPAWN); SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); } @@ -1458,9 +1350,9 @@ void editor_render(Editor *ed, float interpolation) { SDL_RenderFillRect(r, &er); SDL_SetRenderDrawColor(r, COL_EXIT.r, COL_EXIT.g, COL_EXIT.b, 220); SDL_RenderDrawRect(r, &er); - draw_text(r, "EXIT", (int)sp.x + 1, (int)sp.y + 1, COL_EXIT); + font_draw_text(r, "EXIT", (int)sp.x + 1, (int)sp.y + 1, COL_EXIT); if (ez->target[0]) { - draw_text(r, ez->target, (int)sp.x + 1, (int)sp.y + 8, COL_EXIT); + font_draw_text(r, ez->target, (int)sp.x + 1, (int)sp.y + 8, COL_EXIT); } SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); } @@ -1514,7 +1406,7 @@ void editor_render(Editor *ed, float interpolation) { for (int i = 0; i < TOOL_COUNT; i++) { int bx = i * 35 + 2; SDL_Color tc = (i == (int)ed->tool) ? COL_HIGHLIGHT : COL_TEXT_DIM; - draw_text(r, s_tool_names[i], bx, text_y, tc); + font_draw_text(r, s_tool_names[i], bx, text_y, tc); } /* Separator */ @@ -1527,17 +1419,17 @@ void editor_render(Editor *ed, float interpolation) { for (int i = 0; i < EDITOR_LAYER_COUNT; i++) { int bx = layer_start + i * 25; SDL_Color lc = (i == (int)ed->active_layer) ? COL_HIGHLIGHT : COL_TEXT_DIM; - draw_text(r, s_layer_names[i], bx, text_y, lc); + font_draw_text(r, s_layer_names[i], bx, text_y, lc); } /* Grid & Layers indicators */ int grid_x = layer_start + EDITOR_LAYER_COUNT * 25 + 4; - draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", grid_x, text_y, + font_draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", grid_x, text_y, ed->show_grid ? COL_TEXT : COL_TEXT_DIM); /* Tileset switch hint */ int ts_x = grid_x + 7 * (FONT_W + 1) + 4; - draw_text(r, "[T]SET", ts_x, text_y, COL_TEXT_DIM); + font_draw_text(r, "[T]SET", ts_x, text_y, COL_TEXT_DIM); } /* ── Right palette panel ── */ @@ -1565,7 +1457,7 @@ void editor_render(Editor *ed, float interpolation) { { const char *ts_name = strrchr(ed->map.tileset_path, '/'); ts_name = ts_name ? ts_name + 1 : ed->map.tileset_path; - draw_text(r, ts_name[0] ? ts_name : "TILES", + font_draw_text(r, ts_name[0] ? ts_name : "TILES", px + 2, py + (label_h - FONT_H) / 2, COL_TEXT); } @@ -1645,10 +1537,10 @@ void editor_render(Editor *ed, float interpolation) { SDL_Color fc = (flags & TILE_HAZARD) ? (SDL_Color){255, 80, 40, 255} : (flags & TILE_PLATFORM) ? (SDL_Color){80, 200, 255, 255} : (flags & TILE_SOLID) ? COL_TEXT : COL_TEXT_DIM; - draw_text(r, fname, px + 2, ent_section_y - FONT_H - 2, fc); + font_draw_text(r, fname, px + 2, ent_section_y - FONT_H - 2, fc); /* Show [F] hint */ int fw = (int)strlen(fname) * (FONT_W + 1); - draw_text(r, "[F]", px + 2 + fw + 2, ent_section_y - FONT_H - 2, COL_TEXT_DIM); + font_draw_text(r, "[F]", px + 2 + fw + 2, ent_section_y - FONT_H - 2, COL_TEXT_DIM); } } @@ -1659,7 +1551,7 @@ void editor_render(Editor *ed, float interpolation) { /* ── Entity palette (bottom section) ── */ { int label_h = FONT_H + 6; - draw_text(r, "ENTITIES", px + 2, ent_section_y + (label_h - FONT_H) / 2, COL_TEXT); + font_draw_text(r, "ENTITIES", px + 2, ent_section_y + (label_h - FONT_H) / 2, COL_TEXT); int pal_y_start = ent_section_y + label_h; int ent_area_h = py + ph - pal_y_start; @@ -1685,7 +1577,7 @@ void editor_render(Editor *ed, float interpolation) { /* Name */ SDL_Color nc = (i == ed->selected_entity) ? COL_HIGHLIGHT : COL_TEXT; - draw_text(r, ent->display, px + 13, ey + 2, nc); + font_draw_text(r, ent->display, px + 13, ey + 2, nc); } SDL_RenderSetClipRect(r, NULL); @@ -1712,6 +1604,6 @@ void editor_render(Editor *ed, float interpolation) { ed->camera.zoom * 100.0f, ed->has_file ? ed->file_path : "new level", ed->dirty ? " *" : ""); - draw_text(r, status, 2, sy + (EDITOR_STATUS_H - FONT_H) / 2, COL_TEXT); + font_draw_text(r, status, 2, sy + (EDITOR_STATUS_H - FONT_H) / 2, COL_TEXT); } } diff --git a/src/game/enemy.c b/src/game/enemy.c index 7617940..a608110 100644 --- a/src/game/enemy.c +++ b/src/game/enemy.c @@ -3,6 +3,8 @@ #include "game/projectile.h" #include "engine/physics.h" #include "engine/renderer.h" +#include "engine/particle.h" +#include "engine/audio.h" #include #include @@ -270,3 +272,313 @@ Entity *flyer_spawn(EntityManager *em, Vec2 pos) { return e; } + + +/* ════════════════════════════════════════════════════ + * CHARGER — detects player, telegraphs, then rushes + * ════════════════════════════════════════════════════ */ + +#define CHARGER_ALERT_TIME 0.5f /* telegraph before charge */ +#define CHARGER_STUN_TIME 0.8f /* stun duration on wall */ + +static EntityManager *s_charger_em = NULL; + +static void charger_update(Entity *self, float dt, const Tilemap *map) { + ChargerData *cd = (ChargerData *)self->data; + if (!cd) return; + + Body *body = &self->body; + + /* Death sequence */ + if (self->flags & ENTITY_DEAD) { + cd->death_timer -= dt; + body->vel.x = 0; + if (cd->death_timer <= 0) { + particle_emit_death_puff(body->pos, (SDL_Color){220, 120, 40, 255}); + entity_destroy(s_charger_em, self); + } + return; + } + + /* State machine */ + switch (cd->state) { + case CHARGER_PATROL: { + body->vel.x = cd->patrol_dir * CHARGER_PATROL_SPEED; + + /* Face walk direction */ + if (cd->patrol_dir < 0) self->flags |= ENTITY_FACING_LEFT; + else self->flags &= ~ENTITY_FACING_LEFT; + + physics_update(body, dt, map); + + /* Reverse at walls */ + if (body->on_wall_left || body->on_wall_right) { + cd->patrol_dir = -cd->patrol_dir; + } + + /* Reverse at ledge */ + if (body->on_ground) { + float cx = (cd->patrol_dir > 0) ? + body->pos.x + body->size.x + 2.0f : + body->pos.x - 2.0f; + float cy = body->pos.y + body->size.y + 4.0f; + if (!tilemap_is_solid(map, world_to_tile(cx), world_to_tile(cy))) { + cd->patrol_dir = -cd->patrol_dir; + } + } + + /* Detect player — horizontal line-of-sight */ + Entity *player = find_player(s_charger_em); + if (player && player->active && !(player->flags & ENTITY_DEAD)) { + float px = player->body.pos.x + player->body.size.x * 0.5f; + float mx = body->pos.x + body->size.x * 0.5f; + float dy = fabsf(player->body.pos.y - body->pos.y); + float dx = px - mx; + + /* Must be roughly same height and within range */ + if (dy < body->size.y * 1.5f && fabsf(dx) < CHARGER_DETECT_RANGE) { + cd->state = CHARGER_ALERT; + cd->state_timer = CHARGER_ALERT_TIME; + /* Face the player */ + cd->patrol_dir = (dx > 0) ? 1.0f : -1.0f; + if (cd->patrol_dir < 0) self->flags |= ENTITY_FACING_LEFT; + else self->flags &= ~ENTITY_FACING_LEFT; + } + } + break; + } + + case CHARGER_ALERT: { + /* Freeze in place during telegraph */ + body->vel.x = 0; + physics_update(body, dt, map); + cd->state_timer -= dt; + if (cd->state_timer <= 0) { + cd->state = CHARGER_CHARGE; + cd->state_timer = CHARGER_CHARGE_TIME; + } + break; + } + + case CHARGER_CHARGE: { + body->vel.x = cd->patrol_dir * CHARGER_CHARGE_SPEED; + physics_update(body, dt, map); + cd->state_timer -= dt; + + /* Hit a wall -> stunned */ + if (body->on_wall_left || body->on_wall_right) { + cd->state = CHARGER_STUNNED; + cd->state_timer = CHARGER_STUN_TIME; + body->vel.x = 0; + particle_emit_spark(body->pos, (SDL_Color){255, 200, 80, 255}); + } + + /* Ran off a ledge or charge timed out -> back to patrol */ + if (!body->on_ground || cd->state_timer <= 0) { + cd->state = CHARGER_PATROL; + body->vel.x = 0; + } + break; + } + + case CHARGER_STUNNED: { + body->vel.x = 0; + physics_update(body, dt, map); + cd->state_timer -= dt; + if (cd->state_timer <= 0) { + cd->state = CHARGER_PATROL; + /* Reverse direction after stun */ + cd->patrol_dir = -cd->patrol_dir; + } + break; + } + } + + /* Animation — use grunt anims as placeholder */ + if (cd->state == CHARGER_CHARGE) { + animation_set(&self->anim, &anim_grunt_walk); + } else { + animation_set(&self->anim, &anim_grunt_idle); + } + animation_update(&self->anim, dt); +} + +static void charger_render(Entity *self, const Camera *cam) { + Body *body = &self->body; + ChargerData *cd = (ChargerData *)self->data; + + /* Colored rect fallback — orange/amber color scheme */ + SDL_Color color; + if (cd && cd->state == CHARGER_ALERT) { + /* Flash during telegraph */ + float blink = sinf(cd->state_timer * 30.0f); + uint8_t r = (blink > 0) ? 255 : 200; + color = (SDL_Color){r, 160, 40, 255}; + } else if (cd && cd->state == CHARGER_STUNNED) { + color = (SDL_Color){160, 160, 100, 255}; + } else if (cd && cd->state == CHARGER_CHARGE) { + color = (SDL_Color){255, 120, 30, 255}; + } else { + color = (SDL_Color){220, 140, 40, 255}; + } + renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); + + /* Direction indicator: small triangle in facing direction */ + float cx = body->pos.x + body->size.x * 0.5f; + float cy = body->pos.y + 2.0f; + bool left = (self->flags & ENTITY_FACING_LEFT) != 0; + float arrow_x = left ? body->pos.x - 3.0f : body->pos.x + body->size.x + 1.0f; + renderer_draw_rect(vec2(arrow_x, cy), vec2(2, 3), + (SDL_Color){255, 200, 60, 255}, LAYER_ENTITIES, cam); + (void)cx; +} + +static void charger_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +void charger_register(EntityManager *em) { + entity_register(em, ENT_ENEMY_CHARGER, + charger_update, charger_render, charger_destroy); + s_charger_em = em; +} + +Entity *charger_spawn(EntityManager *em, Vec2 pos) { + Entity *e = entity_spawn(em, ENT_ENEMY_CHARGER, pos); + if (!e) return NULL; + + e->body.size = vec2(CHARGER_WIDTH, CHARGER_HEIGHT); + e->body.gravity_scale = 1.0f; + e->health = CHARGER_HEALTH; + e->max_health = CHARGER_HEALTH; + e->damage = 1; + + ChargerData *cd = calloc(1, sizeof(ChargerData)); + if (!cd) { entity_destroy(em, e); return NULL; } + cd->state = CHARGER_PATROL; + cd->patrol_dir = 1.0f; + cd->state_timer = 0; + cd->death_timer = 0.3f; + e->data = cd; + + return e; +} + + +/* ════════════════════════════════════════════════════ + * SPAWNER — stationary, periodically spawns grunts + * ════════════════════════════════════════════════════ */ + +#define SPAWNER_PULSE_TIME 1.0f /* pulse warning before spawn */ + +static EntityManager *s_spawner_em = NULL; + +/* Count how many grunts are currently alive. */ +static int count_alive_grunts(EntityManager *em) { + int count = 0; + for (int i = 0; i < em->count; i++) { + Entity *e = &em->entities[i]; + if (e->active && e->type == ENT_ENEMY_GRUNT && + !(e->flags & ENTITY_DEAD)) { + count++; + } + } + return count; +} + +static void spawner_update(Entity *self, float dt, const Tilemap *map) { + (void)map; + SpawnerData *sd = (SpawnerData *)self->data; + if (!sd) return; + + /* Death sequence */ + if (self->flags & ENTITY_DEAD) { + sd->death_timer -= dt; + if (sd->death_timer <= 0) { + particle_emit_death_puff(self->body.pos, (SDL_Color){180, 60, 180, 255}); + entity_destroy(s_spawner_em, self); + } + return; + } + + sd->spawn_timer -= dt; + + /* Pulse warning when about to spawn */ + if (sd->spawn_timer < SPAWNER_PULSE_TIME && sd->spawn_timer > 0) { + sd->pulse_timer += dt; + } else { + sd->pulse_timer = 0; + } + + /* Spawn a grunt when timer expires */ + if (sd->spawn_timer <= 0) { + sd->spawn_timer = SPAWNER_INTERVAL; + + if (count_alive_grunts(s_spawner_em) < SPAWNER_MAX_ALIVE) { + /* Spawn grunt just below the spawner */ + Vec2 spawn_pos = vec2( + self->body.pos.x, + self->body.pos.y + self->body.size.y + 2.0f + ); + Entity *grunt = grunt_spawn(s_spawner_em, spawn_pos); + if (grunt) { + particle_emit_spark(spawn_pos, (SDL_Color){180, 100, 220, 255}); + } + } + } +} + +static void spawner_render(Entity *self, const Camera *cam) { + Body *body = &self->body; + SpawnerData *sd = (SpawnerData *)self->data; + + /* Pulsing purple color when about to spawn */ + SDL_Color color; + if (sd && sd->pulse_timer > 0) { + float pulse = sinf(sd->pulse_timer * 12.0f) * 0.5f + 0.5f; + uint8_t r = (uint8_t)(140 + 80 * pulse); + uint8_t b = (uint8_t)(180 + 60 * pulse); + color = (SDL_Color){r, 50, b, 255}; + } else { + color = (SDL_Color){140, 50, 160, 255}; + } + renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); + + /* Small inner dot to distinguish from other hazards */ + float cx = body->pos.x + body->size.x * 0.5f - 2.0f; + float cy = body->pos.y + body->size.y * 0.5f - 2.0f; + SDL_Color dot = {255, 200, 255, 255}; + renderer_draw_rect(vec2(cx, cy), vec2(4, 4), dot, LAYER_ENTITIES, cam); +} + +static void spawner_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +void spawner_register(EntityManager *em) { + entity_register(em, ENT_SPAWNER, + spawner_update, spawner_render, spawner_destroy); + s_spawner_em = em; +} + +Entity *spawner_spawn(EntityManager *em, Vec2 pos) { + Entity *e = entity_spawn(em, ENT_SPAWNER, pos); + if (!e) return NULL; + + e->body.size = vec2(SPAWNER_WIDTH, SPAWNER_HEIGHT); + e->body.gravity_scale = 0.0f; /* stationary */ + e->health = SPAWNER_HEALTH; + e->max_health = SPAWNER_HEALTH; + e->damage = 0; /* no contact damage, not invincible */ + + SpawnerData *sd = calloc(1, sizeof(SpawnerData)); + if (!sd) { entity_destroy(em, e); return NULL; } + sd->spawn_timer = SPAWNER_INTERVAL; + sd->death_timer = 0.4f; + sd->pulse_timer = 0; + e->data = sd; + + return e; +} diff --git a/src/game/enemy.h b/src/game/enemy.h index f6edd30..cd6e708 100644 --- a/src/game/enemy.h +++ b/src/game/enemy.h @@ -44,4 +44,52 @@ typedef struct FlyerData { void flyer_register(EntityManager *em); Entity *flyer_spawn(EntityManager *em, Vec2 pos); +/* ── Charger enemy ─────────────────────────────────── */ +/* Ground patrol that detects the player, telegraphs */ +/* briefly, then charges at high speed. Stuns on wall. */ + +#define CHARGER_WIDTH 14 +#define CHARGER_HEIGHT 16 +#define CHARGER_PATROL_SPEED 30.0f /* slow patrol (px/s) */ +#define CHARGER_CHARGE_SPEED 150.0f /* charge rush speed (px/s) */ +#define CHARGER_DETECT_RANGE 200.0f /* horizontal detect (px) */ +#define CHARGER_CHARGE_TIME 2.0f /* max charge duration (s) */ +#define CHARGER_HEALTH 2 + +typedef enum ChargerState { + CHARGER_PATROL, + CHARGER_ALERT, /* telegraph before charging */ + CHARGER_CHARGE, /* full-speed horizontal rush */ + CHARGER_STUNNED, /* hit a wall, briefly vulnerable */ +} ChargerState; + +typedef struct ChargerData { + ChargerState state; + float patrol_dir; /* 1.0 or -1.0 */ + float state_timer; /* countdown for current state */ + float death_timer; +} ChargerData; + +void charger_register(EntityManager *em); +Entity *charger_spawn(EntityManager *em, Vec2 pos); + +/* ── Spawner enemy ─────────────────────────────────── */ +/* Stationary, periodically spawns grunt enemies up to */ +/* a cap. Destructible. */ + +#define SPAWNER_WIDTH 16 +#define SPAWNER_HEIGHT 16 +#define SPAWNER_HEALTH 3 +#define SPAWNER_INTERVAL 4.5f /* seconds between spawns */ +#define SPAWNER_MAX_ALIVE 3 /* max grunts alive at once */ + +typedef struct SpawnerData { + float spawn_timer; /* countdown to next spawn */ + float death_timer; + float pulse_timer; /* visual pulse before spawn */ +} SpawnerData; + +void spawner_register(EntityManager *em); +Entity *spawner_spawn(EntityManager *em, Vec2 pos); + #endif /* JNR_ENEMY_H */ diff --git a/src/game/entity_registry.c b/src/game/entity_registry.c index f149c14..b949cf9 100644 --- a/src/game/entity_registry.c +++ b/src/game/entity_registry.c @@ -3,6 +3,7 @@ #include "game/enemy.h" #include "game/projectile.h" #include "game/hazards.h" +#include "game/laser_turret.h" #include "game/powerup.h" #include "game/drone.h" #include "game/spacecraft.h" @@ -40,6 +41,10 @@ static Entity *spawn_powerup_gun(EntityManager *em, Vec2 pos) { return powerup_spawn_gun(em, pos); } +static Entity *spawn_laser_track(EntityManager *em, Vec2 pos) { + return laser_turret_spawn_tracking(em, pos); +} + /* ── Registry population ─────────────────────────── */ static void reg_add(const char *name, const char *display, @@ -89,6 +94,10 @@ void entity_registry_populate(void) { reg_add("powerup_gun", "Gun Pickup", spawn_powerup_gun, (SDL_Color){200, 200, 220, 255}, 12, 12, ICON_GUN); reg_add("asteroid", "Asteroid", asteroid_spawn, (SDL_Color){140, 110, 80, 255}, ASTEROID_WIDTH, ASTEROID_HEIGHT, ICON_ASTEROID); reg_add("spacecraft", "Spacecraft", spacecraft_spawn, (SDL_Color){187, 187, 187, 255}, SPACECRAFT_WIDTH, SPACECRAFT_HEIGHT, ICON_SPACECRAFT); + reg_add("laser_turret", "Laser Turret", laser_turret_spawn, (SDL_Color){200, 80, 50, 255}, LASER_WIDTH, LASER_HEIGHT, ICON_LASER); + reg_add("laser_turret_track", "Laser Track", spawn_laser_track, (SDL_Color){220, 100, 60, 255}, LASER_WIDTH, LASER_HEIGHT, ICON_LASER_TRACK); + reg_add("charger", "Charger", charger_spawn, (SDL_Color){220, 140, 40, 255}, CHARGER_WIDTH, CHARGER_HEIGHT, ICON_CHARGER); + reg_add("spawner", "Spawner", spawner_spawn, (SDL_Color){140, 50, 160, 255}, SPAWNER_WIDTH, SPAWNER_HEIGHT, ICON_SPAWNER); printf("Entity registry: %d types registered\n", g_entity_registry.count); } @@ -111,6 +120,9 @@ void entity_registry_init(EntityManager *em) { drone_register(em); asteroid_register(em); spacecraft_register(em); + laser_turret_register(em); + charger_register(em); + spawner_register(em); } const EntityRegEntry *entity_registry_find(const char *name) { diff --git a/src/game/entity_registry.h b/src/game/entity_registry.h index f3b8263..07af11d 100644 --- a/src/game/entity_registry.h +++ b/src/game/entity_registry.h @@ -41,6 +41,10 @@ typedef enum EditorIcon { ICON_GUN = 11, /* weapon pickup */ ICON_ASTEROID = 12, /* rock */ ICON_SPACECRAFT = 13, /* ship */ + ICON_LASER = 14, /* laser turret */ + ICON_LASER_TRACK = 15, /* tracking laser */ + ICON_CHARGER = 16, /* charging enemy */ + ICON_SPAWNER = 17, /* spawner enemy */ ICON_COUNT, ICON_NONE = -1 /* no icon (fallback) */ } EditorIcon; diff --git a/src/game/laser_turret.c b/src/game/laser_turret.c new file mode 100644 index 0000000..9885423 --- /dev/null +++ b/src/game/laser_turret.c @@ -0,0 +1,351 @@ +#include "game/laser_turret.h" +#include "game/player.h" +#include "game/sprites.h" +#include "engine/core.h" +#include "engine/physics.h" +#include "engine/renderer.h" +#include "engine/particle.h" +#include "engine/camera.h" +#include +#include + +/* ═══════════════════════════════════════════════════ + * Laser Turret + * + * Cycles through four states: + * IDLE (brief) -> CHARGING (1.5s, thin flickering + * indicator line) -> FIRING (2.5s, thick damaging + * beam) -> COOLDOWN (2s, beam fades) -> IDLE ... + * + * The beam is a raycast from the turret center along + * aim_angle until a solid tile or the map edge. + * + * Fixed variant: aim_angle set at spawn, never moves. + * Tracking variant: aim_angle rotates toward the + * player during IDLE and CHARGING, then locks when + * FIRING begins. + * ═══════════════════════════════════════════════════ */ + +static EntityManager *s_laser_em = NULL; + +/* ── Helpers ──────────────────────────────────────── */ + +static Entity *find_player(EntityManager *em) { + for (int i = 0; i < em->count; i++) { + Entity *e = &em->entities[i]; + if (e->active && e->type == ENT_PLAYER) return e; + } + return NULL; +} + +/* Raycast from (ox, oy) along (dx, dy) in tile steps. + * Returns the world-space endpoint where the beam hits + * a solid tile or the map boundary. Step size is one + * pixel for accuracy. Max range prevents runaway. */ +static void beam_raycast(const Tilemap *map, + float ox, float oy, float dx, float dy, + float *out_x, float *out_y) { + float max_range = 800.0f; /* pixels */ + float step = 1.0f; + float x = ox, y = oy; + float dist = 0.0f; + + while (dist < max_range) { + x += dx * step; + y += dy * step; + dist += step; + + int tx = (int)(x / TILE_SIZE); + int ty = (int)(y / TILE_SIZE); + if (tx < 0 || ty < 0 || tx >= map->width || ty >= map->height) break; + if (tilemap_is_solid(map, tx, ty)) break; + } + + *out_x = x; + *out_y = y; +} + +/* ── Update ──────────────────────────────────────── */ + +static void laser_turret_update(Entity *self, float dt, const Tilemap *map) { + LaserTurretData *ld = (LaserTurretData *)self->data; + if (!ld) return; + + /* Death sequence */ + if (self->flags & ENTITY_DEAD) { + self->timer -= dt; + if (self->timer <= 0) { + particle_emit_death_puff(self->body.pos, (SDL_Color){255, 60, 60, 255}); + entity_destroy(s_laser_em, self); + } + return; + } + + /* Turret center for raycasting and aiming */ + float cx = self->body.pos.x + self->body.size.x * 0.5f; + float cy = self->body.pos.y + self->body.size.y * 0.5f; + + /* Tracking: rotate toward player during IDLE and CHARGING */ + if (ld->tracking && (ld->state == LASER_IDLE || ld->state == LASER_CHARGING)) { + Entity *player = find_player(s_laser_em); + if (player && player->active && !(player->flags & ENTITY_DEAD)) { + float px = player->body.pos.x + player->body.size.x * 0.5f; + float py = player->body.pos.y + player->body.size.y * 0.5f; + float target_angle = atan2f(py - cy, px - cx); + + /* Rotate toward target at limited speed */ + float diff = target_angle - ld->aim_angle; + /* Normalize to [-PI, PI] */ + while (diff > 3.14159f) diff -= 6.28318f; + while (diff < -3.14159f) diff += 6.28318f; + + float max_rot = LASER_TRACK_SPEED * dt; + if (diff > max_rot) ld->aim_angle += max_rot; + else if (diff < -max_rot) ld->aim_angle -= max_rot; + else ld->aim_angle = target_angle; + } + } + + /* Update facing direction from aim */ + float dx = cosf(ld->aim_angle); + if (dx < 0) self->flags |= ENTITY_FACING_LEFT; + else self->flags &= ~ENTITY_FACING_LEFT; + + /* Compute beam endpoint via raycast */ + float dir_x = cosf(ld->aim_angle); + float dir_y = sinf(ld->aim_angle); + beam_raycast(map, cx, cy, dir_x, dir_y, &ld->beam_end_x, &ld->beam_end_y); + + /* Damage cooldown */ + if (ld->damage_cd > 0) ld->damage_cd -= dt; + + /* State machine */ + ld->timer -= dt; + if (ld->timer <= 0) { + switch (ld->state) { + case LASER_IDLE: + ld->state = LASER_CHARGING; + ld->timer = LASER_CHARGE_TIME; + break; + case LASER_CHARGING: + ld->state = LASER_FIRING; + ld->timer = LASER_FIRE_TIME; + ld->damage_cd = 0; + break; + case LASER_FIRING: + ld->state = LASER_COOLDOWN; + ld->timer = LASER_COOLDOWN_TIME; + break; + case LASER_COOLDOWN: + ld->state = LASER_IDLE; + ld->timer = 0.3f; /* brief idle before next cycle */ + break; + } + } + + /* During FIRING: check beam-player overlap and deal damage. */ + if (ld->state == LASER_FIRING && ld->damage_cd <= 0) { + Entity *player = find_player(s_laser_em); + if (player && player->active && !(player->flags & ENTITY_DEAD)) { + /* Test player AABB against beam line (approximated as thin rect). */ + float bx0 = cx, by0 = cy; + float bx1 = ld->beam_end_x, by1 = ld->beam_end_y; + float beam_half_w = 3.0f; /* beam thickness for collision */ + + /* Build an AABB enclosing the beam segment */ + float min_x = (bx0 < bx1 ? bx0 : bx1) - beam_half_w; + float min_y = (by0 < by1 ? by0 : by1) - beam_half_w; + float max_x = (bx0 > bx1 ? bx0 : bx1) + beam_half_w; + float max_y = (by0 > by1 ? by0 : by1) + beam_half_w; + + /* Quick AABB pre-filter */ + Body *pb = &player->body; + if (pb->pos.x + pb->size.x > min_x && pb->pos.x < max_x && + pb->pos.y + pb->size.y > min_y && pb->pos.y < max_y) { + /* Finer check: point-to-line distance for player center. */ + float pcx = pb->pos.x + pb->size.x * 0.5f; + float pcy = pb->pos.y + pb->size.y * 0.5f; + float lx = bx1 - bx0, ly = by1 - by0; + float len_sq = lx * lx + ly * ly; + float dist_to_beam; + if (len_sq < 0.01f) { + dist_to_beam = sqrtf((pcx - bx0) * (pcx - bx0) + + (pcy - by0) * (pcy - by0)); + } else { + float t = ((pcx - bx0) * lx + (pcy - by0) * ly) / len_sq; + if (t < 0) t = 0; + if (t > 1) t = 1; + float closest_x = bx0 + t * lx; + float closest_y = by0 + t * ly; + dist_to_beam = sqrtf((pcx - closest_x) * (pcx - closest_x) + + (pcy - closest_y) * (pcy - closest_y)); + } + + /* Hit if player center is within half-size of beam */ + float hit_radius = (pb->size.x + pb->size.y) * 0.25f + beam_half_w; + if (dist_to_beam < hit_radius) { + player->health -= LASER_DAMAGE; + ld->damage_cd = LASER_DAMAGE_CD; + particle_emit_spark(vec2(pcx, pcy), (SDL_Color){255, 80, 40, 255}); + } + } + } + } + + /* Sparks at beam endpoint during FIRING */ + if (ld->state == LASER_FIRING) { + if (rand() % 4 == 0) { + particle_emit_spark(vec2(ld->beam_end_x, ld->beam_end_y), + (SDL_Color){255, 100, 50, 255}); + } + } +} + +/* ── Render ──────────────────────────────────────── */ + +/* Draw the beam line from turret center to endpoint. + * Uses raw SDL calls since renderer_draw_rect only handles + * axis-aligned rectangles. */ +static void draw_beam_line(const Camera *cam, + float x0, float y0, float x1, float y1, + int thickness, SDL_Color col) { + Vec2 s0 = camera_world_to_screen(cam, vec2(x0, y0)); + Vec2 s1 = camera_world_to_screen(cam, vec2(x1, y1)); + + SDL_SetRenderDrawBlendMode(g_engine.renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(g_engine.renderer, col.r, col.g, col.b, col.a); + + /* Draw multiple lines offset perpendicular to simulate thickness. */ + float dx = s1.x - s0.x; + float dy = s1.y - s0.y; + float len = sqrtf(dx * dx + dy * dy); + if (len < 0.5f) return; + + /* Perpendicular direction */ + float nx = -dy / len; + float ny = dx / len; + + int half = thickness / 2; + for (int i = -half; i <= half; i++) { + float ox = nx * (float)i; + float oy = ny * (float)i; + SDL_RenderDrawLine(g_engine.renderer, + (int)(s0.x + ox), (int)(s0.y + oy), + (int)(s1.x + ox), (int)(s1.y + oy)); + } + + SDL_SetRenderDrawBlendMode(g_engine.renderer, SDL_BLENDMODE_NONE); +} + +static void laser_turret_render(Entity *self, const Camera *cam) { + LaserTurretData *ld = (LaserTurretData *)self->data; + if (!ld) return; + + Body *body = &self->body; + float cx = body->pos.x + body->size.x * 0.5f; + float cy = body->pos.y + body->size.y * 0.5f; + + /* Draw beam based on state */ + if (ld->state == LASER_CHARGING) { + /* Thin flickering indicator line */ + float progress = 1.0f - (ld->timer / LASER_CHARGE_TIME); + uint8_t alpha = (uint8_t)(80 + 100 * progress); + /* Flicker effect: modulate alpha with a fast sine */ + float flicker = sinf(ld->timer * 20.0f) * 0.3f + 0.7f; + alpha = (uint8_t)((float)alpha * flicker); + + SDL_Color beam_col = {255, 60, 40, alpha}; + draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y, + 1, beam_col); + + } else if (ld->state == LASER_FIRING) { + /* Bright thick beam — core + glow */ + SDL_Color glow = {255, 80, 30, 100}; + draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y, + 5, glow); + + SDL_Color core = {255, 200, 150, 230}; + draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y, + 2, core); + + SDL_Color center = {255, 255, 220, 255}; + draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y, + 0, center); + + } else if (ld->state == LASER_COOLDOWN) { + /* Fading beam */ + float fade = ld->timer / LASER_COOLDOWN_TIME; + uint8_t alpha = (uint8_t)(180 * fade); + SDL_Color beam_col = {255, 80, 30, alpha}; + draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y, + (int)(3.0f * fade), beam_col); + } + + /* Draw turret body — colored rect fallback (no sprite yet). */ + SDL_Color base_col; + if (ld->state == LASER_FIRING) { + base_col = (SDL_Color){200, 80, 50, 255}; + } else if (ld->state == LASER_CHARGING) { + base_col = (SDL_Color){180, 100, 60, 255}; + } else { + base_col = (SDL_Color){140, 100, 80, 255}; + } + renderer_draw_rect(body->pos, body->size, base_col, LAYER_ENTITIES, cam); + + /* Small dot indicating aim direction */ + float dot_dist = 10.0f; + float dot_x = cx + cosf(ld->aim_angle) * dot_dist - 1.0f; + float dot_y = cy + sinf(ld->aim_angle) * dot_dist - 1.0f; + SDL_Color dot_col = {255, 50, 30, 255}; + renderer_draw_rect(vec2(dot_x, dot_y), vec2(2, 2), dot_col, + LAYER_ENTITIES, cam); +} + +/* ── Destroy ─────────────────────────────────────── */ + +static void laser_turret_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +/* ── Register ────────────────────────────────────── */ + +void laser_turret_register(EntityManager *em) { + entity_register(em, ENT_LASER_TURRET, + laser_turret_update, laser_turret_render, + laser_turret_destroy); + s_laser_em = em; +} + +/* ── Spawn ───────────────────────────────────────── */ + +static Entity *laser_spawn_internal(EntityManager *em, Vec2 pos, bool tracking) { + Entity *e = entity_spawn(em, ENT_LASER_TURRET, pos); + if (!e) return NULL; + + e->body.size = vec2(LASER_WIDTH, LASER_HEIGHT); + e->body.gravity_scale = 0.0f; + e->health = LASER_HEALTH; + e->max_health = LASER_HEALTH; + e->damage = 0; /* damage comes from the beam, not contact */ + e->timer = 0.3f; + + LaserTurretData *ld = calloc(1, sizeof(LaserTurretData)); + if (!ld) { entity_destroy(em, e); return NULL; } + ld->state = LASER_IDLE; + ld->timer = 0.5f; /* brief idle before first charge */ + ld->aim_angle = 3.14159f; /* default: aim left */ + ld->damage_cd = 0; + ld->tracking = tracking; + e->data = ld; + + return e; +} + +Entity *laser_turret_spawn(EntityManager *em, Vec2 pos) { + return laser_spawn_internal(em, pos, false); +} + +Entity *laser_turret_spawn_tracking(EntityManager *em, Vec2 pos) { + return laser_spawn_internal(em, pos, true); +} diff --git a/src/game/laser_turret.h b/src/game/laser_turret.h new file mode 100644 index 0000000..3b69a3b --- /dev/null +++ b/src/game/laser_turret.h @@ -0,0 +1,48 @@ +#ifndef JNR_LASER_TURRET_H +#define JNR_LASER_TURRET_H + +#include "engine/entity.h" +#include "engine/camera.h" +#include "engine/tilemap.h" + +/* ═══════════════════════════════════════════════════ + * LASER TURRET — Hazard that charges up a visible + * indicator beam, then fires a sustained laser for + * several seconds. Two variants: + * - Fixed direction (placed facing left/right) + * - Tracking (slowly rotates toward the player + * during idle/charge, locks on fire) + * ═══════════════════════════════════════════════════ */ + +#define LASER_WIDTH 14 +#define LASER_HEIGHT 14 +#define LASER_CHARGE_TIME 1.5f /* seconds to charge up */ +#define LASER_FIRE_TIME 2.5f /* seconds beam stays active */ +#define LASER_COOLDOWN_TIME 2.0f /* seconds before next cycle */ +#define LASER_TRACK_SPEED 1.5f /* radians/s tracking rate */ +#define LASER_DAMAGE 1 +#define LASER_DAMAGE_CD 0.5f /* seconds between damage ticks */ +#define LASER_HEALTH 3 + +typedef enum LaserState { + LASER_IDLE, + LASER_CHARGING, + LASER_FIRING, + LASER_COOLDOWN, +} LaserState; + +typedef struct LaserTurretData { + LaserState state; + float timer; /* countdown for current state */ + float aim_angle; /* current aim angle (radians) */ + float damage_cd; /* cooldown between damage ticks */ + bool tracking; /* true = slowly follows player */ + float beam_end_x; /* computed beam endpoint (world) */ + float beam_end_y; +} LaserTurretData; + +void laser_turret_register(EntityManager *em); +Entity *laser_turret_spawn(EntityManager *em, Vec2 pos); +Entity *laser_turret_spawn_tracking(EntityManager *em, Vec2 pos); + +#endif /* JNR_LASER_TURRET_H */ diff --git a/src/game/level.c b/src/game/level.c index e17cc27..d4b9b5b 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -152,9 +152,12 @@ bool level_load_generated(Level *level, Tilemap *gen_map) { level->map = *gen_map; memset(gen_map, 0, sizeof(Tilemap)); /* prevent double-free */ - /* Load tileset texture (the generator doesn't do this) */ - snprintf(level->map.tileset_path, sizeof(level->map.tileset_path), - "%s", "assets/tiles/tileset.png"); + /* Load tileset texture. Use the generator's tileset_path if set, + * otherwise fall back to the default tileset. */ + if (!level->map.tileset_path[0]) { + snprintf(level->map.tileset_path, sizeof(level->map.tileset_path), + "%s", "assets/tiles/tileset.png"); + } level->map.tileset = assets_get_texture(level->map.tileset_path); if (level->map.tileset) { int tex_w; diff --git a/src/game/levelgen.c b/src/game/levelgen.c index b8fc43e..06b398b 100644 --- a/src/game/levelgen.c +++ b/src/game/levelgen.c @@ -538,6 +538,24 @@ static SegmentType pick_segment_type(LevelTheme theme, int index, int total) { if (r < 0.90f) return SEG_FLAT; return SEG_PIT; + case THEME_MARS_SURFACE: + /* Red dusty exterior: spacey, wide-open, few obstacles. + Charger enemies, occasional pits, wind gusts. */ + if (r < 0.35f) return SEG_FLAT; + if (r < 0.55f) return SEG_PIT; + if (r < 0.75f) return SEG_ARENA; + if (r < 0.90f) return SEG_PLATFORMS; + return SEG_SHAFT; + + case THEME_MARS_BASE: + /* Indoor Mars facility: very vertical, narrow corridors, + 90-degree turns, heavy turret/spawner presence. */ + if (r < 0.30f) return SEG_CORRIDOR; + if (r < 0.55f) return SEG_SHAFT; + if (r < 0.70f) return SEG_ARENA; + if (r < 0.85f) return SEG_PLATFORMS; + return SEG_FLAT; + default: /* Only pick content types (exclude TRANSITION and CLIMB connectors) */ return (SegmentType)rng_range(0, SEG_SHAFT); @@ -758,6 +776,8 @@ static float gravity_for_theme(LevelTheme theme) { case THEME_PLANET_SURFACE: return 400.0f; case THEME_PLANET_BASE: return 600.0f; case THEME_SPACE_STATION: return 750.0f; + case THEME_MARS_SURFACE: return 370.0f; /* Mars: ~0.38g */ + case THEME_MARS_BASE: return 700.0f; /* artificial gravity */ default: return 600.0f; } } @@ -768,6 +788,8 @@ static SDL_Color bg_color_for_theme(LevelTheme theme) { case THEME_PLANET_SURFACE: return (SDL_Color){12, 8, 20, 255}; case THEME_PLANET_BASE: return (SDL_Color){10, 14, 22, 255}; case THEME_SPACE_STATION: return (SDL_Color){5, 5, 18, 255}; + case THEME_MARS_SURFACE: return (SDL_Color){30, 12, 8, 255}; + case THEME_MARS_BASE: return (SDL_Color){18, 10, 8, 255}; default: return (SDL_Color){15, 15, 30, 255}; } } @@ -778,6 +800,8 @@ static ParallaxStyle parallax_style_for_theme(LevelTheme theme) { case THEME_PLANET_SURFACE: return PARALLAX_STYLE_ALIEN_SKY; case THEME_PLANET_BASE: return PARALLAX_STYLE_INTERIOR; case THEME_SPACE_STATION: return PARALLAX_STYLE_DEEP_SPACE; + case THEME_MARS_SURFACE: return PARALLAX_STYLE_MARS; + case THEME_MARS_BASE: return PARALLAX_STYLE_INTERIOR; default: return PARALLAX_STYLE_DEFAULT; } } @@ -787,6 +811,8 @@ static const char *theme_label(LevelTheme t) { case THEME_PLANET_SURFACE: return "surface"; case THEME_PLANET_BASE: return "base"; case THEME_SPACE_STATION: return "station"; + case THEME_MARS_SURFACE: return "mars_surf"; + case THEME_MARS_BASE: return "mars_base"; default: return "?"; } } @@ -891,14 +917,18 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) { * to give each theme its own vertical zone. Single-theme levels * stay at the standard 23-tile height. */ bool has_surface = false, has_base = false, has_station = false; + bool has_mars_surf = false, has_mars_base = false; for (int i = 0; i < num_segs; i++) { if (seg_themes[i] == THEME_PLANET_SURFACE) has_surface = true; if (seg_themes[i] == THEME_PLANET_BASE) has_base = true; if (seg_themes[i] == THEME_SPACE_STATION) has_station = true; + if (seg_themes[i] == THEME_MARS_SURFACE) has_mars_surf = true; + if (seg_themes[i] == THEME_MARS_BASE) has_mars_base = true; } /* Go tall if we have theme variety that benefits from verticality */ uses_tall = (has_surface && (has_base || has_station)) || + (has_mars_surf && has_mars_base) || (has_station && num_segs >= 5); if (uses_tall) { @@ -914,9 +944,11 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) { } switch (seg_themes[i]) { case THEME_PLANET_SURFACE: + case THEME_MARS_SURFACE: seg_ground[i] = ZONE_LOW_GROUND; break; case THEME_PLANET_BASE: + case THEME_MARS_BASE: seg_ground[i] = ZONE_HIGH_GROUND; break; case THEME_SPACE_STATION: @@ -1092,6 +1124,13 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) { map->has_bg_color = true; map->parallax_style = (int)parallax_style_for_theme(primary_theme); + /* Select tileset based on theme. Mars themes use a dedicated tileset; + * all others use the default. level_load_generated() loads the texture. */ + if (primary_theme == THEME_MARS_SURFACE || primary_theme == THEME_MARS_BASE) { + snprintf(map->tileset_path, sizeof(map->tileset_path), + "%s", "assets/tiles/mars_tileset.png"); + } + /* Exit zone at the far-right end of the level. * Placed just inside the right border wall, 2 tiles wide, 3 tiles tall * sitting on the ground row. Target "generate" chains to another diff --git a/src/game/levelgen.h b/src/game/levelgen.h index 6b47a46..bbe6da3 100644 --- a/src/game/levelgen.h +++ b/src/game/levelgen.h @@ -23,6 +23,10 @@ typedef enum LevelTheme { force fields, turrets, medium gravity */ THEME_SPACE_STATION, /* orbital station: shafts, platforms, low gravity, moving platforms, turrets */ + THEME_MARS_SURFACE, /* Mars exterior: red dusty terrain, wind, + wide-open with charger enemies */ + THEME_MARS_BASE, /* Mars base interior: very vertical, narrow + corridors, turrets, laser turrets, spawners*/ THEME_COUNT } LevelTheme; diff --git a/src/main.c b/src/main.c index 69053e2..ab05fa8 100644 --- a/src/main.c +++ b/src/main.c @@ -1,8 +1,10 @@ #include "engine/core.h" #include "engine/input.h" +#include "engine/font.h" #include "game/level.h" #include "game/levelgen.h" #include "game/editor.h" +#include "config.h" #include #include #include @@ -19,6 +21,7 @@ typedef enum GameMode { MODE_PLAY, MODE_EDITOR, + MODE_PAUSED, } GameMode; static Level s_level; @@ -38,11 +41,17 @@ static bool s_testing_from_editor = false; * Drives escalating difficulty and length. */ static int s_station_depth = 0; +/* ── Pause menu state ── */ +#define PAUSE_ITEM_COUNT 3 +static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */ + static const char *theme_name(LevelTheme t) { switch (t) { case THEME_PLANET_SURFACE: return "Planet Surface"; case THEME_PLANET_BASE: return "Planet Base"; case THEME_SPACE_STATION: return "Space Station"; + case THEME_MARS_SURFACE: return "Mars Surface"; + case THEME_MARS_BASE: return "Mars Base"; default: return "Unknown"; } } @@ -60,11 +69,17 @@ static void load_generated_level(void) { config.num_segments = 6; config.difficulty = 0.5f; + /* Ensure seed is non-zero so theme selection is deterministic + * across restarts. If caller didn't set s_gen_seed (e.g. first + * run with -gen but no -seed), snapshot time(NULL) now. */ + if (s_gen_seed == 0) s_gen_seed = (uint32_t)time(NULL); + config.seed = s_gen_seed; + /* Build a theme progression — start on surface, move indoors/upward. * Derive from seed so it varies with each regeneration and doesn't * depend on rand() state (which parallax generators clobber). */ - uint32_t seed_for_theme = config.seed ? config.seed : (uint32_t)time(NULL); - int r = (int)(seed_for_theme % 4); + uint32_t seed_for_theme = config.seed; + int r = (int)(seed_for_theme % 6); switch (r) { case 0: /* Surface -> Base */ config.themes[0] = THEME_PLANET_SURFACE; @@ -93,9 +108,27 @@ static void load_generated_level(void) { config.themes[5] = THEME_SPACE_STATION; config.theme_count = 6; break; - case 3: /* Single theme (derived from seed) */ + case 3: /* Mars Surface -> Mars Base */ + config.themes[0] = THEME_MARS_SURFACE; + config.themes[1] = THEME_MARS_SURFACE; + config.themes[2] = THEME_MARS_SURFACE; + config.themes[3] = THEME_MARS_BASE; + config.themes[4] = THEME_MARS_BASE; + config.themes[5] = THEME_MARS_BASE; + config.theme_count = 6; + break; + case 4: /* Mars Surface -> Mars Base -> Station */ + config.themes[0] = THEME_MARS_SURFACE; + config.themes[1] = THEME_MARS_SURFACE; + config.themes[2] = THEME_MARS_BASE; + config.themes[3] = THEME_MARS_BASE; + config.themes[4] = THEME_SPACE_STATION; + config.themes[5] = THEME_SPACE_STATION; + config.theme_count = 6; + break; + case 5: /* Single theme (derived from seed) */ default: { - LevelTheme single = (LevelTheme)(seed_for_theme / 4 % THEME_COUNT); + LevelTheme single = (LevelTheme)(seed_for_theme / 6 % THEME_COUNT); config.themes[0] = single; config.theme_count = 1; break; @@ -200,6 +233,20 @@ static void return_to_editor(void) { SDL_SetWindowTitle(g_engine.window, "Jump 'n Run - Level Editor"); } +/* ── Restart current level (file-based or generated) ── */ +static void restart_level(void) { + level_free(&s_level); + if (s_level_path[0]) { + if (!load_level_file(s_level_path)) { + fprintf(stderr, "Failed to restart level: %s\n", s_level_path); + g_engine.running = false; + } + } else { + /* Generated level — regenerate with same seed. */ + load_generated_level(); + } +} + /* ═══════════════════════════════════════════════════ * Game callbacks * ═══════════════════════════════════════════════════ */ @@ -217,6 +264,48 @@ static void game_init(void) { } } +/* ── Pause menu: handle input and confirm actions ── */ +static void pause_update(void) { + /* Unpause on escape */ + if (input_pressed(ACTION_PAUSE)) { + s_mode = MODE_PLAY; + return; + } + + /* Navigate menu items */ + if (input_pressed(ACTION_UP)) { + s_pause_selection--; + if (s_pause_selection < 0) s_pause_selection = PAUSE_ITEM_COUNT - 1; + } + if (input_pressed(ACTION_DOWN)) { + s_pause_selection++; + if (s_pause_selection >= PAUSE_ITEM_COUNT) s_pause_selection = 0; + } + + /* Confirm selection with jump or enter */ + bool confirm = input_pressed(ACTION_JUMP) + || input_key_pressed(SDL_SCANCODE_RETURN) + || input_key_pressed(SDL_SCANCODE_RETURN2); + if (!confirm) return; + + switch (s_pause_selection) { + case 0: /* Resume */ + s_mode = MODE_PLAY; + break; + case 1: /* Restart */ + s_mode = MODE_PLAY; + restart_level(); + break; + case 2: /* Quit */ + if (s_testing_from_editor) { + return_to_editor(); + } else { + g_engine.running = false; + } + break; + } +} + static void game_update(float dt) { if (s_mode == MODE_EDITOR) { editor_update(&s_editor, dt); @@ -230,15 +319,21 @@ static void game_update(float dt) { return; } + if (s_mode == MODE_PAUSED) { + pause_update(); + return; + } + /* ── Play mode ── */ - /* Quit / return to editor on escape */ + /* Pause on escape (return to editor during test play) */ if (input_pressed(ACTION_PAUSE)) { if (s_testing_from_editor) { return_to_editor(); return; } - g_engine.running = false; + s_pause_selection = 0; + s_mode = MODE_PAUSED; return; } @@ -306,9 +401,54 @@ static void game_update(float dt) { } } +/* ── Draw the pause menu overlay on top of the frozen game frame ── */ +static void pause_render(void) { + SDL_Renderer *r = g_engine.renderer; + + /* Semi-transparent dark overlay */ + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(r, 0, 0, 0, 140); + SDL_Rect overlay = {0, 0, SCREEN_WIDTH, SCREEN_HEIGHT}; + SDL_RenderFillRect(r, &overlay); + + /* Title */ + SDL_Color col_title = {255, 255, 255, 255}; + font_draw_text_centered(r, "PAUSED", SCREEN_HEIGHT / 2 - 40, + SCREEN_WIDTH, col_title); + + /* Menu items */ + static const char *items[PAUSE_ITEM_COUNT] = { + "RESUME", "RESTART", "QUIT" + }; + SDL_Color col_normal = {160, 160, 170, 255}; + SDL_Color col_active = {255, 220, 80, 255}; + + int item_y = SCREEN_HEIGHT / 2 - 8; + for (int i = 0; i < PAUSE_ITEM_COUNT; i++) { + SDL_Color c = (i == s_pause_selection) ? col_active : col_normal; + + /* Draw selection indicator */ + if (i == s_pause_selection) { + int tw = font_text_width(items[i]); + int tx = (SCREEN_WIDTH - tw) / 2; + font_draw_text(r, ">", tx - 8, item_y, col_active); + } + + font_draw_text_centered(r, items[i], item_y, SCREEN_WIDTH, c); + item_y += 14; + } + + /* Restore blend mode */ + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); +} + static void game_render(float interpolation) { if (s_mode == MODE_EDITOR) { editor_render(&s_editor, interpolation); + } else if (s_mode == MODE_PAUSED) { + /* Render frozen game frame, then overlay the pause menu. */ + level_render(&s_level, interpolation); + pause_render(); } else { level_render(&s_level, interpolation); } @@ -318,7 +458,7 @@ static void game_shutdown(void) { /* Always free both — editor may have been initialized even if we're * currently in play mode (e.g. shutdown during test play). editor_free * and level_free are safe to call on zeroed/already-freed structs. */ - if (s_mode == MODE_PLAY || s_testing_from_editor) { + if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED || s_testing_from_editor) { level_free(&s_level); } if (s_mode == MODE_EDITOR || s_use_editor) { @@ -356,7 +496,7 @@ int main(int argc, char *argv[]) { printf("\nIn-game:\n"); printf(" R Regenerate level with new random seed\n"); printf(" E Open level editor\n"); - printf(" ESC Quit (or return to editor from test play)\n"); + printf(" ESC Pause (or return to editor from test play)\n"); printf("\nEditor:\n"); printf(" 1-6 Select tool (Pencil/Eraser/Fill/Entity/Spawn/Exit)\n"); printf(" Q/W/E Select layer (Collision/BG/FG)\n");