require "level_editor/object_attributes" require "level_editor/gui" require "level_editor/layout" require "level_editor/note_editor" editorView = "room" --can be "off", "room", "world", "asset" selectedColor = "white" local bacSprite = love.graphics.newImage("art/_ui/bac.png") local folderSprite = love.graphics.newImage("art/_ui/folder.png") local cancelSprite = love.graphics.newImage("art/_ui/cancel.png") local confirmSprite = love.graphics.newImage("art/_ui/confirm.png") local clearSavesSprite = love.graphics.newImage("art/_ui/clear_saves.png") local notepadSprite = love.graphics.newImage("art/_debug/notepad.png") local selectedFolder = nil local selectedAsset = nil local selectedRoom = nil local gameAssets = {} local inputText = "" local assetProperties = {} local hierarchy = {} local activeField = nil --this is for whenwe are doing text input things local uiButtons = {} local folderButtons = {} local assetListButtons = {} -- flat list of the selected folder's assets (scrolls) local listScroll = { scroll = 0 } -- shared vertical scroll for the room list local editingAssetButtons = {} local labels = {} local editorRoomName = nil local roomNameField = nil local editorStatus = nil local clearSaveConfirmationExpires = 0 local clearSaveConfirmationScope = nil local worldRoomButtons = {} local worldControls = {} local worldPage = 1 local worldPageSize = 5 local assetSearchText = "" local assetSearchButtons = {} local worldSearchText = "" local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 } local cloneSourceRoom = nil local roomBrush = nil local wireBrush = nil local selectedCircuitId = nil local wireCameraX, wireCameraY = nil, nil local wireDirtyRooms = {} local wireOriginalWires = nil local boxPaletteOpen = false local boxPaletteScroll = { scroll = 0 } local boxPaletteSections = {} -- Note authoring state. `noteMode` is nil normally, "create" while placing a new -- note (click a cell to drop it and finish), or "edit" while retyping an -- existing note (with Save / Discard). `editingNote` is the note being edited. local noteMode = nil local editingNote = nil -- Convert a pointer position into a valid room cell. Keeping brush state in -- cell coordinates means a click that jitters within a tile cannot place or -- erase that tile more than once. local function roomCellAt(x, y) if x < 0 or x >= width or y < 0 or y >= height then return nil end return currentRoom:getCellFromMousePos(x, y) end local function paintRoomCell(cell, button) if button == 1 and selectedAsset then currentRoom:attemptPlaceAtCell(selectedAsset, gameAssets[selectedAsset], cell, selectedColor) elseif button == 2 then currentRoom:attemptDeleteAtCell(cell, selectedColor) end end local function paintRoomLine(from, to, button) -- Bresenham keeps a fast mouse movement from leaving holes between the -- cells that happened to receive mouse-move events. local x, y = from.x, from.y local dx, dy = math.abs(to.x - x), math.abs(to.y - y) local stepX = x < to.x and 1 or -1 local stepY = y < to.y and 1 or -1 local err = dx - dy while true do if x ~= from.x or y ~= from.y then paintRoomCell({ x = x, y = y }, button) end if x == to.x and y == to.y then break end local twiceErr = err * 2 if twiceErr > -dy then err = err - dy; x = x + stepX end if twiceErr < dx then err = err + dx; y = y + stepY end end end local function beginRoomBrush(x, y, button) if button ~= 1 and button ~= 2 then return end if button == 1 and not selectedAsset then return end local cell = roomCellAt(x, y) if not cell then return end paintRoomCell(cell, button) roomBrush = { button = button, lastCell = cell } end -- ── Room-view sidebar layout ──────────────────────────────────────────────── -- Declared up here (not next to editorDraw) so editorMouseHandler, defined -- earlier in the file, can still capture these as upvalues. -- Which flat list the room view is currently showing (search > folder > roots). local function currentRoomList() if assetSearchText ~= "" then return assetSearchButtons elseif not selectedFolder then return folderButtons else return assetListButtons end end local function rebuildBoxPaletteSections() boxPaletteSections = {} for _, prefix in ipairs({ "box", "spiky", "sticky", "skewer" }) do local names = {} local pattern = prefix == "box" and "^box" or "^" .. prefix .. "_box" for name in pairs(gameAssets) do if name:match(pattern) then table.insert(names, name) end end table.sort(names) table.insert(boxPaletteSections, { prefix = prefix, names = names }) end if gameAssets.player_blocker then table.insert(boxPaletteSections, { prefix = "barrier", names = {"player_blocker"} }) end end local function buildBoxPicker(rect) local gap, cellSize, headerH = 4, 51, 18 local function contentMetrics(size) local columns = math.max(1, math.floor((rect.w + gap) / (size + gap))) local height = 0 for _, section in ipairs(boxPaletteSections) do height = height + headerH + math.ceil(#section.names / columns) * (size + gap) + gap end return columns, height end -- Keep the picker to one screen when the sidebar is tall enough, while -- preserving a usable minimum tile size for unusually short sidebars. local columns, contentHeight = contentMetrics(cellSize) while cellSize > 20 and contentHeight > rect.h do cellSize = cellSize - 1 columns, contentHeight = contentMetrics(cellSize) end local contentY, sections, items = 0, {}, {} for _, section in ipairs(boxPaletteSections) do table.insert(sections, { prefix = section.prefix, y = contentY }) contentY = contentY + headerH for index, name in ipairs(section.names) do local column = (index - 1) % columns local row = math.floor((index - 1) / columns) table.insert(items, { name = name, x = rect.x + column * (cellSize + gap), y = contentY + row * (cellSize + gap), w = cellSize, h = cellSize, }) end contentY = contentY + math.ceil(#section.names / columns) * (cellSize + gap) + gap end local maxScroll = math.max(0, contentY - rect.h) boxPaletteScroll.scroll = math.max(0, math.min(boxPaletteScroll.scroll or 0, maxScroll)) local picker = { rect = rect, sections = sections, items = items, maxScroll = maxScroll, scroll = boxPaletteScroll.scroll } function picker.at(x, y) if not Layout.hit(rect, x, y) then return nil end local contentYAtMouse = y - rect.y + picker.scroll for _, item in ipairs(items) do if x >= item.x and x < item.x + item.w and contentYAtMouse >= item.y and contentYAtMouse < item.y + item.h then return item.name end end end return picker end local function drawPaletteAsset(name, x, y, size) local props = gameAssets[name] if not props or not props.sprites then return end local maxW, maxH = 1, 1 for _, path in pairs(props.sprites) do local sprite = loadSprite(path) maxW, maxH = math.max(maxW, sprite:getWidth()), math.max(maxH, sprite:getHeight()) end local scale = math.min((size - 6) / maxW, (size - 6) / maxH) local drawX = x + (size - maxW * scale) / 2 local drawY = y + (size - maxH * scale) / 2 for color, path in pairs(props.sprites) do local sprite = loadSprite(path) love.graphics.setColor(gColor[color]:set()) if props.goopy and drawGoopySprite then drawGoopySprite(sprite, drawX, drawY, scale) elseif props.skewer and drawSkewerSprite then drawSkewerSprite(sprite, props.cells and props.cells[color], drawX, drawY, scale) elseif props.spiky and drawSpikySprite then drawSpikySprite(sprite, props.cells and props.cells[color], drawX, drawY, scale) else love.graphics.draw(sprite, drawX, drawY, 0, scale, scale) end end end local function drawBoxPicker(picker) Layout.clip(picker.rect) for _, section in ipairs(picker.sections) do love.graphics.setColor(gColor.white:set()) love.graphics.print(section.prefix, picker.rect.x, picker.rect.y + section.y - picker.scroll) end for _, item in ipairs(picker.items) do local y = picker.rect.y + item.y - picker.scroll if y + item.h > picker.rect.y and y < picker.rect.y + picker.rect.h then love.graphics.setColor(1, 1, 1, 0.45) love.graphics.rectangle("line", item.x, y, item.w, item.h) drawPaletteAsset(item.name, item.x, y, item.w) if item.name == selectedAsset then love.graphics.setColor(1, 0.85, 0.2, 1) love.graphics.setLineWidth(2) love.graphics.rectangle("line", item.x + 1, y + 1, item.w - 2, item.h - 2) end end end Layout.unclip() Layout.scrollbar(picker) end -- Carve the sidebar into fixed, non-overlapping "divs": a search header pinned -- to the top, a control cluster pinned to the bottom, a status strip above it, -- and a flexible list filling whatever is left. Recomputed every frame so it -- tracks window resizes. local function roomSections() local pad = 10 local w = math.max(0, sb.w - pad * 2) local controlsH = 130 local statusH = 44 local search = { x = pad, y = pad, w = w, h = 34 } local controls = { x = pad, y = sb.h - pad - controlsH, w = w, h = controlsH } local status = { x = pad, y = controls.y - statusH, w = w, h = statusH } local listY = search.y + search.h + 8 local list = { x = pad, y = listY, w = w, h = math.max(0, status.y - 8 - listY) } return { search = search, listRect = list, status = status, controls = controls } end -- Save / Discard buttons for note edit mode, laid out in the status strip. Runs -- for both drawing and hit-testing so the two never disagree. local function noteEditButtonRects(s) local gap = 8 local h = math.min(28, s.status.h) local w = (s.status.w - gap) / 2 local y = s.status.y + (s.status.h - h) / 2 return { save = { x = s.status.x, y = y, w = w, h = h }, discard = { x = s.status.x + w + gap, y = y, w = w, h = h }, } end -- Position the bottom control cluster inside its section: level-name field, a -- row of colour buttons, then a row of action buttons. Runs for both drawing -- and hit-testing so the two never disagree. local function layoutRoomControls(c, s) local vgap = 4 -- vertical space between rows local hgap = 8 -- horizontal space between buttons in a row local y = c.y s.labelY = y y = y + 15 local field = uiButtons.roomName if field then field.x = c.x; field.y = y; field.w = c.w; field.h = 22 end y = y + 22 + vgap -- Resize a sprite button to a target height in place, so all control sizing -- lives here instead of being scattered across the creation calls. local function sizeBtn(name, target) local b = uiButtons[name] if b and b.sprite then b.scale = target / b.sprite:getHeight() b.w = b.sprite:getWidth() * b.scale b.h = b.sprite:getHeight() * b.scale elseif b and b.sprites then local spriteW, spriteH = 1, 1 for _, sprite in pairs(b.sprites) do spriteW, spriteH = math.max(spriteW, sprite:getWidth()), math.max(spriteH, sprite:getHeight()) end b.scale = math.min(target / spriteW, target / spriteH) b.w, b.h = spriteW * b.scale, spriteH * b.scale else b.w, b.h = 72, target end return b end -- Pack the row centered with a fixed gap, rather than spreading each button -- across a full-width slot (which left big gaps between small icons). local function placeRow(names, target) local btns, total = {}, 0 for _, name in ipairs(names) do local b = sizeBtn(name, target) if b then table.insert(btns, b); total = total + b.w end end total = total + hgap * math.max(0, #btns - 1) local x = c.x + (c.w - total) / 2 for _, b in ipairs(btns) do b.x = x b.y = y + (target - b.h) / 2 x = x + b.w + hgap end y = y + target + vgap end placeRow({ "white", "red", "green", "blue" }, 22) placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box" }, 22) placeRow({ "save", "clearSaves", "world", "wire", "note" }, 28) end -- Build the full room-view layout (sections + a scroll list + a back/search -- header) without drawing. Shared by editorDraw and editorMouseHandler. local function buildRoomLayout() local s = roomSections() local showBack = assetSearchText ~= "" or selectedFolder or boxPaletteOpen local sx, sw = s.search.x, s.search.w if showBack then s.back = { x = s.search.x, y = s.search.y, w = s.search.h, h = s.search.h } sx = s.back.x + s.back.w + 6 sw = s.search.w - s.back.w - 6 end s.searchBox = { x = sx, y = s.search.y, w = sw, h = s.search.h } if boxPaletteOpen then s.boxPicker = buildBoxPicker(s.listRect) else s.items = currentRoomList() s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2) end layoutRoomControls(s.controls, s) return s end -- Mouse wheel scrolls the room list; clamped by Layout.list next frame. function editorScrollRoomList(dy) local scroll = boxPaletteOpen and boxPaletteScroll or listScroll scroll.scroll = math.max(0, (scroll.scroll or 0) - dy * 40) end -- ──────────────────────────────────────────────────────────────────────────── local function fuzzyMatches(value, query) query = (query or ""):lower():gsub("%s+", "") if query == "" then return true end local index = 1 for character in value:lower():gmatch(".") do if character == query:sub(index, index) then index = index + 1 if index > #query then return true end end end return false end local function beginSearch(field) activeField = field inputText = field == "assetSearch" and assetSearchText or worldSearchText response = inputText end local function rebuildAssetSearchButtons() assetSearchButtons = {} listScroll.scroll = 0 if assetSearchText == "" then return end local names = {} for asset in pairs(gameAssets) do if fuzzyMatches(asset, assetSearchText) then table.insert(names, asset) end end table.sort(names) for index, asset in ipairs(names) do local sprites = {} for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end table.insert(assetSearchButtons, createUIElement{ sprites = sprites, goopy = gameAssets[asset].goopy, skewer = gameAssets[asset].skewer, spiky = gameAssets[asset].spiky, cells = gameAssets[asset].cells, name = "asset", text = asset, x = 30, y = 45 + (index - 1) * 62, onClic = { left = function() selectAsset(asset) end } }) end end local function buttonContains(button, x, y) return x > button.x and x < button.x + button.w and y > button.y and y < button.y + button.h end local function rebuildWorldRoomButtons() worldRoomButtons = {} local names = {} for _, name in ipairs(gameWorld:listRoomNames()) do if fuzzyMatches(name, worldSearchText) then table.insert(names, name) end end local pages = math.max(1, math.ceil(#names / worldPageSize)) worldPage = constrain(worldPage, 1, pages) local first = (worldPage - 1) * worldPageSize + 1 for index = first, math.min(first + worldPageSize - 1, #names) do local roomName = names[index] local row = index - first table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 }) end worldControls = { { text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sb.h - 110, w = sb.w - 20, h = 34, onClic = function(buttonX) if buttonX < sb.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end rebuildWorldRoomButtons() end }, } end local function drawWorldSidebar() love.graphics.setColor(gColor.white:set()) worldSearchBox.w = sb.w - 20 if activeField == "cloneName" then love.graphics.printf("CLONE '" .. cloneSourceRoom .. "' AS:", 8, 6, sb.w - 16, "center") love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h) love.graphics.printf(inputText, worldSearchBox.x + 6, worldSearchBox.y + 7, sb.w - 32, "left") love.graphics.printf("Press Enter to create and select the clone. Esc cancels.", 8, worldSearchBox.y + 34, sb.w - 16, "center") else local instructions = selectedRoom and "placing '" .. selectedRoom .. "' — click a map cell" or "click a map cell to open or create; select a room below to place it" love.graphics.printf("ROOMS — " .. instructions, 8, 6, sb.w - 16, "center") love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h) love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left") end for _, button in ipairs(worldRoomButtons) do love.graphics.setColor(button.name == selectedRoom and 1 or 100 / 255, button.name == selectedRoom and 1 or 100 / 255, button.name == selectedRoom and 1 or 100 / 255, 1) love.graphics.rectangle("line", button.x, button.y, button.w, button.h) gameWorld:drawRoomPreview(button.name, button.x + 4, button.y + 4, button.h - 8) love.graphics.setColor(gColor.white:set()) love.graphics.printf(button.name, button.x + button.h + 4, button.y + 21, button.w - button.h - 8, "left") end for _, control in ipairs(worldControls) do love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("line", control.x, control.y, control.w, control.h) love.graphics.printf(control.text, control.x, control.y + 9, control.w, "center") end -- Pin the assets/world toggle to a fixed spot here; the room view owns its -- position otherwise, so give the world view a stable one of its own. uiButtons.world.x = sb.w / 2 - uiButtons.world.w / 2 uiButtons.world.y = sb.h - uiButtons.world.h - 12 uiButtons.world:draw() end local function validRoomName(name) name = (name or ""):match("^%s*(.-)%s*$") if name == "" then return nil, "Enter a level name." end if not name:match("^[%w][%w_-]*$") then return nil, "Use letters, numbers, _ or - for level names." end return name end local function beginRoomNameEdit() activeField = "roomName" inputText = "" response = "" roomNameField:editText("") editorStatus = "Type a name, press Enter, then Save." end local function saveRoomFromEditor() local requestedName = editorRoomName or currentRoom.name if activeField == "roomName" then requestedName = inputText end local name, err = validRoomName(requestedName) if not name then editorStatus = err return end local oldName = currentRoom.name if name ~= oldName and readFromSource("rooms/" .. name .. ".sav") then editorStatus = "A source level named '" .. name .. "' already exists." return end local savePlayerPositions = not love.keyboard.isDown("lshift", "rshift") if not currentRoom:saveToSource(name, savePlayerPositions) then editorStatus = "Could not save '" .. name .. "'." return end -- A changed name is a rename, not an accidental duplicate. Do this only -- after the new source file is safely written. if oldName and oldName ~= name then removeFromSource("rooms/" .. oldName .. ".sav") gameWorld:renameRoom(oldName, name) end -- The project source is now authoritative. Drop any runtime copy so it -- cannot shadow this editor save the next time the game starts. clearRuntimeSaveData(name) if oldName and oldName ~= name then clearRuntimeSaveData(oldName) end suppressRuntimeAutosave = true gameWorld:invalidatePreview(name) if editorView == "world" then rebuildWorldRoomButtons() end currentRoom.name = name gameWorld:setLevel(currentRoom) editorRoomName = name roomNameField:editText(name) activeField = nil editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or " without player positions.") -- A circuit stroke can touch neighbouring rooms in one gesture. Save those -- authored fragments together so a cross-room route cannot be half-written. for room in pairs(wireDirtyRooms) do if room ~= currentRoom and room.name then room:saveWiresToSource() gameWorld:invalidatePreview(room.name) clearRuntimeSaveData(room.name) end end wireDirtyRooms = {} end local function copyWires(wires) local copy = {} for y, row in pairs(wires or {}) do copy[y] = {} for x, tracks in pairs(row) do copy[y][x] = {} for circuitId, mask in pairs(tracks) do copy[y][x][circuitId] = mask end end end return copy end local function resumeWireRoom(status) local name = wireCameraX and wireCameraY and gameWorld:roomAt(wireCameraX, wireCameraY) local room = name and gameWorld.levels[name] if not room then room = currentRoom status = status .. " The centered world cell is empty, so the previous room remains open." end currentRoom = room gameWorld:setLevel(currentRoom) gameWorld:setLastRoom(currentRoom.name) setEditorRoomName(currentRoom.name) wireOriginalWires = nil wireDirtyRooms = {} assetsView() editorStatus = status end local function saveWireRooms() local saved = 0 for room in pairs(wireDirtyRooms) do local success = room == currentRoom and room:saveToSource(room.name) or room:saveWiresToSource() if room.name and success then saved = saved + 1 gameWorld:invalidatePreview(room.name) clearRuntimeSaveData(room.name) end end suppressRuntimeAutosave = true local status = saved > 0 and ("Saved wire fragments in " .. saved .. " room(s).") or "No wire changes to save." resumeWireRoom(status) end local function discardWireRooms() for room, wires in pairs(wireOriginalWires or {}) do room.wires = copyWires(wires) end gameWorld:resolveCircuits() gameWorld:rebuildCollision() resumeWireRoom("Discarded wire changes.") end local function clearRuntimeSaves() local now = love.timer.getTime() local clearAll = love.keyboard.isDown("lshift", "rshift") local roomName = currentRoom.name local scope = clearAll and "all" or roomName if now > clearSaveConfirmationExpires or clearSaveConfirmationScope ~= scope then clearSaveConfirmationExpires = now + 3 clearSaveConfirmationScope = scope if clearAll then editorStatus = "Shift-click the clear-save icon again within 3 seconds to erase runtime saves for every room." else editorStatus = "Click the clear-save icon again within 3 seconds to erase the runtime save for '" .. (roomName or "this room") .. "'." end return end local removed = clearRuntimeSaveData(clearAll and nil or roomName) suppressRuntimeAutosave = true clearSaveConfirmationExpires = 0 clearSaveConfirmationScope = nil if roomName then currentRoom = Room:new{ name = roomName } gameWorld:setLevel(currentRoom) setEditorRoomName(currentRoom.name) editorStatus = "Cleared " .. removed .. " runtime save item(s) and reloaded '" .. roomName .. ".'" else editorStatus = "Cleared " .. removed .. " runtime save item(s). No named level to reload." end end function _initLevelEditor() gameAssets = getAllAssets() rebuildBoxPaletteSections() editorRoomName = "" --create folder buttons local counter = 1 for asset, attributes in pairs(gameAssets) do if not hierarchy[gameAssets[asset].directory] then hierarchy[gameAssets[asset].directory] = {} end table.insert(hierarchy[gameAssets[asset].directory], asset) end local folders = {} for folder in pairs(hierarchy) do table.insert(folders, folder) end table.sort(folders) for _, folder in ipairs(folders) do local button = createUIElement{ name = "folder", text = folder, x = 30, y = (sb.h - 50) * (counter / 15), sprite = folderSprite, onClic = { left = function(self) print("my asse") selectFolder(folder) end, right = function() end }, } counter = counter + 1 table.insert(folderButtons, button) end _addUIButton(createUIElement{ name = "save", sprite = love.graphics.newImage("art/_ui/save.png"), onClic = { left = function() if editorView == "room" then saveRoomFromEditor() end end, right = function() end }, x = sb.w / 4, y = sb.h * (9 / 10) }, "save") _addUIButton(createUIElement{ name = "world", sprite = love.graphics.newImage("art/_ui/world.png"), onClic = { left = function() if editorView == "world" then assetsView() else worldView() end end, right = function() end }, x = sb.w * (3 / 4), y = sb.h * (9 / 10) }, "world") _addUIButton(createUIElement{ name = "clearSaves", sprite = clearSavesSprite, onClic = { left = clearRuntimeSaves, }, x = sb.w / 2, y = sb.h * (9 / 10), scale = editorScale / 3 }, "clearSaves") for _, color in ipairs({"white", "red", "green", "blue"}) do _addUIButton(createUIElement{ name = color, sprite = love.graphics.newImage("art/_ui/" .. color .. ".png"), onClic = { left = function(self) if editorView == "room" then setSelectedColor(color) end end, right = function() end }, x = sb.w * (_ / 5), y = sb.h * (8 / 10) }, color) end for _, asset in ipairs({ "player", "static_wall", "switch", "door", "wired_switch", "wired_door", "box" }) do local properties = gameAssets[asset] if properties then local sprites = {} for color, path in pairs(properties.sprites) do sprites[color] = love.graphics.newImage(path) end _addUIButton(createUIElement{ name = "quick_" .. asset, sprites = sprites, goopy = properties.goopy, skewer = properties.skewer, spiky = properties.spiky, cells = properties.cells, onClic = { left = function() selectQuickAsset(asset) end }, x = 0, y = 0, }, "quick_" .. asset) end end _addUIButton(createUIElement{ name = "wire", text = "WIRES", onClic = { left = function() if editorView == "wire" then assetsView() else wireView() end end }, x = 0, y = 0, }, "wire") _addUIButton(createUIElement{ name = "note", sprite = notepadSprite, onClic = { left = function() if editorView == "room" then beginNoteCreate() end end }, x = 0, y = 0, }, "note") roomNameField = createUIElement{ name = "roomName", text = editorRoomName, x = 25, y = sb.h * (7 / 10), onClic = { left = beginRoomNameEdit, } } _addUIButton(roomNameField, "roomName") end function setEditorRoomName(name) editorRoomName = name or "" if roomNameField then roomNameField:editText(editorRoomName) end end function setSelectedColor(color) selectedColor = color end function selectFolder(folder) selectedFolder = folder boxPaletteOpen = false listScroll.scroll = 0 assetListButtons = {} table.sort(hierarchy[selectedFolder]) for _, asset in ipairs(hierarchy[selectedFolder]) do local sprites = {} for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end -- x/y are placeholders; the room-view layout repositions every item -- each frame from the list section (see buildRoomLayout). table.insert(assetListButtons, createUIElement{ sprites = sprites, goopy = gameAssets[asset].goopy, skewer = gameAssets[asset].skewer, spiky = gameAssets[asset].spiky, cells = gameAssets[asset].cells, name = "asset", text = asset, x = 30, y = 0, onClic = { left = function() selectAsset(asset) end, right = function() if not gameAssets[asset].synthetic then editAsset(asset) end end, } }) end end function selectAsset(asset) print("selecting", asset) selectedAsset = asset exitNoteMode() -- picking any real asset closes the note text field print(selectedAsset) end -- Close the note text box and return to whatever the sidebar was showing before. function exitNoteMode() noteMode = nil editingNote = nil noteEditorBlur() end -- Start authoring a new note: focus a blank text field. The author types, then -- clicks a grid cell to drop the note there (that placement is the "done"). function beginNoteCreate() if editorView ~= "room" then return end boxPaletteOpen = false noteMode = "create" editingNote = nil noteEditorSetText("") noteEditorFocus() end -- Left-clicking an existing note reopens it for editing, with Save / Discard. function beginNoteEdit(note) noteMode = "edit" editingNote = note noteEditorSetText(note.text) noteEditorFocus() end function saveNoteEdit() if editingNote then editingNote.text = noteEditorText() end exitNoteMode() end function discardNoteEdit() exitNoteMode() end -- Keyboard Escape while the field is focused: discard the edit / cancel placing. function cancelNoteMode() exitNoteMode() end function selectQuickAsset(asset) selectAsset(asset) boxPaletteOpen = asset == "box" boxPaletteScroll.scroll = 0 end function editAsset(asset) inputText = "" editorView = "asset" selectedAsset = asset assetProperties = gameAssets[selectedAsset] local cancelButton = createUIElement{ name = "cancel", sprite = cancelSprite, x = sb.w / 3, y = sb.h - 75, onClic = { left = function(self) assetsView() end, right = function(self) return end } } local confirmButton = createUIElement{ name = "confirm", sprite = confirmSprite, x = sb.w * 2 / 3, y = sb.h - 75, onClic = { left = function(self) saveAsset() end, right = function(self) return end } } editingAssetButtons[cancelButton] = cancelButton editingAssetButtons[confirmButton] = confirmButton local assetSprites = {} for color, path in pairs(assetProperties.sprites) do assetSprites[color] = love.graphics.newImage(path) end local assetButton = createUIElement{ name = asset, sprites = assetSprites, goopy = assetProperties.goopy, skewer = assetProperties.skewer, spiky = assetProperties.spiky, cells = assetProperties.cells, x = sb.w / 2 - 18, y = 80, } local classFieldLabel = { draw = function() love.graphics.printf("CLASS", 0, 125, sb.w, "center") end } labels[classFieldLabel] = classFieldLabel local classButton = createUIElement{ name = "class", text = assetProperties.class or "", x = 25, y = 150, onClic = { left = function() activeField = "class" end } } local preloadLabel = { draw = function() love.graphics.printf("preload", 0, 225, sb.w, "center") end } labels[preloadLabel] = preloadLabel local preloadButton = createUIElement{ name = "preload", text = tostring(assetProperties.preload), x = 25, y = 250, onClic = { left = function() assetProperties.preload = not assetProperties.preload end } } editingAssetButtons[assetButton] = assetButton editingAssetButtons.class = classButton editingAssetButtons.preload = preloadButton end function saveAsset() gameAssets[selectedAsset] = assetProperties -- the asset's class lives in its .meta next to the art (source of truth, -- read back by getAllAssets); write it there local metaPath = "art/" .. assetProperties.directory .. "/" .. selectedAsset .. ".meta" writeToSource(metaPath, TSerial.pack({ class = assetProperties.class }, nil, true)) assetsView() end function _addUIButton(button, name) local index = name or button uiButtons[index] = button end function folderView() selectedFolder = nil assetListButtons = {} listScroll.scroll = 0 labels = {} end function assetsView() editingAssetButtons = {} editorView = "room" wireBrush = nil labels = {} end function wireView() local location = currentRoom and gameWorld:roomLocation(currentRoom.name) if not location then editorStatus = "Place this room in the world before drawing cross-room wires." return end exitNoteMode() editorView = "wire" activeField = nil roomBrush = nil wireCameraX, wireCameraY = location.x, location.y wireOriginalWires = {} for _, room in pairs(gameWorld.levels or {}) do wireOriginalWires[room] = copyWires(room.wires) end wireDirtyRooms = {} editorStatus = selectedCircuitId and "Circuit selected. Drag to draw; right-drag erases; arrow keys pan." or "Drag from a wired door and release on a switch." end function worldView() exitNoteMode() editorView = "world" wireBrush = nil activeField = nil selectedFolder = nil selectedRoom = nil cloneSourceRoom = nil worldPage = 1 rebuildWorldRoomButtons() end local function wireControls() local pad, gap, h = 12, 8, 40 local w = (sb.w - pad * 2 - gap) / 2 return { save = {x = pad, y = sb.h - pad - h, w = w, h = h, text = "SAVE"}, discard = {x = pad + w + gap, y = sb.h - pad - h, w = w, h = h, text = "DISCARD"}, } end local function rememberWireRooms(rooms) for _, room in ipairs(rooms or {}) do wireDirtyRooms[room] = true end end local function wireCellAt(x, y) if x < 0 or x >= width or y < 0 or y >= height then return nil end if not wireCameraX or not wireCameraY then return nil end local cell = gameWorld:wireEditorCellAt(x, y, wireCameraX, wireCameraY) local room = gameWorld:roomCell(cell) return room and cell or nil end local function paintWireStep(from, to, button) if button == 1 then local rooms, err = gameWorld:addWireConnection(from, to, selectedCircuitId) rememberWireRooms(rooms) if err then editorStatus = err end else rememberWireRooms(gameWorld:removeWireAt(to, selectedCircuitId)) end end local function paintWireLine(from, to, button) local current = {x = from.x, y = from.y} local function walk(axis, target) local step = current[axis] < target and 1 or -1 while current[axis] ~= target do local nextCell = {x = current.x, y = current.y} nextCell[axis] = nextCell[axis] + step local room = gameWorld:roomCell(nextCell) if not room then return false end paintWireStep(current, nextCell, button) current = nextCell end return true end -- Manhattan interpolation keeps every authored segment orthogonal even if -- the mouse skips diagonally across several cells between move events. if not walk("x", to.x) then return current end walk("y", to.y) return current end local function beginWireBrush(x, y, button) if button ~= 1 and button ~= 2 then return end local cell = wireCellAt(x, y) if not cell then return end if button == 1 then local door = gameWorld:wiredDoorAt(cell) if door then -- Each stroke leaving a door is its own run and may terminate at one -- logical switch. Multiple runs can meet at the door, whose state is the -- AND of those independently coloured switch terminals. selectedCircuitId = gameWorld:newCircuitId() editorStatus = "Started " .. selectedCircuitId .. ". End this run on one switch." else -- A wire is a selectable terminal even after its door is deleted. -- Clicking empty space keeps the current circuit so a new branch can -- still begin away from the existing route. local circuitAtCell = gameWorld:circuitAt(cell) if circuitAtCell then selectedCircuitId = circuitAtCell editorStatus = "Selected orphaned circuit " .. selectedCircuitId .. "." end end if not selectedCircuitId then editorStatus = "Start by dragging from a wired door." return end -- Do not materialize a wire until this stroke actually crosses into an -- adjacent cell. Previously a click on a door wrote a zero-length track, -- which later behaved like an orphaned circuit underneath that door. else -- Prefer the selected circuit when several routes overlap here; otherwise -- adopt the top route. Repeated right-clicks can therefore remove every -- orphan at an overlapping cell without needing a door. local circuitAtCell = gameWorld:circuitAt(cell, selectedCircuitId) if not circuitAtCell then editorStatus = "There is no wire at this cell to erase." return end selectedCircuitId = circuitAtCell rememberWireRooms(gameWorld:removeWireAt(cell, selectedCircuitId)) editorStatus = "Erasing " .. selectedCircuitId .. "." end wireBrush = {button = button, lastCell = cell} end function editorWireKeypressed(key) if editorView ~= "wire" then return false end local directions = { left = {-1, 0}, right = {1, 0}, up = {0, -1}, down = {0, 1}, } local direction = directions[key] if not direction then return false end wireCameraX = constrain(wireCameraX + direction[1], 1, gameWorld.width) wireCameraY = constrain(wireCameraY + direction[2], 1, gameWorld.height) wireBrush = nil return true end function wireEditorCamera() return wireCameraX, wireCameraY, selectedCircuitId end local function newWorldRoomName(cellX, cellY) local base = string.format("room_%02d_%02d", cellX, cellY) local name, suffix = base, 2 while gameWorld:roomLocation(name) or readFromSource("rooms/" .. name .. ".sav") do name = base .. "_" .. suffix suffix = suffix + 1 end return name end local function cloneWorldRoom(sourceName, cloneName) local source = readFromSource("rooms/" .. sourceName .. ".sav") if not source then editorStatus = "Could not find source level '" .. sourceName .. "'." return false end if readFromSource("rooms/" .. cloneName .. ".sav") then editorStatus = "A level named '" .. cloneName .. "' already exists." return false end -- A clone is a new puzzle slice, so remap every circuit identity found in -- it. Keeping the originals would let the clone silently join the source's -- cross-room wires when both are placed in the same world. local ok, saveTable = pcall(TSerial.unpack, source) if ok and type(saveTable) == "table" then local remapped = {} local function remap(circuitId) if not circuitId then return nil end if not remapped[circuitId] then remapped[circuitId] = gameWorld:newCircuitId() end return remapped[circuitId] end for _, object in ipairs(saveTable.objects or {}) do if object.circuit then object.circuit = remap(object.circuit) end end for _, wire in ipairs(saveTable.wires or {}) do wire.circuit = remap(wire.circuit) end source = TSerial.pack(saveTable, nil, true) end if not writeToSource("rooms/" .. cloneName .. ".sav", source) then editorStatus = "Could not create clone '" .. cloneName .. "'." return false end gameWorld:invalidatePreview(cloneName) selectedRoom = cloneName rebuildWorldRoomButtons() editorStatus = "Cloned '" .. sourceName .. "' as '" .. cloneName .. "'. Click a map cell to place it." return true end local function beginCloneDialog(sourceName) cloneSourceRoom = sourceName activeField = "cloneName" inputText = "" response = "" editorStatus = "Enter a name for the clone." end local function openWorldRoom(cellX, cellY) local name = gameWorld:roomAt(cellX, cellY) if not name then name = newWorldRoomName(cellX, cellY) currentRoom = Room:new{ name = name } -- Creating from an empty cell is a real level creation, not merely a -- transient map reference, so it immediately joins the room picker. currentRoom:saveToSource(name) gameWorld:placeRoom(name, cellX, cellY) gameWorld:invalidatePreview(name) editorStatus = "Created and opened '" .. name .. ".'" else currentRoom = Room:new{ name = name } editorStatus = "Editing '" .. name .. ".'" end gameWorld:setLevel(currentRoom) gameWorld:setLastRoom(currentRoom.name) setEditorRoomName(currentRoom.name) assetsView() end function editorMouseHandler(x, y, button) if x >= sideBarBox.x and x < sideBarBox.x + sideBarBox.w then -- map from real window coords into the sidebar's fixed virtual space -- (same transform used to scale the sidebar canvas up in love.draw) x = (x - sideBarBox.x) * sb.w / sideBarBox.w y = y * sb.h / sideBarBox.h if editorView == "room" then local s = buildRoomLayout() -- back button: drop the search, else leave the folder if s.back and Layout.hit(s.back, x, y) then if assetSearchText ~= "" then assetSearchText = "" inputText = "" activeField = nil rebuildAssetSearchButtons() elseif selectedFolder then folderView() elseif boxPaletteOpen then boxPaletteOpen = false boxPaletteScroll.scroll = 0 end return end if Layout.hit(s.searchBox, x, y) then boxPaletteOpen = false beginSearch("assetSearch") return end -- While authoring a note, the field takes over the list area: a click -- there positions the caret / starts a selection (its rect is set each -- frame by noteEditorDraw). In edit mode, Save / Discard live in the -- status strip. if noteMode then if noteMode == "edit" and button == 1 then local nb = noteEditButtonRects(s) if Layout.hit(nb.save, x, y) then saveNoteEdit(); return end if Layout.hit(nb.discard, x, y) then discardNoteEdit(); return end end if noteEditorMousepressed(x, y) then return end -- The field covers the list area; swallow other clicks there so the -- hidden asset list underneath can't be selected by accident. if Layout.hit(s.listRect, x, y) then return end end if s.boxPicker then local asset = s.boxPicker.at(x, y) if asset then if button == 1 then selectAsset(asset) end return end else -- scrolling list item local i = s.list.at(x, y) if i and s.items[i] then s.items[i]:onClic(button) return end end -- control cluster (only the buttons that live there) for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire", "note" }) do local b = uiButtons[name] if b and b:clicIsInBox(x, y) then b:onClic(button) return end end elseif editorView == "wire" then local controls = wireControls() if buttonContains(controls.save, x, y) then saveWireRooms(); return end if buttonContains(controls.discard, x, y) then discardWireRooms(); return end elseif editorView == "asset" then for _, uiElement in pairs(editingAssetButtons) do if uiElement:clicIsInBox(x, y) then uiElement:onClic(button) end end elseif editorView == "world" then if activeField == "cloneName" then return end if buttonContains(worldSearchBox, x, y) then beginSearch("worldSearch") return end for _, roomButton in ipairs(worldRoomButtons) do if buttonContains(roomButton, x, y) then if button ~= 1 then return end if selectedRoom == roomButton.name then selectedRoom = nil editorStatus = "Placement cancelled. Click the map to open or create a level." else selectedRoom = roomButton.name editorStatus = "Selected '" .. selectedRoom .. "'. Click a map cell to place it." end return end end for _, control in ipairs(worldControls) do if buttonContains(control, x, y) then control.onClic(x) return end end if uiButtons.world:clicIsInBox(x, y) then uiButtons.world:onClic(button) end end else -- click landed on the grid (game area): place the selected asset if editorView == "room" then local cell = button == 1 and roomCellAt(x, y) -- Placing a new note: drop it here carrying the typed text, then finish -- (the drop is the "done"), returning the sidebar to its prior view. if cell and noteMode == "create" then currentRoom:attemptPlaceAtCell("note", nil, cell, selectedColor, noteEditorText()) exitNoteMode() return end -- Left-clicking an existing note opens it for editing instead of -- painting over it. if cell then local note = currentRoom:noteAtCell(cell) if note then beginNoteEdit(note); return end end beginRoomBrush(x, y, button) elseif editorView == "wire" then beginWireBrush(x, y, button) elseif editorView == "world" then local cellX, cellY = gameWorld:getCellFromMousePos(x, y) if not gameWorld:isInBounds(cellX, cellY) then return end if button == 2 then local name = gameWorld:roomAt(cellX, cellY) if name then beginCloneDialog(name) end elseif selectedRoom then local placedRoom = selectedRoom if gameWorld:placeRoom(placedRoom, cellX, cellY) then selectedRoom = nil editorStatus = "Placed '" .. placedRoom .. "' at " .. cellX .. ", " .. cellY .. "." else editorStatus = "Could not place '" .. placedRoom .. "'." end else openWorldRoom(cellX, cellY) end end end end function editorMouseMoved(x, y) if noteEditorActive() and x >= sideBarBox.x then local vx = (x - sideBarBox.x) * sb.w / sideBarBox.w local vy = y * sb.h / sideBarBox.h if noteEditorMousemoved(vx, vy) then return end end if wireBrush then if editorView ~= "wire" then wireBrush = nil; return end local cell = wireCellAt(x, y) if not cell then wireBrush = nil; return end if cell.x ~= wireBrush.lastCell.x or cell.y ~= wireBrush.lastCell.y then wireBrush.lastCell = paintWireLine(wireBrush.lastCell, cell, wireBrush.button) end return end if not roomBrush then return end if editorView ~= "room" then roomBrush = nil return end local cell = roomCellAt(x, y) if not cell then -- Leaving the grid ends this stroke; re-entering should not draw a line -- across the sidebar or outside of the level. roomBrush = nil return end if cell.x == roomBrush.lastCell.x and cell.y == roomBrush.lastCell.y then return end paintRoomLine(roomBrush.lastCell, cell, roomBrush.button) roomBrush.lastCell = cell end function editorMouseReleased(_, _, button) noteEditorMousereleased() if roomBrush and roomBrush.button == button then roomBrush = nil end if wireBrush and wireBrush.button == button then wireBrush = nil end end function editorTextHandler(text) if editorView == "asset" and activeField then inputText = text editingAssetButtons[activeField]:editText(text) elseif editorView == "room" and activeField == "roomName" then inputText = text roomNameField:editText(text) elseif editorView == "room" and activeField == "assetSearch" then inputText = text assetSearchText = text rebuildAssetSearchButtons() elseif editorView == "world" and activeField == "worldSearch" then inputText = text worldSearchText = text worldPage = 1 rebuildWorldRoomButtons() elseif editorView == "world" and activeField == "cloneName" then inputText = text end end function editorCompleteResponse() if editorView == "asset" and activeField then assetProperties[activeField] = inputText elseif editorView == "room" and activeField == "roomName" then editorRoomName = inputText elseif editorView == "room" and activeField == "assetSearch" then assetSearchText = inputText elseif editorView == "world" and activeField == "worldSearch" then worldSearchText = inputText elseif editorView == "world" and activeField == "cloneName" then local cloneName, err = validRoomName(inputText) if not cloneName then editorStatus = err return end local sourceName = cloneSourceRoom if cloneWorldRoom(sourceName, cloneName) then cloneSourceRoom = nil activeField = nil end return end activeField = nil end function editorCancelResponse() if editorView == "wire" then discardWireRooms(); return true end if editorView ~= "world" or activeField ~= "cloneName" then return false end activeField = nil cloneSourceRoom = nil inputText = "" response = "" editorStatus = "Clone cancelled." return true end function sortAllAssets() setAll() end function createUIElement(t) return GUI:new(t) end -- Hover readout for note blocks, drawn in screen space over the play area (not -- the sidebar). Mirrors World:drawHoveredRoomName's pointer-anchored tooltip so -- it matches the overworld hover style. Only meaningful while notes are shown. function editorDrawNoteHover() if not (DEBUG and NOTES_VISIBLE) then return end if editorView ~= "room" then return end local mouseX, mouseY = love.mouse.getPosition() if mouseX < 0 or mouseX >= width or mouseY < 0 or mouseY >= height then return end local note = currentRoom:noteAtCell(currentRoom:getCellFromMousePos(mouseX, mouseY)) if not note or note.text == "" then return end local pad = 6 local maxW = math.min(280, width - 8) local _, wrapped = font:getWrap(note.text, maxW - pad * 2) local textW = 0 for _, line in ipairs(wrapped) do textW = math.max(textW, font:getWidth(line)) end local tooltipW = math.min(textW + pad * 2, maxW) local tooltipH = #wrapped * font:getHeight() + pad * 2 local tooltipX = math.max(4, math.min(mouseX + 14, width - tooltipW - 4)) local tooltipY = math.max(4, math.min(mouseY + 14, height - tooltipH - 4)) love.graphics.setColor(0, 0, 0, 0.9) love.graphics.rectangle("fill", tooltipX, tooltipY, tooltipW, tooltipH) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("line", tooltipX, tooltipY, tooltipW, tooltipH) love.graphics.printf(note.text, tooltipX + pad, tooltipY + pad, tooltipW - pad * 2, "left") end function editorDraw() if editorView == "room" then local s = buildRoomLayout() love.graphics.setColor(gColor.white:set()) -- header: optional back button + search box if s.back then love.graphics.rectangle("line", s.back.x, s.back.y, s.back.w, s.back.h) if boxPaletteOpen then love.graphics.printf("X", s.back.x, s.back.y + 9, s.back.w, "center") else local sc = (s.back.h - 8) / bacSprite:getHeight() love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc) end end love.graphics.rectangle("line", s.searchBox.x, s.searchBox.y, s.searchBox.w, s.searchBox.h) love.graphics.printf(boxPaletteOpen and "QUICK PALETTE" or "SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left") if noteMode then -- Authoring a note: the text field replaces the asset list. In create -- mode you click a grid cell to drop it; in edit mode Save / Discard sit -- in the status strip below. local labelH = 18 local hint = noteMode == "edit" and "EDIT NOTE" or "NEW NOTE (click grid to place)" love.graphics.setColor(gColor.white:set()) love.graphics.printf(hint, s.listRect.x, s.listRect.y, s.listRect.w, "left") noteEditorSetRect(s.listRect.x, s.listRect.y + labelH, s.listRect.w, s.listRect.h - labelH) noteEditorDraw() elseif s.boxPicker then drawBoxPicker(s.boxPicker) else -- Scrolling list, clipped to its section so nothing bleeds into others. Layout.clip(s.listRect) for i, item in ipairs(s.items) do if s.list.visible(i) then item.x, item.y = s.list.pos(i) item.w = s.listRect.w item:draw() if item.name == "asset" and item.text == selectedAsset then love.graphics.setColor(1, 0.85, 0.2, 1) love.graphics.setLineWidth(3) love.graphics.rectangle("line", item.x + 1, item.y + 1, item.w - 2, item.h - 2) end end end Layout.unclip() Layout.scrollbar(s.list) end -- control cluster love.graphics.setColor(gColor.white:set()) love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center") for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire", "note" }) do local button = uiButtons[name] if button then button:draw() local asset = name:match("^quick_(.+)$") if asset and asset:match("^wired_") then love.graphics.setColor(1, 1, 1, 1) love.graphics.print("W", button.x + button.w - 7, button.y - 4) end if (asset and selectedAsset == asset) or (name == "note" and noteMode ~= nil) then love.graphics.setColor(1, 0.85, 0.2, 1) love.graphics.setLineWidth(2) love.graphics.rectangle("line", button.x - 1, button.y - 1, button.w + 2, button.h + 2) end end end -- status strip between the list and the controls: Save / Discard while -- editing an existing note, otherwise the ordinary status line. if noteMode == "edit" then local nb = noteEditButtonRects(s) love.graphics.setColor(gColor.white:set()) for label, r in pairs({ SAVE = nb.save, DISCARD = nb.discard }) do love.graphics.setLineWidth(2) love.graphics.rectangle("line", r.x, r.y, r.w, r.h) love.graphics.printf(label, r.x, r.y + (r.h - font:getHeight()) / 2, r.w, "center") end elseif editorStatus then Layout.clip(s.status) love.graphics.setColor(gColor.white:set()) love.graphics.printf(editorStatus, s.status.x, s.status.y + 4, s.status.w, "center") Layout.unclip() end elseif editorView == "wire" then local circuitColor = selectedCircuitId and require("wire").color(selectedCircuitId) love.graphics.setColor(gColor.white:set()) love.graphics.printf("CIRCUIT WIRING", 10, 16, sb.w - 20, "center") if selectedCircuitId then love.graphics.setColor(circuitColor[1], circuitColor[2], circuitColor[3], 1) love.graphics.circle("fill", 28, 65, 9) love.graphics.setColor(gColor.white:set()) love.graphics.printf(selectedCircuitId, 46, 54, sb.w - 58, "left") else love.graphics.printf("No circuit selected", 10, 54, sb.w - 20, "center") end love.graphics.printf( "Drag from a door to start a new run, or drag from a wire to edit that run. Release on a switch to terminate it; start another run from the door for another switch.\n\nLeft-drag draws. Right-drag erases. Independent wires may share a cell without connecting.\n\nArrow keys pan by one room.", 24, 100, sb.w - 48, "left" ) if editorStatus then love.graphics.setColor(gColor.white:set()) love.graphics.printf(editorStatus, 24, sb.h * 0.55, sb.w - 48, "center") end local controls = wireControls() for _, control in pairs(controls) do love.graphics.setColor(gColor.white:set()) love.graphics.rectangle("line", control.x, control.y, control.w, control.h) love.graphics.printf(control.text, control.x, control.y + 11, control.w, "center") end elseif editorView == "asset" then for _, button in pairs(editingAssetButtons) do button:draw() end elseif editorView == "world" then drawWorldSidebar() end for _, label in pairs(labels) do label:draw() end -- The room view draws its status inside its own strip (see the room branch); -- the other views keep the simple centered line. if editorStatus and editorView ~= "room" and editorView ~= "wire" then love.graphics.setColor(gColor.white:set()) love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center") end end