Files
major_tom/src/game/projectile.c
Thomas af0a9904c2 Fix laser beam blocked by own tile, consolidate enemy-type checks
Beam raycast now skips the tile the turret is mounted on, so
wall-embedded laser turrets can shoot outward.

Extract entity_is_enemy() into engine/entity.c as single source
of truth for enemy-type checks. Replaces triplicated type lists
in level.c, drone.c, and projectile.c that caused the collision
bug when new enemy types were added.
2026-03-02 20:56:45 +00:00

437 lines
15 KiB
C

#include "game/projectile.h"
#include "game/sprites.h"
#include "engine/physics.h"
#include "engine/renderer.h"
#include "engine/particle.h"
#include <stdlib.h>
#include <math.h>
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;
}