Add new level transition state machine

This commit is contained in:
2026-03-14 16:29:10 +00:00
parent 6d64c6426f
commit 7605f0ca8c
15 changed files with 615 additions and 55 deletions

View File

@@ -9,9 +9,20 @@
/* 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 */
/* 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')) {
localStorage.setItem('jnr_client_id', crypto.randomUUID());
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

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

@@ -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 */

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 */