Files
major_tom/src/game/laser_turret.c
Thomas 5f899f61c6 Add pixel art sprites for charger, spawner, and laser turret
Replace colored-rect placeholder rendering with proper 16x16
spritesheet art. Charger: orange armored rhino with horn, eyes,
walk cycle (row 7). Spawner: purple hive pod with glowing core
and tendrils (row 8). Laser turret: angular metallic housing
with red lens that brightens when firing (row 8). All three
retain colored-rect fallback when spritesheet is unavailable.
2026-03-02 20:45:21 +00:00

383 lines
14 KiB
C

#include "game/laser_turret.h"
#include "game/player.h"
#include "game/sprites.h"
#include "engine/core.h"
#include "engine/physics.h"
#include "engine/renderer.h"
#include "engine/particle.h"
#include "engine/camera.h"
#include <stdlib.h>
#include <math.h>
/* ═══════════════════════════════════════════════════
* Laser Turret
*
* Cycles through four states:
* IDLE (brief) -> CHARGING (1.5s, thin flickering
* indicator line) -> FIRING (2.5s, thick damaging
* beam) -> COOLDOWN (2s, beam fades) -> IDLE ...
*
* The beam is a raycast from the turret center along
* aim_angle until a solid tile or the map edge.
*
* Fixed variant: aim_angle set at spawn, never moves.
* Tracking variant: aim_angle rotates toward the
* player during IDLE and CHARGING, then locks when
* FIRING begins.
* ═══════════════════════════════════════════════════ */
static EntityManager *s_laser_em = NULL;
/* ── Helpers ──────────────────────────────────────── */
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;
}
/* Raycast from (ox, oy) along (dx, dy) in tile steps.
* Returns the world-space endpoint where the beam hits
* a solid tile or the map boundary. Step size is one
* pixel for accuracy. Max range prevents runaway. */
static void beam_raycast(const Tilemap *map,
float ox, float oy, float dx, float dy,
float *out_x, float *out_y) {
float max_range = 800.0f; /* pixels */
float step = 1.0f;
float x = ox, y = oy;
float dist = 0.0f;
while (dist < max_range) {
x += dx * step;
y += dy * step;
dist += step;
int tx = (int)(x / TILE_SIZE);
int ty = (int)(y / TILE_SIZE);
if (tx < 0 || ty < 0 || tx >= map->width || ty >= map->height) break;
if (tilemap_is_solid(map, tx, ty)) break;
}
*out_x = x;
*out_y = y;
}
/* ── Update ──────────────────────────────────────── */
static void laser_turret_update(Entity *self, float dt, const Tilemap *map) {
LaserTurretData *ld = (LaserTurretData *)self->data;
if (!ld) return;
/* Death sequence */
if (self->flags & ENTITY_DEAD) {
self->timer -= dt;
if (self->timer <= 0) {
particle_emit_death_puff(self->body.pos, (SDL_Color){255, 60, 60, 255});
entity_destroy(s_laser_em, self);
}
return;
}
/* Turret center for raycasting and aiming */
float cx = self->body.pos.x + self->body.size.x * 0.5f;
float cy = self->body.pos.y + self->body.size.y * 0.5f;
/* Tracking: rotate toward player during IDLE and CHARGING */
if (ld->tracking && (ld->state == LASER_IDLE || ld->state == LASER_CHARGING)) {
Entity *player = find_player(s_laser_em);
if (player && player->active && !(player->flags & ENTITY_DEAD)) {
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 target_angle = atan2f(py - cy, px - cx);
/* Rotate toward target at limited speed */
float diff = target_angle - ld->aim_angle;
/* Normalize to [-PI, PI] */
while (diff > 3.14159f) diff -= 6.28318f;
while (diff < -3.14159f) diff += 6.28318f;
float max_rot = LASER_TRACK_SPEED * dt;
if (diff > max_rot) ld->aim_angle += max_rot;
else if (diff < -max_rot) ld->aim_angle -= max_rot;
else ld->aim_angle = target_angle;
}
}
/* Update facing direction from aim */
float dx = cosf(ld->aim_angle);
if (dx < 0) self->flags |= ENTITY_FACING_LEFT;
else self->flags &= ~ENTITY_FACING_LEFT;
/* Compute beam endpoint via raycast */
float dir_x = cosf(ld->aim_angle);
float dir_y = sinf(ld->aim_angle);
beam_raycast(map, cx, cy, dir_x, dir_y, &ld->beam_end_x, &ld->beam_end_y);
/* Damage cooldown */
if (ld->damage_cd > 0) ld->damage_cd -= dt;
/* State machine */
ld->timer -= dt;
if (ld->timer <= 0) {
switch (ld->state) {
case LASER_IDLE:
ld->state = LASER_CHARGING;
ld->timer = LASER_CHARGE_TIME;
break;
case LASER_CHARGING:
ld->state = LASER_FIRING;
ld->timer = LASER_FIRE_TIME;
ld->damage_cd = 0;
break;
case LASER_FIRING:
ld->state = LASER_COOLDOWN;
ld->timer = LASER_COOLDOWN_TIME;
break;
case LASER_COOLDOWN:
ld->state = LASER_IDLE;
ld->timer = 0.3f; /* brief idle before next cycle */
break;
}
}
/* During FIRING: check beam-player overlap and deal damage. */
if (ld->state == LASER_FIRING && ld->damage_cd <= 0) {
Entity *player = find_player(s_laser_em);
if (player && player->active && !(player->flags & ENTITY_DEAD)) {
/* Test player AABB against beam line (approximated as thin rect). */
float bx0 = cx, by0 = cy;
float bx1 = ld->beam_end_x, by1 = ld->beam_end_y;
float beam_half_w = 3.0f; /* beam thickness for collision */
/* Build an AABB enclosing the beam segment */
float min_x = (bx0 < bx1 ? bx0 : bx1) - beam_half_w;
float min_y = (by0 < by1 ? by0 : by1) - beam_half_w;
float max_x = (bx0 > bx1 ? bx0 : bx1) + beam_half_w;
float max_y = (by0 > by1 ? by0 : by1) + beam_half_w;
/* Quick AABB pre-filter */
Body *pb = &player->body;
if (pb->pos.x + pb->size.x > min_x && pb->pos.x < max_x &&
pb->pos.y + pb->size.y > min_y && pb->pos.y < max_y) {
/* Finer check: point-to-line distance for player center. */
float pcx = pb->pos.x + pb->size.x * 0.5f;
float pcy = pb->pos.y + pb->size.y * 0.5f;
float lx = bx1 - bx0, ly = by1 - by0;
float len_sq = lx * lx + ly * ly;
float dist_to_beam;
if (len_sq < 0.01f) {
dist_to_beam = sqrtf((pcx - bx0) * (pcx - bx0) +
(pcy - by0) * (pcy - by0));
} else {
float t = ((pcx - bx0) * lx + (pcy - by0) * ly) / len_sq;
if (t < 0) t = 0;
if (t > 1) t = 1;
float closest_x = bx0 + t * lx;
float closest_y = by0 + t * ly;
dist_to_beam = sqrtf((pcx - closest_x) * (pcx - closest_x) +
(pcy - closest_y) * (pcy - closest_y));
}
/* Hit if player center is within half-size of beam */
float hit_radius = (pb->size.x + pb->size.y) * 0.25f + beam_half_w;
if (dist_to_beam < hit_radius) {
player->health -= LASER_DAMAGE;
ld->damage_cd = LASER_DAMAGE_CD;
particle_emit_spark(vec2(pcx, pcy), (SDL_Color){255, 80, 40, 255});
}
}
}
}
/* Sparks at beam endpoint during FIRING */
if (ld->state == LASER_FIRING) {
if (rand() % 4 == 0) {
particle_emit_spark(vec2(ld->beam_end_x, ld->beam_end_y),
(SDL_Color){255, 100, 50, 255});
}
}
/* Animation */
if (ld->state == LASER_CHARGING || ld->state == LASER_FIRING) {
animation_set(&self->anim, &anim_laser_turret_fire);
} else {
animation_set(&self->anim, &anim_laser_turret_idle);
}
animation_update(&self->anim, dt);
}
/* ── Render ──────────────────────────────────────── */
/* Draw the beam line from turret center to endpoint.
* Uses raw SDL calls since renderer_draw_rect only handles
* axis-aligned rectangles. */
static void draw_beam_line(const Camera *cam,
float x0, float y0, float x1, float y1,
int thickness, SDL_Color col) {
Vec2 s0 = camera_world_to_screen(cam, vec2(x0, y0));
Vec2 s1 = camera_world_to_screen(cam, vec2(x1, y1));
SDL_SetRenderDrawBlendMode(g_engine.renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(g_engine.renderer, col.r, col.g, col.b, col.a);
/* Draw multiple lines offset perpendicular to simulate thickness. */
float dx = s1.x - s0.x;
float dy = s1.y - s0.y;
float len = sqrtf(dx * dx + dy * dy);
if (len < 0.5f) return;
/* Perpendicular direction */
float nx = -dy / len;
float ny = dx / len;
int half = thickness / 2;
for (int i = -half; i <= half; i++) {
float ox = nx * (float)i;
float oy = ny * (float)i;
SDL_RenderDrawLine(g_engine.renderer,
(int)(s0.x + ox), (int)(s0.y + oy),
(int)(s1.x + ox), (int)(s1.y + oy));
}
SDL_SetRenderDrawBlendMode(g_engine.renderer, SDL_BLENDMODE_NONE);
}
static void laser_turret_render(Entity *self, const Camera *cam) {
LaserTurretData *ld = (LaserTurretData *)self->data;
if (!ld) return;
Body *body = &self->body;
float cx = body->pos.x + body->size.x * 0.5f;
float cy = body->pos.y + body->size.y * 0.5f;
/* Draw beam based on state */
if (ld->state == LASER_CHARGING) {
/* Thin flickering indicator line */
float progress = 1.0f - (ld->timer / LASER_CHARGE_TIME);
uint8_t alpha = (uint8_t)(80 + 100 * progress);
/* Flicker effect: modulate alpha with a fast sine */
float flicker = sinf(ld->timer * 20.0f) * 0.3f + 0.7f;
alpha = (uint8_t)((float)alpha * flicker);
SDL_Color beam_col = {255, 60, 40, alpha};
draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y,
1, beam_col);
} else if (ld->state == LASER_FIRING) {
/* Bright thick beam — core + glow */
SDL_Color glow = {255, 80, 30, 100};
draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y,
5, glow);
SDL_Color core = {255, 200, 150, 230};
draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y,
2, core);
SDL_Color center = {255, 255, 220, 255};
draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y,
0, center);
} else if (ld->state == LASER_COOLDOWN) {
/* Fading beam */
float fade = ld->timer / LASER_COOLDOWN_TIME;
uint8_t alpha = (uint8_t)(180 * fade);
SDL_Color beam_col = {255, 80, 30, alpha};
draw_beam_line(cam, cx, cy, ld->beam_end_x, ld->beam_end_y,
(int)(3.0f * fade), beam_col);
}
/* Draw turret body */
if (g_spritesheet && self->anim.def) {
SDL_Rect src = animation_current_rect(&self->anim);
/* Center the 16x16 sprite over the 14x14 hitbox */
Vec2 render_pos = vec2(
body->pos.x - 1.0f,
body->pos.y - 1.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 {
/* Colored rect fallback */
SDL_Color base_col;
if (ld->state == LASER_FIRING) {
base_col = (SDL_Color){200, 80, 50, 255};
} else if (ld->state == LASER_CHARGING) {
base_col = (SDL_Color){180, 100, 60, 255};
} else {
base_col = (SDL_Color){140, 100, 80, 255};
}
renderer_draw_rect(body->pos, body->size, base_col, LAYER_ENTITIES, cam);
/* Small dot indicating aim direction (only needed for fallback) */
float dot_dist = 10.0f;
float dot_x = cx + cosf(ld->aim_angle) * dot_dist - 1.0f;
float dot_y = cy + sinf(ld->aim_angle) * dot_dist - 1.0f;
SDL_Color dot_col = {255, 50, 30, 255};
renderer_draw_rect(vec2(dot_x, dot_y), vec2(2, 2), dot_col,
LAYER_ENTITIES, cam);
}
}
/* ── Destroy ─────────────────────────────────────── */
static void laser_turret_destroy(Entity *self) {
free(self->data);
self->data = NULL;
}
/* ── Register ────────────────────────────────────── */
void laser_turret_register(EntityManager *em) {
entity_register(em, ENT_LASER_TURRET,
laser_turret_update, laser_turret_render,
laser_turret_destroy);
s_laser_em = em;
}
/* ── Spawn ───────────────────────────────────────── */
static Entity *laser_spawn_internal(EntityManager *em, Vec2 pos, bool tracking) {
Entity *e = entity_spawn(em, ENT_LASER_TURRET, pos);
if (!e) return NULL;
e->body.size = vec2(LASER_WIDTH, LASER_HEIGHT);
e->body.gravity_scale = 0.0f;
e->health = LASER_HEALTH;
e->max_health = LASER_HEALTH;
e->damage = 0; /* damage comes from the beam, not contact */
e->timer = 0.3f;
LaserTurretData *ld = calloc(1, sizeof(LaserTurretData));
if (!ld) { entity_destroy(em, e); return NULL; }
ld->state = LASER_IDLE;
ld->timer = 0.5f; /* brief idle before first charge */
ld->aim_angle = 3.14159f; /* default: aim left */
ld->damage_cd = 0;
ld->tracking = tracking;
e->data = ld;
return e;
}
Entity *laser_turret_spawn(EntityManager *em, Vec2 pos) {
return laser_spawn_internal(em, pos, false);
}
Entity *laser_turret_spawn_tracking(EntityManager *em, Vec2 pos) {
return laser_spawn_internal(em, pos, true);
}