#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 #include /* ════════════════════════════════════════════════════ * 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; }