diff --git a/TODO.md b/TODO.md index 7ece9f0..91249e8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,15 @@ # TODO -## Distance-based sound effects -Sounds (explosions, impacts, etc.) should attenuate based on distance from the -player. Offscreen events far away should be inaudible. Add a helper that takes -a world position and scales volume by proximity to the camera/player. +## ~~Distance-based sound effects~~ ✓ +Implemented: `audio_set_listener()`, `audio_play_sound_at()` with linear +attenuation and stereo panning. Used by enemy death, asteroid impact, and +powerup pickup. + +## ~~Spacecraft art and entity~~ ✓ +Implemented: spacecraft PNG spritesheet (5 frames, 80x48 each), full entity +with state machine (FLYING_IN → LANDING → LANDED → TAKEOFF → FLYING_OUT → +DONE), engine/synth sounds, thruster particles, level intro sequence with +deferred player spawn. ## Large map support (5000x5000) Audit the engine for anything that breaks or becomes slow at very large map @@ -15,18 +21,10 @@ sizes. Key areas to check: - Camera bounds and coordinate overflow (float precision at large coords) - Level file parsing (row lines could exceed fgets buffer at 5000+ columns) -## Spacecraft art and entity -Create placeholder pixel art for the player's spacecraft (fits the 16x16 grid -aesthetic but spans multiple cells, roughly 4-5 tiles wide by 2-3 tiles tall). -Needs at minimum: -- Landed/idle frame (visible near spawn on planet surface levels) -- Landing animation (descending + touchdown, 3-4 frames) -- Takeoff animation (liftoff + ascending, reuse landing in reverse) -Used in two contexts: -- **Level start**: ship flies in, lands at spawn, player exits, ship takes off - and leaves — serves as the level intro sequence -- **Level exit**: ship flies in and lands when player nears the exit zone, - player enters, ship takes off — triggers level transition +## Spacecraft at level exit +Ship landing at exit zone when player approaches — player enters, ship takes +off, triggers level transition. The intro/start sequence is done; this is the +exit counterpart. ## Asteroid refinement - Reduce asteroid count significantly — occasional hazard, not a barrage diff --git a/assets/levels/moon01.lvl b/assets/levels/moon01.lvl index 9071890..4adbda7 100644 --- a/assets/levels/moon01.lvl +++ b/assets/levels/moon01.lvl @@ -12,6 +12,8 @@ PARALLAX_STYLE 4 MUSIC assets/sounds/algardalgar.ogg PLAYER_UNARMED +ENTITY spacecraft 1 14 + ENTITY asteroid 66 1 ENTITY asteroid 70 1 ENTITY asteroid 74 1 diff --git a/assets/sounds/engine.wav b/assets/sounds/engine.wav new file mode 100644 index 0000000..02db879 Binary files /dev/null and b/assets/sounds/engine.wav differ diff --git a/assets/sounds/powerUp.wav b/assets/sounds/powerUp.wav new file mode 100644 index 0000000..502eddb Binary files /dev/null and b/assets/sounds/powerUp.wav differ diff --git a/assets/sounds/synth.wav b/assets/sounds/synth.wav new file mode 100644 index 0000000..5a78cb1 Binary files /dev/null and b/assets/sounds/synth.wav differ diff --git a/assets/sprites/spacecraft.png b/assets/sprites/spacecraft.png new file mode 100644 index 0000000..0372b5b Binary files /dev/null and b/assets/sprites/spacecraft.png differ diff --git a/build/engine/animation.o b/build/engine/animation.o deleted file mode 100644 index 5906b3d..0000000 Binary files a/build/engine/animation.o and /dev/null differ diff --git a/build/engine/assets.o b/build/engine/assets.o deleted file mode 100644 index d2cb0e0..0000000 Binary files a/build/engine/assets.o and /dev/null differ diff --git a/build/engine/audio.o b/build/engine/audio.o deleted file mode 100644 index a538c69..0000000 Binary files a/build/engine/audio.o and /dev/null differ diff --git a/build/engine/camera.o b/build/engine/camera.o deleted file mode 100644 index b1e22cb..0000000 Binary files a/build/engine/camera.o and /dev/null differ diff --git a/build/engine/core.o b/build/engine/core.o deleted file mode 100644 index f88c792..0000000 Binary files a/build/engine/core.o and /dev/null differ diff --git a/build/engine/entity.o b/build/engine/entity.o deleted file mode 100644 index 66ab651..0000000 Binary files a/build/engine/entity.o and /dev/null differ diff --git a/build/engine/input.o b/build/engine/input.o deleted file mode 100644 index 4ee525c..0000000 Binary files a/build/engine/input.o and /dev/null differ diff --git a/build/engine/particle.o b/build/engine/particle.o deleted file mode 100644 index 93d62a8..0000000 Binary files a/build/engine/particle.o and /dev/null differ diff --git a/build/engine/physics.o b/build/engine/physics.o deleted file mode 100644 index 70668a6..0000000 Binary files a/build/engine/physics.o and /dev/null differ diff --git a/build/engine/renderer.o b/build/engine/renderer.o deleted file mode 100644 index e01bdf3..0000000 Binary files a/build/engine/renderer.o and /dev/null differ diff --git a/build/engine/tilemap.o b/build/engine/tilemap.o deleted file mode 100644 index 967ec7d..0000000 Binary files a/build/engine/tilemap.o and /dev/null differ diff --git a/build/game/enemy.o b/build/game/enemy.o deleted file mode 100644 index 65b38d2..0000000 Binary files a/build/game/enemy.o and /dev/null differ diff --git a/build/game/level.o b/build/game/level.o deleted file mode 100644 index 0dece01..0000000 Binary files a/build/game/level.o and /dev/null differ diff --git a/build/game/player.o b/build/game/player.o deleted file mode 100644 index 578a6a7..0000000 Binary files a/build/game/player.o and /dev/null differ diff --git a/build/game/projectile.o b/build/game/projectile.o deleted file mode 100644 index 144062c..0000000 Binary files a/build/game/projectile.o and /dev/null differ diff --git a/build/game/sprites.o b/build/game/sprites.o deleted file mode 100644 index 635c1be..0000000 Binary files a/build/game/sprites.o and /dev/null differ diff --git a/build/main.o b/build/main.o deleted file mode 100644 index 4373df1..0000000 Binary files a/build/main.o and /dev/null differ diff --git a/src/engine/audio.c b/src/engine/audio.c index 279d512..3e1b8c7 100644 --- a/src/engine/audio.c +++ b/src/engine/audio.c @@ -2,9 +2,17 @@ #include "config.h" #include #include +#include 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"); } diff --git a/src/engine/audio.h b/src/engine/audio.h index 8df34a7..3e820f1 100644 --- a/src/engine/audio.h +++ b/src/engine/audio.h @@ -2,6 +2,7 @@ #define JNR_AUDIO_H #include +#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 */ diff --git a/src/engine/entity.h b/src/engine/entity.h index 0b58bea..ff199eb 100644 --- a/src/engine/entity.h +++ b/src/engine/entity.h @@ -27,6 +27,7 @@ typedef enum EntityType { ENT_POWERUP, ENT_DRONE, ENT_ASTEROID, + ENT_SPACECRAFT, ENT_TYPE_COUNT } EntityType; diff --git a/src/game/entity_registry.c b/src/game/entity_registry.c index f1aecb3..5ed6a55 100644 --- a/src/game/entity_registry.c +++ b/src/game/entity_registry.c @@ -5,6 +5,7 @@ #include "game/hazards.h" #include "game/powerup.h" #include "game/drone.h" +#include "game/spacecraft.h" #include #include @@ -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); } diff --git a/src/game/hazards.c b/src/game/hazards.c index 3e54fbd..8e70d4a 100644 --- a/src/game/hazards.c +++ b/src/game/hazards.c @@ -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 */ diff --git a/src/game/level.c b/src/game/level.c index ed5f784..dc4ae47 100644 --- a/src/game/level.c +++ b/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); diff --git a/src/game/level.h b/src/game/level.h index 0e4c070..0188939 100644 --- a/src/game/level.h +++ b/src/game/level.h @@ -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); diff --git a/src/game/spacecraft.c b/src/game/spacecraft.c new file mode 100644 index 0000000..ba7e0d1 --- /dev/null +++ b/src/game/spacecraft.c @@ -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 +#include + +/* ═══════════════════════════════════════════════════ + * 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; +} diff --git a/src/game/spacecraft.h b/src/game/spacecraft.h new file mode 100644 index 0000000..75dc26d --- /dev/null +++ b/src/game/spacecraft.h @@ -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 */ diff --git a/tools/gen_spacecraft.py b/tools/gen_spacecraft.py new file mode 100644 index 0000000..90af1dd --- /dev/null +++ b/tools/gen_spacecraft.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Generate spacecraft spritesheet PNG for the JNR engine. + +Output: assets/sprites/spacecraft.png +Layout: 5 frames horizontally, each 80x48 pixels = 400x48 total +Frames: landed, landing1, landing2, landing3, landing4 + +The ship faces RIGHT, uses the game's pixel art color palette. +""" + +from PIL import Image + +# ── Color palette (RGBA) ───────────────────────────── +T = (0, 0, 0, 0) # transparent +BLK = (26, 26, 46, 255) # dark/outline +WHT = (238, 238, 234, 255) # white +GRY = (136, 136, 136, 255) # grey +GYD = (85, 85, 85, 255) # grey dark +GYL = (187, 187, 187, 255) # grey light +BLU = (74, 124, 189, 255) # blue accent +BLD = (54, 94, 143, 255) # blue dark +BLL = (111, 168, 220, 255) # blue light +CYN = (77, 208, 225, 255) # cyan cockpit +CYD = (0, 172, 193, 255) # cyan dark +ORG = (255, 152, 0, 255) # orange +ORD = (204, 122, 0, 255) # orange dark +YLW = (255, 213, 79, 255) # yellow +RED = (217, 68, 68, 255) # red + +W, H = 80, 48 # frame size + + +def make_frame(): + """Return a blank 80x48 pixel grid.""" + return [[T] * W for _ in range(H)] + + +def hline(grid, y, x0, x1, color): + """Draw horizontal line from x0 to x1 inclusive.""" + if y < 0 or y >= H: + return + for x in range(max(0, x0), min(W, x1 + 1)): + grid[y][x] = color + + +def pixel(grid, x, y, color): + """Set a single pixel.""" + if 0 <= x < W and 0 <= y < H: + grid[y][x] = color + + +def draw_ship_body(grid, oy=0): + """Draw the main spacecraft hull, offset vertically by oy pixels. + + Ship anatomy (facing right): + - Engine block: cols 10-18, thick rear + - Main fuselage: cols 18-58, ~10px tall + - Cockpit: cols 58-66, with CYN canopy + - Nose: cols 66-72, pointed tip + - Upper fin: cols 22-38, extends upward + - Lower fin: cols 22-38, extends downward + """ + cy = 23 + oy # vertical center of fuselage + + # ── Main fuselage (rows cy-5 to cy+4 = 10px tall) ── + for dy in range(-5, 5): + y = cy + dy + # Fuselage hull + if dy == -5: + # Top edge — light highlight + hline(grid, y, 20, 60, GYL) + elif dy == 4: + # Bottom edge — dark shadow + hline(grid, y, 20, 60, BLK) + elif dy == -4: + hline(grid, y, 18, 62, GYL) + elif dy == 3: + hline(grid, y, 18, 62, GYD) + elif dy == -1 or dy == 0: + # Blue racing stripe through center + hline(grid, y, 16, 64, BLU) + elif dy == -2: + hline(grid, y, 17, 63, GRY) + elif dy == 1: + hline(grid, y, 17, 63, GRY) + elif dy == 2: + hline(grid, y, 18, 62, GYD) + elif dy == -3: + hline(grid, y, 19, 61, GRY) + + # ── Engine block (rear, left side) ── + for dy in range(-6, 7): + y = cy + dy + if -6 <= dy <= 6: + # Engine nacelle housing + if abs(dy) <= 3: + hline(grid, y, 10, 18, GYD) + elif abs(dy) <= 5: + hline(grid, y, 12, 18, GYD) + elif abs(dy) == 6: + hline(grid, y, 14, 17, GYD) + + # Engine inner dark (thruster openings) + for dy in range(-3, 4): + y = cy + dy + if abs(dy) <= 2: + hline(grid, y, 10, 12, BLK) + elif abs(dy) == 3: + pixel(grid, 11, y, BLK) + pixel(grid, 12, y, BLK) + + # Engine highlight on top + hline(grid, cy - 4, 13, 17, GRY) + hline(grid, cy - 5, 14, 17, GRY) + + # ── Cockpit canopy ── + # Canopy frame (CYD) and glass (CYN) + for dy in range(-4, 3): + y = cy + dy + if dy == -4: + hline(grid, y, 58, 63, CYD) + elif dy == -3: + hline(grid, y, 59, 65, CYD) + hline(grid, y, 60, 64, CYN) + elif dy == -2: + hline(grid, y, 60, 66, CYD) + hline(grid, y, 61, 65, CYN) + pixel(grid, 63, y, WHT) # glint + elif dy == -1: + hline(grid, y, 61, 67, CYD) + hline(grid, y, 62, 66, CYN) + elif dy == 0: + hline(grid, y, 61, 67, CYD) + hline(grid, y, 62, 66, CYN) + pixel(grid, 64, y, WHT) # glint + elif dy == 1: + hline(grid, y, 60, 66, CYD) + hline(grid, y, 61, 65, CYN) + elif dy == 2: + hline(grid, y, 58, 64, CYD) + + # ── Nose (pointed tip facing right) ── + for dy in range(-3, 4): + y = cy + dy + if dy == 0: + hline(grid, y, 66, 73, GYL) # center line + elif abs(dy) == 1: + hline(grid, y, 66, 71, GRY) + elif abs(dy) == 2: + hline(grid, y, 65, 69, GYD) + elif abs(dy) == 3: + hline(grid, y, 65, 67, BLK) + + # Nose tip highlight + pixel(grid, 73, cy, WHT) + pixel(grid, 72, cy, WHT) + + # ── Upper wing fin ── + for i in range(8): + y = cy - 6 - i + x0 = 26 + i + x1 = 40 - i + if x0 <= x1: + hline(grid, y, x0, x1, GYD) + # Highlight top edge + if i < 7: + pixel(grid, x0, y, GYL) + # BLU accent + if x1 - x0 > 2: + pixel(grid, (x0 + x1) // 2, y, BLU) + + # Wing fin tip accent + pixel(grid, 33, cy - 13, BLU) + + # ── Lower wing fin ── + for i in range(8): + y = cy + 5 + i + x0 = 26 + i + x1 = 40 - i + if x0 <= x1: + hline(grid, y, x0, x1, GYD) + # Shadow bottom edge + if i < 7: + pixel(grid, x0, y, BLK) + # BLU accent + if x1 - x0 > 2: + pixel(grid, (x0 + x1) // 2, y, BLD) + + # ── Panel detail lines ── + for dy in [-3, 2]: + y = cy + dy + for x in [30, 42, 52]: + pixel(grid, x, y, GYD if dy < 0 else BLK) + + +def draw_landing_gear(grid, oy=0, length=4): + """Draw two landing gear struts below the hull.""" + cy = 23 + oy + gear_top = cy + 5 # just below hull bottom + + # Front gear (col 52) + for dy in range(length): + y = gear_top + dy + pixel(grid, 52, y, GYD) + pixel(grid, 53, y, GYD) + # Foot + hline(grid, gear_top + length, 51, 54, BLK) + + # Rear gear (col 26) + for dy in range(length): + y = gear_top + dy + pixel(grid, 26, y, GYD) + pixel(grid, 27, y, GYD) + # Foot + hline(grid, gear_top + length, 25, 28, BLK) + + +def draw_thrusters(grid, oy=0, intensity=0): + """Draw thruster flames behind the engine. + + intensity: 0=off, 1=dim, 2=medium, 3=bright + """ + if intensity == 0: + return + + cy = 23 + oy + + if intensity >= 3: + # Bright: long flame with white core + for dy in range(-2, 3): + y = cy + dy + if abs(dy) == 0: + hline(grid, y, 1, 9, YLW) + hline(grid, y, 3, 7, WHT) + elif abs(dy) == 1: + hline(grid, y, 3, 9, ORG) + hline(grid, y, 4, 8, YLW) + elif abs(dy) == 2: + hline(grid, y, 5, 9, ORD) + # Flame tip flicker + pixel(grid, 1, cy, WHT) + pixel(grid, 2, cy, YLW) + elif intensity >= 2: + # Medium flame + for dy in range(-1, 2): + y = cy + dy + if dy == 0: + hline(grid, y, 4, 9, YLW) + hline(grid, y, 5, 8, WHT) + else: + hline(grid, y, 5, 9, ORG) + hline(grid, y, 6, 8, YLW) + pixel(grid, 4, cy - 2, ORD) + pixel(grid, 4, cy + 2, ORD) + elif intensity >= 1: + # Dim glow + for dy in range(-1, 2): + y = cy + dy + if dy == 0: + hline(grid, y, 7, 9, ORD) + pixel(grid, 8, y, ORG) + else: + pixel(grid, 8, y, ORD) + pixel(grid, 9, y, ORD) + + +def apply_tilt(grid, tilt_pixels): + """Shift the left side of the image down by tilt_pixels to simulate + nose-up tilt. This is a crude per-column vertical shift.""" + if tilt_pixels == 0: + return grid + + new_grid = make_frame() + for x in range(W): + # Linear interpolation: left edge shifts down by tilt_pixels, + # right edge stays the same + shift = int(tilt_pixels * (1.0 - x / W)) + for y in range(H): + src_y = y - shift + if 0 <= src_y < H: + new_grid[y][x] = grid[src_y][x] + return new_grid + + +def make_landed(): + """Frame: ship landed on ground, gear down, no thrust.""" + grid = make_frame() + draw_ship_body(grid, oy=0) + draw_landing_gear(grid, oy=0, length=5) + return grid + + +def make_landing1(): + """Frame 1: ship high, tilted nose-up, bright thrusters.""" + grid = make_frame() + draw_ship_body(grid, oy=-8) + draw_thrusters(grid, oy=-8, intensity=3) + grid = apply_tilt(grid, 3) + return grid + + +def make_landing2(): + """Frame 2: ship lower, less tilt, medium thrusters.""" + grid = make_frame() + draw_ship_body(grid, oy=-4) + draw_thrusters(grid, oy=-4, intensity=2) + grid = apply_tilt(grid, 1) + return grid + + +def make_landing3(): + """Frame 3: ship nearly level, near ground, gear extending, dim thrusters.""" + grid = make_frame() + draw_ship_body(grid, oy=-2) + draw_landing_gear(grid, oy=-2, length=3) + draw_thrusters(grid, oy=-2, intensity=1) + return grid + + +def make_landing4(): + """Frame 4: ship touching down, gear extended, no thrust.""" + grid = make_frame() + draw_ship_body(grid, oy=0) + draw_landing_gear(grid, oy=0, length=5) + # Nearly identical to landed — maybe a very slight glow + pixel(grid, 9, 23, ORD) # residual engine heat + return grid + + +def grid_to_image(grid): + """Convert a pixel grid to a PIL Image.""" + img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + for y in range(H): + for x in range(W): + img.putpixel((x, y), grid[y][x]) + return img + + +def main(): + frames = [ + make_landed(), + make_landing1(), + make_landing2(), + make_landing3(), + make_landing4(), + ] + + # Combine into horizontal spritesheet + sheet = Image.new("RGBA", (W * len(frames), H), (0, 0, 0, 0)) + for i, frame in enumerate(frames): + img = grid_to_image(frame) + sheet.paste(img, (i * W, 0)) + + out_path = "assets/sprites/spacecraft.png" + sheet.save(out_path) + print(f"Saved spritesheet: {out_path} ({sheet.width}x{sheet.height})") + + +if __name__ == "__main__": + main()