#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 #include /* ════════════════════════════════════════════════════ * Shared helpers * ════════════════════════════════════════════════════ */ /* 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; } /* ════════════════════════════════════════════════════ * TURRET — floor/wall-mounted gun, shoots at player * ════════════════════════════════════════════════════ */ static EntityManager *s_turret_em = NULL; static void turret_update(Entity *self, float dt, const Tilemap *map) { (void)map; TurretData *td = (TurretData *)self->data; if (!td) return; /* Death: turrets can be destroyed */ if (self->flags & ENTITY_DEAD) { self->timer -= dt; if (self->timer <= 0) { entity_destroy(s_turret_em, self); } return; } /* Flash timer for muzzle flash visual */ if (td->fire_flash > 0) { td->fire_flash -= dt; } /* Look for player */ Entity *player = find_player(s_turret_em); if (!player || !player->active || (player->flags & ENTITY_DEAD)) { td->shoot_timer = TURRET_SHOOT_CD; return; } /* Distance check */ float px = player->body.pos.x + player->body.size.x * 0.5f; float py = player->body.pos.y + player->body.size.y * 0.5f; float tx = self->body.pos.x + self->body.size.x * 0.5f; float ty = self->body.pos.y + self->body.size.y * 0.5f; float dx = px - tx; float dy = py - ty; float dist = sqrtf(dx * dx + dy * dy); /* Face toward player */ if (dx < 0) self->flags |= ENTITY_FACING_LEFT; else self->flags &= ~ENTITY_FACING_LEFT; if (dist < TURRET_DETECT_RANGE) { td->shoot_timer -= dt; if (td->shoot_timer <= 0 && s_turret_em) { td->shoot_timer = TURRET_SHOOT_CD; td->fire_flash = 0.15f; /* Aim direction: normalize vector to player */ Vec2 dir; if (dist > 0.01f) { dir = vec2(dx / dist, dy / dist); } else { dir = vec2(1.0f, 0.0f); } /* Spawn projectile from turret barrel */ bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0; float bx = facing_left ? self->body.pos.x - 4.0f : self->body.pos.x + self->body.size.x; float by = self->body.pos.y + self->body.size.y * 0.3f; projectile_spawn_dir(s_turret_em, vec2(bx, by), dir, false); } } else { /* Reset cooldown when player leaves range */ if (td->shoot_timer < TURRET_SHOOT_CD * 0.5f) td->shoot_timer = TURRET_SHOOT_CD * 0.5f; } /* Animation */ if (td->fire_flash > 0) { animation_set(&self->anim, &anim_turret_fire); } else { animation_set(&self->anim, &anim_turret_idle); } animation_update(&self->anim, dt); } static void turret_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 ); 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 = {130, 130, 130, 255}; renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); } } static void turret_destroy(Entity *self) { free(self->data); self->data = NULL; } void turret_register(EntityManager *em) { entity_register(em, ENT_TURRET, turret_update, turret_render, turret_destroy); s_turret_em = em; } Entity *turret_spawn(EntityManager *em, Vec2 pos) { Entity *e = entity_spawn(em, ENT_TURRET, pos); if (!e) return NULL; e->body.size = vec2(TURRET_WIDTH, TURRET_HEIGHT); e->body.gravity_scale = 0.0f; /* turrets are stationary */ e->health = TURRET_HEALTH; e->max_health = TURRET_HEALTH; e->damage = 1; /* light contact damage — turret is a hostile machine */ TurretData *td = calloc(1, sizeof(TurretData)); td->shoot_timer = TURRET_SHOOT_CD; td->fire_flash = 0.0f; e->data = td; /* Start with death timer if needed */ e->timer = 0.3f; animation_set(&e->anim, &anim_turret_idle); return e; } /* ════════════════════════════════════════════════════ * MOVING PLATFORM — travels back and forth, * player rides on top * ════════════════════════════════════════════════════ */ static EntityManager *s_mplat_em = NULL; static void mplat_update(Entity *self, float dt, const Tilemap *map) { (void)map; MovingPlatData *md = (MovingPlatData *)self->data; if (!md) return; /* Advance phase (sine oscillation) */ float angular_speed = (MPLAT_SPEED / md->range) * 1.0f; md->phase += angular_speed * dt; if (md->phase > 2.0f * 3.14159265f) md->phase -= 2.0f * 3.14159265f; /* Calculate new position */ float offset = sinf(md->phase) * md->range; Vec2 new_pos = vec2( md->origin.x + md->direction.x * offset, md->origin.y + md->direction.y * offset ); /* Store velocity for player riding (carry velocity) */ self->body.vel.x = (new_pos.x - self->body.pos.x) / dt; self->body.vel.y = (new_pos.y - self->body.pos.y) / dt; self->body.pos = new_pos; /* Check if player is standing on the platform */ Entity *player = find_player(s_mplat_em); if (player && player->active && !(player->flags & ENTITY_DEAD)) { Body *pb = &player->body; Body *mb = &self->body; /* Player's feet must be at the platform top (within 3px) */ float player_bottom = pb->pos.y + pb->size.y; float plat_top = mb->pos.y; bool horizontally_on = (pb->pos.x + pb->size.x > mb->pos.x + 1.0f) && (pb->pos.x < mb->pos.x + mb->size.x - 1.0f); bool vertically_close = (player_bottom >= plat_top - 3.0f) && (player_bottom <= plat_top + 4.0f); bool falling_or_standing = pb->vel.y >= -1.0f; if (horizontally_on && vertically_close && falling_or_standing) { /* Carry the player */ pb->pos.x += self->body.vel.x * dt; pb->pos.y = plat_top - pb->size.y; pb->on_ground = true; if (pb->vel.y > 0) pb->vel.y = 0; } } } static void mplat_render(Entity *self, const Camera *cam) { (void)cam; Body *body = &self->body; if (g_spritesheet) { SDL_Rect src = animation_current_rect(&self->anim); Sprite spr = { .texture = g_spritesheet, .src = src, .pos = body->pos, .size = vec2(SPRITE_CELL, SPRITE_CELL), .flip_x = false, .flip_y = false, .layer = LAYER_ENTITIES, .alpha = 255, }; renderer_submit(&spr); } else { SDL_Color color = {60, 110, 165, 255}; renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); } } static void mplat_destroy(Entity *self) { free(self->data); self->data = NULL; } void mplat_register(EntityManager *em) { entity_register(em, ENT_MOVING_PLATFORM, mplat_update, mplat_render, mplat_destroy); s_mplat_em = em; } Entity *mplat_spawn(EntityManager *em, Vec2 pos) { return mplat_spawn_dir(em, pos, vec2(1.0f, 0.0f)); /* default horizontal */ } Entity *mplat_spawn_dir(EntityManager *em, Vec2 pos, Vec2 dir) { Entity *e = entity_spawn(em, ENT_MOVING_PLATFORM, pos); if (!e) return NULL; e->body.size = vec2(MPLAT_WIDTH, MPLAT_HEIGHT); e->body.gravity_scale = 0.0f; e->health = 9999; /* indestructible */ e->max_health = 9999; e->flags |= ENTITY_INVINCIBLE; e->damage = 0; MovingPlatData *md = calloc(1, sizeof(MovingPlatData)); md->origin = pos; md->direction = dir; md->range = MPLAT_RANGE; md->phase = 0.0f; e->data = md; animation_set(&e->anim, &anim_platform); return e; } /* ════════════════════════════════════════════════════ * FLAME VENT — floor grate with timed blue flames * ════════════════════════════════════════════════════ */ static EntityManager *s_flame_em = NULL; /* Damage cooldown tracking: use entity timer field */ #define FLAME_DAMAGE_CD 0.5f static void flame_vent_update(Entity *self, float dt, const Tilemap *map) { (void)map; FlameVentData *fd = (FlameVentData *)self->data; if (!fd) return; /* Tick the phase timer */ fd->timer -= dt; if (fd->timer <= 0) { fd->active = !fd->active; fd->timer = fd->active ? FLAME_ON_TIME : FLAME_OFF_TIME; } /* Warning flicker: 0.3s before flames ignite */ fd->warn_time = 0.0f; if (!fd->active && fd->timer < 0.3f) { fd->warn_time = fd->timer; } /* Animation */ if (fd->active) { animation_set(&self->anim, &anim_flame_vent_active); } else { animation_set(&self->anim, &anim_flame_vent_idle); } animation_update(&self->anim, dt); /* Damage player when flames are active */ if (fd->active) { /* Damage cooldown */ if (self->timer > 0) { self->timer -= dt; } Entity *player = find_player(s_flame_em); if (player && player->active && !(player->flags & ENTITY_DEAD) && !(player->flags & ENTITY_INVINCIBLE) && self->timer <= 0) { /* Check overlap with flame area (the full sprite, not just base) */ if (physics_overlap(&self->body, &player->body)) { player->health -= FLAME_DAMAGE; if (player->health <= 0) player->flags |= ENTITY_DEAD; self->timer = FLAME_DAMAGE_CD; /* Blue flame hit particles */ Vec2 hit_center = vec2( player->body.pos.x + player->body.size.x * 0.5f, player->body.pos.y + player->body.size.y * 0.5f ); SDL_Color flame_col = {68, 136, 255, 255}; particle_emit_spark(hit_center, flame_col); } } /* Emit ambient flame particles */ if ((int)(fd->timer * 10.0f) % 3 == 0) { Vec2 origin = vec2( self->body.pos.x + self->body.size.x * 0.5f, self->body.pos.y + 2.0f ); ParticleBurst burst = { .origin = origin, .count = 2, .speed_min = 20.0f, .speed_max = 50.0f, .life_min = 0.1f, .life_max = 0.3f, .size_min = 1.0f, .size_max = 2.5f, .spread = 0.4f, .direction = -1.5708f, /* upward */ .drag = 0.0f, .gravity_scale = -0.3f, /* float upward */ .color = {100, 150, 255, 255}, .color_vary = true, }; particle_emit(&burst); } } } static void flame_vent_render(Entity *self, const Camera *cam) { (void)cam; FlameVentData *fd = (FlameVentData *)self->data; Body *body = &self->body; if (g_spritesheet && self->anim.def) { SDL_Rect src = animation_current_rect(&self->anim); Sprite spr = { .texture = g_spritesheet, .src = src, .pos = body->pos, .size = vec2(SPRITE_CELL, SPRITE_CELL), .flip_x = false, .flip_y = false, .layer = LAYER_ENTITIES, .alpha = 255, }; /* Warning flicker: blink the sprite when about to ignite */ if (fd && fd->warn_time > 0) { int blink = (int)(fd->warn_time * 20.0f) % 2; spr.alpha = blink ? 180 : 255; } renderer_submit(&spr); } else { SDL_Color color = fd && fd->active ? (SDL_Color){68, 136, 255, 255} : (SDL_Color){80, 80, 80, 255}; renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); } } static void flame_vent_destroy(Entity *self) { free(self->data); self->data = NULL; } void flame_vent_register(EntityManager *em) { entity_register(em, ENT_FLAME_VENT, flame_vent_update, flame_vent_render, flame_vent_destroy); s_flame_em = em; } Entity *flame_vent_spawn(EntityManager *em, Vec2 pos) { Entity *e = entity_spawn(em, ENT_FLAME_VENT, pos); if (!e) return NULL; e->body.size = vec2(FLAME_WIDTH, FLAME_HEIGHT); e->body.gravity_scale = 0.0f; e->health = 9999; e->max_health = 9999; e->flags |= ENTITY_INVINCIBLE; e->damage = FLAME_DAMAGE; FlameVentData *fd = calloc(1, sizeof(FlameVentData)); fd->active = false; fd->timer = FLAME_OFF_TIME; fd->warn_time = 0.0f; e->data = fd; e->timer = 0.0f; /* damage cooldown */ animation_set(&e->anim, &anim_flame_vent_idle); return e; } /* ════════════════════════════════════════════════════ * FORCE FIELD — energy barrier that toggles on/off * ════════════════════════════════════════════════════ */ static EntityManager *s_ffield_em = NULL; static void force_field_update(Entity *self, float dt, const Tilemap *map) { (void)map; ForceFieldData *fd = (ForceFieldData *)self->data; if (!fd) return; /* Tick the phase timer */ fd->timer -= dt; if (fd->timer <= 0) { fd->active = !fd->active; fd->timer = fd->active ? FFIELD_ON_TIME : FFIELD_OFF_TIME; } /* Animation */ if (fd->active) { animation_set(&self->anim, &anim_force_field_on); } else { animation_set(&self->anim, &anim_force_field_off); } animation_update(&self->anim, dt); /* When active: block player and deal damage */ if (fd->active) { Entity *player = find_player(s_ffield_em); if (player && player->active && !(player->flags & ENTITY_DEAD)) { Body *pb = &player->body; Body *fb = &self->body; if (physics_overlap(pb, fb)) { /* Push player out of the force field */ float player_cx = pb->pos.x + pb->size.x * 0.5f; float field_cx = fb->pos.x + fb->size.x * 0.5f; if (player_cx < field_cx) { /* Push left */ pb->pos.x = fb->pos.x - pb->size.x; if (pb->vel.x > 0) pb->vel.x = 0; } else { /* Push right */ pb->pos.x = fb->pos.x + fb->size.x; if (pb->vel.x < 0) pb->vel.x = 0; } /* Damage with cooldown */ if (!(player->flags & ENTITY_INVINCIBLE) && self->timer <= 0) { player->health -= FFIELD_DAMAGE; if (player->health <= 0) player->flags |= ENTITY_DEAD; self->timer = 0.8f; /* damage cooldown */ /* Electric zap particles */ Vec2 zap_pos = vec2( fb->pos.x + fb->size.x * 0.5f, player->body.pos.y + player->body.size.y * 0.5f ); SDL_Color zap_col = {136, 170, 255, 255}; particle_emit_spark(zap_pos, zap_col); } } } /* Emit shimmer particles */ if ((int)(fd->timer * 8.0f) % 2 == 0) { float ry = self->body.pos.y + (float)(rand() % (int)self->body.size.y); Vec2 origin = vec2( self->body.pos.x + self->body.size.x * 0.5f, ry ); ParticleBurst burst = { .origin = origin, .count = 1, .speed_min = 5.0f, .speed_max = 15.0f, .life_min = 0.1f, .life_max = 0.25f, .size_min = 0.5f, .size_max = 1.5f, .spread = 3.14159f, .direction = 0.0f, .drag = 0.5f, .gravity_scale = 0.0f, .color = {136, 170, 255, 200}, .color_vary = true, }; particle_emit(&burst); } } /* Damage cooldown */ if (self->timer > 0) { self->timer -= dt; } } static void force_field_render(Entity *self, const Camera *cam) { (void)cam; ForceFieldData *fd = (ForceFieldData *)self->data; Body *body = &self->body; if (g_spritesheet && self->anim.def) { SDL_Rect src = animation_current_rect(&self->anim); Sprite spr = { .texture = g_spritesheet, .src = src, .pos = body->pos, .size = vec2(SPRITE_CELL, SPRITE_CELL), .flip_x = false, .flip_y = false, .layer = LAYER_ENTITIES, .alpha = fd && fd->active ? 220 : 100, }; renderer_submit(&spr); } else { SDL_Color color = fd && fd->active ? (SDL_Color){100, 130, 255, 220} : (SDL_Color){40, 50, 80, 100}; renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); } } static void force_field_destroy(Entity *self) { free(self->data); self->data = NULL; } void force_field_register(EntityManager *em) { entity_register(em, ENT_FORCE_FIELD, force_field_update, force_field_render, force_field_destroy); s_ffield_em = em; } Entity *force_field_spawn(EntityManager *em, Vec2 pos) { Entity *e = entity_spawn(em, ENT_FORCE_FIELD, pos); if (!e) return NULL; e->body.size = vec2(FFIELD_WIDTH, FFIELD_HEIGHT); e->body.gravity_scale = 0.0f; e->health = 9999; e->max_health = 9999; e->flags |= ENTITY_INVINCIBLE; e->damage = FFIELD_DAMAGE; ForceFieldData *fd = calloc(1, sizeof(ForceFieldData)); fd->active = true; fd->timer = FFIELD_ON_TIME; e->data = fd; e->timer = 0.0f; /* damage cooldown */ animation_set(&e->anim, &anim_force_field_on); 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; }