Files
major_tom/src/game/editor.c

1621 lines
64 KiB
C

#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 <stdio.h>
#include <stdlib.h>
#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__ */
/* 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);
}
}