forked from tas/major_tom
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.
383 lines
14 KiB
C
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);
|
|
}
|