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__ */
|
||||||
17
src/game/analytics.h
Normal file
17
src/game/analytics.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#ifndef JNR_ANALYTICS_H
|
||||||
|
#define JNR_ANALYTICS_H
|
||||||
|
|
||||||
|
#include "game/stats.h"
|
||||||
|
|
||||||
|
/* Initialize analytics subsystem (load/generate client_id).
|
||||||
|
* No-op on non-WASM builds. */
|
||||||
|
void analytics_init(void);
|
||||||
|
|
||||||
|
/* Start a new analytics session. Sends POST to the backend. */
|
||||||
|
void analytics_session_start(void);
|
||||||
|
|
||||||
|
/* End the current analytics session with final stats.
|
||||||
|
* end_reason: "death", "quit", "timeout", or "completed". */
|
||||||
|
void analytics_session_end(const GameStats *stats, const char *end_reason);
|
||||||
|
|
||||||
|
#endif /* JNR_ANALYTICS_H */
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "game/spacecraft.h"
|
#include "game/spacecraft.h"
|
||||||
#include "game/sprites.h"
|
#include "game/sprites.h"
|
||||||
#include "game/entity_registry.h"
|
#include "game/entity_registry.h"
|
||||||
|
#include "game/stats.h"
|
||||||
#include "engine/core.h"
|
#include "engine/core.h"
|
||||||
#include "engine/renderer.h"
|
#include "engine/renderer.h"
|
||||||
#include "engine/physics.h"
|
#include "engine/physics.h"
|
||||||
@@ -174,9 +175,11 @@ bool level_load_generated(Level *level, Tilemap *gen_map) {
|
|||||||
static Camera *s_active_camera = NULL;
|
static Camera *s_active_camera = NULL;
|
||||||
|
|
||||||
static void damage_entity(Entity *target, int damage) {
|
static void damage_entity(Entity *target, int damage) {
|
||||||
|
stats_record_damage_dealt(damage);
|
||||||
target->health -= damage;
|
target->health -= damage;
|
||||||
if (target->health <= 0) {
|
if (target->health <= 0) {
|
||||||
target->flags |= ENTITY_DEAD;
|
target->flags |= ENTITY_DEAD;
|
||||||
|
stats_record_kill();
|
||||||
|
|
||||||
/* Death particles — centered on entity */
|
/* Death particles — centered on entity */
|
||||||
Vec2 center = vec2(
|
Vec2 center = vec2(
|
||||||
@@ -210,6 +213,7 @@ static void damage_entity(Entity *target, int damage) {
|
|||||||
|
|
||||||
static void damage_player(Entity *player, int damage, Entity *source) {
|
static void damage_player(Entity *player, int damage, Entity *source) {
|
||||||
PlayerData *ppd = (PlayerData *)player->data;
|
PlayerData *ppd = (PlayerData *)player->data;
|
||||||
|
stats_record_damage_taken(damage);
|
||||||
damage_entity(player, damage);
|
damage_entity(player, damage);
|
||||||
|
|
||||||
/* Screen shake on player hit (stronger) */
|
/* Screen shake on player hit (stronger) */
|
||||||
@@ -264,6 +268,7 @@ static void handle_collisions(EntityManager *em) {
|
|||||||
if (from_player && entity_is_enemy(b)) {
|
if (from_player && entity_is_enemy(b)) {
|
||||||
if (physics_overlap(&a->body, &b->body)) {
|
if (physics_overlap(&a->body, &b->body)) {
|
||||||
damage_entity(b, a->damage);
|
damage_entity(b, a->damage);
|
||||||
|
stats_record_shot_hit();
|
||||||
hit = true;
|
hit = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,6 +371,7 @@ static void handle_collisions(EntityManager *em) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (picked_up) {
|
if (picked_up) {
|
||||||
|
stats_record_pickup();
|
||||||
/* Pickup particles */
|
/* Pickup particles */
|
||||||
Vec2 center = vec2(
|
Vec2 center = vec2(
|
||||||
a->body.pos.x + a->body.size.x * 0.5f,
|
a->body.pos.x + a->body.size.x * 0.5f,
|
||||||
@@ -566,6 +572,7 @@ void level_update(Level *level, float dt) {
|
|||||||
for (int i = 0; i < level->entities.count; i++) {
|
for (int i = 0; i < level->entities.count; i++) {
|
||||||
Entity *e = &level->entities.entities[i];
|
Entity *e = &level->entities.entities[i];
|
||||||
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
||||||
|
stats_record_death();
|
||||||
player_respawn(e, level->map.player_spawn);
|
player_respawn(e, level->map.player_spawn);
|
||||||
Vec2 center = vec2(
|
Vec2 center = vec2(
|
||||||
e->body.pos.x + e->body.size.x * 0.5f,
|
e->body.pos.x + e->body.size.x * 0.5f,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "game/player.h"
|
#include "game/player.h"
|
||||||
#include "game/sprites.h"
|
#include "game/sprites.h"
|
||||||
#include "game/projectile.h"
|
#include "game/projectile.h"
|
||||||
|
#include "game/stats.h"
|
||||||
#include "engine/input.h"
|
#include "engine/input.h"
|
||||||
#include "engine/physics.h"
|
#include "engine/physics.h"
|
||||||
#include "engine/renderer.h"
|
#include "engine/renderer.h"
|
||||||
@@ -301,6 +302,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
|
|||||||
|
|
||||||
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
|
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
|
||||||
pd->dash_charges--;
|
pd->dash_charges--;
|
||||||
|
stats_record_dash();
|
||||||
/* Start recharge timer only if not already recharging */
|
/* Start recharge timer only if not already recharging */
|
||||||
if (pd->dash_recharge_timer <= 0) {
|
if (pd->dash_recharge_timer <= 0) {
|
||||||
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
|
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
|
||||||
@@ -410,6 +412,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
|
|||||||
pd->jumping = true;
|
pd->jumping = true;
|
||||||
pd->jump_buffer_timer = 0;
|
pd->jump_buffer_timer = 0;
|
||||||
pd->coyote_timer = 0;
|
pd->coyote_timer = 0;
|
||||||
|
stats_record_jump();
|
||||||
audio_play_sound(s_sfx_jump, 96);
|
audio_play_sound(s_sfx_jump, 96);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +463,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectile_spawn_dir(s_em, bullet_pos, shoot_dir, true);
|
projectile_spawn_dir(s_em, bullet_pos, shoot_dir, true);
|
||||||
|
stats_record_shot_fired();
|
||||||
/* Muzzle flash slightly ahead of bullet origin (at barrel tip) */
|
/* Muzzle flash slightly ahead of bullet origin (at barrel tip) */
|
||||||
Vec2 flash_pos = vec2(
|
Vec2 flash_pos = vec2(
|
||||||
bullet_pos.x + shoot_dir.x * 4.0f,
|
bullet_pos.x + shoot_dir.x * 4.0f,
|
||||||
|
|||||||
29
src/game/stats.c
Normal file
29
src/game/stats.c
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#include "game/stats.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static GameStats *s_active = NULL;
|
||||||
|
|
||||||
|
void stats_reset(GameStats *s) {
|
||||||
|
memset(s, 0, sizeof(GameStats));
|
||||||
|
}
|
||||||
|
|
||||||
|
void stats_update_score(GameStats *s) {
|
||||||
|
int score = s->levels_completed * 100
|
||||||
|
+ s->enemies_killed * 10
|
||||||
|
- s->deaths * 25
|
||||||
|
+ s->pickups_collected * 5;
|
||||||
|
s->score = score > 0 ? score : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void stats_set_active(GameStats *s) { s_active = s; }
|
||||||
|
GameStats *stats_get_active(void) { return s_active; }
|
||||||
|
|
||||||
|
void stats_record_kill(void) { if (s_active) s_active->enemies_killed++; }
|
||||||
|
void stats_record_death(void) { if (s_active) s_active->deaths++; }
|
||||||
|
void stats_record_shot_fired(void) { if (s_active) s_active->shots_fired++; }
|
||||||
|
void stats_record_shot_hit(void) { if (s_active) s_active->shots_hit++; }
|
||||||
|
void stats_record_dash(void) { if (s_active) s_active->dashes_used++; }
|
||||||
|
void stats_record_jump(void) { if (s_active) s_active->jumps++; }
|
||||||
|
void stats_record_pickup(void) { if (s_active) s_active->pickups_collected++; }
|
||||||
|
void stats_record_damage_taken(int n) { if (s_active) s_active->damage_taken += n; }
|
||||||
|
void stats_record_damage_dealt(int n) { if (s_active) s_active->damage_dealt += n; }
|
||||||
45
src/game/stats.h
Normal file
45
src/game/stats.h
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#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 {
|
||||||
|
int score; /* composite score */
|
||||||
|
int levels_completed; /* exit zone triggers */
|
||||||
|
int enemies_killed; /* total kills */
|
||||||
|
int deaths; /* player respawn count */
|
||||||
|
int shots_fired; /* projectiles spawned by player */
|
||||||
|
int shots_hit; /* player projectiles that hit */
|
||||||
|
int dashes_used; /* dash activations */
|
||||||
|
int jumps; /* jump count */
|
||||||
|
int pickups_collected; /* all powerup pickups */
|
||||||
|
int damage_taken; /* total HP lost */
|
||||||
|
int damage_dealt; /* total HP dealt to enemies */
|
||||||
|
float time_elapsed; /* wall-clock seconds */
|
||||||
|
} GameStats;
|
||||||
|
|
||||||
|
/* Reset all stats to zero (call at session start). */
|
||||||
|
void stats_reset(GameStats *s);
|
||||||
|
|
||||||
|
/* Recompute the composite score from raw metrics. */
|
||||||
|
void stats_update_score(GameStats *s);
|
||||||
|
|
||||||
|
/* Set/get the active stats instance (global pointer used by
|
||||||
|
* level.c and player.c to record events during gameplay). */
|
||||||
|
void stats_set_active(GameStats *s);
|
||||||
|
GameStats *stats_get_active(void);
|
||||||
|
|
||||||
|
/* Convenience: increment a stat on the active instance (no-op if NULL). */
|
||||||
|
void stats_record_kill(void);
|
||||||
|
void stats_record_death(void);
|
||||||
|
void stats_record_shot_fired(void);
|
||||||
|
void stats_record_shot_hit(void);
|
||||||
|
void stats_record_dash(void);
|
||||||
|
void stats_record_jump(void);
|
||||||
|
void stats_record_pickup(void);
|
||||||
|
void stats_record_damage_taken(int amount);
|
||||||
|
void stats_record_damage_dealt(int amount);
|
||||||
|
|
||||||
|
#endif /* JNR_STATS_H */
|
||||||
46
src/main.c
46
src/main.c
@@ -4,6 +4,8 @@
|
|||||||
#include "game/level.h"
|
#include "game/level.h"
|
||||||
#include "game/levelgen.h"
|
#include "game/levelgen.h"
|
||||||
#include "game/editor.h"
|
#include "game/editor.h"
|
||||||
|
#include "game/stats.h"
|
||||||
|
#include "game/analytics.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
@@ -46,6 +48,10 @@ static int s_station_depth = 0;
|
|||||||
static int s_mars_depth = 0;
|
static int s_mars_depth = 0;
|
||||||
#define MARS_BASE_GEN_COUNT 2
|
#define MARS_BASE_GEN_COUNT 2
|
||||||
|
|
||||||
|
/* ── Analytics / stats tracking ── */
|
||||||
|
static GameStats s_stats;
|
||||||
|
static bool s_session_active = false;
|
||||||
|
|
||||||
/* ── Pause menu state ── */
|
/* ── Pause menu state ── */
|
||||||
#define PAUSE_ITEM_COUNT 3
|
#define PAUSE_ITEM_COUNT 3
|
||||||
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
|
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';
|
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 ── */
|
/* ── Switch to editor mode ── */
|
||||||
static void enter_editor(void) {
|
static void enter_editor(void) {
|
||||||
if (s_mode == MODE_PLAY) {
|
if (s_mode == MODE_PLAY) {
|
||||||
@@ -305,15 +326,19 @@ static void restart_level(void) {
|
|||||||
* ═══════════════════════════════════════════════════ */
|
* ═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
static void game_init(void) {
|
static void game_init(void) {
|
||||||
|
analytics_init();
|
||||||
|
|
||||||
if (s_use_editor) {
|
if (s_use_editor) {
|
||||||
enter_editor();
|
enter_editor();
|
||||||
} else if (s_use_procgen) {
|
} else if (s_use_procgen) {
|
||||||
load_generated_level();
|
load_generated_level();
|
||||||
|
begin_session();
|
||||||
} else {
|
} else {
|
||||||
if (!load_level_file("assets/levels/moon01.lvl")) {
|
if (!load_level_file("assets/levels/moon01.lvl")) {
|
||||||
fprintf(stderr, "Failed to load level!\n");
|
fprintf(stderr, "Failed to load level!\n");
|
||||||
g_engine.running = false;
|
g_engine.running = false;
|
||||||
}
|
}
|
||||||
|
begin_session();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,12 +372,15 @@ static void pause_update(void) {
|
|||||||
break;
|
break;
|
||||||
case 1: /* Restart */
|
case 1: /* Restart */
|
||||||
s_mode = MODE_PLAY;
|
s_mode = MODE_PLAY;
|
||||||
|
end_session("quit");
|
||||||
restart_level();
|
restart_level();
|
||||||
|
begin_session();
|
||||||
break;
|
break;
|
||||||
case 2: /* Quit */
|
case 2: /* Quit */
|
||||||
if (s_testing_from_editor) {
|
if (s_testing_from_editor) {
|
||||||
return_to_editor();
|
return_to_editor();
|
||||||
} else {
|
} else {
|
||||||
|
end_session("quit");
|
||||||
g_engine.running = false;
|
g_engine.running = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -364,6 +392,7 @@ static void game_update(float dt) {
|
|||||||
/* Handle deferred level load from JS shell dropdown. */
|
/* Handle deferred level load from JS shell dropdown. */
|
||||||
if (s_js_load_request && s_js_load_path[0]) {
|
if (s_js_load_request && s_js_load_path[0]) {
|
||||||
s_js_load_request = 0;
|
s_js_load_request = 0;
|
||||||
|
end_session("quit");
|
||||||
|
|
||||||
/* Tear down whatever mode we are in. */
|
/* Tear down whatever mode we are in. */
|
||||||
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED) {
|
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';
|
s_js_load_path[0] = '\0';
|
||||||
|
|
||||||
SDL_SetWindowTitle(g_engine.window, "Jump 'n Run");
|
SDL_SetWindowTitle(g_engine.window, "Jump 'n Run");
|
||||||
|
begin_session();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -437,22 +467,35 @@ static void game_update(float dt) {
|
|||||||
bool r_pressed = input_key_held(SDL_SCANCODE_R);
|
bool r_pressed = input_key_held(SDL_SCANCODE_R);
|
||||||
if (r_pressed && !r_was_pressed) {
|
if (r_pressed && !r_was_pressed) {
|
||||||
printf("\n=== Regenerating level ===\n");
|
printf("\n=== Regenerating level ===\n");
|
||||||
|
end_session("quit");
|
||||||
level_free(&s_level);
|
level_free(&s_level);
|
||||||
s_gen_seed = (uint32_t)time(NULL);
|
s_gen_seed = (uint32_t)time(NULL);
|
||||||
s_use_procgen = true;
|
s_use_procgen = true;
|
||||||
load_generated_level();
|
load_generated_level();
|
||||||
|
begin_session();
|
||||||
}
|
}
|
||||||
r_was_pressed = r_pressed;
|
r_was_pressed = r_pressed;
|
||||||
|
|
||||||
level_update(&s_level, dt);
|
level_update(&s_level, dt);
|
||||||
|
|
||||||
|
/* Accumulate play time */
|
||||||
|
if (s_session_active) {
|
||||||
|
s_stats.time_elapsed += dt;
|
||||||
|
}
|
||||||
|
|
||||||
/* Check for level exit transition */
|
/* Check for level exit transition */
|
||||||
if (level_exit_triggered(&s_level)) {
|
if (level_exit_triggered(&s_level)) {
|
||||||
const char *target = s_level.exit_target;
|
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') {
|
if (target[0] == '\0') {
|
||||||
/* Empty target = victory / end of game */
|
/* Empty target = victory / end of game */
|
||||||
printf("Level complete! (no next level)\n");
|
printf("Level complete! (no next level)\n");
|
||||||
|
end_session("completed");
|
||||||
/* Loop back to the beginning, reset progression state */
|
/* Loop back to the beginning, reset progression state */
|
||||||
level_free(&s_level);
|
level_free(&s_level);
|
||||||
s_station_depth = 0;
|
s_station_depth = 0;
|
||||||
@@ -460,6 +503,7 @@ static void game_update(float dt) {
|
|||||||
if (!load_level_file("assets/levels/moon01.lvl")) {
|
if (!load_level_file("assets/levels/moon01.lvl")) {
|
||||||
g_engine.running = false;
|
g_engine.running = false;
|
||||||
}
|
}
|
||||||
|
begin_session();
|
||||||
} else if (strcmp(target, "generate") == 0) {
|
} else if (strcmp(target, "generate") == 0) {
|
||||||
/* Procedurally generated next level */
|
/* Procedurally generated next level */
|
||||||
printf("Transitioning to generated level\n");
|
printf("Transitioning to generated level\n");
|
||||||
@@ -549,6 +593,8 @@ static void game_render(float interpolation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void game_shutdown(void) {
|
static void game_shutdown(void) {
|
||||||
|
end_session("quit");
|
||||||
|
|
||||||
/* Always free both — editor may have been initialized even if we're
|
/* Always free both — editor may have been initialized even if we're
|
||||||
* currently in play mode (e.g. shutdown during test play). editor_free
|
* currently in play mode (e.g. shutdown during test play). editor_free
|
||||||
* and level_free are safe to call on zeroed/already-freed structs. */
|
* and level_free are safe to call on zeroed/already-freed structs. */
|
||||||
|
|||||||
@@ -107,7 +107,9 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="canvas-container">
|
<div id="canvas-container"
|
||||||
|
data-analytics-url=""
|
||||||
|
data-analytics-key="">
|
||||||
<canvas class="emscripten" id="canvas" tabindex="1"
|
<canvas class="emscripten" id="canvas" tabindex="1"
|
||||||
width="640" height="360"></canvas>
|
width="640" height="360"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,6 +284,27 @@
|
|||||||
document.title = 'Jump \'n Run - Level Editor';
|
document.title = 'Jump \'n Run - Level Editor';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Analytics: end session on tab close ────────────── */
|
||||||
|
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({
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{{{ SCRIPT }}}
|
{{{ SCRIPT }}}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user