diff --git a/assets/levels/level01.lvl b/assets/levels/level01.lvl index 1e691d8..2e7d1be 100644 --- a/assets/levels/level01.lvl +++ b/assets/levels/level01.lvl @@ -16,6 +16,13 @@ ENTITY grunt 9 16 ENTITY flyer 20 10 ENTITY flyer 30 8 +# Hazards +ENTITY turret 33 4 +ENTITY flame_vent 18 19 +ENTITY force_field 35 17 +ENTITY platform 12 11 +ENTITY platform_v 27 12 + # Tile definitions: ID TEX_X TEX_Y FLAGS # Flag 1 = SOLID, Flag 2 = PLATFORM TILEDEF 1 0 0 1 diff --git a/src/engine/audio.c b/src/engine/audio.c index cfd9fe3..279d512 100644 --- a/src/engine/audio.c +++ b/src/engine/audio.c @@ -88,6 +88,12 @@ void audio_stop_music(void) { Mix_HaltMusic(); } +void audio_free_music(Music *m) { + if (!m || !m->music) return; + Mix_FreeMusic((Mix_Music *)m->music); + m->music = NULL; +} + void audio_set_music_volume(int volume) { if (!s_initialized) return; Mix_VolumeMusic(volume); diff --git a/src/engine/audio.h b/src/engine/audio.h index 283bbc7..8df34a7 100644 --- a/src/engine/audio.h +++ b/src/engine/audio.h @@ -17,6 +17,7 @@ Music audio_load_music(const char *path); void audio_play_sound(Sound s, int volume); /* 0-128, fire and forget */ void audio_play_music(Music m, bool loop); void audio_stop_music(void); +void audio_free_music(Music *m); void audio_set_music_volume(int volume); /* 0-128 */ void audio_shutdown(void); diff --git a/src/engine/camera.c b/src/engine/camera.c index df888f1..b2d3db8 100644 --- a/src/engine/camera.c +++ b/src/engine/camera.c @@ -10,6 +10,7 @@ void camera_init(Camera *c, float vp_w, float vp_h) { c->smoothing = 5.0f; c->deadzone = vec2(30.0f, 20.0f); c->look_ahead = vec2(40.0f, 0.0f); + c->zoom = 1.0f; } void camera_set_bounds(Camera *c, float world_w, float world_h) { @@ -40,6 +41,10 @@ void camera_follow(Camera *c, Vec2 target, Vec2 velocity, float dt) { Vec2 camera_world_to_screen(const Camera *c, Vec2 world_pos) { Vec2 screen = vec2_sub(world_pos, c->pos); + /* Apply zoom */ + if (c->zoom != 1.0f && c->zoom > 0.0f) { + screen = vec2_scale(screen, c->zoom); + } /* Apply shake offset */ screen.x += c->shake_offset.x; screen.y += c->shake_offset.y; @@ -47,7 +52,15 @@ Vec2 camera_world_to_screen(const Camera *c, Vec2 world_pos) { } Vec2 camera_screen_to_world(const Camera *c, Vec2 screen_pos) { - return vec2_add(screen_pos, c->pos); + Vec2 world = screen_pos; + /* Remove shake */ + world.x -= c->shake_offset.x; + world.y -= c->shake_offset.y; + /* Reverse zoom */ + if (c->zoom != 1.0f && c->zoom > 0.0f) { + world = vec2_scale(world, 1.0f / c->zoom); + } + return vec2_add(world, c->pos); } /* ── Screen shake ────────────────────────────────── */ diff --git a/src/engine/camera.h b/src/engine/camera.h index 0e38ec7..298130a 100644 --- a/src/engine/camera.h +++ b/src/engine/camera.h @@ -12,6 +12,8 @@ typedef struct Camera { float smoothing; /* higher = slower follow (0=instant) */ Vec2 deadzone; /* half-size of deadzone rect */ Vec2 look_ahead; /* pixels to lead in move direction */ + /* Zoom (used by editor; gameplay keeps 1.0) */ + float zoom; /* 1.0 = normal, <1 = zoomed out */ /* Screen shake */ float shake_timer; /* remaining shake time */ float shake_intensity; /* current max pixel offset */ diff --git a/src/engine/entity.h b/src/engine/entity.h index 2c10f4c..9ab1e8e 100644 --- a/src/engine/entity.h +++ b/src/engine/entity.h @@ -20,6 +20,12 @@ typedef enum EntityType { ENT_PROJECTILE, ENT_PICKUP, ENT_PARTICLE, + ENT_TURRET, + ENT_MOVING_PLATFORM, + ENT_FLAME_VENT, + ENT_FORCE_FIELD, + ENT_POWERUP, + ENT_DRONE, ENT_TYPE_COUNT } EntityType; diff --git a/src/engine/input.c b/src/engine/input.c index d1811e1..597ec46 100644 --- a/src/engine/input.c +++ b/src/engine/input.c @@ -14,6 +14,19 @@ static bool s_latched_released[ACTION_COUNT]; static bool s_quit_requested; +/* ── Mouse state ──────────────────────────────────── */ +static int s_mouse_x, s_mouse_y; +static bool s_mouse_current[MOUSE_BUTTON_COUNT]; +static bool s_mouse_previous[MOUSE_BUTTON_COUNT]; +static bool s_mouse_latched_pressed[MOUSE_BUTTON_COUNT]; +static bool s_mouse_latched_released[MOUSE_BUTTON_COUNT]; +static int s_mouse_scroll; + +/* ── Raw keyboard state ───────────────────────────── */ +static const Uint8 *s_key_state = NULL; +static Uint8 s_prev_keys[SDL_NUM_SCANCODES]; +static Uint8 s_latched_keys[SDL_NUM_SCANCODES]; + /* Default key bindings (primary + alternate) */ static SDL_Scancode s_bindings[ACTION_COUNT] = { [ACTION_LEFT] = SDL_SCANCODE_LEFT, @@ -36,13 +49,28 @@ void input_init(void) { memset(s_previous, 0, sizeof(s_previous)); memset(s_latched_pressed, 0, sizeof(s_latched_pressed)); memset(s_latched_released, 0, sizeof(s_latched_released)); + memset(s_mouse_current, 0, sizeof(s_mouse_current)); + memset(s_mouse_previous, 0, sizeof(s_mouse_previous)); + memset(s_mouse_latched_pressed, 0, sizeof(s_mouse_latched_pressed)); + memset(s_mouse_latched_released, 0, sizeof(s_mouse_latched_released)); + memset(s_prev_keys, 0, sizeof(s_prev_keys)); + memset(s_latched_keys, 0, sizeof(s_latched_keys)); + s_mouse_x = s_mouse_y = 0; + s_mouse_scroll = 0; s_quit_requested = false; } void input_poll(void) { /* Save previous state */ memcpy(s_previous, s_current, sizeof(s_current)); + memcpy(s_mouse_previous, s_mouse_current, sizeof(s_mouse_current)); s_quit_requested = false; + s_mouse_scroll = 0; + + /* Save previous raw key state */ + if (s_key_state) { + memcpy(s_prev_keys, s_key_state, SDL_NUM_SCANCODES); + } /* Process SDL events */ SDL_Event event; @@ -51,14 +79,17 @@ void input_poll(void) { case SDL_QUIT: s_quit_requested = true; break; + case SDL_MOUSEWHEEL: + s_mouse_scroll += event.wheel.y; + break; } } /* Read keyboard state */ - const Uint8 *keys = SDL_GetKeyboardState(NULL); + s_key_state = SDL_GetKeyboardState(NULL); for (int i = 0; i < ACTION_COUNT; i++) { - s_current[i] = keys[s_bindings[i]]; - if (s_alt_bindings[i] && keys[s_alt_bindings[i]]) { + s_current[i] = s_key_state[s_bindings[i]]; + if (s_alt_bindings[i] && s_key_state[s_alt_bindings[i]]) { s_current[i] = true; } @@ -70,12 +101,50 @@ void input_poll(void) { s_latched_released[i] = true; } } + + /* Latch raw key edges */ + if (s_key_state) { + for (int i = 0; i < SDL_NUM_SCANCODES; i++) { + if (s_key_state[i] && !s_prev_keys[i]) { + s_latched_keys[i] = 1; + } + } + } + + /* Read mouse state */ + Uint32 buttons = SDL_GetMouseState(&s_mouse_x, &s_mouse_y); + + /* Convert window coords to logical coords (SDL handles this via + SDL_RenderSetLogicalSize, but GetMouseState returns window coords) */ + SDL_Renderer *r = SDL_GetRenderer(SDL_GetMouseFocus()); + if (r) { + float lx, ly; + SDL_RenderWindowToLogical(r, s_mouse_x, s_mouse_y, &lx, &ly); + s_mouse_x = (int)lx; + s_mouse_y = (int)ly; + } + + s_mouse_current[MOUSE_LEFT] = (buttons & SDL_BUTTON_LMASK) != 0; + s_mouse_current[MOUSE_MIDDLE] = (buttons & SDL_BUTTON_MMASK) != 0; + s_mouse_current[MOUSE_RIGHT] = (buttons & SDL_BUTTON_RMASK) != 0; + + for (int i = 0; i < MOUSE_BUTTON_COUNT; i++) { + if (s_mouse_current[i] && !s_mouse_previous[i]) { + s_mouse_latched_pressed[i] = true; + } + if (!s_mouse_current[i] && s_mouse_previous[i]) { + s_mouse_latched_released[i] = true; + } + } } void input_consume(void) { /* Clear latched states after an update tick has read them */ memset(s_latched_pressed, 0, sizeof(s_latched_pressed)); memset(s_latched_released, 0, sizeof(s_latched_released)); + memset(s_mouse_latched_pressed, 0, sizeof(s_mouse_latched_pressed)); + memset(s_mouse_latched_released, 0, sizeof(s_mouse_latched_released)); + memset(s_latched_keys, 0, sizeof(s_latched_keys)); } bool input_pressed(Action a) { @@ -94,6 +163,44 @@ bool input_quit_requested(void) { return s_quit_requested; } +/* ── Mouse queries ────────────────────────────────── */ + +void input_mouse_pos(int *x, int *y) { + if (x) *x = s_mouse_x; + if (y) *y = s_mouse_y; +} + +bool input_mouse_pressed(MouseButton btn) { + if (btn < 0 || btn >= MOUSE_BUTTON_COUNT) return false; + return s_mouse_latched_pressed[btn]; +} + +bool input_mouse_held(MouseButton btn) { + if (btn < 0 || btn >= MOUSE_BUTTON_COUNT) return false; + return s_mouse_current[btn]; +} + +bool input_mouse_released(MouseButton btn) { + if (btn < 0 || btn >= MOUSE_BUTTON_COUNT) return false; + return s_mouse_latched_released[btn]; +} + +int input_mouse_scroll(void) { + return s_mouse_scroll; +} + +/* ── Raw keyboard queries ─────────────────────────── */ + +bool input_key_pressed(SDL_Scancode key) { + if (key < 0 || key >= SDL_NUM_SCANCODES) return false; + return s_latched_keys[key] != 0; +} + +bool input_key_held(SDL_Scancode key) { + if (key < 0 || key >= SDL_NUM_SCANCODES) return false; + return s_key_state && s_key_state[key]; +} + void input_shutdown(void) { /* Nothing to clean up */ } diff --git a/src/engine/input.h b/src/engine/input.h index 846a6ef..292ef01 100644 --- a/src/engine/input.h +++ b/src/engine/input.h @@ -27,4 +27,27 @@ void input_shutdown(void); /* Returns true if SDL_QUIT was received */ bool input_quit_requested(void); +/* ── Mouse state ────────────────────────────────── */ +typedef enum MouseButton { + MOUSE_LEFT = 0, + MOUSE_MIDDLE = 1, + MOUSE_RIGHT = 2, + MOUSE_BUTTON_COUNT +} MouseButton; + +/* Mouse position in logical (game) coordinates */ +void input_mouse_pos(int *x, int *y); + +/* Mouse button queries (same semantics as keyboard) */ +bool input_mouse_pressed(MouseButton btn); +bool input_mouse_held(MouseButton btn); +bool input_mouse_released(MouseButton btn); + +/* Scroll wheel delta since last poll (positive = up) */ +int input_mouse_scroll(void); + +/* Raw keyboard access for text-like input in editor */ +bool input_key_pressed(SDL_Scancode key); +bool input_key_held(SDL_Scancode key); + #endif /* JNR_INPUT_H */ diff --git a/src/engine/parallax.c b/src/engine/parallax.c index 0b5c98a..d507ebb 100644 --- a/src/engine/parallax.c +++ b/src/engine/parallax.c @@ -231,6 +231,491 @@ void parallax_generate_nebula(Parallax *p, SDL_Renderer *renderer) { p->near_layer.owns_texture = true; } +/* ── Themed: Alien Sky (planet surface) ──────────────── */ + +static void generate_alien_sky_far(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(55); + + /* Fewer stars than deep space — atmosphere scatters light */ + for (int i = 0; i < 60; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h * 0.6f); /* stars only in upper portion */ + uint8_t brightness = (uint8_t)(60 + (int)(randf() * 80)); + /* Warm-tinted: alien atmosphere filters starlight */ + uint8_t r = clamp_u8(brightness + 20); + uint8_t g = clamp_u8(brightness - 10); + uint8_t b = clamp_u8(brightness - 30); + uint8_t a = (uint8_t)(100 + (int)(randf() * 80)); + SDL_SetRenderDrawColor(renderer, r, g, b, a); + SDL_Rect dot = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &dot); + } + + /* A few brighter stars peeking through */ + for (int i = 0; i < 8; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h * 0.4f); + SDL_SetRenderDrawColor(renderer, 255, 220, 180, 200); + SDL_Rect dot = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &dot); + } + + /* Distant mountain/terrain silhouette at bottom */ + int terrain_base = (int)(h * 0.70f); + for (int x = 0; x < w; x++) { + /* Jagged terrain profile using multiple sine waves */ + float t = (float)x / (float)w; + float h1 = sinf(t * 6.28f * 2.0f) * 15.0f; + float h2 = sinf(t * 6.28f * 5.3f + 1.2f) * 8.0f; + float h3 = sinf(t * 6.28f * 11.7f + 3.1f) * 4.0f; + int peak = terrain_base - (int)(h1 + h2 + h3); + if (peak < terrain_base - 30) peak = terrain_base - 30; + + /* Dark terrain silhouette */ + for (int y = peak; y < h; y++) { + /* Gradient: darker at bottom, slightly lighter at peaks */ + int depth = y - peak; + uint8_t r = clamp_u8(8 - depth / 8); + uint8_t g = clamp_u8(5 - depth / 10); + uint8_t b = clamp_u8(12 - depth / 6); + uint8_t a = (uint8_t)(depth < 3 ? 120 : 180); + SDL_SetRenderDrawColor(renderer, r, g, b, a); + SDL_Rect px = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &px); + } + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->far_layer.texture = tex; + p->far_layer.tex_w = w; + p->far_layer.tex_h = h; + p->far_layer.scroll_x = 0.03f; /* very slow — distant landscape */ + p->far_layer.scroll_y = 0.03f; + p->far_layer.active = true; + p->far_layer.owns_texture = true; +} + +static void generate_alien_sky_near(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(188); + + /* Atmospheric haze / dust clouds — warm oranges and reds */ + typedef struct { uint8_t r, g, b; } HazeColor; + HazeColor haze_palette[] = { + { 80, 30, 15}, /* rusty orange */ + { 60, 20, 25}, /* dark red */ + { 50, 35, 20}, /* brown dust */ + { 70, 25, 40}, /* murky purple */ + { 45, 40, 30}, /* sandy */ + }; + int haze_count = sizeof(haze_palette) / sizeof(haze_palette[0]); + + /* Haze bands across the sky */ + for (int band = 0; band < 4; band++) { + float cy = randf() * h * 0.7f + h * 0.15f; + HazeColor col = haze_palette[band % haze_count]; + int blobs = 20 + (int)(randf() * 15); + for (int b = 0; b < blobs; b++) { + float angle = randf() * (float)(2.0 * M_PI); + float dist = randf() * 100.0f; + int bx = (int)(randf() * w); + int by = (int)(cy + sinf(angle) * dist * 0.3f); + int bw = 15 + (int)(randf() * 30); + int bh = 5 + (int)(randf() * 10); + uint8_t br = clamp_u8(col.r + (int)(randf() * 20 - 10)); + uint8_t bg = clamp_u8(col.g + (int)(randf() * 20 - 10)); + uint8_t bb = clamp_u8(col.b + (int)(randf() * 20 - 10)); + SDL_SetRenderDrawColor(renderer, br, bg, bb, (uint8_t)(6 + (int)(randf() * 12))); + SDL_Rect rect = {bx - bw / 2, by - bh / 2, bw, bh}; + SDL_RenderFillRect(renderer, &rect); + } + } + + /* Floating dust particles */ + for (int i = 0; i < 40; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h); + HazeColor col = haze_palette[(int)(randf() * haze_count)]; + SDL_SetRenderDrawColor(renderer, col.r, col.g, col.b, + (uint8_t)(25 + (int)(randf() * 35))); + SDL_Rect dot = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &dot); + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->near_layer.texture = tex; + p->near_layer.tex_w = w; + p->near_layer.tex_h = h; + p->near_layer.scroll_x = 0.10f; + p->near_layer.scroll_y = 0.06f; + p->near_layer.active = true; + p->near_layer.owns_texture = true; +} + +/* ── Themed: Interior (planet base) ─────────────────── */ + +static void generate_interior_far(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(73); + + /* Wall panel grid — subtle horizontal and vertical lines */ + /* Horizontal beams */ + for (int i = 0; i < 6; i++) { + int y = (int)(randf() * h); + uint8_t a = (uint8_t)(15 + (int)(randf() * 20)); + SDL_SetRenderDrawColor(renderer, 25, 30, 40, a); + SDL_Rect beam = {0, y, w, 2}; + SDL_RenderFillRect(renderer, &beam); + } + + /* Vertical structural columns */ + for (int i = 0; i < 8; i++) { + int x = (int)(randf() * w); + int col_w = 2 + (int)(randf() * 3); + uint8_t a = (uint8_t)(12 + (int)(randf() * 18)); + SDL_SetRenderDrawColor(renderer, 20, 25, 35, a); + SDL_Rect col_rect = {x, 0, col_w, h}; + SDL_RenderFillRect(renderer, &col_rect); + } + + /* Recessed wall panels (darker rectangles) */ + for (int i = 0; i < 12; i++) { + int px = (int)(randf() * w); + int py = (int)(randf() * h); + int pw = 20 + (int)(randf() * 40); + int ph = 15 + (int)(randf() * 30); + SDL_SetRenderDrawColor(renderer, 8, 10, 18, (uint8_t)(20 + (int)(randf() * 15))); + SDL_Rect panel = {px, py, pw, ph}; + SDL_RenderFillRect(renderer, &panel); + /* Panel border highlight (top/left edge) */ + SDL_SetRenderDrawColor(renderer, 30, 35, 50, 20); + SDL_Rect edge_h = {px, py, pw, 1}; + SDL_Rect edge_v = {px, py, 1, ph}; + SDL_RenderFillRect(renderer, &edge_h); + SDL_RenderFillRect(renderer, &edge_v); + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->far_layer.texture = tex; + p->far_layer.tex_w = w; + p->far_layer.tex_h = h; + p->far_layer.scroll_x = 0.02f; /* very slow — background wall */ + p->far_layer.scroll_y = 0.02f; + p->far_layer.active = true; + p->far_layer.owns_texture = true; +} + +static void generate_interior_near(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(209); + + /* Pipes running along ceiling/floor areas */ + for (int i = 0; i < 5; i++) { + int py = (int)(randf() * h); + int pipe_h = 2 + (int)(randf() * 2); + /* Pipe body */ + uint8_t r = (uint8_t)(20 + (int)(randf() * 15)); + uint8_t g = (uint8_t)(25 + (int)(randf() * 15)); + uint8_t b = (uint8_t)(35 + (int)(randf() * 15)); + SDL_SetRenderDrawColor(renderer, r, g, b, 25); + SDL_Rect pipe = {0, py, w, pipe_h}; + SDL_RenderFillRect(renderer, &pipe); + /* Pipe highlight (top edge) */ + SDL_SetRenderDrawColor(renderer, r + 15, g + 15, b + 15, 18); + SDL_Rect highlight = {0, py, w, 1}; + SDL_RenderFillRect(renderer, &highlight); + } + + /* Small indicator lights / LEDs scattered on walls */ + for (int i = 0; i < 15; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h); + float r_chance = randf(); + if (r_chance < 0.4f) { + /* Green status light */ + SDL_SetRenderDrawColor(renderer, 30, 180, 60, 60); + } else if (r_chance < 0.65f) { + /* Amber warning */ + SDL_SetRenderDrawColor(renderer, 200, 150, 30, 50); + } else if (r_chance < 0.80f) { + /* Red alert */ + SDL_SetRenderDrawColor(renderer, 200, 40, 30, 45); + } else { + /* Blue data */ + SDL_SetRenderDrawColor(renderer, 40, 100, 200, 50); + } + SDL_Rect led = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &led); + /* Tiny glow around the LED */ + SDL_SetRenderDrawColor(renderer, 40, 60, 80, 8); + SDL_Rect glow = {x - 1, y - 1, 3, 3}; + SDL_RenderFillRect(renderer, &glow); + } + + /* Ventilation grate patterns */ + for (int i = 0; i < 4; i++) { + int gx = (int)(randf() * w); + int gy = (int)(randf() * h); + int gw = 8 + (int)(randf() * 12); + int gh = 4 + (int)(randf() * 6); + /* Grate slots (horizontal lines within a rectangle) */ + for (int s = 0; s < gh; s += 2) { + SDL_SetRenderDrawColor(renderer, 15, 18, 28, 25); + SDL_Rect slot = {gx, gy + s, gw, 1}; + SDL_RenderFillRect(renderer, &slot); + } + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->near_layer.texture = tex; + p->near_layer.tex_w = w; + p->near_layer.tex_h = h; + p->near_layer.scroll_x = 0.08f; + p->near_layer.scroll_y = 0.05f; + p->near_layer.active = true; + p->near_layer.owns_texture = true; +} + +/* ── Themed: Deep Space (space station viewports) ───── */ + +static void generate_deep_space_far(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(99); + + /* Dense starfield — station viewports show deep space clearly */ + /* Many small stars */ + for (int i = 0; i < 180; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h); + uint8_t brightness = (uint8_t)(120 + (int)(randf() * 100)); + uint8_t r = brightness, g = brightness, b = brightness; + float tint = randf(); + if (tint < 0.3f) { + b = clamp_u8(brightness + 50); + g = clamp_u8(brightness + 10); /* cool blue-white */ + } else if (tint < 0.45f) { + r = clamp_u8(brightness + 30); /* warm */ + } + SDL_SetRenderDrawColor(renderer, r, g, b, (uint8_t)(180 + (int)(randf() * 75))); + SDL_Rect dot = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &dot); + } + + /* Medium stars with halos — more prominent than default */ + for (int i = 0; i < 40; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h); + uint8_t brightness = (uint8_t)(200 + (int)(randf() * 55)); + uint8_t r = brightness, g = brightness, b = 255; + SDL_SetRenderDrawColor(renderer, r, g, b, 255); + SDL_Rect dot = {x, y, 1, 1}; + SDL_RenderFillRect(renderer, &dot); + if (randf() < 0.5f) { + SDL_SetRenderDrawColor(renderer, r, g, b, 90); + SDL_Rect hx = {x - 1, y, 3, 1}; + SDL_Rect hy = {x, y - 1, 1, 3}; + SDL_RenderFillRect(renderer, &hx); + SDL_RenderFillRect(renderer, &hy); + } + } + + /* Bright feature stars */ + for (int i = 0; i < 10; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h); + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); + SDL_Rect core = {x, y, 2, 2}; + SDL_RenderFillRect(renderer, &core); + /* Cyan-white glow */ + SDL_SetRenderDrawColor(renderer, 150, 220, 255, (uint8_t)(100 + (int)(randf() * 80))); + SDL_Rect ch = {x - 1, y, 4, 2}; + SDL_Rect cv = {x, y - 1, 2, 4}; + SDL_RenderFillRect(renderer, &ch); + SDL_RenderFillRect(renderer, &cv); + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->far_layer.texture = tex; + p->far_layer.tex_w = w; + p->far_layer.tex_h = h; + p->far_layer.scroll_x = 0.05f; + p->far_layer.scroll_y = 0.05f; + p->far_layer.active = true; + p->far_layer.owns_texture = true; +} + +static void generate_deep_space_near(Parallax *p, SDL_Renderer *renderer) { + int w = SCREEN_WIDTH; + int h = SCREEN_HEIGHT; + + SDL_Texture *tex = SDL_CreateTexture(renderer, + SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h); + if (!tex) return; + + SDL_SetRenderTarget(renderer, tex); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); + + unsigned int saved_seed = (unsigned int)rand(); + srand(251); + + /* Vivid nebula clouds — cyan, electric blue, violet */ + typedef struct { uint8_t r, g, b; } SpaceColor; + SpaceColor palette[] = { + { 20, 60, 140}, /* electric blue */ + { 30, 90, 130}, /* cyan-blue */ + { 50, 30, 120}, /* deep violet */ + { 15, 80, 100}, /* teal */ + { 70, 20, 100}, /* purple */ + }; + int palette_count = sizeof(palette) / sizeof(palette[0]); + + for (int cloud = 0; cloud < 6; cloud++) { + float cx = randf() * w; + float cy = randf() * h; + SpaceColor col = palette[cloud % palette_count]; + int blobs = 35 + (int)(randf() * 25); + for (int b = 0; b < blobs; b++) { + float angle = randf() * (float)(2.0 * M_PI); + float dist = randf() * 90.0f + randf() * 50.0f; + int bx = (int)(cx + cosf(angle) * dist); + int by = (int)(cy + sinf(angle) * dist); + int bw = 10 + (int)(randf() * 24); + int bh = 8 + (int)(randf() * 18); + uint8_t br = clamp_u8(col.r + (int)(randf() * 30 - 15)); + uint8_t bg = clamp_u8(col.g + (int)(randf() * 30 - 15)); + uint8_t bb = clamp_u8(col.b + (int)(randf() * 30 - 15)); + SDL_SetRenderDrawColor(renderer, br, bg, bb, (uint8_t)(10 + (int)(randf() * 20))); + SDL_Rect rect = {bx - bw / 2, by - bh / 2, bw, bh}; + SDL_RenderFillRect(renderer, &rect); + } + } + + /* Scattered bright dust */ + for (int i = 0; i < 50; i++) { + int x = (int)(randf() * w); + int y = (int)(randf() * h); + SpaceColor col = palette[(int)(randf() * palette_count)]; + SDL_SetRenderDrawColor(renderer, col.r, col.g, col.b, + (uint8_t)(35 + (int)(randf() * 45))); + SDL_Rect dot = {x, y, 2, 2}; + SDL_RenderFillRect(renderer, &dot); + } + + srand(saved_seed); + SDL_SetRenderTarget(renderer, NULL); + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + + p->near_layer.texture = tex; + p->near_layer.tex_w = w; + p->near_layer.tex_h = h; + p->near_layer.scroll_x = 0.15f; + p->near_layer.scroll_y = 0.10f; + p->near_layer.active = true; + p->near_layer.owns_texture = true; +} + +/* ── Themed parallax dispatcher ─────────────────────── */ + +void parallax_generate_themed(Parallax *p, SDL_Renderer *renderer, ParallaxStyle style) { + switch (style) { + case PARALLAX_STYLE_ALIEN_SKY: + generate_alien_sky_far(p, renderer); + generate_alien_sky_near(p, renderer); + break; + case PARALLAX_STYLE_INTERIOR: + generate_interior_far(p, renderer); + generate_interior_near(p, renderer); + break; + case PARALLAX_STYLE_DEEP_SPACE: + generate_deep_space_far(p, renderer); + generate_deep_space_near(p, renderer); + break; + case PARALLAX_STYLE_DEFAULT: + default: + parallax_generate_stars(p, renderer); + parallax_generate_nebula(p, renderer); + break; + } +} + /* ── Render ──────────────────────────────────────────── */ static void render_layer(const ParallaxLayer *layer, const Camera *cam, diff --git a/src/engine/parallax.h b/src/engine/parallax.h index 334b8d0..df61d69 100644 --- a/src/engine/parallax.h +++ b/src/engine/parallax.h @@ -37,6 +37,17 @@ void parallax_generate_stars(Parallax *p, SDL_Renderer *renderer); /* Generate procedural nebula/dust texture (near layer) */ void parallax_generate_nebula(Parallax *p, SDL_Renderer *renderer); +/* Themed parallax generation styles */ +typedef enum ParallaxStyle { + PARALLAX_STYLE_DEFAULT, /* generic space (same as stars+nebula) */ + PARALLAX_STYLE_ALIEN_SKY, /* alien planet surface: dusty, hazy */ + PARALLAX_STYLE_INTERIOR, /* indoor base: panels, pipes, structural */ + PARALLAX_STYLE_DEEP_SPACE, /* space station windows: vivid stars */ +} ParallaxStyle; + +/* Generate both layers with a unified style */ +void parallax_generate_themed(Parallax *p, SDL_Renderer *renderer, ParallaxStyle style); + /* Render both layers (call before tile/entity rendering) */ void parallax_render(const Parallax *p, const Camera *cam, SDL_Renderer *renderer); diff --git a/src/engine/renderer.c b/src/engine/renderer.c index 90a61ae..0d4b5d9 100644 --- a/src/engine/renderer.c +++ b/src/engine/renderer.c @@ -43,11 +43,18 @@ void renderer_flush(const Camera *cam) { screen_pos = camera_world_to_screen(cam, s->pos); } + float zw = s->size.x; + float zh = s->size.y; + if (layer != LAYER_HUD && cam && cam->zoom != 1.0f && cam->zoom > 0.0f) { + zw *= cam->zoom; + zh *= cam->zoom; + } + SDL_Rect dst = { (int)screen_pos.x, (int)screen_pos.y, - (int)s->size.x, - (int)s->size.y + (int)(zw + 0.5f), + (int)(zh + 0.5f) }; SDL_RendererFlip flip = SDL_FLIP_NONE; @@ -75,15 +82,20 @@ void renderer_present(void) { void renderer_draw_rect(Vec2 pos, Vec2 size, SDL_Color color, DrawLayer layer, const Camera *cam) { Vec2 screen_pos = pos; + float zw = size.x, zh = size.y; if (layer != LAYER_HUD && cam) { screen_pos = camera_world_to_screen(cam, pos); + if (cam->zoom != 1.0f && cam->zoom > 0.0f) { + zw *= cam->zoom; + zh *= cam->zoom; + } } SDL_Rect rect = { (int)screen_pos.x, (int)screen_pos.y, - (int)size.x, - (int)size.y + (int)(zw + 0.5f), + (int)(zh + 0.5f) }; SDL_SetRenderDrawColor(s_renderer, color.r, color.g, color.b, color.a); diff --git a/src/engine/tilemap.c b/src/engine/tilemap.c index 2daeb71..244737e 100644 --- a/src/engine/tilemap.c +++ b/src/engine/tilemap.c @@ -129,10 +129,11 @@ void tilemap_render_layer(const Tilemap *map, const uint16_t *layer, int end_x = map->width, end_y = map->height; if (cam) { + float inv_zoom = (cam->zoom > 0.0f) ? (1.0f / cam->zoom) : 1.0f; start_x = (int)(cam->pos.x / TILE_SIZE) - 1; start_y = (int)(cam->pos.y / TILE_SIZE) - 1; - end_x = start_x + (int)(cam->viewport.x / TILE_SIZE) + 3; - end_y = start_y + (int)(cam->viewport.y / TILE_SIZE) + 3; + end_x = start_x + (int)(cam->viewport.x * inv_zoom / TILE_SIZE) + 3; + end_y = start_y + (int)(cam->viewport.y * inv_zoom / TILE_SIZE) + 3; if (start_x < 0) start_x = 0; if (start_y < 0) start_y = 0; @@ -163,11 +164,16 @@ void tilemap_render_layer(const Tilemap *map, const uint16_t *layer, Vec2 world_pos = vec2(tile_to_world(x), tile_to_world(y)); Vec2 screen_pos = cam ? camera_world_to_screen(cam, world_pos) : world_pos; + float tile_draw_size = TILE_SIZE; + if (cam && cam->zoom != 1.0f && cam->zoom > 0.0f) { + tile_draw_size = TILE_SIZE * cam->zoom; + } + SDL_Rect dst = { (int)screen_pos.x, (int)screen_pos.y, - TILE_SIZE, - TILE_SIZE + (int)(tile_draw_size + 0.5f), + (int)(tile_draw_size + 0.5f) }; SDL_RenderCopy(renderer, map->tileset, &src, &dst); diff --git a/src/engine/tilemap.h b/src/engine/tilemap.h index 90f654a..f131e9e 100644 --- a/src/engine/tilemap.h +++ b/src/engine/tilemap.h @@ -46,6 +46,7 @@ typedef struct Tilemap { bool has_bg_color; /* true if BG_COLOR was set */ char parallax_far_path[ASSET_PATH_MAX]; /* far bg image path */ char parallax_near_path[ASSET_PATH_MAX]; /* near bg image path */ + int parallax_style; /* procedural bg style (0=default) */ EntitySpawn entity_spawns[MAX_ENTITY_SPAWNS]; int entity_spawn_count; } Tilemap; diff --git a/src/game/drone.c b/src/game/drone.c new file mode 100644 index 0000000..c5a82bd --- /dev/null +++ b/src/game/drone.c @@ -0,0 +1,217 @@ +#include "game/drone.h" +#include "game/sprites.h" +#include "game/projectile.h" +#include "engine/renderer.h" +#include "engine/particle.h" +#include "engine/physics.h" +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +/* ── Stashed entity manager ────────────────────────── */ +static EntityManager *s_em = NULL; + +/* ── Helpers ───────────────────────────────────────── */ + +/* Find the active player entity */ +static Entity *find_player(void) { + if (!s_em) return NULL; + for (int i = 0; i < s_em->count; i++) { + Entity *e = &s_em->entities[i]; + if (e->active && e->type == ENT_PLAYER) return e; + } + return NULL; +} + +/* Find nearest enemy within range */ +static Entity *find_nearest_enemy(Vec2 from, float range) { + if (!s_em) return NULL; + Entity *best = NULL; + float best_dist = range * range; + for (int i = 0; i < s_em->count; i++) { + Entity *e = &s_em->entities[i]; + if (!e->active || e->health <= 0) continue; + if (e->type != ENT_ENEMY_GRUNT && e->type != ENT_ENEMY_FLYER && + e->type != ENT_TURRET) continue; + Vec2 epos = vec2(e->body.pos.x + e->body.size.x * 0.5f, + e->body.pos.y + e->body.size.y * 0.5f); + float dx = epos.x - from.x; + float dy = epos.y - from.y; + float dist2 = dx * dx + dy * dy; + if (dist2 < best_dist) { + best_dist = dist2; + best = e; + } + } + return best; +} + +/* ── Callbacks ─────────────────────────────────────── */ + +static void drone_update(Entity *self, float dt, const Tilemap *map) { + (void)map; + DroneData *dd = (DroneData *)self->data; + if (!dd) return; + + /* Countdown lifetime */ + dd->lifetime -= dt; + if (dd->lifetime <= 0) { + /* Expiry: death particles and destroy */ + Vec2 center = vec2( + self->body.pos.x + self->body.size.x * 0.5f, + self->body.pos.y + self->body.size.y * 0.5f + ); + particle_emit_spark(center, (SDL_Color){50, 200, 255, 255}); + self->flags |= ENTITY_DEAD; + return; + } + + /* Follow player — orbit around player center */ + Entity *player = find_player(); + if (!player) return; + + Vec2 pcenter = vec2( + player->body.pos.x + player->body.size.x * 0.5f, + player->body.pos.y + player->body.size.y * 0.5f + ); + + dd->orbit_angle += DRONE_ORBIT_SPEED * dt; + if (dd->orbit_angle > 2.0f * (float)M_PI) + dd->orbit_angle -= 2.0f * (float)M_PI; + + float target_x = pcenter.x + cosf(dd->orbit_angle) * DRONE_ORBIT_RADIUS; + float target_y = pcenter.y + sinf(dd->orbit_angle) * DRONE_ORBIT_RADIUS; + + /* Smooth movement toward orbit position */ + float lerp = 8.0f * dt; + if (lerp > 1.0f) lerp = 1.0f; + self->body.pos.x += (target_x - self->body.pos.x - self->body.size.x * 0.5f) * lerp; + self->body.pos.y += (target_y - self->body.pos.y - self->body.size.y * 0.5f) * lerp; + + /* Shooting */ + dd->shoot_cooldown -= dt; + if (dd->shoot_cooldown <= 0) { + Vec2 drone_center = vec2( + self->body.pos.x + self->body.size.x * 0.5f, + self->body.pos.y + self->body.size.y * 0.5f + ); + Entity *target = find_nearest_enemy(drone_center, DRONE_SHOOT_RANGE); + if (target) { + Vec2 tcenter = vec2( + target->body.pos.x + target->body.size.x * 0.5f, + target->body.pos.y + target->body.size.y * 0.5f + ); + Vec2 dir = vec2(tcenter.x - drone_center.x, tcenter.y - drone_center.y); + + /* Spawn projectile from drone, counted as player bullet */ + projectile_spawn_def(s_em, &WEAPON_PLASMA, + drone_center, dir, true); + dd->shoot_cooldown = DRONE_SHOOT_CD; + + /* Muzzle flash */ + particle_emit_muzzle_flash(drone_center, dir); + } + } + + /* Trail particles — subtle engine glow */ + if ((int)(dd->lifetime * 10.0f) % 3 == 0) { + Vec2 center = vec2( + self->body.pos.x + self->body.size.x * 0.5f, + self->body.pos.y + self->body.size.y * 0.5f + ); + ParticleBurst b = { + .origin = center, + .count = 1, + .speed_min = 5.0f, + .speed_max = 15.0f, + .life_min = 0.1f, + .life_max = 0.3f, + .size_min = 1.0f, + .size_max = 1.5f, + .spread = (float)M_PI * 2.0f, + .direction = 0, + .drag = 3.0f, + .gravity_scale = 0.0f, + .color = (SDL_Color){50, 180, 255, 150}, + .color_vary = true, + }; + particle_emit(&b); + } + + /* Blink when about to expire (last 3 seconds) */ + animation_update(&self->anim, dt); +} + +static void drone_render(Entity *self, const Camera *cam) { + (void)cam; + DroneData *dd = (DroneData *)self->data; + if (!dd) return; + + /* Blink when about to expire */ + if (dd->lifetime < 3.0f) { + int blink = (int)(dd->lifetime * 8.0f); + if (blink % 2 == 0) return; /* skip rendering every other frame */ + } + + if (!g_spritesheet || !self->anim.def) return; + + SDL_Rect src = animation_current_rect(&self->anim); + Body *body = &self->body; + + /* Center sprite on hitbox */ + float draw_x = body->pos.x + body->size.x * 0.5f - SPRITE_CELL * 0.5f; + float draw_y = body->pos.y + body->size.y * 0.5f - SPRITE_CELL * 0.5f; + + Sprite spr = { + .texture = g_spritesheet, + .src = src, + .pos = vec2(draw_x, draw_y), + .size = vec2(SPRITE_CELL, SPRITE_CELL), + .flip_x = false, + .flip_y = false, + .layer = LAYER_ENTITIES, + .alpha = 255, + }; + renderer_submit(&spr); +} + +static void drone_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +/* ── Public API ────────────────────────────────────── */ + +void drone_register(EntityManager *em) { + entity_register(em, ENT_DRONE, drone_update, drone_render, drone_destroy); + s_em = em; +} + +Entity *drone_spawn(EntityManager *em, Vec2 player_pos) { + Entity *e = entity_spawn(em, ENT_DRONE, player_pos); + if (!e) return NULL; + + e->body.size = vec2(10.0f, 10.0f); + e->body.gravity_scale = 0.0f; /* floats freely */ + e->health = 99; /* practically invulnerable */ + e->max_health = 99; + e->damage = 0; + e->flags |= ENTITY_INVINCIBLE; + + DroneData *dd = calloc(1, sizeof(DroneData)); + dd->orbit_angle = 0.0f; + dd->shoot_cooldown = 0.5f; /* short initial delay */ + dd->lifetime = DRONE_DURATION; + e->data = dd; + + animation_set(&e->anim, &anim_drone); + + /* Spawn burst */ + particle_emit_spark(player_pos, (SDL_Color){50, 200, 255, 255}); + + printf("Drone deployed! (%0.0fs)\n", DRONE_DURATION); + return e; +} diff --git a/src/game/drone.h b/src/game/drone.h new file mode 100644 index 0000000..482d057 --- /dev/null +++ b/src/game/drone.h @@ -0,0 +1,33 @@ +#ifndef JNR_DRONE_H +#define JNR_DRONE_H + +#include "engine/entity.h" + +/* ═══════════════════════════════════════════════════ + * Combat Drone + * + * A small companion that orbits the player and + * periodically fires at nearby enemies. Spawned + * by the drone powerup. One drone at a time. + * Duration: 15 seconds, then self-destructs. + * ═══════════════════════════════════════════════════ */ + +#define DRONE_DURATION 15.0f /* seconds before expiry */ +#define DRONE_ORBIT_RADIUS 28.0f /* orbit distance from player */ +#define DRONE_ORBIT_SPEED 3.0f /* radians per second */ +#define DRONE_SHOOT_RANGE 120.0f /* detection range for enemies */ +#define DRONE_SHOOT_CD 1.2f /* seconds between shots */ + +typedef struct DroneData { + float orbit_angle; /* current angle around player */ + float shoot_cooldown; /* time until next shot */ + float lifetime; /* remaining lifetime */ +} DroneData; + +/* Register the drone entity type */ +void drone_register(EntityManager *em); + +/* Spawn a drone attached to the player */ +Entity *drone_spawn(EntityManager *em, Vec2 player_pos); + +#endif /* JNR_DRONE_H */ diff --git a/src/game/editor.c b/src/game/editor.c new file mode 100644 index 0000000..dd49e69 --- /dev/null +++ b/src/game/editor.c @@ -0,0 +1,1118 @@ +#include "game/editor.h" +#include "game/entity_registry.h" +#include "engine/core.h" +#include "engine/input.h" +#include "engine/renderer.h" +#include "engine/assets.h" +#include "engine/camera.h" +#include "config.h" +#include +#include +#include +#include + +/* ═══════════════════════════════════════════════════ + * Minimal 4x6 bitmap font + * + * Each character is 4 pixels wide, 6 pixels tall. + * Stored as 6 rows of 4 bits (packed in a uint32_t). + * Covers ASCII 32-95 (space through underscore). + * Lowercase maps to uppercase automatically. + * ═══════════════════════════════════════════════════ */ + +#define FONT_W 4 +#define FONT_H 6 + +/* 4-bit rows packed: row0 in bits 20-23, row1 in 16-19, etc. + * Bit order: MSB = leftmost pixel */ +static const uint32_t s_font_glyphs[64] = { + /* */ 0x000000, + /* ! */ 0x4444404, + /* " */ 0xAA0000, + /* # */ 0xAFAFA0, + /* $ */ 0x4E6E40, /* simplified $ */ + /* % */ 0x924924, /* simplified % */ + /* & */ 0x4A4AC0, + /* ' */ 0x440000, + /* ( */ 0x248840, + /* ) */ 0x842240, + /* * */ 0xA4A000, + /* + */ 0x04E400, + /* , */ 0x000048, + /* - */ 0x00E000, + /* . */ 0x000040, + /* / */ 0x224880, + /* 0 */ 0x6999960, + /* 1 */ 0x2622620, + /* 2 */ 0x6912460, + /* 3 */ 0x6921960, + /* 4 */ 0x2AAF220, + /* 5 */ 0xF88E1E0, + /* 6 */ 0x688E960, + /* 7 */ 0xF112440, + /* 8 */ 0x6966960, + /* 9 */ 0x6997120, + /* : */ 0x040400, + /* ; */ 0x040480, + /* < */ 0x248420, + /* = */ 0x0E0E00, + /* > */ 0x842480, + /* ? */ 0x6920400, + /* @ */ 0x69B9860, + /* A */ 0x699F990, + /* B */ 0xE99E9E0, + /* C */ 0x6988960, + /* D */ 0xE999E00, + /* E */ 0xF8E8F00, /* simplified E/F overlap */ + /* F */ 0xF8E8800, + /* G */ 0x698B960, + /* H */ 0x99F9900, + /* I */ 0xE444E00, + /* J */ 0x7111960, + /* K */ 0x9ACA900, + /* L */ 0x8888F00, + /* M */ 0x9FF9900, + /* N */ 0x9DDB900, + /* O */ 0x6999600, + /* P */ 0xE99E800, + /* Q */ 0x6999A70, + /* R */ 0xE99EA90, + /* S */ 0x698E960, /* reuse from earlier; close enough */ + /* T */ 0xF444400, + /* U */ 0x9999600, + /* V */ 0x999A400, + /* W */ 0x999FF90, /* simplified W */ + /* X */ 0x996690, /* simplified X */ + /* Y */ 0x996440, + /* Z */ 0xF12480, /* simplified Z */ + /* [ */ 0x688860, + /* \ */ 0x884220, + /* ] */ 0x622260, + /* ^ */ 0x4A0000, + /* _ */ 0x00000F, +}; + +static void draw_char(SDL_Renderer *r, char ch, int x, int y, SDL_Color col) { + int idx = 0; + if (ch >= 'a' && ch <= 'z') ch -= 32; /* to uppercase */ + if (ch >= 32 && ch <= 95) idx = ch - 32; + else return; + + uint32_t glyph = s_font_glyphs[idx]; + SDL_SetRenderDrawColor(r, col.r, col.g, col.b, col.a); + + for (int row = 0; row < FONT_H; row++) { + /* Extract 4 bits for this row */ + int shift = (FONT_H - 1 - row) * 4; + int bits = (glyph >> shift) & 0xF; + for (int col_bit = 0; col_bit < FONT_W; col_bit++) { + if (bits & (1 << (FONT_W - 1 - col_bit))) { + SDL_RenderDrawPoint(r, x + col_bit, y + row); + } + } + } +} + +static void draw_text(SDL_Renderer *r, const char *text, int x, int y, SDL_Color col) { + while (*text) { + draw_char(r, *text, x, y, col); + x += FONT_W + 1; /* 1px spacing */ + text++; + } +} + +/* Unused for now but useful for future centered text layouts */ +#if 0 +static int text_width(const char *text) { + int len = (int)strlen(text); + if (len == 0) return 0; + return len * (FONT_W + 1) - 1; +} +#endif + +/* ═══════════════════════════════════════════════════ + * Constants + * ═══════════════════════════════════════════════════ */ + +#define CAM_PAN_SPEED 200.0f +#define ZOOM_MIN 0.25f +#define ZOOM_MAX 2.0f +#define ZOOM_STEP 0.25f + +static const SDL_Color COL_BG = {30, 30, 46, 255}; +static const SDL_Color COL_PANEL = {20, 20, 35, 240}; +static const SDL_Color COL_PANEL_LT = {40, 40, 60, 255}; +static const SDL_Color COL_TEXT = {200, 200, 220, 255}; +static const SDL_Color COL_TEXT_DIM = {120, 120, 140, 255}; +static const SDL_Color COL_HIGHLIGHT= {255, 200, 60, 255}; +static const SDL_Color COL_GRID = {60, 60, 80, 80}; +static const SDL_Color COL_SPAWN = {60, 255, 60, 255}; +static const SDL_Color COL_ENTITY = {255, 100, 100, 255}; +static const SDL_Color COL_SELECT = {255, 255, 100, 255}; + +/* Tool names */ +static const char *s_tool_names[TOOL_COUNT] = { + "PENCIL", "ERASER", "FILL", "ENTITY", "SPAWN" +}; + +/* Layer names */ +static const char *s_layer_names[EDITOR_LAYER_COUNT] = { + "COL", "BG", "FG" +}; + +/* ═══════════════════════════════════════════════════ + * Layer access helpers + * ═══════════════════════════════════════════════════ */ + +static uint16_t *get_layer(Tilemap *map, EditorLayer layer) { + switch (layer) { + case EDITOR_LAYER_COLLISION: return map->collision_layer; + case EDITOR_LAYER_BG: return map->bg_layer; + case EDITOR_LAYER_FG: return map->fg_layer; + default: return map->collision_layer; + } +} + +static uint16_t get_tile(const Tilemap *map, const uint16_t *layer, int tx, int ty) { + if (tx < 0 || tx >= map->width || ty < 0 || ty >= map->height) return 0; + return layer[ty * map->width + tx]; +} + +static void set_tile(Tilemap *map, uint16_t *layer, int tx, int ty, uint16_t id) { + if (tx < 0 || tx >= map->width || ty < 0 || ty >= map->height) return; + layer[ty * map->width + tx] = id; +} + +/* ═══════════════════════════════════════════════════ + * Flood fill (iterative, stack-based) + * ═══════════════════════════════════════════════════ */ + +typedef struct FillNode { int x, y; } FillNode; + +static void flood_fill(Tilemap *map, uint16_t *layer, int sx, int sy, uint16_t new_id) { + uint16_t old_id = get_tile(map, layer, sx, sy); + if (old_id == new_id) return; + + int capacity = 1024; + FillNode *stack = malloc(capacity * sizeof(FillNode)); + int top = 0; + stack[top++] = (FillNode){sx, sy}; + + while (top > 0) { + FillNode n = stack[--top]; + if (n.x < 0 || n.x >= map->width || n.y < 0 || n.y >= map->height) continue; + if (get_tile(map, layer, n.x, n.y) != old_id) continue; + + set_tile(map, layer, n.x, n.y, new_id); + + /* Grow stack if needed */ + if (top + 4 >= capacity) { + capacity *= 2; + stack = realloc(stack, capacity * sizeof(FillNode)); + } + stack[top++] = (FillNode){n.x + 1, n.y}; + stack[top++] = (FillNode){n.x - 1, n.y}; + stack[top++] = (FillNode){n.x, n.y + 1}; + stack[top++] = (FillNode){n.x, n.y - 1}; + } + + free(stack); +} + +/* ═══════════════════════════════════════════════════ + * Tilemap save (reuses .lvl format) + * ═══════════════════════════════════════════════════ */ + +static bool save_tilemap(const Tilemap *map, const char *path) { + FILE *f = fopen(path, "w"); + if (!f) { + fprintf(stderr, "editor: failed to write %s\n", path); + return false; + } + + fprintf(f, "# Level created with in-game editor\n\n"); + fprintf(f, "TILESET assets/tiles/tileset.png\n"); + fprintf(f, "SIZE %d %d\n", map->width, map->height); + + /* Player spawn in tile coords */ + int stx = (int)(map->player_spawn.x / TILE_SIZE); + int sty = (int)(map->player_spawn.y / TILE_SIZE); + fprintf(f, "SPAWN %d %d\n", stx, sty); + + if (map->gravity > 0) + fprintf(f, "GRAVITY %.0f\n", map->gravity); + if (map->has_bg_color) + fprintf(f, "BG_COLOR %d %d %d\n", map->bg_color.r, map->bg_color.g, map->bg_color.b); + if (map->music_path[0]) + fprintf(f, "MUSIC %s\n", map->music_path); + if (map->parallax_far_path[0]) + fprintf(f, "PARALLAX_FAR %s\n", map->parallax_far_path); + if (map->parallax_near_path[0]) + fprintf(f, "PARALLAX_NEAR %s\n", map->parallax_near_path); + + fprintf(f, "\n"); + + /* Entity spawns */ + for (int i = 0; i < map->entity_spawn_count; i++) { + const EntitySpawn *es = &map->entity_spawns[i]; + int tx = (int)(es->x / TILE_SIZE); + int ty = (int)(es->y / TILE_SIZE); + fprintf(f, "ENTITY %s %d %d\n", es->type_name, tx, ty); + } + fprintf(f, "\n"); + + /* Tile definitions */ + for (int id = 1; id < map->tile_def_count; id++) { + const TileDef *td = &map->tile_defs[id]; + if (td->flags || td->tex_x || td->tex_y) { + fprintf(f, "TILEDEF %d %d %d %u\n", id, td->tex_x, td->tex_y, td->flags); + } + } + fprintf(f, "\n"); + + /* Write each layer */ + const char *layer_names[] = {"collision", "bg", "fg"}; + const uint16_t *layers[] = {map->collision_layer, map->bg_layer, map->fg_layer}; + + for (int l = 0; l < 3; l++) { + /* Skip empty layers */ + bool has_data = false; + for (int i = 0; i < map->width * map->height; i++) { + if (layers[l][i]) { has_data = true; break; } + } + if (!has_data && l != 0) continue; /* always write collision */ + + fprintf(f, "LAYER %s\n", layer_names[l]); + for (int y = 0; y < map->height; y++) { + for (int x = 0; x < map->width; x++) { + if (x > 0) fprintf(f, " "); + fprintf(f, "%d", layers[l][y * map->width + x]); + } + fprintf(f, "\n"); + } + fprintf(f, "\n"); + } + + fclose(f); + printf("editor: saved to %s\n", path); + return true; +} + +/* ═══════════════════════════════════════════════════ + * Editor resize + * ═══════════════════════════════════════════════════ */ + +static void resize_layer(uint16_t **layer, int old_w, int old_h, int new_w, int new_h) { + uint16_t *new_data = calloc(new_w * new_h, sizeof(uint16_t)); + int copy_w = old_w < new_w ? old_w : new_w; + int copy_h = old_h < new_h ? old_h : new_h; + for (int y = 0; y < copy_h; y++) { + for (int x = 0; x < copy_w; x++) { + new_data[y * new_w + x] = (*layer)[y * old_w + x]; + } + } + free(*layer); + *layer = new_data; +} + +static void editor_resize(Editor *ed, int new_w, int new_h) { + if (new_w < 10) new_w = 10; + if (new_h < 10) new_h = 10; + if (new_w > 4096) new_w = 4096; + if (new_h > 4096) new_h = 4096; + + int old_w = ed->map.width; + int old_h = ed->map.height; + + resize_layer(&ed->map.collision_layer, old_w, old_h, new_w, new_h); + resize_layer(&ed->map.bg_layer, old_w, old_h, new_w, new_h); + resize_layer(&ed->map.fg_layer, old_w, old_h, new_w, new_h); + + ed->map.width = new_w; + ed->map.height = new_h; + ed->dirty = true; + + camera_set_bounds(&ed->camera, + (float)(new_w * TILE_SIZE), + (float)(new_h * TILE_SIZE)); +} + +/* ═══════════════════════════════════════════════════ + * Discover tileset dimensions + * ═══════════════════════════════════════════════════ */ + +static void discover_tileset(Editor *ed) { + if (!ed->map.tileset) { + ed->tileset_cols = 0; + ed->tileset_rows = 0; + ed->tileset_total = 0; + return; + } + int tex_w, tex_h; + SDL_QueryTexture(ed->map.tileset, NULL, NULL, &tex_w, &tex_h); + ed->tileset_cols = tex_w / TILE_SIZE; + ed->tileset_rows = tex_h / TILE_SIZE; + ed->tileset_total = ed->tileset_cols * ed->tileset_rows; +} + +/* ═══════════════════════════════════════════════════ + * Lifecycle + * ═══════════════════════════════════════════════════ */ + +void editor_init(Editor *ed) { + memset(ed, 0, sizeof(Editor)); + ed->tool = TOOL_PENCIL; + ed->active_layer = EDITOR_LAYER_COLLISION; + ed->selected_tile = 1; + ed->selected_entity = 0; + ed->show_grid = true; + ed->show_all_layers = true; + ed->dragging_entity = -1; + ed->active = true; + camera_init(&ed->camera, SCREEN_WIDTH, SCREEN_HEIGHT); +} + +void editor_new_level(Editor *ed, int width, int height) { + /* Free any existing map */ + tilemap_free(&ed->map); + + memset(&ed->map, 0, sizeof(Tilemap)); + ed->map.width = width; + ed->map.height = height; + + int total = width * height; + ed->map.collision_layer = calloc(total, sizeof(uint16_t)); + ed->map.bg_layer = calloc(total, sizeof(uint16_t)); + ed->map.fg_layer = calloc(total, sizeof(uint16_t)); + + /* Default tile defs (same as levelgen) */ + ed->map.tile_defs[1] = (TileDef){0, 0, TILE_SOLID}; + ed->map.tile_defs[2] = (TileDef){1, 0, TILE_SOLID}; + ed->map.tile_defs[3] = (TileDef){2, 0, TILE_SOLID}; + ed->map.tile_defs[4] = (TileDef){0, 1, TILE_PLATFORM}; + ed->map.tile_def_count = 5; + + /* Default spawn */ + ed->map.player_spawn = vec2(3.0f * TILE_SIZE, (height - 4) * TILE_SIZE); + ed->map.gravity = DEFAULT_GRAVITY; + + /* Load tileset */ + ed->map.tileset = assets_get_texture("assets/tiles/tileset.png"); + if (ed->map.tileset) { + int tex_w; + SDL_QueryTexture(ed->map.tileset, NULL, NULL, &tex_w, NULL); + ed->map.tileset_cols = tex_w / TILE_SIZE; + } + discover_tileset(ed); + + camera_init(&ed->camera, SCREEN_WIDTH, SCREEN_HEIGHT); + camera_set_bounds(&ed->camera, + (float)(width * TILE_SIZE), + (float)(height * TILE_SIZE)); + + ed->has_file = false; + ed->dirty = false; + ed->file_path[0] = '\0'; + + printf("editor: new level %dx%d\n", width, height); +} + +bool editor_load(Editor *ed, const char *path) { + tilemap_free(&ed->map); + memset(&ed->map, 0, sizeof(Tilemap)); + + if (!tilemap_load(&ed->map, path, g_engine.renderer)) { + return false; + } + + discover_tileset(ed); + + camera_init(&ed->camera, SCREEN_WIDTH, SCREEN_HEIGHT); + camera_set_bounds(&ed->camera, + (float)(ed->map.width * TILE_SIZE), + (float)(ed->map.height * TILE_SIZE)); + + strncpy(ed->file_path, path, sizeof(ed->file_path) - 1); + ed->has_file = true; + ed->dirty = false; + + printf("editor: loaded %s\n", path); + return true; +} + +bool editor_save(Editor *ed) { + if (!ed->has_file) { + /* Default path */ + strncpy(ed->file_path, "assets/levels/edited.lvl", + sizeof(ed->file_path) - 1); + ed->has_file = true; + } + if (save_tilemap(&ed->map, ed->file_path)) { + ed->dirty = false; + return true; + } + return false; +} + +bool editor_save_as(Editor *ed, const char *path) { + strncpy(ed->file_path, path, sizeof(ed->file_path) - 1); + ed->has_file = true; + return editor_save(ed); +} + +void editor_free(Editor *ed) { + tilemap_free(&ed->map); + ed->active = false; +} + +/* ═══════════════════════════════════════════════════ + * Coordinate helpers + * ═══════════════════════════════════════════════════ */ + +/* Is the screen position inside the canvas area (not on UI panels)? */ +static bool in_canvas(int sx, int sy) { + return sx < (SCREEN_WIDTH - EDITOR_PALETTE_W) && + sy >= EDITOR_TOOLBAR_H && + sy < (SCREEN_HEIGHT - EDITOR_STATUS_H); +} + +/* Screen position to world tile coordinates */ +static void screen_to_tile(const Editor *ed, int sx, int sy, int *tx, int *ty) { + Vec2 world = camera_screen_to_world(&ed->camera, vec2((float)sx, (float)sy)); + *tx = world_to_tile(world.x); + *ty = world_to_tile(world.y); +} + +/* ═══════════════════════════════════════════════════ + * Entity helpers + * ═══════════════════════════════════════════════════ */ + +/* Find entity spawn at a tile position, return index or -1 */ +static int find_entity_at(const Tilemap *map, float wx, float wy) { + for (int i = 0; i < map->entity_spawn_count; i++) { + const EntitySpawn *es = &map->entity_spawns[i]; + const EntityRegEntry *reg = entity_registry_find(es->type_name); + float ew = reg ? (float)reg->width : TILE_SIZE; + float eh = reg ? (float)reg->height : TILE_SIZE; + if (wx >= es->x && wx < es->x + ew && + wy >= es->y && wy < es->y + eh) { + return i; + } + } + return -1; +} + +static void add_entity_spawn(Tilemap *map, const char *name, float wx, float wy) { + if (map->entity_spawn_count >= MAX_ENTITY_SPAWNS) return; + EntitySpawn *es = &map->entity_spawns[map->entity_spawn_count]; + strncpy(es->type_name, name, 31); + es->type_name[31] = '\0'; + /* Snap to tile grid */ + es->x = floorf(wx / TILE_SIZE) * TILE_SIZE; + es->y = floorf(wy / TILE_SIZE) * TILE_SIZE; + map->entity_spawn_count++; +} + +static void remove_entity_spawn(Tilemap *map, int index) { + if (index < 0 || index >= map->entity_spawn_count) return; + /* Shift remaining entries */ + for (int i = index; i < map->entity_spawn_count - 1; i++) { + map->entity_spawns[i] = map->entity_spawns[i + 1]; + } + map->entity_spawn_count--; +} + +/* ═══════════════════════════════════════════════════ + * Internal state for test-play / quit requests + * ═══════════════════════════════════════════════════ */ + +static bool s_wants_test_play = false; +static bool s_wants_quit = false; + +bool editor_wants_test_play(Editor *ed) { + (void)ed; + bool v = s_wants_test_play; + s_wants_test_play = false; + return v; +} + +bool editor_wants_quit(Editor *ed) { + (void)ed; + bool v = s_wants_quit; + s_wants_quit = false; + return v; +} + +/* ═══════════════════════════════════════════════════ + * Update — input handling + * ═══════════════════════════════════════════════════ */ + +void editor_update(Editor *ed, float dt) { + int mx, my; + input_mouse_pos(&mx, &my); + + /* ── Keyboard shortcuts ────────────────── */ + + /* Tool selection: 1-5 */ + if (input_key_pressed(SDL_SCANCODE_1)) ed->tool = TOOL_PENCIL; + if (input_key_pressed(SDL_SCANCODE_2)) ed->tool = TOOL_ERASER; + if (input_key_pressed(SDL_SCANCODE_3)) ed->tool = TOOL_FILL; + if (input_key_pressed(SDL_SCANCODE_4)) ed->tool = TOOL_ENTITY; + if (input_key_pressed(SDL_SCANCODE_5)) ed->tool = TOOL_SPAWN; + + /* Layer selection: Q/W/E */ + if (input_key_pressed(SDL_SCANCODE_Q)) ed->active_layer = EDITOR_LAYER_COLLISION; + if (input_key_pressed(SDL_SCANCODE_W)) ed->active_layer = EDITOR_LAYER_BG; + if (input_key_pressed(SDL_SCANCODE_E) && !input_key_held(SDL_SCANCODE_LCTRL)) + ed->active_layer = EDITOR_LAYER_FG; + + /* Grid toggle: G */ + if (input_key_pressed(SDL_SCANCODE_G)) ed->show_grid = !ed->show_grid; + + /* Layer visibility toggle: V */ + if (input_key_pressed(SDL_SCANCODE_V)) ed->show_all_layers = !ed->show_all_layers; + + /* Save: Ctrl+S */ + if (input_key_pressed(SDL_SCANCODE_S) && input_key_held(SDL_SCANCODE_LCTRL)) { + editor_save(ed); + } + + /* Test play: P */ + if (input_key_pressed(SDL_SCANCODE_P)) { + s_wants_test_play = true; + } + + /* Quit editor: Escape */ + if (input_key_pressed(SDL_SCANCODE_ESCAPE)) { + s_wants_quit = true; + } + + /* Resize: Ctrl+Shift+Plus/Minus for height, Ctrl+Plus/Minus for width */ + if (input_key_held(SDL_SCANCODE_LCTRL)) { + if (input_key_held(SDL_SCANCODE_LSHIFT)) { + /* Height resize takes priority when Shift is also held */ + if (input_key_pressed(SDL_SCANCODE_EQUALS)) + editor_resize(ed, ed->map.width, ed->map.height + 5); + if (input_key_pressed(SDL_SCANCODE_MINUS)) + editor_resize(ed, ed->map.width, ed->map.height - 5); + } else { + /* Width resize only when Shift is NOT held */ + if (input_key_pressed(SDL_SCANCODE_EQUALS)) + editor_resize(ed, ed->map.width + 10, ed->map.height); + if (input_key_pressed(SDL_SCANCODE_MINUS)) + editor_resize(ed, ed->map.width - 10, ed->map.height); + } + } + + /* ── Camera panning (arrow keys or WASD with no ctrl) ── */ + if (!input_key_held(SDL_SCANCODE_LCTRL)) { + float pan_speed = CAM_PAN_SPEED * dt; + /* Faster when zoomed out */ + if (ed->camera.zoom > 0.0f) + pan_speed /= ed->camera.zoom; + + if (input_key_held(SDL_SCANCODE_LEFT) || input_key_held(SDL_SCANCODE_A)) + ed->camera.pos.x -= pan_speed; + if (input_key_held(SDL_SCANCODE_RIGHT) || input_key_held(SDL_SCANCODE_D)) + ed->camera.pos.x += pan_speed; + if (input_key_held(SDL_SCANCODE_UP)) + ed->camera.pos.y -= pan_speed; + if (input_key_held(SDL_SCANCODE_DOWN) || input_key_held(SDL_SCANCODE_S)) + ed->camera.pos.y += pan_speed; + } + + /* Middle mouse drag panning */ + { + static int last_mx = 0, last_my = 0; + static bool dragging_cam = false; + if (input_mouse_held(MOUSE_MIDDLE)) { + if (!dragging_cam) { + dragging_cam = true; + last_mx = mx; + last_my = my; + } else { + float inv_zoom = (ed->camera.zoom > 0.0f) ? (1.0f / ed->camera.zoom) : 1.0f; + ed->camera.pos.x -= (float)(mx - last_mx) * inv_zoom; + ed->camera.pos.y -= (float)(my - last_my) * inv_zoom; + last_mx = mx; + last_my = my; + } + } else { + dragging_cam = false; + } + } + + /* ── Zoom (scroll wheel) ── */ + int scroll = input_mouse_scroll(); + if (scroll != 0) { + /* Compute world pos under mouse at CURRENT zoom, before changing it */ + Vec2 mouse_world = camera_screen_to_world(&ed->camera, vec2((float)mx, (float)my)); + + float old_zoom = ed->camera.zoom; + ed->camera.zoom += (float)scroll * ZOOM_STEP; + if (ed->camera.zoom < ZOOM_MIN) ed->camera.zoom = ZOOM_MIN; + if (ed->camera.zoom > ZOOM_MAX) ed->camera.zoom = ZOOM_MAX; + + /* Zoom toward mouse position: keep the world point under the + * mouse cursor in the same screen position after the zoom. */ + if (ed->camera.zoom != old_zoom && in_canvas(mx, my)) { + float new_zoom = ed->camera.zoom; + ed->camera.pos.x = mouse_world.x - (float)mx / new_zoom; + ed->camera.pos.y = mouse_world.y - (float)my / new_zoom; + } + } + + /* Clamp camera */ + float vp_w = ed->camera.viewport.x; + float vp_h = ed->camera.viewport.y; + if (ed->camera.zoom > 0.0f) { + vp_w /= ed->camera.zoom; + vp_h /= ed->camera.zoom; + } + float max_x = ed->camera.bounds_max.x - vp_w; + float max_y = ed->camera.bounds_max.y - vp_h; + if (ed->camera.pos.x < ed->camera.bounds_min.x - vp_w * 0.5f) + ed->camera.pos.x = ed->camera.bounds_min.x - vp_w * 0.5f; + if (ed->camera.pos.y < ed->camera.bounds_min.y - vp_h * 0.5f) + ed->camera.pos.y = ed->camera.bounds_min.y - vp_h * 0.5f; + if (max_x > 0 && ed->camera.pos.x > max_x + vp_w * 0.5f) + ed->camera.pos.x = max_x + vp_w * 0.5f; + if (max_y > 0 && ed->camera.pos.y > max_y + vp_h * 0.5f) + ed->camera.pos.y = max_y + vp_h * 0.5f; + + /* ── Tile palette click (right panel) ── */ + if (mx >= SCREEN_WIDTH - EDITOR_PALETTE_W && my >= EDITOR_TOOLBAR_H && + my < SCREEN_HEIGHT - EDITOR_STATUS_H) { + if (ed->tool != TOOL_ENTITY) { + /* Tile palette area */ + if (input_mouse_pressed(MOUSE_LEFT)) { + int pal_x = mx - (SCREEN_WIDTH - EDITOR_PALETTE_W) - 2; + int pal_y = my - EDITOR_TOOLBAR_H - 2 + ed->tile_palette_scroll * TILE_SIZE; + if (pal_x >= 0 && pal_y >= 0) { + int tile_col = pal_x / (TILE_SIZE + 1); + int tile_row = pal_y / (TILE_SIZE + 1); + if (ed->tileset_cols > 0) { + int tile_id = tile_row * ed->tileset_cols + tile_col + 1; + if (tile_id >= 1 && tile_id <= ed->tileset_total) { + ed->selected_tile = (uint16_t)tile_id; + } + } + } + } + /* Scroll palette */ + if (scroll != 0) { + ed->tile_palette_scroll -= scroll; + if (ed->tile_palette_scroll < 0) ed->tile_palette_scroll = 0; + } + } else { + /* Entity palette area */ + if (input_mouse_pressed(MOUSE_LEFT)) { + int pal_y = my - EDITOR_TOOLBAR_H - 2 + ed->entity_palette_scroll * 12; + int entry_idx = pal_y / 12; + if (entry_idx >= 0 && entry_idx < g_entity_registry.count) { + ed->selected_entity = entry_idx; + } + } + if (scroll != 0) { + ed->entity_palette_scroll -= scroll; + if (ed->entity_palette_scroll < 0) ed->entity_palette_scroll = 0; + } + } + return; /* Don't process canvas clicks when on palette */ + } + + /* ── Toolbar click ── */ + if (my < EDITOR_TOOLBAR_H) { + if (input_mouse_pressed(MOUSE_LEFT)) { + /* Tool buttons: each ~30px wide */ + int btn = mx / 32; + if (btn >= 0 && btn < TOOL_COUNT) { + ed->tool = (EditorTool)btn; + } + /* Layer buttons at x=170+ */ + int lx = mx - 170; + if (lx >= 0) { + int lbtn = lx / 25; + if (lbtn >= 0 && lbtn < EDITOR_LAYER_COUNT) { + ed->active_layer = (EditorLayer)lbtn; + } + } + } + return; + } + + /* ── Canvas interaction ── */ + if (!in_canvas(mx, my)) return; + + int tile_x, tile_y; + screen_to_tile(ed, mx, my, &tile_x, &tile_y); + + Vec2 world_pos = camera_screen_to_world(&ed->camera, vec2((float)mx, (float)my)); + + switch (ed->tool) { + case TOOL_PENCIL: + if (input_mouse_held(MOUSE_LEFT)) { + uint16_t *layer = get_layer(&ed->map, ed->active_layer); + set_tile(&ed->map, layer, tile_x, tile_y, ed->selected_tile); + ed->dirty = true; + } + if (input_mouse_held(MOUSE_RIGHT)) { + /* Pick tile under cursor */ + uint16_t *layer = get_layer(&ed->map, ed->active_layer); + uint16_t t = get_tile(&ed->map, layer, tile_x, tile_y); + if (t != 0) ed->selected_tile = t; + } + break; + + case TOOL_ERASER: + if (input_mouse_held(MOUSE_LEFT)) { + uint16_t *layer = get_layer(&ed->map, ed->active_layer); + set_tile(&ed->map, layer, tile_x, tile_y, 0); + ed->dirty = true; + } + break; + + case TOOL_FILL: + if (input_mouse_pressed(MOUSE_LEFT)) { + uint16_t *layer = get_layer(&ed->map, ed->active_layer); + flood_fill(&ed->map, layer, tile_x, tile_y, ed->selected_tile); + ed->dirty = true; + } + if (input_mouse_pressed(MOUSE_RIGHT)) { + uint16_t *layer = get_layer(&ed->map, ed->active_layer); + flood_fill(&ed->map, layer, tile_x, tile_y, 0); + ed->dirty = true; + } + break; + + case TOOL_ENTITY: + if (input_mouse_pressed(MOUSE_LEFT)) { + /* Check if clicking an existing entity */ + int eidx = find_entity_at(&ed->map, world_pos.x, world_pos.y); + if (eidx >= 0) { + /* Start dragging */ + ed->dragging_entity = eidx; + ed->drag_offset_x = ed->map.entity_spawns[eidx].x - world_pos.x; + ed->drag_offset_y = ed->map.entity_spawns[eidx].y - world_pos.y; + } else { + /* Place new entity */ + if (ed->selected_entity >= 0 && ed->selected_entity < g_entity_registry.count) { + add_entity_spawn(&ed->map, + g_entity_registry.entries[ed->selected_entity].name, + world_pos.x, world_pos.y); + ed->dirty = true; + } + } + } + if (input_mouse_held(MOUSE_LEFT) && ed->dragging_entity >= 0) { + /* Drag entity — snap to grid */ + float nx = floorf((world_pos.x + ed->drag_offset_x) / TILE_SIZE) * TILE_SIZE; + float ny = floorf((world_pos.y + ed->drag_offset_y) / TILE_SIZE) * TILE_SIZE; + ed->map.entity_spawns[ed->dragging_entity].x = nx; + ed->map.entity_spawns[ed->dragging_entity].y = ny; + ed->dirty = true; + } + if (input_mouse_released(MOUSE_LEFT)) { + ed->dragging_entity = -1; + } + /* Right-click to delete entity */ + if (input_mouse_pressed(MOUSE_RIGHT)) { + int eidx = find_entity_at(&ed->map, world_pos.x, world_pos.y); + if (eidx >= 0) { + remove_entity_spawn(&ed->map, eidx); + ed->dirty = true; + } + } + break; + + case TOOL_SPAWN: + if (input_mouse_pressed(MOUSE_LEFT)) { + ed->map.player_spawn = vec2( + floorf(world_pos.x / TILE_SIZE) * TILE_SIZE, + floorf(world_pos.y / TILE_SIZE) * TILE_SIZE + ); + ed->dirty = true; + } + break; + + default: + break; + } +} + +/* ═══════════════════════════════════════════════════ + * Render + * ═══════════════════════════════════════════════════ */ + +void editor_render(Editor *ed, float interpolation) { + (void)interpolation; + + SDL_Renderer *r = g_engine.renderer; + Camera *cam = &ed->camera; + + /* ── Clear background ── */ + SDL_SetRenderDrawColor(r, COL_BG.r, COL_BG.g, COL_BG.b, 255); + SDL_RenderClear(r); + + /* ── Render tile layers ── */ + if (ed->show_all_layers) { + /* Draw all layers, dim inactive ones */ + for (int l = 0; l < EDITOR_LAYER_COUNT; l++) { + uint16_t *layer = get_layer(&ed->map, (EditorLayer)l); + if (l != (int)ed->active_layer) { + SDL_SetTextureAlphaMod(ed->map.tileset, 80); + } else { + SDL_SetTextureAlphaMod(ed->map.tileset, 255); + } + tilemap_render_layer(&ed->map, layer, cam, r); + } + SDL_SetTextureAlphaMod(ed->map.tileset, 255); + } else { + /* Only active layer */ + uint16_t *layer = get_layer(&ed->map, ed->active_layer); + tilemap_render_layer(&ed->map, layer, cam, r); + } + + /* ── Grid ── */ + if (ed->show_grid) { + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(r, COL_GRID.r, COL_GRID.g, COL_GRID.b, COL_GRID.a); + + float inv_zoom = (cam->zoom > 0.0f) ? (1.0f / cam->zoom) : 1.0f; + int start_tx = (int)floorf(cam->pos.x / TILE_SIZE); + int start_ty = (int)floorf(cam->pos.y / TILE_SIZE); + int end_tx = start_tx + (int)(cam->viewport.x * inv_zoom / TILE_SIZE) + 2; + int end_ty = start_ty + (int)(cam->viewport.y * inv_zoom / TILE_SIZE) + 2; + + if (start_tx < 0) start_tx = 0; + if (start_ty < 0) start_ty = 0; + if (end_tx > ed->map.width) end_tx = ed->map.width; + if (end_ty > ed->map.height) end_ty = ed->map.height; + + /* Vertical lines */ + for (int x = start_tx; x <= end_tx; x++) { + Vec2 top = camera_world_to_screen(cam, vec2(tile_to_world(x), tile_to_world(start_ty))); + Vec2 bot = camera_world_to_screen(cam, vec2(tile_to_world(x), tile_to_world(end_ty))); + SDL_RenderDrawLine(r, (int)top.x, (int)top.y, (int)bot.x, (int)bot.y); + } + /* Horizontal lines */ + for (int y = start_ty; y <= end_ty; y++) { + Vec2 left = camera_world_to_screen(cam, vec2(tile_to_world(start_tx), tile_to_world(y))); + Vec2 right_pt = camera_world_to_screen(cam, vec2(tile_to_world(end_tx), tile_to_world(y))); + SDL_RenderDrawLine(r, (int)left.x, (int)left.y, (int)right_pt.x, (int)right_pt.y); + } + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); + } + + /* ── Level boundary ── */ + { + SDL_SetRenderDrawColor(r, 200, 200, 255, 120); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); + Vec2 tl = camera_world_to_screen(cam, vec2(0, 0)); + Vec2 br = camera_world_to_screen(cam, + vec2((float)(ed->map.width * TILE_SIZE), (float)(ed->map.height * TILE_SIZE))); + SDL_Rect border = {(int)tl.x, (int)tl.y, (int)(br.x - tl.x), (int)(br.y - tl.y)}; + SDL_RenderDrawRect(r, &border); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); + } + + /* ── Entity spawn markers ── */ + for (int i = 0; i < ed->map.entity_spawn_count; i++) { + const EntitySpawn *es = &ed->map.entity_spawns[i]; + const EntityRegEntry *reg = entity_registry_find(es->type_name); + SDL_Color col = reg ? reg->color : COL_ENTITY; + float ew = reg ? (float)reg->width : TILE_SIZE; + float eh = reg ? (float)reg->height : TILE_SIZE; + + Vec2 sp = camera_world_to_screen(cam, vec2(es->x, es->y)); + float zw = ew * cam->zoom; + float zh = eh * cam->zoom; + + SDL_SetRenderDrawColor(r, col.r, col.g, col.b, 180); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); + SDL_Rect er = {(int)sp.x, (int)sp.y, (int)(zw + 0.5f), (int)(zh + 0.5f)}; + SDL_RenderFillRect(r, &er); + SDL_SetRenderDrawColor(r, 255, 255, 255, 200); + SDL_RenderDrawRect(r, &er); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); + + /* Draw first letter of entity name */ + if (reg && reg->display[0] && zw >= 6) { + draw_char(r, reg->display[0], (int)sp.x + 1, (int)sp.y + 1, COL_TEXT); + } + } + + /* ── Player spawn marker ── */ + { + Vec2 sp = camera_world_to_screen(cam, ed->map.player_spawn); + float zs = TILE_SIZE * cam->zoom; + SDL_SetRenderDrawColor(r, COL_SPAWN.r, COL_SPAWN.g, COL_SPAWN.b, 200); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); + SDL_Rect sr = {(int)sp.x, (int)sp.y, (int)(zs + 0.5f), (int)(zs + 0.5f)}; + SDL_RenderDrawRect(r, &sr); + draw_text(r, "SP", (int)sp.x + 1, (int)sp.y + 1, COL_SPAWN); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); + } + + /* ── Cursor highlight ── */ + { + int mx_c, my_c; + input_mouse_pos(&mx_c, &my_c); + if (in_canvas(mx_c, my_c)) { + int tx, ty; + screen_to_tile(ed, mx_c, my_c, &tx, &ty); + if (tx >= 0 && tx < ed->map.width && ty >= 0 && ty < ed->map.height) { + Vec2 cpos = camera_world_to_screen(cam, + vec2(tile_to_world(tx), tile_to_world(ty))); + float zs = TILE_SIZE * cam->zoom; + SDL_SetRenderDrawColor(r, COL_SELECT.r, COL_SELECT.g, COL_SELECT.b, 120); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); + SDL_Rect cr = {(int)cpos.x, (int)cpos.y, (int)(zs + 0.5f), (int)(zs + 0.5f)}; + SDL_RenderDrawRect(r, &cr); + SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); + } + } + } + + /* ═════════════════════════════════════════ + * UI Panels (drawn in screen space) + * ═════════════════════════════════════════ */ + + /* ── Top toolbar ── */ + { + SDL_SetRenderDrawColor(r, COL_PANEL.r, COL_PANEL.g, COL_PANEL.b, COL_PANEL.a); + SDL_Rect tb = {0, 0, SCREEN_WIDTH, EDITOR_TOOLBAR_H}; + SDL_RenderFillRect(r, &tb); + + /* Tool buttons */ + for (int i = 0; i < TOOL_COUNT; i++) { + int bx = i * 32 + 2; + SDL_Color tc = (i == (int)ed->tool) ? COL_HIGHLIGHT : COL_TEXT_DIM; + draw_text(r, s_tool_names[i], bx, 4, tc); + } + + /* Layer buttons */ + for (int i = 0; i < EDITOR_LAYER_COUNT; i++) { + int bx = 170 + i * 25; + SDL_Color lc = (i == (int)ed->active_layer) ? COL_HIGHLIGHT : COL_TEXT_DIM; + draw_text(r, s_layer_names[i], bx, 4, lc); + } + + /* Grid & Layers indicators */ + draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", 260, 4, + ed->show_grid ? COL_TEXT : COL_TEXT_DIM); + } + + /* ── Right palette panel ── */ + { + int px = SCREEN_WIDTH - EDITOR_PALETTE_W; + int py = EDITOR_TOOLBAR_H; + int ph = SCREEN_HEIGHT - EDITOR_TOOLBAR_H - EDITOR_STATUS_H; + + SDL_SetRenderDrawColor(r, COL_PANEL.r, COL_PANEL.g, COL_PANEL.b, COL_PANEL.a); + SDL_Rect panel = {px, py, EDITOR_PALETTE_W, ph}; + SDL_RenderFillRect(r, &panel); + + /* Separator line */ + SDL_SetRenderDrawColor(r, COL_PANEL_LT.r, COL_PANEL_LT.g, COL_PANEL_LT.b, 255); + SDL_RenderDrawLine(r, px, py, px, py + ph); + + if (ed->tool != TOOL_ENTITY) { + /* ── Tile palette ── */ + draw_text(r, "TILES", px + 2, py + 2, COL_TEXT); + + int pal_y_start = py + 10; + int max_cols = (EDITOR_PALETTE_W - 4) / (TILE_SIZE + 1); + if (max_cols < 1) max_cols = 1; + + if (ed->map.tileset && ed->tileset_total > 0) { + /* Set clip rect to palette area */ + SDL_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W, ph - 10}; + SDL_RenderSetClipRect(r, &clip); + + for (int id = 1; id <= ed->tileset_total; id++) { + int col_idx = (id - 1) % max_cols; + int row_idx = (id - 1) / max_cols; + + int draw_x = px + 2 + col_idx * (TILE_SIZE + 1); + int draw_y = pal_y_start + row_idx * (TILE_SIZE + 1) + - ed->tile_palette_scroll * (TILE_SIZE + 1); + + /* Skip if off-screen */ + if (draw_y + TILE_SIZE < pal_y_start || draw_y > py + ph) + continue; + + /* Source rect from tileset */ + int src_col = (id - 1) % ed->tileset_cols; + int src_row = (id - 1) / ed->tileset_cols; + SDL_Rect src = { + src_col * TILE_SIZE, src_row * TILE_SIZE, + TILE_SIZE, TILE_SIZE + }; + SDL_Rect dst = {draw_x, draw_y, TILE_SIZE, TILE_SIZE}; + SDL_RenderCopy(r, ed->map.tileset, &src, &dst); + + /* Highlight selected */ + if (id == (int)ed->selected_tile) { + SDL_SetRenderDrawColor(r, COL_HIGHLIGHT.r, COL_HIGHLIGHT.g, + COL_HIGHLIGHT.b, 255); + SDL_Rect sel = {draw_x - 1, draw_y - 1, + TILE_SIZE + 2, TILE_SIZE + 2}; + SDL_RenderDrawRect(r, &sel); + } + } + + SDL_RenderSetClipRect(r, NULL); + } + } else { + /* ── Entity palette ── */ + draw_text(r, "ENTITIES", px + 2, py + 2, COL_TEXT); + + int pal_y_start = py + 12; + SDL_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W, ph - 12}; + SDL_RenderSetClipRect(r, &clip); + + for (int i = 0; i < g_entity_registry.count; i++) { + const EntityRegEntry *ent = &g_entity_registry.entries[i]; + int ey = pal_y_start + i * 12 - ed->entity_palette_scroll * 12; + + if (ey + 10 < pal_y_start || ey > py + ph) continue; + + /* Color swatch */ + SDL_SetRenderDrawColor(r, ent->color.r, ent->color.g, + ent->color.b, 255); + SDL_Rect swatch = {px + 2, ey + 1, 8, 8}; + SDL_RenderFillRect(r, &swatch); + + /* Name */ + SDL_Color nc = (i == ed->selected_entity) ? COL_HIGHLIGHT : COL_TEXT; + draw_text(r, ent->display, px + 13, ey + 2, nc); + } + + SDL_RenderSetClipRect(r, NULL); + } + } + + /* ── Bottom status bar ── */ + { + int sy = SCREEN_HEIGHT - EDITOR_STATUS_H; + SDL_SetRenderDrawColor(r, COL_PANEL.r, COL_PANEL.g, COL_PANEL.b, COL_PANEL.a); + SDL_Rect sb = {0, sy, SCREEN_WIDTH, EDITOR_STATUS_H}; + SDL_RenderFillRect(r, &sb); + + /* Cursor position */ + int mx_s, my_s; + input_mouse_pos(&mx_s, &my_s); + int tx = 0, ty = 0; + screen_to_tile(ed, mx_s, my_s, &tx, &ty); + + char status[512]; + snprintf(status, sizeof(status), "%dx%d (%d,%d) Z:%.0f%% %s%s", + ed->map.width, ed->map.height, + tx, ty, + ed->camera.zoom * 100.0f, + ed->has_file ? ed->file_path : "new level", + ed->dirty ? " *" : ""); + draw_text(r, status, 2, sy + 2, COL_TEXT); + } +} diff --git a/src/game/editor.h b/src/game/editor.h new file mode 100644 index 0000000..a01f125 --- /dev/null +++ b/src/game/editor.h @@ -0,0 +1,98 @@ +#ifndef JNR_EDITOR_H +#define JNR_EDITOR_H + +#include "engine/tilemap.h" +#include "engine/camera.h" +#include +#include + +/* ═══════════════════════════════════════════════════ + * Level Editor + * + * An in-engine level editor that runs as an + * alternative game mode. Uses the same renderer, + * camera, and tilemap systems as gameplay. + * + * Tile palette is auto-discovered from the tileset + * texture. Entity palette is auto-discovered from + * the entity registry. Adding new tiles or entities + * to the game makes them appear in the editor + * automatically. + * ═══════════════════════════════════════════════════ */ + +typedef enum EditorTool { + TOOL_PENCIL, /* paint tiles */ + TOOL_ERASER, /* clear tiles (paint tile 0) */ + TOOL_FILL, /* flood fill area */ + TOOL_ENTITY, /* place/select entities */ + TOOL_SPAWN, /* set player spawn point */ + TOOL_COUNT +} EditorTool; + +typedef enum EditorLayer { + EDITOR_LAYER_COLLISION, + EDITOR_LAYER_BG, + EDITOR_LAYER_FG, + EDITOR_LAYER_COUNT +} EditorLayer; + +/* UI panel regions */ +#define EDITOR_TOOLBAR_H 14 /* top bar height */ +#define EDITOR_PALETTE_W 80 /* right panel width */ +#define EDITOR_STATUS_H 10 /* bottom status bar height */ + +typedef struct Editor { + /* ── Map data ──────────────────────────── */ + Tilemap map; + char file_path[256]; + bool has_file; /* true if loaded from / saved to file */ + bool dirty; /* unsaved changes */ + + /* ── Camera ────────────────────────────── */ + Camera camera; + + /* ── Tool state ────────────────────────── */ + EditorTool tool; + EditorLayer active_layer; + uint16_t selected_tile; /* currently selected tile ID */ + int selected_entity; /* index into entity registry */ + bool show_grid; + bool show_all_layers; /* dim inactive layers */ + + /* ── Entity editing ────────────────────── */ + int dragging_entity; /* index in entity_spawns, or -1 */ + float drag_offset_x; /* offset from entity origin to mouse */ + float drag_offset_y; + + /* ── Palette scroll ────────────────────── */ + int tile_palette_scroll; + int entity_palette_scroll; + + /* ── Tileset info (auto-discovered) ────── */ + int tileset_cols; + int tileset_rows; + int tileset_total; /* total tiles in tileset */ + + /* ── UI state ──────────────────────────── */ + bool active; /* editor is running */ +} Editor; + +/* Lifecycle */ +void editor_init(Editor *ed); +void editor_new_level(Editor *ed, int width, int height); +bool editor_load(Editor *ed, const char *path); +bool editor_save(Editor *ed); +bool editor_save_as(Editor *ed, const char *path); +void editor_free(Editor *ed); + +/* Frame callbacks (plug into engine) */ +void editor_update(Editor *ed, float dt); +void editor_render(Editor *ed, float interpolation); + +/* Returns true if the user pressed the test-play key */ +bool editor_wants_test_play(Editor *ed); + +/* Returns true if the editor wants to quit */ +bool editor_wants_quit(Editor *ed); + +#endif /* JNR_EDITOR_H */ diff --git a/src/game/entity_registry.c b/src/game/entity_registry.c new file mode 100644 index 0000000..3e6239d --- /dev/null +++ b/src/game/entity_registry.c @@ -0,0 +1,108 @@ +#include "game/entity_registry.h" +#include "game/player.h" +#include "game/enemy.h" +#include "game/projectile.h" +#include "game/hazards.h" +#include "game/powerup.h" +#include "game/drone.h" +#include +#include + +EntityRegistry g_entity_registry = {0}; + +/* ── Wrapper spawn functions ────────────────────── + * Some spawn functions need extra args beyond (em, pos). + * We wrap them to match the EntitySpawnFn signature. + * ──────────────────────────────────────────────────── */ + +static Entity *spawn_platform_v(EntityManager *em, Vec2 pos) { + return mplat_spawn_dir(em, pos, vec2(0.0f, 1.0f)); +} + +static Entity *spawn_powerup_health(EntityManager *em, Vec2 pos) { + return powerup_spawn_health(em, pos); +} + +static Entity *spawn_powerup_jetpack(EntityManager *em, Vec2 pos) { + return powerup_spawn_jetpack(em, pos); +} + +static Entity *spawn_powerup_drone(EntityManager *em, Vec2 pos) { + return powerup_spawn_drone(em, pos); +} + +/* ── Registry population ─────────────────────────── */ + +static void reg_add(const char *name, const char *display, + EntitySpawnFn fn, SDL_Color color, int w, int h) { + EntityRegistry *r = &g_entity_registry; + if (r->count >= MAX_REGISTRY_ENTRIES) return; + r->entries[r->count++] = (EntityRegEntry){ + .name = name, + .display = display, + .spawn_fn = fn, + .color = color, + .width = w, + .height = h, + }; +} + +void entity_registry_init(EntityManager *em) { + g_entity_registry.count = 0; + + /* Register all entity behaviors with the entity manager */ + player_register(em); + player_set_entity_manager(em); + grunt_register(em); + flyer_register(em); + projectile_register(em); + turret_register(em); + mplat_register(em); + flame_vent_register(em); + force_field_register(em); + powerup_register(em); + drone_register(em); + + /* ════════════════════════════════════════════ + * REGISTRY TABLE + * + * To add a new entity type to the game: + * 1. Create its .h/.c files + * 2. Register its behaviors above + * 3. Add one reg_add() line below + * + * The editor will pick it up automatically. + * ════════════════════════════════════════════ */ + + /* .lvl name display name spawn fn color (editor) w h */ + reg_add("grunt", "Grunt", grunt_spawn, (SDL_Color){200, 60, 60, 255}, GRUNT_WIDTH, GRUNT_HEIGHT); + reg_add("flyer", "Flyer", flyer_spawn, (SDL_Color){140, 80, 200, 255}, FLYER_WIDTH, FLYER_HEIGHT); + reg_add("turret", "Turret", turret_spawn, (SDL_Color){160, 160, 160, 255}, TURRET_WIDTH, TURRET_HEIGHT); + reg_add("platform", "Platform (H)", mplat_spawn, (SDL_Color){80, 180, 80, 255}, MPLAT_WIDTH, MPLAT_HEIGHT); + reg_add("platform_v", "Platform (V)", spawn_platform_v, (SDL_Color){80, 160, 100, 255}, MPLAT_WIDTH, MPLAT_HEIGHT); + reg_add("flame_vent", "Flame Vent", flame_vent_spawn, (SDL_Color){255, 120, 40, 255}, FLAME_WIDTH, FLAME_HEIGHT); + reg_add("force_field", "Force Field", force_field_spawn, (SDL_Color){60, 140, 255, 255}, FFIELD_WIDTH, FFIELD_HEIGHT); + reg_add("powerup_hp", "Health Pickup", spawn_powerup_health, (SDL_Color){255, 80, 80, 255}, 12, 12); + reg_add("powerup_jet", "Jetpack Refill", spawn_powerup_jetpack,(SDL_Color){255, 200, 50, 255}, 12, 12); + reg_add("powerup_drone", "Drone Pickup", spawn_powerup_drone, (SDL_Color){80, 200, 255, 255}, 12, 12); + + printf("Entity registry: %d types registered\n", g_entity_registry.count); +} + +const EntityRegEntry *entity_registry_find(const char *name) { + for (int i = 0; i < g_entity_registry.count; i++) { + if (strcmp(g_entity_registry.entries[i].name, name) == 0) { + return &g_entity_registry.entries[i]; + } + } + return NULL; +} + +Entity *entity_registry_spawn(EntityManager *em, const char *name, Vec2 pos) { + const EntityRegEntry *entry = entity_registry_find(name); + if (!entry) { + fprintf(stderr, "entity_registry_spawn: unknown type '%s'\n", name); + return NULL; + } + return entry->spawn_fn(em, pos); +} diff --git a/src/game/entity_registry.h b/src/game/entity_registry.h new file mode 100644 index 0000000..5422954 --- /dev/null +++ b/src/game/entity_registry.h @@ -0,0 +1,52 @@ +#ifndef JNR_ENTITY_REGISTRY_H +#define JNR_ENTITY_REGISTRY_H + +#include "engine/entity.h" +#include + +/* ═══════════════════════════════════════════════════ + * Entity Registry + * + * Central table that maps entity type name strings + * to their spawn functions and metadata. Used by: + * - Level loader (to spawn entities from .lvl files) + * - Level editor (to discover available entity types) + * + * When adding a new entity type to the game, add one + * entry here and it will appear in the editor palette + * automatically. + * ═══════════════════════════════════════════════════ */ + +#define MAX_REGISTRY_ENTRIES 32 + +/* Spawn function: creates an entity at position, returns it */ +typedef Entity *(*EntitySpawnFn)(EntityManager *em, Vec2 pos); + +typedef struct EntityRegEntry { + const char *name; /* .lvl file name, e.g. "grunt" */ + const char *display; /* human-readable, e.g. "Grunt" */ + EntitySpawnFn spawn_fn; /* function that creates the entity */ + SDL_Color color; /* editor palette color for preview */ + int width; /* hitbox width (for editor preview) */ + int height; /* hitbox height (for editor preview) */ +} EntityRegEntry; + +typedef struct EntityRegistry { + EntityRegEntry entries[MAX_REGISTRY_ENTRIES]; + int count; +} EntityRegistry; + +/* Global registry instance */ +extern EntityRegistry g_entity_registry; + +/* Call once at startup to populate the registry. + * Also registers entity behaviors with the given EntityManager. */ +void entity_registry_init(EntityManager *em); + +/* Look up a registry entry by name (returns NULL if not found) */ +const EntityRegEntry *entity_registry_find(const char *name); + +/* Spawn an entity by registry name. Returns NULL if name unknown. */ +Entity *entity_registry_spawn(EntityManager *em, const char *name, Vec2 pos); + +#endif /* JNR_ENTITY_REGISTRY_H */ diff --git a/src/game/hazards.c b/src/game/hazards.c new file mode 100644 index 0000000..3074950 --- /dev/null +++ b/src/game/hazards.c @@ -0,0 +1,599 @@ +#include "game/hazards.h" +#include "game/sprites.h" +#include "game/projectile.h" +#include "engine/physics.h" +#include "engine/renderer.h" +#include "engine/particle.h" +#include +#include + +/* ════════════════════════════════════════════════════ + * Shared helpers + * ════════════════════════════════════════════════════ */ + +/* Find the player entity in the manager */ +static Entity *find_player(EntityManager *em) { + for (int i = 0; i < em->count; i++) { + Entity *e = &em->entities[i]; + if (e->active && e->type == ENT_PLAYER) return e; + } + return NULL; +} + +/* ════════════════════════════════════════════════════ + * TURRET — floor/wall-mounted gun, shoots at player + * ════════════════════════════════════════════════════ */ + +static EntityManager *s_turret_em = NULL; + +static void turret_update(Entity *self, float dt, const Tilemap *map) { + (void)map; + TurretData *td = (TurretData *)self->data; + if (!td) return; + + /* Death: turrets can be destroyed */ + if (self->flags & ENTITY_DEAD) { + self->timer -= dt; + if (self->timer <= 0) { + entity_destroy(s_turret_em, self); + } + return; + } + + /* Flash timer for muzzle flash visual */ + if (td->fire_flash > 0) { + td->fire_flash -= dt; + } + + /* Look for player */ + Entity *player = find_player(s_turret_em); + if (!player || !player->active || (player->flags & ENTITY_DEAD)) { + td->shoot_timer = TURRET_SHOOT_CD; + return; + } + + /* Distance check */ + float px = player->body.pos.x + player->body.size.x * 0.5f; + float py = player->body.pos.y + player->body.size.y * 0.5f; + float tx = self->body.pos.x + self->body.size.x * 0.5f; + float ty = self->body.pos.y + self->body.size.y * 0.5f; + + float dx = px - tx; + float dy = py - ty; + float dist = sqrtf(dx * dx + dy * dy); + + /* Face toward player */ + if (dx < 0) self->flags |= ENTITY_FACING_LEFT; + else self->flags &= ~ENTITY_FACING_LEFT; + + if (dist < TURRET_DETECT_RANGE) { + td->shoot_timer -= dt; + if (td->shoot_timer <= 0 && s_turret_em) { + td->shoot_timer = TURRET_SHOOT_CD; + td->fire_flash = 0.15f; + + /* Aim direction: normalize vector to player */ + Vec2 dir; + if (dist > 0.01f) { + dir = vec2(dx / dist, dy / dist); + } else { + dir = vec2(1.0f, 0.0f); + } + + /* Spawn projectile from turret barrel */ + bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0; + float bx = facing_left ? + self->body.pos.x - 4.0f : + self->body.pos.x + self->body.size.x; + float by = self->body.pos.y + self->body.size.y * 0.3f; + + projectile_spawn_dir(s_turret_em, vec2(bx, by), dir, false); + } + } else { + /* Reset cooldown when player leaves range */ + if (td->shoot_timer < TURRET_SHOOT_CD * 0.5f) + td->shoot_timer = TURRET_SHOOT_CD * 0.5f; + } + + /* Animation */ + if (td->fire_flash > 0) { + animation_set(&self->anim, &anim_turret_fire); + } else { + animation_set(&self->anim, &anim_turret_idle); + } + animation_update(&self->anim, dt); +} + +static void turret_render(Entity *self, const Camera *cam) { + (void)cam; + Body *body = &self->body; + + if (g_spritesheet && self->anim.def) { + SDL_Rect src = animation_current_rect(&self->anim); + Vec2 render_pos = vec2( + body->pos.x - 1.0f, + body->pos.y + ); + Sprite spr = { + .texture = g_spritesheet, + .src = src, + .pos = render_pos, + .size = vec2(SPRITE_CELL, SPRITE_CELL), + .flip_x = (self->flags & ENTITY_FACING_LEFT) != 0, + .flip_y = false, + .layer = LAYER_ENTITIES, + .alpha = 255, + }; + renderer_submit(&spr); + } else { + SDL_Color color = {130, 130, 130, 255}; + renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); + } +} + +static void turret_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +void turret_register(EntityManager *em) { + entity_register(em, ENT_TURRET, turret_update, turret_render, turret_destroy); + s_turret_em = em; +} + +Entity *turret_spawn(EntityManager *em, Vec2 pos) { + Entity *e = entity_spawn(em, ENT_TURRET, pos); + if (!e) return NULL; + + e->body.size = vec2(TURRET_WIDTH, TURRET_HEIGHT); + e->body.gravity_scale = 0.0f; /* turrets are stationary */ + e->health = TURRET_HEALTH; + e->max_health = TURRET_HEALTH; + e->damage = 1; /* light contact damage — turret is a hostile machine */ + + TurretData *td = calloc(1, sizeof(TurretData)); + td->shoot_timer = TURRET_SHOOT_CD; + td->fire_flash = 0.0f; + e->data = td; + + /* Start with death timer if needed */ + e->timer = 0.3f; + + animation_set(&e->anim, &anim_turret_idle); + + return e; +} + + +/* ════════════════════════════════════════════════════ + * MOVING PLATFORM — travels back and forth, + * player rides on top + * ════════════════════════════════════════════════════ */ + +static EntityManager *s_mplat_em = NULL; + +static void mplat_update(Entity *self, float dt, const Tilemap *map) { + (void)map; + MovingPlatData *md = (MovingPlatData *)self->data; + if (!md) return; + + /* Advance phase (sine oscillation) */ + float angular_speed = (MPLAT_SPEED / md->range) * 1.0f; + md->phase += angular_speed * dt; + if (md->phase > 2.0f * 3.14159265f) + md->phase -= 2.0f * 3.14159265f; + + /* Calculate new position */ + float offset = sinf(md->phase) * md->range; + Vec2 new_pos = vec2( + md->origin.x + md->direction.x * offset, + md->origin.y + md->direction.y * offset + ); + + /* Store velocity for player riding (carry velocity) */ + self->body.vel.x = (new_pos.x - self->body.pos.x) / dt; + self->body.vel.y = (new_pos.y - self->body.pos.y) / dt; + + self->body.pos = new_pos; + + /* Check if player is standing on the platform */ + Entity *player = find_player(s_mplat_em); + if (player && player->active && !(player->flags & ENTITY_DEAD)) { + Body *pb = &player->body; + Body *mb = &self->body; + + /* Player's feet must be at the platform top (within 3px) */ + float player_bottom = pb->pos.y + pb->size.y; + float plat_top = mb->pos.y; + + bool horizontally_on = (pb->pos.x + pb->size.x > mb->pos.x + 1.0f) && + (pb->pos.x < mb->pos.x + mb->size.x - 1.0f); + bool vertically_close = (player_bottom >= plat_top - 3.0f) && + (player_bottom <= plat_top + 4.0f); + bool falling_or_standing = pb->vel.y >= -1.0f; + + if (horizontally_on && vertically_close && falling_or_standing) { + /* Carry the player */ + pb->pos.x += self->body.vel.x * dt; + pb->pos.y = plat_top - pb->size.y; + pb->on_ground = true; + if (pb->vel.y > 0) pb->vel.y = 0; + } + } +} + +static void mplat_render(Entity *self, const Camera *cam) { + (void)cam; + Body *body = &self->body; + + if (g_spritesheet) { + SDL_Rect src = animation_current_rect(&self->anim); + Sprite spr = { + .texture = g_spritesheet, + .src = src, + .pos = body->pos, + .size = vec2(SPRITE_CELL, SPRITE_CELL), + .flip_x = false, + .flip_y = false, + .layer = LAYER_ENTITIES, + .alpha = 255, + }; + renderer_submit(&spr); + } else { + SDL_Color color = {60, 110, 165, 255}; + renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); + } +} + +static void mplat_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +void mplat_register(EntityManager *em) { + entity_register(em, ENT_MOVING_PLATFORM, mplat_update, mplat_render, mplat_destroy); + s_mplat_em = em; +} + +Entity *mplat_spawn(EntityManager *em, Vec2 pos) { + return mplat_spawn_dir(em, pos, vec2(1.0f, 0.0f)); /* default horizontal */ +} + +Entity *mplat_spawn_dir(EntityManager *em, Vec2 pos, Vec2 dir) { + Entity *e = entity_spawn(em, ENT_MOVING_PLATFORM, pos); + if (!e) return NULL; + + e->body.size = vec2(MPLAT_WIDTH, MPLAT_HEIGHT); + e->body.gravity_scale = 0.0f; + e->health = 9999; /* indestructible */ + e->max_health = 9999; + e->flags |= ENTITY_INVINCIBLE; + e->damage = 0; + + MovingPlatData *md = calloc(1, sizeof(MovingPlatData)); + md->origin = pos; + md->direction = dir; + md->range = MPLAT_RANGE; + md->phase = 0.0f; + e->data = md; + + animation_set(&e->anim, &anim_platform); + + return e; +} + + +/* ════════════════════════════════════════════════════ + * FLAME VENT — floor grate with timed blue flames + * ════════════════════════════════════════════════════ */ + +static EntityManager *s_flame_em = NULL; + +/* Damage cooldown tracking: use entity timer field */ +#define FLAME_DAMAGE_CD 0.5f + +static void flame_vent_update(Entity *self, float dt, const Tilemap *map) { + (void)map; + FlameVentData *fd = (FlameVentData *)self->data; + if (!fd) return; + + /* Tick the phase timer */ + fd->timer -= dt; + if (fd->timer <= 0) { + fd->active = !fd->active; + fd->timer = fd->active ? FLAME_ON_TIME : FLAME_OFF_TIME; + } + + /* Warning flicker: 0.3s before flames ignite */ + fd->warn_time = 0.0f; + if (!fd->active && fd->timer < 0.3f) { + fd->warn_time = fd->timer; + } + + /* Animation */ + if (fd->active) { + animation_set(&self->anim, &anim_flame_vent_active); + } else { + animation_set(&self->anim, &anim_flame_vent_idle); + } + animation_update(&self->anim, dt); + + /* Damage player when flames are active */ + if (fd->active) { + /* Damage cooldown */ + if (self->timer > 0) { + self->timer -= dt; + } + + Entity *player = find_player(s_flame_em); + if (player && player->active && + !(player->flags & ENTITY_DEAD) && + !(player->flags & ENTITY_INVINCIBLE) && + self->timer <= 0) { + /* Check overlap with flame area (the full sprite, not just base) */ + if (physics_overlap(&self->body, &player->body)) { + player->health -= FLAME_DAMAGE; + if (player->health <= 0) + player->flags |= ENTITY_DEAD; + self->timer = FLAME_DAMAGE_CD; + + /* Blue flame hit particles */ + Vec2 hit_center = vec2( + player->body.pos.x + player->body.size.x * 0.5f, + player->body.pos.y + player->body.size.y * 0.5f + ); + SDL_Color flame_col = {68, 136, 255, 255}; + particle_emit_spark(hit_center, flame_col); + } + } + + /* Emit ambient flame particles */ + if ((int)(fd->timer * 10.0f) % 3 == 0) { + Vec2 origin = vec2( + self->body.pos.x + self->body.size.x * 0.5f, + self->body.pos.y + 2.0f + ); + ParticleBurst burst = { + .origin = origin, + .count = 2, + .speed_min = 20.0f, + .speed_max = 50.0f, + .life_min = 0.1f, + .life_max = 0.3f, + .size_min = 1.0f, + .size_max = 2.5f, + .spread = 0.4f, + .direction = -1.5708f, /* upward */ + .drag = 0.0f, + .gravity_scale = -0.3f, /* float upward */ + .color = {100, 150, 255, 255}, + .color_vary = true, + }; + particle_emit(&burst); + } + } +} + +static void flame_vent_render(Entity *self, const Camera *cam) { + (void)cam; + FlameVentData *fd = (FlameVentData *)self->data; + Body *body = &self->body; + + if (g_spritesheet && self->anim.def) { + SDL_Rect src = animation_current_rect(&self->anim); + Sprite spr = { + .texture = g_spritesheet, + .src = src, + .pos = body->pos, + .size = vec2(SPRITE_CELL, SPRITE_CELL), + .flip_x = false, + .flip_y = false, + .layer = LAYER_ENTITIES, + .alpha = 255, + }; + + /* Warning flicker: blink the sprite when about to ignite */ + if (fd && fd->warn_time > 0) { + int blink = (int)(fd->warn_time * 20.0f) % 2; + spr.alpha = blink ? 180 : 255; + } + + renderer_submit(&spr); + } else { + SDL_Color color = fd && fd->active ? + (SDL_Color){68, 136, 255, 255} : + (SDL_Color){80, 80, 80, 255}; + renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); + } +} + +static void flame_vent_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +void flame_vent_register(EntityManager *em) { + entity_register(em, ENT_FLAME_VENT, flame_vent_update, flame_vent_render, flame_vent_destroy); + s_flame_em = em; +} + +Entity *flame_vent_spawn(EntityManager *em, Vec2 pos) { + Entity *e = entity_spawn(em, ENT_FLAME_VENT, pos); + if (!e) return NULL; + + e->body.size = vec2(FLAME_WIDTH, FLAME_HEIGHT); + e->body.gravity_scale = 0.0f; + e->health = 9999; + e->max_health = 9999; + e->flags |= ENTITY_INVINCIBLE; + e->damage = FLAME_DAMAGE; + + FlameVentData *fd = calloc(1, sizeof(FlameVentData)); + fd->active = false; + fd->timer = FLAME_OFF_TIME; + fd->warn_time = 0.0f; + e->data = fd; + e->timer = 0.0f; /* damage cooldown */ + + animation_set(&e->anim, &anim_flame_vent_idle); + + return e; +} + + +/* ════════════════════════════════════════════════════ + * FORCE FIELD — energy barrier that toggles on/off + * ════════════════════════════════════════════════════ */ + +static EntityManager *s_ffield_em = NULL; + +static void force_field_update(Entity *self, float dt, const Tilemap *map) { + (void)map; + ForceFieldData *fd = (ForceFieldData *)self->data; + if (!fd) return; + + /* Tick the phase timer */ + fd->timer -= dt; + if (fd->timer <= 0) { + fd->active = !fd->active; + fd->timer = fd->active ? FFIELD_ON_TIME : FFIELD_OFF_TIME; + } + + /* Animation */ + if (fd->active) { + animation_set(&self->anim, &anim_force_field_on); + } else { + animation_set(&self->anim, &anim_force_field_off); + } + animation_update(&self->anim, dt); + + /* When active: block player and deal damage */ + if (fd->active) { + Entity *player = find_player(s_ffield_em); + if (player && player->active && !(player->flags & ENTITY_DEAD)) { + Body *pb = &player->body; + Body *fb = &self->body; + + if (physics_overlap(pb, fb)) { + /* Push player out of the force field */ + float player_cx = pb->pos.x + pb->size.x * 0.5f; + float field_cx = fb->pos.x + fb->size.x * 0.5f; + + if (player_cx < field_cx) { + /* Push left */ + pb->pos.x = fb->pos.x - pb->size.x; + if (pb->vel.x > 0) pb->vel.x = 0; + } else { + /* Push right */ + pb->pos.x = fb->pos.x + fb->size.x; + if (pb->vel.x < 0) pb->vel.x = 0; + } + + /* Damage with cooldown */ + if (!(player->flags & ENTITY_INVINCIBLE) && self->timer <= 0) { + player->health -= FFIELD_DAMAGE; + if (player->health <= 0) + player->flags |= ENTITY_DEAD; + self->timer = 0.8f; /* damage cooldown */ + + /* Electric zap particles */ + Vec2 zap_pos = vec2( + fb->pos.x + fb->size.x * 0.5f, + player->body.pos.y + player->body.size.y * 0.5f + ); + SDL_Color zap_col = {136, 170, 255, 255}; + particle_emit_spark(zap_pos, zap_col); + } + } + } + + /* Emit shimmer particles */ + if ((int)(fd->timer * 8.0f) % 2 == 0) { + float ry = self->body.pos.y + (float)(rand() % (int)self->body.size.y); + Vec2 origin = vec2( + self->body.pos.x + self->body.size.x * 0.5f, + ry + ); + ParticleBurst burst = { + .origin = origin, + .count = 1, + .speed_min = 5.0f, + .speed_max = 15.0f, + .life_min = 0.1f, + .life_max = 0.25f, + .size_min = 0.5f, + .size_max = 1.5f, + .spread = 3.14159f, + .direction = 0.0f, + .drag = 0.5f, + .gravity_scale = 0.0f, + .color = {136, 170, 255, 200}, + .color_vary = true, + }; + particle_emit(&burst); + } + } + + /* Damage cooldown */ + if (self->timer > 0) { + self->timer -= dt; + } +} + +static void force_field_render(Entity *self, const Camera *cam) { + (void)cam; + ForceFieldData *fd = (ForceFieldData *)self->data; + Body *body = &self->body; + + if (g_spritesheet && self->anim.def) { + SDL_Rect src = animation_current_rect(&self->anim); + Sprite spr = { + .texture = g_spritesheet, + .src = src, + .pos = body->pos, + .size = vec2(SPRITE_CELL, SPRITE_CELL), + .flip_x = false, + .flip_y = false, + .layer = LAYER_ENTITIES, + .alpha = fd && fd->active ? 220 : 100, + }; + renderer_submit(&spr); + } else { + SDL_Color color = fd && fd->active ? + (SDL_Color){100, 130, 255, 220} : + (SDL_Color){40, 50, 80, 100}; + renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam); + } +} + +static void force_field_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +void force_field_register(EntityManager *em) { + entity_register(em, ENT_FORCE_FIELD, force_field_update, force_field_render, force_field_destroy); + s_ffield_em = em; +} + +Entity *force_field_spawn(EntityManager *em, Vec2 pos) { + Entity *e = entity_spawn(em, ENT_FORCE_FIELD, pos); + if (!e) return NULL; + + e->body.size = vec2(FFIELD_WIDTH, FFIELD_HEIGHT); + e->body.gravity_scale = 0.0f; + e->health = 9999; + e->max_health = 9999; + e->flags |= ENTITY_INVINCIBLE; + e->damage = FFIELD_DAMAGE; + + ForceFieldData *fd = calloc(1, sizeof(ForceFieldData)); + fd->active = true; + fd->timer = FFIELD_ON_TIME; + e->data = fd; + e->timer = 0.0f; /* damage cooldown */ + + animation_set(&e->anim, &anim_force_field_on); + + return e; +} diff --git a/src/game/hazards.h b/src/game/hazards.h new file mode 100644 index 0000000..9d6fe64 --- /dev/null +++ b/src/game/hazards.h @@ -0,0 +1,90 @@ +#ifndef JNR_HAZARDS_H +#define JNR_HAZARDS_H + +#include "engine/entity.h" +#include "engine/camera.h" +#include "engine/tilemap.h" + +/* ═══════════════════════════════════════════════════ + * TURRET — Wall/floor-mounted gun that shoots at + * the player when in range, on a cooldown timer. + * ═══════════════════════════════════════════════════ */ + +#define TURRET_WIDTH 14 +#define TURRET_HEIGHT 16 +#define TURRET_DETECT_RANGE 160.0f /* detection radius in px */ +#define TURRET_SHOOT_CD 2.0f /* seconds between shots */ +#define TURRET_HEALTH 3 + +typedef struct TurretData { + float shoot_timer; /* countdown to next shot */ + float fire_flash; /* visual: time remaining on flash */ +} TurretData; + +void turret_register(EntityManager *em); +Entity *turret_spawn(EntityManager *em, Vec2 pos); + +/* ═══════════════════════════════════════════════════ + * MOVING PLATFORM — Travels back and forth along a + * horizontal or vertical path. Player rides on top. + * ═══════════════════════════════════════════════════ */ + +#define MPLAT_WIDTH 16 +#define MPLAT_HEIGHT 16 +#define MPLAT_SPEED 40.0f /* pixels per second */ +#define MPLAT_RANGE 80.0f /* total travel distance in px */ + +typedef struct MovingPlatData { + Vec2 origin; /* center of travel path */ + Vec2 direction; /* (1,0) horizontal / (0,1) vert*/ + float range; /* half-distance of travel */ + float phase; /* current sine phase (radians) */ +} MovingPlatData; + +void mplat_register(EntityManager *em); +Entity *mplat_spawn(EntityManager *em, Vec2 pos); +/* Spawn with explicit direction: dir (1,0) = horizontal, (0,1) = vertical */ +Entity *mplat_spawn_dir(EntityManager *em, Vec2 pos, Vec2 dir); + +/* ═══════════════════════════════════════════════════ + * FLAME VENT — Floor-mounted grate that periodically + * bursts blue flames upward. Damages on contact + * when flames are active. + * ═══════════════════════════════════════════════════ */ + +#define FLAME_WIDTH 16 +#define FLAME_HEIGHT 16 +#define FLAME_ON_TIME 1.5f /* seconds flames are active */ +#define FLAME_OFF_TIME 2.0f /* seconds between bursts */ +#define FLAME_DAMAGE 1 + +typedef struct FlameVentData { + float timer; /* counts down in current phase */ + bool active; /* true = flames on */ + float warn_time; /* brief flicker before igniting*/ +} FlameVentData; + +void flame_vent_register(EntityManager *em); +Entity *flame_vent_spawn(EntityManager *em, Vec2 pos); + +/* ═══════════════════════════════════════════════════ + * FORCE FIELD — Energy barrier that toggles on/off + * on a timer. Blocks movement and damages when + * active, passable when off. + * ═══════════════════════════════════════════════════ */ + +#define FFIELD_WIDTH 16 +#define FFIELD_HEIGHT 16 +#define FFIELD_ON_TIME 3.0f /* seconds barrier is active */ +#define FFIELD_OFF_TIME 2.0f /* seconds barrier is off */ +#define FFIELD_DAMAGE 1 + +typedef struct ForceFieldData { + float timer; /* counts down in current phase */ + bool active; /* true = barrier is on */ +} ForceFieldData; + +void force_field_register(EntityManager *em); +Entity *force_field_spawn(EntityManager *em, Vec2 pos); + +#endif /* JNR_HAZARDS_H */ diff --git a/src/game/level.c b/src/game/level.c index 21dc786..35668bc 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -2,7 +2,9 @@ #include "game/player.h" #include "game/enemy.h" #include "game/projectile.h" +#include "game/hazards.h" #include "game/sprites.h" +#include "game/entity_registry.h" #include "engine/core.h" #include "engine/renderer.h" #include "engine/physics.h" @@ -19,9 +21,8 @@ static Sound s_sfx_hit; static Sound s_sfx_enemy_death; static bool s_sfx_loaded = false; -bool level_load(Level *level, const char *path) { - memset(level, 0, sizeof(Level)); - +/* ── Shared level setup (after tilemap is ready) ─── */ +static bool level_setup(Level *level) { /* Initialize subsystems */ entity_manager_init(&level->entities); camera_init(&level->camera, SCREEN_WIDTH, SCREEN_HEIGHT); @@ -40,17 +41,8 @@ bool level_load(Level *level, const char *path) { fprintf(stderr, "Warning: failed to generate spritesheet\n"); } - /* Register entity types */ - player_register(&level->entities); - player_set_entity_manager(&level->entities); - grunt_register(&level->entities); - flyer_register(&level->entities); - projectile_register(&level->entities); - - /* Load tilemap */ - if (!tilemap_load(&level->map, path, g_engine.renderer)) { - return false; - } + /* Register all entity types via the central registry */ + entity_registry_init(&level->entities); /* Apply level gravity (0 = use default) */ if (level->map.gravity > 0) { @@ -75,11 +67,19 @@ bool level_load(Level *level, const char *path) { if (near_tex) parallax_set_near(&level->parallax, near_tex, 0.15f, 0.10f); } /* Generate procedural backgrounds for any layers not loaded from file */ - if (!level->parallax.far_layer.active) { - parallax_generate_stars(&level->parallax, g_engine.renderer); - } - if (!level->parallax.near_layer.active) { - parallax_generate_nebula(&level->parallax, g_engine.renderer); + if (!level->parallax.far_layer.active && !level->parallax.near_layer.active + && level->map.parallax_style != 0) { + /* Use themed parallax when a style is specified */ + parallax_generate_themed(&level->parallax, g_engine.renderer, + (ParallaxStyle)level->map.parallax_style); + } else { + /* Default: generic stars + nebula */ + if (!level->parallax.far_layer.active) { + parallax_generate_stars(&level->parallax, g_engine.renderer); + } + if (!level->parallax.near_layer.active) { + parallax_generate_nebula(&level->parallax, g_engine.renderer); + } } /* Set camera bounds to level size */ @@ -94,18 +94,11 @@ bool level_load(Level *level, const char *path) { return false; } - /* Spawn entities from level data */ + /* 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); - - if (strcmp(es->type_name, "grunt") == 0) { - grunt_spawn(&level->entities, pos); - } else if (strcmp(es->type_name, "flyer") == 0) { - flyer_spawn(&level->entities, pos); - } else { - fprintf(stderr, "Unknown entity type: %s\n", es->type_name); - } + entity_registry_spawn(&level->entities, es->type_name, pos); } /* Load level music (playback deferred to first update — @@ -119,6 +112,35 @@ bool level_load(Level *level, const char *path) { return true; } +bool level_load(Level *level, const char *path) { + memset(level, 0, sizeof(Level)); + + /* Load tilemap from file */ + if (!tilemap_load(&level->map, path, g_engine.renderer)) { + return false; + } + + return level_setup(level); +} + +bool level_load_generated(Level *level, Tilemap *gen_map) { + memset(level, 0, sizeof(Level)); + + /* Take ownership of the generated tilemap */ + level->map = *gen_map; + memset(gen_map, 0, sizeof(Tilemap)); /* prevent double-free */ + + /* Load tileset texture (the generator doesn't do this) */ + level->map.tileset = assets_get_texture("assets/tiles/tileset.png"); + if (level->map.tileset) { + int tex_w; + SDL_QueryTexture(level->map.tileset, NULL, NULL, &tex_w, NULL); + level->map.tileset_cols = tex_w / TILE_SIZE; + } + + return level_setup(level); +} + /* ── Collision handling ──────────────────────────── */ /* Forward declaration for shake access */ @@ -139,6 +161,8 @@ static void damage_entity(Entity *target, int damage) { death_color = (SDL_Color){200, 60, 60, 255}; /* red debris */ } else if (target->type == ENT_ENEMY_FLYER) { death_color = (SDL_Color){140, 80, 200, 255}; /* purple puff */ + } else if (target->type == ENT_TURRET) { + death_color = (SDL_Color){160, 160, 160, 255}; /* metal scraps */ } else { death_color = (SDL_Color){200, 200, 200, 255}; /* grey */ } @@ -179,7 +203,8 @@ static void damage_player(Entity *player, int damage, Entity *source) { } static bool is_enemy(const Entity *e) { - return e->type == ENT_ENEMY_GRUNT || e->type == ENT_ENEMY_FLYER; + return e->type == ENT_ENEMY_GRUNT || e->type == ENT_ENEMY_FLYER || + e->type == ENT_TURRET; } static void handle_collisions(EntityManager *em) { @@ -392,6 +417,11 @@ void level_render(Level *level, float interpolation) { void level_free(Level *level) { audio_stop_music(); + + /* Free music handle (prevent leak on reload) */ + audio_free_music(&level->music); + level->music_started = false; + entity_manager_clear(&level->entities); particle_clear(); parallax_free(&level->parallax); diff --git a/src/game/level.h b/src/game/level.h index 93bf840..43b3e6b 100644 --- a/src/game/level.h +++ b/src/game/level.h @@ -17,6 +17,7 @@ typedef struct Level { } Level; bool level_load(Level *level, const char *path); +bool level_load_generated(Level *level, Tilemap *gen_map); void level_update(Level *level, float dt); void level_render(Level *level, float interpolation); void level_free(Level *level); diff --git a/src/game/levelgen.c b/src/game/levelgen.c new file mode 100644 index 0000000..56ee42d --- /dev/null +++ b/src/game/levelgen.c @@ -0,0 +1,903 @@ +#include "game/levelgen.h" +#include "engine/parallax.h" +#include +#include +#include +#include + +/* ═══════════════════════════════════════════════════ + * RNG — simple xorshift32 for deterministic generation + * ═══════════════════════════════════════════════════ */ + +static uint32_t s_rng_state; + +static void rng_seed(uint32_t seed) { + s_rng_state = seed ? seed : (uint32_t)time(NULL); + if (s_rng_state == 0) s_rng_state = 1; +} + +static uint32_t rng_next(void) { + uint32_t x = s_rng_state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + s_rng_state = x; + return x; +} + +/* Random int in [min, max] inclusive */ +static int rng_range(int min, int max) { + if (min >= max) return min; + return min + (int)(rng_next() % (uint32_t)(max - min + 1)); +} + +/* Random float in [0, 1) */ +static float rng_float(void) { + return (float)(rng_next() & 0xFFFF) / 65536.0f; +} + +/* ═══════════════════════════════════════════════════ + * Tile IDs used in generation + * (must match TILEDEF entries we set up) + * ═══════════════════════════════════════════════════ */ + +#define TILE_EMPTY 0 +#define TILE_SOLID_1 1 /* main ground/wall tile */ +#define TILE_SOLID_2 2 /* variant solid tile */ +#define TILE_SOLID_3 3 /* variant solid tile */ +#define TILE_PLAT 4 /* one-way platform */ + +/* ═══════════════════════════════════════════════════ + * Segment types and templates + * + * Each segment is a rectangular section of tiles + * with a fixed height (the full level height) and + * a variable width. Segments are stitched left-to-right. + * + * The level has a standard height of 23 tiles + * (matching level01 — fits ~1.0 screen vertically). + * Ground is at row 20-22 (3 tiles thick). + * ═══════════════════════════════════════════════════ */ + +#define SEG_HEIGHT 23 +#define GROUND_ROW 20 /* first ground row */ +#define FLOOR_ROWS 3 /* rows 20, 21, 22 */ + +typedef enum SegmentType { + SEG_FLAT, /* flat ground, maybe some platforms above */ + SEG_PIT, /* gap in the ground — must jump across */ + SEG_PLATFORMS, /* vertical platforming section */ + SEG_CORRIDOR, /* walled corridor with ceiling */ + SEG_ARENA, /* open area, wider, good for combat */ + SEG_SHAFT, /* vertical shaft with platforms inside */ + SEG_TRANSITION, /* doorway/airlock between themes */ + SEG_TYPE_COUNT +} SegmentType; + +/* ═══════════════════════════════════════════════════ + * Tile placement helpers + * ═══════════════════════════════════════════════════ */ + +static void set_tile(uint16_t *layer, int map_w, int x, int y, uint16_t id) { + if (x >= 0 && x < map_w && y >= 0 && y < SEG_HEIGHT) { + layer[y * map_w + x] = id; + } +} + +static void fill_rect(uint16_t *layer, int map_w, + int x0, int y0, int x1, int y1, uint16_t id) { + for (int y = y0; y <= y1; y++) { + for (int x = x0; x <= x1; x++) { + set_tile(layer, map_w, x, y, id); + } + } +} + +static void fill_ground(uint16_t *layer, int map_w, int x0, int x1) { + fill_rect(layer, map_w, x0, GROUND_ROW, x1, SEG_HEIGHT - 1, TILE_SOLID_1); +} + +/* Add a random solid variant tile to break up visual monotony */ +static uint16_t random_solid(void) { + int r = rng_range(0, 5); + if (r == 0) return TILE_SOLID_2; + if (r == 1) return TILE_SOLID_3; + return TILE_SOLID_1; +} + +/* ═══════════════════════════════════════════════════ + * Entity spawn helpers + * ═══════════════════════════════════════════════════ */ + +static void add_entity(Tilemap *map, const char *type, int tile_x, int tile_y) { + if (map->entity_spawn_count >= MAX_ENTITY_SPAWNS) return; + EntitySpawn *es = &map->entity_spawns[map->entity_spawn_count]; + strncpy(es->type_name, type, 31); + es->type_name[31] = '\0'; + es->x = (float)(tile_x * TILE_SIZE); + es->y = (float)(tile_y * TILE_SIZE); + map->entity_spawn_count++; +} + +/* ═══════════════════════════════════════════════════ + * Segment generators + * + * Each writes tiles into the collision layer starting + * at column offset `x0` for `width` columns. + * Returns the effective ground connectivity at the + * right edge (row of the topmost ground tile, or -1 + * for no ground at the edge). + * ═══════════════════════════════════════════════════ */ + +/* SEG_FLAT: solid ground, optionally with platforms and enemies */ +static void gen_flat(Tilemap *map, int x0, int w, float difficulty, LevelTheme theme) { + (void)theme; + uint16_t *col = map->collision_layer; + int mw = map->width; + + fill_ground(col, mw, x0, x0 + w - 1); + + /* Random platforms above */ + int num_plats = rng_range(0, 2); + for (int i = 0; i < num_plats; i++) { + int px = x0 + rng_range(1, w - 5); + int py = rng_range(13, 17); + int pw = rng_range(3, 5); + for (int j = 0; j < pw && px + j < x0 + w; j++) { + set_tile(col, mw, px + j, py, TILE_PLAT); + } + + /* Maybe a grunt on the platform */ + if (difficulty > 0.3f && rng_float() < difficulty * 0.5f) { + add_entity(map, "grunt", px + pw / 2, py - 1); + } + } + + /* Ground enemies */ + if (rng_float() < 0.4f + difficulty * 0.4f) { + add_entity(map, "grunt", x0 + rng_range(2, w - 3), GROUND_ROW - 1); + } +} + +/* SEG_PIT: gap in the ground the player must jump */ +static void gen_pit(Tilemap *map, int x0, int w, float difficulty, LevelTheme theme) { + uint16_t *col = map->collision_layer; + int mw = map->width; + + int pit_start = rng_range(3, 5); + int pit_width = rng_range(3, 5 + (int)(difficulty * 3)); + if (pit_width > w - 4) pit_width = w - 4; + int pit_end = pit_start + pit_width; + + /* Ground before pit */ + fill_ground(col, mw, x0, x0 + pit_start - 1); + /* Ground after pit */ + if (x0 + pit_end < x0 + w) { + fill_ground(col, mw, x0 + pit_end, x0 + w - 1); + } + + /* Hazard at pit bottom — theme dependent */ + if (difficulty > 0.2f && rng_float() < 0.5f) { + int fx = x0 + pit_start + pit_width / 2; + if (theme == THEME_PLANET_SURFACE || theme == THEME_PLANET_BASE) { + /* Flame vents: natural hazard (surface) or industrial (base) */ + add_entity(map, "flame_vent", fx, GROUND_ROW); + fill_rect(col, mw, fx - 1, SEG_HEIGHT - 2, fx + 1, SEG_HEIGHT - 1, TILE_SOLID_1); + } else { + /* Space station: force field across the pit */ + add_entity(map, "force_field", fx, GROUND_ROW - 2); + } + } + + /* Stepping stones in wider pits */ + if (pit_width >= 5) { + int sx = x0 + pit_start + pit_width / 2; + if (theme == THEME_SPACE_STATION && rng_float() < 0.6f) { + /* Moving platform instead of static stepping stones */ + add_entity(map, "platform", sx, GROUND_ROW - 2); + } else { + set_tile(col, mw, sx, GROUND_ROW - 1, TILE_PLAT); + set_tile(col, mw, sx + 1, GROUND_ROW - 1, TILE_PLAT); + } + } +} + +/* SEG_PLATFORMS: vertical platforming section */ +static void gen_platforms(Tilemap *map, int x0, int w, float difficulty, LevelTheme theme) { + uint16_t *col = map->collision_layer; + int mw = map->width; + + /* Ground at bottom */ + fill_ground(col, mw, x0, x0 + w - 1); + + /* Stack of platforms ascending */ + int num_plats = rng_range(3, 5); + /* Space station: more platforms, low gravity makes climbing easier */ + if (theme == THEME_SPACE_STATION) num_plats += 1; + + for (int i = 0; i < num_plats; i++) { + int py = GROUND_ROW - 3 - i * 3; + if (py < 3) break; + int px = x0 + rng_range(1, w - 4); + int pw = rng_range(2, 4); + for (int j = 0; j < pw && px + j < x0 + w; j++) { + set_tile(col, mw, px + j, py, TILE_PLAT); + } + } + + /* Moving platform — more common on stations, less on surface */ + float plat_chance = (theme == THEME_SPACE_STATION) ? 0.8f : + (theme == THEME_PLANET_SURFACE) ? 0.3f : 0.5f; + if (rng_float() < plat_chance) { + int py = rng_range(10, 15); + bool vertical = (theme == THEME_SPACE_STATION && rng_float() < 0.5f); + add_entity(map, vertical ? "platform_v" : "platform", x0 + w / 2, py); + } + + /* Aerial threat */ + if (difficulty > 0.3f && rng_float() < difficulty) { + if (theme == THEME_PLANET_SURFACE) { + /* Surface: flyers are alien wildlife */ + add_entity(map, "flyer", x0 + rng_range(2, w - 3), rng_range(5, 10)); + } else if (theme == THEME_SPACE_STATION) { + /* Station: turret mounted high up or flyer drone */ + if (rng_float() < 0.5f) { + add_entity(map, "turret", x0 + rng_range(2, w - 3), rng_range(4, 8)); + } else { + add_entity(map, "flyer", x0 + rng_range(2, w - 3), rng_range(5, 10)); + } + } else { + /* Base: mix */ + add_entity(map, "flyer", x0 + rng_range(2, w - 3), rng_range(5, 10)); + } + } +} + +/* SEG_CORRIDOR: walled section with ceiling */ +static void gen_corridor(Tilemap *map, int x0, int w, float difficulty, LevelTheme theme) { + uint16_t *col = map->collision_layer; + int mw = map->width; + + int ceil_row = rng_range(12, 15); + + /* Ground */ + fill_ground(col, mw, x0, x0 + w - 1); + + /* Ceiling */ + fill_rect(col, mw, x0, ceil_row - 2, x0 + w - 1, ceil_row, TILE_SOLID_1); + + /* Walls at edges (short, just to frame) */ + fill_rect(col, mw, x0, ceil_row, x0, GROUND_ROW - 1, TILE_SOLID_1); + fill_rect(col, mw, x0 + w - 1, ceil_row, x0 + w - 1, GROUND_ROW - 1, TILE_SOLID_1); + + /* Opening in left wall (1 tile above ground to enter) */ + set_tile(col, mw, x0, GROUND_ROW - 1, TILE_EMPTY); + set_tile(col, mw, x0, GROUND_ROW - 2, TILE_EMPTY); + set_tile(col, mw, x0, GROUND_ROW - 3, TILE_EMPTY); + + /* Opening in right wall */ + set_tile(col, mw, x0 + w - 1, GROUND_ROW - 1, TILE_EMPTY); + set_tile(col, mw, x0 + w - 1, GROUND_ROW - 2, TILE_EMPTY); + set_tile(col, mw, x0 + w - 1, GROUND_ROW - 3, TILE_EMPTY); + + /* Theme-dependent corridor hazards */ + if (theme == THEME_PLANET_BASE || theme == THEME_SPACE_STATION) { + /* Tech environments: turrets and force fields */ + if (difficulty > 0.2f && rng_float() < 0.7f) { + add_entity(map, "turret", x0 + w / 2, ceil_row + 1); + } + if (difficulty > 0.4f && rng_float() < 0.5f) { + add_entity(map, "force_field", x0 + w / 2, GROUND_ROW - 1); + } + } else { + /* Planet surface: flame vents leak through the floor */ + if (difficulty > 0.3f && rng_float() < 0.5f) { + add_entity(map, "flame_vent", x0 + rng_range(2, w - 3), GROUND_ROW - 1); + } + /* Rare turret even on surface (crashed tech, scavenged) */ + if (difficulty > 0.5f && rng_float() < 0.3f) { + add_entity(map, "turret", x0 + w / 2, ceil_row + 1); + } + } + + /* Grunt patrol inside (all themes) */ + if (rng_float() < 0.5f + difficulty * 0.3f) { + add_entity(map, "grunt", x0 + rng_range(2, w - 3), GROUND_ROW - 1); + } +} + +/* SEG_ARENA: wide open area, multiple enemies */ +static void gen_arena(Tilemap *map, int x0, int w, float difficulty, LevelTheme theme) { + uint16_t *col = map->collision_layer; + int mw = map->width; + + fill_ground(col, mw, x0, x0 + w - 1); + + /* Raised platforms on sides */ + int plat_h = rng_range(16, 18); + fill_rect(col, mw, x0, plat_h, x0 + 2, GROUND_ROW - 1, TILE_SOLID_1); + fill_rect(col, mw, x0 + w - 3, plat_h, x0 + w - 1, GROUND_ROW - 1, TILE_SOLID_1); + + /* Central platform */ + int cp_y = rng_range(14, 16); + int cp_x = x0 + w / 2 - 2; + for (int j = 0; j < 4; j++) { + set_tile(col, mw, cp_x + j, cp_y, TILE_PLAT); + } + + /* Multiple enemies — composition depends on theme */ + int num_enemies = 1 + (int)(difficulty * 3); + for (int i = 0; i < num_enemies; i++) { + float r = rng_float(); + if (theme == THEME_PLANET_SURFACE) { + /* Alien wildlife: more grunts, some flyers */ + if (r < 0.65f) + add_entity(map, "grunt", x0 + rng_range(3, w - 4), GROUND_ROW - 1); + else + add_entity(map, "flyer", x0 + rng_range(3, w - 4), rng_range(8, 14)); + } else if (theme == THEME_SPACE_STATION) { + /* Station security: more flyers (drones), some grunts */ + if (r < 0.35f) + add_entity(map, "grunt", x0 + rng_range(3, w - 4), GROUND_ROW - 1); + else + add_entity(map, "flyer", x0 + rng_range(3, w - 4), rng_range(8, 14)); + } else { + /* Base: balanced mix */ + if (r < 0.5f) + add_entity(map, "grunt", x0 + rng_range(3, w - 4), GROUND_ROW - 1); + else + add_entity(map, "flyer", x0 + rng_range(3, w - 4), rng_range(8, 14)); + } + } + + /* Theme-dependent arena hazard */ + if (difficulty > 0.5f && rng_float() < 0.7f) { + int side = rng_range(0, 1); + int tx = side ? x0 + w - 2 : x0 + 1; + if (theme == THEME_PLANET_SURFACE) { + /* Surface: flame vents on the arena floor */ + add_entity(map, "flame_vent", x0 + w / 2, GROUND_ROW - 1); + } else { + /* Base/Station: turret on elevated ledge */ + add_entity(map, "turret", tx, plat_h - 1); + } + } +} + +/* SEG_SHAFT: vertical shaft with platforms to climb */ +static void gen_shaft(Tilemap *map, int x0, int w, float difficulty, LevelTheme theme) { + uint16_t *col = map->collision_layer; + int mw = map->width; + + /* Ground at bottom */ + fill_ground(col, mw, x0, x0 + w - 1); + + /* Walls on both sides forming a shaft */ + int shaft_left = x0 + 1; + int shaft_right = x0 + w - 2; + fill_rect(col, mw, x0, 3, x0, GROUND_ROW - 1, TILE_SOLID_1); + fill_rect(col, mw, x0 + w - 1, 3, x0 + w - 1, GROUND_ROW - 1, TILE_SOLID_1); + + /* Opening at top */ + set_tile(col, mw, x0, 3, TILE_EMPTY); + set_tile(col, mw, x0 + w - 1, 3, TILE_EMPTY); + + /* Openings at bottom to enter */ + for (int r = GROUND_ROW - 3; r < GROUND_ROW; r++) { + set_tile(col, mw, x0, r, TILE_EMPTY); + set_tile(col, mw, x0 + w - 1, r, TILE_EMPTY); + } + + /* Alternating platforms up the shaft */ + int inner_w = shaft_right - shaft_left + 1; + for (int i = 0; i < 5; i++) { + int py = GROUND_ROW - 3 - i * 3; + if (py < 4) break; + bool left_side = (i % 2 == 0); + int px = left_side ? shaft_left : shaft_right - 2; + int pw = (inner_w > 4) ? 3 : 2; + for (int j = 0; j < pw; j++) { + set_tile(col, mw, px + j, py, TILE_PLAT); + } + } + + /* Vertical moving platform — very common in stations */ + float vplat_chance = (theme == THEME_SPACE_STATION) ? 0.8f : 0.4f; + if (rng_float() < vplat_chance) { + add_entity(map, "platform_v", x0 + w / 2, 10); + } + + /* Bottom hazard — theme dependent */ + if (difficulty > 0.3f && rng_float() < 0.5f) { + if (theme == THEME_PLANET_SURFACE || theme == THEME_PLANET_BASE) { + add_entity(map, "flame_vent", x0 + w / 2, GROUND_ROW - 1); + } else { + /* Station: force field at bottom of shaft */ + add_entity(map, "force_field", x0 + w / 2, GROUND_ROW - 2); + } + } + + /* Aerial threat in shaft */ + if (difficulty > 0.4f && rng_float() < difficulty) { + if (theme == THEME_SPACE_STATION && rng_float() < 0.4f) { + /* Station: turret on shaft wall */ + int side = rng_range(0, 1); + int turret_x = side ? x0 + 1 : x0 + w - 2; + add_entity(map, "turret", turret_x, rng_range(8, 14)); + } else { + add_entity(map, "flyer", x0 + w / 2, rng_range(6, 12)); + } + } +} + +/* ═══════════════════════════════════════════════════ + * Segment type selection based on theme + * ═══════════════════════════════════════════════════ */ + +static SegmentType pick_segment_type(LevelTheme theme, int index, int total) { + /* First segment is always flat (safe start) */ + if (index == 0) return SEG_FLAT; + + /* Last segment tends to be an arena (climactic) */ + if (index == total - 1 && rng_float() < 0.6f) return SEG_ARENA; + + /* Theme biases */ + float r = rng_float(); + switch (theme) { + case THEME_PLANET_SURFACE: + /* Open terrain: lots of flat ground, pits, arenas. + Harsh alien landscape — wide and horizontal. */ + if (r < 0.25f) return SEG_FLAT; + if (r < 0.50f) return SEG_PIT; + if (r < 0.70f) return SEG_ARENA; + if (r < 0.85f) return SEG_PLATFORMS; + return SEG_SHAFT; + + case THEME_PLANET_BASE: + /* Research outpost: corridors, shafts, structured. + Indoor military feel — walled and enclosed. */ + if (r < 0.30f) return SEG_CORRIDOR; + if (r < 0.50f) return SEG_SHAFT; + if (r < 0.65f) return SEG_ARENA; + if (r < 0.80f) return SEG_FLAT; + if (r < 0.90f) return SEG_PLATFORMS; + return SEG_PIT; + + case THEME_SPACE_STATION: + /* Orbital station: shafts, platforms, corridors. + Vertical design, low gravity — lots of verticality. */ + if (r < 0.25f) return SEG_SHAFT; + if (r < 0.45f) return SEG_PLATFORMS; + if (r < 0.60f) return SEG_CORRIDOR; + if (r < 0.75f) return SEG_ARENA; + if (r < 0.90f) return SEG_FLAT; + return SEG_PIT; + + default: + return (SegmentType)rng_range(0, SEG_TYPE_COUNT - 1); + } +} + +/* Width for each segment type */ +static int segment_width(SegmentType type) { + switch (type) { + case SEG_FLAT: return rng_range(8, 14); + case SEG_PIT: return rng_range(10, 14); + case SEG_PLATFORMS: return rng_range(10, 14); + case SEG_CORRIDOR: return rng_range(10, 16); + case SEG_ARENA: return rng_range(14, 20); + case SEG_SHAFT: return rng_range(6, 10); + case SEG_TRANSITION: return 6; /* fixed: doorway/airlock */ + default: return 10; + } +} + +/* ═══════════════════════════════════════════════════ + * SEG_TRANSITION — doorway/airlock between themes + * + * A narrow walled passage with a doorway frame. + * Visually signals the environment change. + * ═══════════════════════════════════════════════════ */ + +static void gen_transition(Tilemap *map, int x0, int w, + float difficulty, LevelTheme from, LevelTheme to) { + (void)difficulty; + uint16_t *col = map->collision_layer; + int mw = map->width; + + /* ── Ground: solid floor across the whole transition ── */ + fill_ground(col, mw, x0, x0 + w - 1); + + /* ── Airlock structure ── + * Layout (6 tiles wide): + * col 0: outer wall (left bulkhead) + * col 1: inner left frame + * col 2-3: airlock chamber (open space) + * col 4: inner right frame + * col 5: outer wall (right bulkhead) + * + * Rows 4-6: thick ceiling / airlock header + * Rows 7-8: door frame top (inner columns only) + * Rows 9-18: open passageway + * Row 19: floor level (ground starts at 20) + */ + + int left = x0; + int right = x0 + w - 1; + + /* Outer bulkhead walls — full height from top to ground */ + fill_rect(col, mw, left, 0, left, GROUND_ROW - 1, TILE_SOLID_1); + fill_rect(col, mw, right, 0, right, GROUND_ROW - 1, TILE_SOLID_1); + + /* Thick ceiling header (airlock hull) */ + fill_rect(col, mw, left, 4, right, 7, TILE_SOLID_2); + + /* Inner frame columns — pillars flanking the doorway */ + fill_rect(col, mw, left + 1, 8, left + 1, GROUND_ROW - 1, TILE_SOLID_2); + fill_rect(col, mw, right - 1, 8, right - 1, GROUND_ROW - 1, TILE_SOLID_2); + + /* Clear the inner chamber (passable area between pillars) */ + for (int y = 8; y < GROUND_ROW; y++) { + for (int x = left + 2; x <= right - 2; x++) { + set_tile(col, mw, x, y, TILE_EMPTY); + } + } + + /* Door openings in the outer walls (player entry/exit) */ + for (int y = GROUND_ROW - 4; y < GROUND_ROW; y++) { + set_tile(col, mw, left, y, TILE_EMPTY); + set_tile(col, mw, right, y, TILE_EMPTY); + } + + /* Floor plating inside the airlock — one-way platform */ + for (int x = left + 2; x <= right - 2; x++) { + set_tile(col, mw, x, GROUND_ROW - 1, TILE_PLAT); + } + + /* ── Hazards inside the airlock ── */ + + /* Force field barrier in the doorway when entering a tech zone */ + if ((to == THEME_PLANET_BASE || to == THEME_SPACE_STATION) && + from == THEME_PLANET_SURFACE) { + add_entity(map, "force_field", x0 + w / 2, 14); + } + + /* Force field when transitioning between base and station too */ + if ((from == THEME_PLANET_BASE && to == THEME_SPACE_STATION) || + (from == THEME_SPACE_STATION && to == THEME_PLANET_BASE)) { + add_entity(map, "force_field", x0 + w / 2, 13); + } +} + +/* ═══════════════════════════════════════════════════ + * Theme sequence helpers + * ═══════════════════════════════════════════════════ */ + +/* Get the theme for a given segment index from the config */ +static LevelTheme theme_for_segment(const LevelGenConfig *config, int seg_idx) { + if (config->theme_count <= 0) return config->themes[0]; + if (seg_idx >= config->theme_count) return config->themes[config->theme_count - 1]; + return config->themes[seg_idx]; +} + +/* Get gravity for a theme. + * Surface: low gravity alien world — floaty jumps. + * Base: normal Earth-like artificial gravity. + * Station: heavy centrifugal / mag-lock gravity. */ +static float gravity_for_theme(LevelTheme theme) { + switch (theme) { + case THEME_PLANET_SURFACE: return 400.0f; + case THEME_PLANET_BASE: return 600.0f; + case THEME_SPACE_STATION: return 750.0f; + default: return 600.0f; + } +} + +/* Get background color for a theme */ +static SDL_Color bg_color_for_theme(LevelTheme theme) { + switch (theme) { + case THEME_PLANET_SURFACE: return (SDL_Color){12, 8, 20, 255}; + case THEME_PLANET_BASE: return (SDL_Color){10, 14, 22, 255}; + case THEME_SPACE_STATION: return (SDL_Color){5, 5, 18, 255}; + default: return (SDL_Color){15, 15, 30, 255}; + } +} + +/* Map theme to parallax visual style */ +static ParallaxStyle parallax_style_for_theme(LevelTheme theme) { + switch (theme) { + case THEME_PLANET_SURFACE: return PARALLAX_STYLE_ALIEN_SKY; + case THEME_PLANET_BASE: return PARALLAX_STYLE_INTERIOR; + case THEME_SPACE_STATION: return PARALLAX_STYLE_DEEP_SPACE; + default: return PARALLAX_STYLE_DEFAULT; + } +} + +static const char *theme_label(LevelTheme t) { + switch (t) { + case THEME_PLANET_SURFACE: return "surface"; + case THEME_PLANET_BASE: return "base"; + case THEME_SPACE_STATION: return "station"; + default: return "?"; + } +} + +/* ═══════════════════════════════════════════════════ + * Background decoration (bg_layer) + * ═══════════════════════════════════════════════════ */ + +static void gen_bg_decoration(Tilemap *map) { + /* Scatter some background tiles for visual depth */ + uint16_t *bg = map->bg_layer; + int mw = map->width; + + for (int y = 0; y < SEG_HEIGHT - FLOOR_ROWS; y++) { + for (int x = 0; x < mw; x++) { + /* Only place bg tiles where collision is empty */ + if (map->collision_layer[y * mw + x] != TILE_EMPTY) continue; + + /* Sparse random decoration */ + if (rng_float() < 0.02f) { + bg[y * mw + x] = (uint16_t)rng_range(2, 3); + } + } + } +} + +/* ═══════════════════════════════════════════════════ + * Main generator + * ═══════════════════════════════════════════════════ */ + +LevelGenConfig levelgen_default_config(void) { + LevelGenConfig config = { + .seed = 0, + .num_segments = 5, + .difficulty = 0.5f, + .gravity = 0, /* 0 = let theme decide */ + .theme_count = 1, + }; + config.themes[0] = THEME_SPACE_STATION; + return config; +} + +bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) { + if (!map || !config) return false; + + rng_seed(config->seed); + + int num_segs = config->num_segments; + if (num_segs < 2) num_segs = 2; + if (num_segs > 10) num_segs = 10; + + /* ── Phase 1: decide segment types and widths ── */ + /* We may insert transition segments between theme boundaries, + * so the final count can be larger than num_segs. + * Max: num_segs content + (num_segs-1) transitions = 2*num_segs-1 */ + #define MAX_FINAL_SEGS 20 + SegmentType seg_types[MAX_FINAL_SEGS]; + int seg_widths[MAX_FINAL_SEGS]; + LevelTheme seg_themes[MAX_FINAL_SEGS]; /* per-segment theme */ + LevelTheme seg_from[MAX_FINAL_SEGS]; /* for transitions: source theme */ + int final_seg_count = 0; + int total_width = 0; + + for (int i = 0; i < num_segs; i++) { + LevelTheme t = theme_for_segment(config, i); + + /* Insert a transition segment at theme boundaries */ + if (i > 0) { + LevelTheme prev_t = theme_for_segment(config, i - 1); + if (t != prev_t && final_seg_count < MAX_FINAL_SEGS) { + seg_types[final_seg_count] = SEG_TRANSITION; + seg_widths[final_seg_count] = segment_width(SEG_TRANSITION); + seg_themes[final_seg_count] = t; /* entering new theme */ + seg_from[final_seg_count] = prev_t; /* leaving old theme */ + total_width += seg_widths[final_seg_count]; + final_seg_count++; + } + } + + if (final_seg_count >= MAX_FINAL_SEGS) break; + + seg_types[final_seg_count] = pick_segment_type(t, i, num_segs); + seg_widths[final_seg_count] = segment_width(seg_types[final_seg_count]); + seg_themes[final_seg_count] = t; + seg_from[final_seg_count] = t; /* not a transition */ + total_width += seg_widths[final_seg_count]; + final_seg_count++; + } + num_segs = final_seg_count; + + /* Add 2-tile buffer on each side */ + total_width += 4; + + /* ── Phase 2: allocate tilemap ── */ + memset(map, 0, sizeof(Tilemap)); + map->width = total_width; + map->height = SEG_HEIGHT; + + int total_tiles = map->width * map->height; + map->collision_layer = calloc(total_tiles, sizeof(uint16_t)); + map->bg_layer = calloc(total_tiles, sizeof(uint16_t)); + map->fg_layer = calloc(total_tiles, sizeof(uint16_t)); + + if (!map->collision_layer || !map->bg_layer || !map->fg_layer) { + fprintf(stderr, "levelgen: failed to allocate layers\n"); + return false; + } + + /* ── Phase 3: set tile definitions ── */ + map->tile_defs[1] = (TileDef){0, 0, TILE_SOLID}; /* main solid */ + map->tile_defs[2] = (TileDef){1, 0, TILE_SOLID}; /* solid variant */ + map->tile_defs[3] = (TileDef){2, 0, TILE_SOLID}; /* solid variant */ + map->tile_defs[4] = (TileDef){0, 1, TILE_PLATFORM}; /* one-way */ + map->tile_def_count = 5; + + /* ── Phase 4: generate segments ── */ + int cursor = 2; /* start after left buffer */ + + /* Left border wall */ + fill_rect(map->collision_layer, map->width, 0, 0, 1, SEG_HEIGHT - 1, TILE_SOLID_1); + + for (int i = 0; i < num_segs; i++) { + int w = seg_widths[i]; + LevelTheme theme = seg_themes[i]; + + switch (seg_types[i]) { + case SEG_FLAT: gen_flat(map, cursor, w, config->difficulty, theme); break; + case SEG_PIT: gen_pit(map, cursor, w, config->difficulty, theme); break; + case SEG_PLATFORMS: gen_platforms(map, cursor, w, config->difficulty, theme); break; + case SEG_CORRIDOR: gen_corridor(map, cursor, w, config->difficulty, theme); break; + case SEG_ARENA: gen_arena(map, cursor, w, config->difficulty, theme); break; + case SEG_SHAFT: gen_shaft(map, cursor, w, config->difficulty, theme); break; + case SEG_TRANSITION: + gen_transition(map, cursor, w, config->difficulty, seg_from[i], theme); + break; + default: gen_flat(map, cursor, w, config->difficulty, theme); break; + } + + cursor += w; + } + + /* Right border wall */ + fill_rect(map->collision_layer, map->width, + map->width - 2, 0, map->width - 1, SEG_HEIGHT - 1, TILE_SOLID_1); + + /* ── Phase 5: add visual variety to solid tiles ── */ + for (int y = 0; y < map->height; y++) { + for (int x = 0; x < map->width; x++) { + int idx = y * map->width + x; + if (map->collision_layer[idx] == TILE_SOLID_1) { + /* Only vary interior tiles (not edge tiles) */ + bool has_air_neighbor = false; + if (y > 0 && map->collision_layer[(y-1)*map->width+x] == 0) has_air_neighbor = true; + if (!has_air_neighbor) { + map->collision_layer[idx] = random_solid(); + } + } + } + } + + /* ── Phase 6: background decoration ── */ + gen_bg_decoration(map); + + /* ── Phase 7: metadata ── */ + map->player_spawn = vec2(3.0f * TILE_SIZE, (GROUND_ROW - 2) * TILE_SIZE); + + /* Theme-based gravity and atmosphere (uses first theme in sequence) */ + LevelTheme primary_theme = config->themes[0]; + map->gravity = config->gravity > 0 ? config->gravity : gravity_for_theme(primary_theme); + map->bg_color = bg_color_for_theme(primary_theme); + map->has_bg_color = true; + map->parallax_style = (int)parallax_style_for_theme(primary_theme); + + /* Music */ + strncpy(map->music_path, "assets/sounds/algardalgar.ogg", ASSET_PATH_MAX - 1); + + /* Tileset */ + /* NOTE: tileset texture will be loaded by level_load_generated */ + + /* Segment type names for debug output */ + static const char *seg_names[] = { + "flat", "pit", "plat", "corr", "arena", "shaft", "trans" + }; + printf("levelgen: generated %dx%d level (%d segments, seed=%u)\n", + map->width, map->height, num_segs, s_rng_state); + printf(" segments:"); + for (int i = 0; i < num_segs; i++) { + printf(" %s[%s]", seg_names[seg_types[i]], theme_label(seg_themes[i])); + } + printf("\n"); + + return true; +} + +/* ═══════════════════════════════════════════════════ + * Dump to .lvl file format + * ═══════════════════════════════════════════════════ */ + +bool levelgen_dump_lvl(const Tilemap *map, const char *path) { + if (!map || !path) return false; + + FILE *f = fopen(path, "w"); + if (!f) { + fprintf(stderr, "levelgen: failed to open %s for writing\n", path); + return false; + } + + fprintf(f, "# Procedurally generated level\n"); + fprintf(f, "# Seed state: %u\n\n", s_rng_state); + + fprintf(f, "TILESET assets/tiles/tileset.png\n"); + fprintf(f, "SIZE %d %d\n", map->width, map->height); + + /* Player spawn in tile coords */ + int spawn_tx = (int)(map->player_spawn.x / TILE_SIZE); + int spawn_ty = (int)(map->player_spawn.y / TILE_SIZE); + fprintf(f, "SPAWN %d %d\n", spawn_tx, spawn_ty); + + if (map->gravity > 0) { + fprintf(f, "GRAVITY %.0f\n", map->gravity); + } + + if (map->has_bg_color) { + fprintf(f, "BG_COLOR %d %d %d\n", + map->bg_color.r, map->bg_color.g, map->bg_color.b); + } + + if (map->music_path[0]) { + fprintf(f, "MUSIC %s\n", map->music_path); + } + + fprintf(f, "\n"); + + /* Entity spawns */ + for (int i = 0; i < map->entity_spawn_count; i++) { + const EntitySpawn *es = &map->entity_spawns[i]; + int tx = (int)(es->x / TILE_SIZE); + int ty = (int)(es->y / TILE_SIZE); + fprintf(f, "ENTITY %s %d %d\n", es->type_name, tx, ty); + } + + fprintf(f, "\n"); + + /* Tile definitions */ + for (int id = 1; id < map->tile_def_count; id++) { + const TileDef *td = &map->tile_defs[id]; + if (td->flags != 0 || td->tex_x != 0 || td->tex_y != 0) { + fprintf(f, "TILEDEF %d %d %d %u\n", id, td->tex_x, td->tex_y, td->flags); + } + } + + fprintf(f, "\n"); + + /* Collision layer */ + fprintf(f, "LAYER collision\n"); + for (int y = 0; y < map->height; y++) { + for (int x = 0; x < map->width; x++) { + if (x > 0) fprintf(f, " "); + fprintf(f, "%d", map->collision_layer[y * map->width + x]); + } + fprintf(f, "\n"); + } + + /* Background layer */ + bool has_bg = false; + for (int i = 0; i < map->width * map->height; i++) { + if (map->bg_layer[i] != 0) { has_bg = true; break; } + } + if (has_bg) { + fprintf(f, "\nLAYER bg\n"); + for (int y = 0; y < map->height; y++) { + for (int x = 0; x < map->width; x++) { + if (x > 0) fprintf(f, " "); + fprintf(f, "%d", map->bg_layer[y * map->width + x]); + } + fprintf(f, "\n"); + } + } + + fclose(f); + printf("levelgen: dumped level to %s\n", path); + return true; +} diff --git a/src/game/levelgen.h b/src/game/levelgen.h new file mode 100644 index 0000000..043cf4c --- /dev/null +++ b/src/game/levelgen.h @@ -0,0 +1,61 @@ +#ifndef JNR_LEVELGEN_H +#define JNR_LEVELGEN_H + +#include "engine/tilemap.h" +#include + +/* ═══════════════════════════════════════════════════ + * Procedural Level Generator + * + * Generates a Tilemap directly in memory by stitching + * together segment templates. Supports: + * - Mixed layouts (horizontal, vertical, arena) + * - Enemy and hazard placement + * - Difficulty scaling via seed + * - Dumping to .lvl format for inspection + * ═══════════════════════════════════════════════════ */ + +/* Level theme — affects layout, hazards, gravity, atmosphere */ +typedef enum LevelTheme { + THEME_PLANET_SURFACE, /* alien planet exterior: open terrain, pits, + flame vents, harsh conditions, high gravity */ + THEME_PLANET_BASE, /* research outpost on a planet: corridors, + force fields, turrets, medium gravity */ + THEME_SPACE_STATION, /* orbital station: shafts, platforms, low + gravity, moving platforms, turrets */ + THEME_COUNT +} LevelTheme; + +#define MAX_THEME_SEQUENCE 10 + +/* Generator configuration */ +typedef struct LevelGenConfig { + uint32_t seed; /* RNG seed (0 = random from time) */ + int num_segments; /* how many segments to stitch (2-10) */ + float difficulty; /* 0.0 = easy, 1.0 = hard */ + float gravity; /* level gravity override (0 = auto) */ + + /* Theme sequence: one theme per segment. + * If theme_count == 0, all segments use themes[0]. + * If theme_count > 0, segment i uses themes[min(i, theme_count-1)]. + * Transition segments are auto-inserted at theme boundaries. */ + LevelTheme themes[MAX_THEME_SEQUENCE]; + int theme_count; /* number of entries in themes[] */ +} LevelGenConfig; + +/* Default config for quick generation */ +LevelGenConfig levelgen_default_config(void); + +/* Generate a level directly into a Tilemap struct. + * The Tilemap will have layers allocated, tile defs set, + * entity spawns populated, and metadata configured. + * Caller must eventually call tilemap_free() on it. + * Returns true on success. */ +bool levelgen_generate(Tilemap *map, const LevelGenConfig *config); + +/* Dump a generated (or any) Tilemap to a .lvl file. + * Useful for inspecting/editing procedural output. + * Returns true on success. */ +bool levelgen_dump_lvl(const Tilemap *map, const char *path); + +#endif /* JNR_LEVELGEN_H */ diff --git a/src/game/powerup.c b/src/game/powerup.c new file mode 100644 index 0000000..b9b8e62 --- /dev/null +++ b/src/game/powerup.c @@ -0,0 +1,142 @@ +#include "game/powerup.h" +#include "game/sprites.h" +#include "engine/renderer.h" +#include "engine/particle.h" +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +/* ── Stashed entity manager ────────────────────────── */ +static EntityManager *s_em = NULL; + +/* ── Constants ─────────────────────────────────────── */ +#define BOB_SPEED 3.0f /* sine cycles per second */ +#define BOB_HEIGHT 3.0f /* pixels of vertical bob */ +#define PICKUP_SIZE 12 /* hitbox size (centered in 16x16) */ + +/* ── Callbacks ─────────────────────────────────────── */ + +static void powerup_update(Entity *self, float dt, const Tilemap *map) { + (void)map; + PowerupData *pd = (PowerupData *)self->data; + if (!pd) return; + + /* Sine bob animation */ + pd->bob_timer += dt; + float bob = sinf(pd->bob_timer * BOB_SPEED * 2.0f * (float)M_PI) * BOB_HEIGHT; + self->body.pos.y = pd->base_pos.y + bob; + + /* Gentle sparkle particles every ~0.4s */ + if ((int)(pd->bob_timer * 10.0f) % 4 == 0) { + SDL_Color color; + switch (pd->kind) { + case POWERUP_HEALTH: color = (SDL_Color){220, 50, 50, 200}; break; + case POWERUP_JETPACK: color = (SDL_Color){255, 180, 50, 200}; break; + case POWERUP_DRONE: color = (SDL_Color){50, 200, 255, 200}; break; + default: color = (SDL_Color){255, 255, 255, 200}; break; + } + Vec2 center = vec2( + self->body.pos.x + self->body.size.x * 0.5f, + self->body.pos.y + self->body.size.y * 0.5f + ); + ParticleBurst b = { + .origin = center, + .count = 1, + .speed_min = 5.0f, + .speed_max = 15.0f, + .life_min = 0.3f, + .life_max = 0.6f, + .size_min = 1.0f, + .size_max = 2.0f, + .spread = (float)M_PI, + .direction = -(float)M_PI / 2.0f, /* upward */ + .drag = 1.0f, + .gravity_scale = 0.0f, + .color = color, + .color_vary = true, + }; + particle_emit(&b); + } + + /* Animation cycling */ + animation_update(&self->anim, dt); +} + +static void powerup_render(Entity *self, const Camera *cam) { + (void)cam; + if (!g_spritesheet || !self->anim.def) return; + + SDL_Rect src = animation_current_rect(&self->anim); + Body *body = &self->body; + + /* Center the 16x16 sprite on the smaller hitbox */ + float draw_x = body->pos.x + body->size.x * 0.5f - SPRITE_CELL * 0.5f; + float draw_y = body->pos.y + body->size.y * 0.5f - SPRITE_CELL * 0.5f; + + Sprite spr = { + .texture = g_spritesheet, + .src = src, + .pos = vec2(draw_x, draw_y), + .size = vec2(SPRITE_CELL, SPRITE_CELL), + .flip_x = false, + .flip_y = false, + .layer = LAYER_ENTITIES, + .alpha = 255, + }; + renderer_submit(&spr); +} + +static void powerup_destroy(Entity *self) { + free(self->data); + self->data = NULL; +} + +/* ── Public API ────────────────────────────────────── */ + +void powerup_register(EntityManager *em) { + entity_register(em, ENT_POWERUP, powerup_update, powerup_render, powerup_destroy); + s_em = em; +} + +Entity *powerup_spawn(EntityManager *em, Vec2 pos, PowerupKind kind) { + Entity *e = entity_spawn(em, ENT_POWERUP, pos); + if (!e) return NULL; + + e->body.size = vec2(PICKUP_SIZE, PICKUP_SIZE); + e->body.gravity_scale = 0.0f; /* floats in place */ + e->health = 1; + e->max_health = 1; + e->damage = 0; + e->flags |= ENTITY_INVINCIBLE; /* can't be shot */ + + PowerupData *pd = calloc(1, sizeof(PowerupData)); + pd->kind = kind; + pd->bob_timer = 0.0f; + pd->base_pos = pos; + e->data = pd; + + /* Set animation based on kind */ + switch (kind) { + case POWERUP_HEALTH: animation_set(&e->anim, &anim_powerup_health); break; + case POWERUP_JETPACK: animation_set(&e->anim, &anim_powerup_jetpack); break; + case POWERUP_DRONE: animation_set(&e->anim, &anim_powerup_drone); break; + default: animation_set(&e->anim, &anim_powerup_health); break; + } + + return e; +} + +Entity *powerup_spawn_health(EntityManager *em, Vec2 pos) { + return powerup_spawn(em, pos, POWERUP_HEALTH); +} + +Entity *powerup_spawn_jetpack(EntityManager *em, Vec2 pos) { + return powerup_spawn(em, pos, POWERUP_JETPACK); +} + +Entity *powerup_spawn_drone(EntityManager *em, Vec2 pos) { + return powerup_spawn(em, pos, POWERUP_DRONE); +} diff --git a/src/game/powerup.h b/src/game/powerup.h new file mode 100644 index 0000000..4d55183 --- /dev/null +++ b/src/game/powerup.h @@ -0,0 +1,40 @@ +#ifndef JNR_POWERUP_H +#define JNR_POWERUP_H + +#include "engine/entity.h" + +/* ═══════════════════════════════════════════════════ + * Powerup Pickups + * + * Small collectible items that grant the player + * an instant benefit on contact: + * - Health: restores 1 HP + * - Jetpack: instantly refills all dash charges + * - Drone: spawns an orbiting combat drone + * ═══════════════════════════════════════════════════ */ + +typedef enum PowerupKind { + POWERUP_HEALTH, + POWERUP_JETPACK, + POWERUP_DRONE, + POWERUP_KIND_COUNT +} PowerupKind; + +typedef struct PowerupData { + PowerupKind kind; + float bob_timer; /* sine bob animation */ + Vec2 base_pos; /* original spawn position */ +} PowerupData; + +/* Register the powerup entity type */ +void powerup_register(EntityManager *em); + +/* Spawn a specific powerup at a position */ +Entity *powerup_spawn(EntityManager *em, Vec2 pos, PowerupKind kind); + +/* Convenience spawners for level/levelgen */ +Entity *powerup_spawn_health(EntityManager *em, Vec2 pos); +Entity *powerup_spawn_jetpack(EntityManager *em, Vec2 pos); +Entity *powerup_spawn_drone(EntityManager *em, Vec2 pos); + +#endif /* JNR_POWERUP_H */ diff --git a/src/game/sprites.c b/src/game/sprites.c index e624db4..3041851 100644 --- a/src/game/sprites.c +++ b/src/game/sprites.c @@ -505,6 +505,361 @@ static const uint32_t enemy_bullet2[16*16] = { }; +/* ── Hazard sprites ─────────────────────────────────── */ + +/* Turret idle - metallic gun turret mounted on wall/floor */ +static const uint32_t turret_idle1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, T, T, T, + T, T, T, T, GYD, GRY, GRY, GRY, GRY, GRY, GRY, GRY, GRY, GYD, T, T, + T, T, T, T, GYD, GRY, GYL, GRY, GRY, GYL, GRY, GRY, GRY, GYD, T, T, + T, T, T, GYD, GRY, GRY, GRY, RED, RED, GRY, GRY, GRY, GRY, GRY, GYD, T, + T, T, T, GYD, GRY, GRY, GRY, RDD, RDD, GRY, GRY, GRY, GRY, GRY, GYD, T, + T, T, T, T, GYD, GRY, GYL, GRY, GRY, GYL, GRY, GRY, GRY, GYD, T, T, + T, T, T, T, GYD, GRY, GRY, GRY, GRY, GRY, GRY, GRY, GRY, GYD, T, T, + T, T, T, T, T, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, T, T, T, + T, T, T, T, T, T, GYD, GRY, GRY, GYD, T, T, T, T, T, T, + T, T, T, T, T, GYD, GRY, GRY, GRY, GRY, GYD, T, T, T, T, T, + T, T, T, T, GYD, GRY, GRY, GRY, GRY, GRY, GRY, GYD, T, T, T, T, + T, T, T, T, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, T, T, T, T, +}; + +/* Turret fire - barrel flash */ +static const uint32_t turret_fire1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, ORG, T, T, + T, T, T, T, T, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, YLW, ORG, T, + T, T, T, T, GYD, GRY, GRY, GRY, GRY, GRY, GRY, GRY, GRY, WHT, YLW, T, + T, T, T, T, GYD, GRY, GYL, GRY, GRY, GYL, GRY, GRY, GRY, WHT, YLW, T, + T, T, T, GYD, GRY, GRY, GRY, RED, RED, GRY, GRY, GRY, GRY, YLW, ORG, T, + T, T, T, GYD, GRY, GRY, GRY, RDD, RDD, GRY, GRY, GRY, GRY, YLW, ORG, T, + T, T, T, T, GYD, GRY, GYL, GRY, GRY, GYL, GRY, GRY, GRY, WHT, YLW, T, + T, T, T, T, GYD, GRY, GRY, GRY, GRY, GRY, GRY, GRY, GRY, WHT, YLW, T, + T, T, T, T, T, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, YLW, ORG, T, + T, T, T, T, T, T, GYD, GRY, GRY, GYD, T, T, T, ORG, T, T, + T, T, T, T, T, GYD, GRY, GRY, GRY, GRY, GYD, T, T, T, T, T, + T, T, T, T, GYD, GRY, GRY, GRY, GRY, GRY, GRY, GYD, T, T, T, T, + T, T, T, T, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, T, T, T, T, +}; + +/* Moving platform - metallic platform with glowing edges */ +#define PLT 0x3a6ea5FF /* platform blue */ +#define PLD 0x2a5080FF /* platform blue dark */ +#define PLL 0x5a9ed5FF /* platform blue light */ +static const uint32_t platform1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, CYN, + PLL, PLL, PLT, PLT, PLT, PLT, PLT, PLT, PLT, PLT, PLT, PLT, PLT, PLT, PLL, PLL, + PLT, PLT, PLT, PLD, PLT, PLT, PLD, PLT, PLT, PLD, PLT, PLT, PLD, PLT, PLT, PLT, + PLT, PLT, PLT, PLD, PLT, PLT, PLD, PLT, PLT, PLD, PLT, PLT, PLD, PLT, PLT, PLT, + PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, PLD, + GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* Flame vent base - metallic grate on floor */ +#define FLB 0x2244aaFF /* flame blue */ +#define FLD 0x1a3388FF /* flame blue dark */ +#define FLL 0x4488ffFF /* flame blue light */ +static const uint32_t flame_vent_idle1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, FLB, FLB, T, T, T, T, T, T, T, + GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, + GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, + GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, + GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, + GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, +}; + +/* Flame vent active - blue flames shooting up from grate */ +static const uint32_t flame_vent_active1[16*16] = { + T, T, T, T, T, T, FLD, T, T, FLD, T, T, T, T, T, T, + T, T, T, T, T, FLD, FLB, FLD, FLD, FLB, FLD, T, T, T, T, T, + T, T, T, T, FLD, FLB, FLL, FLB, FLB, FLL, FLB, FLD, T, T, T, T, + T, T, T, FLD, FLB, FLL, WHT, FLL, FLL, WHT, FLL, FLB, FLD, T, T, T, + T, T, T, FLB, FLL, WHT, WHT, FLL, FLL, WHT, WHT, FLL, FLB, T, T, T, + T, T, FLD, FLB, FLL, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLB, FLD, T, T, + T, T, FLB, FLL, WHT, WHT, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLB, T, T, + T, T, FLB, FLL, WHT, WHT, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLB, T, T, + T, T, FLD, FLB, FLL, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLB, FLD, T, T, + T, T, T, FLB, FLL, FLL, WHT, WHT, WHT, WHT, FLL, FLL, FLB, T, T, T, + T, T, T, T, FLB, FLL, FLL, FLB, FLB, FLL, FLL, FLB, T, T, T, T, + GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, + GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, + GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, + GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, + GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, +}; + +/* Flame vent active frame 2 - flame flicker */ +static const uint32_t flame_vent_active2[16*16] = { + T, T, T, T, T, T, T, FLD, FLD, T, T, T, T, T, T, T, + T, T, T, T, T, T, FLD, FLB, FLB, FLD, T, T, T, T, T, T, + T, T, T, T, T, FLD, FLB, FLL, FLL, FLB, FLD, T, T, T, T, T, + T, T, T, T, FLD, FLB, FLL, WHT, WHT, FLL, FLB, FLD, T, T, T, T, + T, T, T, FLD, FLB, FLL, WHT, WHT, WHT, WHT, FLL, FLB, FLD, T, T, T, + T, T, T, FLB, FLL, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLB, T, T, T, + T, T, FLD, FLB, FLL, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLB, FLD, T, T, + T, T, FLB, FLL, WHT, WHT, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLB, T, T, + T, T, FLB, FLL, FLL, WHT, WHT, WHT, WHT, WHT, WHT, FLL, FLL, FLB, T, T, + T, T, T, FLB, FLL, FLL, WHT, WHT, WHT, WHT, FLL, FLL, FLB, T, T, T, + T, T, T, T, FLB, FLB, FLL, FLB, FLB, FLL, FLB, FLB, T, T, T, T, + GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, + GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, + GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, + GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, GRY, GYD, + GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, GYD, +}; + +/* Force field on - vertical energy barrier */ +#define FFB 0x4466ffFF /* force field blue */ +#define FFD 0x2244ccFF /* force field dark */ +#define FFL 0x88aaFFFF /* force field light */ +static const uint32_t force_field_on1[16*16] = { + T, T, T, FFD, T, FFD, FFB, FFL, FFL, FFB, FFD, T, FFD, T, T, T, + T, T, FFD, FFB, FFD, FFB, FFL, WHT, WHT, FFL, FFB, FFD, FFB, FFD, T, T, + T, T, T, FFB, FFB, FFL, WHT, WHT, WHT, WHT, FFL, FFB, FFB, T, T, T, + T, T, FFD, FFB, FFL, WHT, WHT, FFL, FFL, WHT, WHT, FFL, FFB, FFD, T, T, + T, T, T, FFB, FFL, WHT, FFL, FFB, FFB, FFL, WHT, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFL, FFB, FFD, FFD, FFB, FFL, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFL, FFB, FFD, FFB, FFB, FFD, FFB, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFD, FFB, FFL, FFL, FFB, FFD, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFD, FFB, FFL, WHT, WHT, FFL, FFB, FFD, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFL, WHT, WHT, WHT, WHT, FFL, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFL, WHT, WHT, FFL, FFL, WHT, WHT, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFL, FFB, FFD, FFD, FFB, FFL, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFL, FFB, FFD, FFB, FFB, FFD, FFB, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFD, FFB, FFL, FFL, FFL, FFL, FFB, FFD, FFB, FFD, T, T, + T, T, T, FFD, T, FFD, FFB, FFL, FFL, FFB, FFD, T, FFD, T, T, T, + T, T, T, T, T, T, FFD, FFD, FFD, FFD, T, T, T, T, T, T, +}; + +/* Force field on frame 2 - shimmer */ +static const uint32_t force_field_on2[16*16] = { + T, T, T, T, T, FFD, FFB, FFB, FFB, FFB, FFD, T, T, T, T, T, + T, T, T, FFD, FFD, FFB, FFL, FFL, FFL, FFL, FFB, FFD, FFD, T, T, T, + T, T, FFD, FFB, FFB, FFL, WHT, FFL, FFL, WHT, FFL, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFL, WHT, FFL, FFB, FFB, FFL, WHT, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFL, FFB, FFD, FFD, FFB, FFL, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFL, FFB, FFD, FFB, FFB, FFD, FFB, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFD, FFB, FFL, FFL, FFB, FFD, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFD, FFB, FFL, WHT, WHT, FFL, FFB, FFD, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFL, WHT, WHT, WHT, WHT, FFL, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFL, WHT, FFL, FFB, FFB, FFL, WHT, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFB, FFL, FFB, FFD, FFD, FFB, FFL, FFB, FFB, FFD, T, T, + T, T, T, FFB, FFL, FFB, FFD, FFB, FFB, FFD, FFB, FFL, FFB, T, T, T, + T, T, FFD, FFB, FFD, FFB, FFL, FFL, FFL, FFL, FFB, FFD, FFB, FFD, T, T, + T, T, T, FFD, T, FFD, FFB, FFL, FFL, FFB, FFD, T, FFD, T, T, T, + T, T, T, T, T, T, FFD, FFB, FFB, FFD, T, T, T, T, T, T, + T, T, T, T, T, T, T, FFD, FFD, T, T, T, T, T, T, T, +}; + +/* Force field off - just dim posts/emitters */ +static const uint32_t force_field_off1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, FFD, FFD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, FFD, FFD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, FFD, FFD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, FFD, FFD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, FFD, FFD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, FFD, FFD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* ── Powerup sprites ────────────────────────────────── */ + +/* Health powerup — red cross / heart icon */ +static const uint32_t powerup_health1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, RDL, RDL, T, T, RDL, RDL, T, T, T, T, T, + T, T, T, T, RDL, RED, RED, RDL, RDL, RED, RED, RDL, T, T, T, T, + T, T, T, RDL, RED, RED, RED, RED, RED, RED, RED, RED, RDL, T, T, T, + T, T, T, RDL, RED, RED, WHT, RED, RED, WHT, RED, RED, RDL, T, T, T, + T, T, T, RDL, RED, RED, RED, WHT, WHT, RED, RED, RED, RDL, T, T, T, + T, T, T, RDL, RED, RED, RED, RED, RED, RED, RED, RED, RDL, T, T, T, + T, T, T, T, RDL, RED, RED, RED, RED, RED, RED, RDL, T, T, T, T, + T, T, T, T, T, RDL, RED, RED, RED, RED, RDL, T, T, T, T, T, + T, T, T, T, T, T, RDL, RED, RED, RDL, T, T, T, T, T, T, + T, T, T, T, T, T, T, RDD, RDD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* Health powerup frame 2 — slight glow/pulse */ +static const uint32_t powerup_health2[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, RDL, RDL, T, T, RDL, RDL, T, T, T, T, T, + T, T, T, T, RDL, RED, RED, RDL, RDL, RED, RED, RDL, T, T, T, T, + T, T, T, RDL, RED, RED, RED, RED, RED, RED, RED, RED, RDL, T, T, T, + T, T, RDL, RED, RED, RED, RED, RED, RED, RED, RED, RED, RED, RDL, T, T, + T, T, RDL, RED, RED, RED, WHT, WHT, WHT, WHT, RED, RED, RED, RDL, T, T, + T, T, RDL, RED, RED, WHT, WHT, WHT, WHT, WHT, WHT, RED, RED, RDL, T, T, + T, T, RDL, RED, RED, RED, WHT, WHT, WHT, WHT, RED, RED, RED, RDL, T, T, + T, T, T, RDL, RED, RED, RED, RED, RED, RED, RED, RED, RDL, T, T, T, + T, T, T, T, RDL, RED, RED, RED, RED, RED, RED, RDL, T, T, T, T, + T, T, T, T, T, RDL, RED, RED, RED, RED, RDL, T, T, T, T, T, + T, T, T, T, T, T, RDD, RDD, RDD, RDD, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* Jetpack recharge powerup — orange lightning bolt */ +static const uint32_t powerup_jetpack1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, ORG, ORG, ORG, T, T, T, T, T, T, + T, T, T, T, T, T, ORG, YLW, YLW, ORG, T, T, T, T, T, T, + T, T, T, T, T, ORG, YLW, YLW, ORG, T, T, T, T, T, T, T, + T, T, T, T, ORG, YLW, YLW, ORG, T, T, T, T, T, T, T, T, + T, T, T, ORG, YLW, WHT, YLW, ORG, ORG, ORG, ORG, T, T, T, T, T, + T, T, T, ORG, YLW, WHT, WHT, YLW, YLW, YLW, ORG, T, T, T, T, T, + T, T, T, T, ORG, ORG, ORG, YLW, WHT, YLW, ORG, T, T, T, T, T, + T, T, T, T, T, T, T, ORG, YLW, ORG, T, T, T, T, T, T, + T, T, T, T, T, T, ORG, YLW, ORG, T, T, T, T, T, T, T, + T, T, T, T, T, ORG, YLW, ORG, T, T, T, T, T, T, T, T, + T, T, T, T, T, ORG, ORG, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* Jetpack powerup frame 2 — brighter glow */ +static const uint32_t powerup_jetpack2[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, YLW, YLW, YLW, T, T, T, T, T, T, + T, T, T, T, T, T, YLW, WHT, WHT, YLW, T, T, T, T, T, T, + T, T, T, T, T, YLW, WHT, WHT, YLW, T, T, T, T, T, T, T, + T, T, T, T, YLW, WHT, WHT, YLW, T, T, T, T, T, T, T, T, + T, T, T, YLW, WHT, WHT, WHT, YLW, YLW, YLW, YLW, T, T, T, T, T, + T, T, T, YLW, WHT, WHT, WHT, WHT, WHT, WHT, YLW, T, T, T, T, T, + T, T, T, T, YLW, YLW, YLW, WHT, WHT, WHT, YLW, T, T, T, T, T, + T, T, T, T, T, T, T, YLW, WHT, YLW, T, T, T, T, T, T, + T, T, T, T, T, T, YLW, WHT, YLW, T, T, T, T, T, T, T, + T, T, T, T, T, YLW, WHT, YLW, T, T, T, T, T, T, T, T, + T, T, T, T, T, YLW, YLW, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* Drone powerup — cyan spinning gear/orb */ +static const uint32_t powerup_drone1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, CYD, CYD, T, T, T, T, T, T, T, + T, T, T, T, T, CYD, CYN, CYN, CYN, CYN, CYD, T, T, T, T, T, + T, T, T, T, CYD, CYN, CYN, WHT, WHT, CYN, CYN, CYD, T, T, T, T, + T, T, T, CYD, CYN, CYN, WHT, WHT, WHT, WHT, CYN, CYN, CYD, T, T, T, + T, T, T, CYN, CYN, WHT, WHT, CYN, CYN, WHT, WHT, CYN, CYN, T, T, T, + T, T, CYD, CYN, WHT, WHT, CYN, CYD, CYD, CYN, WHT, WHT, CYN, CYD, T, T, + T, T, CYD, CYN, WHT, WHT, CYN, CYD, CYD, CYN, WHT, WHT, CYN, CYD, T, T, + T, T, T, CYN, CYN, WHT, WHT, CYN, CYN, WHT, WHT, CYN, CYN, T, T, T, + T, T, T, CYD, CYN, CYN, WHT, WHT, WHT, WHT, CYN, CYN, CYD, T, T, T, + T, T, T, T, CYD, CYN, CYN, WHT, WHT, CYN, CYN, CYD, T, T, T, T, + T, T, T, T, T, CYD, CYN, CYN, CYN, CYN, CYD, T, T, T, T, T, + T, T, T, T, T, T, T, CYD, CYD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* Drone powerup frame 2 — rotated highlight */ +static const uint32_t powerup_drone2[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, CYD, CYD, T, T, T, T, T, T, T, + T, T, T, T, T, CYD, CYN, CYN, CYN, CYN, CYD, T, T, T, T, T, + T, T, T, T, CYD, CYN, WHT, WHT, CYN, CYN, CYN, CYD, T, T, T, T, + T, T, T, CYD, CYN, WHT, WHT, CYN, CYN, CYN, CYN, CYN, CYD, T, T, T, + T, T, T, CYN, WHT, WHT, CYN, CYD, CYD, CYN, CYN, CYN, CYN, T, T, T, + T, T, CYD, CYN, WHT, CYN, CYD, CYD, CYD, CYD, CYN, WHT, CYN, CYD, T, T, + T, T, CYD, CYN, WHT, CYN, CYD, CYD, CYD, CYD, CYN, WHT, CYN, CYD, T, T, + T, T, T, CYN, CYN, CYN, CYN, CYD, CYD, CYN, WHT, WHT, CYN, T, T, T, + T, T, T, CYD, CYN, CYN, CYN, CYN, CYN, WHT, WHT, CYN, CYD, T, T, T, + T, T, T, T, CYD, CYN, CYN, CYN, WHT, WHT, CYN, CYD, T, T, T, T, + T, T, T, T, T, CYD, CYN, CYN, CYN, CYN, CYD, T, T, T, T, T, + T, T, T, T, T, T, T, CYD, CYD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* ── Drone companion sprite ────────────────────────── */ + +/* Small hovering drone — metallic with cyan thruster */ +static const uint32_t drone_frame1[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, GYL, GYL, GYL, GYL, T, T, T, T, T, T, + T, T, T, T, T, GYL, GRY, GRY, GRY, GRY, GYL, T, T, T, T, T, + T, T, T, T, GYL, GRY, GRY, CYN, CYN, GRY, GRY, GYL, T, T, T, T, + T, T, T, T, GYD, GRY, CYN, WHT, WHT, CYN, GRY, GYD, T, T, T, T, + T, T, T, T, GYD, GRY, CYN, WHT, WHT, CYN, GRY, GYD, T, T, T, T, + T, T, T, T, GYL, GRY, GRY, CYN, CYN, GRY, GRY, GYL, T, T, T, T, + T, T, T, T, T, GYD, GRY, GRY, GRY, GRY, GYD, T, T, T, T, T, + T, T, T, T, T, T, GYD, CYD, CYD, GYD, T, T, T, T, T, T, + T, T, T, T, T, T, T, CYN, CYN, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + +/* Drone frame 2 — thruster flicker */ +static const uint32_t drone_frame2[16*16] = { + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, GYL, GYL, GYL, GYL, T, T, T, T, T, T, + T, T, T, T, T, GYL, GRY, GRY, GRY, GRY, GYL, T, T, T, T, T, + T, T, T, T, GYL, GRY, GRY, CYN, CYN, GRY, GRY, GYL, T, T, T, T, + T, T, T, T, GYD, GRY, CYN, WHT, WHT, CYN, GRY, GYD, T, T, T, T, + T, T, T, T, GYD, GRY, CYN, WHT, WHT, CYN, GRY, GYD, T, T, T, T, + T, T, T, T, GYL, GRY, GRY, CYN, CYN, GRY, GRY, GYL, T, T, T, T, + T, T, T, T, T, GYD, GRY, GRY, GRY, GRY, GYD, T, T, T, T, T, + T, T, T, T, T, T, CYD, CYN, CYN, CYD, T, T, T, T, T, T, + T, T, T, T, T, T, CYN, WHT, WHT, CYN, T, T, T, T, T, T, + T, T, T, T, T, T, T, CYD, CYD, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, + T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, +}; + /* ── Spritesheet generation ────────────────────────── */ /* All sprite definitions for the sheet - row, column, pixel data */ @@ -551,10 +906,33 @@ static const SpriteDef s_sprite_defs[] = { {3, 4, impact3}, {3, 5, enemy_bullet1}, {3, 6, enemy_bullet2}, + + /* Row 4: Hazards */ + {4, 0, turret_idle1}, + {4, 1, turret_fire1}, + {4, 2, platform1}, + {4, 3, flame_vent_idle1}, + {4, 4, flame_vent_active1}, + {4, 5, flame_vent_active2}, + {4, 6, force_field_on1}, + {4, 7, force_field_on2}, + + /* Row 5: Hazards continued + Powerups */ + {5, 0, force_field_off1}, + {5, 1, powerup_health1}, + {5, 2, powerup_health2}, + {5, 3, powerup_jetpack1}, + {5, 4, powerup_jetpack2}, + {5, 5, powerup_drone1}, + {5, 6, powerup_drone2}, + + /* Row 6: Drone companion */ + {6, 0, drone_frame1}, + {6, 1, drone_frame2}, }; #define SHEET_COLS 8 -#define SHEET_ROWS 4 +#define SHEET_ROWS 7 SDL_Texture *sprites_generate(SDL_Renderer *renderer) { int w = SHEET_COLS * SPRITE_CELL; @@ -690,6 +1068,59 @@ static AnimFrame s_enemy_bullet_frames[] = { FRAME(6, 3, 0.1f), }; +/* Hazard animations */ +static AnimFrame s_turret_idle_frames[] = { + FRAME(0, 4, 1.0f), +}; + +static AnimFrame s_turret_fire_frames[] = { + FRAME(1, 4, 0.15f), +}; + +static AnimFrame s_platform_frames[] = { + FRAME(2, 4, 1.0f), +}; + +static AnimFrame s_flame_vent_idle_frames[] = { + FRAME(3, 4, 1.0f), +}; + +static AnimFrame s_flame_vent_active_frames[] = { + FRAME(4, 4, 0.08f), + FRAME(5, 4, 0.08f), +}; + +static AnimFrame s_force_field_on_frames[] = { + FRAME(6, 4, 0.15f), + FRAME(7, 4, 0.15f), +}; + +static AnimFrame s_force_field_off_frames[] = { + FRAME(0, 5, 1.0f), +}; + +/* Powerups */ +static AnimFrame s_powerup_health_frames[] = { + FRAME(1, 5, 0.4f), + FRAME(2, 5, 0.4f), +}; + +static AnimFrame s_powerup_jetpack_frames[] = { + FRAME(3, 5, 0.3f), + FRAME(4, 5, 0.3f), +}; + +static AnimFrame s_powerup_drone_frames[] = { + FRAME(5, 5, 0.3f), + FRAME(6, 5, 0.3f), +}; + +/* Drone companion */ +static AnimFrame s_drone_frames[] = { + FRAME(0, 6, 0.2f), + FRAME(1, 6, 0.2f), +}; + /* Exported animation definitions */ AnimDef anim_player_idle; AnimDef anim_player_run; @@ -708,6 +1139,20 @@ AnimDef anim_bullet; AnimDef anim_bullet_impact; AnimDef anim_enemy_bullet; +AnimDef anim_turret_idle; +AnimDef anim_turret_fire; +AnimDef anim_platform; +AnimDef anim_flame_vent_idle; +AnimDef anim_flame_vent_active; +AnimDef anim_force_field_on; +AnimDef anim_force_field_off; + +AnimDef anim_powerup_health; +AnimDef anim_powerup_jetpack; +AnimDef anim_powerup_drone; + +AnimDef anim_drone; + void sprites_init_anims(void) { anim_player_idle = (AnimDef){s_player_idle_frames, 2, true, NULL}; anim_player_run = (AnimDef){s_player_run_frames, 4, true, NULL}; @@ -725,4 +1170,18 @@ void sprites_init_anims(void) { anim_bullet = (AnimDef){s_bullet_frames, 2, true, NULL}; anim_bullet_impact = (AnimDef){s_impact_frames, 3, false, NULL}; anim_enemy_bullet = (AnimDef){s_enemy_bullet_frames, 2, true, NULL}; + + anim_turret_idle = (AnimDef){s_turret_idle_frames, 1, true, NULL}; + anim_turret_fire = (AnimDef){s_turret_fire_frames, 1, false, NULL}; + anim_platform = (AnimDef){s_platform_frames, 1, true, NULL}; + anim_flame_vent_idle = (AnimDef){s_flame_vent_idle_frames, 1, true, NULL}; + anim_flame_vent_active = (AnimDef){s_flame_vent_active_frames, 2, true, NULL}; + anim_force_field_on = (AnimDef){s_force_field_on_frames, 2, true, NULL}; + anim_force_field_off = (AnimDef){s_force_field_off_frames, 1, true, NULL}; + + anim_powerup_health = (AnimDef){s_powerup_health_frames, 2, true, NULL}; + anim_powerup_jetpack = (AnimDef){s_powerup_jetpack_frames, 2, true, NULL}; + anim_powerup_drone = (AnimDef){s_powerup_drone_frames, 2, true, NULL}; + + anim_drone = (AnimDef){s_drone_frames, 2, true, NULL}; } diff --git a/src/game/sprites.h b/src/game/sprites.h index 01176be..ea16642 100644 --- a/src/game/sprites.h +++ b/src/game/sprites.h @@ -40,6 +40,23 @@ extern AnimDef anim_bullet; extern AnimDef anim_bullet_impact; extern AnimDef anim_enemy_bullet; +/* ── Hazard animations ─────────────────────────── */ +extern AnimDef anim_turret_idle; +extern AnimDef anim_turret_fire; +extern AnimDef anim_platform; +extern AnimDef anim_flame_vent_idle; +extern AnimDef anim_flame_vent_active; +extern AnimDef anim_force_field_on; +extern AnimDef anim_force_field_off; + +/* ── Powerup animations ────────────────────────── */ +extern AnimDef anim_powerup_health; +extern AnimDef anim_powerup_jetpack; +extern AnimDef anim_powerup_drone; + +/* ── Drone animation ───────────────────────────── */ +extern AnimDef anim_drone; + /* Initialize all animation definitions */ void sprites_init_anims(void); diff --git a/src/main.c b/src/main.c index 5836ca2..c8c9109 100644 --- a/src/main.c +++ b/src/main.c @@ -1,38 +1,298 @@ #include "engine/core.h" #include "engine/input.h" #include "game/level.h" +#include "game/levelgen.h" +#include "game/editor.h" #include +#include +#include +#include -static Level s_level; +/* ═══════════════════════════════════════════════════ + * Game modes + * ═══════════════════════════════════════════════════ */ -static void game_init(void) { - if (!level_load(&s_level, "assets/levels/level01.lvl")) { - fprintf(stderr, "Failed to load level!\n"); +typedef enum GameMode { + MODE_PLAY, + MODE_EDITOR, +} GameMode; + +static Level s_level; +static Editor s_editor; +static GameMode s_mode = MODE_PLAY; +static bool s_use_procgen = false; +static bool s_dump_lvl = false; +static bool s_use_editor = false; +static uint32_t s_gen_seed = 0; +static char s_edit_path[256] = {0}; + +/* Track whether we came from the editor (for returning after test play) */ +static bool s_testing_from_editor = false; + +static const char *theme_name(LevelTheme t) { + switch (t) { + case THEME_PLANET_SURFACE: return "Planet Surface"; + case THEME_PLANET_BASE: return "Planet Base"; + case THEME_SPACE_STATION: return "Space Station"; + default: return "Unknown"; + } +} + +static void load_generated_level(void) { + LevelGenConfig config = levelgen_default_config(); + config.seed = s_gen_seed; + config.num_segments = 6; + config.difficulty = 0.5f; + + /* Build a theme progression — start on surface, move indoors/upward. + * Derive from seed so it varies with each regeneration and doesn't + * depend on rand() state (which parallax generators clobber). */ + uint32_t seed_for_theme = config.seed ? config.seed : (uint32_t)time(NULL); + int r = (int)(seed_for_theme % 4); + switch (r) { + case 0: /* Surface -> Base */ + config.themes[0] = THEME_PLANET_SURFACE; + config.themes[1] = THEME_PLANET_SURFACE; + config.themes[2] = THEME_PLANET_BASE; + config.themes[3] = THEME_PLANET_BASE; + config.themes[4] = THEME_PLANET_BASE; + config.themes[5] = THEME_PLANET_BASE; + config.theme_count = 6; + break; + case 1: /* Base -> Station */ + config.themes[0] = THEME_PLANET_BASE; + config.themes[1] = THEME_PLANET_BASE; + config.themes[2] = THEME_PLANET_BASE; + config.themes[3] = THEME_SPACE_STATION; + config.themes[4] = THEME_SPACE_STATION; + config.themes[5] = THEME_SPACE_STATION; + config.theme_count = 6; + break; + case 2: /* Surface -> Base -> Station (full journey) */ + config.themes[0] = THEME_PLANET_SURFACE; + config.themes[1] = THEME_PLANET_SURFACE; + config.themes[2] = THEME_PLANET_BASE; + config.themes[3] = THEME_PLANET_BASE; + config.themes[4] = THEME_SPACE_STATION; + config.themes[5] = THEME_SPACE_STATION; + config.theme_count = 6; + break; + case 3: /* Single theme (derived from seed) */ + default: { + LevelTheme single = (LevelTheme)(seed_for_theme / 4 % THEME_COUNT); + config.themes[0] = single; + config.theme_count = 1; + break; + } + } + + printf("Theme sequence:"); + for (int i = 0; i < config.theme_count; i++) { + printf(" %s", theme_name(config.themes[i])); + } + printf("\n"); + + Tilemap gen_map; + if (!levelgen_generate(&gen_map, &config)) { + fprintf(stderr, "Failed to generate level!\n"); + g_engine.running = false; + return; + } + + /* Optionally dump to file for inspection */ + if (s_dump_lvl) { + levelgen_dump_lvl(&gen_map, "assets/levels/generated.lvl"); + } + + if (!level_load_generated(&s_level, &gen_map)) { + fprintf(stderr, "Failed to load generated level!\n"); g_engine.running = false; } } +/* ── Switch to editor mode ── */ +static void enter_editor(void) { + if (s_mode == MODE_PLAY) { + level_free(&s_level); + } + s_mode = MODE_EDITOR; + + editor_init(&s_editor); + if (s_edit_path[0]) { + if (!editor_load(&s_editor, s_edit_path)) { + fprintf(stderr, "Failed to load %s, creating new level\n", s_edit_path); + editor_new_level(&s_editor, 40, 23); + } + } else { + editor_new_level(&s_editor, 40, 23); + } + + /* Change window title */ + SDL_SetWindowTitle(g_engine.window, "Jump 'n Run - Level Editor"); +} + +/* ── Switch to play mode (test play from editor) ── */ +static void enter_test_play(void) { + /* Save the current level to a temp file */ + editor_save_as(&s_editor, "assets/levels/_editor_test.lvl"); + + s_mode = MODE_PLAY; + s_testing_from_editor = true; + + if (!level_load(&s_level, "assets/levels/_editor_test.lvl")) { + fprintf(stderr, "Failed to load editor test level!\n"); + /* Fall back to editor */ + s_mode = MODE_EDITOR; + s_testing_from_editor = false; + return; + } + + SDL_SetWindowTitle(g_engine.window, "Jump 'n Run - Testing"); +} + +/* ── Return from test play to editor ── */ +static void return_to_editor(void) { + level_free(&s_level); + s_mode = MODE_EDITOR; + s_testing_from_editor = false; + SDL_SetWindowTitle(g_engine.window, "Jump 'n Run - Level Editor"); +} + +/* ═══════════════════════════════════════════════════ + * Game callbacks + * ═══════════════════════════════════════════════════ */ + +static void game_init(void) { + if (s_use_editor) { + enter_editor(); + } else if (s_use_procgen) { + load_generated_level(); + } else { + if (!level_load(&s_level, "assets/levels/level01.lvl")) { + fprintf(stderr, "Failed to load level!\n"); + g_engine.running = false; + } + } +} + static void game_update(float dt) { - /* Quit on escape */ + if (s_mode == MODE_EDITOR) { + editor_update(&s_editor, dt); + + if (editor_wants_test_play(&s_editor)) { + enter_test_play(); + } + if (editor_wants_quit(&s_editor)) { + g_engine.running = false; + } + return; + } + + /* ── Play mode ── */ + + /* Quit / return to editor on escape */ if (input_pressed(ACTION_PAUSE)) { + if (s_testing_from_editor) { + return_to_editor(); + return; + } g_engine.running = false; return; } + /* E key: enter editor from gameplay (not during test play) */ + if (!s_testing_from_editor && input_key_pressed(SDL_SCANCODE_E)) { + /* Save current level path for potential re-editing */ + level_free(&s_level); + enter_editor(); + return; + } + + /* R key: regenerate level with new seed */ + static bool r_was_pressed = false; + bool r_pressed = input_key_held(SDL_SCANCODE_R); + if (r_pressed && !r_was_pressed) { + printf("\n=== Regenerating level ===\n"); + level_free(&s_level); + s_gen_seed = (uint32_t)time(NULL); + s_use_procgen = true; + load_generated_level(); + } + r_was_pressed = r_pressed; + level_update(&s_level, dt); } static void game_render(float interpolation) { - level_render(&s_level, interpolation); + if (s_mode == MODE_EDITOR) { + editor_render(&s_editor, interpolation); + } else { + level_render(&s_level, interpolation); + } } static void game_shutdown(void) { - level_free(&s_level); + /* Always free both — editor may have been initialized even if we're + * currently in play mode (e.g. shutdown during test play). editor_free + * and level_free are safe to call on zeroed/already-freed structs. */ + if (s_mode == MODE_PLAY || s_testing_from_editor) { + level_free(&s_level); + } + if (s_mode == MODE_EDITOR || s_use_editor) { + editor_free(&s_editor); + } } +/* ═══════════════════════════════════════════════════ + * Main + * ═══════════════════════════════════════════════════ */ + int main(int argc, char *argv[]) { - (void)argc; - (void)argv; + /* Parse command-line arguments */ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--generate") == 0 || strcmp(argv[i], "-g") == 0) { + s_use_procgen = true; + } else if (strcmp(argv[i], "--dump") == 0 || strcmp(argv[i], "-d") == 0) { + s_dump_lvl = true; + } else if (strcmp(argv[i], "--seed") == 0 || strcmp(argv[i], "-s") == 0) { + if (i + 1 < argc) { + s_gen_seed = (uint32_t)atoi(argv[++i]); + } + } else if (strcmp(argv[i], "--edit") == 0 || strcmp(argv[i], "-e") == 0) { + s_use_editor = true; + /* Optional: next arg is a file path */ + if (i + 1 < argc && argv[i + 1][0] != '-') { + strncpy(s_edit_path, argv[++i], sizeof(s_edit_path) - 1); + } + } else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + printf("Usage: jnr [options]\n"); + printf(" --generate, -g Load a procedurally generated level\n"); + printf(" --dump, -d Dump generated level to assets/levels/generated.lvl\n"); + printf(" --seed N, -s N Set RNG seed for generation\n"); + printf(" --edit [file], -e [file] Open level editor (optionally load a .lvl file)\n"); + printf("\nIn-game:\n"); + printf(" R Regenerate level with new random seed\n"); + printf(" E Open level editor\n"); + printf(" ESC Quit (or return to editor from test play)\n"); + printf("\nEditor:\n"); + printf(" 1-5 Select tool (Pencil/Eraser/Fill/Entity/Spawn)\n"); + printf(" Q/W/E Select layer (Collision/BG/FG)\n"); + printf(" G Toggle grid\n"); + printf(" V Toggle all-layer visibility\n"); + printf(" Arrow keys / WASD Pan camera\n"); + printf(" Scroll wheel Zoom in/out\n"); + printf(" Middle mouse drag Pan camera\n"); + printf(" Left click Paint/place (canvas) or select (palette)\n"); + printf(" Right click Pick tile / delete entity\n"); + printf(" Ctrl+S Save level\n"); + printf(" Ctrl++/- Resize level width\n"); + printf(" P Test play level\n"); + printf(" ESC Quit editor\n"); + return 0; + } + } + + srand((unsigned)time(NULL)); if (!engine_init()) { return 1;