forked from tas/major_tom
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:
159
src/game/analytics.c
Normal file
159
src/game/analytics.c
Normal file
@@ -0,0 +1,159 @@
|
||||
#include "game/analytics.h"
|
||||
#include <stdio.h>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten.h>
|
||||
|
||||
/* ── EM_JS bridge: JavaScript functions callable from C ─────────── */
|
||||
|
||||
/* Initialize client_id in localStorage and store the analytics
|
||||
* API URL + key. Called once at startup. */
|
||||
EM_JS(void, js_analytics_init, (), {
|
||||
/* Generate or retrieve a persistent client UUID */
|
||||
if (!localStorage.getItem('jnr_client_id')) {
|
||||
localStorage.setItem('jnr_client_id', crypto.randomUUID());
|
||||
}
|
||||
/* Store config on the Module for later use by other EM_JS calls.
|
||||
* ANALYTICS_URL and ANALYTICS_KEY are replaced at build time via
|
||||
* -D defines, falling back to sensible defaults. */
|
||||
Module._analyticsClientId = localStorage.getItem('jnr_client_id');
|
||||
Module._analyticsSessionId = null;
|
||||
|
||||
/* Runtime config: check for data attributes on the canvas container
|
||||
* first, then fall back to compiled-in defaults. */
|
||||
var container = document.getElementById('canvas-container');
|
||||
Module._analyticsUrl = (container && container.dataset.analyticsUrl)
|
||||
? container.dataset.analyticsUrl
|
||||
: (typeof ANALYTICS_URL !== 'undefined' ? ANALYTICS_URL : '');
|
||||
Module._analyticsKey = (container && container.dataset.analyticsKey)
|
||||
? container.dataset.analyticsKey
|
||||
: (typeof ANALYTICS_KEY !== 'undefined' ? ANALYTICS_KEY : '');
|
||||
|
||||
if (!Module._analyticsUrl) {
|
||||
console.log('[analytics] No analytics URL configured — analytics disabled');
|
||||
} else {
|
||||
console.log('[analytics] Initialized, client_id=' + Module._analyticsClientId);
|
||||
}
|
||||
});
|
||||
|
||||
/* Start a new session. Sends POST /api/analytics/session/start/. */
|
||||
EM_JS(void, js_analytics_session_start, (), {
|
||||
if (!Module._analyticsUrl) return;
|
||||
|
||||
var body = JSON.stringify({
|
||||
client_id: Module._analyticsClientId,
|
||||
device: {
|
||||
platform: navigator.platform || '',
|
||||
language: navigator.language || '',
|
||||
screen_width: screen.width,
|
||||
screen_height: screen.height,
|
||||
device_pixel_ratio: window.devicePixelRatio || 1,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
|
||||
webgl_renderer: (function() {
|
||||
try {
|
||||
var c = document.createElement('canvas');
|
||||
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
|
||||
if (gl) {
|
||||
var ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : '';
|
||||
}
|
||||
} catch(e) {}
|
||||
return '';
|
||||
})(),
|
||||
touch_support: ('ontouchstart' in window)
|
||||
}
|
||||
});
|
||||
|
||||
fetch(Module._analyticsUrl + '/api/analytics/session/start/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': Module._analyticsKey
|
||||
},
|
||||
body: body
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
Module._analyticsSessionId = data.session_id || null;
|
||||
console.log('[analytics] Session started: ' + Module._analyticsSessionId);
|
||||
})
|
||||
.catch(function(err) {
|
||||
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;
|
||||
|
||||
var endReason = UTF8ToString(end_reason_ptr);
|
||||
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 */
|
||||
Module._analyticsSessionId = null;
|
||||
});
|
||||
|
||||
/* ── C wrappers ─────────────────────────────────────────────────── */
|
||||
|
||||
void analytics_init(void) {
|
||||
js_analytics_init();
|
||||
}
|
||||
|
||||
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(
|
||||
stats->score,
|
||||
stats->levels_completed > 0 ? stats->levels_completed : 1,
|
||||
stats->deaths,
|
||||
(int)stats->time_elapsed,
|
||||
end_reason
|
||||
);
|
||||
}
|
||||
|
||||
#else
|
||||
/* ── Non-WASM stubs ─────────────────────────────────────────────── */
|
||||
|
||||
void analytics_init(void) {
|
||||
printf("[analytics] Analytics disabled (native build)\n");
|
||||
}
|
||||
|
||||
void analytics_session_start(void) {}
|
||||
|
||||
void analytics_session_end(const GameStats *stats, const char *end_reason) {
|
||||
(void)stats;
|
||||
(void)end_reason;
|
||||
}
|
||||
|
||||
#endif /* __EMSCRIPTEN__ */
|
||||
Reference in New Issue
Block a user