Add analytics integration with Horchposten backend

Implements session-based analytics tracking that sends gameplay
stats to the Horchposten API. Adds stats.{c,h} for accumulating
per-session metrics (kills, deaths, shots, dashes, jumps, pickups,
damage, time) and analytics.{c,h} with EM_JS bridge for fetch()
calls to the backend. Client ID is persisted in localStorage.
Session start/end hooks are wired into all game lifecycle events
(level transitions, restart, quit, tab close via sendBeacon).
Analytics URL/key are configured via data attributes on the canvas
container. Non-WASM builds compile with no-op stubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 17:11:39 +00:00
parent 4407932a2d
commit a23ecaf4c1
8 changed files with 331 additions and 1 deletions

View File

@@ -4,6 +4,8 @@
#include "game/level.h"
#include "game/levelgen.h"
#include "game/editor.h"
#include "game/stats.h"
#include "game/analytics.h"
#include "config.h"
#include <stdio.h>
#include <string.h>
@@ -46,6 +48,10 @@ static int s_station_depth = 0;
static int s_mars_depth = 0;
#define MARS_BASE_GEN_COUNT 2
/* ── Analytics / stats tracking ── */
static GameStats s_stats;
static bool s_session_active = false;
/* ── Pause menu state ── */
#define PAUSE_ITEM_COUNT 3
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
@@ -238,6 +244,21 @@ static void load_mars_base_level(void) {
s_level_path[0] = '\0';
}
/* ── Analytics session helpers ── */
static void begin_session(void) {
stats_reset(&s_stats);
stats_set_active(&s_stats);
analytics_session_start();
s_session_active = true;
}
static void end_session(const char *reason) {
if (!s_session_active) return;
s_session_active = false;
stats_set_active(NULL);
analytics_session_end(&s_stats, reason);
}
/* ── Switch to editor mode ── */
static void enter_editor(void) {
if (s_mode == MODE_PLAY) {
@@ -305,15 +326,19 @@ static void restart_level(void) {
* ═══════════════════════════════════════════════════ */
static void game_init(void) {
analytics_init();
if (s_use_editor) {
enter_editor();
} else if (s_use_procgen) {
load_generated_level();
begin_session();
} else {
if (!load_level_file("assets/levels/moon01.lvl")) {
fprintf(stderr, "Failed to load level!\n");
g_engine.running = false;
}
begin_session();
}
}
@@ -347,12 +372,15 @@ static void pause_update(void) {
break;
case 1: /* Restart */
s_mode = MODE_PLAY;
end_session("quit");
restart_level();
begin_session();
break;
case 2: /* Quit */
if (s_testing_from_editor) {
return_to_editor();
} else {
end_session("quit");
g_engine.running = false;
}
break;
@@ -364,6 +392,7 @@ static void game_update(float dt) {
/* Handle deferred level load from JS shell dropdown. */
if (s_js_load_request && s_js_load_path[0]) {
s_js_load_request = 0;
end_session("quit");
/* Tear down whatever mode we are in. */
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED) {
@@ -389,6 +418,7 @@ static void game_update(float dt) {
s_js_load_path[0] = '\0';
SDL_SetWindowTitle(g_engine.window, "Jump 'n Run");
begin_session();
return;
}
#endif
@@ -437,22 +467,35 @@ 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");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
s_use_procgen = true;
load_generated_level();
begin_session();
}
r_was_pressed = r_pressed;
level_update(&s_level, dt);
/* Accumulate play time */
if (s_session_active) {
s_stats.time_elapsed += dt;
}
/* Check for level exit transition */
if (level_exit_triggered(&s_level)) {
const char *target = s_level.exit_target;
/* Record the level completion in stats */
if (s_session_active) {
s_stats.levels_completed++;
}
if (target[0] == '\0') {
/* Empty target = victory / end of game */
printf("Level complete! (no next level)\n");
end_session("completed");
/* Loop back to the beginning, reset progression state */
level_free(&s_level);
s_station_depth = 0;
@@ -460,6 +503,7 @@ static void game_update(float dt) {
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
begin_session();
} else if (strcmp(target, "generate") == 0) {
/* Procedurally generated next level */
printf("Transitioning to generated level\n");
@@ -549,6 +593,8 @@ static void game_render(float interpolation) {
}
static void game_shutdown(void) {
end_session("quit");
/* 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. */