#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 #include #include /* ── 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; } }