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.
This commit is contained in:
@@ -2,9 +2,17 @@
|
||||
#include "config.h"
|
||||
#include <SDL2/SDL_mixer.h>
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
|
||||
static bool s_initialized = false;
|
||||
|
||||
/* ── Listener state for spatial audio ────────────── */
|
||||
static Vec2 s_listener_pos = {0};
|
||||
static bool s_listener_set = false;
|
||||
|
||||
/* Default max audible distance in pixels (~40 tiles) */
|
||||
#define AUDIO_DEFAULT_MAX_DIST 640.0f
|
||||
|
||||
bool audio_init(void) {
|
||||
/* Initialize MP3 (and OGG) decoder support */
|
||||
int mix_flags = MIX_INIT_MP3 | MIX_INIT_OGG;
|
||||
@@ -66,9 +74,25 @@ void audio_play_sound(Sound s, int volume) {
|
||||
int ch = Mix_PlayChannel(-1, (Mix_Chunk *)s.chunk, 0);
|
||||
if (ch >= 0) {
|
||||
Mix_Volume(ch, volume);
|
||||
Mix_SetPanning(ch, 255, 255); /* reset to center (no panning) */
|
||||
}
|
||||
}
|
||||
|
||||
int audio_play_sound_loop(Sound s, int volume) {
|
||||
if (!s_initialized || !s.chunk) return -1;
|
||||
int ch = Mix_PlayChannel(-1, (Mix_Chunk *)s.chunk, -1); /* -1 = loop forever */
|
||||
if (ch >= 0) {
|
||||
Mix_Volume(ch, volume);
|
||||
Mix_SetPanning(ch, 255, 255); /* reset to center */
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
void audio_stop_channel(int ch) {
|
||||
if (!s_initialized || ch < 0) return;
|
||||
Mix_HaltChannel(ch);
|
||||
}
|
||||
|
||||
void audio_play_music(Music m, bool loop) {
|
||||
if (!s_initialized) {
|
||||
fprintf(stderr, "audio_play_music: audio not initialized\n");
|
||||
@@ -99,11 +123,60 @@ void audio_set_music_volume(int volume) {
|
||||
Mix_VolumeMusic(volume);
|
||||
}
|
||||
|
||||
/* ── Positional / spatial audio ──────────────────── */
|
||||
|
||||
void audio_set_listener(Vec2 pos) {
|
||||
s_listener_pos = pos;
|
||||
s_listener_set = true;
|
||||
}
|
||||
|
||||
void audio_play_sound_at(Sound s, int base_volume, Vec2 world_pos, float max_dist) {
|
||||
if (!s_initialized || !s.chunk) return;
|
||||
|
||||
if (max_dist <= 0.0f) max_dist = AUDIO_DEFAULT_MAX_DIST;
|
||||
|
||||
/* Fall back to non-positional if listener was never set */
|
||||
if (!s_listener_set) {
|
||||
audio_play_sound(s, base_volume);
|
||||
return;
|
||||
}
|
||||
|
||||
float dx = world_pos.x - s_listener_pos.x;
|
||||
float dy = world_pos.y - s_listener_pos.y;
|
||||
float dist = sqrtf(dx * dx + dy * dy);
|
||||
|
||||
/* Inaudible beyond max distance */
|
||||
if (dist >= max_dist) return;
|
||||
|
||||
/* Linear attenuation: full volume at dist=0, silent at max_dist */
|
||||
float atten = 1.0f - (dist / max_dist);
|
||||
int vol = (int)(base_volume * atten);
|
||||
if (vol <= 0) return;
|
||||
if (vol > 128) vol = 128;
|
||||
|
||||
int ch = Mix_PlayChannel(-1, (Mix_Chunk *)s.chunk, 0);
|
||||
if (ch < 0) return;
|
||||
|
||||
Mix_Volume(ch, vol);
|
||||
|
||||
/* Stereo panning: center = 127, hard-left = 0, hard-right = 254.
|
||||
* Pan linearly based on horizontal offset relative to half the
|
||||
* viewport width (~half of SCREEN_WIDTH). */
|
||||
float half_screen = (float)SCREEN_WIDTH * 0.5f;
|
||||
float pan_ratio = dx / half_screen; /* -1..+1 roughly */
|
||||
if (pan_ratio < -1.0f) pan_ratio = -1.0f;
|
||||
if (pan_ratio > 1.0f) pan_ratio = 1.0f;
|
||||
Uint8 pan_right = (Uint8)(127 + (int)(pan_ratio * 127.0f));
|
||||
Uint8 pan_left = 254 - pan_right;
|
||||
Mix_SetPanning(ch, pan_left, pan_right);
|
||||
}
|
||||
|
||||
void audio_shutdown(void) {
|
||||
if (!s_initialized) return;
|
||||
Mix_HaltMusic();
|
||||
Mix_CloseAudio();
|
||||
Mix_Quit();
|
||||
s_initialized = false;
|
||||
s_listener_set = false;
|
||||
printf("Audio shut down.\n");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define JNR_AUDIO_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include "util/vec2.h"
|
||||
|
||||
typedef struct Sound {
|
||||
void *chunk; /* Mix_Chunk* */
|
||||
@@ -21,4 +22,23 @@ void audio_free_music(Music *m);
|
||||
void audio_set_music_volume(int volume); /* 0-128 */
|
||||
void audio_shutdown(void);
|
||||
|
||||
/* ── Looping sounds ────────────────────────────── */
|
||||
|
||||
/* Play a sound on loop, returns the channel number (or -1 on failure).
|
||||
* Use audio_stop_channel() to stop it later. */
|
||||
int audio_play_sound_loop(Sound s, int volume);
|
||||
|
||||
/* Stop a specific channel (from audio_play_sound_loop). No-op if ch < 0. */
|
||||
void audio_stop_channel(int ch);
|
||||
|
||||
/* ── Positional / spatial audio ────────────────── */
|
||||
|
||||
/* Set the listener position (call once per frame, typically from camera center) */
|
||||
void audio_set_listener(Vec2 pos);
|
||||
|
||||
/* Play a sound at a world position. Volume is attenuated by distance from the
|
||||
* listener and stereo-panned based on horizontal offset. Sounds beyond
|
||||
* max_dist are inaudible. Pass max_dist <= 0 for a sensible default. */
|
||||
void audio_play_sound_at(Sound s, int base_volume, Vec2 world_pos, float max_dist);
|
||||
|
||||
#endif /* JNR_AUDIO_H */
|
||||
|
||||
@@ -27,6 +27,7 @@ typedef enum EntityType {
|
||||
ENT_POWERUP,
|
||||
ENT_DRONE,
|
||||
ENT_ASTEROID,
|
||||
ENT_SPACECRAFT,
|
||||
ENT_TYPE_COUNT
|
||||
} EntityType;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "game/hazards.h"
|
||||
#include "game/powerup.h"
|
||||
#include "game/drone.h"
|
||||
#include "game/spacecraft.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
@@ -67,6 +68,7 @@ void entity_registry_init(EntityManager *em) {
|
||||
powerup_register(em);
|
||||
drone_register(em);
|
||||
asteroid_register(em);
|
||||
spacecraft_register(em);
|
||||
|
||||
/* ════════════════════════════════════════════
|
||||
* REGISTRY TABLE
|
||||
@@ -92,6 +94,7 @@ void entity_registry_init(EntityManager *em) {
|
||||
reg_add("powerup_drone", "Drone Pickup", spawn_powerup_drone, (SDL_Color){80, 200, 255, 255}, 12, 12);
|
||||
reg_add("powerup_gun", "Gun Pickup", spawn_powerup_gun, (SDL_Color){200, 200, 220, 255}, 12, 12);
|
||||
reg_add("asteroid", "Asteroid", asteroid_spawn, (SDL_Color){140, 110, 80, 255}, ASTEROID_WIDTH, ASTEROID_HEIGHT);
|
||||
reg_add("spacecraft", "Spacecraft", spacecraft_spawn, (SDL_Color){187, 187, 187, 255}, SPACECRAFT_WIDTH, SPACECRAFT_HEIGHT);
|
||||
|
||||
printf("Entity registry: %d types registered\n", g_entity_registry.count);
|
||||
}
|
||||
|
||||
@@ -702,7 +702,7 @@ static void asteroid_update(Entity *self, float dt, const Tilemap *map) {
|
||||
particle_emit_death_puff(hit_pos, (SDL_Color){140, 110, 80, 255});
|
||||
|
||||
if (s_asteroid_sfx_loaded) {
|
||||
audio_play_sound(s_sfx_asteroid_impact, 80);
|
||||
audio_play_sound_at(s_sfx_asteroid_impact, 80, hit_pos, 0);
|
||||
}
|
||||
|
||||
/* Explode on contact — hide and start respawn */
|
||||
@@ -729,7 +729,7 @@ static void asteroid_update(Entity *self, float dt, const Tilemap *map) {
|
||||
particle_emit_death_puff(impact_pos, (SDL_Color){140, 110, 80, 255});
|
||||
|
||||
if (s_asteroid_sfx_loaded) {
|
||||
audio_play_sound(s_sfx_asteroid_impact, 60);
|
||||
audio_play_sound_at(s_sfx_asteroid_impact, 60, impact_pos, 0);
|
||||
}
|
||||
|
||||
/* Hide and start respawn timer */
|
||||
|
||||
148
src/game/level.c
148
src/game/level.c
@@ -5,6 +5,7 @@
|
||||
#include "game/hazards.h"
|
||||
#include "game/powerup.h"
|
||||
#include "game/drone.h"
|
||||
#include "game/spacecraft.h"
|
||||
#include "game/sprites.h"
|
||||
#include "game/entity_registry.h"
|
||||
#include "engine/core.h"
|
||||
@@ -36,7 +37,7 @@ static bool level_setup(Level *level) {
|
||||
if (!s_sfx_loaded) {
|
||||
s_sfx_hit = audio_load_sound("assets/sounds/hitHurt.wav");
|
||||
s_sfx_enemy_death = audio_load_sound("assets/sounds/teleport.wav");
|
||||
s_sfx_pickup = audio_load_sound("assets/sounds/teleport.wav");
|
||||
s_sfx_pickup = audio_load_sound("assets/sounds/powerUp.wav");
|
||||
s_sfx_loaded = true;
|
||||
}
|
||||
|
||||
@@ -92,24 +93,31 @@ static bool level_setup(Level *level) {
|
||||
(float)(level->map.width * TILE_SIZE),
|
||||
(float)(level->map.height * TILE_SIZE));
|
||||
|
||||
/* Spawn player at map spawn point */
|
||||
Entity *player = player_spawn(&level->entities, level->map.player_spawn);
|
||||
if (!player) {
|
||||
fprintf(stderr, "Failed to spawn player!\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Disarm player if level requests it */
|
||||
if (level->map.player_unarmed) {
|
||||
PlayerData *ppd = (PlayerData *)player->data;
|
||||
if (ppd) ppd->has_gun = false;
|
||||
}
|
||||
|
||||
/* Spawn entities from level data (via registry) */
|
||||
level->has_intro_ship = false;
|
||||
for (int i = 0; i < level->map.entity_spawn_count; i++) {
|
||||
EntitySpawn *es = &level->map.entity_spawns[i];
|
||||
Vec2 pos = vec2(es->x, es->y);
|
||||
entity_registry_spawn(&level->entities, es->type_name, pos);
|
||||
if (strcmp(es->type_name, "spacecraft") == 0) {
|
||||
level->has_intro_ship = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spawn player — deferred if the level has an intro spacecraft */
|
||||
if (level->has_intro_ship) {
|
||||
level->player_spawned = false;
|
||||
} else {
|
||||
Entity *player = player_spawn(&level->entities, level->map.player_spawn);
|
||||
if (!player) {
|
||||
fprintf(stderr, "Failed to spawn player!\n");
|
||||
return false;
|
||||
}
|
||||
if (level->map.player_unarmed) {
|
||||
PlayerData *ppd = (PlayerData *)player->data;
|
||||
if (ppd) ppd->has_gun = false;
|
||||
}
|
||||
level->player_spawned = true;
|
||||
}
|
||||
|
||||
/* Load level music (playback deferred to first update —
|
||||
@@ -184,7 +192,7 @@ static void damage_entity(Entity *target, int damage) {
|
||||
camera_shake(s_active_camera, 2.0f, 0.15f);
|
||||
}
|
||||
|
||||
audio_play_sound(s_sfx_enemy_death, 80);
|
||||
audio_play_sound_at(s_sfx_enemy_death, 80, center, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +350,7 @@ static void handle_collisions(EntityManager *em) {
|
||||
a->body.pos.y + a->body.size.y * 0.5f
|
||||
);
|
||||
particle_emit_spark(center, (SDL_Color){255, 255, 100, 255});
|
||||
audio_play_sound(s_sfx_pickup, 80);
|
||||
audio_play_sound_at(s_sfx_pickup, 80, center, 0);
|
||||
|
||||
/* Destroy the powerup */
|
||||
a->flags |= ENTITY_DEAD;
|
||||
@@ -403,46 +411,100 @@ void level_update(Level *level, float dt) {
|
||||
/* Set camera pointer for collision shake triggers */
|
||||
s_active_camera = &level->camera;
|
||||
|
||||
/* ── Deferred player spawn: wait for spacecraft to land ── */
|
||||
if (level->has_intro_ship && !level->player_spawned) {
|
||||
/* Find the spacecraft and check if it has landed */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_SPACECRAFT &&
|
||||
spacecraft_is_landed(e)) {
|
||||
/* Ship has landed — spawn the player at spawn point */
|
||||
Entity *player = player_spawn(&level->entities,
|
||||
level->map.player_spawn);
|
||||
if (player) {
|
||||
if (level->map.player_unarmed) {
|
||||
PlayerData *ppd = (PlayerData *)player->data;
|
||||
if (ppd) ppd->has_gun = false;
|
||||
}
|
||||
level->player_spawned = true;
|
||||
|
||||
/* Trigger takeoff now that the player has exited */
|
||||
spacecraft_takeoff(e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Update all entities */
|
||||
entity_update_all(&level->entities, dt, &level->map);
|
||||
|
||||
/* Handle collisions */
|
||||
handle_collisions(&level->entities);
|
||||
/* Handle collisions (only meaningful once player exists) */
|
||||
if (level->player_spawned) {
|
||||
handle_collisions(&level->entities);
|
||||
|
||||
/* Check exit zones */
|
||||
check_exit_zones(level);
|
||||
/* Check exit zones */
|
||||
check_exit_zones(level);
|
||||
|
||||
/* Check for player respawn */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
||||
player_respawn(e, level->map.player_spawn);
|
||||
/* Re-snap camera to player immediately */
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f
|
||||
);
|
||||
camera_follow(&level->camera, center, vec2_zero(), dt);
|
||||
break;
|
||||
/* Check for player respawn */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
||||
player_respawn(e, level->map.player_spawn);
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f
|
||||
);
|
||||
camera_follow(&level->camera, center, vec2_zero(), dt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Update particles */
|
||||
particle_update(dt);
|
||||
|
||||
/* Find player for camera tracking */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER) {
|
||||
float look_offset = player_get_look_up_offset(e);
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f + look_offset
|
||||
);
|
||||
camera_follow(&level->camera, center, e->body.vel, dt);
|
||||
break;
|
||||
/* Camera tracking — follow player if spawned, otherwise follow spacecraft */
|
||||
bool cam_tracked = false;
|
||||
if (level->player_spawned) {
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER) {
|
||||
float look_offset = player_get_look_up_offset(e);
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f + look_offset
|
||||
);
|
||||
camera_follow(&level->camera, center, e->body.vel, dt);
|
||||
cam_tracked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cam_tracked) {
|
||||
/* Follow the spacecraft during intro */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_SPACECRAFT) {
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f
|
||||
);
|
||||
camera_follow(&level->camera, center, vec2_zero(), dt);
|
||||
cam_tracked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Set audio listener to camera center */
|
||||
if (cam_tracked) {
|
||||
Vec2 listener = vec2(
|
||||
level->camera.pos.x + level->camera.viewport.x * 0.5f,
|
||||
level->camera.pos.y + level->camera.viewport.y * 0.5f
|
||||
);
|
||||
audio_set_listener(listener);
|
||||
}
|
||||
|
||||
/* Update screen shake */
|
||||
camera_update_shake(&level->camera, dt);
|
||||
|
||||
@@ -18,6 +18,10 @@ typedef struct Level {
|
||||
/* ── Exit / transition state ─────────── */
|
||||
bool exit_triggered; /* player entered an exit zone */
|
||||
char exit_target[ASSET_PATH_MAX]; /* target level path */
|
||||
|
||||
/* ── Intro sequence state ────────────── */
|
||||
bool has_intro_ship; /* level has a spacecraft intro */
|
||||
bool player_spawned; /* player has been spawned */
|
||||
} Level;
|
||||
|
||||
bool level_load(Level *level, const char *path);
|
||||
|
||||
372
src/game/spacecraft.c
Normal file
372
src/game/spacecraft.c
Normal file
@@ -0,0 +1,372 @@
|
||||
#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;
|
||||
}
|
||||
70
src/game/spacecraft.h
Normal file
70
src/game/spacecraft.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#ifndef JNR_SPACECRAFT_H
|
||||
#define JNR_SPACECRAFT_H
|
||||
|
||||
#include "engine/entity.h"
|
||||
#include "engine/animation.h"
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* Spacecraft
|
||||
*
|
||||
* The player's ship. Appears at level start (fly-in
|
||||
* and landing sequence) and at exit zones (landing
|
||||
* and fly-out when the player reaches the exit).
|
||||
*
|
||||
* Spritesheet: assets/sprites/spacecraft.png
|
||||
* Layout: 5 frames at 80x48, horizontal strip
|
||||
* 0 = landed/idle
|
||||
* 1 = landing frame 1 (high, bright thrusters)
|
||||
* 2 = landing frame 2 (mid, medium thrusters)
|
||||
* 3 = landing frame 3 (low, dim thrusters)
|
||||
* 4 = landing frame 4 (touchdown)
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
|
||||
/* Source sprite dimensions (pixels in PNG) */
|
||||
#define SPACECRAFT_SRC_W 80
|
||||
#define SPACECRAFT_SRC_H 48
|
||||
|
||||
/* Render scale — drawn larger than the source art */
|
||||
#define SPACECRAFT_SCALE 2.0f
|
||||
|
||||
/* Rendered dimensions (used for hitbox/positioning) */
|
||||
#define SPACECRAFT_WIDTH ((int)(SPACECRAFT_SRC_W * SPACECRAFT_SCALE))
|
||||
#define SPACECRAFT_HEIGHT ((int)(SPACECRAFT_SRC_H * SPACECRAFT_SCALE))
|
||||
|
||||
/* State machine */
|
||||
typedef enum SpacecraftState {
|
||||
SC_FLYING_IN, /* approaching from off-screen */
|
||||
SC_LANDING, /* playing landing animation */
|
||||
SC_LANDED, /* on the ground, idle */
|
||||
SC_TAKEOFF, /* playing takeoff animation (reverse landing) */
|
||||
SC_FLYING_OUT, /* departing off-screen */
|
||||
SC_DONE /* sequence complete, can be removed */
|
||||
} SpacecraftState;
|
||||
|
||||
typedef struct SpacecraftData {
|
||||
SpacecraftState state;
|
||||
float state_timer; /* time spent in current state */
|
||||
Vec2 target_pos; /* where to land (ground level) */
|
||||
Vec2 start_pos; /* fly-in origin (off-screen) */
|
||||
float fly_speed; /* pixels/s during fly in/out */
|
||||
bool is_exit_ship; /* true = exit ship, false = intro ship */
|
||||
int engine_channel; /* SDL_mixer channel for engine loop, -1 = none */
|
||||
} SpacecraftData;
|
||||
|
||||
/* Register the spacecraft entity type */
|
||||
void spacecraft_register(EntityManager *em);
|
||||
|
||||
/* Spawn a spacecraft that will fly in and land at the given position.
|
||||
* The position is the bottom-center of where the ship should land. */
|
||||
Entity *spacecraft_spawn(EntityManager *em, Vec2 land_pos);
|
||||
|
||||
/* Trigger takeoff on an existing landed spacecraft */
|
||||
void spacecraft_takeoff(Entity *ship);
|
||||
|
||||
/* Check if the spacecraft has finished its full sequence */
|
||||
bool spacecraft_is_done(const Entity *ship);
|
||||
|
||||
/* Check if the spacecraft is in the landed state */
|
||||
bool spacecraft_is_landed(const Entity *ship);
|
||||
|
||||
#endif /* JNR_SPACECRAFT_H */
|
||||
Reference in New Issue
Block a user