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