forked from tas/major_tom
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.
437 lines
15 KiB
C
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;
|
|
}
|