Files
major_tom/src/game/level.c
Thomas ea6e16358f Add in-game level editor with auto-discovered tile/entity palettes
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)
2026-02-28 20:24:43 +00:00

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;
}
}