Files
major_tom/src/engine/particle.c
Thomas 6c4b076c68 Add per-level wind atmosphere property
WIND directive in .lvl files sets a constant horizontal force (px/s^2)
that pushes entities, projectiles, and particles. Positive is rightward.

Wind is applied as acceleration in physics_update() (halved on ground),
directly to projectile and particle velocities, and as a gentle position
drift on flyers. Entities with gravity_scale=0 (drones, spacecraft) are
unaffected. Levels default to no wind when the directive is absent.
2026-03-01 17:13:01 +00:00

532 lines
15 KiB
C

#include "engine/particle.h"
#include "engine/renderer.h"
#include "engine/physics.h"
#include "engine/camera.h"
#include <stdlib.h>
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static Particle s_particles[MAX_PARTICLES];
/* ── Helpers ─────────────────────────────────────────── */
static float randf(void) {
return (float)rand() / (float)RAND_MAX;
}
static float randf_range(float lo, float hi) {
return lo + randf() * (hi - lo);
}
static uint8_t clamp_u8(int v) {
if (v < 0) return 0;
if (v > 255) return 255;
return (uint8_t)v;
}
/* ── Core API ────────────────────────────────────────── */
void particle_init(void) {
for (int i = 0; i < MAX_PARTICLES; i++) {
s_particles[i].active = false;
}
}
static Particle *alloc_particle(void) {
/* Find a free slot */
for (int i = 0; i < MAX_PARTICLES; i++) {
if (!s_particles[i].active) return &s_particles[i];
}
/* Steal the oldest (lowest remaining life) */
float min_life = 1e9f;
int min_idx = 0;
for (int i = 0; i < MAX_PARTICLES; i++) {
if (s_particles[i].life < min_life) {
min_life = s_particles[i].life;
min_idx = i;
}
}
return &s_particles[min_idx];
}
void particle_emit(const ParticleBurst *b) {
for (int i = 0; i < b->count; i++) {
Particle *p = alloc_particle();
/* Random angle within spread cone */
float angle = b->direction + randf_range(-b->spread, b->spread);
float speed = randf_range(b->speed_min, b->speed_max);
p->pos = b->origin;
p->vel = vec2(cosf(angle) * speed, sinf(angle) * speed);
p->life = randf_range(b->life_min, b->life_max);
p->max_life = p->life;
p->size = randf_range(b->size_min, b->size_max);
p->drag = b->drag;
p->gravity_scale = b->gravity_scale;
p->active = true;
/* Color with optional variation */
p->color = b->color;
if (b->color_vary) {
int vary = 30;
p->color.r = clamp_u8(b->color.r + (int)randf_range(-vary, vary));
p->color.g = clamp_u8(b->color.g + (int)randf_range(-vary, vary));
p->color.b = clamp_u8(b->color.b + (int)randf_range(-vary, vary));
}
}
}
void particle_update(float dt) {
float gravity = physics_get_gravity();
float wind = physics_get_wind();
for (int i = 0; i < MAX_PARTICLES; i++) {
Particle *p = &s_particles[i];
if (!p->active) continue;
p->life -= dt;
if (p->life <= 0) {
p->active = false;
continue;
}
/* Apply gravity */
p->vel.y += gravity * p->gravity_scale * dt;
/* Apply wind (reuse gravity_scale as environmental-force scale) */
if (wind != 0.0f && p->gravity_scale > 0.0f) {
p->vel.x += wind * p->gravity_scale * dt;
}
/* Apply drag */
if (p->drag > 0) {
float factor = 1.0f - p->drag * dt;
if (factor < 0) factor = 0;
p->vel.x *= factor;
p->vel.y *= factor;
}
/* Move */
p->pos.x += p->vel.x * dt;
p->pos.y += p->vel.y * dt;
}
}
void particle_render(const Camera *cam) {
for (int i = 0; i < MAX_PARTICLES; i++) {
Particle *p = &s_particles[i];
if (!p->active) continue;
/* Alpha fade based on remaining life */
float t = p->life / p->max_life;
SDL_Color c = p->color;
c.a = (uint8_t)(t * 255.0f);
/* Shrink slightly as particle ages */
float sz = p->size * (0.3f + 0.7f * t);
if (sz < 0.5f) sz = 0.5f;
renderer_draw_rect(p->pos, vec2(sz, sz), c, LAYER_PARTICLES, cam);
}
}
void particle_clear(void) {
for (int i = 0; i < MAX_PARTICLES; i++) {
s_particles[i].active = false;
}
}
/* ── Presets ─────────────────────────────────────────── */
void particle_emit_death_puff(Vec2 pos, SDL_Color color) {
ParticleBurst b = {
.origin = pos,
.count = 12,
.speed_min = 30.0f,
.speed_max = 100.0f,
.life_min = 0.2f,
.life_max = 0.5f,
.size_min = 1.5f,
.size_max = 3.5f,
.spread = (float)M_PI, /* full circle */
.direction = 0,
.drag = 3.0f,
.gravity_scale = 0.3f,
.color = color,
.color_vary = true,
};
particle_emit(&b);
}
void particle_emit_landing_dust(Vec2 pos) {
ParticleBurst b = {
.origin = pos,
.count = 6,
.speed_min = 20.0f,
.speed_max = 60.0f,
.life_min = 0.15f,
.life_max = 0.35f,
.size_min = 1.0f,
.size_max = 2.5f,
.spread = 0.6f, /* ~35 degrees */
.direction = -(float)M_PI * 0.5f, /* upward */
.drag = 4.0f,
.gravity_scale = 0.0f,
.color = {180, 170, 150, 255}, /* dusty tan */
.color_vary = true,
};
particle_emit(&b);
}
void particle_emit_spark(Vec2 pos, SDL_Color color) {
ParticleBurst b = {
.origin = pos,
.count = 5,
.speed_min = 40.0f,
.speed_max = 120.0f,
.life_min = 0.1f,
.life_max = 0.25f,
.size_min = 1.0f,
.size_max = 2.0f,
.spread = (float)M_PI,
.direction = 0,
.drag = 2.0f,
.gravity_scale = 0.5f,
.color = color,
.color_vary = true,
};
particle_emit(&b);
}
void particle_emit_jetpack_burst(Vec2 pos, Vec2 dash_dir) {
/* Exhaust fires opposite to dash direction */
float exhaust_angle = atan2f(-dash_dir.y, -dash_dir.x);
/* Big initial burst — hot core (white/yellow) */
ParticleBurst core = {
.origin = pos,
.count = 18,
.speed_min = 80.0f,
.speed_max = 200.0f,
.life_min = 0.15f,
.life_max = 0.35f,
.size_min = 2.0f,
.size_max = 4.0f,
.spread = 0.5f, /* ~30 degree cone */
.direction = exhaust_angle,
.drag = 3.0f,
.gravity_scale = 0.1f,
.color = {255, 220, 120, 255}, /* bright yellow */
.color_vary = true,
};
particle_emit(&core);
/* Outer plume — orange/red, wider spread */
ParticleBurst plume = {
.origin = pos,
.count = 14,
.speed_min = 50.0f,
.speed_max = 150.0f,
.life_min = 0.2f,
.life_max = 0.45f,
.size_min = 1.5f,
.size_max = 3.5f,
.spread = 0.8f, /* wider cone */
.direction = exhaust_angle,
.drag = 2.5f,
.gravity_scale = 0.2f,
.color = {255, 120, 40, 255}, /* orange */
.color_vary = true,
};
particle_emit(&plume);
/* Smoke tail — grey, slow, lingers */
ParticleBurst smoke = {
.origin = pos,
.count = 8,
.speed_min = 20.0f,
.speed_max = 60.0f,
.life_min = 0.3f,
.life_max = 0.6f,
.size_min = 2.0f,
.size_max = 4.5f,
.spread = 1.0f,
.direction = exhaust_angle,
.drag = 4.0f,
.gravity_scale = -0.1f, /* slight float-up */
.color = {160, 150, 140, 200}, /* smoke grey */
.color_vary = true,
};
particle_emit(&smoke);
}
void particle_emit_jetpack_trail(Vec2 pos, Vec2 dash_dir) {
/* Continuous trail — fewer particles per frame, same exhaust direction */
float exhaust_angle = atan2f(-dash_dir.y, -dash_dir.x);
/* Hot sparks */
ParticleBurst sparks = {
.origin = pos,
.count = 3,
.speed_min = 60.0f,
.speed_max = 160.0f,
.life_min = 0.1f,
.life_max = 0.25f,
.size_min = 1.5f,
.size_max = 3.0f,
.spread = 0.4f,
.direction = exhaust_angle,
.drag = 3.0f,
.gravity_scale = 0.1f,
.color = {255, 200, 80, 255}, /* yellow-orange */
.color_vary = true,
};
particle_emit(&sparks);
/* Smoke wisps */
ParticleBurst smoke = {
.origin = pos,
.count = 2,
.speed_min = 15.0f,
.speed_max = 40.0f,
.life_min = 0.2f,
.life_max = 0.4f,
.size_min = 1.5f,
.size_max = 3.5f,
.spread = 0.7f,
.direction = exhaust_angle,
.drag = 3.5f,
.gravity_scale = -0.1f,
.color = {140, 130, 120, 180},
.color_vary = true,
};
particle_emit(&smoke);
}
void particle_emit_jetpack_boost_burst(Vec2 pos, Vec2 dash_dir) {
/* Blue flame accents mixed into the regular jetpack burst */
float exhaust_angle = atan2f(-dash_dir.y, -dash_dir.x);
/* Hot blue core — bright electric blue */
ParticleBurst core = {
.origin = pos,
.count = 10,
.speed_min = 90.0f,
.speed_max = 220.0f,
.life_min = 0.15f,
.life_max = 0.35f,
.size_min = 2.0f,
.size_max = 4.5f,
.spread = 0.4f,
.direction = exhaust_angle,
.drag = 2.5f,
.gravity_scale = 0.05f,
.color = {80, 160, 255, 255}, /* electric blue */
.color_vary = true,
};
particle_emit(&core);
/* Outer blue-white flare */
ParticleBurst flare = {
.origin = pos,
.count = 8,
.speed_min = 40.0f,
.speed_max = 130.0f,
.life_min = 0.2f,
.life_max = 0.4f,
.size_min = 1.5f,
.size_max = 3.0f,
.spread = 0.7f,
.direction = exhaust_angle,
.drag = 3.0f,
.gravity_scale = 0.1f,
.color = {140, 200, 255, 255}, /* light blue */
.color_vary = true,
};
particle_emit(&flare);
}
void particle_emit_jetpack_boost_trail(Vec2 pos, Vec2 dash_dir) {
/* Continuous blue flame trail while dashing with boost active */
float exhaust_angle = atan2f(-dash_dir.y, -dash_dir.x);
/* Blue flame sparks */
ParticleBurst sparks = {
.origin = pos,
.count = 2,
.speed_min = 50.0f,
.speed_max = 140.0f,
.life_min = 0.1f,
.life_max = 0.25f,
.size_min = 1.5f,
.size_max = 3.0f,
.spread = 0.35f,
.direction = exhaust_angle,
.drag = 3.0f,
.gravity_scale = 0.05f,
.color = {60, 140, 255, 255}, /* bright blue */
.color_vary = true,
};
particle_emit(&sparks);
/* Blue-white wisps */
ParticleBurst wisps = {
.origin = pos,
.count = 1,
.speed_min = 20.0f,
.speed_max = 50.0f,
.life_min = 0.15f,
.life_max = 0.35f,
.size_min = 2.0f,
.size_max = 3.5f,
.spread = 0.5f,
.direction = exhaust_angle,
.drag = 3.5f,
.gravity_scale = -0.05f,
.color = {160, 210, 255, 200}, /* pale blue */
.color_vary = true,
};
particle_emit(&wisps);
}
void particle_emit_jetpack_boost_idle(Vec2 pos, bool facing_left) {
/* Ambient blue flame simmering from the player's back while boost is
* active but the player isn't dashing. Exhaust drifts backward and
* slightly downward — a subtle idle glow effect. */
float exhaust_angle = facing_left ? 0.0f : (float)M_PI; /* away from facing */
/* Small blue sparks drifting backward */
ParticleBurst sparks = {
.origin = pos,
.count = 1,
.speed_min = 15.0f,
.speed_max = 45.0f,
.life_min = 0.12f,
.life_max = 0.3f,
.size_min = 1.0f,
.size_max = 2.5f,
.spread = 0.8f,
.direction = exhaust_angle,
.drag = 4.0f,
.gravity_scale = 0.15f,
.color = {50, 130, 255, 220}, /* medium blue */
.color_vary = true,
};
particle_emit(&sparks);
/* Faint blue-white wisps floating up */
ParticleBurst wisps = {
.origin = pos,
.count = 1,
.speed_min = 8.0f,
.speed_max = 25.0f,
.life_min = 0.15f,
.life_max = 0.35f,
.size_min = 1.5f,
.size_max = 2.5f,
.spread = 1.0f,
.direction = exhaust_angle - 0.3f, /* slightly upward */
.drag = 4.5f,
.gravity_scale = -0.1f,
.color = {140, 200, 255, 160}, /* pale blue, translucent */
.color_vary = true,
};
particle_emit(&wisps);
}
void particle_emit_muzzle_flash(Vec2 pos, Vec2 shoot_dir) {
float angle = atan2f(shoot_dir.y, shoot_dir.x);
/* Perpendicular angle for the up/down flare lines */
float perp = angle + (float)M_PI * 0.5f;
/* Forward beam flash — bright cyan, fast, tight cone */
ParticleBurst beam = {
.origin = pos,
.count = 5,
.speed_min = 120.0f,
.speed_max = 250.0f,
.life_min = 0.03f,
.life_max = 0.07f,
.size_min = 1.5f,
.size_max = 2.5f,
.spread = 0.15f, /* very tight forward */
.direction = angle,
.drag = 6.0f,
.gravity_scale = 0.0f,
.color = {100, 220, 255, 255}, /* cyan */
.color_vary = true,
};
particle_emit(&beam);
/* Vertical flare — perpendicular lines (up) */
ParticleBurst flare_up = {
.origin = pos,
.count = 3,
.speed_min = 60.0f,
.speed_max = 140.0f,
.life_min = 0.02f,
.life_max = 0.06f,
.size_min = 1.0f,
.size_max = 2.0f,
.spread = 0.12f,
.direction = perp,
.drag = 8.0f,
.gravity_scale = 0.0f,
.color = {160, 240, 255, 255}, /* light cyan */
.color_vary = true,
};
particle_emit(&flare_up);
/* Vertical flare — perpendicular lines (down) */
ParticleBurst flare_down = flare_up;
flare_down.direction = perp + (float)M_PI; /* opposite perpendicular */
particle_emit(&flare_down);
/* Bright white core — instant pop at barrel */
ParticleBurst core = {
.origin = pos,
.count = 2,
.speed_min = 5.0f,
.speed_max = 20.0f,
.life_min = 0.03f,
.life_max = 0.06f,
.size_min = 2.5f,
.size_max = 4.0f,
.spread = (float)M_PI, /* all directions, stays near barrel */
.direction = 0,
.drag = 10.0f,
.gravity_scale = 0.0f,
.color = {200, 255, 255, 255}, /* white-cyan glow */
.color_vary = false,
};
particle_emit(&core);
}
void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir) {
/* Small dust puffs pushed away from the wall */
float away_angle = (wall_dir < 0) ? 0.0f : (float)M_PI; /* away from wall */
ParticleBurst dust = {
.origin = pos,
.count = 2,
.speed_min = 15.0f,
.speed_max = 40.0f,
.life_min = 0.1f,
.life_max = 0.25f,
.size_min = 1.0f,
.size_max = 2.0f,
.spread = 0.8f,
.direction = away_angle,
.drag = 4.0f,
.gravity_scale = -0.1f, /* slight float-up */
.color = {180, 170, 150, 200}, /* dusty tan */
.color_vary = true,
};
particle_emit(&dust);
}