#include "game/projectile.h" #include "game/sprites.h" #include "engine/physics.h" #include "engine/renderer.h" #include "engine/particle.h" #include #include static EntityManager *s_proj_em = NULL; /* ════════════════════════════════════════════════════ * Built-in weapon definitions * ════════════════════════════════════════════════════ */ const ProjectileDef WEAPON_PLASMA = { .name = "plasma", .speed = 400.0f, .damage = 1, .lifetime = 2.0f, .gravity_scale = 0.0f, .pierce_count = 0, .bounce_count = 0, .homing_strength = 0.0f, .hitbox_w = 8.0f, .hitbox_h = 8.0f, .flags = 0, .anim_fly = NULL, /* set in projectile_register after sprites_init_anims */ .anim_impact = NULL, }; const ProjectileDef WEAPON_SPREAD = { .name = "spread", .speed = 350.0f, .damage = 1, .lifetime = 0.8f, /* short range */ .gravity_scale = 0.0f, .pierce_count = 0, .bounce_count = 0, .homing_strength = 0.0f, .hitbox_w = 6.0f, .hitbox_h = 6.0f, .flags = 0, .anim_fly = NULL, .anim_impact = NULL, }; const ProjectileDef WEAPON_LASER = { .name = "laser", .speed = 600.0f, .damage = 1, .lifetime = 1.5f, .gravity_scale = 0.0f, .pierce_count = 3, /* passes through 3 enemies */ .bounce_count = 0, .homing_strength = 0.0f, .hitbox_w = 10.0f, .hitbox_h = 4.0f, .flags = PROJ_PIERCING, .anim_fly = NULL, .anim_impact = NULL, }; const ProjectileDef WEAPON_ROCKET = { .name = "rocket", .speed = 200.0f, .damage = 3, .lifetime = 3.0f, .gravity_scale = 0.1f, /* slight drop */ .pierce_count = 0, .bounce_count = 0, .homing_strength = 0.0f, .hitbox_w = 10.0f, .hitbox_h = 6.0f, .flags = PROJ_GRAVITY, .anim_fly = NULL, .anim_impact = NULL, }; const ProjectileDef WEAPON_BOUNCE = { .name = "bounce", .speed = 300.0f, .damage = 1, .lifetime = 4.0f, .gravity_scale = 0.5f, .pierce_count = 0, .bounce_count = 3, /* bounces off 3 walls */ .homing_strength = 0.0f, .hitbox_w = 6.0f, .hitbox_h = 6.0f, .flags = PROJ_BOUNCY | PROJ_GRAVITY, .anim_fly = NULL, .anim_impact = NULL, }; const ProjectileDef WEAPON_ENEMY_FIRE = { .name = "enemy_fire", .speed = 180.0f, .damage = 1, .lifetime = 3.0f, .gravity_scale = 0.0f, .pierce_count = 0, .bounce_count = 0, .homing_strength = 0.0f, .hitbox_w = 8.0f, .hitbox_h = 8.0f, .flags = 0, .anim_fly = NULL, .anim_impact = NULL, }; /* ── Mutable copies with animation pointers ────────── */ /* We need mutable copies because the AnimDef pointers */ /* aren't available at compile time (set after init). */ static ProjectileDef s_weapon_plasma; static ProjectileDef s_weapon_spread; static ProjectileDef s_weapon_laser; static ProjectileDef s_weapon_rocket; static ProjectileDef s_weapon_bounce; static ProjectileDef s_weapon_enemy_fire; static void init_weapon_defs(void) { s_weapon_plasma = WEAPON_PLASMA; s_weapon_plasma.anim_fly = &anim_bullet; s_weapon_plasma.anim_impact = &anim_bullet_impact; s_weapon_spread = WEAPON_SPREAD; s_weapon_spread.anim_fly = &anim_bullet; s_weapon_spread.anim_impact = &anim_bullet_impact; s_weapon_laser = WEAPON_LASER; s_weapon_laser.anim_fly = &anim_bullet; s_weapon_laser.anim_impact = &anim_bullet_impact; s_weapon_rocket = WEAPON_ROCKET; s_weapon_rocket.anim_fly = &anim_bullet; s_weapon_rocket.anim_impact = &anim_bullet_impact; s_weapon_bounce = WEAPON_BOUNCE; s_weapon_bounce.anim_fly = &anim_bullet; s_weapon_bounce.anim_impact = &anim_bullet_impact; s_weapon_enemy_fire = WEAPON_ENEMY_FIRE; s_weapon_enemy_fire.anim_fly = &anim_enemy_bullet; s_weapon_enemy_fire.anim_impact = &anim_bullet_impact; } /* ════════════════════════════════════════════════════ * Projectile entity callbacks * ════════════════════════════════════════════════════ */ static void resolve_wall_bounce(Body *body, const Tilemap *map, int *bounces_left) { /* Check if the center of the projectile is inside a solid tile */ int cx = world_to_tile(body->pos.x + body->size.x * 0.5f); int cy = world_to_tile(body->pos.y + body->size.y * 0.5f); if (!tilemap_is_solid(map, cx, cy)) return; if (*bounces_left <= 0) return; (*bounces_left)--; /* Determine bounce axis by checking neighboring tiles */ int left_tile = world_to_tile(body->pos.x); int right_tile = world_to_tile(body->pos.x + body->size.x); int top_tile = world_to_tile(body->pos.y); int bot_tile = world_to_tile(body->pos.y + body->size.y); bool hit_h = tilemap_is_solid(map, cx, top_tile - 1) == false && tilemap_is_solid(map, cx, bot_tile + 1) == false; bool hit_v = tilemap_is_solid(map, left_tile - 1, cy) == false && tilemap_is_solid(map, right_tile + 1, cy) == false; if (hit_h || (!hit_h && !hit_v)) { body->vel.y = -body->vel.y; } if (hit_v || (!hit_h && !hit_v)) { body->vel.x = -body->vel.x; } /* Push out of solid tile */ body->pos.x += body->vel.x * DT; body->pos.y += body->vel.y * DT; } static void projectile_update(Entity *self, float dt, const Tilemap *map) { ProjectileData *pd = (ProjectileData *)self->data; if (!pd || !pd->def) return; const ProjectileDef *def = pd->def; Body *body = &self->body; /* ── Impact animation phase ──────────────── */ if (pd->proj_flags & PROJ_IMPACT) { animation_update(&self->anim, dt); if (self->anim.finished) { entity_destroy(s_proj_em, self); } return; } /* ── Apply gravity if flagged ────────────── */ if (def->flags & PROJ_GRAVITY) { body->vel.y += physics_get_gravity() * def->gravity_scale * dt; if (body->vel.y > MAX_FALL_SPEED) body->vel.y = MAX_FALL_SPEED; } /* ── Apply wind ─────────────────────────── */ float wind = physics_get_wind(); if (wind != 0.0f) { body->vel.x += wind * dt; } /* ── Move ────────────────────────────────── */ body->pos.x += body->vel.x * dt; body->pos.y += body->vel.y * dt; /* ── Tilemap collision ───────────────────── */ int cx = world_to_tile(body->pos.x + body->size.x * 0.5f); int cy = world_to_tile(body->pos.y + body->size.y * 0.5f); if (tilemap_is_solid(map, cx, cy)) { if ((def->flags & PROJ_BOUNCY) && pd->bounces_left > 0) { resolve_wall_bounce(body, map, &pd->bounces_left); } else { projectile_hit(self); return; } } /* ── Lifetime ────────────────────────────── */ pd->lifetime -= dt; if (pd->lifetime <= 0) { entity_destroy(s_proj_em, self); return; } /* ── Homing ──────────────────────────────── */ if ((def->flags & PROJ_HOMING) && def->homing_strength > 0 && s_proj_em) { /* Find nearest valid target */ bool is_player_proj = (pd->proj_flags & PROJ_FROM_PLAYER) != 0; Entity *best = NULL; float best_dist = 99999.0f; Vec2 proj_center = vec2( body->pos.x + body->size.x * 0.5f, body->pos.y + body->size.y * 0.5f ); for (int i = 0; i < s_proj_em->count; i++) { Entity *e = &s_proj_em->entities[i]; if (!e->active || (e->flags & ENTITY_DEAD)) continue; /* Player bullets target enemies, enemy bullets target player */ if (is_player_proj) { if (!entity_is_enemy(e)) continue; } else { if (e->type != ENT_PLAYER) continue; } Vec2 target_center = vec2( e->body.pos.x + e->body.size.x * 0.5f, e->body.pos.y + e->body.size.y * 0.5f ); float d = vec2_dist(proj_center, target_center); if (d < best_dist) { best_dist = d; best = e; } } if (best) { Vec2 target_center = vec2( best->body.pos.x + best->body.size.x * 0.5f, best->body.pos.y + best->body.size.y * 0.5f ); Vec2 to_target = vec2_norm(vec2_sub(target_center, proj_center)); Vec2 cur_dir = vec2_norm(body->vel); float spd = vec2_len(body->vel); /* Steer toward target */ Vec2 new_dir = vec2_norm(vec2_lerp(cur_dir, to_target, def->homing_strength * dt)); body->vel = vec2_scale(new_dir, spd); } } /* ── Animation ───────────────────────────── */ animation_update(&self->anim, dt); } static void projectile_render(Entity *self, const Camera *cam) { (void)cam; Body *body = &self->body; ProjectileData *pd = (ProjectileData *)self->data; if (g_spritesheet && self->anim.def) { SDL_Rect src = animation_current_rect(&self->anim); /* Center the 16x16 sprite on the smaller hitbox */ Vec2 render_pos = vec2( body->pos.x + body->size.x * 0.5f - SPRITE_CELL * 0.5f, body->pos.y + body->size.y * 0.5f - SPRITE_CELL * 0.5f ); bool flip_x = false; bool flip_y = false; if (pd && !(pd->proj_flags & PROJ_IMPACT)) { flip_x = (body->vel.x < 0); /* Flip vertically for downward-only projectiles */ flip_y = (body->vel.y > 0 && fabsf(body->vel.x) < 1.0f); } Sprite spr = { .texture = g_spritesheet, .src = src, .pos = render_pos, .size = vec2(SPRITE_CELL, SPRITE_CELL), .flip_x = flip_x, .flip_y = flip_y, .layer = LAYER_ENTITIES, .alpha = 255, }; renderer_submit(&spr); } else { /* Fallback colored rectangle */ SDL_Color color = (pd && (pd->proj_flags & PROJ_FROM_PLAYER)) ? (SDL_Color){100, 220, 255, 255} : (SDL_Color){255, 180, 50, 255}; renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); } } static void projectile_destroy_fn(Entity *self) { free(self->data); self->data = NULL; } /* ════════════════════════════════════════════════════ * Public API * ════════════════════════════════════════════════════ */ void projectile_register(EntityManager *em) { entity_register(em, ENT_PROJECTILE, projectile_update, projectile_render, projectile_destroy_fn); s_proj_em = em; init_weapon_defs(); } Entity *projectile_spawn_def(EntityManager *em, const ProjectileDef *def, Vec2 pos, Vec2 dir, bool from_player) { Entity *e = entity_spawn(em, ENT_PROJECTILE, pos); if (!e) return NULL; e->body.size = vec2(def->hitbox_w, def->hitbox_h); e->body.gravity_scale = 0.0f; /* gravity handled manually via def */ /* Normalize direction and scale to projectile speed */ Vec2 norm_dir = vec2_norm(dir); e->body.vel = vec2_scale(norm_dir, def->speed); e->health = 1; e->damage = def->damage; ProjectileData *pd = calloc(1, sizeof(ProjectileData)); pd->def = def; pd->lifetime = def->lifetime; pd->pierces_left = def->pierce_count; pd->bounces_left = def->bounce_count; if (from_player) { pd->proj_flags |= PROJ_FROM_PLAYER; } if (def->anim_fly) { animation_set(&e->anim, def->anim_fly); } e->data = pd; return e; } Entity *projectile_spawn_dir(EntityManager *em, Vec2 pos, Vec2 dir, bool from_player) { const ProjectileDef *def = from_player ? &s_weapon_plasma : &s_weapon_enemy_fire; return projectile_spawn_def(em, def, pos, dir, from_player); } Entity *projectile_spawn(EntityManager *em, Vec2 pos, bool facing_left, bool from_player) { Vec2 dir = facing_left ? vec2(-1, 0) : vec2(1, 0); return projectile_spawn_dir(em, pos, dir, from_player); } void projectile_hit(Entity *proj) { if (!proj || !proj->data) return; ProjectileData *pd = (ProjectileData *)proj->data; /* If piercing and has pierces left, don't destroy */ if ((pd->def->flags & PROJ_PIERCING) && pd->pierces_left > 0) { pd->pierces_left--; return; } /* Emit impact sparks */ Vec2 hit_center = vec2( proj->body.pos.x + proj->body.size.x * 0.5f, proj->body.pos.y + proj->body.size.y * 0.5f ); SDL_Color spark_color = (pd->proj_flags & PROJ_FROM_PLAYER) ? (SDL_Color){100, 220, 255, 255} : /* cyan for player bullets */ (SDL_Color){255, 180, 50, 255}; /* orange for enemy bullets */ particle_emit_spark(hit_center, spark_color); /* Switch to impact animation */ pd->proj_flags |= PROJ_IMPACT; proj->body.vel = vec2(0, 0); if (pd->def->anim_impact) { animation_set(&proj->anim, pd->def->anim_impact); proj->anim.current_frame = 0; proj->anim.timer = 0; proj->anim.finished = false; } else { /* No impact animation, destroy immediately */ entity_destroy(s_proj_em, proj); } } bool projectile_is_impacting(const Entity *proj) { if (!proj || !proj->data) return true; const ProjectileData *pd = (const ProjectileData *)proj->data; return (pd->proj_flags & PROJ_IMPACT) != 0; } bool projectile_is_from_player(const Entity *proj) { if (!proj || !proj->data) return false; const ProjectileData *pd = (const ProjectileData *)proj->data; return (pd->proj_flags & PROJ_FROM_PLAYER) != 0; }