-- sporel-module-map-editor v0.1.0 -- Interactive map editor — see docs/superpowers/specs/2026-05-23-sporel-module-map-editor-design.md -- -- All editor logic in this single file: engine sandbox only allows -- require() for declared lib-deps; multi-file modules either use -- dofile (causes state divergence for shared singletons) or inline -- everything (vagrant-skeleton pattern). Inlined here via IIFE-wrapped -- local module tables. local maps = require("lib-core.maps") local camera = require("lib-core.camera") local input = require("lib-core.input") -- ===================================================================== -- state (was src/state.lua) -- ===================================================================== local state = (function() local M = {} -- ===================================================================== -- State (module-singleton) -- ===================================================================== local state = { map_id = nil, -- set by init.lua after maps.load() map_path = "maps/work.map.json", active_layer = "surface", -- one of VALID_LAYER_NAMES active_atlas = 0, -- atlas_index 0..N-1 active_tile = 1, -- tile_id active_rot = 0, -- 0..3 roof_mode = false, -- if true, paint targets roof layer_visible = { foundation=true, subsurface=true, surface=true, topsurface=true, lower_wall=true, wall=true, upper_wall=true, canopy=true, }, dirty = false, picker_open = false, -- atlas+tile picker expanded? mouse_cell = nil, -- {x, y} or nil if mouse off-map } -- ===================================================================== -- Setters / getters (encapsulate state for testability) -- ===================================================================== function M.set_map(map_id, path) state.map_id = map_id state.map_path = path end function M.get_map_id() return state.map_id end function M.get_map_path() return state.map_path end function M.set_active_layer(name) state.active_layer = name end function M.get_active_layer() return state.active_layer end function M.set_active_atlas(idx) state.active_atlas = idx end function M.get_active_atlas() return state.active_atlas end function M.set_active_tile(id) state.active_tile = id end function M.get_active_tile() return state.active_tile end function M.cycle_rotation() state.active_rot = (state.active_rot + 1) % 4 end function M.get_active_rot() return state.active_rot end function M.toggle_roof_mode() state.roof_mode = not state.roof_mode end function M.is_roof_mode() return state.roof_mode end function M.toggle_layer_visible(name) state.layer_visible[name] = not state.layer_visible[name] end function M.is_layer_visible(name) return state.layer_visible[name] ~= false end function M.mark_dirty() state.dirty = true end function M.mark_clean() state.dirty = false end function M.is_dirty() return state.dirty end function M.toggle_picker_open() state.picker_open = not state.picker_open end function M.is_picker_open() return state.picker_open end function M.set_mouse_cell(x, y) if x == nil then state.mouse_cell = nil else state.mouse_cell = { x = x, y = y } end end function M.get_mouse_cell() return state.mouse_cell end -- Used by tests to reset between asserts function M.reset() state.map_id = nil state.active_layer = "surface" state.active_atlas = 0 state.active_tile = 1 state.active_rot = 0 state.roof_mode = false state.dirty = false state.picker_open = false state.mouse_cell = nil for name in pairs(state.layer_visible) do state.layer_visible[name] = true end end return M end)() -- ===================================================================== -- actions (was src/actions.lua) -- ===================================================================== local actions = (function() local M = {} -- ===================================================================== -- Cell painting -- ===================================================================== function M.paint_cell_at(cell_x, cell_y) if state.is_roof_mode() then maps.set_roof(cell_x, cell_y, 1) else local gid = maps.encode_gid( state.get_active_atlas(), state.get_active_tile(), state.get_active_rot() ) maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, gid) end state.mark_dirty() end function M.erase_cell_at(cell_x, cell_y) if state.is_roof_mode() then maps.set_roof(cell_x, cell_y, 0) else maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, 0) end state.mark_dirty() end -- ===================================================================== -- Hotkey-driven actions -- ===================================================================== function M.cycle_rotation() state.cycle_rotation() end function M.save() local id = state.get_map_id() local path = state.get_map_path() if not id or not path then engine.print("map-editor: cannot save — no map loaded") return end maps.save_to_disk(id, path) state.mark_clean() engine.print(string.format("map-editor: saved '%s' to %s", id, path)) end -- ===================================================================== -- UI-driven actions (called by ui.lua hit-tests) -- ===================================================================== function M.set_active_layer(layer_name) state.set_active_layer(layer_name) end function M.toggle_layer_visible(layer_name) state.toggle_layer_visible(layer_name) end function M.toggle_roof_mode() state.toggle_roof_mode() end function M.set_active_atlas(atlas_idx) state.set_active_atlas(atlas_idx) -- Reset to first tile of the new atlas state.set_active_tile(1) end function M.set_active_tile(tile_id) state.set_active_tile(tile_id) end function M.toggle_picker() state.toggle_picker_open() end return M end)() -- ===================================================================== -- ui (was src/ui.lua) -- ===================================================================== local ui = (function() local M = {} -- ===================================================================== -- Constants -- ===================================================================== local CHIP_SIZE = 32 -- layer chip W/H in pixels local CHIP_MARGIN = 4 -- gap between chips local CHIP_ALPHA_ACTIVE = 0xFF local CHIP_ALPHA_INACTIVE = 0x78 -- 120/255 local CHIP_ALPHA_HOVER = 0xC8 -- 200/255 local PICKER_COLLAPSED_W = 80 local PICKER_COLLAPSED_H = 80 local PICKER_EXPANDED_W = 320 local PICKER_EXPANDED_H = 240 local PICKER_MARGIN = 8 local TILE_THUMB_SIZE = 48 local STATUS_W = 80 local STATUS_H = 24 local STATUS_MARGIN = 8 -- Layer order (top of screen = top of z-stack). Roof above with divider. local LAYER_CHIPS = { { name="roof_toggle", label="Rf", is_roof=true }, { name="canopy", label="Ca" }, { name="upper_wall", label="Uw" }, { name="wall", label="Wa" }, { name="lower_wall", label="Lw" }, -- entity-slot divider rendered between Lw and Ts { name="topsurface", label="Ts" }, { name="surface", label="Sf" }, { name="subsurface", label="Sb" }, { name="foundation", label="Fo" }, } local COLOR_CHIP_BG = 0x202020 -- RGB (alpha added at draw) local COLOR_CHIP_BG_ACT = 0xE0E0E0 local COLOR_CHIP_TEXT = 0xFFFFFF local COLOR_CHIP_TEXT_ACT = 0x202020 local COLOR_CHIP_BORDER = 0xFFFFFF local COLOR_DIVIDER = 0xFFFFFF local COLOR_STRIKE = 0xFF4040 -- hidden-layer overlay line local COLOR_PICKER_BG = 0x303030 local COLOR_STATUS_BG = 0x303030 local COLOR_STATUS_TEXT = 0xFFFFFF -- ===================================================================== -- Helpers -- ===================================================================== local function rgba(rgb, alpha) return (rgb << 8) | (alpha & 0xFF) end local function in_rect(mx, my, x, y, w, h) return mx >= x and mx < x + w and my >= y and my < y + h end -- Layer-chip layout: vertical stack on the left edge, starting at y_top. -- Returns: array of { name, label, is_roof, x, y, w, h, is_divider_after? } local function chip_layout() local out = {} local x = CHIP_MARGIN local y = CHIP_MARGIN for i, chip in ipairs(LAYER_CHIPS) do local entry = { name=chip.name, label=chip.label, is_roof=chip.is_roof, x=x, y=y, w=CHIP_SIZE, h=CHIP_SIZE, } -- Roof-Toggle gets a thicker gap below it if chip.is_roof then entry.gap_after = CHIP_SIZE / 2 elseif chip.name == "lower_wall" then entry.gap_after = CHIP_SIZE / 2 -- entity-slot divider entry.divider_after = true else entry.gap_after = CHIP_MARGIN end out[i] = entry y = y + entry.h + entry.gap_after end return out end -- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right. local function picker_layout() local screen_w, screen_h = engine.window.size() if state.is_picker_open() then return { x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, -- right of layer-chips y = screen_h - PICKER_EXPANDED_H - PICKER_MARGIN, w = PICKER_EXPANDED_W, h = PICKER_EXPANDED_H, } else return { x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, y = screen_h - PICKER_COLLAPSED_H - PICKER_MARGIN, w = PICKER_COLLAPSED_W, h = PICKER_COLLAPSED_H, } end end local function status_layout() local screen_w, screen_h = engine.window.size() return { x = screen_w - STATUS_W - STATUS_MARGIN, y = screen_h - STATUS_H - STATUS_MARGIN, w = STATUS_W, h = STATUS_H, } end -- ===================================================================== -- Drawing -- ===================================================================== local function draw_chip(chip, mx, my) -- State decisions local is_active if chip.is_roof then is_active = state.is_roof_mode() else is_active = (state.get_active_layer() == chip.name) end local is_hover = in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) local alpha if is_active then alpha = CHIP_ALPHA_ACTIVE elseif is_hover then alpha = CHIP_ALPHA_HOVER else alpha = CHIP_ALPHA_INACTIVE end local bg_color = is_active and rgba(COLOR_CHIP_BG_ACT, alpha) or rgba(COLOR_CHIP_BG, alpha) local text_color = is_active and rgba(COLOR_CHIP_TEXT_ACT, 0xFF) or rgba(COLOR_CHIP_TEXT, 0xFF) -- Background engine.render.draw_rect(chip.x, chip.y, chip.w, chip.h, bg_color) -- Border (active only) -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) if is_active then engine.render.draw_rect_lines(chip.x, chip.y, chip.w, chip.h, rgba(COLOR_CHIP_BORDER, 0xFF)) end -- Label text — centered (approximate using fixed-width assumption) local text_x = chip.x + (chip.w / 2) - 8 local text_y = chip.y + (chip.h / 2) - 8 engine.render.draw_text(chip.label, text_x, text_y, 14, text_color) -- Hidden overlay (strikethrough for invisible layers) -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) if not chip.is_roof and not state.is_layer_visible(chip.name) then engine.render.draw_line(chip.x, chip.y, chip.x + chip.w, chip.y + chip.h, rgba(COLOR_STRIKE, 0xFF), 2) end -- Entity-slot divider below lower_wall if chip.divider_after then local dy = chip.y + chip.h + (chip.gap_after / 2) -- Dashed: 4 short segments -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) for seg = 0, 3 do local sx = chip.x + seg * 9 engine.render.draw_line(sx, dy, sx + 6, dy, rgba(COLOR_DIVIDER, 0xFF), 1) end end end local function draw_layer_chips(mx, my) for _, chip in ipairs(chip_layout()) do draw_chip(chip, mx, my) end end -- Placeholder tile thumbnail (v0.1.0 limitation: lib-core.maps doesn't -- expose per-tile UVs to Lua; v0.2.0 task). local function draw_tile_thumb(atlas_idx, tile_id, x, y, size) engine.render.draw_rect(x, y, size, size, rgba(0x606060, 0xFF)) engine.render.draw_text("t" .. tile_id, x + 4, y + size/2 - 6, 10, rgba(0xFFFFFF, 0xFF)) end -- Returns a list of tile IDs for the given atlas. Placeholder for v0.1.0: -- lib-core.maps does not yet expose atlas.tile_ids() — return just the -- currently-active tile so the expanded picker has something to render. local function atlas_tile_list(atlas_idx) return { state.get_active_tile() } end local function draw_picker(mx, my) local p = picker_layout() engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COLOR_PICKER_BG, 0xE0)) -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) engine.render.draw_rect_lines(p.x, p.y, p.w, p.h, rgba(0xFFFFFF, 0xFF)) if not state.is_picker_open() then -- Collapsed view: thumbnail of active tile + truncated labels local atlas_idx = state.get_active_atlas() local tile_id = state.get_active_tile() local atlas_id = maps.atlas_id_at(atlas_idx) or "?" -- Active tile thumbnail (top of chip) draw_tile_thumb(atlas_idx, tile_id, p.x + 16, p.y + 8, TILE_THUMB_SIZE) -- Labels (bottom) local atlas_label = atlas_id:sub(1, 8) .. (atlas_id:len() > 8 and "…" or "") engine.render.draw_text(atlas_label, p.x + 4, p.y + 60, 10, rgba(0xCCCCCC, 0xFF)) return end -- Expanded view: atlas row at top, tile grid below engine.render.draw_text("Atlas:", p.x + 8, p.y + 4, 11, rgba(0xFFFFFF, 0xFF)) local atlas_count = maps.atlas_count() local atlas_x = p.x + 8 for i = 0, atlas_count - 1 do local label = maps.atlas_id_at(i) or "?" local short = label:sub(1, 6) local color = (i == state.get_active_atlas()) and rgba(0xFFFFFF, 0xFF) or rgba(0x808080, 0xFF) engine.render.draw_text("["..short.."]", atlas_x, p.y + 20, 11, color) atlas_x = atlas_x + 60 -- crude spacing end -- Tile grid (below the atlas row) engine.render.draw_text("Tiles:", p.x + 8, p.y + 40, 11, rgba(0xFFFFFF, 0xFF)) local active_atlas = state.get_active_atlas() local tiles = atlas_tile_list(active_atlas) local grid_x = p.x + 8 local grid_y = p.y + 60 for i, tid in ipairs(tiles) do local col = (i - 1) % 5 local row = math.floor((i - 1) / 5) local tx = grid_x + col * (TILE_THUMB_SIZE + 4) local ty = grid_y + row * (TILE_THUMB_SIZE + 4) draw_tile_thumb(active_atlas, tid, tx, ty, TILE_THUMB_SIZE) if tid == state.get_active_tile() then -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) engine.render.draw_rect_lines(tx, ty, TILE_THUMB_SIZE, TILE_THUMB_SIZE, rgba(0xFFFF00, 0xFF)) end end end local function draw_status(mx, my) local s = status_layout() engine.render.draw_rect(s.x, s.y, s.w, s.h, rgba(COLOR_STATUS_BG, 0xC0)) -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(0xFFFFFF, 0xFF)) local cell = state.get_mouse_cell() local cell_str if cell then cell_str = string.format("(%d,%d)", cell.x, cell.y) else cell_str = "(-,-)" end local dirty_str = state.is_dirty() and "*" or " " engine.render.draw_text(cell_str .. " " .. dirty_str, s.x + 4, s.y + 4, 12, rgba(COLOR_STATUS_TEXT, 0xFF)) end -- ===================================================================== -- Public API -- ===================================================================== function M.draw() local mx, my = engine.input.get_mouse_pos() draw_layer_chips(mx, my) draw_picker(mx, my) draw_status(mx, my) end -- Hit-test mouse click. Returns true if the click was consumed by UI. -- button: "left" | "right" function M.handle_click(mx, my, button) local p = picker_layout() -- Picker: check expanded panel first (largest area) if in_rect(mx, my, p.x, p.y, p.w, p.h) then if state.is_picker_open() then -- TODO: hit-test individual tile/atlas slots inside the panel -- For v0.1.0, any click inside expanded picker closes it. actions.toggle_picker() return true else actions.toggle_picker() return true end end -- Layer chips for _, chip in ipairs(chip_layout()) do if in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) then if button == "left" then if chip.is_roof then actions.toggle_roof_mode() else actions.set_active_layer(chip.name) end elseif button == "right" then if not chip.is_roof then actions.toggle_layer_visible(chip.name) end end return true end end -- Status chip — no-op fallback local s = status_layout() if in_rect(mx, my, s.x, s.y, s.w, s.h) then return true -- consumed but no action end return false -- click falls through to map-area handler in init.lua end return M end)() -- ===================================================================== -- Setup + Frame hooks (was the rest of original init.lua) -- ===================================================================== local WORK_PATH = "maps/work.map.json" local work_map_id = maps.load(WORK_PATH) maps.set_current(work_map_id) maps.load_textures(engine.module.asset_aliases()) state.set_map(work_map_id, WORK_PATH) -- Center camera on the map local m_size = maps.size() local t_size = maps.tile_size() camera.set_target((m_size.w * t_size) / 2, (m_size.h * t_size) / 2) camera.set_zoom(1.0) -- ===================================================================== -- Input bindings -- ===================================================================== input.bind("quit", { "escape" }) input.bind("erase", { "e" }) input.bind("rotate", { "r" }) input.bind("save_map", { "s" }) engine.print("map-editor: ready. Hotkeys: E=erase, R=rotate, S=save, ESC=quit") -- ===================================================================== -- Frame hooks -- ===================================================================== function update(ctx, dt) -- Quit if input.was_action_pressed("quit") then engine.exit() return end -- Update mouse-cell tracking for status chip local mx, my = engine.input.get_mouse_pos() local wx, wy = camera.screen_to_world(mx, my) local cell_x = math.floor(wx / t_size) local cell_y = math.floor(wy / t_size) if cell_x >= 0 and cell_y >= 0 and cell_x < m_size.w and cell_y < m_size.h then state.set_mouse_cell(cell_x, cell_y) else state.set_mouse_cell(nil) end -- Hotkey: rotate if input.was_action_pressed("rotate") then actions.cycle_rotation() end -- Hotkey: save if input.was_action_pressed("save_map") then actions.save() end -- Hotkey: erase (uses current mouse-hover cell) if input.was_action_pressed("erase") then local cell = state.get_mouse_cell() if cell then actions.erase_cell_at(cell.x, cell.y) end end -- Mouse clicks: hit-test UI first, then map area if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then local consumed = ui.handle_click(mx, my, "left") if not consumed and state.get_mouse_cell() then local cell = state.get_mouse_cell() actions.paint_cell_at(cell.x, cell.y) end end if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then ui.handle_click(mx, my, "right") end camera.update(dt) end function render(ctx) camera.begin() maps.draw_map() camera.finish() ui.draw() -- screen-space overlays after camera ends end