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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user