Files
major_tom/src/engine/particle.c
root c57ac68a04 Fix atmosphere particles clustering on left side of screen
When wind is zero or near-zero, dust particles were always spawning at
the left viewport edge (the "upwind" edge for wind >= 0). Without wind
force to carry them across, they accumulated on the left side. Now
particles spawn across the full viewport when wind is calm (< 5 px/s²),
with random drift directions for even distribution.

Closes #2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:14:52 +00:00

625 lines
20 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);
}
/* Spawn a single dust mote with the given visual properties. */
static void spawn_dust_mote(Vec2 pos, Vec2 vel,
float life_min, float life_max,
float size_min, float size_max,
float drag, float gscale,
uint8_t r, uint8_t g, uint8_t b, int vary) {
Particle *p = alloc_particle();
p->pos = pos;
p->vel = vel;
p->life = randf_range(life_min, life_max);
p->max_life = p->life;
p->size = randf_range(size_min, size_max);
p->drag = drag;
p->gravity_scale = gscale;
p->active = true;
p->color.r = clamp_u8(r + (int)randf_range(-vary, vary));
p->color.g = clamp_u8(g + (int)randf_range(-vary, vary));
p->color.b = clamp_u8(b + (int)randf_range(-vary, vary));
p->color.a = 255; /* alpha applied during render from life ratio */
}
void particle_emit_atmosphere_dust(Vec2 cam_pos, Vec2 vp) {
/* Ambient Mars dust — subtle motes drifting across the viewport.
* Two sub-layers for depth: large slow "far" motes and small quick
* "near" specks. Wind carries them; gravity_scale controls how much
* environmental forces (wind + gravity) affect each particle.
* When wind is strong, particles spawn along the upwind viewport edge
* and drift inward. When wind is calm, particles spawn across the
* full viewport to avoid clustering on one side. */
float wind = physics_get_wind();
float margin = 32.0f;
float abs_wind = (wind >= 0.0f) ? wind : -wind;
int has_wind = abs_wind > 5.0f; /* threshold for edge-spawning */
if (has_wind) {
float dir = (wind >= 0.0f) ? 1.0f : -1.0f;
/* Upwind edge X for the two edge-spawned layers */
float edge_far = (wind >= 0.0f) ? cam_pos.x - margin
: cam_pos.x + vp.x + margin;
float edge_near = (wind >= 0.0f) ? cam_pos.x - margin * 0.5f
: cam_pos.x + vp.x + margin * 0.5f;
/* Far dust motes — large, slow, translucent (1/frame) */
spawn_dust_mote(
vec2(edge_far, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(8.0f, 25.0f), randf_range(-6.0f, 6.0f)),
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
180, 140, 100, 25);
/* Near dust specks — small, quicker, brighter (1/frame) */
spawn_dust_mote(
vec2(edge_near, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(15.0f, 40.0f), randf_range(-10.0f, 10.0f)),
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
200, 160, 120, 20);
/* Occasional interior spawn — prevents edge seam */
if (rand() % 3 == 0) {
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
160, 130, 95, 25);
}
} else {
/* Calm wind — spawn across the full viewport to distribute evenly */
/* Far dust motes — large, slow, translucent (1/frame) */
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-10.0f, 10.0f), randf_range(-6.0f, 6.0f)),
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
180, 140, 100, 25);
/* Near dust specks — small, quicker, brighter (1/frame) */
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-15.0f, 15.0f), randf_range(-10.0f, 10.0f)),
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
200, 160, 120, 20);
/* Extra interior mote for density parity with windy path */
if (rand() % 3 == 0) {
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
160, 130, 95, 25);
}
}
}