Files
major_tom/src/game/level.c
Thomas ded662b42a Add spacecraft exit sequence at level exit zones
Spawn an exit ship when the player approaches an exit zone. The ship
flies in, lands near the exit, and waits for the player to board.
On boarding the player is deactivated, the ship takes off, and the
level transition fires after departure.
2026-03-01 12:38:41 +00:00

774 lines
27 KiB
C

#include "game/level.h"
#include "game/player.h"
#include "game/enemy.h"
#include "game/projectile.h"
#include "game/hazards.h"
#include "game/powerup.h"
#include "game/drone.h"
#include "game/spacecraft.h"
#include "game/sprites.h"
#include "game/entity_registry.h"
#include "engine/core.h"
#include "engine/renderer.h"
#include "engine/physics.h"
#include "engine/particle.h"
#include "engine/audio.h"
#include "engine/input.h"
#include "engine/camera.h"
#include "engine/assets.h"
#include <stdio.h>
#include <string.h>
#include <math.h>
/* ── Sound effects ───────────────────────────────── */
static Sound s_sfx_hit;
static Sound s_sfx_enemy_death;
static Sound s_sfx_pickup;
static bool s_sfx_loaded = false;
/* ── Shared level setup (after tilemap is ready) ─── */
static bool level_setup(Level *level) {
/* Initialize subsystems */
entity_manager_init(&level->entities);
camera_init(&level->camera, SCREEN_WIDTH, SCREEN_HEIGHT);
particle_init();
/* Load combat sound effects */
if (!s_sfx_loaded) {
s_sfx_hit = audio_load_sound("assets/sounds/hitHurt.wav");
s_sfx_enemy_death = audio_load_sound("assets/sounds/teleport.wav");
s_sfx_pickup = audio_load_sound("assets/sounds/powerUp.wav");
s_sfx_loaded = true;
}
/* Generate spritesheet */
sprites_init_anims();
if (!sprites_generate(g_engine.renderer)) {
fprintf(stderr, "Warning: failed to generate spritesheet\n");
}
/* Register all entity types via the central registry */
entity_registry_init(&level->entities);
/* Apply level gravity (0 = use default) */
if (level->map.gravity > 0) {
physics_set_gravity(level->map.gravity);
} else {
physics_set_gravity(DEFAULT_GRAVITY);
}
/* Apply level background color */
if (level->map.has_bg_color) {
renderer_set_clear_color(level->map.bg_color);
}
/* Initialize parallax backgrounds */
parallax_init(&level->parallax);
if (level->map.parallax_far_path[0]) {
SDL_Texture *far_tex = assets_get_texture(level->map.parallax_far_path);
if (far_tex) parallax_set_far(&level->parallax, far_tex, 0.05f, 0.05f);
}
if (level->map.parallax_near_path[0]) {
SDL_Texture *near_tex = assets_get_texture(level->map.parallax_near_path);
if (near_tex) parallax_set_near(&level->parallax, near_tex, 0.15f, 0.10f);
}
/* Generate procedural backgrounds for any layers not loaded from file */
if (!level->parallax.far_layer.active && !level->parallax.near_layer.active
&& level->map.parallax_style != 0) {
/* Use themed parallax when a style is specified */
parallax_generate_themed(&level->parallax, g_engine.renderer,
(ParallaxStyle)level->map.parallax_style);
} else {
/* Default: generic stars + nebula */
if (!level->parallax.far_layer.active) {
parallax_generate_stars(&level->parallax, g_engine.renderer);
}
if (!level->parallax.near_layer.active) {
parallax_generate_nebula(&level->parallax, g_engine.renderer);
}
}
/* Set camera bounds to level size */
camera_set_bounds(&level->camera,
(float)(level->map.width * TILE_SIZE),
(float)(level->map.height * TILE_SIZE));
/* Spawn entities from level data (via registry) */
level->has_intro_ship = false;
for (int i = 0; i < level->map.entity_spawn_count; i++) {
EntitySpawn *es = &level->map.entity_spawns[i];
Vec2 pos = vec2(es->x, es->y);
entity_registry_spawn(&level->entities, es->type_name, pos);
if (strcmp(es->type_name, "spacecraft") == 0) {
level->has_intro_ship = true;
}
}
/* Spawn player — deferred if the level has an intro spacecraft */
if (level->has_intro_ship) {
level->player_spawned = false;
} else {
Entity *player = player_spawn(&level->entities, level->map.player_spawn);
if (!player) {
fprintf(stderr, "Failed to spawn player!\n");
return false;
}
if (level->map.player_unarmed) {
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) ppd->has_gun = false;
}
level->player_spawned = true;
}
/* Load level music (playback deferred to first update —
* browsers require user interaction before playing audio) */
if (level->map.music_path[0]) {
level->music = audio_load_music(level->map.music_path);
}
level->music_started = false;
printf("Level loaded successfully.\n");
return true;
}
bool level_load(Level *level, const char *path) {
memset(level, 0, sizeof(Level));
/* Load tilemap from file */
if (!tilemap_load(&level->map, path, g_engine.renderer)) {
return false;
}
return level_setup(level);
}
bool level_load_generated(Level *level, Tilemap *gen_map) {
memset(level, 0, sizeof(Level));
/* Take ownership of the generated tilemap */
level->map = *gen_map;
memset(gen_map, 0, sizeof(Tilemap)); /* prevent double-free */
/* Load tileset texture (the generator doesn't do this) */
level->map.tileset = assets_get_texture("assets/tiles/tileset.png");
if (level->map.tileset) {
int tex_w;
SDL_QueryTexture(level->map.tileset, NULL, NULL, &tex_w, NULL);
level->map.tileset_cols = tex_w / TILE_SIZE;
}
return level_setup(level);
}
/* ── Collision handling ──────────────────────────── */
/* Forward declaration for shake access */
static Camera *s_active_camera = NULL;
static void damage_entity(Entity *target, int damage) {
target->health -= damage;
if (target->health <= 0) {
target->flags |= ENTITY_DEAD;
/* Death particles — centered on entity */
Vec2 center = vec2(
target->body.pos.x + target->body.size.x * 0.5f,
target->body.pos.y + target->body.size.y * 0.5f
);
SDL_Color death_color;
if (target->type == ENT_ENEMY_GRUNT) {
death_color = (SDL_Color){200, 60, 60, 255}; /* red debris */
} else if (target->type == ENT_ENEMY_FLYER) {
death_color = (SDL_Color){140, 80, 200, 255}; /* purple puff */
} else if (target->type == ENT_TURRET) {
death_color = (SDL_Color){160, 160, 160, 255}; /* metal scraps */
} else {
death_color = (SDL_Color){200, 200, 200, 255}; /* grey */
}
particle_emit_death_puff(center, death_color);
/* Screen shake on kill */
if (s_active_camera) {
camera_shake(s_active_camera, 2.0f, 0.15f);
}
audio_play_sound_at(s_sfx_enemy_death, 80, center, 0);
}
}
static void damage_player(Entity *player, int damage, Entity *source) {
PlayerData *ppd = (PlayerData *)player->data;
damage_entity(player, damage);
/* Screen shake on player hit (stronger) */
if (s_active_camera) {
camera_shake(s_active_camera, 4.0f, 0.2f);
}
audio_play_sound(s_sfx_hit, 100);
if (player->health > 0 && ppd) {
ppd->inv_timer = PLAYER_INV_TIME;
player->flags |= ENTITY_INVINCIBLE;
/* Knockback away from source */
if (source) {
float knock_dir = (player->body.pos.x < source->body.pos.x) ?
-1.0f : 1.0f;
player->body.vel.x = knock_dir * 150.0f;
player->body.vel.y = -150.0f;
}
}
}
static bool is_enemy(const Entity *e) {
return e->type == ENT_ENEMY_GRUNT || e->type == ENT_ENEMY_FLYER ||
e->type == ENT_TURRET;
}
static void handle_collisions(EntityManager *em) {
/* Find the player */
Entity *player = NULL;
for (int i = 0; i < em->count; i++) {
Entity *e = &em->entities[i];
if (e->active && e->type == ENT_PLAYER && !(e->flags & ENTITY_DEAD)) {
player = e;
break;
}
}
for (int i = 0; i < em->count; i++) {
Entity *a = &em->entities[i];
if (!a->active) continue;
/* ── Projectile vs entities ──────────── */
if (a->type == ENT_PROJECTILE) {
if (projectile_is_impacting(a)) continue;
bool from_player = projectile_is_from_player(a);
for (int j = 0; j < em->count; j++) {
Entity *b = &em->entities[j];
if (!b->active || b == a) continue;
if (b->flags & ENTITY_DEAD) continue;
bool hit = false;
/* Player bullet hits enemies */
if (from_player && is_enemy(b)) {
if (physics_overlap(&a->body, &b->body)) {
damage_entity(b, a->damage);
hit = true;
}
}
/* Enemy bullet hits player */
if (!from_player && b->type == ENT_PLAYER &&
!(b->flags & ENTITY_INVINCIBLE)) {
if (physics_overlap(&a->body, &b->body)) {
damage_player(b, a->damage, NULL);
hit = true;
}
}
if (hit) {
projectile_hit(a);
/* If projectile was destroyed or is impacting, stop checking */
if (!a->active || projectile_is_impacting(a)) break;
}
}
}
/* ── Enemy contact damage to player ──── */
if (player && !(player->flags & ENTITY_INVINCIBLE) &&
is_enemy(a) && !(a->flags & ENTITY_DEAD)) {
if (physics_overlap(&a->body, &player->body)) {
/* Check if player is stomping (falling onto enemy from above) */
bool stomping = (player->body.vel.y > 0) &&
(player->body.pos.y + player->body.size.y <
a->body.pos.y + a->body.size.y * 0.5f);
if (stomping) {
damage_entity(a, 2);
player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f;
} else {
damage_player(player, a->damage, a);
}
}
}
/* ── Powerup pickup ────────────────────── */
if (player && a->type == ENT_POWERUP && a->active &&
!(a->flags & ENTITY_DEAD)) {
if (physics_overlap(&a->body, &player->body)) {
PowerupData *pd = (PowerupData *)a->data;
bool picked_up = false;
if (pd) {
switch (pd->kind) {
case POWERUP_HEALTH:
if (player->health < player->max_health) {
player->health++;
picked_up = true;
}
break;
case POWERUP_JETPACK: {
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) {
ppd->dash_charges = ppd->dash_max_charges;
ppd->dash_recharge_timer = 0.0f;
ppd->jetpack_boost_timer = PLAYER_JETPACK_BOOST_DURATION;
picked_up = true;
}
break;
}
case POWERUP_DRONE:
drone_spawn(em, vec2(
player->body.pos.x + player->body.size.x * 0.5f,
player->body.pos.y
));
picked_up = true;
break;
case POWERUP_GUN:
if (!player_has_gun(player)) {
player_give_gun(player);
picked_up = true;
}
break;
default:
break;
}
}
if (picked_up) {
/* Pickup particles */
Vec2 center = vec2(
a->body.pos.x + a->body.size.x * 0.5f,
a->body.pos.y + a->body.size.y * 0.5f
);
particle_emit_spark(center, (SDL_Color){255, 255, 100, 255});
audio_play_sound_at(s_sfx_pickup, 80, center, 0);
/* Destroy the powerup */
a->flags |= ENTITY_DEAD;
}
}
}
}
}
/* ── Exit zone / exit ship ───────────────────────── */
/* How close the player must be to an exit zone to trigger the exit ship
* fly-in. Roughly 2 screen widths. */
#define EXIT_SHIP_TRIGGER_DIST (SCREEN_WIDTH * 2.0f)
/* Landing offset: where the ship lands relative to the exit zone center.
* The ship lands a bit to the left of the exit zone so the player walks
* rightward into it. */
#define EXIT_SHIP_LAND_OFFSET_X (-SPACECRAFT_WIDTH * 0.5f)
/* Find the active, alive player entity (or NULL). */
static Entity *find_player(EntityManager *em) {
for (int i = 0; i < em->count; i++) {
Entity *e = &em->entities[i];
if (e->active && e->type == ENT_PLAYER && !(e->flags & ENTITY_DEAD))
return e;
}
return NULL;
}
/* Find the exit spacecraft entity (or NULL). */
static Entity *find_exit_ship(EntityManager *em) {
for (int i = 0; i < em->count; i++) {
Entity *e = &em->entities[i];
if (e->active && e->type == ENT_SPACECRAFT && spacecraft_is_exit_ship(e))
return e;
}
return NULL;
}
/* Spawn the exit ship when the player gets close to an exit zone. */
static void check_exit_ship_proximity(Level *level) {
if (level->exit_triggered) return;
if (level->exit_ship_spawned) return;
if (level->map.exit_zone_count == 0) return;
Entity *player = find_player(&level->entities);
if (!player) return;
Vec2 pc = vec2(
player->body.pos.x + player->body.size.x * 0.5f,
player->body.pos.y + player->body.size.y * 0.5f
);
for (int i = 0; i < level->map.exit_zone_count; i++) {
const ExitZone *ez = &level->map.exit_zones[i];
Vec2 ez_center = vec2(ez->x + ez->w * 0.5f, ez->y + ez->h * 0.5f);
float dx = pc.x - ez_center.x;
float dy = pc.y - ez_center.y;
float dist = sqrtf(dx * dx + dy * dy);
if (dist < EXIT_SHIP_TRIGGER_DIST) {
/* Land the ship so its bottom aligns with the exit zone bottom.
* land_pos is the top-left of the ship's resting position. */
float land_x = ez->x + ez->w * 0.5f + EXIT_SHIP_LAND_OFFSET_X;
float land_y = ez->y + ez->h - (float)SPACECRAFT_HEIGHT;
Entity *ship = spacecraft_spawn_exit(&level->entities,
vec2(land_x, land_y));
if (ship) {
level->exit_ship_spawned = true;
level->exit_zone_idx = i;
printf("Exit ship triggered (zone %d, dist=%.0f)\n", i, dist);
}
return;
}
}
}
/* Handle the exit ship boarding and departure sequence. */
static void update_exit_ship(Level *level) {
if (level->exit_triggered) return;
if (!level->exit_ship_spawned) return;
Entity *ship = find_exit_ship(&level->entities);
/* Ship may have been destroyed (SC_DONE) */
if (!ship) {
if (level->exit_ship_boarded) {
/* Ship finished its departure — trigger the level exit */
const ExitZone *ez = &level->map.exit_zones[level->exit_zone_idx];
level->exit_triggered = true;
snprintf(level->exit_target, sizeof(level->exit_target),
"%s", ez->target);
printf("Exit ship departed -> %s\n",
ez->target[0] ? ez->target : "(victory)");
}
return;
}
/* Wait for the ship to land, then check for player boarding */
if (!level->exit_ship_boarded && spacecraft_is_landed(ship)) {
Entity *player = find_player(&level->entities);
if (player) {
/* Check overlap between player and the landed ship */
if (physics_overlap(&player->body, &ship->body)) {
/* Board the ship: deactivate the player, start takeoff */
level->exit_ship_boarded = true;
player->active = false;
spacecraft_takeoff(ship);
printf("Player boarded exit ship\n");
}
}
}
}
/* Direct exit zone overlap — fallback for levels without spacecraft exit. */
static void check_exit_zones(Level *level) {
if (level->exit_triggered) return;
if (level->exit_ship_spawned) return; /* ship handles the exit instead */
if (level->map.exit_zone_count == 0) return;
Entity *player = find_player(&level->entities);
if (!player) return;
for (int i = 0; i < level->map.exit_zone_count; i++) {
const ExitZone *ez = &level->map.exit_zones[i];
if (physics_aabb_overlap(
player->body.pos, player->body.size,
vec2(ez->x, ez->y), vec2(ez->w, ez->h))) {
level->exit_triggered = true;
snprintf(level->exit_target, sizeof(level->exit_target),
"%s", ez->target);
printf("Exit zone triggered -> %s\n",
ez->target[0] ? ez->target : "(victory)");
return;
}
}
}
bool level_exit_triggered(const Level *level) {
return level->exit_triggered;
}
void level_update(Level *level, float dt) {
/* Don't update if exit already triggered (transition pending) */
if (level->exit_triggered) return;
/* Start music on first update (deferred so browser audio context
* is unlocked by the first user interaction / keypress) */
if (!level->music_started && level->music.music) {
audio_set_music_volume(64);
audio_play_music(level->music, true);
level->music_started = true;
}
/* Set camera pointer for collision shake triggers */
s_active_camera = &level->camera;
/* ── Deferred player spawn: wait for spacecraft to land ── */
if (level->has_intro_ship && !level->player_spawned) {
/* Find the spacecraft and check if it has landed */
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_SPACECRAFT &&
!spacecraft_is_exit_ship(e) &&
spacecraft_is_landed(e)) {
/* Intro ship has landed — spawn the player at spawn point */
Entity *player = player_spawn(&level->entities,
level->map.player_spawn);
if (player) {
if (level->map.player_unarmed) {
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) ppd->has_gun = false;
}
level->player_spawned = true;
/* Trigger takeoff now that the player has exited */
spacecraft_takeoff(e);
}
break;
}
}
}
/* Update all entities */
entity_update_all(&level->entities, dt, &level->map);
/* Handle collisions (only meaningful once player exists) */
if (level->player_spawned) {
handle_collisions(&level->entities);
/* Exit ship: proximity trigger + boarding/departure */
check_exit_ship_proximity(level);
update_exit_ship(level);
/* Fallback direct exit zone check (for levels without ship exit) */
check_exit_zones(level);
/* Check for player respawn (skip if player boarded exit ship) */
if (!level->exit_ship_boarded) {
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)) {
player_respawn(e, level->map.player_spawn);
Vec2 center = vec2(
e->body.pos.x + e->body.size.x * 0.5f,
e->body.pos.y + e->body.size.y * 0.5f
);
camera_follow(&level->camera, center, vec2_zero(), dt);
break;
}
}
}
}
/* Update particles */
particle_update(dt);
/* Camera tracking:
* 1. Exit ship boarded → hold camera still (ship flies out of frame)
* 2. Player spawned → follow the player
* 3. Intro ship flying in → follow the spacecraft */
bool cam_tracked = false;
if (level->exit_ship_boarded) {
/* Camera stays put — the ship flies out of frame on its own */
cam_tracked = true;
}
if (!cam_tracked && level->player_spawned) {
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_PLAYER) {
float look_offset = player_get_look_up_offset(e);
Vec2 center = vec2(
e->body.pos.x + e->body.size.x * 0.5f,
e->body.pos.y + e->body.size.y * 0.5f + look_offset
);
camera_follow(&level->camera, center, e->body.vel, dt);
cam_tracked = true;
break;
}
}
}
if (!cam_tracked) {
/* Follow the spacecraft during intro */
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_SPACECRAFT &&
!spacecraft_is_exit_ship(e)) {
Vec2 center = vec2(
e->body.pos.x + e->body.size.x * 0.5f,
e->body.pos.y + e->body.size.y * 0.5f
);
camera_follow(&level->camera, center, vec2_zero(), dt);
cam_tracked = true;
break;
}
}
}
/* Set audio listener to camera center */
if (cam_tracked) {
Vec2 listener = vec2(
level->camera.pos.x + level->camera.viewport.x * 0.5f,
level->camera.pos.y + level->camera.viewport.y * 0.5f
);
audio_set_listener(listener);
}
/* Update screen shake */
camera_update_shake(&level->camera, dt);
}
void level_render(Level *level, float interpolation) {
(void)interpolation; /* TODO: use for render interpolation */
Camera *cam = &level->camera;
/* Render parallax backgrounds (behind everything) */
parallax_render(&level->parallax, cam, g_engine.renderer);
/* Render tile layers */
tilemap_render_layer(&level->map, level->map.bg_layer,
cam, g_engine.renderer);
tilemap_render_layer(&level->map, level->map.collision_layer,
cam, g_engine.renderer);
/* Render exit zones (pulsing glow on ground layer) */
if (level->map.exit_zone_count > 0) {
/* Pulse alpha between 40 and 100 using a sine wave */
static float s_exit_pulse = 0.0f;
s_exit_pulse += 3.0f * DT; /* ~3 Hz pulse */
float pulse = 0.5f + 0.5f * sinf(s_exit_pulse);
uint8_t alpha = (uint8_t)(40.0f + pulse * 60.0f);
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
for (int i = 0; i < level->map.exit_zone_count; i++) {
const ExitZone *ez = &level->map.exit_zones[i];
Vec2 screen_pos = camera_world_to_screen(cam, vec2(ez->x, ez->y));
float zoom = cam->zoom > 0.0f ? cam->zoom : 1.0f;
SDL_Rect rect = {
(int)screen_pos.x,
(int)screen_pos.y,
(int)(ez->w * zoom + 0.5f),
(int)(ez->h * zoom + 0.5f)
};
/* Green/cyan fill */
SDL_SetRenderDrawColor(r, 50, 230, 180, alpha);
SDL_RenderFillRect(r, &rect);
/* Brighter border */
SDL_SetRenderDrawColor(r, 80, 255, 200, (uint8_t)(alpha + 40));
SDL_RenderDrawRect(r, &rect);
}
}
/* Render entities */
entity_render_all(&level->entities, cam);
/* Render particles (between entities and foreground) */
particle_render(cam);
/* Render foreground tiles */
tilemap_render_layer(&level->map, level->map.fg_layer,
cam, g_engine.renderer);
/* Render HUD - health display */
Entity *player = NULL;
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_PLAYER) {
player = e;
break;
}
}
if (player) {
/* Draw health hearts */
for (int i = 0; i < player->max_health; i++) {
SDL_Color heart_color;
if (i < player->health) {
heart_color = (SDL_Color){220, 50, 50, 255}; /* red = full */
} else {
heart_color = (SDL_Color){80, 80, 80, 255}; /* grey = empty */
}
Vec2 pos = vec2(8.0f + i * 14.0f, 8.0f);
Vec2 size = vec2(10.0f, 10.0f);
renderer_draw_rect(pos, size, heart_color, LAYER_HUD, cam);
}
/* Draw jetpack charge indicators */
int charges, max_charges;
float recharge_pct;
bool boosted = false;
if (player_get_dash_charges(player, &charges, &max_charges,
&recharge_pct, &boosted)) {
/* Blue when boosted, orange normally */
SDL_Color full_color = boosted
? (SDL_Color){50, 150, 255, 255}
: (SDL_Color){255, 180, 50, 255};
SDL_Color partial_color = boosted
? (SDL_Color){40, 120, 200, 180}
: (SDL_Color){200, 140, 40, 180};
for (int i = 0; i < max_charges; i++) {
float bx = 8.0f + i * 10.0f;
float by = 22.0f;
float bw = 7.0f;
float bh = 5.0f;
/* Background (empty slot) */
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
(SDL_Color){50, 50, 60, 255}, LAYER_HUD, cam);
if (i < charges) {
/* Full charge */
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
full_color, LAYER_HUD, cam);
} else if (i == charges) {
/* Currently recharging — partial fill */
float fill = recharge_pct * bw;
if (fill > 0.5f) {
renderer_draw_rect(vec2(bx, by), vec2(fill, bh),
partial_color, LAYER_HUD, cam);
}
}
}
}
}
/* Flush the renderer */
renderer_flush(cam);
}
void level_free(Level *level) {
audio_stop_music();
/* Free music handle (prevent leak on reload) */
audio_free_music(&level->music);
level->music_started = false;
entity_manager_clear(&level->entities);
particle_clear();
parallax_free(&level->parallax);
tilemap_free(&level->map);
/* Free spritesheet */
if (g_spritesheet) {
SDL_DestroyTexture(g_spritesheet);
g_spritesheet = NULL;
}
}