Files
major_tom/src/game/spacecraft.c
Thomas 49ed2d6f7b Add spatial audio, spacecraft entity, and level intro sequence
Distance-based sound effects with stereo panning for explosions, impacts,
and pickups. Spacecraft entity with full state machine (fly-in, land, take
off, fly-out), engine/synth sound loops, thruster particles, and PNG
spritesheet. Moon level intro defers player spawn until ship lands.

Also untrack build/ objects that were committed by mistake.
2026-03-01 11:00:51 +00:00

373 lines
12 KiB
C

#include "game/spacecraft.h"
#include "engine/assets.h"
#include "engine/renderer.h"
#include "engine/particle.h"
#include "engine/audio.h"
#include "config.h"
#include <stdlib.h>
#include <math.h>
/* ═══════════════════════════════════════════════════
* Constants
* ═══════════════════════════════════════════════════ */
#define SC_FLY_SPEED 180.0f /* px/s during fly-in / fly-out */
#define SC_LAND_DURATION 1.6f /* seconds for the landing sequence */
#define SC_TAKEOFF_DURATION 1.2f /* seconds for the takeoff sequence */
/* Spritesheet frame indices (each 80x48, laid out horizontally) */
#define SC_FRAME_LANDED 0
#define SC_FRAME_LAND1 1
#define SC_FRAME_LAND2 2
#define SC_FRAME_LAND3 3
#define SC_FRAME_LAND4 4
#define SC_FRAME_COUNT 5
/* How far off-screen the ship starts / exits */
#define SC_OFFSCREEN_DIST 200.0f
/* ═══════════════════════════════════════════════════
* Static state
* ═══════════════════════════════════════════════════ */
static EntityManager *s_em = NULL;
static SDL_Texture *s_texture = NULL;
static bool s_texture_loaded = false;
/* ── Sound effects ──────────────────────────────── */
static Sound s_sfx_engine;
static Sound s_sfx_synth;
static bool s_sfx_loaded = false;
static void load_sfx(void) {
if (s_sfx_loaded) return;
s_sfx_engine = audio_load_sound("assets/sounds/engine.wav");
s_sfx_synth = audio_load_sound("assets/sounds/synth.wav");
s_sfx_loaded = true;
}
/* ═══════════════════════════════════════════════════
* Helpers
* ═══════════════════════════════════════════════════ */
static void load_texture(void) {
if (s_texture_loaded) return;
s_texture = assets_get_texture("assets/sprites/spacecraft.png");
s_texture_loaded = true;
}
/* Get the source rect for a given frame index (in PNG pixel coords) */
static SDL_Rect frame_rect(int frame_idx) {
return (SDL_Rect){
frame_idx * SPACECRAFT_SRC_W, 0,
SPACECRAFT_SRC_W, SPACECRAFT_SRC_H
};
}
/* Pick the landing animation frame based on progress (0.0 = start, 1.0 = landed) */
static int landing_frame(float progress) {
if (progress < 0.25f) return SC_FRAME_LAND1;
if (progress < 0.50f) return SC_FRAME_LAND2;
if (progress < 0.75f) return SC_FRAME_LAND3;
if (progress < 0.95f) return SC_FRAME_LAND4;
return SC_FRAME_LANDED;
}
/* Pick the takeoff animation frame (reverse of landing) */
static int takeoff_frame(float progress) {
return landing_frame(1.0f - progress);
}
/* Emit thruster particles behind the ship */
static void emit_thruster_particles(Vec2 ship_pos, float intensity) {
if (intensity <= 0.0f) return;
/* Particles emit from the left (rear) center of the ship */
Vec2 origin = vec2(
ship_pos.x + 8.0f,
ship_pos.y + SPACECRAFT_HEIGHT * 0.5f
);
int count = (int)(intensity * 3.0f);
if (count < 1) count = 1;
ParticleBurst b = {
.origin = origin,
.count = count,
.speed_min = 30.0f * intensity,
.speed_max = 80.0f * intensity,
.life_min = 0.1f,
.life_max = 0.3f,
.size_min = 1.0f,
.size_max = 2.5f,
.spread = 0.6f,
.direction = 3.14159f, /* leftward */
.drag = 2.0f,
.gravity_scale = 0.0f,
.color = (SDL_Color){255, 180, 50, 200},
.color_vary = true,
};
particle_emit(&b);
}
/* ═══════════════════════════════════════════════════
* Entity callbacks
* ═══════════════════════════════════════════════════ */
/* Start the engine loop sound, stopping any previous one */
static void engine_start(SpacecraftData *sd) {
if (sd->engine_channel >= 0) return; /* already playing */
sd->engine_channel = audio_play_sound_loop(s_sfx_engine, 70);
}
/* Stop the engine loop sound */
static void engine_stop(SpacecraftData *sd) {
if (sd->engine_channel < 0) return;
audio_stop_channel(sd->engine_channel);
sd->engine_channel = -1;
}
static void spacecraft_update(Entity *self, float dt, const Tilemap *map) {
(void)map;
SpacecraftData *sd = (SpacecraftData *)self->data;
if (!sd) return;
sd->state_timer += dt;
switch (sd->state) {
case SC_FLYING_IN: {
/* Move from start_pos toward target_pos */
Vec2 dir = vec2_sub(sd->target_pos, sd->start_pos);
float total_dist = vec2_len(dir);
if (total_dist < 1.0f) {
/* Already at target */
engine_stop(sd);
sd->state = SC_LANDING;
sd->state_timer = 0.0f;
self->body.pos = sd->target_pos;
break;
}
Vec2 norm = vec2_scale(dir, 1.0f / total_dist);
float traveled = sd->state_timer * sd->fly_speed;
if (traveled >= total_dist) {
/* Arrived at landing zone */
engine_stop(sd);
self->body.pos = sd->target_pos;
sd->state = SC_LANDING;
sd->state_timer = 0.0f;
} else {
self->body.pos = vec2_add(sd->start_pos,
vec2_scale(norm, traveled));
emit_thruster_particles(self->body.pos, 1.0f);
}
break;
}
case SC_LANDING: {
float progress = sd->state_timer / SC_LAND_DURATION;
if (progress >= 1.0f) {
progress = 1.0f;
self->body.pos = sd->target_pos;
sd->state = SC_LANDED;
sd->state_timer = 0.0f;
/* Play synth on touchdown */
audio_play_sound(s_sfx_synth, 80);
} else {
/* Ease down from slightly above target to target */
float height_offset = (1.0f - progress) * 30.0f;
self->body.pos = vec2(
sd->target_pos.x,
sd->target_pos.y - height_offset
);
emit_thruster_particles(self->body.pos, 1.0f - progress);
}
break;
}
case SC_LANDED: {
self->body.pos = sd->target_pos;
/* Stays landed until spacecraft_takeoff() is called externally
* (e.g. by level.c after spawning the player). */
break;
}
case SC_TAKEOFF: {
float progress = sd->state_timer / SC_TAKEOFF_DURATION;
if (progress >= 1.0f) {
sd->state = SC_FLYING_OUT;
sd->state_timer = 0.0f;
engine_start(sd);
} else {
float height_offset = progress * 30.0f;
self->body.pos = vec2(
sd->target_pos.x,
sd->target_pos.y - height_offset
);
emit_thruster_particles(self->body.pos, progress);
}
break;
}
case SC_FLYING_OUT: {
/* Fly upward and to the right, off-screen */
float speed = sd->fly_speed * 1.5f;
self->body.pos.x += speed * 0.7f * dt;
self->body.pos.y -= speed * dt;
emit_thruster_particles(self->body.pos, 1.0f);
/* Check if far enough off-screen */
if (self->body.pos.y < -SC_OFFSCREEN_DIST ||
self->body.pos.x > sd->target_pos.x + SC_OFFSCREEN_DIST * 2.0f) {
engine_stop(sd);
sd->state = SC_DONE;
sd->state_timer = 0.0f;
}
break;
}
case SC_DONE:
/* Remove the entity */
entity_destroy(s_em, self);
return;
}
}
static void spacecraft_render(Entity *self, const Camera *cam) {
(void)cam;
SpacecraftData *sd = (SpacecraftData *)self->data;
if (!sd || !s_texture) return;
/* Determine which frame to show */
int frame = SC_FRAME_LANDED;
switch (sd->state) {
case SC_FLYING_IN:
frame = SC_FRAME_LAND1; /* thrusters on while flying */
break;
case SC_LANDING: {
float progress = sd->state_timer / SC_LAND_DURATION;
if (progress > 1.0f) progress = 1.0f;
frame = landing_frame(progress);
break;
}
case SC_LANDED:
frame = SC_FRAME_LANDED;
break;
case SC_TAKEOFF: {
float progress = sd->state_timer / SC_TAKEOFF_DURATION;
if (progress > 1.0f) progress = 1.0f;
frame = takeoff_frame(progress);
break;
}
case SC_FLYING_OUT:
frame = SC_FRAME_LAND1;
break;
case SC_DONE:
return;
}
SDL_Rect src = frame_rect(frame);
Sprite spr = {
.texture = s_texture,
.src = src,
.pos = self->body.pos,
.size = vec2(SPACECRAFT_WIDTH, SPACECRAFT_HEIGHT),
.flip_x = false,
.flip_y = false,
.layer = LAYER_ENTITIES,
.alpha = 255,
.rotation = 0.0,
};
renderer_submit(&spr);
}
static void spacecraft_destroy(Entity *self) {
if (self->data) {
SpacecraftData *sd = (SpacecraftData *)self->data;
engine_stop(sd);
free(self->data);
self->data = NULL;
}
}
/* ═══════════════════════════════════════════════════
* Public API
* ═══════════════════════════════════════════════════ */
void spacecraft_register(EntityManager *em) {
entity_register(em, ENT_SPACECRAFT,
spacecraft_update, spacecraft_render, spacecraft_destroy);
s_em = em;
load_texture();
load_sfx();
}
Entity *spacecraft_spawn(EntityManager *em, Vec2 land_pos) {
load_texture();
/* land_pos is the top-left of where the ship should rest on the
* ground (as provided by the ENTITY directive in .lvl files). */
Vec2 target = land_pos;
/* Start off-screen: above and to the right */
Vec2 start = vec2(
target.x + SC_OFFSCREEN_DIST,
target.y - SC_OFFSCREEN_DIST
);
Entity *e = entity_spawn(em, ENT_SPACECRAFT, start);
if (!e) return NULL;
e->body.size = vec2(SPACECRAFT_WIDTH, SPACECRAFT_HEIGHT);
e->body.gravity_scale = 0.0f; /* manual movement, no physics */
e->health = 999;
e->max_health = 999;
e->damage = 0;
e->flags |= ENTITY_INVINCIBLE;
SpacecraftData *sd = calloc(1, sizeof(SpacecraftData));
if (!sd) {
entity_destroy(em, e);
return NULL;
}
sd->state = SC_FLYING_IN;
sd->state_timer = 0.0f;
sd->target_pos = target;
sd->start_pos = start;
sd->fly_speed = SC_FLY_SPEED;
sd->is_exit_ship = false;
sd->engine_channel = -1;
e->data = sd;
/* Start engine sound for fly-in */
engine_start(sd);
printf("Spacecraft spawned (fly-in to %.0f, %.0f)\n",
target.x, target.y);
return e;
}
void spacecraft_takeoff(Entity *ship) {
if (!ship || !ship->data) return;
SpacecraftData *sd = (SpacecraftData *)ship->data;
if (sd->state == SC_LANDED) {
sd->state = SC_TAKEOFF;
sd->state_timer = 0.0f;
}
}
bool spacecraft_is_done(const Entity *ship) {
if (!ship || !ship->data) return true;
const SpacecraftData *sd = (const SpacecraftData *)ship->data;
return sd->state == SC_DONE;
}
bool spacecraft_is_landed(const Entity *ship) {
if (!ship || !ship->data) return false;
const SpacecraftData *sd = (const SpacecraftData *)ship->data;
return sd->state == SC_LANDED;
}