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

@@ -1,9 +1,11 @@
#include "game/hazards.h"
#include "game/player.h"
#include "game/sprites.h"
#include "game/projectile.h"
#include "engine/physics.h"
#include "engine/renderer.h"
#include "engine/particle.h"
#include "engine/audio.h"
#include <stdlib.h>
#include <math.h>
@@ -597,3 +599,203 @@ Entity *force_field_spawn(EntityManager *em, Vec2 pos) {
return e;
}
/* ════════════════════════════════════════════════════
* ASTEROID — Falling space rock
* ════════════════════════════════════════════════════ */
static EntityManager *s_asteroid_em = NULL;
static Sound s_sfx_asteroid_impact;
static bool s_asteroid_sfx_loaded = false;
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static void asteroid_update(Entity *self, float dt, const Tilemap *map) {
AsteroidData *ad = (AsteroidData *)self->data;
if (!ad) return;
/* Initial delay before first fall */
if (ad->start_delay > 0) {
ad->start_delay -= dt;
self->body.pos.y = -20.0f; /* hide off-screen */
return;
}
if (!ad->falling) {
/* Waiting to respawn */
ad->respawn_timer -= dt;
if (ad->respawn_timer <= 0) {
/* Reset to spawn position and start falling */
self->body.pos = ad->spawn_pos;
ad->falling = true;
ad->trail_timer = 0;
ad->fall_speed = ASTEROID_FALL_SPEED;
}
return;
}
/* Accelerate while falling (gravity-like) */
ad->fall_speed += 200.0f * dt;
self->body.pos.y += ad->fall_speed * dt;
/* Tumble animation */
animation_update(&self->anim, dt);
/* Smoke trail particles */
ad->trail_timer -= dt;
if (ad->trail_timer <= 0) {
ad->trail_timer = 0.04f;
Vec2 center = vec2(
self->body.pos.x + self->body.size.x * 0.5f,
self->body.pos.y
);
ParticleBurst trail = {
.origin = center,
.count = 2,
.speed_min = 5.0f,
.speed_max = 20.0f,
.life_min = 0.2f,
.life_max = 0.5f,
.size_min = 1.0f,
.size_max = 2.0f,
.spread = 0.5f,
.direction = -(float)M_PI / 2.0f, /* upward */
.drag = 2.0f,
.gravity_scale = 0.0f,
.color = {140, 110, 80, 180},
.color_vary = true,
};
particle_emit(&trail);
}
/* Check player collision */
Entity *player = find_player(s_asteroid_em);
if (player && !(player->flags & ENTITY_INVINCIBLE) &&
!(player->flags & ENTITY_DEAD)) {
if (physics_overlap(&self->body, &player->body)) {
player->health -= ASTEROID_DAMAGE;
if (player->health <= 0) {
player->health = 0;
player->flags |= ENTITY_DEAD;
} else {
/* Grant invincibility frames */
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) {
ppd->inv_timer = PLAYER_INV_TIME;
player->flags |= ENTITY_INVINCIBLE;
}
/* Knockback downward and away */
float knock_dir = (player->body.pos.x < self->body.pos.x)
? -1.0f : 1.0f;
player->body.vel.x = knock_dir * 120.0f;
player->body.vel.y = 100.0f;
}
Vec2 hit_pos = vec2(
player->body.pos.x + player->body.size.x * 0.5f,
player->body.pos.y + player->body.size.y * 0.5f
);
particle_emit_spark(hit_pos, (SDL_Color){180, 140, 80, 255});
if (s_asteroid_sfx_loaded) {
audio_play_sound(s_sfx_asteroid_impact, 80);
}
}
}
/* Check ground collision */
int tx = world_to_tile(self->body.pos.x + self->body.size.x * 0.5f);
int ty = world_to_tile(self->body.pos.y + self->body.size.y);
bool hit_ground = tilemap_is_solid(map, tx, ty);
/* Also despawn if far below level */
float level_bottom = (float)(map->height * TILE_SIZE) + 32.0f;
if (hit_ground || self->body.pos.y > level_bottom) {
/* Impact effect */
Vec2 impact_pos = vec2(
self->body.pos.x + self->body.size.x * 0.5f,
self->body.pos.y + self->body.size.y
);
particle_emit_death_puff(impact_pos, (SDL_Color){140, 110, 80, 255});
if (s_asteroid_sfx_loaded) {
audio_play_sound(s_sfx_asteroid_impact, 60);
}
/* Hide and start respawn timer */
ad->falling = false;
ad->respawn_timer = ASTEROID_RESPAWN;
self->body.pos.y = -100.0f; /* hide off-screen */
}
}
static void asteroid_render(Entity *self, const Camera *cam) {
(void)cam;
AsteroidData *ad = (AsteroidData *)self->data;
if (!ad || !ad->falling) return;
if (!g_spritesheet || !self->anim.def) return;
SDL_Rect src = animation_current_rect(&self->anim);
Body *body = &self->body;
float draw_x = body->pos.x + body->size.x * 0.5f - SPRITE_CELL * 0.5f;
float draw_y = body->pos.y + body->size.y * 0.5f - SPRITE_CELL * 0.5f;
Sprite spr = {
.texture = g_spritesheet,
.src = src,
.pos = vec2(draw_x, draw_y),
.size = vec2(SPRITE_CELL, SPRITE_CELL),
.flip_x = false,
.flip_y = false,
.layer = LAYER_ENTITIES,
.alpha = 255,
};
renderer_submit(&spr);
}
static void asteroid_destroy(Entity *self) {
free(self->data);
self->data = NULL;
}
void asteroid_register(EntityManager *em) {
entity_register(em, ENT_ASTEROID, asteroid_update, asteroid_render, asteroid_destroy);
s_asteroid_em = em;
if (!s_asteroid_sfx_loaded) {
s_sfx_asteroid_impact = audio_load_sound("assets/sounds/hitHurt.wav");
s_asteroid_sfx_loaded = true;
}
}
Entity *asteroid_spawn(EntityManager *em, Vec2 pos) {
Entity *e = entity_spawn(em, ENT_ASTEROID, pos);
if (!e) return NULL;
e->body.size = vec2(ASTEROID_WIDTH, ASTEROID_HEIGHT);
e->body.gravity_scale = 0.0f; /* we handle movement manually */
e->health = 9999;
e->max_health = 9999;
e->flags |= ENTITY_INVINCIBLE;
e->damage = ASTEROID_DAMAGE;
AsteroidData *ad = calloc(1, sizeof(AsteroidData));
ad->spawn_pos = pos;
ad->falling = true;
ad->fall_speed = ASTEROID_FALL_SPEED;
ad->trail_timer = 0;
ad->respawn_timer = 0;
/* Stagger start times based on spawn position to avoid all falling at once */
ad->start_delay = (pos.x * 0.013f + pos.y * 0.007f);
ad->start_delay = ad->start_delay - (float)(int)ad->start_delay; /* frac part */
ad->start_delay *= 3.0f; /* 0-3s stagger */
e->data = ad;
animation_set(&e->anim, &anim_asteroid);
return e;
}