Compare commits

...

12 Commits

Author SHA1 Message Date
79e9d0e2ad Show current score in top-right corner of HUD
Some checks failed
Deploy / deploy (push) Failing after 58s
Display the player's current score using the font renderer,
right-aligned with an 8px margin from the top-right screen edge.

Closes #5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:43:19 +00:00
81ebbd9eec Fix score formula to match design specification
Some checks failed
Deploy / deploy (push) Has been cancelled
The score computation used wrong weights and terms compared to DESIGN.md.
Updated to: enemies_killed*100 + levels_completed*500 - deaths*200 - time_elapsed

Closes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:42:56 +00:00
90e3d5aec0 Add free downward jetpack on double-tap down mid-air
Some checks failed
Deploy / deploy (push) Has been cancelled
Double pressing the down arrow while airborne triggers a free downward
dash that doesn't consume jetpack charges. Uses a 0.3s window for the
double-tap detection, resets on landing.

Closes #9

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:42:31 +00:00
68856fb8c5 add deploy action
Some checks failed
Deploy / deploy (push) Failing after 3m54s
2026-03-14 19:54:21 +00:00
7605f0ca8c Add new level transition state machine 2026-03-14 16:29:10 +00:00
6d64c6426f Fix atmosphere wind particles appearing inside Mars station
mars02.lvl (Mars Base interior) incorrectly used PARALLAX_STYLE 5
(Mars surface), causing atmosphere dust particles to spawn indoors.
Changed to PARALLAX_STYLE 2 (Interior) to match the level's setting.

Closes #4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 15:53:23 +00:00
93ae351959 Add Gitea Actions CI workflow for pull request builds 2026-03-14 14:47:56 +00:00
root
c57ac68a04 Fix atmosphere particles clustering on left side of screen
When wind is zero or near-zero, dust particles were always spawning at
the left viewport edge (the "upwind" edge for wind >= 0). Without wind
force to carry them across, they accumulated on the left side. Now
particles spawn across the full viewport when wind is calm (< 5 px/s²),
with random drift directions for even distribution.

Closes #2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:14:52 +00:00
Thomas
5793af4896 Configure analytics URL and obfuscate API key
Set the Horchposten backend URL in the shell data attribute and replace
the plaintext key lookup with XOR-encoded byte arrays decoded at runtime,
so the API key never appears as a readable string in source, HTML, or the
compiled WASM binary.
2026-03-08 19:52:34 +00:00
tas
702dbd4f9a Merge pull request 'Add analytics integration with Horchposten backend' (#1) from feature/analytics-integration into main
Reviewed-on: #1
2026-03-08 19:42:58 +00:00
322dd184ab Fix review issues in analytics integration
1. Race condition: session_end now waits for an in-flight
   session_start promise before sending, so quick restarts
   don't drop the end call.

2. beforeunload: replaced sendBeacon (which can't set headers)
   with fetch(..., keepalive: true) so the X-API-Key header
   is included and the backend doesn't 401.

3. Stats double-counting: removed stats_record_damage_dealt
   and stats_record_kill from damage_entity (which was called
   for all damage including player deaths). Now only recorded
   at player-sourced call sites (projectile hits, stomps).

4. Removed const-cast: analytics_session_end now takes
   GameStats* (non-const) since stats_update_score mutates it.

5. beforeunload now uses stashed stats from the last C-side
   session_end call instead of hardcoded zeroes. Session ID is
   cleared synchronously before async fetch to prevent races.

6. Removed unused stdint.h include from stats.h.
2026-03-08 19:29:11 +00:00
root
a23ecaf4c1 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>
2026-03-08 17:11:39 +00:00
25 changed files with 1171 additions and 80 deletions

27
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,27 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends \
build-essential \
libsdl2-dev \
libsdl2-image-dev \
libsdl2-mixer-dev \
pkg-config
- name: Checkout
uses: actions/checkout@v4
- name: Build (native Linux)
run: make

View File

@@ -0,0 +1,51 @@
name: Deploy
on:
push:
branches: [main]
env:
REGISTRY: git.kimchi
IMAGE: git.kimchi/tas/major_tom
NAMESPACE: jnr-web
DEPLOYMENT: jnr-web
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# ── Build WASM artifacts inside an Emscripten container ──
- name: Build WASM
uses: docker://emscripten/emsdk:3.1.51
with:
args: make web
# ── Build container image and push to Gitea registry ──
- name: Build and push container image
run: |
IMAGE_TAG="${{ env.IMAGE }}:sha-${GITHUB_SHA::8}"
IMAGE_LATEST="${{ env.IMAGE }}:latest"
docker build -f Containerfile -t "$IMAGE_TAG" -t "$IMAGE_LATEST" .
docker login "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_PASSWORD }}"
docker push "$IMAGE_TAG"
docker push "$IMAGE_LATEST"
echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV"
# ── Deploy to k3s ──
- name: Deploy to k3s
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl set image deployment/${{ env.DEPLOYMENT }} \
${{ env.DEPLOYMENT }}="${{ env.IMAGE_TAG }}" \
-n ${{ env.NAMESPACE }}
kubectl rollout status deployment/${{ env.DEPLOYMENT }} \
-n ${{ env.NAMESPACE }} --timeout=60s

View File

@@ -146,7 +146,7 @@ adding a new def. See `src/game/projectile.h` for the full definition.
## Levels
### Format (.lvl)
Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PARALLAX_NEAR`, `TILEDEF`, `ENTITY`, `EXIT`, `LAYER`
Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PARALLAX_NEAR`, `TRANSITION_IN`, `TRANSITION_OUT`, `TILEDEF`, `ENTITY`, `EXIT`, `LAYER`
**Needed additions:**
- `STORM`, `DRAG` — Remaining atmosphere settings

30
TODO.md
View File

@@ -160,11 +160,25 @@ The spacecraft fly-in animation should only play on surface levels (moon01,
mars01, etc.). Interior/base levels (mars02, mars03, generated mars_base,
generated station) should skip it — the player is already indoors.
## New level transition styles: elevator and teleporter
Two new transition animations to complement the spacecraft fly-in:
- **Elevator** — Doors slide shut, brief pause (screen shake / rumble),
doors slide open onto the new level. Good for base/station interior
transitions (mars02 → mars_base, between generated station levels).
- **Teleporter** — Energy charge-up effect around the player, flash/warp
distortion, player materialises in the new level. Good for cross-planet
jumps or generated-to-handcrafted transitions.
## ~~New level transition styles: elevator and teleporter~~ ✓
Implemented: `src/game/transition.h` / `transition.c` module with two-phase
transition state machine (outro on old level → level swap → intro on new level).
- **Elevator** — Two horizontal doors slide inward from top/bottom (0.6 s),
hold closed with screen-shake rumble (0.3 s), then slide apart on the new
level (0.6 s). Smooth ease-in-out motion, dark gray industrial color,
bright seam at the meeting edge. Used for base/station interior transitions.
- **Teleporter** — Scanline dissolve: 3 px-tall horizontal bands sweep
across the screen in alternating directions with staggered top-to-bottom
timing (0.5 s), then white flash (0.15 s). Intro reverses the sweep
bottom-to-top (0.5 s). Uses `teleport.wav` sound effect.
New `MODE_TRANSITION` game state in `main.c` pauses gameplay during the
animation. Level-load dispatch extracted into `dispatch_level_load()` helper,
called both from instant transitions and from the transition state machine.
New `.lvl` directives `TRANSITION_IN` and `TRANSITION_OUT` with values
`none`, `spacecraft`, `elevator`, `teleporter`. Parsed in `tilemap.c`,
saved by editor and level generator dump. All three procedural generators
(generic, station, mars_base) set `TRANS_ELEVATOR` for interior themes.
Handcrafted levels updated: mars02, mars03, level01, level02.

View File

@@ -8,6 +8,7 @@ SPAWN 3 18
GRAVITY 400
BG_COLOR 15 15 30
MUSIC assets/sounds/algardalgar.ogg
TRANSITION_OUT elevator
# Spacecraft landing intro (arriving from moon)
ENTITY spacecraft 1 14

View File

@@ -7,6 +7,8 @@ SPAWN 3 18
GRAVITY 600
BG_COLOR 10 10 25
MUSIC assets/sounds/algardalgar.ogg
TRANSITION_IN elevator
TRANSITION_OUT elevator
# Enemies
ENTITY grunt 12 18

View File

@@ -10,8 +10,9 @@ SIZE 40 46
SPAWN 3 7
GRAVITY 700
BG_COLOR 20 10 6
PARALLAX_STYLE 5
PARALLAX_STYLE 2
MUSIC assets/sounds/kaffe_og_kage.ogg
TRANSITION_OUT elevator
ENTITY spacecraft 1 3

View File

@@ -12,6 +12,8 @@ GRAVITY 700
BG_COLOR 15 8 5
PARALLAX_STYLE 3
MUSIC assets/sounds/kaffe_og_kage.ogg
TRANSITION_IN elevator
TRANSITION_OUT elevator
# Gun pickup right at spawn — the player needs it
ENTITY powerup_gun 5 18

View File

@@ -28,6 +28,14 @@
/* ── Level transitions ─────────────────────────────── */
#define MAX_EXIT_ZONES 16 /* max exit zones per level */
typedef enum TransitionStyle {
TRANS_NONE, /* instant cut (default) */
TRANS_SPACECRAFT, /* handled by spacecraft entity */
TRANS_ELEVATOR, /* doors close, rumble, doors open */
TRANS_TELEPORTER, /* scanline dissolve, flash, materialize */
TRANS_STYLE_COUNT
} TransitionStyle;
/* ── Rendering ──────────────────────────────────────── */
#define MAX_SPRITES 2048 /* max queued sprites per frame */

View File

@@ -556,38 +556,69 @@ void particle_emit_atmosphere_dust(Vec2 cam_pos, Vec2 vp) {
* Two sub-layers for depth: large slow "far" motes and small quick
* "near" specks. Wind carries them; gravity_scale controls how much
* environmental forces (wind + gravity) affect each particle.
* Particles spawn along the upwind viewport edge and drift inward;
* occasional interior spawns prevent a visible edge seam. */
float wind = physics_get_wind();
float margin = 32.0f;
float dir = (wind >= 0.0f) ? 1.0f : -1.0f; /* velocity sign */
* When wind is strong, particles spawn along the upwind viewport edge
* and drift inward. When wind is calm, particles spawn across the
* full viewport to avoid clustering on one side. */
float wind = physics_get_wind();
float margin = 32.0f;
float abs_wind = (wind >= 0.0f) ? wind : -wind;
int has_wind = abs_wind > 5.0f; /* threshold for edge-spawning */
/* Upwind edge X for the two edge-spawned layers */
float edge_far = (wind >= 0.0f) ? cam_pos.x - margin
: cam_pos.x + vp.x + margin;
float edge_near = (wind >= 0.0f) ? cam_pos.x - margin * 0.5f
: cam_pos.x + vp.x + margin * 0.5f;
if (has_wind) {
float dir = (wind >= 0.0f) ? 1.0f : -1.0f;
/* Far dust motes — large, slow, translucent (1/frame) */
spawn_dust_mote(
vec2(edge_far, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(8.0f, 25.0f), randf_range(-6.0f, 6.0f)),
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
180, 140, 100, 25);
/* Upwind edge X for the two edge-spawned layers */
float edge_far = (wind >= 0.0f) ? cam_pos.x - margin
: cam_pos.x + vp.x + margin;
float edge_near = (wind >= 0.0f) ? cam_pos.x - margin * 0.5f
: cam_pos.x + vp.x + margin * 0.5f;
/* Near dust specks — small, quicker, brighter (1/frame) */
spawn_dust_mote(
vec2(edge_near, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(15.0f, 40.0f), randf_range(-10.0f, 10.0f)),
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
200, 160, 120, 20);
/* Far dust motes — large, slow, translucent (1/frame) */
spawn_dust_mote(
vec2(edge_far, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(8.0f, 25.0f), randf_range(-6.0f, 6.0f)),
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
180, 140, 100, 25);
/* Occasional interior spawn — prevents edge seam on calm wind */
if (rand() % 3 == 0) {
/* Near dust specks — small, quicker, brighter (1/frame) */
spawn_dust_mote(
vec2(edge_near, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(15.0f, 40.0f), randf_range(-10.0f, 10.0f)),
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
200, 160, 120, 20);
/* Occasional interior spawn — prevents edge seam */
if (rand() % 3 == 0) {
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
160, 130, 95, 25);
}
} else {
/* Calm wind — spawn across the full viewport to distribute evenly */
/* Far dust motes — large, slow, translucent (1/frame) */
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
160, 130, 95, 25);
vec2(randf_range(-10.0f, 10.0f), randf_range(-6.0f, 6.0f)),
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
180, 140, 100, 25);
/* Near dust specks — small, quicker, brighter (1/frame) */
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-15.0f, 15.0f), randf_range(-10.0f, 10.0f)),
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
200, 160, 120, 20);
/* Extra interior mote for density parity with windy path */
if (rand() % 3 == 0) {
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
160, 130, 95, 25);
}
}
}

View File

@@ -6,6 +6,14 @@
#include <stdlib.h>
#include <string.h>
/* ── Transition style name → enum mapping ── */
static TransitionStyle parse_transition_style(const char *name) {
if (strcmp(name, "spacecraft") == 0) return TRANS_SPACECRAFT;
if (strcmp(name, "elevator") == 0) return TRANS_ELEVATOR;
if (strcmp(name, "teleporter") == 0) return TRANS_TELEPORTER;
return TRANS_NONE;
}
/* Read a full line from f into a dynamically growing buffer.
* *buf and *cap track the heap buffer; the caller must free *buf.
* Returns the line length, or -1 on EOF/error. */
@@ -120,6 +128,16 @@ bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) {
}
} else if (strncmp(line, "PLAYER_UNARMED", 14) == 0) {
map->player_unarmed = true;
} else if (strncmp(line, "TRANSITION_IN ", 14) == 0) {
char tname[32] = {0};
if (sscanf(line + 14, "%31s", tname) == 1) {
map->transition_in = parse_transition_style(tname);
}
} else if (strncmp(line, "TRANSITION_OUT ", 15) == 0) {
char tname[32] = {0};
if (sscanf(line + 15, "%31s", tname) == 1) {
map->transition_out = parse_transition_style(tname);
}
} else if (strncmp(line, "EXIT ", 5) == 0) {
if (map->exit_zone_count < MAX_EXIT_ZONES) {
ExitZone *ez = &map->exit_zones[map->exit_zone_count];

View File

@@ -58,6 +58,8 @@ typedef struct Tilemap {
char parallax_near_path[ASSET_PATH_MAX]; /* near bg image path */
int parallax_style; /* procedural bg style (0=default) */
bool player_unarmed; /* if true, player starts without gun */
TransitionStyle transition_in; /* transition animation for level entry */
TransitionStyle transition_out; /* transition animation for level exit */
EntitySpawn entity_spawns[MAX_ENTITY_SPAWNS];
int entity_spawn_count;
ExitZone exit_zones[MAX_EXIT_ZONES];

205
src/game/analytics.c Normal file
View File

@@ -0,0 +1,205 @@
#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.
* crypto.randomUUID() requires a secure context (HTTPS) and is
* absent in older browsers, so fall back to a manual v4 UUID. */
if (!localStorage.getItem('jnr_client_id')) {
var uuid;
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
uuid = crypto.randomUUID();
} else {
uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
localStorage.setItem('jnr_client_id', uuid);
}
/* 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;
Module._analyticsStartPending = null; /* Promise while start is in-flight */
Module._analyticsLastStats = null; /* stashed for beforeunload fallback */
/* Runtime config: URL from data attribute, key decoded at runtime.
* The key is XOR-encoded across two byte arrays so it never appears
* as a plain string in the WASM binary, emitted JS, or HTML. */
var container = document.getElementById('canvas-container');
Module._analyticsUrl = (container && container.dataset.analyticsUrl)
? container.dataset.analyticsUrl
: (typeof ANALYTICS_URL !== 'undefined' ? ANALYTICS_URL : '');
var _a = [53,75,96,19,114,122,112,34,28,62,24,5,57,34,126,14,
112,73,105,121,122,79,50,0,77,33,82,58,61,19,44,0];
var _b = [82,15,4,95,36,32,29,18,95,14,87,95,115,70,12,76,
55,5,4,12,28,30,65,78,4,72,26,92,84,90,70,54];
var _k = '';
for (var i = 0; i < _a.length; i++) _k += String.fromCharCode(_a[i] ^ _b[i]);
Module._analyticsKey = _k;
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/.
* Stores the in-flight promise so session_end can wait for it. */
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)
}
});
Module._analyticsStartPending = 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;
Module._analyticsStartPending = null;
console.log('[analytics] Session started: ' + Module._analyticsSessionId);
})
.catch(function(err) {
Module._analyticsStartPending = null;
console.error('[analytics] Session start failed:', err);
});
});
/* 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;
/* Clear synchronously before the async request to prevent races */
Module._analyticsSessionId = null;
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
});
/* ── C wrappers ─────────────────────────────────────────────────── */
void analytics_init(void) {
js_analytics_init();
}
void analytics_session_start(void) {
js_analytics_session_start();
}
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,
(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(GameStats *stats, const char *end_reason) {
(void)stats;
(void)end_reason;
}
#endif /* __EMSCRIPTEN__ */

18
src/game/analytics.h Normal file
View File

@@ -0,0 +1,18 @@
#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.
* Computes the composite score before sending.
* end_reason: "death", "quit", "timeout", or "completed". */
void analytics_session_end(GameStats *stats, const char *end_reason);
#endif /* JNR_ANALYTICS_H */

View File

@@ -1,5 +1,6 @@
#include "game/editor.h"
#include "game/entity_registry.h"
#include "game/transition.h"
#include "engine/core.h"
#include "engine/input.h"
#include "engine/renderer.h"
@@ -359,6 +360,16 @@ static bool save_tilemap(const Tilemap *map, const char *path) {
if (map->player_unarmed)
fprintf(f, "PLAYER_UNARMED\n");
/* Transition styles */
if (map->transition_in != TRANS_NONE) {
fprintf(f, "TRANSITION_IN %s\n",
transition_style_name(map->transition_in));
}
if (map->transition_out != TRANS_NONE) {
fprintf(f, "TRANSITION_OUT %s\n",
transition_style_name(map->transition_out));
}
fprintf(f, "\n");
/* Entity spawns */

View File

@@ -8,6 +8,7 @@
#include "game/spacecraft.h"
#include "game/sprites.h"
#include "game/entity_registry.h"
#include "game/stats.h"
#include "engine/core.h"
#include "engine/renderer.h"
#include "engine/physics.h"
@@ -16,6 +17,7 @@
#include "engine/input.h"
#include "engine/camera.h"
#include "engine/assets.h"
#include "engine/font.h"
#include <stdio.h>
#include <string.h>
#include <math.h>
@@ -210,7 +212,10 @@ static void damage_entity(Entity *target, int damage) {
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) {
@@ -263,7 +268,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;
}
}
@@ -295,7 +303,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);
@@ -366,6 +376,7 @@ static void handle_collisions(EntityManager *em) {
}
if (picked_up) {
stats_record_pickup();
/* Pickup particles */
Vec2 center = vec2(
a->body.pos.x + a->body.size.x * 0.5f,
@@ -566,6 +577,7 @@ void level_update(Level *level, float dt) {
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,
@@ -768,6 +780,20 @@ void level_render(Level *level, float interpolation) {
}
}
/* Draw score in top-right corner */
{
GameStats *stats = stats_get_active();
if (stats) {
stats_update_score(stats);
char score_buf[16];
snprintf(score_buf, sizeof(score_buf), "%d", stats->score);
int text_w = font_text_width(score_buf);
font_draw_text(g_engine.renderer, score_buf,
SCREEN_WIDTH - text_w - 8, 8,
(SDL_Color){255, 220, 80, 255});
}
}
/* Flush the renderer */
renderer_flush(cam);
}

View File

@@ -1,4 +1,5 @@
#include "game/levelgen.h"
#include "game/transition.h"
#include "engine/parallax.h"
#include <stdio.h>
#include <stdlib.h>
@@ -1317,6 +1318,14 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
}
/* Transition style — interior themes use elevator, surface uses none
* (spacecraft entity handles surface transitions). */
if (primary_theme == THEME_PLANET_BASE || primary_theme == THEME_MARS_BASE
|| primary_theme == THEME_SPACE_STATION) {
map->transition_in = TRANS_ELEVATOR;
map->transition_out = TRANS_ELEVATOR;
}
/* Tileset */
/* NOTE: tileset texture will be loaded by level_load_generated */
@@ -1826,6 +1835,10 @@ bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
/* Music */
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
/* Interior levels use elevator transitions. */
map->transition_in = TRANS_ELEVATOR;
map->transition_out = TRANS_ELEVATOR;
printf("levelgen_station: generated %dx%d level (%d segments, seed=%u, gravity=%.0f)\n",
map->width, map->height, num_segs, s_rng_state, map->gravity);
printf(" segments:");
@@ -2485,6 +2498,10 @@ bool levelgen_generate_mars_base(Tilemap *map, const LevelGenConfig *config) {
/* Music */
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/kaffe_og_kage.ogg");
/* Interior levels use elevator transitions. */
map->transition_in = TRANS_ELEVATOR;
map->transition_out = TRANS_ELEVATOR;
printf("levelgen_mars_base: generated %dx%d level (%d segments, seed=%u)\n",
map->width, map->height, num_segs, s_rng_state);
printf(" segments:");
@@ -2541,6 +2558,16 @@ bool levelgen_dump_lvl(const Tilemap *map, const char *path) {
fprintf(f, "PLAYER_UNARMED\n");
}
/* Transition styles */
if (map->transition_in != TRANS_NONE) {
fprintf(f, "TRANSITION_IN %s\n",
transition_style_name(map->transition_in));
}
if (map->transition_out != TRANS_NONE) {
fprintf(f, "TRANSITION_OUT %s\n",
transition_style_name(map->transition_out));
}
fprintf(f, "\n");
/* Entity spawns */

View File

@@ -1,6 +1,7 @@
#include "game/player.h"
#include "game/sprites.h"
#include "game/projectile.h"
#include "game/stats.h"
#include "engine/input.h"
#include "engine/physics.h"
#include "engine/renderer.h"
@@ -299,8 +300,36 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
return;
}
/* ── Double-tap down for free downward jetpack ── */
if (!body->on_ground && input_pressed(ACTION_DOWN)) {
if (pd->down_tap_timer > 0) {
/* Second tap — trigger free downward dash */
pd->down_tap_timer = 0;
pd->dash_timer = PLAYER_DASH_DURATION;
pd->dash_dir = vec2(0.0f, 1.0f);
/* Brief invincibility during dash */
pd->inv_timer = PLAYER_DASH_DURATION;
self->flags |= ENTITY_INVINCIBLE;
/* Jetpack burst downward */
Vec2 exhaust_pos = vec2(
body->pos.x + body->size.x * 0.5f,
body->pos.y + body->size.y * 0.5f
);
particle_emit_jetpack_burst(exhaust_pos, pd->dash_dir);
audio_play_sound(s_sfx_dash, 96);
return;
}
pd->down_tap_timer = 0.3f; /* window for second tap */
}
if (pd->down_tap_timer > 0) {
pd->down_tap_timer -= dt;
}
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
pd->dash_charges--;
stats_record_dash();
/* Start recharge timer only if not already recharging */
if (pd->dash_recharge_timer <= 0) {
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
@@ -410,6 +439,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
pd->jumping = true;
pd->jump_buffer_timer = 0;
pd->coyote_timer = 0;
stats_record_jump();
audio_play_sound(s_sfx_jump, 96);
}
@@ -460,6 +490,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
}
projectile_spawn_dir(s_em, bullet_pos, shoot_dir, true);
stats_record_shot_fired();
/* Muzzle flash slightly ahead of bullet origin (at barrel tip) */
Vec2 flash_pos = vec2(
bullet_pos.x + shoot_dir.x * 4.0f,
@@ -474,6 +505,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
/* ── Landing detection ───────────────────── */
if (body->on_ground && !pd->was_on_ground) {
pd->down_tap_timer = 0; /* reset double-tap on landing */
/* Just landed — emit dust at feet */
Vec2 feet = vec2(
body->pos.x + body->size.x * 0.5f,

View File

@@ -67,6 +67,8 @@ typedef struct PlayerData {
AimDir aim_dir; /* current aim direction */
bool looking_up; /* holding up without moving */
float look_up_timer; /* how long up has been held */
/* Down-arrow double-tap (free downward jetpack) */
float down_tap_timer; /* time since last mid-air down press */
/* Death / Respawn */
float respawn_timer; /* countdown after death anim finishes */
Vec2 spawn_point; /* where to respawn */

29
src/game/stats.c Normal file
View 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->enemies_killed * 100
+ s->levels_completed * 500
- s->deaths * 200
- (int)s->time_elapsed;
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; }

43
src/game/stats.h Normal file
View File

@@ -0,0 +1,43 @@
#ifndef JNR_STATS_H
#define JNR_STATS_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 */

351
src/game/transition.c Normal file
View File

@@ -0,0 +1,351 @@
#include "game/transition.h"
#include "engine/core.h"
#include "engine/audio.h"
#include "engine/particle.h"
#include "config.h"
#include <string.h>
#include <math.h>
#include <SDL2/SDL.h>
/* ═══════════════════════════════════════════════════
* Constants
* ═══════════════════════════════════════════════════ */
/* ── Elevator timing ── */
#define ELEVATOR_CLOSE_DURATION 0.6f /* doors slide shut */
#define ELEVATOR_HOLD_DURATION 0.3f /* closed with rumble */
#define ELEVATOR_OPEN_DURATION 0.6f /* doors slide open */
#define ELEVATOR_OUT_DURATION (ELEVATOR_CLOSE_DURATION + ELEVATOR_HOLD_DURATION)
#define ELEVATOR_IN_DURATION (ELEVATOR_HOLD_DURATION + ELEVATOR_OPEN_DURATION)
/* ── Teleporter timing ── */
#define TELEPORT_DISSOLVE_DURATION 0.5f /* scanline sweep out */
#define TELEPORT_FLASH_DURATION 0.15f /* white flash */
#define TELEPORT_MATERIALIZE_DURATION 0.5f /* scanline sweep in */
#define TELEPORT_OUT_DURATION (TELEPORT_DISSOLVE_DURATION + TELEPORT_FLASH_DURATION)
#define TELEPORT_IN_DURATION (TELEPORT_FLASH_DURATION + TELEPORT_MATERIALIZE_DURATION)
/* ── Scanline dissolve parameters ── */
#define SCANLINE_HEIGHT 3 /* pixel height per band */
#define SCANLINE_STAGGER 0.3f /* time spread between first/last band */
/* ── Elevator colors ── */
#define ELEV_R 40
#define ELEV_G 42
#define ELEV_B 48
/* ── Sound effects ── */
static Sound s_sfx_teleport;
static bool s_sfx_loaded = false;
static void ensure_sfx(void) {
if (s_sfx_loaded) return;
s_sfx_teleport = audio_load_sound("assets/sounds/teleport.wav");
s_sfx_loaded = true;
}
/* ═══════════════════════════════════════════════════
* Outro duration for a given style
* ═══════════════════════════════════════════════════ */
static float outro_duration(TransitionStyle style) {
switch (style) {
case TRANS_ELEVATOR: return ELEVATOR_OUT_DURATION;
case TRANS_TELEPORTER: return TELEPORT_OUT_DURATION;
default: return 0.0f;
}
}
static float intro_duration(TransitionStyle style) {
switch (style) {
case TRANS_ELEVATOR: return ELEVATOR_IN_DURATION;
case TRANS_TELEPORTER: return TELEPORT_IN_DURATION;
default: return 0.0f;
}
}
/* ═══════════════════════════════════════════════════
* Public API
* ═══════════════════════════════════════════════════ */
void transition_start_out(TransitionState *ts, TransitionStyle out_style) {
ensure_sfx();
ts->out_style = out_style;
ts->in_style = TRANS_NONE;
ts->phase = TRANS_PHASE_OUT;
ts->timer = 0.0f;
ts->phase_dur = outro_duration(out_style);
ts->sound_played = false;
}
void transition_set_in_style(TransitionState *ts, TransitionStyle in_style) {
ts->in_style = in_style;
}
void transition_update(TransitionState *ts, float dt, Camera *cam) {
if (ts->phase == TRANS_IDLE || ts->phase == TRANS_PHASE_DONE) return;
ts->timer += dt;
/* ── Outro phase ── */
if (ts->phase == TRANS_PHASE_OUT) {
/* Play sound once at start of teleporter dissolve. */
if (ts->out_style == TRANS_TELEPORTER && !ts->sound_played) {
audio_play_sound(s_sfx_teleport, 80);
ts->sound_played = true;
}
/* Elevator rumble during the hold period. */
if (ts->out_style == TRANS_ELEVATOR && cam) {
if (ts->timer > ELEVATOR_CLOSE_DURATION) {
camera_shake(cam, 3.0f, 0.1f);
}
}
if (ts->timer >= ts->phase_dur) {
ts->phase = TRANS_PHASE_LOAD;
}
return;
}
/* ── Load phase: caller must call transition_begin_intro() ── */
if (ts->phase == TRANS_PHASE_LOAD) {
return;
}
/* ── Intro phase ── */
if (ts->phase == TRANS_PHASE_IN) {
/* Elevator rumble at the start of intro (before doors open). */
if (ts->in_style == TRANS_ELEVATOR && cam) {
if (ts->timer < ELEVATOR_HOLD_DURATION) {
camera_shake(cam, 2.5f, 0.1f);
}
}
if (ts->timer >= ts->phase_dur) {
ts->phase = TRANS_PHASE_DONE;
}
return;
}
}
bool transition_needs_load(const TransitionState *ts) {
return ts->phase == TRANS_PHASE_LOAD;
}
void transition_begin_intro(TransitionState *ts) {
ts->phase = TRANS_PHASE_IN;
ts->timer = 0.0f;
ts->phase_dur = intro_duration(ts->in_style);
ts->sound_played = false;
}
bool transition_is_done(const TransitionState *ts) {
return ts->phase == TRANS_PHASE_DONE;
}
void transition_reset(TransitionState *ts) {
ts->phase = TRANS_IDLE;
ts->timer = 0.0f;
ts->phase_dur = 0.0f;
}
/* ═══════════════════════════════════════════════════
* Elevator rendering helpers
* ═══════════════════════════════════════════════════ */
/* Returns door coverage 0.0 (fully open) to 1.0 (fully closed). */
static float elevator_coverage(const TransitionState *ts) {
if (ts->phase == TRANS_PHASE_OUT) {
/* Closing: 0 → 1 over ELEVATOR_CLOSE_DURATION, then hold at 1. */
float t = ts->timer / ELEVATOR_CLOSE_DURATION;
if (t > 1.0f) t = 1.0f;
/* Ease-in-out for smooth motion. */
return t * t * (3.0f - 2.0f * t);
}
if (ts->phase == TRANS_PHASE_IN) {
/* Hold closed during ELEVATOR_HOLD_DURATION, then open. */
float open_t = ts->timer - ELEVATOR_HOLD_DURATION;
if (open_t <= 0.0f) return 1.0f;
float t = open_t / ELEVATOR_OPEN_DURATION;
if (t > 1.0f) t = 1.0f;
float ease = t * t * (3.0f - 2.0f * t);
return 1.0f - ease;
}
/* PHASE_LOAD: fully closed. */
return 1.0f;
}
static void render_elevator(const TransitionState *ts) {
float coverage = elevator_coverage(ts);
if (coverage <= 0.0f) return;
int half_h = (int)(coverage * (float)SCREEN_HEIGHT * 0.5f + 0.5f);
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
SDL_SetRenderDrawColor(r, ELEV_R, ELEV_G, ELEV_B, 255);
/* Top door. */
SDL_Rect top = {0, 0, SCREEN_WIDTH, half_h};
SDL_RenderFillRect(r, &top);
/* Bottom door. */
SDL_Rect bot = {0, SCREEN_HEIGHT - half_h, SCREEN_WIDTH, half_h};
SDL_RenderFillRect(r, &bot);
/* Thin bright seam at the meeting edge (visual detail). */
if (coverage > 0.7f) {
int seam_y = half_h - 1;
SDL_SetRenderDrawColor(r, 100, 110, 130, 255);
SDL_Rect seam_top = {0, seam_y, SCREEN_WIDTH, 1};
SDL_RenderFillRect(r, &seam_top);
int seam_bot_y = SCREEN_HEIGHT - half_h;
SDL_Rect seam_bot = {0, seam_bot_y, SCREEN_WIDTH, 1};
SDL_RenderFillRect(r, &seam_bot);
}
}
/* ═══════════════════════════════════════════════════
* Teleporter rendering helpers
* ═══════════════════════════════════════════════════ */
/* Scanline dissolve progress: each band sweeps across the screen.
* band_idx: which band (0 = top), total_bands: how many bands,
* global_progress: 0.01.0 across the dissolve duration,
* reverse: if true, sweep right-to-left / bottom-to-top. */
static float band_progress(int band_idx, int total_bands,
float global_progress, bool reverse) {
float band_offset;
if (reverse) {
band_offset = (float)(total_bands - 1 - band_idx) / (float)total_bands
* SCANLINE_STAGGER;
} else {
band_offset = (float)band_idx / (float)total_bands * SCANLINE_STAGGER;
}
float local = (global_progress - band_offset) / (1.0f - SCANLINE_STAGGER);
if (local < 0.0f) local = 0.0f;
if (local > 1.0f) local = 1.0f;
return local;
}
static void render_teleporter_scanlines(float global_progress,
bool reverse) {
int total_bands = SCREEN_HEIGHT / SCANLINE_HEIGHT;
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
SDL_SetRenderDrawColor(r, 0, 0, 0, 255);
for (int i = 0; i < total_bands; i++) {
float bp = band_progress(i, total_bands, global_progress, reverse);
if (bp <= 0.0f) continue;
int y = i * SCANLINE_HEIGHT;
int w = (int)(bp * (float)SCREEN_WIDTH + 0.5f);
if (w <= 0) continue;
if (w > SCREEN_WIDTH) w = SCREEN_WIDTH;
/* Alternate sweep direction per band for visual interest. */
int x = (i % 2 == 0) ? 0 : (SCREEN_WIDTH - w);
SDL_Rect band = {x, y, w, SCANLINE_HEIGHT};
SDL_RenderFillRect(r, &band);
}
}
static void render_teleporter_flash(float alpha_f) {
if (alpha_f <= 0.0f) return;
uint8_t alpha = (uint8_t)(alpha_f * 255.0f);
if (alpha == 0) return;
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(r, 255, 255, 255, alpha);
SDL_Rect rect = {0, 0, SCREEN_WIDTH, SCREEN_HEIGHT};
SDL_RenderFillRect(r, &rect);
}
static void render_teleporter(const TransitionState *ts) {
if (ts->phase == TRANS_PHASE_OUT) {
/* Phase 1: scanline dissolve, then flash builds up. */
if (ts->timer < TELEPORT_DISSOLVE_DURATION) {
float progress = ts->timer / TELEPORT_DISSOLVE_DURATION;
render_teleporter_scanlines(progress, false);
} else {
/* Dissolve complete — full black + rising flash. */
render_teleporter_scanlines(1.0f, false);
float flash_t = (ts->timer - TELEPORT_DISSOLVE_DURATION)
/ TELEPORT_FLASH_DURATION;
if (flash_t > 1.0f) flash_t = 1.0f;
render_teleporter_flash(flash_t);
}
return;
}
if (ts->phase == TRANS_PHASE_LOAD) {
/* Fully covered: black + white flash. */
render_teleporter_scanlines(1.0f, false);
render_teleporter_flash(1.0f);
return;
}
if (ts->phase == TRANS_PHASE_IN) {
/* Phase 2: flash fades out, then scanlines recede. */
if (ts->timer < TELEPORT_FLASH_DURATION) {
/* Flash fading over black. */
render_teleporter_scanlines(1.0f, true);
float flash_t = 1.0f - ts->timer / TELEPORT_FLASH_DURATION;
render_teleporter_flash(flash_t);
} else {
/* Scanlines receding to reveal the new level. */
float mat_t = (ts->timer - TELEPORT_FLASH_DURATION)
/ TELEPORT_MATERIALIZE_DURATION;
if (mat_t > 1.0f) mat_t = 1.0f;
/* Progress inverted: 1.0 = fully covered, 0.0 = revealed. */
render_teleporter_scanlines(1.0f - mat_t, true);
}
return;
}
}
/* ═══════════════════════════════════════════════════
* Render dispatch
* ═══════════════════════════════════════════════════ */
void transition_render(const TransitionState *ts) {
if (ts->phase == TRANS_IDLE || ts->phase == TRANS_PHASE_DONE) return;
/* Pick the active style based on which phase we are in. */
TransitionStyle style = (ts->phase == TRANS_PHASE_IN)
? ts->in_style : ts->out_style;
switch (style) {
case TRANS_ELEVATOR: render_elevator(ts); break;
case TRANS_TELEPORTER: render_teleporter(ts); break;
default: break;
}
}
/* ═══════════════════════════════════════════════════
* Name ↔ enum conversion
* ═══════════════════════════════════════════════════ */
TransitionStyle transition_style_from_name(const char *name) {
if (!name) return TRANS_NONE;
if (strcmp(name, "spacecraft") == 0) return TRANS_SPACECRAFT;
if (strcmp(name, "elevator") == 0) return TRANS_ELEVATOR;
if (strcmp(name, "teleporter") == 0) return TRANS_TELEPORTER;
return TRANS_NONE;
}
const char *transition_style_name(TransitionStyle style) {
switch (style) {
case TRANS_SPACECRAFT: return "spacecraft";
case TRANS_ELEVATOR: return "elevator";
case TRANS_TELEPORTER: return "teleporter";
default: return "none";
}
}

68
src/game/transition.h Normal file
View File

@@ -0,0 +1,68 @@
#ifndef JNR_TRANSITION_H
#define JNR_TRANSITION_H
#include <stdbool.h>
#include "engine/camera.h"
#include "config.h"
/* ═══════════════════════════════════════════════════
* Level transition animations
*
* Two-phase system: outro (on the old level) then
* intro (on the new level). The caller is responsible
* for freeing the old level and loading the new one
* between phases (when transition_needs_load() is true).
*
* TransitionStyle enum is defined in config.h so that
* both engine (tilemap) and game code can reference it
* without circular includes.
* ═══════════════════════════════════════════════════ */
typedef enum TransitionPhase {
TRANS_IDLE, /* no transition active */
TRANS_PHASE_OUT, /* outro animation on the old level */
TRANS_PHASE_LOAD, /* ready for the caller to swap levels */
TRANS_PHASE_IN, /* intro animation on the new level */
TRANS_PHASE_DONE, /* transition complete, return to play */
} TransitionPhase;
typedef struct TransitionState {
TransitionStyle out_style; /* outro style (from departing level) */
TransitionStyle in_style; /* intro style (from arriving level) */
TransitionPhase phase;
float timer; /* elapsed time in current phase */
float phase_dur; /* total duration of current phase */
bool sound_played; /* flag to fire a sound once per phase */
} TransitionState;
/* Start the outro phase. out_style comes from the departing level. */
void transition_start_out(TransitionState *ts, TransitionStyle out_style);
/* Set the intro style (call after loading the new level). */
void transition_set_in_style(TransitionState *ts, TransitionStyle in_style);
/* Advance the transition. cam may be NULL during the load gap. */
void transition_update(TransitionState *ts, float dt, Camera *cam);
/* True when the outro is done and the caller should swap levels. */
bool transition_needs_load(const TransitionState *ts);
/* Acknowledge the load — advance to the intro phase. */
void transition_begin_intro(TransitionState *ts);
/* True when the full transition is finished. */
bool transition_is_done(const TransitionState *ts);
/* Render the transition overlay (call AFTER rendering the level). */
void transition_render(const TransitionState *ts);
/* Reset to idle. */
void transition_reset(TransitionState *ts);
/* Parse a style name string ("none", "elevator", etc.). */
TransitionStyle transition_style_from_name(const char *name);
/* Return the directive string for a style. */
const char *transition_style_name(TransitionStyle style);
#endif /* JNR_TRANSITION_H */

View File

@@ -4,6 +4,9 @@
#include "game/level.h"
#include "game/levelgen.h"
#include "game/editor.h"
#include "game/stats.h"
#include "game/analytics.h"
#include "game/transition.h"
#include "config.h"
#include <stdio.h>
#include <string.h>
@@ -22,6 +25,7 @@ typedef enum GameMode {
MODE_PLAY,
MODE_EDITOR,
MODE_PAUSED,
MODE_TRANSITION,
} GameMode;
static Level s_level;
@@ -46,10 +50,18 @@ 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 */
/* ── Level transition state ── */
static TransitionState s_transition;
static char s_pending_target[ASSET_PATH_MAX] = {0}; /* exit target stashed during transition */
#ifdef __EMSCRIPTEN__
/* JS-initiated level load request (level-select dropdown in shell). */
static int s_js_load_request = 0;
@@ -238,6 +250,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) {
@@ -300,20 +327,66 @@ static void restart_level(void) {
}
}
/* ── Level load dispatch — loads the next level based on target string ── */
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");
level_free(&s_level);
s_station_depth = 0;
s_mars_depth = 0;
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
begin_session();
} else if (strcmp(target, "generate") == 0) {
printf("Transitioning to generated level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_generated_level();
} else if (strcmp(target, "generate:station") == 0) {
printf("Transitioning to space station level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_station_level();
} else if (strcmp(target, "generate:mars_base") == 0) {
printf("Transitioning to Mars Base level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_mars_base_level();
} else {
printf("Transitioning to: %s\n", target);
char path[ASSET_PATH_MAX];
snprintf(path, sizeof(path), "%s", target);
level_free(&s_level);
if (!load_level_file(path)) {
fprintf(stderr, "Failed to load next level: %s\n", path);
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
}
}
}
/* ═══════════════════════════════════════════════════
* Game callbacks
* ═══════════════════════════════════════════════════ */
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 +420,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,9 +440,12 @@ 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) {
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|| s_mode == MODE_TRANSITION) {
transition_reset(&s_transition);
level_free(&s_level);
} else if (s_mode == MODE_EDITOR) {
editor_free(&s_editor);
@@ -389,6 +468,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
@@ -410,6 +490,28 @@ static void game_update(float dt) {
return;
}
if (s_mode == MODE_TRANSITION) {
transition_update(&s_transition, dt, &s_level.camera);
/* Outro finished — swap levels. */
if (transition_needs_load(&s_transition)) {
dispatch_level_load(s_pending_target);
s_pending_target[0] = '\0';
/* Use the new level's intro style. */
TransitionStyle in_style = s_level.map.transition_in;
transition_set_in_style(&s_transition, in_style);
transition_begin_intro(&s_transition);
}
/* Intro finished — return to play. */
if (transition_is_done(&s_transition)) {
transition_reset(&s_transition);
s_mode = MODE_PLAY;
}
return;
}
/* ── Play mode ── */
/* Pause on escape (return to editor during test play) */
@@ -437,60 +539,41 @@ 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;
if (target[0] == '\0') {
/* Empty target = victory / end of game */
printf("Level complete! (no next level)\n");
/* Loop back to the beginning, reset progression state */
level_free(&s_level);
s_station_depth = 0;
s_mars_depth = 0;
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
} else if (strcmp(target, "generate") == 0) {
/* Procedurally generated next level */
printf("Transitioning to generated level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_generated_level();
} else if (strcmp(target, "generate:station") == 0) {
/* Procedurally generated space station level */
printf("Transitioning to space station level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_station_level();
} else if (strcmp(target, "generate:mars_base") == 0) {
/* Procedurally generated Mars Base level */
printf("Transitioning to Mars Base level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_mars_base_level();
/* Record the level completion in stats */
if (s_session_active) {
s_stats.levels_completed++;
}
TransitionStyle out_style = s_level.map.transition_out;
if (out_style == TRANS_ELEVATOR || out_style == TRANS_TELEPORTER) {
/* Animated transition: stash target, start outro. */
snprintf(s_pending_target, sizeof(s_pending_target), "%s", target);
transition_start_out(&s_transition, out_style);
s_mode = MODE_TRANSITION;
} else {
/* Load a specific level file */
printf("Transitioning to: %s\n", target);
char path[ASSET_PATH_MAX];
snprintf(path, sizeof(path), "%s", target);
level_free(&s_level);
if (!load_level_file(path)) {
fprintf(stderr, "Failed to load next level: %s\n", path);
/* Fallback to moon01 */
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
}
/* Instant transition (none or spacecraft-driven). */
dispatch_level_load(target);
}
}
}
@@ -543,16 +626,23 @@ static void game_render(float interpolation) {
/* Render frozen game frame, then overlay the pause menu. */
level_render(&s_level, interpolation);
pause_render();
} else if (s_mode == MODE_TRANSITION) {
/* Render the level (frozen) with the transition overlay on top. */
level_render(&s_level, interpolation);
transition_render(&s_transition);
} else {
level_render(&s_level, 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. */
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED || s_testing_from_editor) {
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|| s_mode == MODE_TRANSITION || s_testing_from_editor) {
level_free(&s_level);
}
if (s_mode == MODE_EDITOR || s_use_editor) {

View File

@@ -107,7 +107,8 @@
</style>
</head>
<body>
<div id="canvas-container">
<div id="canvas-container"
data-analytics-url="https://horchposten.schick-web.site">
<canvas class="emscripten" id="canvas" tabindex="1"
width="640" height="360"></canvas>
</div>
@@ -282,6 +283,37 @@
document.title = 'Jump \'n Run - Level Editor';
}
}
/* ── 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) {
var sid = Module._analyticsSessionId;
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'
});
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>
{{{ SCRIPT }}}
</body>