#include "engine/particle.h" #include "engine/renderer.h" #include "engine/physics.h" #include "engine/camera.h" #include #include #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); } } }