forked from tas/major_tom
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.
767 lines
27 KiB
C
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;
|
|
}
|
|
}
|