forked from tas/major_tom
Implements a full level editor that runs inside the game engine as an alternative mode, accessible via --edit flag or E key during gameplay. The editor auto-discovers available tiles from the tileset texture and entities from a new central registry, so adding new game content automatically appears in the editor without any editor-specific changes. Editor features: tile painting (pencil/eraser/flood fill) across 3 layers, entity placement with drag-to-move, player spawn point tool, camera pan/zoom, grid overlay, .lvl save/load, map resize, and test play (P to play, ESC to return to editor). Supporting changes: - Entity registry centralizes spawn functions (replaces strcmp chain) - Mouse input + raw keyboard access added to input system - Camera zoom support for editor overview - Zoom-aware rendering in tilemap, renderer, and sprite systems - Powerup and drone sprites/animations wired up (were defined but unused) - Bitmap font renderer for editor UI (4x6 pixel glyphs, no dependencies)
436 lines
15 KiB
C
436 lines
15 KiB
C
#include "game/level.h"
|
|
#include "game/player.h"
|
|
#include "game/enemy.h"
|
|
#include "game/projectile.h"
|
|
#include "game/hazards.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>
|
|
|
|
/* ── Sound effects ───────────────────────────────── */
|
|
static Sound s_sfx_hit;
|
|
static Sound s_sfx_enemy_death;
|
|
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_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 player at map spawn point */
|
|
Entity *player = player_spawn(&level->entities, level->map.player_spawn);
|
|
if (!player) {
|
|
fprintf(stderr, "Failed to spawn player!\n");
|
|
return false;
|
|
}
|
|
|
|
/* Spawn entities from level data (via registry) */
|
|
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);
|
|
}
|
|
|
|
/* 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(s_sfx_enemy_death, 80);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void level_update(Level *level, float dt) {
|
|
/* 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;
|
|
|
|
/* Update all entities */
|
|
entity_update_all(&level->entities, dt, &level->map);
|
|
|
|
/* Handle collisions */
|
|
handle_collisions(&level->entities);
|
|
|
|
/* Check for player respawn */
|
|
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);
|
|
/* Re-snap camera to player immediately */
|
|
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);
|
|
|
|
/* Find player for camera tracking */
|
|
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);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* 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 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;
|
|
if (player_get_dash_charges(player, &charges, &max_charges, &recharge_pct)) {
|
|
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 — bright orange */
|
|
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
|
|
(SDL_Color){255, 180, 50, 255}, 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),
|
|
(SDL_Color){200, 140, 40, 180}, 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;
|
|
}
|
|
}
|