Files
major_tom/src/game/enemy.c
Thomas 5f899f61c6 Add pixel art sprites for charger, spawner, and laser turret
Replace colored-rect placeholder rendering with proper 16x16
spritesheet art. Charger: orange armored rhino with horn, eyes,
walk cycle (row 7). Spawner: purple hive pod with glowing core
and tendrils (row 8). Laser turret: angular metallic housing
with red lens that brightens when firing (row 8). All three
retain colored-rect fallback when spritesheet is unavailable.
2026-03-02 20:45:21 +00:00

633 lines
20 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) {
animation_set(&self->anim, &anim_charger_death);
animation_update(&self->anim, dt);
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 */
if (cd->state == CHARGER_CHARGE || cd->state == CHARGER_PATROL) {
animation_set(&self->anim, &anim_charger_walk);
} else {
animation_set(&self->anim, &anim_charger_idle);
}
animation_update(&self->anim, dt);
}
static void charger_render(Entity *self, const Camera *cam) {
(void)cam;
Body *body = &self->body;
ChargerData *cd = (ChargerData *)self->data;
if (g_spritesheet && self->anim.def) {
SDL_Rect src = animation_current_rect(&self->anim);
/* Center the 16x16 sprite over the 14x16 hitbox */
Vec2 render_pos = vec2(
body->pos.x - 1.0f,
body->pos.y
);
/* Flash white during alert telegraph */
uint8_t alpha = 255;
if (cd && cd->state == CHARGER_ALERT) {
float blink = sinf(cd->state_timer * 30.0f);
alpha = (blink > 0) ? 180 : 255;
}
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 = alpha,
};
renderer_submit(&spr);
} else {
/* Colored rect fallback */
SDL_Color color;
if (cd && cd->state == CHARGER_ALERT) {
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);
}
}
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) {
animation_set(&self->anim, &anim_spawner_death);
animation_update(&self->anim, dt);
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;
}
/* Animation */
animation_set(&self->anim, &anim_spawner_idle);
animation_update(&self->anim, dt);
/* 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) {
(void)cam;
Body *body = &self->body;
SpawnerData *sd = (SpawnerData *)self->data;
if (g_spritesheet && self->anim.def) {
SDL_Rect src = animation_current_rect(&self->anim);
Vec2 render_pos = vec2(body->pos.x, body->pos.y);
/* Pulse brightness when about to spawn */
uint8_t alpha = 255;
if (sd && sd->pulse_timer > 0) {
float pulse = sinf(sd->pulse_timer * 12.0f);
alpha = (pulse > 0) ? 200 : 255;
}
Sprite spr = {
.texture = g_spritesheet,
.src = src,
.pos = render_pos,
.size = vec2(SPRITE_CELL, SPRITE_CELL),
.flip_x = false,
.flip_y = false,
.layer = LAYER_ENTITIES,
.alpha = alpha,
};
renderer_submit(&spr);
} else {
/* Colored rect fallback */
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);
}
}
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;
}