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:
Thomas
2026-03-01 11:00:51 +00:00
parent dbb507bfd2
commit 49ed2d6f7b
33 changed files with 1026 additions and 61 deletions

30
TODO.md
View File

@@ -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

View File

@@ -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

BIN
assets/sounds/engine.wav Normal file

Binary file not shown.

BIN
assets/sounds/powerUp.wav Normal file

Binary file not shown.

BIN
assets/sounds/synth.wav Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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");
}

View File

@@ -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 */

View File

@@ -27,6 +27,7 @@ typedef enum EntityType {
ENT_POWERUP,
ENT_DRONE,
ENT_ASTEROID,
ENT_SPACECRAFT,
ENT_TYPE_COUNT
} EntityType;

View File

@@ -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);
}

View File

@@ -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 */

View File

@@ -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 */
/* 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;
}
/* 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) */
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);
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,10 +411,36 @@ 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 (only meaningful once player exists) */
if (level->player_spawned) {
handle_collisions(&level->entities);
/* Check exit zones */
@@ -417,7 +451,6 @@ void level_update(Level *level, float dt) {
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
@@ -426,11 +459,14 @@ void level_update(Level *level, float dt) {
break;
}
}
}
/* Update particles */
particle_update(dt);
/* Find player for camera tracking */
/* 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) {
@@ -440,9 +476,35 @@ void level_update(Level *level, float dt) {
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);

View File

@@ -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
View 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
View 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 */

360
tools/gen_spacecraft.py Normal file
View File

@@ -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()