Add moon surface intro level with asteroid hazards and unarmed mechanics

Introduce moon01.lvl as the starting level — a pure jump-and-run intro
with no gun and no enemies, just platforming over gaps and dodging falling
asteroids. The player picks up their gun upon transitioning to level01.

New features:
- Moon tileset and PARALLAX_STYLE_MOON with crater terrain backgrounds
- Asteroid entity (ENT_ASTEROID): falls from sky, damages on contact,
  explodes on ground with particles, respawns after delay
- PLAYER_UNARMED directive disables gun for the level
- Pit rescue mechanic: falling costs 1 HP and auto-dashes upward
- Gun powerup entity type for future armed-pickup levels
- Segment-based procedural level generator with themed rooms
- Extended editor with entity palette and improved tile cycling
- Web shell improvements for Emscripten builds
This commit is contained in:
Thomas
2026-03-01 09:20:49 +00:00
parent ea6e16358f
commit fac7085056
30 changed files with 2139 additions and 83 deletions

View File

@@ -3,6 +3,8 @@
#include "game/enemy.h"
#include "game/projectile.h"
#include "game/hazards.h"
#include "game/powerup.h"
#include "game/drone.h"
#include "game/sprites.h"
#include "game/entity_registry.h"
#include "engine/core.h"
@@ -15,10 +17,12 @@
#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) ─── */
@@ -32,6 +36,7 @@ static bool level_setup(Level *level) {
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/teleport.wav");
s_sfx_loaded = true;
}
@@ -94,6 +99,12 @@ static bool level_setup(Level *level) {
return false;
}
/* Disarm player if level requests it */
if (level->map.player_unarmed) {
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) ppd->has_gun = 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];
@@ -276,10 +287,111 @@ static void handle_collisions(EntityManager *em) {
}
}
}
/* ── 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(s_sfx_pickup, 80);
/* Destroy the powerup */
a->flags |= ENTITY_DEAD;
}
}
}
}
}
/* ── Exit zone checking ──────────────────────────── */
static void check_exit_zones(Level *level) {
if (level->exit_triggered) return;
if (level->map.exit_zone_count == 0) return;
/* Find the player */
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 && !(e->flags & ENTITY_DEAD)) {
player = e;
break;
}
}
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) {
@@ -297,6 +409,9 @@ void level_update(Level *level, float dt) {
/* Handle collisions */
handle_collisions(&level->entities);
/* Check exit zones */
check_exit_zones(level);
/* Check for player respawn */
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
@@ -347,6 +462,39 @@ void level_render(Level *level, float interpolation) {
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);
@@ -384,7 +532,17 @@ void level_render(Level *level, float interpolation) {
/* Draw jetpack charge indicators */
int charges, max_charges;
float recharge_pct;
if (player_get_dash_charges(player, &charges, &max_charges, &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;
@@ -396,15 +554,15 @@ void level_render(Level *level, float interpolation) {
(SDL_Color){50, 50, 60, 255}, LAYER_HUD, cam);
if (i < charges) {
/* Full charge — bright orange */
/* Full charge */
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
(SDL_Color){255, 180, 50, 255}, LAYER_HUD, cam);
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),
(SDL_Color){200, 140, 40, 180}, LAYER_HUD, cam);
partial_color, LAYER_HUD, cam);
}
}
}