forked from tas/major_tom
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:
30
TODO.md
30
TODO.md
@@ -1,9 +1,15 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## Distance-based sound effects
|
## ~~Distance-based sound effects~~ ✓
|
||||||
Sounds (explosions, impacts, etc.) should attenuate based on distance from the
|
Implemented: `audio_set_listener()`, `audio_play_sound_at()` with linear
|
||||||
player. Offscreen events far away should be inaudible. Add a helper that takes
|
attenuation and stereo panning. Used by enemy death, asteroid impact, and
|
||||||
a world position and scales volume by proximity to the camera/player.
|
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)
|
## Large map support (5000x5000)
|
||||||
Audit the engine for anything that breaks or becomes slow at very large map
|
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)
|
- Camera bounds and coordinate overflow (float precision at large coords)
|
||||||
- Level file parsing (row lines could exceed fgets buffer at 5000+ columns)
|
- Level file parsing (row lines could exceed fgets buffer at 5000+ columns)
|
||||||
|
|
||||||
## Spacecraft art and entity
|
## Spacecraft at level exit
|
||||||
Create placeholder pixel art for the player's spacecraft (fits the 16x16 grid
|
Ship landing at exit zone when player approaches — player enters, ship takes
|
||||||
aesthetic but spans multiple cells, roughly 4-5 tiles wide by 2-3 tiles tall).
|
off, triggers level transition. The intro/start sequence is done; this is the
|
||||||
Needs at minimum:
|
exit counterpart.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Asteroid refinement
|
## Asteroid refinement
|
||||||
- Reduce asteroid count significantly — occasional hazard, not a barrage
|
- Reduce asteroid count significantly — occasional hazard, not a barrage
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ PARALLAX_STYLE 4
|
|||||||
MUSIC assets/sounds/algardalgar.ogg
|
MUSIC assets/sounds/algardalgar.ogg
|
||||||
PLAYER_UNARMED
|
PLAYER_UNARMED
|
||||||
|
|
||||||
|
ENTITY spacecraft 1 14
|
||||||
|
|
||||||
ENTITY asteroid 66 1
|
ENTITY asteroid 66 1
|
||||||
ENTITY asteroid 70 1
|
ENTITY asteroid 70 1
|
||||||
ENTITY asteroid 74 1
|
ENTITY asteroid 74 1
|
||||||
|
|||||||
BIN
assets/sounds/engine.wav
Normal file
BIN
assets/sounds/engine.wav
Normal file
Binary file not shown.
BIN
assets/sounds/powerUp.wav
Normal file
BIN
assets/sounds/powerUp.wav
Normal file
Binary file not shown.
BIN
assets/sounds/synth.wav
Normal file
BIN
assets/sounds/synth.wav
Normal file
Binary file not shown.
BIN
assets/sprites/spacecraft.png
Normal file
BIN
assets/sprites/spacecraft.png
Normal file
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.
BIN
build/main.o
BIN
build/main.o
Binary file not shown.
@@ -2,9 +2,17 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include <SDL2/SDL_mixer.h>
|
#include <SDL2/SDL_mixer.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
static bool s_initialized = false;
|
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) {
|
bool audio_init(void) {
|
||||||
/* Initialize MP3 (and OGG) decoder support */
|
/* Initialize MP3 (and OGG) decoder support */
|
||||||
int mix_flags = MIX_INIT_MP3 | MIX_INIT_OGG;
|
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);
|
int ch = Mix_PlayChannel(-1, (Mix_Chunk *)s.chunk, 0);
|
||||||
if (ch >= 0) {
|
if (ch >= 0) {
|
||||||
Mix_Volume(ch, volume);
|
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) {
|
void audio_play_music(Music m, bool loop) {
|
||||||
if (!s_initialized) {
|
if (!s_initialized) {
|
||||||
fprintf(stderr, "audio_play_music: audio not initialized\n");
|
fprintf(stderr, "audio_play_music: audio not initialized\n");
|
||||||
@@ -99,11 +123,60 @@ void audio_set_music_volume(int volume) {
|
|||||||
Mix_VolumeMusic(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) {
|
void audio_shutdown(void) {
|
||||||
if (!s_initialized) return;
|
if (!s_initialized) return;
|
||||||
Mix_HaltMusic();
|
Mix_HaltMusic();
|
||||||
Mix_CloseAudio();
|
Mix_CloseAudio();
|
||||||
Mix_Quit();
|
Mix_Quit();
|
||||||
s_initialized = false;
|
s_initialized = false;
|
||||||
|
s_listener_set = false;
|
||||||
printf("Audio shut down.\n");
|
printf("Audio shut down.\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#define JNR_AUDIO_H
|
#define JNR_AUDIO_H
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include "util/vec2.h"
|
||||||
|
|
||||||
typedef struct Sound {
|
typedef struct Sound {
|
||||||
void *chunk; /* Mix_Chunk* */
|
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_set_music_volume(int volume); /* 0-128 */
|
||||||
void audio_shutdown(void);
|
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 */
|
#endif /* JNR_AUDIO_H */
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ typedef enum EntityType {
|
|||||||
ENT_POWERUP,
|
ENT_POWERUP,
|
||||||
ENT_DRONE,
|
ENT_DRONE,
|
||||||
ENT_ASTEROID,
|
ENT_ASTEROID,
|
||||||
|
ENT_SPACECRAFT,
|
||||||
ENT_TYPE_COUNT
|
ENT_TYPE_COUNT
|
||||||
} EntityType;
|
} EntityType;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "game/hazards.h"
|
#include "game/hazards.h"
|
||||||
#include "game/powerup.h"
|
#include "game/powerup.h"
|
||||||
#include "game/drone.h"
|
#include "game/drone.h"
|
||||||
|
#include "game/spacecraft.h"
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ void entity_registry_init(EntityManager *em) {
|
|||||||
powerup_register(em);
|
powerup_register(em);
|
||||||
drone_register(em);
|
drone_register(em);
|
||||||
asteroid_register(em);
|
asteroid_register(em);
|
||||||
|
spacecraft_register(em);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════
|
/* ════════════════════════════════════════════
|
||||||
* REGISTRY TABLE
|
* 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_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("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("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);
|
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});
|
particle_emit_death_puff(hit_pos, (SDL_Color){140, 110, 80, 255});
|
||||||
|
|
||||||
if (s_asteroid_sfx_loaded) {
|
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 */
|
/* 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});
|
particle_emit_death_puff(impact_pos, (SDL_Color){140, 110, 80, 255});
|
||||||
|
|
||||||
if (s_asteroid_sfx_loaded) {
|
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 */
|
/* 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/hazards.h"
|
||||||
#include "game/powerup.h"
|
#include "game/powerup.h"
|
||||||
#include "game/drone.h"
|
#include "game/drone.h"
|
||||||
|
#include "game/spacecraft.h"
|
||||||
#include "game/sprites.h"
|
#include "game/sprites.h"
|
||||||
#include "game/entity_registry.h"
|
#include "game/entity_registry.h"
|
||||||
#include "engine/core.h"
|
#include "engine/core.h"
|
||||||
@@ -36,7 +37,7 @@ static bool level_setup(Level *level) {
|
|||||||
if (!s_sfx_loaded) {
|
if (!s_sfx_loaded) {
|
||||||
s_sfx_hit = audio_load_sound("assets/sounds/hitHurt.wav");
|
s_sfx_hit = audio_load_sound("assets/sounds/hitHurt.wav");
|
||||||
s_sfx_enemy_death = audio_load_sound("assets/sounds/teleport.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;
|
s_sfx_loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,24 +93,31 @@ static bool level_setup(Level *level) {
|
|||||||
(float)(level->map.width * TILE_SIZE),
|
(float)(level->map.width * TILE_SIZE),
|
||||||
(float)(level->map.height * 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) */
|
/* Spawn entities from level data (via registry) */
|
||||||
|
level->has_intro_ship = false;
|
||||||
for (int i = 0; i < level->map.entity_spawn_count; i++) {
|
for (int i = 0; i < level->map.entity_spawn_count; i++) {
|
||||||
EntitySpawn *es = &level->map.entity_spawns[i];
|
EntitySpawn *es = &level->map.entity_spawns[i];
|
||||||
Vec2 pos = vec2(es->x, es->y);
|
Vec2 pos = vec2(es->x, es->y);
|
||||||
entity_registry_spawn(&level->entities, es->type_name, pos);
|
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 —
|
/* 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);
|
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
|
a->body.pos.y + a->body.size.y * 0.5f
|
||||||
);
|
);
|
||||||
particle_emit_spark(center, (SDL_Color){255, 255, 100, 255});
|
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 */
|
/* Destroy the powerup */
|
||||||
a->flags |= ENTITY_DEAD;
|
a->flags |= ENTITY_DEAD;
|
||||||
@@ -403,46 +411,100 @@ void level_update(Level *level, float dt) {
|
|||||||
/* Set camera pointer for collision shake triggers */
|
/* Set camera pointer for collision shake triggers */
|
||||||
s_active_camera = &level->camera;
|
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 */
|
/* Update all entities */
|
||||||
entity_update_all(&level->entities, dt, &level->map);
|
entity_update_all(&level->entities, dt, &level->map);
|
||||||
|
|
||||||
/* Handle collisions */
|
/* Handle collisions (only meaningful once player exists) */
|
||||||
handle_collisions(&level->entities);
|
if (level->player_spawned) {
|
||||||
|
handle_collisions(&level->entities);
|
||||||
|
|
||||||
/* Check exit zones */
|
/* Check exit zones */
|
||||||
check_exit_zones(level);
|
check_exit_zones(level);
|
||||||
|
|
||||||
/* Check for player respawn */
|
/* Check for player respawn */
|
||||||
for (int i = 0; i < level->entities.count; i++) {
|
for (int i = 0; i < level->entities.count; i++) {
|
||||||
Entity *e = &level->entities.entities[i];
|
Entity *e = &level->entities.entities[i];
|
||||||
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
||||||
player_respawn(e, level->map.player_spawn);
|
player_respawn(e, level->map.player_spawn);
|
||||||
/* Re-snap camera to player immediately */
|
Vec2 center = vec2(
|
||||||
Vec2 center = vec2(
|
e->body.pos.x + e->body.size.x * 0.5f,
|
||||||
e->body.pos.x + e->body.size.x * 0.5f,
|
e->body.pos.y + e->body.size.y * 0.5f
|
||||||
e->body.pos.y + e->body.size.y * 0.5f
|
);
|
||||||
);
|
camera_follow(&level->camera, center, vec2_zero(), dt);
|
||||||
camera_follow(&level->camera, center, vec2_zero(), dt);
|
break;
|
||||||
break;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update particles */
|
/* Update particles */
|
||||||
particle_update(dt);
|
particle_update(dt);
|
||||||
|
|
||||||
/* Find player for camera tracking */
|
/* Camera tracking — follow player if spawned, otherwise follow spacecraft */
|
||||||
for (int i = 0; i < level->entities.count; i++) {
|
bool cam_tracked = false;
|
||||||
Entity *e = &level->entities.entities[i];
|
if (level->player_spawned) {
|
||||||
if (e->active && e->type == ENT_PLAYER) {
|
for (int i = 0; i < level->entities.count; i++) {
|
||||||
float look_offset = player_get_look_up_offset(e);
|
Entity *e = &level->entities.entities[i];
|
||||||
Vec2 center = vec2(
|
if (e->active && e->type == ENT_PLAYER) {
|
||||||
e->body.pos.x + e->body.size.x * 0.5f,
|
float look_offset = player_get_look_up_offset(e);
|
||||||
e->body.pos.y + e->body.size.y * 0.5f + look_offset
|
Vec2 center = vec2(
|
||||||
);
|
e->body.pos.x + e->body.size.x * 0.5f,
|
||||||
camera_follow(&level->camera, center, e->body.vel, dt);
|
e->body.pos.y + e->body.size.y * 0.5f + look_offset
|
||||||
break;
|
);
|
||||||
|
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 */
|
/* Update screen shake */
|
||||||
camera_update_shake(&level->camera, dt);
|
camera_update_shake(&level->camera, dt);
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ typedef struct Level {
|
|||||||
/* ── Exit / transition state ─────────── */
|
/* ── Exit / transition state ─────────── */
|
||||||
bool exit_triggered; /* player entered an exit zone */
|
bool exit_triggered; /* player entered an exit zone */
|
||||||
char exit_target[ASSET_PATH_MAX]; /* target level path */
|
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;
|
} Level;
|
||||||
|
|
||||||
bool level_load(Level *level, const char *path);
|
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 */
|
||||||
360
tools/gen_spacecraft.py
Normal file
360
tools/gen_spacecraft.py
Normal 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()
|
||||||
Reference in New Issue
Block a user