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

@@ -178,13 +178,39 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
return;
}
/* Fall off bottom of level = instant death */
float level_bottom = (float)(map->height * TILE_SIZE) + 64.0f;
if (self->body.pos.y > level_bottom) {
self->health = 0;
self->flags |= ENTITY_DEAD;
pd->respawn_timer = 0.3f; /* shorter delay for pit death */
return;
/* Fall off bottom of level — lose 1 HP and auto-dash upward */
float level_bottom = (float)(map->height * TILE_SIZE);
if (self->body.pos.y + self->body.size.y > level_bottom &&
!(self->flags & ENTITY_INVINCIBLE)) {
self->health--;
if (self->health <= 0) {
/* Out of HP — die as before */
self->health = 0;
self->flags |= ENTITY_DEAD;
pd->respawn_timer = 0.3f;
return;
}
/* Drain all jetpack charges */
pd->dash_charges = 0;
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
/* Auto-trigger upward dash (ignores charge requirement) */
pd->dash_timer = PLAYER_DASH_DURATION;
pd->dash_dir = vec2(0.0f, -1.0f);
self->body.vel.y = 0;
/* Grant invincibility so this doesn't re-trigger immediately */
pd->inv_timer = PLAYER_INV_TIME;
self->flags |= ENTITY_INVINCIBLE;
/* Effects */
Vec2 burst_pos = vec2(
self->body.pos.x + self->body.size.x * 0.5f,
self->body.pos.y + self->body.size.y
);
particle_emit_jetpack_burst(burst_pos, vec2(0.0f, -1.0f));
audio_play_sound(s_sfx_dash, 96);
}
/* Update invincibility */
@@ -225,14 +251,24 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
/* ── Dash / Jetpack ─────────────────────── */
/* Count down jetpack boost timer */
if (pd->jetpack_boost_timer > 0) {
pd->jetpack_boost_timer -= dt;
if (pd->jetpack_boost_timer < 0)
pd->jetpack_boost_timer = 0;
}
/* Recharge jetpack charges over time */
float recharge_rate = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE
: PLAYER_DASH_RECHARGE;
if (pd->dash_charges < pd->dash_max_charges) {
pd->dash_recharge_timer -= dt;
if (pd->dash_recharge_timer <= 0) {
pd->dash_charges++;
/* Reset timer for next charge (if still not full) */
if (pd->dash_charges < pd->dash_max_charges) {
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
pd->dash_recharge_timer = recharge_rate;
} else {
pd->dash_recharge_timer = 0;
}
@@ -260,7 +296,8 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
pd->dash_charges--;
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
pd->dash_timer = PLAYER_DASH_DURATION;
/* Determine dash direction from input */
@@ -362,7 +399,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
/* ── Shooting ────────────────────────────── */
pd->shoot_cooldown -= dt;
if (input_pressed(ACTION_SHOOT) && pd->shoot_cooldown <= 0 && s_em) {
if (pd->has_gun && input_pressed(ACTION_SHOOT) && pd->shoot_cooldown <= 0 && s_em) {
pd->shoot_cooldown = PLAYER_SHOOT_COOLDOWN;
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
@@ -486,7 +523,7 @@ void player_render(Entity *self, const Camera *cam) {
renderer_submit(&spr);
/* ── Weapon overlay ─────────────────── */
if (s_weapon_tex && !(self->flags & ENTITY_DEAD) && pd) {
if (s_weapon_tex && !(self->flags & ENTITY_DEAD) && pd && pd->has_gun) {
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
/* Anchor gun to the player sprite position (not body pos)
@@ -574,6 +611,7 @@ Entity *player_spawn(EntityManager *em, Vec2 pos) {
e->max_health = 3;
PlayerData *pd = calloc(1, sizeof(PlayerData));
pd->has_gun = true; /* armed by default; moon level overrides */
pd->dash_charges = PLAYER_DASH_MAX_CHARGES;
pd->dash_max_charges = PLAYER_DASH_MAX_CHARGES;
pd->respawn_timer = RESPAWN_DELAY;
@@ -595,21 +633,36 @@ float player_get_look_up_offset(const Entity *self) {
}
bool player_get_dash_charges(const Entity *self, int *charges, int *max_charges,
float *recharge_pct) {
float *recharge_pct, bool *boosted) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
const PlayerData *pd = (const PlayerData *)self->data;
if (charges) *charges = pd->dash_charges;
if (max_charges) *max_charges = pd->dash_max_charges;
if (boosted) *boosted = pd->jetpack_boost_timer > 0;
if (recharge_pct) {
float rate = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
if (pd->dash_charges >= pd->dash_max_charges) {
*recharge_pct = 1.0f;
} else {
*recharge_pct = 1.0f - (pd->dash_recharge_timer / PLAYER_DASH_RECHARGE);
*recharge_pct = 1.0f - (pd->dash_recharge_timer / rate);
}
}
return true;
}
void player_give_gun(Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return;
PlayerData *pd = (PlayerData *)self->data;
pd->has_gun = true;
}
bool player_has_gun(const Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
const PlayerData *pd = (const PlayerData *)self->data;
return pd->has_gun;
}
bool player_wants_respawn(const Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
if (!(self->flags & ENTITY_DEAD)) return false;
@@ -631,7 +684,7 @@ void player_respawn(Entity *self, Vec2 pos) {
pd->inv_timer = PLAYER_INV_TIME;
self->flags |= ENTITY_INVINCIBLE;
/* Reset player-specific state */
/* Reset player-specific state (preserve has_gun across respawn) */
pd->coyote_timer = 0;
pd->jump_buffer_timer = 0;
pd->jumping = false;
@@ -640,6 +693,7 @@ void player_respawn(Entity *self, Vec2 pos) {
pd->dash_timer = 0;
pd->dash_charges = pd->dash_max_charges;
pd->dash_recharge_timer = 0;
pd->jetpack_boost_timer = 0;
pd->aim_dir = AIM_FORWARD;
pd->looking_up = false;
pd->look_up_timer = 0;