diff --git a/TODO.md b/TODO.md index 833f0bd..56e5d6e 100644 --- a/TODO.md +++ b/TODO.md @@ -11,15 +11,21 @@ with state machine (FLYING_IN → LANDING → LANDED → TAKEOFF → FLYING_OUT DONE), engine/synth sounds, thruster particles, level intro sequence with deferred player spawn. -## Large map support (5000x5000) -Audit the engine for anything that breaks or becomes slow at very large map -sizes. Key areas to check: -- Tile layer allocation (`uint16_t *` for 25M tiles) -- Tilemap rendering culling (already viewport-clipped, verify correctness) -- Physics / collision queries (should only check nearby tiles) -- Entity updates (currently iterates full pool regardless of distance) -- Camera bounds and coordinate overflow (float precision at large coords) -- Level file parsing (row lines could exceed fgets buffer at 5000+ columns) +## ~~Large map support (5000x5000)~~ ✓ +Audited and fixed all engine bottlenecks for maps up to 8192x8192: +- `MAX_MAP_SIZE` constant (8192) replaces hard-coded 4096 caps in tilemap + loader and editor resize. Tile layers allocate fine at 5000x5000 (~143 MB). +- Dynamic line reader in `tilemap_load()` replaces fixed 16 KB `fgets` buffer; + handles arbitrarily long tile rows without truncation. +- `MAX_ENTITY_SPAWNS` raised from 128 to 512; `MAX_EXIT_ZONES` from 8 to 16. +- Entity distance culling: `entity_update_all()` skips entities beyond 2× + screen width from the camera; `entity_render_all()` skips entities outside + the viewport + 64 px margin. Player, spacecraft, and drone use + `ENTITY_ALWAYS_UPDATE` flag to opt out of culling. +- Editor flood fill replaced with scanline algorithm — O(height) stack usage + instead of O(area), safe for very large maps. +- Verified: tilemap rendering already viewport-culled, physics queries are O(1) + local tile lookups, float precision fine up to ~1M pixels (62 K tiles). ## ~~Spacecraft at level exit~~ ✓ Implemented: `spacecraft_spawn_exit()` with `is_exit_ship` flag. Proximity diff --git a/include/config.h b/include/config.h index b655189..dbf95e0 100644 --- a/include/config.h +++ b/include/config.h @@ -19,13 +19,14 @@ /* ── Tiles ──────────────────────────────────────────── */ #define TILE_SIZE 16 /* pixels per tile */ #define MAX_TILE_DEFS 256 /* unique tile types */ +#define MAX_MAP_SIZE 8192 /* max width or height in tiles */ /* ── Entities ───────────────────────────────────────── */ #define MAX_ENTITIES 512 -#define MAX_ENTITY_SPAWNS 128 /* max entity spawns per level */ +#define MAX_ENTITY_SPAWNS 512 /* max entity spawns per level */ /* ── Level transitions ─────────────────────────────── */ -#define MAX_EXIT_ZONES 8 /* max exit zones per level */ +#define MAX_EXIT_ZONES 16 /* max exit zones per level */ /* ── Rendering ──────────────────────────────────────── */ #define MAX_SPRITES 2048 /* max queued sprites per frame */ diff --git a/src/engine/entity.c b/src/engine/entity.c index bfb21f9..475a030 100644 --- a/src/engine/entity.c +++ b/src/engine/entity.c @@ -1,7 +1,33 @@ #include "engine/entity.h" +#include "engine/camera.h" #include #include +/* ── Distance culling ──────────────────────────────── */ + +/* Margin around the viewport for update culling (in pixels). + * Entities within this margin still update even if off-screen, + * so they are active before scrolling into view. */ +#define UPDATE_MARGIN (SCREEN_WIDTH * 2.0f) + +/* Margin for render culling — tighter than update since only + * entities actually visible (plus a small buffer) need drawing. */ +#define RENDER_MARGIN 64.0f + +/* Check whether a body is within a rectangle expanded by margin. */ +static bool in_camera_range(const Body *body, const Camera *cam, float margin) { + float left = cam->pos.x - margin; + float top = cam->pos.y - margin; + float right = cam->pos.x + cam->viewport.x + margin; + float bottom = cam->pos.y + cam->viewport.y + margin; + + float ex = body->pos.x + body->size.x; + float ey = body->pos.y + body->size.y; + + return body->pos.x < right && ex > left && + body->pos.y < bottom && ey > top; +} + void entity_manager_init(EntityManager *em) { memset(em, 0, sizeof(EntityManager)); } @@ -43,7 +69,8 @@ void entity_destroy(EntityManager *em, Entity *e) { } } -void entity_update_all(EntityManager *em, float dt, const Tilemap *map) { +void entity_update_all(EntityManager *em, float dt, const Tilemap *map, + const Camera *cam) { for (int i = 0; i < em->count; i++) { Entity *e = &em->entities[i]; if (!e->active) continue; @@ -53,6 +80,14 @@ void entity_update_all(EntityManager *em, float dt, const Tilemap *map) { e->flags |= ENTITY_DEAD; } + /* Distance culling: skip far-away entities unless they opt out. + * Dead entities always get their update callback so type-specific + * cleanup (destroy, particles, etc.) can run. */ + if (cam && !(e->flags & (ENTITY_ALWAYS_UPDATE | ENTITY_DEAD))) { + if (!in_camera_range(&e->body, cam, UPDATE_MARGIN)) + continue; + } + /* Call type-specific update */ if (em->update_fn[e->type]) { em->update_fn[e->type](e, dt, map); @@ -65,6 +100,10 @@ void entity_render_all(EntityManager *em, const Camera *cam) { Entity *e = &em->entities[i]; if (!e->active) continue; + /* Render culling: skip entities outside the visible area. */ + if (cam && !in_camera_range(&e->body, cam, RENDER_MARGIN)) + continue; + if (em->render_fn[e->type]) { em->render_fn[e->type](e, cam); } diff --git a/src/engine/entity.h b/src/engine/entity.h index ff199eb..ad06bd0 100644 --- a/src/engine/entity.h +++ b/src/engine/entity.h @@ -32,9 +32,10 @@ typedef enum EntityType { } EntityType; /* Entity flags */ -#define ENTITY_INVINCIBLE (1 << 0) -#define ENTITY_FACING_LEFT (1 << 1) -#define ENTITY_DEAD (1 << 2) +#define ENTITY_INVINCIBLE (1 << 0) +#define ENTITY_FACING_LEFT (1 << 1) +#define ENTITY_DEAD (1 << 2) +#define ENTITY_ALWAYS_UPDATE (1 << 3) /* never culled by distance */ typedef struct Entity { EntityType type; @@ -66,7 +67,8 @@ typedef struct EntityManager { void entity_manager_init(EntityManager *em); Entity *entity_spawn(EntityManager *em, EntityType type, Vec2 pos); void entity_destroy(EntityManager *em, Entity *e); -void entity_update_all(EntityManager *em, float dt, const Tilemap *map); +void entity_update_all(EntityManager *em, float dt, const Tilemap *map, + const Camera *cam); void entity_render_all(EntityManager *em, const Camera *cam); void entity_manager_clear(EntityManager *em); diff --git a/src/engine/tilemap.c b/src/engine/tilemap.c index 6f01942..f38437d 100644 --- a/src/engine/tilemap.c +++ b/src/engine/tilemap.c @@ -6,6 +6,33 @@ #include #include +/* Read a full line from f into a dynamically growing buffer. + * *buf and *cap track the heap buffer; the caller must free *buf. + * Returns the line length, or -1 on EOF/error. */ +static int read_line(FILE *f, char **buf, int *cap) { + if (!*buf) { + *cap = 16384; + *buf = malloc(*cap); + if (!*buf) return -1; + } + int len = 0; + for (;;) { + if (len + 1 >= *cap) { + int new_cap = *cap * 2; + char *tmp = realloc(*buf, new_cap); + if (!tmp) return -1; + *buf = tmp; + *cap = new_cap; + } + int ch = fgetc(f); + if (ch == EOF) return len > 0 ? len : -1; + (*buf)[len++] = (char)ch; + if (ch == '\n') break; + } + (*buf)[len] = '\0'; + return len; +} + bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) { FILE *f = fopen(path, "r"); if (!f) { @@ -15,12 +42,13 @@ bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) { memset(map, 0, sizeof(Tilemap)); - char line[16384]; - char tileset_path[256] = {0}; - int current_layer = -1; /* 0=collision, 1=bg, 2=fg */ - int row = 0; + char *line = NULL; + int line_cap = 0; + char tileset_path[256] = {0}; + int current_layer = -1; /* 0=collision, 1=bg, 2=fg */ + int row = 0; - while (fgets(line, sizeof(line), f)) { + while (read_line(f, &line, &line_cap) >= 0) { /* Skip comments and empty lines */ if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue; @@ -30,8 +58,9 @@ bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) { } else if (strncmp(line, "SIZE ", 5) == 0) { sscanf(line + 5, "%d %d", &map->width, &map->height); if (map->width <= 0 || map->height <= 0 || - map->width > 4096 || map->height > 4096) { + map->width > MAX_MAP_SIZE || map->height > MAX_MAP_SIZE) { fprintf(stderr, "Invalid map size: %dx%d\n", map->width, map->height); + free(line); fclose(f); return false; } @@ -39,6 +68,17 @@ bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) { map->collision_layer = calloc(total, sizeof(uint16_t)); map->bg_layer = calloc(total, sizeof(uint16_t)); map->fg_layer = calloc(total, sizeof(uint16_t)); + if (!map->collision_layer || !map->bg_layer || !map->fg_layer) { + fprintf(stderr, "Failed to allocate tile layers (%dx%d)\n", + map->width, map->height); + free(map->collision_layer); + free(map->bg_layer); + free(map->fg_layer); + memset(map, 0, sizeof(Tilemap)); + free(line); + fclose(f); + return false; + } } else if (strncmp(line, "SPAWN ", 6) == 0) { float sx, sy; sscanf(line + 6, "%f %f", &sx, &sy); @@ -132,6 +172,7 @@ bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) { } } } + free(line); fclose(f); /* Load tileset texture */ diff --git a/src/game/drone.c b/src/game/drone.c index 3e15751..32bfc1d 100644 --- a/src/game/drone.c +++ b/src/game/drone.c @@ -199,7 +199,7 @@ Entity *drone_spawn(EntityManager *em, Vec2 player_pos) { e->health = 99; /* practically invulnerable */ e->max_health = 99; e->damage = 0; - e->flags |= ENTITY_INVINCIBLE; + e->flags |= ENTITY_INVINCIBLE | ENTITY_ALWAYS_UPDATE; DroneData *dd = calloc(1, sizeof(DroneData)); dd->orbit_angle = 0.0f; diff --git a/src/game/editor.c b/src/game/editor.c index 9294c66..c54f23e 100644 --- a/src/game/editor.c +++ b/src/game/editor.c @@ -279,36 +279,82 @@ static void set_tile(Tilemap *map, uint16_t *layer, int tx, int ty, uint16_t id) } /* ═══════════════════════════════════════════════════ - * Flood fill (iterative, stack-based) + * Flood fill (scanline algorithm) + * + * Processes entire horizontal spans at once, then + * pushes only the boundary seeds above and below. + * Much more memory-efficient than naive 4-neighbor + * fill on large maps (O(height) stack vs O(area)). * ═══════════════════════════════════════════════════ */ -typedef struct FillNode { int x, y; } FillNode; +typedef struct FillSpan { int x_left, x_right, y, dir; } FillSpan; 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)); + FillSpan *stack = malloc(capacity * sizeof(FillSpan)); + if (!stack) return; int top = 0; - stack[top++] = (FillNode){sx, sy}; + + /* Fill the initial span containing (sx, sy). */ + int xl = sx, xr = sx; + while (xl > 0 && get_tile(map, layer, xl - 1, sy) == old_id) xl--; + while (xr < map->width - 1 && get_tile(map, layer, xr + 1, sy) == old_id) xr++; + for (int x = xl; x <= xr; x++) set_tile(map, layer, x, sy, new_id); + + /* Seed rows above and below. */ + stack[top++] = (FillSpan){xl, xr, sy, -1}; + stack[top++] = (FillSpan){xl, xr, sy, 1}; 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; + FillSpan s = stack[--top]; + int ny = s.y + s.dir; + if (ny < 0 || ny >= map->height) continue; - set_tile(map, layer, n.x, n.y, new_id); + /* Scan the row for sub-spans that match old_id and are seeded + * by the parent span [x_left, x_right]. */ + int x = s.x_left; + while (x <= s.x_right) { + /* Skip non-matching tiles. */ + if (get_tile(map, layer, x, ny) != old_id) { x++; continue; } - /* Grow stack if needed */ - if (top + 4 >= capacity) { - capacity *= 2; - stack = realloc(stack, capacity * sizeof(FillNode)); + /* Found a matching run; expand it fully. */ + int run_l = x; + while (run_l > 0 && get_tile(map, layer, run_l - 1, ny) == old_id) run_l--; + int run_r = x; + while (run_r < map->width - 1 && get_tile(map, layer, run_r + 1, ny) == old_id) run_r++; + + /* Fill this run. */ + for (int fx = run_l; fx <= run_r; fx++) set_tile(map, layer, fx, ny, new_id); + + /* Grow stack if needed. */ + if (top + 2 >= capacity) { + capacity *= 2; + FillSpan *tmp = realloc(stack, capacity * sizeof(FillSpan)); + if (!tmp) { fprintf(stderr, "Warning: flood fill out of memory\n"); free(stack); return; } + stack = tmp; + } + + /* Continue in the same direction. */ + stack[top++] = (FillSpan){run_l, run_r, ny, s.dir}; + /* Also seed the opposite direction for portions that extend + * beyond the parent span (leak-around fills). */ + if (run_l < s.x_left) + stack[top++] = (FillSpan){run_l, s.x_left - 1, ny, -s.dir}; + if (run_r > s.x_right) { + if (top + 1 >= capacity) { + capacity *= 2; + FillSpan *tmp = realloc(stack, capacity * sizeof(FillSpan)); + if (!tmp) { fprintf(stderr, "Warning: flood fill out of memory\n"); free(stack); return; } + stack = tmp; + } + stack[top++] = (FillSpan){s.x_right + 1, run_r, ny, -s.dir}; + } + + x = run_r + 1; } - 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); @@ -430,8 +476,8 @@ static void resize_layer(uint16_t **layer, int old_w, int old_h, int new_w, int 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; + if (new_w > MAX_MAP_SIZE) new_w = MAX_MAP_SIZE; + if (new_h > MAX_MAP_SIZE) new_h = MAX_MAP_SIZE; int old_w = ed->map.width; int old_h = ed->map.height; diff --git a/src/game/hazards.c b/src/game/hazards.c index ba540f1..c6dedab 100644 --- a/src/game/hazards.c +++ b/src/game/hazards.c @@ -269,7 +269,7 @@ Entity *mplat_spawn_dir(EntityManager *em, Vec2 pos, Vec2 dir) { e->body.gravity_scale = 0.0f; e->health = 9999; /* indestructible */ e->max_health = 9999; - e->flags |= ENTITY_INVINCIBLE; + e->flags |= ENTITY_INVINCIBLE | ENTITY_ALWAYS_UPDATE; e->damage = 0; MovingPlatData *md = calloc(1, sizeof(MovingPlatData)); diff --git a/src/game/level.c b/src/game/level.c index 9d8a609..1333f20 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -526,7 +526,7 @@ void level_update(Level *level, float dt) { } /* Update all entities */ - entity_update_all(&level->entities, dt, &level->map); + entity_update_all(&level->entities, dt, &level->map, &level->camera); /* Handle collisions (only meaningful once player exists) */ if (level->player_spawned) { diff --git a/src/game/player.c b/src/game/player.c index 7b42500..53ae4fb 100644 --- a/src/game/player.c +++ b/src/game/player.c @@ -632,6 +632,7 @@ Entity *player_spawn(EntityManager *em, Vec2 pos) { e->body.gravity_scale = 1.0f; e->health = 3; e->max_health = 3; + e->flags |= ENTITY_ALWAYS_UPDATE; PlayerData *pd = calloc(1, sizeof(PlayerData)); pd->has_gun = true; /* armed by default; moon level overrides */ diff --git a/src/game/spacecraft.c b/src/game/spacecraft.c index 539c4d4..8abf240 100644 --- a/src/game/spacecraft.c +++ b/src/game/spacecraft.c @@ -326,7 +326,7 @@ Entity *spacecraft_spawn(EntityManager *em, Vec2 land_pos) { e->health = 999; e->max_health = 999; e->damage = 0; - e->flags |= ENTITY_INVINCIBLE; + e->flags |= ENTITY_INVINCIBLE | ENTITY_ALWAYS_UPDATE; SpacecraftData *sd = calloc(1, sizeof(SpacecraftData)); if (!sd) { @@ -369,7 +369,7 @@ Entity *spacecraft_spawn_exit(EntityManager *em, Vec2 land_pos) { e->health = 999; e->max_health = 999; e->damage = 0; - e->flags |= ENTITY_INVINCIBLE; + e->flags |= ENTITY_INVINCIBLE | ENTITY_ALWAYS_UPDATE; SpacecraftData *sd = calloc(1, sizeof(SpacecraftData)); if (!sd) {