From 7605f0ca8ca59603f0cf78000cc5fdd17d521979 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 14 Mar 2026 16:29:10 +0000 Subject: [PATCH] Add new level transition state machine --- DESIGN.md | 2 +- TODO.md | 30 +++- assets/levels/level01.lvl | 1 + assets/levels/level02.lvl | 2 + assets/levels/mars02.lvl | 1 + assets/levels/mars03.lvl | 2 + include/config.h | 8 + src/engine/tilemap.c | 18 ++ src/engine/tilemap.h | 2 + src/game/analytics.c | 15 +- src/game/editor.c | 11 ++ src/game/levelgen.c | 27 +++ src/game/transition.c | 351 ++++++++++++++++++++++++++++++++++++++ src/game/transition.h | 68 ++++++++ src/main.c | 132 +++++++++----- 15 files changed, 615 insertions(+), 55 deletions(-) create mode 100644 src/game/transition.c create mode 100644 src/game/transition.h diff --git a/DESIGN.md b/DESIGN.md index c9d8a62..8934937 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/TODO.md b/TODO.md index 6cb7875..2e833c7 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/assets/levels/level01.lvl b/assets/levels/level01.lvl index 6744df3..d53e9df 100644 --- a/assets/levels/level01.lvl +++ b/assets/levels/level01.lvl @@ -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 diff --git a/assets/levels/level02.lvl b/assets/levels/level02.lvl index 31ddc15..101560a 100644 --- a/assets/levels/level02.lvl +++ b/assets/levels/level02.lvl @@ -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 diff --git a/assets/levels/mars02.lvl b/assets/levels/mars02.lvl index 66fb393..31c2f8c 100644 --- a/assets/levels/mars02.lvl +++ b/assets/levels/mars02.lvl @@ -12,6 +12,7 @@ GRAVITY 700 BG_COLOR 20 10 6 PARALLAX_STYLE 2 MUSIC assets/sounds/kaffe_og_kage.ogg +TRANSITION_OUT elevator ENTITY spacecraft 1 3 diff --git a/assets/levels/mars03.lvl b/assets/levels/mars03.lvl index e4aef84..b887e38 100644 --- a/assets/levels/mars03.lvl +++ b/assets/levels/mars03.lvl @@ -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 diff --git a/include/config.h b/include/config.h index dbf95e0..96d6f68 100644 --- a/include/config.h +++ b/include/config.h @@ -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 */ diff --git a/src/engine/tilemap.c b/src/engine/tilemap.c index 65ae073..d079add 100644 --- a/src/engine/tilemap.c +++ b/src/engine/tilemap.c @@ -6,6 +6,14 @@ #include #include +/* ── 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]; diff --git a/src/engine/tilemap.h b/src/engine/tilemap.h index 8ec1c4e..417ffd8 100644 --- a/src/engine/tilemap.h +++ b/src/engine/tilemap.h @@ -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]; diff --git a/src/game/analytics.c b/src/game/analytics.c index e39f387..7b40b33 100644 --- a/src/game/analytics.c +++ b/src/game/analytics.c @@ -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 diff --git a/src/game/editor.c b/src/game/editor.c index 458263c..b725265 100644 --- a/src/game/editor.c +++ b/src/game/editor.c @@ -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 */ diff --git a/src/game/levelgen.c b/src/game/levelgen.c index 1a657f6..decddb8 100644 --- a/src/game/levelgen.c +++ b/src/game/levelgen.c @@ -1,4 +1,5 @@ #include "game/levelgen.h" +#include "game/transition.h" #include "engine/parallax.h" #include #include @@ -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 */ diff --git a/src/game/transition.c b/src/game/transition.c new file mode 100644 index 0000000..9c9ad30 --- /dev/null +++ b/src/game/transition.c @@ -0,0 +1,351 @@ +#include "game/transition.h" +#include "engine/core.h" +#include "engine/audio.h" +#include "engine/particle.h" +#include "config.h" +#include +#include +#include + +/* ═══════════════════════════════════════════════════ + * 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.0–1.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"; + } +} diff --git a/src/game/transition.h b/src/game/transition.h new file mode 100644 index 0000000..db24d3a --- /dev/null +++ b/src/game/transition.h @@ -0,0 +1,68 @@ +#ifndef JNR_TRANSITION_H +#define JNR_TRANSITION_H + +#include +#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 */ diff --git a/src/main.c b/src/main.c index 471415e..4d4315b 100644 --- a/src/main.c +++ b/src/main.c @@ -6,6 +6,7 @@ #include "game/editor.h" #include "game/stats.h" #include "game/analytics.h" +#include "game/transition.h" #include "config.h" #include #include @@ -24,6 +25,7 @@ typedef enum GameMode { MODE_PLAY, MODE_EDITOR, MODE_PAUSED, + MODE_TRANSITION, } GameMode; static Level s_level; @@ -56,6 +58,10 @@ static bool s_session_active = false; #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; @@ -321,6 +327,48 @@ 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 * ═══════════════════════════════════════════════════ */ @@ -395,7 +443,9 @@ static void game_update(float dt) { 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); @@ -440,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) */ @@ -492,49 +564,16 @@ static void game_update(float dt) { s_stats.levels_completed++; } - if (target[0] == '\0') { - /* Empty target = victory / end of game */ - printf("Level complete! (no next level)\n"); - end_session("completed"); - /* 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; - } - begin_session(); - } 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(); + 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); } } } @@ -587,6 +626,10 @@ 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); } @@ -598,7 +641,8 @@ static void game_shutdown(void) { /* 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) {