Implement four feature phases: Phase 1 - Pause menu: extract bitmap font into shared engine/font module, add MODE_PAUSED with Resume/Restart/Quit overlay. Phase 2 - Laser turret hazard: ENT_LASER_TURRET with charge/fire/ cooldown state machine, per-pixel beam raycast, two variants (fixed and tracking). Registered in entity registry with editor icons. Phase 3 - Charger and Spawner enemies: charger ground patrol with detect/telegraph/charge/stun cycle (2s charge timeout), spawner that periodically creates grunts up to a global cap of 3. Phase 4 - Mars campaign: two handcrafted levels (mars01 surface, mars02 base), mars_tileset.png, PARALLAX_STYLE_MARS with salmon sky and red mesas, THEME_MARS_SURFACE/THEME_MARS_BASE for the procedural generator with per-theme gravity/tileset/parallax. Moon campaign now chains moon03 -> mars01 -> mars02 -> victory. Also fix review findings: deterministic seed on generated level restart, NULL checks on calloc in spawn functions, charge timeout to prevent infinite charge on flat terrain, and stop suppressing stderr in Makefile web-serve target so real errors are visible.
585 lines
19 KiB
C
585 lines
19 KiB
C
#include "game/enemy.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>
|
|
|
|
/* ════════════════════════════════════════════════════
|
|
* GRUNT - ground patrol enemy
|
|
* ════════════════════════════════════════════════════ */
|
|
|
|
static EntityManager *s_grunt_em = NULL;
|
|
|
|
static void grunt_update(Entity *self, float dt, const Tilemap *map) {
|
|
GruntData *gd = (GruntData *)self->data;
|
|
if (!gd) return;
|
|
|
|
Body *body = &self->body;
|
|
|
|
/* Death sequence */
|
|
if (self->flags & ENTITY_DEAD) {
|
|
animation_set(&self->anim, &anim_grunt_death);
|
|
animation_update(&self->anim, dt);
|
|
gd->death_timer -= dt;
|
|
body->vel.x = 0;
|
|
if (gd->death_timer <= 0) {
|
|
entity_destroy(s_grunt_em, self);
|
|
}
|
|
return;
|
|
}
|
|
|
|
/* Patrol: walk in one direction, reverse when hitting a wall
|
|
or about to walk off a ledge */
|
|
body->vel.x = gd->patrol_dir * GRUNT_SPEED;
|
|
|
|
/* Set facing direction */
|
|
if (gd->patrol_dir < 0) self->flags |= ENTITY_FACING_LEFT;
|
|
else self->flags &= ~ENTITY_FACING_LEFT;
|
|
|
|
/* Physics */
|
|
physics_update(body, dt, map);
|
|
|
|
/* Turn around on wall collision */
|
|
if (body->on_wall_left || body->on_wall_right) {
|
|
gd->patrol_dir = -gd->patrol_dir;
|
|
}
|
|
|
|
/* Turn around at ledge: check if there's ground ahead */
|
|
if (body->on_ground) {
|
|
float check_x = (gd->patrol_dir > 0) ?
|
|
body->pos.x + body->size.x + 2.0f :
|
|
body->pos.x - 2.0f;
|
|
float check_y = body->pos.y + body->size.y + 4.0f;
|
|
|
|
int tx = world_to_tile(check_x);
|
|
int ty = world_to_tile(check_y);
|
|
|
|
if (!tilemap_is_solid(map, tx, ty)) {
|
|
gd->patrol_dir = -gd->patrol_dir;
|
|
}
|
|
}
|
|
|
|
/* Animation */
|
|
if (fabsf(body->vel.x) > 1.0f) {
|
|
animation_set(&self->anim, &anim_grunt_walk);
|
|
} else {
|
|
animation_set(&self->anim, &anim_grunt_idle);
|
|
}
|
|
animation_update(&self->anim, dt);
|
|
}
|
|
|
|
static void grunt_render(Entity *self, const Camera *cam) {
|
|
(void)cam;
|
|
Body *body = &self->body;
|
|
|
|
if (g_spritesheet && self->anim.def) {
|
|
SDL_Rect src = animation_current_rect(&self->anim);
|
|
|
|
Vec2 render_pos = vec2(
|
|
body->pos.x - 2.0f,
|
|
body->pos.y
|
|
);
|
|
|
|
Sprite spr = {
|
|
.texture = g_spritesheet,
|
|
.src = src,
|
|
.pos = render_pos,
|
|
.size = vec2(SPRITE_CELL, SPRITE_CELL),
|
|
.flip_x = (self->flags & ENTITY_FACING_LEFT) != 0,
|
|
.flip_y = false,
|
|
.layer = LAYER_ENTITIES,
|
|
.alpha = 255,
|
|
};
|
|
renderer_submit(&spr);
|
|
} else {
|
|
SDL_Color color = {200, 60, 60, 255};
|
|
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
|
|
}
|
|
}
|
|
|
|
static void grunt_destroy(Entity *self) {
|
|
free(self->data);
|
|
self->data = NULL;
|
|
}
|
|
|
|
void grunt_register(EntityManager *em) {
|
|
entity_register(em, ENT_ENEMY_GRUNT, grunt_update, grunt_render, grunt_destroy);
|
|
s_grunt_em = em;
|
|
}
|
|
|
|
Entity *grunt_spawn(EntityManager *em, Vec2 pos) {
|
|
Entity *e = entity_spawn(em, ENT_ENEMY_GRUNT, pos);
|
|
if (!e) return NULL;
|
|
|
|
e->body.size = vec2(GRUNT_WIDTH, GRUNT_HEIGHT);
|
|
e->body.gravity_scale = 1.0f;
|
|
e->health = GRUNT_HEALTH;
|
|
e->max_health = GRUNT_HEALTH;
|
|
e->damage = 1;
|
|
|
|
GruntData *gd = calloc(1, sizeof(GruntData));
|
|
gd->patrol_dir = 1.0f;
|
|
gd->death_timer = 0.3f;
|
|
e->data = gd;
|
|
|
|
return e;
|
|
}
|
|
|
|
|
|
/* ════════════════════════════════════════════════════
|
|
* FLYER - flying enemy
|
|
* ════════════════════════════════════════════════════ */
|
|
|
|
static EntityManager *s_flyer_em = NULL;
|
|
|
|
/* Helper: find the player entity in the manager */
|
|
static Entity *find_player(EntityManager *em) {
|
|
for (int i = 0; i < em->count; i++) {
|
|
Entity *e = &em->entities[i];
|
|
if (e->active && e->type == ENT_PLAYER) return e;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static void flyer_update(Entity *self, float dt, const Tilemap *map) {
|
|
(void)map; /* flyers don't collide with tiles */
|
|
FlyerData *fd = (FlyerData *)self->data;
|
|
if (!fd) return;
|
|
|
|
Body *body = &self->body;
|
|
|
|
/* Death sequence */
|
|
if (self->flags & ENTITY_DEAD) {
|
|
animation_set(&self->anim, &anim_flyer_death);
|
|
animation_update(&self->anim, dt);
|
|
/* Fall when dead */
|
|
body->vel.y += physics_get_gravity() * dt;
|
|
if (body->vel.y > MAX_FALL_SPEED) body->vel.y = MAX_FALL_SPEED;
|
|
body->pos.y += body->vel.y * dt;
|
|
fd->death_timer -= dt;
|
|
if (fd->death_timer <= 0) {
|
|
entity_destroy(s_flyer_em, self);
|
|
}
|
|
return;
|
|
}
|
|
|
|
/* Bob up and down */
|
|
fd->bob_timer += dt;
|
|
float bob_offset = sinf(fd->bob_timer * FLYER_BOB_SPD) * FLYER_BOB_AMP;
|
|
body->pos.y = fd->base_y + bob_offset;
|
|
|
|
/* Wind drifts flyers (they bypass physics_update).
|
|
* Apply as gentle position offset matching first-frame physics. */
|
|
float wind = physics_get_wind();
|
|
if (wind != 0.0f) {
|
|
body->pos.x += wind * dt * dt;
|
|
}
|
|
|
|
/* Chase player if in range */
|
|
Entity *player = find_player(s_flyer_em);
|
|
if (player && player->active && !(player->flags & ENTITY_DEAD)) {
|
|
float px = player->body.pos.x + player->body.size.x * 0.5f;
|
|
float fx = body->pos.x + body->size.x * 0.5f;
|
|
float dist = px - fx;
|
|
|
|
if (fabsf(dist) < FLYER_DETECT) {
|
|
/* Move toward player */
|
|
if (dist < -2.0f) {
|
|
body->pos.x -= FLYER_SPEED * dt;
|
|
self->flags |= ENTITY_FACING_LEFT;
|
|
} else if (dist > 2.0f) {
|
|
body->pos.x += FLYER_SPEED * dt;
|
|
self->flags &= ~ENTITY_FACING_LEFT;
|
|
}
|
|
|
|
/* Shoot at player */
|
|
fd->shoot_timer -= dt;
|
|
if (fd->shoot_timer <= 0 && s_flyer_em) {
|
|
fd->shoot_timer = FLYER_SHOOT_CD;
|
|
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
|
|
float bx = facing_left ?
|
|
body->pos.x - 4.0f :
|
|
body->pos.x + body->size.x;
|
|
float by = body->pos.y + body->size.y * 0.4f;
|
|
projectile_spawn(s_flyer_em, vec2(bx, by), facing_left, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Animation */
|
|
animation_set(&self->anim, &anim_flyer_fly);
|
|
animation_update(&self->anim, dt);
|
|
}
|
|
|
|
static void flyer_render(Entity *self, const Camera *cam) {
|
|
(void)cam;
|
|
Body *body = &self->body;
|
|
|
|
if (g_spritesheet && self->anim.def) {
|
|
SDL_Rect src = animation_current_rect(&self->anim);
|
|
|
|
Vec2 render_pos = vec2(
|
|
body->pos.x - 1.0f,
|
|
body->pos.y - 2.0f
|
|
);
|
|
|
|
Sprite spr = {
|
|
.texture = g_spritesheet,
|
|
.src = src,
|
|
.pos = render_pos,
|
|
.size = vec2(SPRITE_CELL, SPRITE_CELL),
|
|
.flip_x = (self->flags & ENTITY_FACING_LEFT) != 0,
|
|
.flip_y = false,
|
|
.layer = LAYER_ENTITIES,
|
|
.alpha = 255,
|
|
};
|
|
renderer_submit(&spr);
|
|
} else {
|
|
SDL_Color color = {150, 50, 180, 255};
|
|
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
|
|
}
|
|
}
|
|
|
|
static void flyer_destroy(Entity *self) {
|
|
free(self->data);
|
|
self->data = NULL;
|
|
}
|
|
|
|
void flyer_register(EntityManager *em) {
|
|
entity_register(em, ENT_ENEMY_FLYER, flyer_update, flyer_render, flyer_destroy);
|
|
s_flyer_em = em;
|
|
}
|
|
|
|
Entity *flyer_spawn(EntityManager *em, Vec2 pos) {
|
|
Entity *e = entity_spawn(em, ENT_ENEMY_FLYER, pos);
|
|
if (!e) return NULL;
|
|
|
|
e->body.size = vec2(FLYER_WIDTH, FLYER_HEIGHT);
|
|
e->body.gravity_scale = 0.0f; /* flyers don't fall */
|
|
e->health = FLYER_HEALTH;
|
|
e->max_health = FLYER_HEALTH;
|
|
e->damage = 1;
|
|
|
|
FlyerData *fd = calloc(1, sizeof(FlyerData));
|
|
fd->base_y = pos.y;
|
|
fd->death_timer = 0.5f;
|
|
fd->shoot_timer = FLYER_SHOOT_CD;
|
|
e->data = fd;
|
|
|
|
return e;
|
|
}
|
|
|
|
|
|
/* ════════════════════════════════════════════════════
|
|
* CHARGER — detects player, telegraphs, then rushes
|
|
* ════════════════════════════════════════════════════ */
|
|
|
|
#define CHARGER_ALERT_TIME 0.5f /* telegraph before charge */
|
|
#define CHARGER_STUN_TIME 0.8f /* stun duration on wall */
|
|
|
|
static EntityManager *s_charger_em = NULL;
|
|
|
|
static void charger_update(Entity *self, float dt, const Tilemap *map) {
|
|
ChargerData *cd = (ChargerData *)self->data;
|
|
if (!cd) return;
|
|
|
|
Body *body = &self->body;
|
|
|
|
/* Death sequence */
|
|
if (self->flags & ENTITY_DEAD) {
|
|
cd->death_timer -= dt;
|
|
body->vel.x = 0;
|
|
if (cd->death_timer <= 0) {
|
|
particle_emit_death_puff(body->pos, (SDL_Color){220, 120, 40, 255});
|
|
entity_destroy(s_charger_em, self);
|
|
}
|
|
return;
|
|
}
|
|
|
|
/* State machine */
|
|
switch (cd->state) {
|
|
case CHARGER_PATROL: {
|
|
body->vel.x = cd->patrol_dir * CHARGER_PATROL_SPEED;
|
|
|
|
/* Face walk direction */
|
|
if (cd->patrol_dir < 0) self->flags |= ENTITY_FACING_LEFT;
|
|
else self->flags &= ~ENTITY_FACING_LEFT;
|
|
|
|
physics_update(body, dt, map);
|
|
|
|
/* Reverse at walls */
|
|
if (body->on_wall_left || body->on_wall_right) {
|
|
cd->patrol_dir = -cd->patrol_dir;
|
|
}
|
|
|
|
/* Reverse at ledge */
|
|
if (body->on_ground) {
|
|
float cx = (cd->patrol_dir > 0) ?
|
|
body->pos.x + body->size.x + 2.0f :
|
|
body->pos.x - 2.0f;
|
|
float cy = body->pos.y + body->size.y + 4.0f;
|
|
if (!tilemap_is_solid(map, world_to_tile(cx), world_to_tile(cy))) {
|
|
cd->patrol_dir = -cd->patrol_dir;
|
|
}
|
|
}
|
|
|
|
/* Detect player — horizontal line-of-sight */
|
|
Entity *player = find_player(s_charger_em);
|
|
if (player && player->active && !(player->flags & ENTITY_DEAD)) {
|
|
float px = player->body.pos.x + player->body.size.x * 0.5f;
|
|
float mx = body->pos.x + body->size.x * 0.5f;
|
|
float dy = fabsf(player->body.pos.y - body->pos.y);
|
|
float dx = px - mx;
|
|
|
|
/* Must be roughly same height and within range */
|
|
if (dy < body->size.y * 1.5f && fabsf(dx) < CHARGER_DETECT_RANGE) {
|
|
cd->state = CHARGER_ALERT;
|
|
cd->state_timer = CHARGER_ALERT_TIME;
|
|
/* Face the player */
|
|
cd->patrol_dir = (dx > 0) ? 1.0f : -1.0f;
|
|
if (cd->patrol_dir < 0) self->flags |= ENTITY_FACING_LEFT;
|
|
else self->flags &= ~ENTITY_FACING_LEFT;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case CHARGER_ALERT: {
|
|
/* Freeze in place during telegraph */
|
|
body->vel.x = 0;
|
|
physics_update(body, dt, map);
|
|
cd->state_timer -= dt;
|
|
if (cd->state_timer <= 0) {
|
|
cd->state = CHARGER_CHARGE;
|
|
cd->state_timer = CHARGER_CHARGE_TIME;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case CHARGER_CHARGE: {
|
|
body->vel.x = cd->patrol_dir * CHARGER_CHARGE_SPEED;
|
|
physics_update(body, dt, map);
|
|
cd->state_timer -= dt;
|
|
|
|
/* Hit a wall -> stunned */
|
|
if (body->on_wall_left || body->on_wall_right) {
|
|
cd->state = CHARGER_STUNNED;
|
|
cd->state_timer = CHARGER_STUN_TIME;
|
|
body->vel.x = 0;
|
|
particle_emit_spark(body->pos, (SDL_Color){255, 200, 80, 255});
|
|
}
|
|
|
|
/* Ran off a ledge or charge timed out -> back to patrol */
|
|
if (!body->on_ground || cd->state_timer <= 0) {
|
|
cd->state = CHARGER_PATROL;
|
|
body->vel.x = 0;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case CHARGER_STUNNED: {
|
|
body->vel.x = 0;
|
|
physics_update(body, dt, map);
|
|
cd->state_timer -= dt;
|
|
if (cd->state_timer <= 0) {
|
|
cd->state = CHARGER_PATROL;
|
|
/* Reverse direction after stun */
|
|
cd->patrol_dir = -cd->patrol_dir;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* Animation — use grunt anims as placeholder */
|
|
if (cd->state == CHARGER_CHARGE) {
|
|
animation_set(&self->anim, &anim_grunt_walk);
|
|
} else {
|
|
animation_set(&self->anim, &anim_grunt_idle);
|
|
}
|
|
animation_update(&self->anim, dt);
|
|
}
|
|
|
|
static void charger_render(Entity *self, const Camera *cam) {
|
|
Body *body = &self->body;
|
|
ChargerData *cd = (ChargerData *)self->data;
|
|
|
|
/* Colored rect fallback — orange/amber color scheme */
|
|
SDL_Color color;
|
|
if (cd && cd->state == CHARGER_ALERT) {
|
|
/* Flash during telegraph */
|
|
float blink = sinf(cd->state_timer * 30.0f);
|
|
uint8_t r = (blink > 0) ? 255 : 200;
|
|
color = (SDL_Color){r, 160, 40, 255};
|
|
} else if (cd && cd->state == CHARGER_STUNNED) {
|
|
color = (SDL_Color){160, 160, 100, 255};
|
|
} else if (cd && cd->state == CHARGER_CHARGE) {
|
|
color = (SDL_Color){255, 120, 30, 255};
|
|
} else {
|
|
color = (SDL_Color){220, 140, 40, 255};
|
|
}
|
|
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
|
|
|
|
/* Direction indicator: small triangle in facing direction */
|
|
float cx = body->pos.x + body->size.x * 0.5f;
|
|
float cy = body->pos.y + 2.0f;
|
|
bool left = (self->flags & ENTITY_FACING_LEFT) != 0;
|
|
float arrow_x = left ? body->pos.x - 3.0f : body->pos.x + body->size.x + 1.0f;
|
|
renderer_draw_rect(vec2(arrow_x, cy), vec2(2, 3),
|
|
(SDL_Color){255, 200, 60, 255}, LAYER_ENTITIES, cam);
|
|
(void)cx;
|
|
}
|
|
|
|
static void charger_destroy(Entity *self) {
|
|
free(self->data);
|
|
self->data = NULL;
|
|
}
|
|
|
|
void charger_register(EntityManager *em) {
|
|
entity_register(em, ENT_ENEMY_CHARGER,
|
|
charger_update, charger_render, charger_destroy);
|
|
s_charger_em = em;
|
|
}
|
|
|
|
Entity *charger_spawn(EntityManager *em, Vec2 pos) {
|
|
Entity *e = entity_spawn(em, ENT_ENEMY_CHARGER, pos);
|
|
if (!e) return NULL;
|
|
|
|
e->body.size = vec2(CHARGER_WIDTH, CHARGER_HEIGHT);
|
|
e->body.gravity_scale = 1.0f;
|
|
e->health = CHARGER_HEALTH;
|
|
e->max_health = CHARGER_HEALTH;
|
|
e->damage = 1;
|
|
|
|
ChargerData *cd = calloc(1, sizeof(ChargerData));
|
|
if (!cd) { entity_destroy(em, e); return NULL; }
|
|
cd->state = CHARGER_PATROL;
|
|
cd->patrol_dir = 1.0f;
|
|
cd->state_timer = 0;
|
|
cd->death_timer = 0.3f;
|
|
e->data = cd;
|
|
|
|
return e;
|
|
}
|
|
|
|
|
|
/* ════════════════════════════════════════════════════
|
|
* SPAWNER — stationary, periodically spawns grunts
|
|
* ════════════════════════════════════════════════════ */
|
|
|
|
#define SPAWNER_PULSE_TIME 1.0f /* pulse warning before spawn */
|
|
|
|
static EntityManager *s_spawner_em = NULL;
|
|
|
|
/* Count how many grunts are currently alive. */
|
|
static int count_alive_grunts(EntityManager *em) {
|
|
int count = 0;
|
|
for (int i = 0; i < em->count; i++) {
|
|
Entity *e = &em->entities[i];
|
|
if (e->active && e->type == ENT_ENEMY_GRUNT &&
|
|
!(e->flags & ENTITY_DEAD)) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
static void spawner_update(Entity *self, float dt, const Tilemap *map) {
|
|
(void)map;
|
|
SpawnerData *sd = (SpawnerData *)self->data;
|
|
if (!sd) return;
|
|
|
|
/* Death sequence */
|
|
if (self->flags & ENTITY_DEAD) {
|
|
sd->death_timer -= dt;
|
|
if (sd->death_timer <= 0) {
|
|
particle_emit_death_puff(self->body.pos, (SDL_Color){180, 60, 180, 255});
|
|
entity_destroy(s_spawner_em, self);
|
|
}
|
|
return;
|
|
}
|
|
|
|
sd->spawn_timer -= dt;
|
|
|
|
/* Pulse warning when about to spawn */
|
|
if (sd->spawn_timer < SPAWNER_PULSE_TIME && sd->spawn_timer > 0) {
|
|
sd->pulse_timer += dt;
|
|
} else {
|
|
sd->pulse_timer = 0;
|
|
}
|
|
|
|
/* Spawn a grunt when timer expires */
|
|
if (sd->spawn_timer <= 0) {
|
|
sd->spawn_timer = SPAWNER_INTERVAL;
|
|
|
|
if (count_alive_grunts(s_spawner_em) < SPAWNER_MAX_ALIVE) {
|
|
/* Spawn grunt just below the spawner */
|
|
Vec2 spawn_pos = vec2(
|
|
self->body.pos.x,
|
|
self->body.pos.y + self->body.size.y + 2.0f
|
|
);
|
|
Entity *grunt = grunt_spawn(s_spawner_em, spawn_pos);
|
|
if (grunt) {
|
|
particle_emit_spark(spawn_pos, (SDL_Color){180, 100, 220, 255});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static void spawner_render(Entity *self, const Camera *cam) {
|
|
Body *body = &self->body;
|
|
SpawnerData *sd = (SpawnerData *)self->data;
|
|
|
|
/* Pulsing purple color when about to spawn */
|
|
SDL_Color color;
|
|
if (sd && sd->pulse_timer > 0) {
|
|
float pulse = sinf(sd->pulse_timer * 12.0f) * 0.5f + 0.5f;
|
|
uint8_t r = (uint8_t)(140 + 80 * pulse);
|
|
uint8_t b = (uint8_t)(180 + 60 * pulse);
|
|
color = (SDL_Color){r, 50, b, 255};
|
|
} else {
|
|
color = (SDL_Color){140, 50, 160, 255};
|
|
}
|
|
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
|
|
|
|
/* Small inner dot to distinguish from other hazards */
|
|
float cx = body->pos.x + body->size.x * 0.5f - 2.0f;
|
|
float cy = body->pos.y + body->size.y * 0.5f - 2.0f;
|
|
SDL_Color dot = {255, 200, 255, 255};
|
|
renderer_draw_rect(vec2(cx, cy), vec2(4, 4), dot, LAYER_ENTITIES, cam);
|
|
}
|
|
|
|
static void spawner_destroy(Entity *self) {
|
|
free(self->data);
|
|
self->data = NULL;
|
|
}
|
|
|
|
void spawner_register(EntityManager *em) {
|
|
entity_register(em, ENT_SPAWNER,
|
|
spawner_update, spawner_render, spawner_destroy);
|
|
s_spawner_em = em;
|
|
}
|
|
|
|
Entity *spawner_spawn(EntityManager *em, Vec2 pos) {
|
|
Entity *e = entity_spawn(em, ENT_SPAWNER, pos);
|
|
if (!e) return NULL;
|
|
|
|
e->body.size = vec2(SPAWNER_WIDTH, SPAWNER_HEIGHT);
|
|
e->body.gravity_scale = 0.0f; /* stationary */
|
|
e->health = SPAWNER_HEALTH;
|
|
e->max_health = SPAWNER_HEALTH;
|
|
e->damage = 0; /* no contact damage, not invincible */
|
|
|
|
SpawnerData *sd = calloc(1, sizeof(SpawnerData));
|
|
if (!sd) { entity_destroy(em, e); return NULL; }
|
|
sd->spawn_timer = SPAWNER_INTERVAL;
|
|
sd->death_timer = 0.4f;
|
|
sd->pulse_timer = 0;
|
|
e->data = sd;
|
|
|
|
return e;
|
|
}
|