Add pause menu, laser turret, charger/spawner enemies, and Mars campaign

Implement four feature phases:

Phase 1 - Pause menu: extract bitmap font into shared engine/font
module, add MODE_PAUSED with Resume/Restart/Quit overlay.

Phase 2 - Laser turret hazard: ENT_LASER_TURRET with charge/fire/
cooldown state machine, per-pixel beam raycast, two variants (fixed
and tracking). Registered in entity registry with editor icons.

Phase 3 - Charger and Spawner enemies: charger ground patrol with
detect/telegraph/charge/stun cycle (2s charge timeout), spawner that
periodically creates grunts up to a global cap of 3.

Phase 4 - Mars campaign: two handcrafted levels (mars01 surface,
mars02 base), mars_tileset.png, PARALLAX_STYLE_MARS with salmon sky
and red mesas, THEME_MARS_SURFACE/THEME_MARS_BASE for the procedural
generator with per-theme gravity/tileset/parallax. Moon campaign now
chains moon03 -> mars01 -> mars02 -> victory.

Also fix review findings: deterministic seed on generated level
restart, NULL checks on calloc in spawn functions, charge timeout
to prevent infinite charge on flat terrain, and stop suppressing
stderr in Makefile web-serve target so real errors are visible.
This commit is contained in:
Thomas
2026-03-02 19:34:12 +00:00
parent e5e91247fe
commit d0853fb38d
22 changed files with 1519 additions and 147 deletions

View File

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

38
TODO.md
View File

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

62
assets/levels/mars01.lvl Normal file
View File

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

108
assets/levels/mars02.lvl Normal file
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

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

121
src/engine/font.c Normal file
View File

@@ -0,0 +1,121 @@
#include "engine/font.h"
#include <string.h>
/* ═══════════════════════════════════════════════════
* 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;
}

32
src/engine/font.h Normal file
View File

@@ -0,0 +1,32 @@
#ifndef JNR_FONT_H
#define JNR_FONT_H
#include <SDL2/SDL.h>
/* ═══════════════════════════════════════════════════
* 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 */

View File

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

View File

@@ -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 */

View File

@@ -5,6 +5,7 @@
#include "engine/renderer.h"
#include "engine/assets.h"
#include "engine/camera.h"
#include "engine/font.h"
#include "config.h"
#include <stdio.h>
#include <stdlib.h>
@@ -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);
}
}

View File

@@ -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 <stdlib.h>
#include <math.h>
@@ -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;
}

View File

@@ -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 */

View File

@@ -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) {

View File

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

351
src/game/laser_turret.c Normal file
View File

@@ -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 <stdlib.h>
#include <math.h>
/* ═══════════════════════════════════════════════════
* 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);
}

48
src/game/laser_turret.h Normal file
View File

@@ -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 */

View File

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

View File

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

View File

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

View File

@@ -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 <stdio.h>
#include <string.h>
#include <time.h>
@@ -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");