#include "game/editor.h" #include "game/entity_registry.h" #include "game/transition.h" #include "engine/core.h" #include "engine/input.h" #include "engine/renderer.h" #include "engine/assets.h" #include "engine/camera.h" #include "engine/font.h" #include "config.h" #include #include #include #include #ifdef __EMSCRIPTEN__ #include /* ═══════════════════════════════════════════════════ * Browser file I/O helpers (Emscripten only) * * Save: write .lvl to virtual FS, then trigger * browser download via Blob URL. * Load: open an 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__ */ /* Bitmap font provided by engine/font.h (included above). */ /* ═══════════════════════════════════════════════════ * 8x8 pixel mini-icons for the entity palette * * Each icon is 8 pixels wide, 8 pixels tall. * Stored as 8 rows of 8 bits packed in a uint64_t. * Row 0 is the top row, stored in bits 56-63. * Bit 7 of each byte = leftmost pixel. * ═══════════════════════════════════════════════════ */ #define ICON_SIZE 8 static const uint64_t s_icon_bitmaps[ICON_COUNT] = { /* ICON_GRUNT: spiky creature */ 0x5A3C7EDB7E3C5A00ULL, /* ICON_FLYER: bat with wings */ 0x0018187E7E241800ULL, /* ICON_TURRET: mounted cannon */ 0x0010107C7C381000ULL, /* ICON_PLATFORM: horizontal bar */ 0x000000007E7E0000ULL, /* ICON_PLATFORM_V: vertical bar */ 0x0018181818180000ULL, /* ICON_FLAME: fire */ 0x0010385454381000ULL, /* ICON_FORCEFIELD: electric bars */ 0x2424FF2424FF2424ULL, /* ICON_HEART: classic heart shape */ 0x006C7E7E3E1C0800ULL, /* ICON_BOLT: lightning bolt */ 0x0C1C18387060C000ULL, /* ICON_FUEL: fuel canister */ 0x003C4242423C1800ULL, /* ICON_DRONE: quad-rotor */ 0x00427E3C3C7E4200ULL, /* ICON_GUN: pistol shape */ 0x00007C7C10100000ULL, /* ICON_ASTEROID: jagged rock */ 0x001C3E7F7F3E1C00ULL, /* ICON_SPACECRAFT: ship */ 0x0018183C7E7E2400ULL, /* ICON_LASER: laser turret (box with beam line) */ 0x003C3C18187E0000ULL, /* ICON_LASER_TRACK: tracking laser (box with rotating beam) */ 0x003C3C1818660000ULL, /* ICON_CHARGER: arrow/charging creature */ 0x0018187E7E181800ULL, /* ICON_SPAWNER: pulsing core with dots */ 0x24003C3C3C002400ULL, }; static void draw_icon(SDL_Renderer *r, EditorIcon icon, int x, int y, SDL_Color col) { if (icon < 0 || icon >= ICON_COUNT) return; uint64_t bits = s_icon_bitmaps[icon]; SDL_SetRenderDrawColor(r, col.r, col.g, col.b, col.a); for (int row = 0; row < ICON_SIZE; row++) { int byte = (int)((bits >> (56 - row * 8)) & 0xFF); for (int col_bit = 0; col_bit < ICON_SIZE; col_bit++) { if (byte & (1 << (7 - col_bit))) { SDL_RenderDrawPoint(r, x + col_bit, y + row); } } } } /* ═══════════════════════════════════════════════════ * Constants * ═══════════════════════════════════════════════════ */ #define CAM_PAN_SPEED 200.0f #define ZOOM_MIN 0.25f #define ZOOM_MAX 2.0f #define ZOOM_STEP 0.25f /* ═══════════════════════════════════════════════════ * Available tilesets (cycle with T key) * ═══════════════════════════════════════════════════ */ static const char *s_tilesets[] = { "assets/tiles/tileset.png", "assets/tiles/moon_tileset.png", }; #define TILESET_COUNT ((int)(sizeof(s_tilesets) / sizeof(s_tilesets[0]))) static const SDL_Color COL_BG = {30, 30, 46, 255}; 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}; static const SDL_Color COL_HIGHLIGHT= {255, 200, 60, 255}; static const SDL_Color COL_GRID = {60, 60, 80, 80}; static const SDL_Color COL_SPAWN = {60, 255, 60, 255}; static const SDL_Color COL_ENTITY = {255, 100, 100, 255}; static const SDL_Color COL_SELECT = {255, 255, 100, 255}; 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", "EXIT" }; /* Layer names */ static const char *s_layer_names[EDITOR_LAYER_COUNT] = { "COL", "BG", "FG" }; /* ═══════════════════════════════════════════════════ * Layer access helpers * ═══════════════════════════════════════════════════ */ static uint16_t *get_layer(Tilemap *map, EditorLayer layer) { switch (layer) { case EDITOR_LAYER_COLLISION: return map->collision_layer; case EDITOR_LAYER_BG: return map->bg_layer; case EDITOR_LAYER_FG: return map->fg_layer; default: return map->collision_layer; } } static uint16_t get_tile(const Tilemap *map, const uint16_t *layer, int tx, int ty) { if (tx < 0 || tx >= map->width || ty < 0 || ty >= map->height) return 0; return layer[ty * map->width + tx]; } static void set_tile(Tilemap *map, uint16_t *layer, int tx, int ty, uint16_t id) { if (tx < 0 || tx >= map->width || ty < 0 || ty >= map->height) return; layer[ty * map->width + tx] = id; } /* ═══════════════════════════════════════════════════ * Flood fill (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 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; FillSpan *stack = malloc(capacity * sizeof(FillSpan)); if (!stack) return; int top = 0; /* 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) { FillSpan s = stack[--top]; int ny = s.y + s.dir; if (ny < 0 || ny >= map->height) continue; /* 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; } /* 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; } } free(stack); } /* ═══════════════════════════════════════════════════ * Tilemap save (reuses .lvl format) * ═══════════════════════════════════════════════════ */ static bool save_tilemap(const Tilemap *map, const char *path) { FILE *f = fopen(path, "w"); if (!f) { fprintf(stderr, "editor: failed to write %s\n", path); return false; } fprintf(f, "# Level created with in-game editor\n\n"); /* Write the tileset path stored in the map, falling back to default */ fprintf(f, "TILESET %s\n", map->tileset_path[0] ? map->tileset_path : "assets/tiles/tileset.png"); fprintf(f, "SIZE %d %d\n", map->width, map->height); /* Player spawn in tile coords */ int stx = (int)(map->player_spawn.x / TILE_SIZE); int sty = (int)(map->player_spawn.y / TILE_SIZE); fprintf(f, "SPAWN %d %d\n", stx, sty); if (map->gravity > 0) fprintf(f, "GRAVITY %.0f\n", map->gravity); if (map->wind != 0.0f) fprintf(f, "WIND %.0f\n", map->wind); if (map->has_bg_color) fprintf(f, "BG_COLOR %d %d %d\n", map->bg_color.r, map->bg_color.g, map->bg_color.b); if (map->music_path[0]) fprintf(f, "MUSIC %s\n", map->music_path); if (map->parallax_far_path[0]) fprintf(f, "PARALLAX_FAR %s\n", map->parallax_far_path); if (map->parallax_near_path[0]) fprintf(f, "PARALLAX_NEAR %s\n", map->parallax_near_path); if (map->parallax_style > 0) fprintf(f, "PARALLAX_STYLE %d\n", map->parallax_style); if (map->player_unarmed) fprintf(f, "PLAYER_UNARMED\n"); /* Transition styles */ if (map->transition_in != TRANS_NONE) { fprintf(f, "TRANSITION_IN %s\n", transition_style_name(map->transition_in)); } if (map->transition_out != TRANS_NONE) { fprintf(f, "TRANSITION_OUT %s\n", transition_style_name(map->transition_out)); } fprintf(f, "\n"); /* Entity spawns */ for (int i = 0; i < map->entity_spawn_count; i++) { const EntitySpawn *es = &map->entity_spawns[i]; int tx = (int)(es->x / TILE_SIZE); int ty = (int)(es->y / TILE_SIZE); fprintf(f, "ENTITY %s %d %d\n", es->type_name, tx, ty); } fprintf(f, "\n"); /* 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 — save all entries so flags and tex coords survive * round-trips, including tiles at position (0,0) with no flags. */ for (int id = 1; id < map->tile_def_count; id++) { const TileDef *td = &map->tile_defs[id]; fprintf(f, "TILEDEF %d %d %d %u\n", id, td->tex_x, td->tex_y, td->flags); } fprintf(f, "\n"); /* Write each layer */ const char *layer_names[] = {"collision", "bg", "fg"}; const uint16_t *layers[] = {map->collision_layer, map->bg_layer, map->fg_layer}; for (int l = 0; l < 3; l++) { /* Skip empty layers */ bool has_data = false; for (int i = 0; i < map->width * map->height; i++) { if (layers[l][i]) { has_data = true; break; } } if (!has_data && l != 0) continue; /* always write collision */ fprintf(f, "LAYER %s\n", layer_names[l]); for (int y = 0; y < map->height; y++) { for (int x = 0; x < map->width; x++) { if (x > 0) fprintf(f, " "); fprintf(f, "%d", layers[l][y * map->width + x]); } fprintf(f, "\n"); } fprintf(f, "\n"); } fclose(f); printf("editor: saved to %s\n", path); return true; } /* ═══════════════════════════════════════════════════ * Editor resize * ═══════════════════════════════════════════════════ */ static void resize_layer(uint16_t **layer, int old_w, int old_h, int new_w, int new_h) { uint16_t *new_data = calloc(new_w * new_h, sizeof(uint16_t)); int copy_w = old_w < new_w ? old_w : new_w; int copy_h = old_h < new_h ? old_h : new_h; for (int y = 0; y < copy_h; y++) { for (int x = 0; x < copy_w; x++) { new_data[y * new_w + x] = (*layer)[y * old_w + x]; } } free(*layer); *layer = new_data; } static void editor_resize(Editor *ed, int new_w, int new_h) { if (new_w < 10) new_w = 10; if (new_h < 10) new_h = 10; if (new_w > 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; resize_layer(&ed->map.collision_layer, old_w, old_h, new_w, new_h); resize_layer(&ed->map.bg_layer, old_w, old_h, new_w, new_h); resize_layer(&ed->map.fg_layer, old_w, old_h, new_w, new_h); ed->map.width = new_w; ed->map.height = new_h; ed->dirty = true; camera_set_bounds(&ed->camera, (float)(new_w * TILE_SIZE), (float)(new_h * TILE_SIZE)); } /* ═══════════════════════════════════════════════════ * Discover tileset dimensions * ═══════════════════════════════════════════════════ */ static void discover_tileset(Editor *ed) { if (!ed->map.tileset) { ed->tileset_cols = 0; ed->tileset_rows = 0; ed->tileset_total = 0; return; } int tex_w, tex_h; SDL_QueryTexture(ed->map.tileset, NULL, NULL, &tex_w, &tex_h); ed->tileset_cols = tex_w / TILE_SIZE; ed->tileset_rows = tex_h / TILE_SIZE; ed->tileset_total = ed->tileset_cols * ed->tileset_rows; } /* ═══════════════════════════════════════════════════ * Tileset switching * ═══════════════════════════════════════════════════ */ /* Find current tileset index in the table, or -1 */ static int find_tileset_index(const char *path) { for (int i = 0; i < TILESET_COUNT; i++) { if (strcmp(path, s_tilesets[i]) == 0) return i; } return -1; } /* Switch the editor to a different tileset by path */ static void editor_switch_tileset(Editor *ed, const char *path) { SDL_Texture *tex = assets_get_texture(path); if (!tex) { fprintf(stderr, "editor: cannot load tileset %s\n", path); return; } snprintf(ed->map.tileset_path, sizeof(ed->map.tileset_path), "%s", path); ed->map.tileset = tex; int tex_w; SDL_QueryTexture(tex, NULL, NULL, &tex_w, NULL); ed->map.tileset_cols = tex_w / TILE_SIZE; discover_tileset(ed); ed->tile_palette_scroll = 0; ed->dirty = true; } /* Cycle to the next tileset in the table. * If the current tileset is not in the list, cycle to the first entry. */ static void editor_cycle_tileset(Editor *ed) { int idx = find_tileset_index(ed->map.tileset_path); int next = (idx < 0) ? 0 : (idx + 1) % TILESET_COUNT; editor_switch_tileset(ed, s_tilesets[next]); printf("editor: tileset -> %s\n", s_tilesets[next]); } /* ── Tile flag helpers ───────────────────────────── */ /* Ensure a tile ID has a TileDef entry. Creates one if missing. */ static void ensure_tile_def(Tilemap *map, uint16_t tile_id) { if (tile_id == 0 || tile_id >= MAX_TILE_DEFS) return; if (tile_id < map->tile_def_count) return; /* Fill gaps with default entries (tex pos from grid, no flags) */ int cols = map->tileset_cols > 0 ? map->tileset_cols : 16; for (int id = map->tile_def_count; id <= tile_id; id++) { map->tile_defs[id].tex_x = (uint16_t)((id - 1) % cols); map->tile_defs[id].tex_y = (uint16_t)((id - 1) / cols); map->tile_defs[id].flags = 0; } map->tile_def_count = tile_id + 1; } /* Cycle the flag on the currently selected tile: none -> solid -> platform -> hazard -> none */ static void editor_cycle_tile_flag(Editor *ed) { uint16_t id = ed->selected_tile; if (id == 0 || id >= MAX_TILE_DEFS) return; ensure_tile_def(&ed->map, id); uint32_t cur = ed->map.tile_defs[id].flags; /* Cycle: 0 -> SOLID -> PLATFORM -> HAZARD -> 0 */ if (cur & TILE_HAZARD) ed->map.tile_defs[id].flags = 0; else if (cur & TILE_PLATFORM) ed->map.tile_defs[id].flags = TILE_HAZARD; else if (cur & TILE_SOLID) ed->map.tile_defs[id].flags = TILE_PLATFORM; else ed->map.tile_defs[id].flags = TILE_SOLID; ed->dirty = true; } /* Get display name for the flags of the selected tile */ static const char *tile_flag_name(uint32_t flags) { if (flags & TILE_HAZARD) return "HAZARD"; if (flags & TILE_PLATFORM) return "PLATFORM"; if (flags & TILE_SOLID) return "SOLID"; return "NONE"; } /* ═══════════════════════════════════════════════════ * Lifecycle * ═══════════════════════════════════════════════════ */ void editor_init(Editor *ed) { memset(ed, 0, sizeof(Editor)); ed->tool = TOOL_PENCIL; ed->active_layer = EDITOR_LAYER_COLLISION; ed->selected_tile = 1; ed->selected_entity = 0; ed->show_grid = true; ed->show_all_layers = true; ed->dragging_entity = -1; ed->active = true; camera_init(&ed->camera, SCREEN_WIDTH, SCREEN_HEIGHT); /* Ensure entity registry is populated for the palette. * If we came from gameplay it is already initialized; * if the editor was launched directly it will be empty. */ if (g_entity_registry.count == 0) { entity_registry_populate(); } } void editor_new_level(Editor *ed, int width, int height) { /* Free any existing map */ tilemap_free(&ed->map); memset(&ed->map, 0, sizeof(Tilemap)); ed->map.width = width; ed->map.height = height; int total = width * height; ed->map.collision_layer = calloc(total, sizeof(uint16_t)); ed->map.bg_layer = calloc(total, sizeof(uint16_t)); ed->map.fg_layer = calloc(total, sizeof(uint16_t)); /* Default tile defs (same as levelgen) */ ed->map.tile_defs[1] = (TileDef){0, 0, TILE_SOLID}; ed->map.tile_defs[2] = (TileDef){1, 0, TILE_SOLID}; ed->map.tile_defs[3] = (TileDef){2, 0, TILE_SOLID}; ed->map.tile_defs[4] = (TileDef){0, 1, TILE_PLATFORM}; ed->map.tile_def_count = 5; /* Default spawn */ ed->map.player_spawn = vec2(3.0f * TILE_SIZE, (height - 4) * TILE_SIZE); ed->map.gravity = DEFAULT_GRAVITY; /* Load tileset */ snprintf(ed->map.tileset_path, sizeof(ed->map.tileset_path), "%s", "assets/tiles/tileset.png"); ed->map.tileset = assets_get_texture(ed->map.tileset_path); if (ed->map.tileset) { int tex_w; SDL_QueryTexture(ed->map.tileset, NULL, NULL, &tex_w, NULL); ed->map.tileset_cols = tex_w / TILE_SIZE; } discover_tileset(ed); camera_init(&ed->camera, SCREEN_WIDTH, SCREEN_HEIGHT); camera_set_bounds(&ed->camera, (float)(width * TILE_SIZE), (float)(height * TILE_SIZE)); ed->has_file = false; ed->dirty = false; ed->file_path[0] = '\0'; printf("editor: new level %dx%d\n", width, height); } bool editor_load(Editor *ed, const char *path) { tilemap_free(&ed->map); memset(&ed->map, 0, sizeof(Tilemap)); if (!tilemap_load(&ed->map, path, g_engine.renderer)) { return false; } discover_tileset(ed); camera_init(&ed->camera, SCREEN_WIDTH, SCREEN_HEIGHT); camera_set_bounds(&ed->camera, (float)(ed->map.width * TILE_SIZE), (float)(ed->map.height * TILE_SIZE)); strncpy(ed->file_path, path, sizeof(ed->file_path) - 1); ed->has_file = true; ed->dirty = false; printf("editor: loaded %s\n", path); return true; } bool editor_save(Editor *ed) { if (!ed->has_file) { /* Default path */ strncpy(ed->file_path, "assets/levels/edited.lvl", sizeof(ed->file_path) - 1); ed->has_file = true; } if (save_tilemap(&ed->map, ed->file_path)) { ed->dirty = false; #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; } bool editor_save_as(Editor *ed, const char *path) { strncpy(ed->file_path, path, sizeof(ed->file_path) - 1); ed->has_file = true; return editor_save(ed); } void editor_free(Editor *ed) { tilemap_free(&ed->map); ed->active = false; } /* ═══════════════════════════════════════════════════ * Coordinate helpers * ═══════════════════════════════════════════════════ */ /* Is the screen position inside the canvas area (not on UI panels)? */ static bool in_canvas(int sx, int sy) { return sx < (SCREEN_WIDTH - EDITOR_PALETTE_W) && sy >= EDITOR_TOOLBAR_H && sy < (SCREEN_HEIGHT - EDITOR_STATUS_H); } /* Screen position to world tile coordinates */ static void screen_to_tile(const Editor *ed, int sx, int sy, int *tx, int *ty) { Vec2 world = camera_screen_to_world(&ed->camera, vec2((float)sx, (float)sy)); *tx = world_to_tile(world.x); *ty = world_to_tile(world.y); } /* ═══════════════════════════════════════════════════ * Entity helpers * ═══════════════════════════════════════════════════ */ /* Find entity spawn at a tile position, return index or -1 */ static int find_entity_at(const Tilemap *map, float wx, float wy) { for (int i = 0; i < map->entity_spawn_count; i++) { const EntitySpawn *es = &map->entity_spawns[i]; const EntityRegEntry *reg = entity_registry_find(es->type_name); float ew = reg ? (float)reg->width : TILE_SIZE; float eh = reg ? (float)reg->height : TILE_SIZE; if (wx >= es->x && wx < es->x + ew && wy >= es->y && wy < es->y + eh) { return i; } } return -1; } static void add_entity_spawn(Tilemap *map, const char *name, float wx, float wy) { if (map->entity_spawn_count >= MAX_ENTITY_SPAWNS) return; EntitySpawn *es = &map->entity_spawns[map->entity_spawn_count]; strncpy(es->type_name, name, 31); es->type_name[31] = '\0'; /* Snap to tile grid */ es->x = floorf(wx / TILE_SIZE) * TILE_SIZE; es->y = floorf(wy / TILE_SIZE) * TILE_SIZE; map->entity_spawn_count++; } static void remove_entity_spawn(Tilemap *map, int index) { if (index < 0 || index >= map->entity_spawn_count) return; /* Shift remaining entries */ for (int i = index; i < map->entity_spawn_count - 1; i++) { map->entity_spawns[i] = map->entity_spawns[i + 1]; } map->entity_spawn_count--; } /* ── 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 * ═══════════════════════════════════════════════════ */ static bool s_wants_test_play = false; static bool s_wants_quit = false; bool editor_wants_test_play(Editor *ed) { (void)ed; bool v = s_wants_test_play; s_wants_test_play = false; return v; } bool editor_wants_quit(Editor *ed) { (void)ed; bool v = s_wants_quit; s_wants_quit = false; return v; } /* ═══════════════════════════════════════════════════ * Update — input handling * ═══════════════════════════════════════════════════ */ void editor_update(Editor *ed, float dt) { int mx, my; input_mouse_pos(&mx, &my); /* ── Keyboard shortcuts ────────────────── */ /* Tool selection: 1-5 */ if (input_key_pressed(SDL_SCANCODE_1)) ed->tool = TOOL_PENCIL; if (input_key_pressed(SDL_SCANCODE_2)) ed->tool = TOOL_ERASER; if (input_key_pressed(SDL_SCANCODE_3)) ed->tool = TOOL_FILL; if (input_key_pressed(SDL_SCANCODE_4)) ed->tool = TOOL_ENTITY; if (input_key_pressed(SDL_SCANCODE_5)) ed->tool = TOOL_SPAWN; 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; if (input_key_pressed(SDL_SCANCODE_W)) ed->active_layer = EDITOR_LAYER_BG; if (input_key_pressed(SDL_SCANCODE_E) && !input_key_held(SDL_SCANCODE_LCTRL)) ed->active_layer = EDITOR_LAYER_FG; /* Grid toggle: G */ if (input_key_pressed(SDL_SCANCODE_G)) ed->show_grid = !ed->show_grid; /* Layer visibility toggle: V */ if (input_key_pressed(SDL_SCANCODE_V)) ed->show_all_layers = !ed->show_all_layers; /* Tileset cycle: T */ if (input_key_pressed(SDL_SCANCODE_T) && !input_key_held(SDL_SCANCODE_LCTRL)) editor_cycle_tileset(ed); /* Tile flag cycle: F (solid/platform/hazard/none) */ if (input_key_pressed(SDL_SCANCODE_F) && !input_key_held(SDL_SCANCODE_LCTRL)) editor_cycle_tile_flag(ed); /* Save: Ctrl+S */ if (input_key_pressed(SDL_SCANCODE_S) && input_key_held(SDL_SCANCODE_LCTRL)) { 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; } /* Quit editor: Escape */ if (input_key_pressed(SDL_SCANCODE_ESCAPE)) { s_wants_quit = true; } /* Resize: Ctrl+Shift+Plus/Minus for height, Ctrl+Plus/Minus for width */ if (input_key_held(SDL_SCANCODE_LCTRL)) { if (input_key_held(SDL_SCANCODE_LSHIFT)) { /* Height resize takes priority when Shift is also held */ if (input_key_pressed(SDL_SCANCODE_EQUALS)) editor_resize(ed, ed->map.width, ed->map.height + 5); if (input_key_pressed(SDL_SCANCODE_MINUS)) editor_resize(ed, ed->map.width, ed->map.height - 5); } else { /* Width resize only when Shift is NOT held */ if (input_key_pressed(SDL_SCANCODE_EQUALS)) editor_resize(ed, ed->map.width + 10, ed->map.height); if (input_key_pressed(SDL_SCANCODE_MINUS)) editor_resize(ed, ed->map.width - 10, ed->map.height); } } /* ── Camera panning (arrow keys or WASD with no ctrl) ── */ if (!input_key_held(SDL_SCANCODE_LCTRL)) { float pan_speed = CAM_PAN_SPEED * dt; /* Faster when zoomed out */ if (ed->camera.zoom > 0.0f) pan_speed /= ed->camera.zoom; if (input_key_held(SDL_SCANCODE_LEFT) || input_key_held(SDL_SCANCODE_A)) ed->camera.pos.x -= pan_speed; if (input_key_held(SDL_SCANCODE_RIGHT) || input_key_held(SDL_SCANCODE_D)) ed->camera.pos.x += pan_speed; if (input_key_held(SDL_SCANCODE_UP)) ed->camera.pos.y -= pan_speed; if (input_key_held(SDL_SCANCODE_DOWN) || input_key_held(SDL_SCANCODE_S)) ed->camera.pos.y += pan_speed; } /* Middle mouse drag panning */ { static int last_mx = 0, last_my = 0; static bool dragging_cam = false; if (input_mouse_held(MOUSE_MIDDLE)) { if (!dragging_cam) { dragging_cam = true; last_mx = mx; last_my = my; } else { float inv_zoom = (ed->camera.zoom > 0.0f) ? (1.0f / ed->camera.zoom) : 1.0f; ed->camera.pos.x -= (float)(mx - last_mx) * inv_zoom; ed->camera.pos.y -= (float)(my - last_my) * inv_zoom; last_mx = mx; last_my = my; } } else { dragging_cam = false; } } /* ── Zoom (scroll wheel) — only when mouse is over the canvas ── */ int scroll = input_mouse_scroll(); 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)); float old_zoom = ed->camera.zoom; ed->camera.zoom += (float)scroll * ZOOM_STEP; if (ed->camera.zoom < ZOOM_MIN) ed->camera.zoom = ZOOM_MIN; if (ed->camera.zoom > ZOOM_MAX) ed->camera.zoom = ZOOM_MAX; /* Zoom toward mouse position: keep the world point under the * mouse cursor in the same screen position after the zoom. */ if (ed->camera.zoom != old_zoom) { float new_zoom = ed->camera.zoom; ed->camera.pos.x = mouse_world.x - (float)mx / new_zoom; ed->camera.pos.y = mouse_world.y - (float)my / new_zoom; } } /* Clamp camera */ float vp_w = ed->camera.viewport.x; float vp_h = ed->camera.viewport.y; if (ed->camera.zoom > 0.0f) { vp_w /= ed->camera.zoom; vp_h /= ed->camera.zoom; } float max_x = ed->camera.bounds_max.x - vp_w; float max_y = ed->camera.bounds_max.y - vp_h; if (ed->camera.pos.x < ed->camera.bounds_min.x - vp_w * 0.5f) ed->camera.pos.x = ed->camera.bounds_min.x - vp_w * 0.5f; if (ed->camera.pos.y < ed->camera.bounds_min.y - vp_h * 0.5f) ed->camera.pos.y = ed->camera.bounds_min.y - vp_h * 0.5f; if (max_x > 0 && ed->camera.pos.x > max_x + vp_w * 0.5f) ed->camera.pos.x = max_x + vp_w * 0.5f; if (max_y > 0 && ed->camera.pos.y > max_y + vp_h * 0.5f) ed->camera.pos.y = max_y + vp_h * 0.5f; /* ── Right palette click ── */ if (mx >= SCREEN_WIDTH - EDITOR_PALETTE_W && my >= EDITOR_TOOLBAR_H && my < SCREEN_HEIGHT - EDITOR_STATUS_H) { 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 - 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); 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; } } } if (scroll != 0) { ed->tile_palette_scroll -= scroll; if (ed->tile_palette_scroll < 0) ed->tile_palette_scroll = 0; } } else { /* Entity palette area */ if (input_mouse_pressed(MOUSE_LEFT)) { int pal_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) { ed->entity_palette_scroll -= scroll; if (ed->entity_palette_scroll < 0) ed->entity_palette_scroll = 0; } } return; /* Don't process canvas clicks when on palette */ } /* ── Toolbar click ── */ if (my < EDITOR_TOOLBAR_H) { if (input_mouse_pressed(MOUSE_LEFT)) { /* Tool buttons: each 35px wide starting at x=2 */ int btn = (mx - 2) / 35; if (btn >= 0 && btn < TOOL_COUNT) { ed->tool = (EditorTool)btn; } /* Layer buttons after separator */ int sep_x = TOOL_COUNT * 35 + 8; int lx = mx - sep_x; if (lx >= 0) { int lbtn = lx / 25; if (lbtn >= 0 && lbtn < EDITOR_LAYER_COUNT) { ed->active_layer = (EditorLayer)lbtn; } } } return; } /* ── Canvas interaction ── */ if (!in_canvas(mx, my)) return; int tile_x, tile_y; screen_to_tile(ed, mx, my, &tile_x, &tile_y); Vec2 world_pos = camera_screen_to_world(&ed->camera, vec2((float)mx, (float)my)); switch (ed->tool) { case TOOL_PENCIL: if (input_mouse_held(MOUSE_LEFT)) { uint16_t *layer = get_layer(&ed->map, ed->active_layer); /* Ensure tile has an explicit TileDef so it does not fall * through to the "unknown tile = solid" default. */ ensure_tile_def(&ed->map, ed->selected_tile); set_tile(&ed->map, layer, tile_x, tile_y, ed->selected_tile); ed->dirty = true; } if (input_mouse_held(MOUSE_RIGHT)) { /* Pick tile under cursor */ uint16_t *layer = get_layer(&ed->map, ed->active_layer); uint16_t t = get_tile(&ed->map, layer, tile_x, tile_y); if (t != 0) ed->selected_tile = t; } break; case TOOL_ERASER: if (input_mouse_held(MOUSE_LEFT)) { uint16_t *layer = get_layer(&ed->map, ed->active_layer); set_tile(&ed->map, layer, tile_x, tile_y, 0); ed->dirty = true; } break; case TOOL_FILL: if (input_mouse_pressed(MOUSE_LEFT)) { uint16_t *layer = get_layer(&ed->map, ed->active_layer); ensure_tile_def(&ed->map, ed->selected_tile); flood_fill(&ed->map, layer, tile_x, tile_y, ed->selected_tile); ed->dirty = true; } if (input_mouse_pressed(MOUSE_RIGHT)) { uint16_t *layer = get_layer(&ed->map, ed->active_layer); flood_fill(&ed->map, layer, tile_x, tile_y, 0); ed->dirty = true; } break; case TOOL_ENTITY: if (input_mouse_pressed(MOUSE_LEFT)) { /* Check if clicking an existing entity */ int eidx = find_entity_at(&ed->map, world_pos.x, world_pos.y); if (eidx >= 0) { /* Start dragging */ ed->dragging_entity = eidx; ed->drag_offset_x = ed->map.entity_spawns[eidx].x - world_pos.x; ed->drag_offset_y = ed->map.entity_spawns[eidx].y - world_pos.y; } else { /* Place new entity */ if (ed->selected_entity >= 0 && ed->selected_entity < g_entity_registry.count) { add_entity_spawn(&ed->map, g_entity_registry.entries[ed->selected_entity].name, world_pos.x, world_pos.y); ed->dirty = true; } } } if (input_mouse_held(MOUSE_LEFT) && ed->dragging_entity >= 0) { /* Drag entity — snap to grid */ float nx = floorf((world_pos.x + ed->drag_offset_x) / TILE_SIZE) * TILE_SIZE; float ny = floorf((world_pos.y + ed->drag_offset_y) / TILE_SIZE) * TILE_SIZE; ed->map.entity_spawns[ed->dragging_entity].x = nx; ed->map.entity_spawns[ed->dragging_entity].y = ny; ed->dirty = true; } if (input_mouse_released(MOUSE_LEFT)) { ed->dragging_entity = -1; } /* Right-click to delete entity */ if (input_mouse_pressed(MOUSE_RIGHT)) { int eidx = find_entity_at(&ed->map, world_pos.x, world_pos.y); if (eidx >= 0) { remove_entity_spawn(&ed->map, eidx); ed->dirty = true; } } break; case TOOL_SPAWN: if (input_mouse_pressed(MOUSE_LEFT)) { ed->map.player_spawn = vec2( floorf(world_pos.x / TILE_SIZE) * TILE_SIZE, floorf(world_pos.y / TILE_SIZE) * TILE_SIZE ); ed->dirty = true; } break; 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; } } /* ═══════════════════════════════════════════════════ * Render * ═══════════════════════════════════════════════════ */ void editor_render(Editor *ed, float interpolation) { (void)interpolation; SDL_Renderer *r = g_engine.renderer; Camera *cam = &ed->camera; /* ── Clear background ── */ SDL_SetRenderDrawColor(r, COL_BG.r, COL_BG.g, COL_BG.b, 255); SDL_RenderClear(r); /* ── Render tile layers ── */ if (ed->show_all_layers) { /* Draw all layers, dim inactive ones */ for (int l = 0; l < EDITOR_LAYER_COUNT; l++) { uint16_t *layer = get_layer(&ed->map, (EditorLayer)l); if (l != (int)ed->active_layer) { SDL_SetTextureAlphaMod(ed->map.tileset, 80); } else { SDL_SetTextureAlphaMod(ed->map.tileset, 255); } tilemap_render_layer(&ed->map, layer, cam, r); } SDL_SetTextureAlphaMod(ed->map.tileset, 255); } else { /* Only active layer */ uint16_t *layer = get_layer(&ed->map, ed->active_layer); tilemap_render_layer(&ed->map, layer, cam, r); } /* ── Grid ── */ if (ed->show_grid) { SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(r, COL_GRID.r, COL_GRID.g, COL_GRID.b, COL_GRID.a); float inv_zoom = (cam->zoom > 0.0f) ? (1.0f / cam->zoom) : 1.0f; int start_tx = (int)floorf(cam->pos.x / TILE_SIZE); int start_ty = (int)floorf(cam->pos.y / TILE_SIZE); int end_tx = start_tx + (int)(cam->viewport.x * inv_zoom / TILE_SIZE) + 2; int end_ty = start_ty + (int)(cam->viewport.y * inv_zoom / TILE_SIZE) + 2; if (start_tx < 0) start_tx = 0; if (start_ty < 0) start_ty = 0; if (end_tx > ed->map.width) end_tx = ed->map.width; if (end_ty > ed->map.height) end_ty = ed->map.height; /* Vertical lines */ for (int x = start_tx; x <= end_tx; x++) { Vec2 top = camera_world_to_screen(cam, vec2(tile_to_world(x), tile_to_world(start_ty))); Vec2 bot = camera_world_to_screen(cam, vec2(tile_to_world(x), tile_to_world(end_ty))); SDL_RenderDrawLine(r, (int)top.x, (int)top.y, (int)bot.x, (int)bot.y); } /* Horizontal lines */ for (int y = start_ty; y <= end_ty; y++) { Vec2 left = camera_world_to_screen(cam, vec2(tile_to_world(start_tx), tile_to_world(y))); Vec2 right_pt = camera_world_to_screen(cam, vec2(tile_to_world(end_tx), tile_to_world(y))); SDL_RenderDrawLine(r, (int)left.x, (int)left.y, (int)right_pt.x, (int)right_pt.y); } SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); } /* ── Hazard tile overlay (collision layer) ── */ if (ed->map.collision_layer) { float inv_zoom = (cam->zoom > 0.0f) ? (1.0f / cam->zoom) : 1.0f; int hs_tx = (int)floorf(cam->pos.x / TILE_SIZE) - 1; int hs_ty = (int)floorf(cam->pos.y / TILE_SIZE) - 1; int he_tx = hs_tx + (int)(cam->viewport.x * inv_zoom / TILE_SIZE) + 3; int he_ty = hs_ty + (int)(cam->viewport.y * inv_zoom / TILE_SIZE) + 3; if (hs_tx < 0) hs_tx = 0; if (hs_ty < 0) hs_ty = 0; if (he_tx > ed->map.width) he_tx = ed->map.width; if (he_ty > ed->map.height) he_ty = ed->map.height; SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); for (int y = hs_ty; y < he_ty; y++) { for (int x = hs_tx; x < he_tx; x++) { uint16_t tid = ed->map.collision_layer[y * ed->map.width + x]; if (tid == 0 || tid >= ed->map.tile_def_count) continue; if (!(ed->map.tile_defs[tid].flags & TILE_HAZARD)) continue; Vec2 sp = camera_world_to_screen(cam, vec2(tile_to_world(x), tile_to_world(y))); float zs = TILE_SIZE * cam->zoom; SDL_SetRenderDrawColor(r, 255, 60, 30, 60); SDL_Rect hr = {(int)sp.x, (int)sp.y, (int)(zs + 0.5f), (int)(zs + 0.5f)}; SDL_RenderFillRect(r, &hr); } } SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); } /* ── Level boundary ── */ { SDL_SetRenderDrawColor(r, 200, 200, 255, 120); SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); Vec2 tl = camera_world_to_screen(cam, vec2(0, 0)); Vec2 br = camera_world_to_screen(cam, vec2((float)(ed->map.width * TILE_SIZE), (float)(ed->map.height * TILE_SIZE))); SDL_Rect border = {(int)tl.x, (int)tl.y, (int)(br.x - tl.x), (int)(br.y - tl.y)}; SDL_RenderDrawRect(r, &border); SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); } /* ── Entity spawn markers ── */ for (int i = 0; i < ed->map.entity_spawn_count; i++) { const EntitySpawn *es = &ed->map.entity_spawns[i]; const EntityRegEntry *reg = entity_registry_find(es->type_name); SDL_Color col = reg ? reg->color : COL_ENTITY; float ew = reg ? (float)reg->width : TILE_SIZE; float eh = reg ? (float)reg->height : TILE_SIZE; Vec2 sp = camera_world_to_screen(cam, vec2(es->x, es->y)); float zw = ew * cam->zoom; float zh = eh * cam->zoom; SDL_SetRenderDrawColor(r, col.r, col.g, col.b, 180); SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); SDL_Rect er = {(int)sp.x, (int)sp.y, (int)(zw + 0.5f), (int)(zh + 0.5f)}; SDL_RenderFillRect(r, &er); SDL_SetRenderDrawColor(r, 255, 255, 255, 200); SDL_RenderDrawRect(r, &er); SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); /* Draw icon or first letter of entity name */ if (reg && zw >= ICON_SIZE + 2 && reg->icon >= 0 && reg->icon < ICON_COUNT) { draw_icon(r, (EditorIcon)reg->icon, (int)sp.x + 1, (int)sp.y + 1, COL_TEXT); } else if (reg && reg->display[0] && zw >= 6) { font_draw_char(r, reg->display[0], (int)sp.x + 1, (int)sp.y + 1, COL_TEXT); } } /* ── Player spawn marker ── */ { Vec2 sp = camera_world_to_screen(cam, ed->map.player_spawn); float zs = TILE_SIZE * cam->zoom; SDL_SetRenderDrawColor(r, COL_SPAWN.r, COL_SPAWN.g, COL_SPAWN.b, 200); SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); SDL_Rect sr = {(int)sp.x, (int)sp.y, (int)(zs + 0.5f), (int)(zs + 0.5f)}; SDL_RenderDrawRect(r, &sr); font_draw_text(r, "SP", (int)sp.x + 1, (int)sp.y + 1, COL_SPAWN); 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); font_draw_text(r, "EXIT", (int)sp.x + 1, (int)sp.y + 1, COL_EXIT); if (ez->target[0]) { font_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; input_mouse_pos(&mx_c, &my_c); if (in_canvas(mx_c, my_c)) { int tx, ty; screen_to_tile(ed, mx_c, my_c, &tx, &ty); if (tx >= 0 && tx < ed->map.width && ty >= 0 && ty < ed->map.height) { Vec2 cpos = camera_world_to_screen(cam, vec2(tile_to_world(tx), tile_to_world(ty))); float zs = TILE_SIZE * cam->zoom; /* 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 * cw + 0.5f), (int)(zs * ch + 0.5f)}; SDL_RenderDrawRect(r, &cr); SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE); } } } /* ═════════════════════════════════════════ * UI Panels (drawn in screen space) * ═════════════════════════════════════════ */ /* ── Top toolbar ── */ { SDL_SetRenderDrawColor(r, COL_PANEL.r, COL_PANEL.g, COL_PANEL.b, COL_PANEL.a); SDL_Rect tb = {0, 0, SCREEN_WIDTH, EDITOR_TOOLBAR_H}; SDL_RenderFillRect(r, &tb); /* 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 * 35 + 2; SDL_Color tc = (i == (int)ed->tool) ? COL_HIGHLIGHT : COL_TEXT_DIM; font_draw_text(r, s_tool_names[i], bx, text_y, tc); } /* Separator */ int sep_x = TOOL_COUNT * 35 + 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 = layer_start + i * 25; SDL_Color lc = (i == (int)ed->active_layer) ? COL_HIGHLIGHT : COL_TEXT_DIM; font_draw_text(r, s_layer_names[i], bx, text_y, lc); } /* Grid & Layers indicators */ int grid_x = layer_start + EDITOR_LAYER_COUNT * 25 + 4; font_draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", grid_x, text_y, ed->show_grid ? COL_TEXT : COL_TEXT_DIM); /* Tileset switch hint */ int ts_x = grid_x + 7 * (FONT_W + 1) + 4; font_draw_text(r, "[T]SET", ts_x, text_y, COL_TEXT_DIM); } /* ── Right palette panel ── */ { int px = SCREEN_WIDTH - EDITOR_PALETTE_W; int py = EDITOR_TOOLBAR_H; int ph = SCREEN_HEIGHT - EDITOR_TOOLBAR_H - EDITOR_STATUS_H; SDL_SetRenderDrawColor(r, COL_PANEL.r, COL_PANEL.g, COL_PANEL.b, COL_PANEL.a); SDL_Rect panel = {px, py, EDITOR_PALETTE_W, ph}; SDL_RenderFillRect(r, &panel); /* 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); /* Split: tiles top ~55%, entities bottom ~45% */ int tile_section_h = (ph * 55) / 100; int ent_section_y = py + tile_section_h; /* ── Tile palette (top section) ── */ { int label_h = FONT_H + 6; /* label area: font + padding */ /* Show tileset filename (strip directory) */ { const char *ts_name = strrchr(ed->map.tileset_path, '/'); ts_name = ts_name ? ts_name + 1 : ed->map.tileset_path; font_draw_text(r, ts_name[0] ? ts_name : "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) { 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++) { int col_idx = (id - 1) % max_cols; int row_idx = (id - 1) / max_cols; int draw_x = px + 2 + col_idx * (TILE_SIZE + 1); int draw_y = pal_y_start + row_idx * (TILE_SIZE + 1) - ed->tile_palette_scroll * (TILE_SIZE + 1); if (draw_y + TILE_SIZE < pal_y_start || draw_y > ent_section_y) continue; /* Use TileDef tex coords when available, else grid position */ int src_col, src_row; if (id < ed->map.tile_def_count) { src_col = ed->map.tile_defs[id].tex_x; src_row = ed->map.tile_defs[id].tex_y; } else { src_col = (id - 1) % ed->tileset_cols; src_row = (id - 1) / ed->tileset_cols; } SDL_Rect src = { src_col * TILE_SIZE, src_row * TILE_SIZE, TILE_SIZE, TILE_SIZE }; SDL_Rect dst = {draw_x, draw_y, TILE_SIZE, TILE_SIZE}; SDL_RenderCopy(r, ed->map.tileset, &src, &dst); /* Flag indicator: colored dot for solid/platform/hazard */ if (id < ed->map.tile_def_count) { uint32_t fl = ed->map.tile_defs[id].flags; if (fl & TILE_HAZARD) { SDL_SetRenderDrawColor(r, 255, 80, 40, 255); SDL_Rect dot = {draw_x + TILE_SIZE - 3, draw_y, 3, 3}; SDL_RenderFillRect(r, &dot); } else if (fl & TILE_PLATFORM) { SDL_SetRenderDrawColor(r, 80, 200, 255, 255); SDL_Rect dot = {draw_x + TILE_SIZE - 3, draw_y, 3, 3}; SDL_RenderFillRect(r, &dot); } else if (fl & TILE_SOLID) { SDL_SetRenderDrawColor(r, 200, 200, 200, 180); SDL_Rect dot = {draw_x + TILE_SIZE - 3, draw_y, 3, 3}; SDL_RenderFillRect(r, &dot); } } /* Highlight selected */ if (id == (int)ed->selected_tile) { SDL_SetRenderDrawColor(r, COL_HIGHLIGHT.r, COL_HIGHLIGHT.g, COL_HIGHLIGHT.b, 255); SDL_Rect sel = {draw_x - 1, draw_y - 1, TILE_SIZE + 2, TILE_SIZE + 2}; SDL_RenderDrawRect(r, &sel); } } SDL_RenderSetClipRect(r, NULL); } /* Show selected tile's flag below palette */ if (ed->selected_tile > 0 && ed->selected_tile < MAX_TILE_DEFS) { uint32_t flags = 0; if (ed->selected_tile < ed->map.tile_def_count) flags = ed->map.tile_defs[ed->selected_tile].flags; const char *fname = tile_flag_name(flags); SDL_Color fc = (flags & TILE_HAZARD) ? (SDL_Color){255, 80, 40, 255} : (flags & TILE_PLATFORM) ? (SDL_Color){80, 200, 255, 255} : (flags & TILE_SOLID) ? COL_TEXT : COL_TEXT_DIM; font_draw_text(r, fname, px + 2, ent_section_y - FONT_H - 2, fc); /* Show [F] hint */ int fw = (int)strlen(fname) * (FONT_W + 1); font_draw_text(r, "[F]", px + 2 + fw + 2, ent_section_y - FONT_H - 2, COL_TEXT_DIM); } } /* ── 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; font_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++) { const EntityRegEntry *ent = &g_entity_registry.entries[i]; int ey = pal_y_start + i * 12 - ed->entity_palette_scroll * 12; if (ey + 10 < pal_y_start || ey > py + ph) continue; /* Mini-icon (or color swatch fallback) */ if (ent->icon >= 0 && ent->icon < ICON_COUNT) { draw_icon(r, (EditorIcon)ent->icon, px + 2, ey + 1, ent->color); } else { SDL_SetRenderDrawColor(r, ent->color.r, ent->color.g, ent->color.b, 255); SDL_Rect swatch = {px + 2, ey + 1, 8, 8}; SDL_RenderFillRect(r, &swatch); } /* Name */ SDL_Color nc = (i == ed->selected_entity) ? COL_HIGHLIGHT : COL_TEXT; font_draw_text(r, ent->display, px + 13, ey + 2, nc); } SDL_RenderSetClipRect(r, NULL); } } /* ── Bottom status bar ── */ { int sy = SCREEN_HEIGHT - EDITOR_STATUS_H; SDL_SetRenderDrawColor(r, COL_PANEL.r, COL_PANEL.g, COL_PANEL.b, COL_PANEL.a); SDL_Rect sb = {0, sy, SCREEN_WIDTH, EDITOR_STATUS_H}; SDL_RenderFillRect(r, &sb); /* Cursor position */ int mx_s, my_s; input_mouse_pos(&mx_s, &my_s); int tx = 0, ty = 0; screen_to_tile(ed, mx_s, my_s, &tx, &ty); char status[512]; snprintf(status, sizeof(status), "%dx%d (%d,%d) Z:%.0f%% %s%s", ed->map.width, ed->map.height, tx, ty, ed->camera.zoom * 100.0f, ed->has_file ? ed->file_path : "new level", ed->dirty ? " *" : ""); font_draw_text(r, status, 2, sy + (EDITOR_STATUS_H - FONT_H) / 2, COL_TEXT); } }