Add moon surface intro level with asteroid hazards and unarmed mechanics
Introduce moon01.lvl as the starting level — a pure jump-and-run intro with no gun and no enemies, just platforming over gaps and dodging falling asteroids. The player picks up their gun upon transitioning to level01. New features: - Moon tileset and PARALLAX_STYLE_MOON with crater terrain backgrounds - Asteroid entity (ENT_ASTEROID): falls from sky, damages on contact, explodes on ground with particles, respawns after delay - PLAYER_UNARMED directive disables gun for the level - Pit rescue mechanic: falling costs 1 HP and auto-dashes upward - Gun powerup entity type for future armed-pickup levels - Segment-based procedural level generator with themed rooms - Extended editor with entity palette and improved tile cycling - Web shell improvements for Emscripten builds
This commit is contained in:
@@ -65,7 +65,7 @@ static void drone_update(Entity *self, float dt, const Tilemap *map) {
|
||||
self->body.pos.y + self->body.size.y * 0.5f
|
||||
);
|
||||
particle_emit_spark(center, (SDL_Color){50, 200, 255, 255});
|
||||
self->flags |= ENTITY_DEAD;
|
||||
entity_destroy(s_em, self);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,99 @@
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten.h>
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* Browser file I/O helpers (Emscripten only)
|
||||
*
|
||||
* Save: write .lvl to virtual FS, then trigger
|
||||
* browser download via Blob URL.
|
||||
* Load: open an <input type="file"> dialog, read
|
||||
* the file contents, write to virtual FS,
|
||||
* and set a flag for the editor to pick up.
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
|
||||
/* Download a file from the Emscripten virtual FS to the user's machine */
|
||||
EM_JS(void, browser_download_file, (const char *vfs_path, const char *download_name), {
|
||||
var path = UTF8ToString(vfs_path);
|
||||
var name = UTF8ToString(download_name);
|
||||
try {
|
||||
var data = FS.readFile(path);
|
||||
var blob = new Blob([data], { type: 'text/plain' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
/* Path where uploaded file will be written in virtual FS */
|
||||
#define UPLOAD_VFS_PATH "/tmp/_upload.lvl"
|
||||
|
||||
/* Flags polled by editor_update — set by JS */
|
||||
static volatile int s_upload_ready = 0; /* file upload completed */
|
||||
static volatile int s_save_request = 0; /* Ctrl+S or Save button */
|
||||
static volatile int s_load_request = 0; /* Ctrl+O or Load button */
|
||||
|
||||
/* Path to load when s_load_request is 2 (load a specific VFS file) */
|
||||
static char s_load_vfs_path[256] = {0};
|
||||
|
||||
/* Open a browser file picker; on selection, write contents to virtual FS */
|
||||
EM_JS(void, browser_open_file_picker, (), {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.lvl,.txt';
|
||||
input.onchange = function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
var data = new Uint8Array(ev.target.result);
|
||||
try { FS.unlink('/tmp/_upload.lvl'); } catch(x) {}
|
||||
FS.writeFile('/tmp/_upload.lvl', data);
|
||||
/* Set the C flag via direct heap access */
|
||||
var ptr = _editor_upload_flag_ptr();
|
||||
HEAP32[ptr >> 2] = 1;
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
|
||||
/* Export addresses so JS can set flags and paths */
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int *editor_upload_flag_ptr(void) {
|
||||
return (int *)&s_upload_ready;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int *editor_save_flag_ptr(void) {
|
||||
return (int *)&s_save_request;
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int *editor_load_flag_ptr(void) {
|
||||
return (int *)&s_load_request;
|
||||
}
|
||||
|
||||
/* Called from JS to load an existing level file from the virtual FS */
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void editor_load_vfs_file(const char *path) {
|
||||
snprintf(s_load_vfs_path, sizeof(s_load_vfs_path), "%s", path);
|
||||
s_load_request = 2; /* 2 = load from specific VFS path */
|
||||
}
|
||||
|
||||
#endif /* __EMSCRIPTEN__ */
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* Minimal 4x6 bitmap font
|
||||
*
|
||||
@@ -140,7 +233,7 @@ static int text_width(const char *text) {
|
||||
#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 = {20, 20, 35, 255};
|
||||
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};
|
||||
@@ -150,9 +243,11 @@ 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};
|
||||
|
||||
static const SDL_Color COL_EXIT = {50, 230, 180, 255};
|
||||
|
||||
/* Tool names */
|
||||
static const char *s_tool_names[TOOL_COUNT] = {
|
||||
"PENCIL", "ERASER", "FILL", "ENTITY", "SPAWN"
|
||||
"PENCIL", "ERASER", "FILL", "ENTITY", "SPAWN", "EXIT"
|
||||
};
|
||||
|
||||
/* Layer names */
|
||||
@@ -249,6 +344,8 @@ static bool save_tilemap(const Tilemap *map, const char *path) {
|
||||
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);
|
||||
if (map->player_unarmed)
|
||||
fprintf(f, "PLAYER_UNARMED\n");
|
||||
|
||||
fprintf(f, "\n");
|
||||
|
||||
@@ -261,6 +358,21 @@ static bool save_tilemap(const Tilemap *map, const char *path) {
|
||||
}
|
||||
fprintf(f, "\n");
|
||||
|
||||
/* Exit zones */
|
||||
for (int i = 0; i < map->exit_zone_count; i++) {
|
||||
const ExitZone *ez = &map->exit_zones[i];
|
||||
int tx = (int)(ez->x / TILE_SIZE);
|
||||
int ty = (int)(ez->y / TILE_SIZE);
|
||||
int tw = (int)(ez->w / TILE_SIZE);
|
||||
int th = (int)(ez->h / TILE_SIZE);
|
||||
if (ez->target[0]) {
|
||||
fprintf(f, "EXIT %d %d %d %d %s\n", tx, ty, tw, th, ez->target);
|
||||
} else {
|
||||
fprintf(f, "EXIT %d %d %d %d\n", tx, ty, tw, th);
|
||||
}
|
||||
}
|
||||
if (map->exit_zone_count > 0) fprintf(f, "\n");
|
||||
|
||||
/* Tile definitions */
|
||||
for (int id = 1; id < map->tile_def_count; id++) {
|
||||
const TileDef *td = &map->tile_defs[id];
|
||||
@@ -449,6 +561,15 @@ bool editor_save(Editor *ed) {
|
||||
}
|
||||
if (save_tilemap(&ed->map, ed->file_path)) {
|
||||
ed->dirty = false;
|
||||
#ifdef __EMSCRIPTEN__
|
||||
/* Trigger a browser download of the saved file */
|
||||
{
|
||||
/* Extract just the filename from the path for the download name */
|
||||
const char *name = strrchr(ed->file_path, '/');
|
||||
name = name ? name + 1 : ed->file_path;
|
||||
browser_download_file(ed->file_path, name);
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -522,6 +643,42 @@ static void remove_entity_spawn(Tilemap *map, int index) {
|
||||
map->entity_spawn_count--;
|
||||
}
|
||||
|
||||
/* ── Exit zone helpers ─────────────────────────────── */
|
||||
|
||||
#define EXIT_DEFAULT_W 2 /* default exit zone width in tiles */
|
||||
#define EXIT_DEFAULT_H 3 /* default exit zone height in tiles */
|
||||
|
||||
static int find_exit_at(const Tilemap *map, float wx, float wy) {
|
||||
for (int i = 0; i < map->exit_zone_count; i++) {
|
||||
const ExitZone *ez = &map->exit_zones[i];
|
||||
if (wx >= ez->x && wx < ez->x + ez->w &&
|
||||
wy >= ez->y && wy < ez->y + ez->h) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void add_exit_zone(Tilemap *map, float wx, float wy) {
|
||||
if (map->exit_zone_count >= MAX_EXIT_ZONES) return;
|
||||
ExitZone *ez = &map->exit_zones[map->exit_zone_count];
|
||||
/* Snap to tile grid */
|
||||
ez->x = floorf(wx / TILE_SIZE) * TILE_SIZE;
|
||||
ez->y = floorf(wy / TILE_SIZE) * TILE_SIZE;
|
||||
ez->w = EXIT_DEFAULT_W * TILE_SIZE;
|
||||
ez->h = EXIT_DEFAULT_H * TILE_SIZE;
|
||||
ez->target[0] = '\0'; /* empty = victory / end of level */
|
||||
map->exit_zone_count++;
|
||||
}
|
||||
|
||||
static void remove_exit_zone(Tilemap *map, int index) {
|
||||
if (index < 0 || index >= map->exit_zone_count) return;
|
||||
for (int i = index; i < map->exit_zone_count - 1; i++) {
|
||||
map->exit_zones[i] = map->exit_zones[i + 1];
|
||||
}
|
||||
map->exit_zone_count--;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* Internal state for test-play / quit requests
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
@@ -559,6 +716,7 @@ void editor_update(Editor *ed, float dt) {
|
||||
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;
|
||||
if (input_key_pressed(SDL_SCANCODE_6)) ed->tool = TOOL_EXIT;
|
||||
|
||||
/* Layer selection: Q/W/E */
|
||||
if (input_key_pressed(SDL_SCANCODE_Q)) ed->active_layer = EDITOR_LAYER_COLLISION;
|
||||
@@ -577,6 +735,55 @@ void editor_update(Editor *ed, float dt) {
|
||||
editor_save(ed);
|
||||
}
|
||||
|
||||
/* Open / Load: Ctrl+O */
|
||||
if (input_key_pressed(SDL_SCANCODE_O) && input_key_held(SDL_SCANCODE_LCTRL)) {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
browser_open_file_picker();
|
||||
#else
|
||||
/* Desktop: reload from current file (no native file dialog) */
|
||||
if (ed->has_file) {
|
||||
editor_load(ed, ed->file_path);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
/* Poll for save request from JS (Ctrl+S or Save button) */
|
||||
if (s_save_request) {
|
||||
s_save_request = 0;
|
||||
editor_save(ed);
|
||||
}
|
||||
|
||||
/* Poll for load request from JS */
|
||||
if (s_load_request == 1) {
|
||||
/* 1 = open file picker (Ctrl+O or Load button) */
|
||||
s_load_request = 0;
|
||||
browser_open_file_picker();
|
||||
} else if (s_load_request == 2) {
|
||||
/* 2 = load a specific VFS file (level picker dropdown) */
|
||||
s_load_request = 0;
|
||||
if (s_load_vfs_path[0]) {
|
||||
if (editor_load(ed, s_load_vfs_path)) {
|
||||
printf("editor: loaded %s\n", s_load_vfs_path);
|
||||
}
|
||||
s_load_vfs_path[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Poll for completed file upload from browser */
|
||||
if (s_upload_ready) {
|
||||
s_upload_ready = 0;
|
||||
if (editor_load(ed, UPLOAD_VFS_PATH)) {
|
||||
/* Clear the stored path so saves go to default,
|
||||
* not the tmp upload path */
|
||||
strncpy(ed->file_path, "assets/levels/edited.lvl",
|
||||
sizeof(ed->file_path) - 1);
|
||||
ed->has_file = true;
|
||||
printf("editor: loaded from browser upload\n");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Test play: P */
|
||||
if (input_key_pressed(SDL_SCANCODE_P)) {
|
||||
s_wants_test_play = true;
|
||||
@@ -642,9 +849,9 @@ void editor_update(Editor *ed, float dt) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Zoom (scroll wheel) ── */
|
||||
/* ── Zoom (scroll wheel) — only when mouse is over the canvas ── */
|
||||
int scroll = input_mouse_scroll();
|
||||
if (scroll != 0) {
|
||||
if (scroll != 0 && in_canvas(mx, my)) {
|
||||
/* 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));
|
||||
|
||||
@@ -655,7 +862,7 @@ void editor_update(Editor *ed, float dt) {
|
||||
|
||||
/* 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)) {
|
||||
if (ed->camera.zoom != old_zoom) {
|
||||
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;
|
||||
@@ -680,26 +887,37 @@ void editor_update(Editor *ed, float dt) {
|
||||
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) ── */
|
||||
/* ── Right palette click ── */
|
||||
if (mx >= SCREEN_WIDTH - EDITOR_PALETTE_W && my >= EDITOR_TOOLBAR_H &&
|
||||
my < SCREEN_HEIGHT - EDITOR_STATUS_H) {
|
||||
if (ed->tool != TOOL_ENTITY) {
|
||||
int px = SCREEN_WIDTH - EDITOR_PALETTE_W;
|
||||
int py = EDITOR_TOOLBAR_H;
|
||||
int ph = SCREEN_HEIGHT - EDITOR_TOOLBAR_H - EDITOR_STATUS_H;
|
||||
|
||||
/* Split point: tiles take ~55%, entities take ~45% */
|
||||
int tile_section_h = (ph * 55) / 100;
|
||||
int ent_section_y = py + tile_section_h;
|
||||
|
||||
if (my < ent_section_y) {
|
||||
/* 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;
|
||||
int pal_x = mx - px - 2;
|
||||
int pal_label_h = FONT_H + 6;
|
||||
int pal_y = my - py - pal_label_h
|
||||
+ ed->tile_palette_scroll * (TILE_SIZE + 1);
|
||||
if (pal_x >= 0 && pal_y >= 0) {
|
||||
int max_cols = (EDITOR_PALETTE_W - 4) / (TILE_SIZE + 1);
|
||||
if (max_cols < 1) max_cols = 1;
|
||||
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;
|
||||
}
|
||||
int tile_id = tile_row * max_cols + tile_col + 1;
|
||||
if (tile_id >= 1 && tile_id <= ed->tileset_total) {
|
||||
ed->selected_tile = (uint16_t)tile_id;
|
||||
/* Auto-switch to pencil tool when selecting a tile */
|
||||
if (ed->tool == TOOL_ENTITY) ed->tool = TOOL_PENCIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Scroll palette */
|
||||
if (scroll != 0) {
|
||||
ed->tile_palette_scroll -= scroll;
|
||||
if (ed->tile_palette_scroll < 0) ed->tile_palette_scroll = 0;
|
||||
@@ -707,10 +925,14 @@ void editor_update(Editor *ed, float dt) {
|
||||
} else {
|
||||
/* Entity palette area */
|
||||
if (input_mouse_pressed(MOUSE_LEFT)) {
|
||||
int pal_y = my - EDITOR_TOOLBAR_H - 2 + ed->entity_palette_scroll * 12;
|
||||
int pal_label_h = FONT_H + 6;
|
||||
int pal_y = my - ent_section_y - pal_label_h
|
||||
+ 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;
|
||||
/* Auto-switch to entity tool when selecting an entity */
|
||||
ed->tool = TOOL_ENTITY;
|
||||
}
|
||||
}
|
||||
if (scroll != 0) {
|
||||
@@ -724,13 +946,14 @@ void editor_update(Editor *ed, float dt) {
|
||||
/* ── Toolbar click ── */
|
||||
if (my < EDITOR_TOOLBAR_H) {
|
||||
if (input_mouse_pressed(MOUSE_LEFT)) {
|
||||
/* Tool buttons: each ~30px wide */
|
||||
int btn = mx / 32;
|
||||
/* Tool buttons: each 28px wide starting at x=2 */
|
||||
int btn = (mx - 2) / 28;
|
||||
if (btn >= 0 && btn < TOOL_COUNT) {
|
||||
ed->tool = (EditorTool)btn;
|
||||
}
|
||||
/* Layer buttons at x=170+ */
|
||||
int lx = mx - 170;
|
||||
/* Layer buttons after separator */
|
||||
int sep_x = TOOL_COUNT * 28 + 8;
|
||||
int lx = mx - sep_x;
|
||||
if (lx >= 0) {
|
||||
int lbtn = lx / 25;
|
||||
if (lbtn >= 0 && lbtn < EDITOR_LAYER_COUNT) {
|
||||
@@ -835,6 +1058,25 @@ void editor_update(Editor *ed, float dt) {
|
||||
}
|
||||
break;
|
||||
|
||||
case TOOL_EXIT:
|
||||
if (input_mouse_pressed(MOUSE_LEFT)) {
|
||||
/* Place a new exit zone (if not clicking an existing one) */
|
||||
int eidx = find_exit_at(&ed->map, world_pos.x, world_pos.y);
|
||||
if (eidx < 0) {
|
||||
add_exit_zone(&ed->map, world_pos.x, world_pos.y);
|
||||
ed->dirty = true;
|
||||
}
|
||||
}
|
||||
/* Right-click to delete exit zone */
|
||||
if (input_mouse_pressed(MOUSE_RIGHT)) {
|
||||
int eidx = find_exit_at(&ed->map, world_pos.x, world_pos.y);
|
||||
if (eidx >= 0) {
|
||||
remove_exit_zone(&ed->map, eidx);
|
||||
ed->dirty = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -954,6 +1196,26 @@ void editor_render(Editor *ed, float interpolation) {
|
||||
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
|
||||
}
|
||||
|
||||
/* ── Exit zone markers ── */
|
||||
for (int i = 0; i < ed->map.exit_zone_count; i++) {
|
||||
const ExitZone *ez = &ed->map.exit_zones[i];
|
||||
Vec2 sp = camera_world_to_screen(cam, vec2(ez->x, ez->y));
|
||||
float zw = ez->w * cam->zoom;
|
||||
float zh = ez->h * cam->zoom;
|
||||
|
||||
SDL_SetRenderDrawColor(r, COL_EXIT.r, COL_EXIT.g, COL_EXIT.b, 80);
|
||||
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, COL_EXIT.r, COL_EXIT.g, COL_EXIT.b, 220);
|
||||
SDL_RenderDrawRect(r, &er);
|
||||
draw_text(r, "EXIT", (int)sp.x + 1, (int)sp.y + 1, COL_EXIT);
|
||||
if (ez->target[0]) {
|
||||
draw_text(r, ez->target, (int)sp.x + 1, (int)sp.y + 8, COL_EXIT);
|
||||
}
|
||||
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
|
||||
}
|
||||
|
||||
/* ── Cursor highlight ── */
|
||||
{
|
||||
int mx_c, my_c;
|
||||
@@ -965,9 +1227,21 @@ void editor_render(Editor *ed, float interpolation) {
|
||||
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);
|
||||
|
||||
/* Exit tool: show 2x3 preview */
|
||||
int cw = 1, ch = 1;
|
||||
if (ed->tool == TOOL_EXIT) {
|
||||
cw = EXIT_DEFAULT_W;
|
||||
ch = EXIT_DEFAULT_H;
|
||||
}
|
||||
|
||||
SDL_SetRenderDrawColor(r,
|
||||
ed->tool == TOOL_EXIT ? COL_EXIT.r : COL_SELECT.r,
|
||||
ed->tool == TOOL_EXIT ? COL_EXIT.g : COL_SELECT.g,
|
||||
ed->tool == TOOL_EXIT ? COL_EXIT.b : 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_Rect cr = {(int)cpos.x, (int)cpos.y,
|
||||
(int)(zs * cw + 0.5f), (int)(zs * ch + 0.5f)};
|
||||
SDL_RenderDrawRect(r, &cr);
|
||||
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
|
||||
}
|
||||
@@ -984,22 +1258,32 @@ void editor_render(Editor *ed, float interpolation) {
|
||||
SDL_Rect tb = {0, 0, SCREEN_WIDTH, EDITOR_TOOLBAR_H};
|
||||
SDL_RenderFillRect(r, &tb);
|
||||
|
||||
/* Vertically center text: (EDITOR_TOOLBAR_H - FONT_H) / 2 */
|
||||
int text_y = (EDITOR_TOOLBAR_H - FONT_H) / 2;
|
||||
|
||||
/* Tool buttons */
|
||||
for (int i = 0; i < TOOL_COUNT; i++) {
|
||||
int bx = i * 32 + 2;
|
||||
int bx = i * 28 + 2;
|
||||
SDL_Color tc = (i == (int)ed->tool) ? COL_HIGHLIGHT : COL_TEXT_DIM;
|
||||
draw_text(r, s_tool_names[i], bx, 4, tc);
|
||||
draw_text(r, s_tool_names[i], bx, text_y, tc);
|
||||
}
|
||||
|
||||
/* Separator */
|
||||
int sep_x = TOOL_COUNT * 28 + 4;
|
||||
SDL_SetRenderDrawColor(r, COL_PANEL_LT.r, COL_PANEL_LT.g, COL_PANEL_LT.b, 255);
|
||||
SDL_RenderDrawLine(r, sep_x, 3, sep_x, EDITOR_TOOLBAR_H - 3);
|
||||
|
||||
/* Layer buttons */
|
||||
int layer_start = sep_x + 4;
|
||||
for (int i = 0; i < EDITOR_LAYER_COUNT; i++) {
|
||||
int bx = 170 + i * 25;
|
||||
int bx = layer_start + 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);
|
||||
draw_text(r, s_layer_names[i], bx, text_y, lc);
|
||||
}
|
||||
|
||||
/* Grid & Layers indicators */
|
||||
draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", 260, 4,
|
||||
int grid_x = layer_start + EDITOR_LAYER_COUNT * 25 + 4;
|
||||
draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", grid_x, text_y,
|
||||
ed->show_grid ? COL_TEXT : COL_TEXT_DIM);
|
||||
}
|
||||
|
||||
@@ -1013,21 +1297,26 @@ void editor_render(Editor *ed, float interpolation) {
|
||||
SDL_Rect panel = {px, py, EDITOR_PALETTE_W, ph};
|
||||
SDL_RenderFillRect(r, &panel);
|
||||
|
||||
/* Separator line */
|
||||
/* Left 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);
|
||||
/* Split: tiles top ~55%, entities bottom ~45% */
|
||||
int tile_section_h = (ph * 55) / 100;
|
||||
int ent_section_y = py + tile_section_h;
|
||||
|
||||
int pal_y_start = py + 10;
|
||||
/* ── Tile palette (top section) ── */
|
||||
{
|
||||
int label_h = FONT_H + 6; /* label area: font + padding */
|
||||
draw_text(r, "TILES", px + 2, py + (label_h - FONT_H) / 2, COL_TEXT);
|
||||
|
||||
int pal_y_start = py + label_h;
|
||||
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_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W,
|
||||
tile_section_h - label_h};
|
||||
SDL_RenderSetClipRect(r, &clip);
|
||||
|
||||
for (int id = 1; id <= ed->tileset_total; id++) {
|
||||
@@ -1038,11 +1327,9 @@ void editor_render(Editor *ed, float interpolation) {
|
||||
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;
|
||||
if (draw_y + TILE_SIZE < pal_y_start ||
|
||||
draw_y > ent_section_y) continue;
|
||||
|
||||
/* Source rect from tileset */
|
||||
int src_col = (id - 1) % ed->tileset_cols;
|
||||
int src_row = (id - 1) / ed->tileset_cols;
|
||||
SDL_Rect src = {
|
||||
@@ -1064,12 +1351,20 @@ void editor_render(Editor *ed, float interpolation) {
|
||||
|
||||
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};
|
||||
/* ── Divider line between tiles and entities ── */
|
||||
SDL_SetRenderDrawColor(r, COL_PANEL_LT.r, COL_PANEL_LT.g, COL_PANEL_LT.b, 255);
|
||||
SDL_RenderDrawLine(r, px + 2, ent_section_y, px + EDITOR_PALETTE_W - 2, ent_section_y);
|
||||
|
||||
/* ── Entity palette (bottom section) ── */
|
||||
{
|
||||
int label_h = FONT_H + 6;
|
||||
draw_text(r, "ENTITIES", px + 2, ent_section_y + (label_h - FONT_H) / 2, COL_TEXT);
|
||||
|
||||
int pal_y_start = ent_section_y + label_h;
|
||||
int ent_area_h = py + ph - pal_y_start;
|
||||
SDL_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W, ent_area_h};
|
||||
SDL_RenderSetClipRect(r, &clip);
|
||||
|
||||
for (int i = 0; i < g_entity_registry.count; i++) {
|
||||
@@ -1113,6 +1408,6 @@ void editor_render(Editor *ed, float interpolation) {
|
||||
ed->camera.zoom * 100.0f,
|
||||
ed->has_file ? ed->file_path : "new level",
|
||||
ed->dirty ? " *" : "");
|
||||
draw_text(r, status, 2, sy + 2, COL_TEXT);
|
||||
draw_text(r, status, 2, sy + (EDITOR_STATUS_H - FONT_H) / 2, COL_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ typedef enum EditorTool {
|
||||
TOOL_FILL, /* flood fill area */
|
||||
TOOL_ENTITY, /* place/select entities */
|
||||
TOOL_SPAWN, /* set player spawn point */
|
||||
TOOL_EXIT, /* place/delete exit zones */
|
||||
TOOL_COUNT
|
||||
} EditorTool;
|
||||
|
||||
@@ -37,9 +38,9 @@ typedef enum EditorLayer {
|
||||
} EditorLayer;
|
||||
|
||||
/* UI panel regions */
|
||||
#define EDITOR_TOOLBAR_H 14 /* top bar height */
|
||||
#define EDITOR_TOOLBAR_H 18 /* top bar height */
|
||||
#define EDITOR_PALETTE_W 80 /* right panel width */
|
||||
#define EDITOR_STATUS_H 10 /* bottom status bar height */
|
||||
#define EDITOR_STATUS_H 16 /* bottom status bar height */
|
||||
|
||||
typedef struct Editor {
|
||||
/* ── Map data ──────────────────────────── */
|
||||
|
||||
@@ -31,6 +31,10 @@ static Entity *spawn_powerup_drone(EntityManager *em, Vec2 pos) {
|
||||
return powerup_spawn_drone(em, pos);
|
||||
}
|
||||
|
||||
static Entity *spawn_powerup_gun(EntityManager *em, Vec2 pos) {
|
||||
return powerup_spawn_gun(em, pos);
|
||||
}
|
||||
|
||||
/* ── Registry population ─────────────────────────── */
|
||||
|
||||
static void reg_add(const char *name, const char *display,
|
||||
@@ -62,6 +66,7 @@ void entity_registry_init(EntityManager *em) {
|
||||
force_field_register(em);
|
||||
powerup_register(em);
|
||||
drone_register(em);
|
||||
asteroid_register(em);
|
||||
|
||||
/* ════════════════════════════════════════════
|
||||
* REGISTRY TABLE
|
||||
@@ -85,6 +90,8 @@ void entity_registry_init(EntityManager *em) {
|
||||
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);
|
||||
reg_add("powerup_gun", "Gun Pickup", spawn_powerup_gun, (SDL_Color){200, 200, 220, 255}, 12, 12);
|
||||
reg_add("asteroid", "Asteroid", asteroid_spawn, (SDL_Color){140, 110, 80, 255}, ASTEROID_WIDTH, ASTEROID_HEIGHT);
|
||||
|
||||
printf("Entity registry: %d types registered\n", g_entity_registry.count);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#include "game/hazards.h"
|
||||
#include "game/player.h"
|
||||
#include "game/sprites.h"
|
||||
#include "game/projectile.h"
|
||||
#include "engine/physics.h"
|
||||
#include "engine/renderer.h"
|
||||
#include "engine/particle.h"
|
||||
#include "engine/audio.h"
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
|
||||
@@ -597,3 +599,203 @@ Entity *force_field_spawn(EntityManager *em, Vec2 pos) {
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
* ASTEROID — Falling space rock
|
||||
* ════════════════════════════════════════════════════ */
|
||||
|
||||
static EntityManager *s_asteroid_em = NULL;
|
||||
static Sound s_sfx_asteroid_impact;
|
||||
static bool s_asteroid_sfx_loaded = false;
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
static void asteroid_update(Entity *self, float dt, const Tilemap *map) {
|
||||
AsteroidData *ad = (AsteroidData *)self->data;
|
||||
if (!ad) return;
|
||||
|
||||
/* Initial delay before first fall */
|
||||
if (ad->start_delay > 0) {
|
||||
ad->start_delay -= dt;
|
||||
self->body.pos.y = -20.0f; /* hide off-screen */
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ad->falling) {
|
||||
/* Waiting to respawn */
|
||||
ad->respawn_timer -= dt;
|
||||
if (ad->respawn_timer <= 0) {
|
||||
/* Reset to spawn position and start falling */
|
||||
self->body.pos = ad->spawn_pos;
|
||||
ad->falling = true;
|
||||
ad->trail_timer = 0;
|
||||
ad->fall_speed = ASTEROID_FALL_SPEED;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Accelerate while falling (gravity-like) */
|
||||
ad->fall_speed += 200.0f * dt;
|
||||
self->body.pos.y += ad->fall_speed * dt;
|
||||
|
||||
/* Tumble animation */
|
||||
animation_update(&self->anim, dt);
|
||||
|
||||
/* Smoke trail particles */
|
||||
ad->trail_timer -= dt;
|
||||
if (ad->trail_timer <= 0) {
|
||||
ad->trail_timer = 0.04f;
|
||||
Vec2 center = vec2(
|
||||
self->body.pos.x + self->body.size.x * 0.5f,
|
||||
self->body.pos.y
|
||||
);
|
||||
ParticleBurst trail = {
|
||||
.origin = center,
|
||||
.count = 2,
|
||||
.speed_min = 5.0f,
|
||||
.speed_max = 20.0f,
|
||||
.life_min = 0.2f,
|
||||
.life_max = 0.5f,
|
||||
.size_min = 1.0f,
|
||||
.size_max = 2.0f,
|
||||
.spread = 0.5f,
|
||||
.direction = -(float)M_PI / 2.0f, /* upward */
|
||||
.drag = 2.0f,
|
||||
.gravity_scale = 0.0f,
|
||||
.color = {140, 110, 80, 180},
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&trail);
|
||||
}
|
||||
|
||||
/* Check player collision */
|
||||
Entity *player = find_player(s_asteroid_em);
|
||||
if (player && !(player->flags & ENTITY_INVINCIBLE) &&
|
||||
!(player->flags & ENTITY_DEAD)) {
|
||||
if (physics_overlap(&self->body, &player->body)) {
|
||||
player->health -= ASTEROID_DAMAGE;
|
||||
if (player->health <= 0) {
|
||||
player->health = 0;
|
||||
player->flags |= ENTITY_DEAD;
|
||||
} else {
|
||||
/* Grant invincibility frames */
|
||||
PlayerData *ppd = (PlayerData *)player->data;
|
||||
if (ppd) {
|
||||
ppd->inv_timer = PLAYER_INV_TIME;
|
||||
player->flags |= ENTITY_INVINCIBLE;
|
||||
}
|
||||
|
||||
/* Knockback downward and away */
|
||||
float knock_dir = (player->body.pos.x < self->body.pos.x)
|
||||
? -1.0f : 1.0f;
|
||||
player->body.vel.x = knock_dir * 120.0f;
|
||||
player->body.vel.y = 100.0f;
|
||||
}
|
||||
|
||||
Vec2 hit_pos = vec2(
|
||||
player->body.pos.x + player->body.size.x * 0.5f,
|
||||
player->body.pos.y + player->body.size.y * 0.5f
|
||||
);
|
||||
particle_emit_spark(hit_pos, (SDL_Color){180, 140, 80, 255});
|
||||
|
||||
if (s_asteroid_sfx_loaded) {
|
||||
audio_play_sound(s_sfx_asteroid_impact, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Check ground collision */
|
||||
int tx = world_to_tile(self->body.pos.x + self->body.size.x * 0.5f);
|
||||
int ty = world_to_tile(self->body.pos.y + self->body.size.y);
|
||||
bool hit_ground = tilemap_is_solid(map, tx, ty);
|
||||
|
||||
/* Also despawn if far below level */
|
||||
float level_bottom = (float)(map->height * TILE_SIZE) + 32.0f;
|
||||
if (hit_ground || self->body.pos.y > level_bottom) {
|
||||
/* Impact effect */
|
||||
Vec2 impact_pos = vec2(
|
||||
self->body.pos.x + self->body.size.x * 0.5f,
|
||||
self->body.pos.y + self->body.size.y
|
||||
);
|
||||
particle_emit_death_puff(impact_pos, (SDL_Color){140, 110, 80, 255});
|
||||
|
||||
if (s_asteroid_sfx_loaded) {
|
||||
audio_play_sound(s_sfx_asteroid_impact, 60);
|
||||
}
|
||||
|
||||
/* Hide and start respawn timer */
|
||||
ad->falling = false;
|
||||
ad->respawn_timer = ASTEROID_RESPAWN;
|
||||
self->body.pos.y = -100.0f; /* hide off-screen */
|
||||
}
|
||||
}
|
||||
|
||||
static void asteroid_render(Entity *self, const Camera *cam) {
|
||||
(void)cam;
|
||||
AsteroidData *ad = (AsteroidData *)self->data;
|
||||
if (!ad || !ad->falling) return;
|
||||
if (!g_spritesheet || !self->anim.def) return;
|
||||
|
||||
SDL_Rect src = animation_current_rect(&self->anim);
|
||||
Body *body = &self->body;
|
||||
|
||||
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 asteroid_destroy(Entity *self) {
|
||||
free(self->data);
|
||||
self->data = NULL;
|
||||
}
|
||||
|
||||
void asteroid_register(EntityManager *em) {
|
||||
entity_register(em, ENT_ASTEROID, asteroid_update, asteroid_render, asteroid_destroy);
|
||||
s_asteroid_em = em;
|
||||
|
||||
if (!s_asteroid_sfx_loaded) {
|
||||
s_sfx_asteroid_impact = audio_load_sound("assets/sounds/hitHurt.wav");
|
||||
s_asteroid_sfx_loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
Entity *asteroid_spawn(EntityManager *em, Vec2 pos) {
|
||||
Entity *e = entity_spawn(em, ENT_ASTEROID, pos);
|
||||
if (!e) return NULL;
|
||||
|
||||
e->body.size = vec2(ASTEROID_WIDTH, ASTEROID_HEIGHT);
|
||||
e->body.gravity_scale = 0.0f; /* we handle movement manually */
|
||||
e->health = 9999;
|
||||
e->max_health = 9999;
|
||||
e->flags |= ENTITY_INVINCIBLE;
|
||||
e->damage = ASTEROID_DAMAGE;
|
||||
|
||||
AsteroidData *ad = calloc(1, sizeof(AsteroidData));
|
||||
ad->spawn_pos = pos;
|
||||
ad->falling = true;
|
||||
ad->fall_speed = ASTEROID_FALL_SPEED;
|
||||
ad->trail_timer = 0;
|
||||
ad->respawn_timer = 0;
|
||||
/* Stagger start times based on spawn position to avoid all falling at once */
|
||||
ad->start_delay = (pos.x * 0.013f + pos.y * 0.007f);
|
||||
ad->start_delay = ad->start_delay - (float)(int)ad->start_delay; /* frac part */
|
||||
ad->start_delay *= 3.0f; /* 0-3s stagger */
|
||||
e->data = ad;
|
||||
|
||||
animation_set(&e->anim, &anim_asteroid);
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -87,4 +87,28 @@ typedef struct ForceFieldData {
|
||||
void force_field_register(EntityManager *em);
|
||||
Entity *force_field_spawn(EntityManager *em, Vec2 pos);
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* ASTEROID — Falls from the sky, damages the player
|
||||
* on contact, explodes on hitting the ground, then
|
||||
* respawns at its original position after a delay.
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
|
||||
#define ASTEROID_WIDTH 14
|
||||
#define ASTEROID_HEIGHT 14
|
||||
#define ASTEROID_FALL_SPEED 120.0f /* base fall speed (px/s) */
|
||||
#define ASTEROID_DAMAGE 1
|
||||
#define ASTEROID_RESPAWN 3.0f /* seconds before respawning */
|
||||
|
||||
typedef struct AsteroidData {
|
||||
Vec2 spawn_pos; /* original position (for reset)*/
|
||||
float fall_speed; /* current fall velocity */
|
||||
float respawn_timer; /* countdown while inactive */
|
||||
bool falling; /* true = falling, false = wait */
|
||||
float trail_timer; /* particle trail interval */
|
||||
float start_delay; /* initial delay before first fall */
|
||||
} AsteroidData;
|
||||
|
||||
void asteroid_register(EntityManager *em);
|
||||
Entity *asteroid_spawn(EntityManager *em, Vec2 pos);
|
||||
|
||||
#endif /* JNR_HAZARDS_H */
|
||||
|
||||
166
src/game/level.c
166
src/game/level.c
@@ -3,6 +3,8 @@
|
||||
#include "game/enemy.h"
|
||||
#include "game/projectile.h"
|
||||
#include "game/hazards.h"
|
||||
#include "game/powerup.h"
|
||||
#include "game/drone.h"
|
||||
#include "game/sprites.h"
|
||||
#include "game/entity_registry.h"
|
||||
#include "engine/core.h"
|
||||
@@ -15,10 +17,12 @@
|
||||
#include "engine/assets.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
/* ── Sound effects ───────────────────────────────── */
|
||||
static Sound s_sfx_hit;
|
||||
static Sound s_sfx_enemy_death;
|
||||
static Sound s_sfx_pickup;
|
||||
static bool s_sfx_loaded = false;
|
||||
|
||||
/* ── Shared level setup (after tilemap is ready) ─── */
|
||||
@@ -32,6 +36,7 @@ static bool level_setup(Level *level) {
|
||||
if (!s_sfx_loaded) {
|
||||
s_sfx_hit = audio_load_sound("assets/sounds/hitHurt.wav");
|
||||
s_sfx_enemy_death = audio_load_sound("assets/sounds/teleport.wav");
|
||||
s_sfx_pickup = audio_load_sound("assets/sounds/teleport.wav");
|
||||
s_sfx_loaded = true;
|
||||
}
|
||||
|
||||
@@ -94,6 +99,12 @@ static bool level_setup(Level *level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Disarm player if level requests it */
|
||||
if (level->map.player_unarmed) {
|
||||
PlayerData *ppd = (PlayerData *)player->data;
|
||||
if (ppd) ppd->has_gun = false;
|
||||
}
|
||||
|
||||
/* Spawn entities from level data (via registry) */
|
||||
for (int i = 0; i < level->map.entity_spawn_count; i++) {
|
||||
EntitySpawn *es = &level->map.entity_spawns[i];
|
||||
@@ -276,10 +287,111 @@ static void handle_collisions(EntityManager *em) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Powerup pickup ────────────────────── */
|
||||
if (player && a->type == ENT_POWERUP && a->active &&
|
||||
!(a->flags & ENTITY_DEAD)) {
|
||||
if (physics_overlap(&a->body, &player->body)) {
|
||||
PowerupData *pd = (PowerupData *)a->data;
|
||||
bool picked_up = false;
|
||||
|
||||
if (pd) {
|
||||
switch (pd->kind) {
|
||||
case POWERUP_HEALTH:
|
||||
if (player->health < player->max_health) {
|
||||
player->health++;
|
||||
picked_up = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case POWERUP_JETPACK: {
|
||||
PlayerData *ppd = (PlayerData *)player->data;
|
||||
if (ppd) {
|
||||
ppd->dash_charges = ppd->dash_max_charges;
|
||||
ppd->dash_recharge_timer = 0.0f;
|
||||
ppd->jetpack_boost_timer = PLAYER_JETPACK_BOOST_DURATION;
|
||||
picked_up = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case POWERUP_DRONE:
|
||||
drone_spawn(em, vec2(
|
||||
player->body.pos.x + player->body.size.x * 0.5f,
|
||||
player->body.pos.y
|
||||
));
|
||||
picked_up = true;
|
||||
break;
|
||||
|
||||
case POWERUP_GUN:
|
||||
if (!player_has_gun(player)) {
|
||||
player_give_gun(player);
|
||||
picked_up = true;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (picked_up) {
|
||||
/* Pickup particles */
|
||||
Vec2 center = vec2(
|
||||
a->body.pos.x + a->body.size.x * 0.5f,
|
||||
a->body.pos.y + a->body.size.y * 0.5f
|
||||
);
|
||||
particle_emit_spark(center, (SDL_Color){255, 255, 100, 255});
|
||||
audio_play_sound(s_sfx_pickup, 80);
|
||||
|
||||
/* Destroy the powerup */
|
||||
a->flags |= ENTITY_DEAD;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Exit zone checking ──────────────────────────── */
|
||||
|
||||
static void check_exit_zones(Level *level) {
|
||||
if (level->exit_triggered) return;
|
||||
if (level->map.exit_zone_count == 0) return;
|
||||
|
||||
/* Find the player */
|
||||
Entity *player = NULL;
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER && !(e->flags & ENTITY_DEAD)) {
|
||||
player = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!player) return;
|
||||
|
||||
for (int i = 0; i < level->map.exit_zone_count; i++) {
|
||||
const ExitZone *ez = &level->map.exit_zones[i];
|
||||
if (physics_aabb_overlap(
|
||||
player->body.pos, player->body.size,
|
||||
vec2(ez->x, ez->y), vec2(ez->w, ez->h))) {
|
||||
level->exit_triggered = true;
|
||||
snprintf(level->exit_target, sizeof(level->exit_target),
|
||||
"%s", ez->target);
|
||||
printf("Exit zone triggered -> %s\n",
|
||||
ez->target[0] ? ez->target : "(victory)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool level_exit_triggered(const Level *level) {
|
||||
return level->exit_triggered;
|
||||
}
|
||||
|
||||
void level_update(Level *level, float dt) {
|
||||
/* Don't update if exit already triggered (transition pending) */
|
||||
if (level->exit_triggered) return;
|
||||
|
||||
/* Start music on first update (deferred so browser audio context
|
||||
* is unlocked by the first user interaction / keypress) */
|
||||
if (!level->music_started && level->music.music) {
|
||||
@@ -297,6 +409,9 @@ void level_update(Level *level, float dt) {
|
||||
/* Handle collisions */
|
||||
handle_collisions(&level->entities);
|
||||
|
||||
/* Check exit zones */
|
||||
check_exit_zones(level);
|
||||
|
||||
/* Check for player respawn */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
@@ -347,6 +462,39 @@ void level_render(Level *level, float interpolation) {
|
||||
tilemap_render_layer(&level->map, level->map.collision_layer,
|
||||
cam, g_engine.renderer);
|
||||
|
||||
/* Render exit zones (pulsing glow on ground layer) */
|
||||
if (level->map.exit_zone_count > 0) {
|
||||
/* Pulse alpha between 40 and 100 using a sine wave */
|
||||
static float s_exit_pulse = 0.0f;
|
||||
s_exit_pulse += 3.0f * DT; /* ~3 Hz pulse */
|
||||
float pulse = 0.5f + 0.5f * sinf(s_exit_pulse);
|
||||
uint8_t alpha = (uint8_t)(40.0f + pulse * 60.0f);
|
||||
|
||||
SDL_Renderer *r = g_engine.renderer;
|
||||
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
|
||||
|
||||
for (int i = 0; i < level->map.exit_zone_count; i++) {
|
||||
const ExitZone *ez = &level->map.exit_zones[i];
|
||||
Vec2 screen_pos = camera_world_to_screen(cam, vec2(ez->x, ez->y));
|
||||
float zoom = cam->zoom > 0.0f ? cam->zoom : 1.0f;
|
||||
|
||||
SDL_Rect rect = {
|
||||
(int)screen_pos.x,
|
||||
(int)screen_pos.y,
|
||||
(int)(ez->w * zoom + 0.5f),
|
||||
(int)(ez->h * zoom + 0.5f)
|
||||
};
|
||||
|
||||
/* Green/cyan fill */
|
||||
SDL_SetRenderDrawColor(r, 50, 230, 180, alpha);
|
||||
SDL_RenderFillRect(r, &rect);
|
||||
|
||||
/* Brighter border */
|
||||
SDL_SetRenderDrawColor(r, 80, 255, 200, (uint8_t)(alpha + 40));
|
||||
SDL_RenderDrawRect(r, &rect);
|
||||
}
|
||||
}
|
||||
|
||||
/* Render entities */
|
||||
entity_render_all(&level->entities, cam);
|
||||
|
||||
@@ -384,7 +532,17 @@ void level_render(Level *level, float interpolation) {
|
||||
/* Draw jetpack charge indicators */
|
||||
int charges, max_charges;
|
||||
float recharge_pct;
|
||||
if (player_get_dash_charges(player, &charges, &max_charges, &recharge_pct)) {
|
||||
bool boosted = false;
|
||||
if (player_get_dash_charges(player, &charges, &max_charges,
|
||||
&recharge_pct, &boosted)) {
|
||||
/* Blue when boosted, orange normally */
|
||||
SDL_Color full_color = boosted
|
||||
? (SDL_Color){50, 150, 255, 255}
|
||||
: (SDL_Color){255, 180, 50, 255};
|
||||
SDL_Color partial_color = boosted
|
||||
? (SDL_Color){40, 120, 200, 180}
|
||||
: (SDL_Color){200, 140, 40, 180};
|
||||
|
||||
for (int i = 0; i < max_charges; i++) {
|
||||
float bx = 8.0f + i * 10.0f;
|
||||
float by = 22.0f;
|
||||
@@ -396,15 +554,15 @@ void level_render(Level *level, float interpolation) {
|
||||
(SDL_Color){50, 50, 60, 255}, LAYER_HUD, cam);
|
||||
|
||||
if (i < charges) {
|
||||
/* Full charge — bright orange */
|
||||
/* Full charge */
|
||||
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
|
||||
(SDL_Color){255, 180, 50, 255}, LAYER_HUD, cam);
|
||||
full_color, LAYER_HUD, cam);
|
||||
} else if (i == charges) {
|
||||
/* Currently recharging — partial fill */
|
||||
float fill = recharge_pct * bw;
|
||||
if (fill > 0.5f) {
|
||||
renderer_draw_rect(vec2(bx, by), vec2(fill, bh),
|
||||
(SDL_Color){200, 140, 40, 180}, LAYER_HUD, cam);
|
||||
partial_color, LAYER_HUD, cam);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ typedef struct Level {
|
||||
Parallax parallax;
|
||||
Music music;
|
||||
bool music_started;
|
||||
|
||||
/* ── Exit / transition state ─────────── */
|
||||
bool exit_triggered; /* player entered an exit zone */
|
||||
char exit_target[ASSET_PATH_MAX]; /* target level path */
|
||||
} Level;
|
||||
|
||||
bool level_load(Level *level, const char *path);
|
||||
@@ -22,4 +26,8 @@ void level_update(Level *level, float dt);
|
||||
void level_render(Level *level, float interpolation);
|
||||
void level_free(Level *level);
|
||||
|
||||
/* Returns true if the player has triggered a level exit.
|
||||
* The target path is stored in level->exit_target. */
|
||||
bool level_exit_triggered(const Level *level);
|
||||
|
||||
#endif /* JNR_LEVEL_H */
|
||||
|
||||
@@ -251,6 +251,14 @@ static void gen_platforms(Tilemap *map, int x0, int w, float difficulty, LevelTh
|
||||
add_entity(map, "flyer", x0 + rng_range(2, w - 3), rng_range(5, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/* Jetpack refill on a high platform — reward for climbing */
|
||||
if (rng_float() < 0.3f) {
|
||||
int top_py = GROUND_ROW - 3 - (num_plats - 1) * 3;
|
||||
if (top_py >= 3) {
|
||||
add_entity(map, "powerup_jet", x0 + rng_range(2, w - 3), top_py - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* SEG_CORRIDOR: walled section with ceiling */
|
||||
@@ -304,6 +312,11 @@ static void gen_corridor(Tilemap *map, int x0, int w, float difficulty, LevelThe
|
||||
if (rng_float() < 0.5f + difficulty * 0.3f) {
|
||||
add_entity(map, "grunt", x0 + rng_range(2, w - 3), GROUND_ROW - 1);
|
||||
}
|
||||
|
||||
/* Health pickup near the exit — reward for surviving the corridor */
|
||||
if (difficulty > 0.3f && rng_float() < 0.35f) {
|
||||
add_entity(map, "powerup_hp", x0 + w - 3, GROUND_ROW - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* SEG_ARENA: wide open area, multiple enemies */
|
||||
@@ -362,6 +375,15 @@ static void gen_arena(Tilemap *map, int x0, int w, float difficulty, LevelTheme
|
||||
add_entity(map, "turret", tx, plat_h - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Powerup reward on the central platform (earned by clearing the arena) */
|
||||
if (rng_float() < 0.5f) {
|
||||
if (difficulty > 0.6f && rng_float() < 0.25f) {
|
||||
add_entity(map, "powerup_drone", cp_x + 2, cp_y - 1);
|
||||
} else {
|
||||
add_entity(map, "powerup_hp", cp_x + 2, cp_y - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* SEG_SHAFT: vertical shaft with platforms to climb */
|
||||
@@ -428,6 +450,11 @@ static void gen_shaft(Tilemap *map, int x0, int w, float difficulty, LevelTheme
|
||||
add_entity(map, "flyer", x0 + w / 2, rng_range(6, 12));
|
||||
}
|
||||
}
|
||||
|
||||
/* Jetpack refill near the top — reward for climbing */
|
||||
if (rng_float() < 0.4f) {
|
||||
add_entity(map, "powerup_jet", x0 + w / 2, 6);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
@@ -791,8 +818,30 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
|
||||
map->has_bg_color = true;
|
||||
map->parallax_style = (int)parallax_style_for_theme(primary_theme);
|
||||
|
||||
/* Exit zone at the far-right end of the level.
|
||||
* Placed just inside the right border wall, 2 tiles wide, 3 tiles tall
|
||||
* sitting on the ground row. Target "generate" chains to another
|
||||
* procedurally generated level. */
|
||||
if (map->exit_zone_count < MAX_EXIT_ZONES) {
|
||||
ExitZone *ez = &map->exit_zones[map->exit_zone_count++];
|
||||
int exit_x = map->width - 5; /* a few tiles from the right wall */
|
||||
int exit_y = GROUND_ROW - 3; /* 3 tiles above ground */
|
||||
ez->x = (float)(exit_x * TILE_SIZE);
|
||||
ez->y = (float)(exit_y * TILE_SIZE);
|
||||
ez->w = 2.0f * TILE_SIZE;
|
||||
ez->h = 3.0f * TILE_SIZE;
|
||||
snprintf(ez->target, sizeof(ez->target), "generate");
|
||||
|
||||
/* Clear any solid tiles in the exit zone area so the player can walk into it */
|
||||
for (int y = exit_y; y < exit_y + 3 && y < map->height; y++) {
|
||||
for (int x = exit_x; x < exit_x + 2 && x < map->width; x++) {
|
||||
map->collision_layer[y * map->width + x] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Music */
|
||||
strncpy(map->music_path, "assets/sounds/algardalgar.ogg", ASSET_PATH_MAX - 1);
|
||||
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
|
||||
|
||||
/* Tileset */
|
||||
/* NOTE: tileset texture will be loaded by level_load_generated */
|
||||
@@ -812,6 +861,470 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* Space Station Generator
|
||||
*
|
||||
* A dedicated generator for long, narrow, low-gravity
|
||||
* space station levels. Uses the standard 23-tile height
|
||||
* but fills ceiling and floor to create a narrower
|
||||
* playable corridor (roughly 10 tiles of vertical space).
|
||||
*
|
||||
* Segment types are biased toward horizontal layouts:
|
||||
* corridors, flat sections, arenas. Shafts are replaced
|
||||
* by wider horizontal variants.
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
|
||||
/* Station layout constants */
|
||||
#define STATION_CEIL_ROW 4 /* ceiling bottom edge (rows 0-4 solid) */
|
||||
#define STATION_FLOOR_ROW 17 /* floor top edge (rows 17-22 solid) */
|
||||
#define STATION_PLAY_H 12 /* playable rows: 5-16 inclusive */
|
||||
|
||||
static void station_fill_envelope(uint16_t *col, int mw, int x0, int x1) {
|
||||
/* Ceiling: rows 0 through STATION_CEIL_ROW */
|
||||
fill_rect(col, mw, x0, 0, x1, STATION_CEIL_ROW, TILE_SOLID_1);
|
||||
/* Floor: rows STATION_FLOOR_ROW through bottom */
|
||||
fill_rect(col, mw, x0, STATION_FLOOR_ROW, x1, SEG_HEIGHT - 1, TILE_SOLID_1);
|
||||
}
|
||||
|
||||
/* ── Station segment: long flat corridor ── */
|
||||
static void gen_station_corridor(Tilemap *map, int x0, int w, float difficulty) {
|
||||
uint16_t *col = map->collision_layer;
|
||||
int mw = map->width;
|
||||
|
||||
station_fill_envelope(col, mw, x0, x0 + w - 1);
|
||||
|
||||
/* Random platforms floating in the corridor */
|
||||
int num_plats = rng_range(1, 3);
|
||||
for (int i = 0; i < num_plats; i++) {
|
||||
int px = x0 + rng_range(2, w - 5);
|
||||
int py = rng_range(STATION_CEIL_ROW + 3, STATION_FLOOR_ROW - 3);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ground patrols — always at least 1, scales with difficulty */
|
||||
int num_grunts = 1 + (int)(difficulty * 2);
|
||||
for (int i = 0; i < num_grunts; i++) {
|
||||
add_entity(map, "grunt", x0 + rng_range(3, w - 4), STATION_FLOOR_ROW - 1);
|
||||
}
|
||||
|
||||
/* Flyers in the corridor */
|
||||
int num_flyers = (int)(difficulty * 2);
|
||||
for (int i = 0; i < num_flyers; i++) {
|
||||
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
|
||||
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 3));
|
||||
}
|
||||
|
||||
/* Turret at high difficulty */
|
||||
if (difficulty > 0.6f && rng_float() < 0.5f) {
|
||||
add_entity(map, "turret", x0 + rng_range(3, w - 4), STATION_CEIL_ROW + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Station segment: bulkhead (walls with doorway) ── */
|
||||
static void gen_station_bulkhead(Tilemap *map, int x0, int w, float difficulty) {
|
||||
uint16_t *col = map->collision_layer;
|
||||
int mw = map->width;
|
||||
|
||||
station_fill_envelope(col, mw, x0, x0 + w - 1);
|
||||
|
||||
/* Central bulkhead wall with a doorway */
|
||||
int wall_x = x0 + w / 2;
|
||||
fill_rect(col, mw, wall_x, STATION_CEIL_ROW + 1, wall_x, STATION_FLOOR_ROW - 1, TILE_SOLID_2);
|
||||
|
||||
/* Doorway opening (3 tiles tall) */
|
||||
int door_y = rng_range(STATION_CEIL_ROW + 3, STATION_FLOOR_ROW - 4);
|
||||
for (int y = door_y; y < door_y + 3; y++) {
|
||||
set_tile(col, mw, wall_x, y, TILE_EMPTY);
|
||||
}
|
||||
|
||||
/* Turret guarding the doorway — always present */
|
||||
add_entity(map, "turret", wall_x - 2, door_y - 1);
|
||||
|
||||
/* Second turret on the other side at higher difficulty */
|
||||
if (difficulty > 0.6f) {
|
||||
add_entity(map, "turret", wall_x + 2, door_y - 1);
|
||||
}
|
||||
|
||||
/* Force field in the doorway */
|
||||
if (difficulty > 0.4f && rng_float() < 0.6f) {
|
||||
add_entity(map, "force_field", wall_x, door_y + 1);
|
||||
}
|
||||
|
||||
/* Grunts on both sides of the bulkhead */
|
||||
add_entity(map, "grunt", x0 + rng_range(2, w / 2 - 2), STATION_FLOOR_ROW - 1);
|
||||
if (rng_float() < 0.5f + difficulty * 0.5f) {
|
||||
add_entity(map, "grunt", x0 + rng_range(w / 2 + 2, w - 3), STATION_FLOOR_ROW - 1);
|
||||
}
|
||||
|
||||
/* Flyer patrol */
|
||||
if (difficulty > 0.4f) {
|
||||
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
|
||||
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 3));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Station segment: platform gauntlet ── */
|
||||
static void gen_station_platforms(Tilemap *map, int x0, int w, float difficulty) {
|
||||
uint16_t *col = map->collision_layer;
|
||||
int mw = map->width;
|
||||
|
||||
station_fill_envelope(col, mw, x0, x0 + w - 1);
|
||||
|
||||
/* Remove some floor sections to create pits (lethal in a station) */
|
||||
int pit_start = x0 + rng_range(3, 6);
|
||||
int pit_end = x0 + w - rng_range(3, 6);
|
||||
for (int x = pit_start; x < pit_end && x < x0 + w; x++) {
|
||||
for (int y = STATION_FLOOR_ROW; y < STATION_FLOOR_ROW + 2; y++) {
|
||||
set_tile(col, mw, x, y, TILE_EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating platforms across the gap */
|
||||
int num_plats = rng_range(3, 5);
|
||||
int spacing = (pit_end - pit_start) / (num_plats + 1);
|
||||
if (spacing < 2) spacing = 2;
|
||||
for (int i = 0; i < num_plats; i++) {
|
||||
int px = pit_start + (i + 1) * spacing - 1;
|
||||
int py = rng_range(STATION_CEIL_ROW + 4, STATION_FLOOR_ROW - 2);
|
||||
int pw = rng_range(2, 3);
|
||||
for (int j = 0; j < pw && px + j < x0 + w; j++) {
|
||||
set_tile(col, mw, px + j, py, TILE_PLAT);
|
||||
}
|
||||
}
|
||||
|
||||
/* Moving platform */
|
||||
if (rng_float() < 0.7f) {
|
||||
bool vertical = rng_float() < 0.4f;
|
||||
int mx = pit_start + (pit_end - pit_start) / 2;
|
||||
int my = rng_range(STATION_CEIL_ROW + 4, STATION_FLOOR_ROW - 3);
|
||||
add_entity(map, vertical ? "platform_v" : "platform", mx, my);
|
||||
}
|
||||
|
||||
/* Flyers guarding the gap — always at least 1 */
|
||||
int num_flyers = 1 + (int)(difficulty * 2);
|
||||
for (int i = 0; i < num_flyers; i++) {
|
||||
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
|
||||
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 4));
|
||||
}
|
||||
|
||||
/* Turret overlooking the pit */
|
||||
if (difficulty > 0.5f) {
|
||||
add_entity(map, "turret", x0 + rng_range(2, 4), STATION_CEIL_ROW + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Station segment: combat bay (wider arena) ── */
|
||||
static void gen_station_bay(Tilemap *map, int x0, int w, float difficulty) {
|
||||
uint16_t *col = map->collision_layer;
|
||||
int mw = map->width;
|
||||
|
||||
station_fill_envelope(col, mw, x0, x0 + w - 1);
|
||||
|
||||
/* Open up a taller space by raising the ceiling locally */
|
||||
int bay_x0 = x0 + 2;
|
||||
int bay_x1 = x0 + w - 3;
|
||||
for (int x = bay_x0; x <= bay_x1; x++) {
|
||||
set_tile(col, mw, x, STATION_CEIL_ROW, TILE_EMPTY);
|
||||
set_tile(col, mw, x, STATION_CEIL_ROW - 1, TILE_EMPTY);
|
||||
}
|
||||
|
||||
/* Central floating platform */
|
||||
int cp_x = x0 + w / 2 - 2;
|
||||
int cp_y = rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 5);
|
||||
for (int j = 0; j < 4; j++) {
|
||||
set_tile(col, mw, cp_x + j, cp_y, TILE_PLAT);
|
||||
}
|
||||
|
||||
/* Ledges on the sides */
|
||||
fill_rect(col, mw, x0, STATION_FLOOR_ROW - 3, x0 + 1, STATION_FLOOR_ROW - 1, TILE_SOLID_1);
|
||||
fill_rect(col, mw, x0 + w - 2, STATION_FLOOR_ROW - 3, x0 + w - 1, STATION_FLOOR_ROW - 1, TILE_SOLID_1);
|
||||
|
||||
/* Swarm of enemies — bays are the big combat encounters */
|
||||
int num_enemies = 3 + (int)(difficulty * 4);
|
||||
for (int i = 0; i < num_enemies; i++) {
|
||||
float r = rng_float();
|
||||
if (r < 0.30f) {
|
||||
add_entity(map, "grunt", x0 + rng_range(3, w - 4), STATION_FLOOR_ROW - 1);
|
||||
} else {
|
||||
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
|
||||
rng_range(STATION_CEIL_ROW + 1, STATION_FLOOR_ROW - 4));
|
||||
}
|
||||
}
|
||||
|
||||
/* Turrets on both side ledges */
|
||||
add_entity(map, "turret", x0 + 1, STATION_FLOOR_ROW - 4);
|
||||
if (difficulty > 0.5f) {
|
||||
add_entity(map, "turret", x0 + w - 2, STATION_FLOOR_ROW - 4);
|
||||
}
|
||||
|
||||
/* Powerup on the central platform — reward for surviving */
|
||||
if (rng_float() < 0.6f) {
|
||||
if (difficulty > 0.5f && rng_float() < 0.3f) {
|
||||
add_entity(map, "powerup_drone", cp_x + 2, cp_y - 1);
|
||||
} else {
|
||||
add_entity(map, "powerup_hp", cp_x + 2, cp_y - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Station segment: vent / crawlspace ── */
|
||||
static void gen_station_vent(Tilemap *map, int x0, int w, float difficulty) {
|
||||
uint16_t *col = map->collision_layer;
|
||||
int mw = map->width;
|
||||
|
||||
station_fill_envelope(col, mw, x0, x0 + w - 1);
|
||||
|
||||
/* Lower the ceiling even more for a tight crawlspace */
|
||||
int vent_ceil = STATION_CEIL_ROW + 3;
|
||||
fill_rect(col, mw, x0, STATION_CEIL_ROW + 1, x0 + w - 1, vent_ceil, TILE_SOLID_2);
|
||||
|
||||
/* Opening at left */
|
||||
for (int y = vent_ceil - 1; y <= vent_ceil + 2 && y < STATION_FLOOR_ROW; y++) {
|
||||
set_tile(col, mw, x0, y, TILE_EMPTY);
|
||||
}
|
||||
/* Opening at right */
|
||||
for (int y = vent_ceil - 1; y <= vent_ceil + 2 && y < STATION_FLOOR_ROW; y++) {
|
||||
set_tile(col, mw, x0 + w - 1, y, TILE_EMPTY);
|
||||
}
|
||||
|
||||
/* Flame vents along the floor — always present, more at higher difficulty */
|
||||
int num_vents = 1 + (int)(difficulty * 2);
|
||||
for (int i = 0; i < num_vents; i++) {
|
||||
add_entity(map, "flame_vent", x0 + rng_range(2, w - 3), STATION_FLOOR_ROW - 1);
|
||||
}
|
||||
|
||||
/* Force field obstacle */
|
||||
if (difficulty > 0.3f && rng_float() < 0.6f) {
|
||||
add_entity(map, "force_field", x0 + w / 2, vent_ceil + 2);
|
||||
}
|
||||
|
||||
/* Grunt lurking in the vent */
|
||||
if (rng_float() < 0.4f + difficulty * 0.4f) {
|
||||
add_entity(map, "grunt", x0 + rng_range(2, w - 3), STATION_FLOOR_ROW - 1);
|
||||
}
|
||||
|
||||
/* Jetpack refill reward (useful in low gravity) */
|
||||
if (rng_float() < 0.35f) {
|
||||
add_entity(map, "powerup_jet", x0 + rng_range(2, w - 3), vent_ceil + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Station segment: airlock entry (first segment) ── */
|
||||
static void gen_station_entry(Tilemap *map, int x0, int w, float difficulty) {
|
||||
uint16_t *col = map->collision_layer;
|
||||
int mw = map->width;
|
||||
|
||||
station_fill_envelope(col, mw, x0, x0 + w - 1);
|
||||
|
||||
/* Health pickup to start — always present */
|
||||
add_entity(map, "powerup_hp", x0 + w / 2, STATION_FLOOR_ROW - 1);
|
||||
|
||||
/* At higher difficulty, even the entry isn't safe */
|
||||
if (difficulty > 0.6f) {
|
||||
add_entity(map, "grunt", x0 + rng_range(w / 2 + 2, w - 3), STATION_FLOOR_ROW - 1);
|
||||
}
|
||||
if (difficulty > 0.8f) {
|
||||
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
|
||||
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 3));
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment type selection for station */
|
||||
typedef enum StationSegType {
|
||||
SSEG_ENTRY,
|
||||
SSEG_CORRIDOR,
|
||||
SSEG_BULKHEAD,
|
||||
SSEG_PLATFORMS,
|
||||
SSEG_BAY,
|
||||
SSEG_VENT,
|
||||
SSEG_TYPE_COUNT
|
||||
} StationSegType;
|
||||
|
||||
static StationSegType pick_station_segment(int index, int total) {
|
||||
if (index == 0) return SSEG_ENTRY;
|
||||
if (index == total - 1 && rng_float() < 0.6f) return SSEG_BAY;
|
||||
|
||||
float r = rng_float();
|
||||
if (r < 0.25f) return SSEG_CORRIDOR;
|
||||
if (r < 0.45f) return SSEG_BULKHEAD;
|
||||
if (r < 0.65f) return SSEG_PLATFORMS;
|
||||
if (r < 0.80f) return SSEG_BAY;
|
||||
return SSEG_VENT;
|
||||
}
|
||||
|
||||
static int station_segment_width(StationSegType type) {
|
||||
switch (type) {
|
||||
case SSEG_ENTRY: return rng_range(12, 16);
|
||||
case SSEG_CORRIDOR: return rng_range(18, 28);
|
||||
case SSEG_BULKHEAD: return rng_range(16, 22);
|
||||
case SSEG_PLATFORMS: return rng_range(20, 30);
|
||||
case SSEG_BAY: return rng_range(24, 34);
|
||||
case SSEG_VENT: return rng_range(14, 20);
|
||||
default: return 18;
|
||||
}
|
||||
}
|
||||
|
||||
LevelGenConfig levelgen_station_config(uint32_t seed, int depth) {
|
||||
if (depth < 0) depth = 0;
|
||||
|
||||
/* Segments grow with depth: 10 -> 11 -> 12 -> 13 -> 14 (capped) */
|
||||
int segments = 10 + depth;
|
||||
if (segments > 14) segments = 14;
|
||||
|
||||
/* Difficulty ramps up: 0.5 -> 0.65 -> 0.8 -> 0.9 -> 1.0 (capped) */
|
||||
float diff = 0.5f + depth * 0.15f;
|
||||
if (diff > 1.0f) diff = 1.0f;
|
||||
|
||||
LevelGenConfig config = {
|
||||
.seed = seed,
|
||||
.num_segments = segments,
|
||||
.difficulty = diff,
|
||||
.gravity = 150.0f, /* near-zero: floaty space station */
|
||||
.theme_count = 1,
|
||||
};
|
||||
config.themes[0] = THEME_SPACE_STATION;
|
||||
return config;
|
||||
}
|
||||
|
||||
bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
|
||||
if (!map || !config) return false;
|
||||
|
||||
rng_seed(config->seed);
|
||||
|
||||
int num_segs = config->num_segments;
|
||||
if (num_segs < 3) num_segs = 3;
|
||||
if (num_segs > 14) num_segs = 14;
|
||||
|
||||
/* ── Phase 1: decide segment types and widths ── */
|
||||
StationSegType seg_types[20];
|
||||
int seg_widths[20];
|
||||
int total_width = 0;
|
||||
|
||||
for (int i = 0; i < num_segs && i < 20; i++) {
|
||||
seg_types[i] = pick_station_segment(i, num_segs);
|
||||
seg_widths[i] = station_segment_width(seg_types[i]);
|
||||
total_width += seg_widths[i];
|
||||
}
|
||||
|
||||
/* 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_station: failed to allocate layers\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Phase 3: tile definitions ── */
|
||||
map->tile_defs[1] = (TileDef){0, 0, TILE_SOLID};
|
||||
map->tile_defs[2] = (TileDef){1, 0, TILE_SOLID};
|
||||
map->tile_defs[3] = (TileDef){2, 0, TILE_SOLID};
|
||||
map->tile_defs[4] = (TileDef){0, 1, TILE_PLATFORM};
|
||||
map->tile_def_count = 5;
|
||||
|
||||
/* ── Phase 4: generate segments ── */
|
||||
int cursor = 2;
|
||||
|
||||
/* Left border wall */
|
||||
fill_rect(map->collision_layer, map->width, 0, 0, 1, SEG_HEIGHT - 1, TILE_SOLID_1);
|
||||
|
||||
static const char *sseg_names[] = {
|
||||
"entry", "corr", "bulk", "plat", "bay", "vent"
|
||||
};
|
||||
|
||||
for (int i = 0; i < num_segs; i++) {
|
||||
int w = seg_widths[i];
|
||||
float diff = config->difficulty;
|
||||
|
||||
switch (seg_types[i]) {
|
||||
case SSEG_ENTRY: gen_station_entry(map, cursor, w, diff); break;
|
||||
case SSEG_CORRIDOR: gen_station_corridor(map, cursor, w, diff); break;
|
||||
case SSEG_BULKHEAD: gen_station_bulkhead(map, cursor, w, diff); break;
|
||||
case SSEG_PLATFORMS: gen_station_platforms(map, cursor, w, diff); break;
|
||||
case SSEG_BAY: gen_station_bay(map, cursor, w, diff); break;
|
||||
case SSEG_VENT: gen_station_vent(map, cursor, w, diff); break;
|
||||
default: gen_station_corridor(map, cursor, w, diff); 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: visual variety ── */
|
||||
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) {
|
||||
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(4.0f * TILE_SIZE,
|
||||
(STATION_FLOOR_ROW - 2) * TILE_SIZE);
|
||||
|
||||
map->gravity = config->gravity > 0 ? config->gravity : 150.0f;
|
||||
map->bg_color = (SDL_Color){3, 3, 14, 255}; /* very dark blue-black */
|
||||
map->has_bg_color = true;
|
||||
map->parallax_style = (int)PARALLAX_STYLE_DEEP_SPACE;
|
||||
|
||||
/* Exit zone at far right */
|
||||
if (map->exit_zone_count < MAX_EXIT_ZONES) {
|
||||
ExitZone *ez = &map->exit_zones[map->exit_zone_count++];
|
||||
int exit_x = map->width - 5;
|
||||
int exit_y = STATION_FLOOR_ROW - 3;
|
||||
ez->x = (float)(exit_x * TILE_SIZE);
|
||||
ez->y = (float)(exit_y * TILE_SIZE);
|
||||
ez->w = 2.0f * TILE_SIZE;
|
||||
ez->h = 3.0f * TILE_SIZE;
|
||||
snprintf(ez->target, sizeof(ez->target), "generate:station");
|
||||
|
||||
/* Clear exit zone area */
|
||||
for (int y = exit_y; y < exit_y + 3 && y < map->height; y++) {
|
||||
for (int x = exit_x; x < exit_x + 2 && x < map->width; x++) {
|
||||
map->collision_layer[y * map->width + x] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Music */
|
||||
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
|
||||
|
||||
printf("levelgen_station: generated %dx%d level (%d segments, seed=%u, gravity=%.0f)\n",
|
||||
map->width, map->height, num_segs, s_rng_state, map->gravity);
|
||||
printf(" segments:");
|
||||
for (int i = 0; i < num_segs; i++) {
|
||||
printf(" %s", sseg_names[seg_types[i]]);
|
||||
}
|
||||
printf("\n");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
* Dump to .lvl file format
|
||||
* ═══════════════════════════════════════════════════ */
|
||||
@@ -849,6 +1362,10 @@ bool levelgen_dump_lvl(const Tilemap *map, const char *path) {
|
||||
fprintf(f, "MUSIC %s\n", map->music_path);
|
||||
}
|
||||
|
||||
if (map->player_unarmed) {
|
||||
fprintf(f, "PLAYER_UNARMED\n");
|
||||
}
|
||||
|
||||
fprintf(f, "\n");
|
||||
|
||||
/* Entity spawns */
|
||||
@@ -861,6 +1378,21 @@ bool levelgen_dump_lvl(const Tilemap *map, const char *path) {
|
||||
|
||||
fprintf(f, "\n");
|
||||
|
||||
/* Exit zones */
|
||||
for (int i = 0; i < map->exit_zone_count; i++) {
|
||||
const ExitZone *ez = &map->exit_zones[i];
|
||||
int tx = (int)(ez->x / TILE_SIZE);
|
||||
int ty = (int)(ez->y / TILE_SIZE);
|
||||
int tw = (int)(ez->w / TILE_SIZE);
|
||||
int th = (int)(ez->h / TILE_SIZE);
|
||||
if (ez->target[0]) {
|
||||
fprintf(f, "EXIT %d %d %d %d %s\n", tx, ty, tw, th, ez->target);
|
||||
} else {
|
||||
fprintf(f, "EXIT %d %d %d %d\n", tx, ty, tw, th);
|
||||
}
|
||||
}
|
||||
if (map->exit_zone_count > 0) fprintf(f, "\n");
|
||||
|
||||
/* Tile definitions */
|
||||
for (int id = 1; id < map->tile_def_count; id++) {
|
||||
const TileDef *td = &map->tile_defs[id];
|
||||
|
||||
@@ -46,6 +46,13 @@ typedef struct LevelGenConfig {
|
||||
/* Default config for quick generation */
|
||||
LevelGenConfig levelgen_default_config(void);
|
||||
|
||||
/* Config preset for a space station level:
|
||||
* very long horizontally, narrow playable area,
|
||||
* near-zero gravity, all THEME_SPACE_STATION.
|
||||
* depth (0-based) escalates difficulty and length
|
||||
* with each subsequent station level. */
|
||||
LevelGenConfig levelgen_station_config(uint32_t seed, int depth);
|
||||
|
||||
/* Generate a level directly into a Tilemap struct.
|
||||
* The Tilemap will have layers allocated, tile defs set,
|
||||
* entity spawns populated, and metadata configured.
|
||||
@@ -53,6 +60,12 @@ LevelGenConfig levelgen_default_config(void);
|
||||
* Returns true on success. */
|
||||
bool levelgen_generate(Tilemap *map, const LevelGenConfig *config);
|
||||
|
||||
/* Generate a space-station level: very long, narrow,
|
||||
* near-zero gravity. Uses its own segment pool tuned
|
||||
* for horizontal layouts in tight corridors.
|
||||
* Returns true on success. */
|
||||
bool levelgen_generate_station(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. */
|
||||
|
||||
@@ -178,13 +178,39 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Fall off bottom of level = instant death */
|
||||
float level_bottom = (float)(map->height * TILE_SIZE) + 64.0f;
|
||||
if (self->body.pos.y > level_bottom) {
|
||||
self->health = 0;
|
||||
self->flags |= ENTITY_DEAD;
|
||||
pd->respawn_timer = 0.3f; /* shorter delay for pit death */
|
||||
return;
|
||||
/* Fall off bottom of level — lose 1 HP and auto-dash upward */
|
||||
float level_bottom = (float)(map->height * TILE_SIZE);
|
||||
if (self->body.pos.y + self->body.size.y > level_bottom &&
|
||||
!(self->flags & ENTITY_INVINCIBLE)) {
|
||||
self->health--;
|
||||
if (self->health <= 0) {
|
||||
/* Out of HP — die as before */
|
||||
self->health = 0;
|
||||
self->flags |= ENTITY_DEAD;
|
||||
pd->respawn_timer = 0.3f;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Drain all jetpack charges */
|
||||
pd->dash_charges = 0;
|
||||
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
|
||||
|
||||
/* Auto-trigger upward dash (ignores charge requirement) */
|
||||
pd->dash_timer = PLAYER_DASH_DURATION;
|
||||
pd->dash_dir = vec2(0.0f, -1.0f);
|
||||
self->body.vel.y = 0;
|
||||
|
||||
/* Grant invincibility so this doesn't re-trigger immediately */
|
||||
pd->inv_timer = PLAYER_INV_TIME;
|
||||
self->flags |= ENTITY_INVINCIBLE;
|
||||
|
||||
/* Effects */
|
||||
Vec2 burst_pos = vec2(
|
||||
self->body.pos.x + self->body.size.x * 0.5f,
|
||||
self->body.pos.y + self->body.size.y
|
||||
);
|
||||
particle_emit_jetpack_burst(burst_pos, vec2(0.0f, -1.0f));
|
||||
audio_play_sound(s_sfx_dash, 96);
|
||||
}
|
||||
|
||||
/* Update invincibility */
|
||||
@@ -225,14 +251,24 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
|
||||
|
||||
/* ── Dash / Jetpack ─────────────────────── */
|
||||
|
||||
/* Count down jetpack boost timer */
|
||||
if (pd->jetpack_boost_timer > 0) {
|
||||
pd->jetpack_boost_timer -= dt;
|
||||
if (pd->jetpack_boost_timer < 0)
|
||||
pd->jetpack_boost_timer = 0;
|
||||
}
|
||||
|
||||
/* Recharge jetpack charges over time */
|
||||
float recharge_rate = (pd->jetpack_boost_timer > 0)
|
||||
? PLAYER_JETPACK_BOOST_RECHARGE
|
||||
: PLAYER_DASH_RECHARGE;
|
||||
if (pd->dash_charges < pd->dash_max_charges) {
|
||||
pd->dash_recharge_timer -= dt;
|
||||
if (pd->dash_recharge_timer <= 0) {
|
||||
pd->dash_charges++;
|
||||
/* Reset timer for next charge (if still not full) */
|
||||
if (pd->dash_charges < pd->dash_max_charges) {
|
||||
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
|
||||
pd->dash_recharge_timer = recharge_rate;
|
||||
} else {
|
||||
pd->dash_recharge_timer = 0;
|
||||
}
|
||||
@@ -260,7 +296,8 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
|
||||
|
||||
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
|
||||
pd->dash_charges--;
|
||||
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
|
||||
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
|
||||
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
|
||||
pd->dash_timer = PLAYER_DASH_DURATION;
|
||||
|
||||
/* Determine dash direction from input */
|
||||
@@ -362,7 +399,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
|
||||
|
||||
/* ── Shooting ────────────────────────────── */
|
||||
pd->shoot_cooldown -= dt;
|
||||
if (input_pressed(ACTION_SHOOT) && pd->shoot_cooldown <= 0 && s_em) {
|
||||
if (pd->has_gun && input_pressed(ACTION_SHOOT) && pd->shoot_cooldown <= 0 && s_em) {
|
||||
pd->shoot_cooldown = PLAYER_SHOOT_COOLDOWN;
|
||||
|
||||
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
|
||||
@@ -486,7 +523,7 @@ void player_render(Entity *self, const Camera *cam) {
|
||||
renderer_submit(&spr);
|
||||
|
||||
/* ── Weapon overlay ─────────────────── */
|
||||
if (s_weapon_tex && !(self->flags & ENTITY_DEAD) && pd) {
|
||||
if (s_weapon_tex && !(self->flags & ENTITY_DEAD) && pd && pd->has_gun) {
|
||||
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
|
||||
|
||||
/* Anchor gun to the player sprite position (not body pos)
|
||||
@@ -574,6 +611,7 @@ Entity *player_spawn(EntityManager *em, Vec2 pos) {
|
||||
e->max_health = 3;
|
||||
|
||||
PlayerData *pd = calloc(1, sizeof(PlayerData));
|
||||
pd->has_gun = true; /* armed by default; moon level overrides */
|
||||
pd->dash_charges = PLAYER_DASH_MAX_CHARGES;
|
||||
pd->dash_max_charges = PLAYER_DASH_MAX_CHARGES;
|
||||
pd->respawn_timer = RESPAWN_DELAY;
|
||||
@@ -595,21 +633,36 @@ float player_get_look_up_offset(const Entity *self) {
|
||||
}
|
||||
|
||||
bool player_get_dash_charges(const Entity *self, int *charges, int *max_charges,
|
||||
float *recharge_pct) {
|
||||
float *recharge_pct, bool *boosted) {
|
||||
if (!self || !self->data || self->type != ENT_PLAYER) return false;
|
||||
const PlayerData *pd = (const PlayerData *)self->data;
|
||||
if (charges) *charges = pd->dash_charges;
|
||||
if (max_charges) *max_charges = pd->dash_max_charges;
|
||||
if (boosted) *boosted = pd->jetpack_boost_timer > 0;
|
||||
if (recharge_pct) {
|
||||
float rate = (pd->jetpack_boost_timer > 0)
|
||||
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
|
||||
if (pd->dash_charges >= pd->dash_max_charges) {
|
||||
*recharge_pct = 1.0f;
|
||||
} else {
|
||||
*recharge_pct = 1.0f - (pd->dash_recharge_timer / PLAYER_DASH_RECHARGE);
|
||||
*recharge_pct = 1.0f - (pd->dash_recharge_timer / rate);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void player_give_gun(Entity *self) {
|
||||
if (!self || !self->data || self->type != ENT_PLAYER) return;
|
||||
PlayerData *pd = (PlayerData *)self->data;
|
||||
pd->has_gun = true;
|
||||
}
|
||||
|
||||
bool player_has_gun(const Entity *self) {
|
||||
if (!self || !self->data || self->type != ENT_PLAYER) return false;
|
||||
const PlayerData *pd = (const PlayerData *)self->data;
|
||||
return pd->has_gun;
|
||||
}
|
||||
|
||||
bool player_wants_respawn(const Entity *self) {
|
||||
if (!self || !self->data || self->type != ENT_PLAYER) return false;
|
||||
if (!(self->flags & ENTITY_DEAD)) return false;
|
||||
@@ -631,7 +684,7 @@ void player_respawn(Entity *self, Vec2 pos) {
|
||||
pd->inv_timer = PLAYER_INV_TIME;
|
||||
self->flags |= ENTITY_INVINCIBLE;
|
||||
|
||||
/* Reset player-specific state */
|
||||
/* Reset player-specific state (preserve has_gun across respawn) */
|
||||
pd->coyote_timer = 0;
|
||||
pd->jump_buffer_timer = 0;
|
||||
pd->jumping = false;
|
||||
@@ -640,6 +693,7 @@ void player_respawn(Entity *self, Vec2 pos) {
|
||||
pd->dash_timer = 0;
|
||||
pd->dash_charges = pd->dash_max_charges;
|
||||
pd->dash_recharge_timer = 0;
|
||||
pd->jetpack_boost_timer = 0;
|
||||
pd->aim_dir = AIM_FORWARD;
|
||||
pd->looking_up = false;
|
||||
pd->look_up_timer = 0;
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
#define PLAYER_DASH_MAX_CHARGES 3 /* max jetpack charges */
|
||||
#define PLAYER_DASH_RECHARGE 3.0f /* seconds to recharge one charge*/
|
||||
|
||||
/* Jetpack boost (from powerup) */
|
||||
#define PLAYER_JETPACK_BOOST_DURATION 15.0f /* seconds the boost lasts */
|
||||
#define PLAYER_JETPACK_BOOST_RECHARGE 0.5f /* boosted recharge rate (s) */
|
||||
|
||||
/* Invincibility after taking damage */
|
||||
#define PLAYER_INV_TIME 1.5f /* seconds of invincibility */
|
||||
|
||||
@@ -56,6 +60,9 @@ typedef struct PlayerData {
|
||||
int dash_max_charges; /* max charges (for HUD) */
|
||||
float dash_recharge_timer; /* time until next charge restored*/
|
||||
Vec2 dash_dir; /* direction of current dash */
|
||||
float jetpack_boost_timer; /* remaining boost time (0=off) */
|
||||
/* Weapon */
|
||||
bool has_gun; /* false until gun powerup picked up */
|
||||
/* Aiming */
|
||||
AimDir aim_dir; /* current aim direction */
|
||||
bool looking_up; /* holding up without moving */
|
||||
@@ -84,7 +91,13 @@ float player_get_look_up_offset(const Entity *self);
|
||||
|
||||
/* Get jetpack dash charge info for HUD (returns false if entity is not player) */
|
||||
bool player_get_dash_charges(const Entity *self, int *charges, int *max_charges,
|
||||
float *recharge_pct);
|
||||
float *recharge_pct, bool *boosted);
|
||||
|
||||
/* Arm the player (give them the gun). Safe to call multiple times. */
|
||||
void player_give_gun(Entity *self);
|
||||
|
||||
/* Check if the player has a gun */
|
||||
bool player_has_gun(const Entity *self);
|
||||
|
||||
/* Check if the player is requesting a respawn (death anim finished + timer expired).
|
||||
* Returns true when respawn should occur. */
|
||||
|
||||
@@ -24,6 +24,12 @@ static void powerup_update(Entity *self, float dt, const Tilemap *map) {
|
||||
PowerupData *pd = (PowerupData *)self->data;
|
||||
if (!pd) return;
|
||||
|
||||
/* Destroy when picked up */
|
||||
if (self->flags & ENTITY_DEAD) {
|
||||
entity_destroy(s_em, self);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Sine bob animation */
|
||||
pd->bob_timer += dt;
|
||||
float bob = sinf(pd->bob_timer * BOB_SPEED * 2.0f * (float)M_PI) * BOB_HEIGHT;
|
||||
@@ -36,6 +42,7 @@ static void powerup_update(Entity *self, float dt, const Tilemap *map) {
|
||||
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;
|
||||
case POWERUP_GUN: color = (SDL_Color){200, 200, 220, 200}; break;
|
||||
default: color = (SDL_Color){255, 255, 255, 200}; break;
|
||||
}
|
||||
Vec2 center = vec2(
|
||||
@@ -123,6 +130,7 @@ Entity *powerup_spawn(EntityManager *em, Vec2 pos, PowerupKind 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;
|
||||
case POWERUP_GUN: animation_set(&e->anim, &anim_powerup_gun); break;
|
||||
default: animation_set(&e->anim, &anim_powerup_health); break;
|
||||
}
|
||||
|
||||
@@ -140,3 +148,7 @@ Entity *powerup_spawn_jetpack(EntityManager *em, Vec2 pos) {
|
||||
Entity *powerup_spawn_drone(EntityManager *em, Vec2 pos) {
|
||||
return powerup_spawn(em, pos, POWERUP_DRONE);
|
||||
}
|
||||
|
||||
Entity *powerup_spawn_gun(EntityManager *em, Vec2 pos) {
|
||||
return powerup_spawn(em, pos, POWERUP_GUN);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ typedef enum PowerupKind {
|
||||
POWERUP_HEALTH,
|
||||
POWERUP_JETPACK,
|
||||
POWERUP_DRONE,
|
||||
POWERUP_GUN,
|
||||
POWERUP_KIND_COUNT
|
||||
} PowerupKind;
|
||||
|
||||
@@ -36,5 +37,6 @@ Entity *powerup_spawn(EntityManager *em, Vec2 pos, PowerupKind kind);
|
||||
Entity *powerup_spawn_health(EntityManager *em, Vec2 pos);
|
||||
Entity *powerup_spawn_jetpack(EntityManager *em, Vec2 pos);
|
||||
Entity *powerup_spawn_drone(EntityManager *em, Vec2 pos);
|
||||
Entity *powerup_spawn_gun(EntityManager *em, Vec2 pos);
|
||||
|
||||
#endif /* JNR_POWERUP_H */
|
||||
|
||||
@@ -860,6 +860,90 @@ static const uint32_t drone_frame2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* ── Gun powerup sprite ────────────────────────────── */
|
||||
|
||||
/* Gun powerup — metallic gun icon (grey/cyan) */
|
||||
static const uint32_t powerup_gun1[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, GYD, GYD, GYD, GYD, GYD, GYD, GYD, T, T, T,
|
||||
T, T, T, T, GYD, GYD, GRY, GRY, GYL, GYL, GYL, GRY, GRY, GYD, T, T,
|
||||
T, T, T, GYD, GRY, GRY, GYL, WHT, WHT, WHT, GYL, GRY, GRY, GRY, GYD, T,
|
||||
T, T, T, GYD, GRY, GYL, WHT, WHT, WHT, WHT, GYL, GRY, GRY, GRY, GYD, T,
|
||||
T, T, T, T, GYD, GRY, GRY, GYL, GYL, GRY, GRY, GYD, GYD, GYD, T, T,
|
||||
T, T, T, T, T, T, GYD, GRY, GRY, GYD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, GYD, GYD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Gun powerup frame 2 — brighter glow */
|
||||
static const uint32_t powerup_gun2[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, GYL, GYL, GYL, GYL, GYL, GYL, GYD, T, T, T,
|
||||
T, T, T, GYD, GYD, GYL, WHT, WHT, WHT, WHT, WHT, GYL, GYL, GYD, T, T,
|
||||
T, T, T, GYD, GYL, WHT, WHT, WHT, WHT, WHT, WHT, GYL, GYL, GYL, GYD, T,
|
||||
T, T, T, GYD, GYL, WHT, WHT, WHT, WHT, WHT, WHT, GYL, GYL, GYL, GYD, T,
|
||||
T, T, T, T, GYD, GYL, GYL, WHT, WHT, GYL, GYL, GYD, GYD, GYD, T, T,
|
||||
T, T, T, T, T, T, GYD, GYL, GYL, GYD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, GYD, GYD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* ── Asteroid sprite ────────────────────────────────── */
|
||||
|
||||
/* Asteroid frame 1 — jagged rocky boulder */
|
||||
static const uint32_t asteroid1[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
|
||||
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
|
||||
T, T, T, BRN, BRD, GYD, GRY, GYD, GRY, GYD, BRD, BRN, T, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GRY, GYL, GRY, GRY, GYD, GYD, BRD, BRN, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GRY, GRY, GYD, GRY, GYL, GYD, BRD, BRN, T, T, T,
|
||||
T, T, BRD, BRD, GYD, GRY, GYD, BRD, GYD, GRY, GYD, BRD, BRD, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GYD, GRY, GYD, GRY, GYD, GYD, BRD, BRN, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GRY, GYL, GRY, GYD, GRY, GYD, BRD, BRN, T, T, T,
|
||||
T, T, T, BRN, BRD, GYD, GRY, GYD, GRY, GYD, BRD, BRN, T, T, T, T,
|
||||
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
|
||||
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Asteroid frame 2 — rotated highlights */
|
||||
static const uint32_t asteroid2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
|
||||
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
|
||||
T, T, T, BRN, BRD, GYD, GRY, GYD, GYL, GYD, BRD, BRN, T, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GRY, GYD, GRY, GRY, GYL, GYD, BRD, BRN, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GYD, GRY, GYD, GYL, GRY, GYD, BRD, BRN, T, T, T,
|
||||
T, T, BRD, BRD, GYD, GRY, GYD, BRD, GYD, GYD, GYD, BRD, BRD, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GYL, GRY, GYD, GRY, GYD, GYD, BRD, BRN, T, T, T,
|
||||
T, T, BRN, BRD, GYD, GRY, GRY, GYD, GYD, GRY, GYD, BRD, BRN, T, T, T,
|
||||
T, T, T, BRN, BRD, GYD, GYL, GYD, GRY, GYD, BRD, BRN, T, T, T, T,
|
||||
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
|
||||
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, 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 */
|
||||
@@ -926,9 +1010,13 @@ static const SpriteDef s_sprite_defs[] = {
|
||||
{5, 5, powerup_drone1},
|
||||
{5, 6, powerup_drone2},
|
||||
|
||||
/* Row 6: Drone companion */
|
||||
/* Row 6: Drone companion + Gun powerup + Asteroid */
|
||||
{6, 0, drone_frame1},
|
||||
{6, 1, drone_frame2},
|
||||
{6, 2, powerup_gun1},
|
||||
{6, 3, powerup_gun2},
|
||||
{6, 4, asteroid1},
|
||||
{6, 5, asteroid2},
|
||||
};
|
||||
|
||||
#define SHEET_COLS 8
|
||||
@@ -1115,6 +1203,18 @@ static AnimFrame s_powerup_drone_frames[] = {
|
||||
FRAME(6, 5, 0.3f),
|
||||
};
|
||||
|
||||
/* Gun powerup */
|
||||
static AnimFrame s_powerup_gun_frames[] = {
|
||||
FRAME(2, 6, 0.4f),
|
||||
FRAME(3, 6, 0.4f),
|
||||
};
|
||||
|
||||
/* Asteroid */
|
||||
static AnimFrame s_asteroid_frames[] = {
|
||||
FRAME(4, 6, 0.15f),
|
||||
FRAME(5, 6, 0.15f),
|
||||
};
|
||||
|
||||
/* Drone companion */
|
||||
static AnimFrame s_drone_frames[] = {
|
||||
FRAME(0, 6, 0.2f),
|
||||
@@ -1150,6 +1250,8 @@ AnimDef anim_force_field_off;
|
||||
AnimDef anim_powerup_health;
|
||||
AnimDef anim_powerup_jetpack;
|
||||
AnimDef anim_powerup_drone;
|
||||
AnimDef anim_powerup_gun;
|
||||
AnimDef anim_asteroid;
|
||||
|
||||
AnimDef anim_drone;
|
||||
|
||||
@@ -1182,6 +1284,8 @@ void sprites_init_anims(void) {
|
||||
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_powerup_gun = (AnimDef){s_powerup_gun_frames, 2, true, NULL};
|
||||
anim_asteroid = (AnimDef){s_asteroid_frames, 2, true, NULL};
|
||||
|
||||
anim_drone = (AnimDef){s_drone_frames, 2, true, NULL};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ extern AnimDef anim_force_field_off;
|
||||
extern AnimDef anim_powerup_health;
|
||||
extern AnimDef anim_powerup_jetpack;
|
||||
extern AnimDef anim_powerup_drone;
|
||||
extern AnimDef anim_powerup_gun;
|
||||
|
||||
/* ── Asteroid animation ─────────────────────────── */
|
||||
extern AnimDef anim_asteroid;
|
||||
|
||||
/* ── Drone animation ───────────────────────────── */
|
||||
extern AnimDef anim_drone;
|
||||
|
||||
Reference in New Issue
Block a user