Add analytics integration with Horchposten backend #1

Merged
tas merged 2 commits from feature/analytics-integration into main 2026-03-08 19:42:59 +00:00
5 changed files with 94 additions and 51 deletions
Showing only changes of commit 322dd184ab - Show all commits

View File

@@ -18,6 +18,8 @@ EM_JS(void, js_analytics_init, (), {
* -D defines, falling back to sensible defaults. */
Module._analyticsClientId = localStorage.getItem('jnr_client_id');
Module._analyticsSessionId = null;
Module._analyticsStartPending = null; /* Promise while start is in-flight */
Module._analyticsLastStats = null; /* stashed for beforeunload fallback */
/* Runtime config: check for data attributes on the canvas container
* first, then fall back to compiled-in defaults. */
@@ -36,7 +38,8 @@ EM_JS(void, js_analytics_init, (), {
}
});
/* Start a new session. Sends POST /api/analytics/session/start/. */
/* Start a new session. Sends POST /api/analytics/session/start/.
* Stores the in-flight promise so session_end can wait for it. */
EM_JS(void, js_analytics_session_start, (), {
if (!Module._analyticsUrl) return;
@@ -64,7 +67,7 @@ EM_JS(void, js_analytics_session_start, (), {
}
});
fetch(Module._analyticsUrl + '/api/analytics/session/start/', {
Module._analyticsStartPending = fetch(Module._analyticsUrl + '/api/analytics/session/start/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -75,50 +78,77 @@ EM_JS(void, js_analytics_session_start, (), {
.then(function(r) { return r.json(); })
.then(function(data) {
Module._analyticsSessionId = data.session_id || null;
Module._analyticsStartPending = null;
console.log('[analytics] Session started: ' + Module._analyticsSessionId);
})
.catch(function(err) {
Module._analyticsStartPending = null;
console.error('[analytics] Session start failed:', err);
});
});
/* End the current session with gameplay stats.
* Parameters are passed from C as integers/string. */
EM_JS(void, js_analytics_session_end, (int score, int level_reached,
int lives_used, int duration_secs,
const char *end_reason_ptr), {
if (!Module._analyticsUrl || !Module._analyticsSessionId) return;
/* 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), {
/* Helper that performs the actual end request given a session id. */
function doEnd(sid, endReason, score, levelReached, livesUsed, durationSecs) {
var body = JSON.stringify({
score: score,
level_reached: levelReached > 0 ? levelReached : 1,
lives_used: livesUsed,
duration_seconds: durationSecs,
end_reason: endReason
});
/* Stash stats for the beforeunload fallback */
Module._analyticsLastStats = body;
return fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': Module._analyticsKey
},
body: body,
keepalive: true
})
.then(function(r) { return r.json(); })
.then(function(data) {
console.log('[analytics] Session ended: ' + sid +
(data.new_high_score ? ' (NEW HIGH SCORE!)' : ''));
})
.catch(function(err) {
console.error('[analytics] Session end failed:', err);
});
}
if (!Module._analyticsUrl) return;
var endReason = UTF8ToString(end_reason_ptr);
/* If session start is still in-flight, wait for it before ending. */
if (Module._analyticsStartPending) {
var pending = Module._analyticsStartPending;
/* Clear session synchronously so duplicate end calls are harmless */
Module._analyticsStartPending = null;
pending.then(function() {
var sid = Module._analyticsSessionId;
if (sid) {
Module._analyticsSessionId = null;
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
}
});
return;
}
if (!Module._analyticsSessionId) return;
var sid = Module._analyticsSessionId;
var body = JSON.stringify({
score: score,
level_reached: level_reached > 0 ? level_reached : 1,
lives_used: lives_used,
duration_seconds: duration_secs,
end_reason: endReason
});
fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': Module._analyticsKey
},
body: body
})
.then(function(r) { return r.json(); })
.then(function(data) {
console.log('[analytics] Session ended: ' + sid +
(data.new_high_score ? ' (NEW HIGH SCORE!)' : ''));
})
.catch(function(err) {
console.error('[analytics] Session end failed:', err);
});
/* Clear session so duplicate end calls are harmless */
/* Clear synchronously before the async request to prevent races */
Module._analyticsSessionId = null;
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
});
/* ── C wrappers ─────────────────────────────────────────────────── */
@@ -131,9 +161,9 @@ void analytics_session_start(void) {
js_analytics_session_start();
}
void analytics_session_end(const GameStats *stats, const char *end_reason) {
stats_update_score((GameStats *)stats);
js_analytics_session_end(
void analytics_session_end(GameStats *stats, const char *end_reason) {
stats_update_score(stats);
js_analytics_send_end(
stats->score,
stats->levels_completed > 0 ? stats->levels_completed : 1,
stats->deaths,
@@ -151,7 +181,7 @@ void analytics_init(void) {
void analytics_session_start(void) {}
void analytics_session_end(const GameStats *stats, const char *end_reason) {
void analytics_session_end(GameStats *stats, const char *end_reason) {
(void)stats;
(void)end_reason;
}

View File

@@ -11,7 +11,8 @@ void analytics_init(void);
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(const GameStats *stats, const char *end_reason);
void analytics_session_end(GameStats *stats, const char *end_reason);
#endif /* JNR_ANALYTICS_H */

View File

@@ -175,11 +175,9 @@ bool level_load_generated(Level *level, Tilemap *gen_map) {
static Camera *s_active_camera = NULL;
static void damage_entity(Entity *target, int damage) {
stats_record_damage_dealt(damage);
target->health -= damage;
if (target->health <= 0) {
target->flags |= ENTITY_DEAD;
stats_record_kill();
/* Death particles — centered on entity */
Vec2 center = vec2(
@@ -215,6 +213,8 @@ static void damage_player(Entity *player, int damage, Entity *source) {
PlayerData *ppd = (PlayerData *)player->data;
stats_record_damage_taken(damage);
damage_entity(player, damage);
/* Note: damage_taken is recorded here; damage_dealt and kills are
* recorded at the specific call sites that deal player-sourced damage. */
/* Screen shake on player hit (stronger) */
if (s_active_camera) {
@@ -267,8 +267,10 @@ static void handle_collisions(EntityManager *em) {
/* Player bullet hits enemies */
if (from_player && entity_is_enemy(b)) {
if (physics_overlap(&a->body, &b->body)) {
stats_record_damage_dealt(a->damage);
damage_entity(b, a->damage);
stats_record_shot_hit();
if (b->flags & ENTITY_DEAD) stats_record_kill();
hit = true;
}
}
@@ -300,7 +302,9 @@ static void handle_collisions(EntityManager *em) {
a->body.pos.y + a->body.size.y * 0.5f);
if (stomping) {
stats_record_damage_dealt(2);
damage_entity(a, 2);
if (a->flags & ENTITY_DEAD) stats_record_kill();
player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f;
} else {
damage_player(player, a->damage, a);

View File

@@ -1,8 +1,6 @@
#ifndef JNR_STATS_H
#define JNR_STATS_H
#include <stdint.h>
/* Per-session gameplay statistics, accumulated during play and
* submitted to the analytics backend when the session ends. */
typedef struct GameStats {

View File

@@ -286,23 +286,33 @@
}
/* ── Analytics: end session on tab close ────────────── */
/* Fallback for when the WASM shutdown path didn't get a chance to
* run (e.g. user closes tab mid-game). Uses fetch with keepalive
* so the browser can send the request after the page is gone, and
* includes the X-API-Key header that sendBeacon can't carry. */
window.addEventListener('beforeunload', function() {
if (typeof Module !== 'undefined' && Module._analyticsSessionId &&
Module._analyticsUrl) {
/* Use sendBeacon for reliability during page unload */
var sid = Module._analyticsSessionId;
var body = JSON.stringify({
Module._analyticsSessionId = null;
/* Use stashed stats from the last C-side update if available,
* otherwise send minimal data so the session isn't left open. */
var body = Module._analyticsLastStats || JSON.stringify({
score: 0,
level_reached: 1,
lives_used: 0,
duration_seconds: 0,
end_reason: 'quit'
});
var blob = new Blob([body], { type: 'application/json' });
navigator.sendBeacon(
Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/',
blob
);
fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': Module._analyticsKey
},
body: body,
keepalive: true
});
}
});
</script>