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

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