Files
major_tom/src/game/level.c
Thomas 6c4b076c68 Add per-level wind atmosphere property
WIND directive in .lvl files sets a constant horizontal force (px/s^2)
that pushes entities, projectiles, and particles. Positive is rightward.

Wind is applied as acceleration in physics_update() (halved on ground),
directly to projectile and particle velocities, and as a gentle position
drift on flyers. Entities with gravity_scale=0 (drones, spacecraft) are
unaffected. Levels default to no wind when the directive is absent.
2026-03-01 17:13:01 +00:00

767 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 wind (0 = no wind) */
physics_set_wind(level->map.wind);
/* 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) */
snprintf(level->map.tileset_path, sizeof(level->map.tileset_path),
"%s", "assets/tiles/tileset.png");
level->map.tileset = assets_get_texture(level->map.tileset_path);
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 ───────────────────────── */
/* 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 touches an exit zone. */
static void check_exit_ship_trigger(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;
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))) {
/* 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)\n", i);
}
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, &level->camera);
/* Handle collisions (only meaningful once player exists) */
if (level->player_spawned) {
handle_collisions(&level->entities);
/* Exit ship: proximity trigger + boarding/departure */
check_exit_ship_trigger(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;
}
}