From 478c44212b32534efc28f4a3d61738de35b69cd3 Mon Sep 17 00:00:00 2001 From: Le Serjant Date: Fri, 20 Mar 2026 05:30:00 +0000 Subject: [PATCH] Fix #28: Add lives system, game over screen, and fix leaderboard score 0 - Add 3-life system: each death costs a life, game over at 0 lives - Add game over screen with arcade-style 3-character name entry - Submit player name with score to analytics backend on game over - Fix score 0 on leaderboard by periodically stashing stats to JS so the beforeunload fallback has real data when users close the tab - Display lives counter in HUD next to health hearts - Move player respawn control from level.c to main.c via player_death_pending flag for proper lives tracking --- src/game/analytics.c | 58 +++++++++++-- src/game/analytics.h | 11 ++- src/game/level.c | 33 +++++-- src/game/level.h | 7 ++ src/game/stats.h | 1 + src/main.c | 199 ++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 281 insertions(+), 28 deletions(-) diff --git a/src/game/analytics.c b/src/game/analytics.c index 7b40b33..5cb2099 100644 --- a/src/game/analytics.c +++ b/src/game/analytics.c @@ -103,20 +103,39 @@ EM_JS(void, js_analytics_session_start, (), { }); }); +/* Stash current stats into Module._analyticsLastStats so the + * beforeunload fallback has real data if the user closes the tab. + * Called periodically from C (e.g. once per second). */ +EM_JS(void, js_analytics_stash_stats, (int score, int level_reached, + int lives_used, int duration_secs), { + if (!Module._analyticsUrl) return; + Module._analyticsLastStats = JSON.stringify({ + score: score, + level_reached: level_reached > 0 ? level_reached : 1, + lives_used: lives_used, + duration_seconds: duration_secs, + end_reason: 'quit' + }); +}); + /* Internal helper: send the session-end POST (used by both the C wrapper * and the beforeunload fallback). */ EM_JS(void, js_analytics_send_end, (int score, int level_reached, int lives_used, int duration_secs, - const char *end_reason_ptr), { + const char *end_reason_ptr, + const char *player_name_ptr), { /* Helper that performs the actual end request given a session id. */ - function doEnd(sid, endReason, score, levelReached, livesUsed, durationSecs) { - var body = JSON.stringify({ + function doEnd(sid, endReason, playerName, score, levelReached, + livesUsed, durationSecs) { + var payload = { score: score, level_reached: levelReached > 0 ? levelReached : 1, lives_used: livesUsed, duration_seconds: durationSecs, end_reason: endReason - }); + }; + if (playerName) payload.player_name = playerName; + var body = JSON.stringify(payload); /* Stash stats for the beforeunload fallback */ Module._analyticsLastStats = body; @@ -143,6 +162,7 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached, if (!Module._analyticsUrl) return; var endReason = UTF8ToString(end_reason_ptr); + var playerName = player_name_ptr ? UTF8ToString(player_name_ptr) : ''; /* If session start is still in-flight, wait for it before ending. */ if (Module._analyticsStartPending) { @@ -153,7 +173,8 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached, var sid = Module._analyticsSessionId; if (sid) { Module._analyticsSessionId = null; - doEnd(sid, endReason, score, level_reached, lives_used, duration_secs); + doEnd(sid, endReason, playerName, score, level_reached, + lives_used, duration_secs); } }); return; @@ -164,7 +185,8 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached, var sid = Module._analyticsSessionId; /* Clear synchronously before the async request to prevent races */ Module._analyticsSessionId = null; - doEnd(sid, endReason, score, level_reached, lives_used, duration_secs); + doEnd(sid, endReason, playerName, score, level_reached, + lives_used, duration_secs); }); /* ── C wrappers ─────────────────────────────────────────────────── */ @@ -177,14 +199,26 @@ void analytics_session_start(void) { js_analytics_session_start(); } -void analytics_session_end(GameStats *stats, const char *end_reason) { +void analytics_session_end(GameStats *stats, const char *end_reason, + const char *player_name) { stats_update_score(stats); js_analytics_send_end( stats->score, stats->levels_completed > 0 ? stats->levels_completed : 1, stats->deaths, (int)stats->time_elapsed, - end_reason + end_reason, + player_name + ); +} + +void analytics_stash_stats(GameStats *stats) { + stats_update_score(stats); + js_analytics_stash_stats( + stats->score, + stats->levels_completed > 0 ? stats->levels_completed : 1, + stats->deaths, + (int)stats->time_elapsed ); } @@ -197,9 +231,15 @@ void analytics_init(void) { void analytics_session_start(void) {} -void analytics_session_end(GameStats *stats, const char *end_reason) { +void analytics_session_end(GameStats *stats, const char *end_reason, + const char *player_name) { (void)stats; (void)end_reason; + (void)player_name; +} + +void analytics_stash_stats(GameStats *stats) { + (void)stats; } #endif /* __EMSCRIPTEN__ */ diff --git a/src/game/analytics.h b/src/game/analytics.h index cc55822..dd10507 100644 --- a/src/game/analytics.h +++ b/src/game/analytics.h @@ -12,7 +12,14 @@ void analytics_session_start(void); /* End the current analytics session with final stats. * Computes the composite score before sending. - * end_reason: "death", "quit", "timeout", or "completed". */ -void analytics_session_end(GameStats *stats, const char *end_reason); + * end_reason: "death", "quit", "timeout", or "completed". + * player_name may be NULL or empty if no name was entered. */ +void analytics_session_end(GameStats *stats, const char *end_reason, + const char *player_name); + +/* Stash current stats into JS so the beforeunload fallback has + * real data if the user closes the tab mid-game. Call periodically + * (e.g. once per second or on level completion). No-op on native. */ +void analytics_stash_stats(GameStats *stats); #endif /* JNR_ANALYTICS_H */ diff --git a/src/game/level.c b/src/game/level.c index 992119b..7c19810 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -591,18 +591,14 @@ void level_update(Level *level, float dt) { /* Fallback direct exit zone check (for levels without ship exit) */ check_exit_zones(level); - /* Check for player respawn (skip if player boarded exit ship) */ + /* Check for player death (skip if player boarded exit ship). + * Don't auto-respawn — set a flag for main.c to handle lives. */ if (!level->exit_ship_boarded) { for (int i = 0; i < level->entities.count; i++) { Entity *e = &level->entities.entities[i]; if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) { stats_record_death(); - player_respawn(e, level->map.player_spawn); - Vec2 center = vec2( - e->body.pos.x + e->body.size.x * 0.5f, - e->body.pos.y + e->body.size.y * 0.5f - ); - camera_follow(&level->camera, center, vec2_zero(), dt); + level->player_death_pending = true; break; } } @@ -759,6 +755,18 @@ void level_render(Level *level, float interpolation) { renderer_draw_rect(pos, size, heart_color, LAYER_HUD, cam); } + /* Draw lives counter next to hearts */ + { + GameStats *st = stats_get_active(); + if (st && st->lives > 0) { + char lives_buf[8]; + snprintf(lives_buf, sizeof(lives_buf), "x%d", st->lives); + int lx = 8 + player->max_health * 14 + 4; + font_draw_text(g_engine.renderer, lives_buf, lx, 8, + (SDL_Color){200, 200, 200, 255}); + } + } + /* Draw jetpack charge indicators */ int charges, max_charges; float recharge_pct; @@ -817,6 +825,17 @@ void level_render(Level *level, float interpolation) { renderer_flush(cam); } +void level_respawn_player(Level *level) { + level->player_death_pending = false; + for (int i = 0; i < level->entities.count; i++) { + Entity *e = &level->entities.entities[i]; + if (e->active && e->type == ENT_PLAYER) { + player_respawn(e, level->map.player_spawn); + break; + } + } +} + void level_free(Level *level) { audio_stop_music(); diff --git a/src/game/level.h b/src/game/level.h index f42d3f0..f4a3bcd 100644 --- a/src/game/level.h +++ b/src/game/level.h @@ -27,6 +27,9 @@ typedef struct Level { bool exit_ship_spawned; /* exit spacecraft has been spawned */ bool exit_ship_boarded; /* player has entered the ship */ int exit_zone_idx; /* which exit zone the ship is for */ + + /* ── Death / game over ────────────────── */ + bool player_death_pending; /* player died, awaiting main.c */ } Level; bool level_load(Level *level, const char *path); @@ -39,4 +42,8 @@ void level_free(Level *level); * The target path is stored in level->exit_target. */ bool level_exit_triggered(const Level *level); +/* Respawn the player at the level's spawn point. Called by main.c + * after acknowledging a death (decrementing lives). */ +void level_respawn_player(Level *level); + #endif /* JNR_LEVEL_H */ diff --git a/src/game/stats.h b/src/game/stats.h index d00f8d6..312709a 100644 --- a/src/game/stats.h +++ b/src/game/stats.h @@ -16,6 +16,7 @@ typedef struct GameStats { int damage_taken; /* total HP lost */ int damage_dealt; /* total HP dealt to enemies */ float time_elapsed; /* wall-clock seconds */ + int lives; /* remaining lives (set by main) */ } GameStats; /* Reset all stats to zero (call at session start). */ diff --git a/src/main.c b/src/main.c index a718758..e70bd40 100644 --- a/src/main.c +++ b/src/main.c @@ -27,6 +27,7 @@ typedef enum GameMode { MODE_EDITOR, MODE_PAUSED, MODE_TRANSITION, + MODE_GAMEOVER, } GameMode; static Level s_level; @@ -56,10 +57,22 @@ static int s_mars_depth = 0; static GameStats s_stats; static bool s_session_active = false; +/* ── Lives system ── */ +#define STARTING_LIVES 3 +static int s_lives = STARTING_LIVES; +static float s_stash_timer = 0.0f; /* timer for periodic stats stash */ +#define STASH_INTERVAL 2.0f /* seconds between stats stashes */ + /* ── Pause menu state ── */ #define PAUSE_ITEM_COUNT 3 static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */ +/* ── Game over state ── */ +#define NAME_MAX_LEN 3 +static char s_go_name[NAME_MAX_LEN + 1] = "AAA"; /* player name for scoreboard */ +static int s_go_cursor = 0; /* which character is selected */ +static bool s_go_submitted = false; /* name has been submitted */ + /* ── Level transition state ── */ static TransitionState s_transition; static char s_pending_target[ASSET_PATH_MAX] = {0}; /* exit target stashed during transition */ @@ -259,16 +272,19 @@ static void load_mars_base_level(void) { /* ── Analytics session helpers ── */ static void begin_session(void) { stats_reset(&s_stats); + s_lives = STARTING_LIVES; + s_stats.lives = s_lives; + s_stash_timer = 0.0f; stats_set_active(&s_stats); analytics_session_start(); s_session_active = true; } -static void end_session(const char *reason) { +static void end_session(const char *reason, const char *player_name) { if (!s_session_active) return; s_session_active = false; stats_set_active(NULL); - analytics_session_end(&s_stats, reason); + analytics_session_end(&s_stats, reason, player_name); } /* ── Switch to editor mode ── */ @@ -342,7 +358,7 @@ static void dispatch_level_load(const char *target) { if (target[0] == '\0') { /* Empty target = victory / end of game. */ printf("Level complete! (no next level)\n"); - end_session("completed"); + end_session("completed", NULL); level_free(&s_level); s_station_depth = 0; s_mars_depth = 0; @@ -430,7 +446,7 @@ static void pause_update(void) { break; case 1: /* Restart */ s_mode = MODE_PLAY; - end_session("quit"); + end_session("quit", NULL); restart_level(); begin_session(); break; @@ -438,7 +454,7 @@ static void pause_update(void) { if (s_testing_from_editor) { return_to_editor(); } else { - end_session("quit"); + end_session("quit", NULL); g_engine.running = false; } break; @@ -462,11 +478,11 @@ static void game_update(float dt) { return; } - end_session("quit"); + end_session("quit", NULL); /* Tear down whatever mode we are in. */ if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED - || s_mode == MODE_TRANSITION) { + || s_mode == MODE_TRANSITION || s_mode == MODE_GAMEOVER) { debuglog_set_level(NULL, NULL); transition_reset(&s_transition); level_free(&s_level); @@ -511,6 +527,11 @@ static void game_update(float dt) { return; } + if (s_mode == MODE_GAMEOVER) { + gameover_update(); + return; + } + if (s_mode == MODE_TRANSITION) { transition_update(&s_transition, dt, &s_level.camera); @@ -566,7 +587,7 @@ static void game_update(float dt) { bool r_pressed = input_key_held(SDL_SCANCODE_R); if (r_pressed && !r_was_pressed) { printf("\n=== Regenerating level ===\n"); - end_session("quit"); + end_session("quit", NULL); debuglog_set_level(NULL, NULL); level_free(&s_level); s_gen_seed = (uint32_t)time(NULL); @@ -583,6 +604,25 @@ static void game_update(float dt) { s_stats.time_elapsed += dt; } + /* Check for player death — decrement lives or trigger game over */ + if (s_level.player_death_pending) { + s_level.player_death_pending = false; + s_lives--; + s_stats.lives = s_lives; + if (s_lives <= 0) { + enter_gameover(); + return; + } + level_respawn_player(&s_level); + } + + /* Periodically stash stats so beforeunload has real data */ + s_stash_timer += dt; + if (s_stash_timer >= STASH_INTERVAL && s_session_active) { + analytics_stash_stats(&s_stats); + s_stash_timer = 0.0f; + } + /* Check for level exit transition */ if (level_exit_triggered(&s_level)) { const char *target = s_level.exit_target; @@ -647,6 +687,140 @@ static void pause_render(void) { SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); } +/* ── Enter game over mode ── */ +static void enter_gameover(void) { + s_mode = MODE_GAMEOVER; + s_go_cursor = 0; + s_go_submitted = false; + memcpy(s_go_name, "AAA", NAME_MAX_LEN + 1); + + /* Finalize score for display */ + stats_update_score(&s_stats); +} + +/* ── Game over: handle name entry input ── */ +static void gameover_update(void) { + if (s_go_submitted) { + /* After submission, wait for confirm to restart */ + bool confirm = input_pressed(ACTION_JUMP) + || input_key_pressed(SDL_SCANCODE_RETURN) + || input_key_pressed(SDL_SCANCODE_RETURN2); + if (confirm) { + end_session("death", s_go_name); + debuglog_set_level(NULL, NULL); + level_free(&s_level); + s_station_depth = 0; + s_mars_depth = 0; + s_mode = MODE_PLAY; + if (!load_level_file("assets/levels/moon01.lvl")) { + g_engine.running = false; + } + begin_session(); + } + return; + } + + /* Cycle letter up/down */ + if (input_pressed(ACTION_UP)) { + s_go_name[s_go_cursor]++; + if (s_go_name[s_go_cursor] > 'Z') s_go_name[s_go_cursor] = 'A'; + } + if (input_pressed(ACTION_DOWN)) { + s_go_name[s_go_cursor]--; + if (s_go_name[s_go_cursor] < 'A') s_go_name[s_go_cursor] = 'Z'; + } + + /* Move cursor left/right */ + if (input_pressed(ACTION_LEFT)) { + if (s_go_cursor > 0) s_go_cursor--; + } + if (input_pressed(ACTION_RIGHT)) { + if (s_go_cursor < NAME_MAX_LEN - 1) s_go_cursor++; + } + + /* Confirm name */ + bool confirm = input_pressed(ACTION_JUMP) + || input_key_pressed(SDL_SCANCODE_RETURN) + || input_key_pressed(SDL_SCANCODE_RETURN2); + if (confirm) { + s_go_submitted = true; + } +} + +/* ── Game over: render overlay ── */ +static void gameover_render(void) { + SDL_Renderer *r = g_engine.renderer; + + /* Dark overlay */ + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(r, 0, 0, 0, 180); + SDL_Rect overlay = {0, 0, SCREEN_WIDTH, SCREEN_HEIGHT}; + SDL_RenderFillRect(r, &overlay); + + SDL_Color col_title = {220, 50, 50, 255}; + SDL_Color col_text = {200, 200, 200, 255}; + SDL_Color col_score = {255, 220, 80, 255}; + SDL_Color col_dim = {120, 120, 130, 255}; + + int cy = SCREEN_HEIGHT / 2 - 50; + + font_draw_text_centered(r, "GAME OVER", cy, SCREEN_WIDTH, col_title); + cy += 20; + + /* Show final score */ + { + char score_buf[32]; + snprintf(score_buf, sizeof(score_buf), "SCORE: %d", s_stats.score); + font_draw_text_centered(r, score_buf, cy, SCREEN_WIDTH, col_score); + cy += 20; + } + + if (!s_go_submitted) { + font_draw_text_centered(r, "ENTER YOUR NAME", cy, SCREEN_WIDTH, col_text); + cy += 16; + + /* Draw name characters with cursor highlight */ + { + char name_display[16]; + snprintf(name_display, sizeof(name_display), "%c %c %c", + s_go_name[0], s_go_name[1], s_go_name[2]); + int nw = font_text_width(name_display); + int nx = (SCREEN_WIDTH - nw) / 2; + int spacing = nw / 3; + + for (int i = 0; i < NAME_MAX_LEN; i++) { + char ch[2] = { s_go_name[i], '\0' }; + SDL_Color c = (i == s_go_cursor) ? col_score : col_text; + int cx = nx + i * spacing; + font_draw_text(r, ch, cx, cy, c); + + /* Draw cursor arrow above selected character */ + if (i == s_go_cursor) { + font_draw_text(r, "^", cx, cy + 10, col_score); + } + } + cy += 24; + } + + font_draw_text_centered(r, "UP/DOWN=LETTER LEFT/RIGHT=MOVE", + cy, SCREEN_WIDTH, col_dim); + cy += 12; + font_draw_text_centered(r, "JUMP=CONFIRM", cy, SCREEN_WIDTH, col_dim); + } else { + /* Name submitted — show confirmation */ + { + char msg[32]; + snprintf(msg, sizeof(msg), "NAME: %s", s_go_name); + font_draw_text_centered(r, msg, cy, SCREEN_WIDTH, col_text); + cy += 20; + } + font_draw_text_centered(r, "PRESS JUMP TO CONTINUE", + cy, SCREEN_WIDTH, col_dim); + } + + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); +} + static void game_render(float interpolation) { if (s_mode == MODE_EDITOR) { editor_render(&s_editor, interpolation); @@ -658,20 +832,25 @@ static void game_render(float interpolation) { /* Render the level (frozen) with the transition overlay on top. */ level_render(&s_level, interpolation); transition_render(&s_transition); + } else if (s_mode == MODE_GAMEOVER) { + /* Render frozen game frame with game over overlay. */ + level_render(&s_level, interpolation); + gameover_render(); } else { level_render(&s_level, interpolation); } } static void game_shutdown(void) { - end_session("quit"); + end_session("quit", NULL); debuglog_set_level(NULL, NULL); /* 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_mode == MODE_PAUSED - || s_mode == MODE_TRANSITION || s_testing_from_editor) { + || s_mode == MODE_TRANSITION || s_mode == MODE_GAMEOVER + || s_testing_from_editor) { level_free(&s_level); } if (s_mode == MODE_EDITOR || s_use_editor) {