Improve world editor palette workflow

This commit is contained in:
Your Name 2026-08-11 16:24:51 -04:00
parent 4cb536d6c8
commit 858775fdc8
3 changed files with 336 additions and 67 deletions

View file

@ -3,7 +3,7 @@ require "level_editor/gui"
require "level_editor/layout"
editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "red"
selectedColor = "white"
local bacSprite = love.graphics.newImage("art/_ui/bac.png")
local folderSprite = love.graphics.newImage("art/_ui/folder.png")
@ -34,8 +34,6 @@ local roomNameField = nil
local editorStatus = nil
local clearSaveConfirmationExpires = 0
local clearSaveConfirmationScope = nil
local worldSeedConfirmationExpires = 0
local worldMode = "place"
local worldRoomButtons = {}
local worldControls = {}
local worldPage = 1
@ -44,7 +42,11 @@ local assetSearchText = ""
local assetSearchButtons = {}
local worldSearchText = ""
local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 }
local cloneSourceRoom = nil
local roomBrush = nil
local boxPaletteOpen = false
local boxPaletteScroll = { scroll = 0 }
local boxPaletteSections = {}
-- 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
@ -107,6 +109,120 @@ local function currentRoomList()
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
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
@ -114,7 +230,7 @@ end
local function roomSections()
local pad = 10
local w = math.max(0, sb.w - pad * 2)
local controlsH = 104
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 }
@ -147,6 +263,13 @@ local function layoutRoomControls(c, s)
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
end
return b
end
@ -170,6 +293,7 @@ local function layoutRoomControls(c, s)
end
placeRow({ "white", "red", "green", "blue" }, 22)
placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world" }, 28)
end
@ -178,7 +302,7 @@ end
local function buildRoomLayout()
local s = roomSections()
local showBack = assetSearchText ~= "" or selectedFolder
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 }
@ -187,8 +311,12 @@ local function buildRoomLayout()
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
@ -196,7 +324,8 @@ end
-- Mouse wheel scrolls the room list; clamped by Layout.list next frame.
function editorScrollRoomList(dy)
listScroll.scroll = math.max(0, (listScroll.scroll or 0) - dy * 40)
local scroll = boxPaletteOpen and boxPaletteScroll or listScroll
scroll.scroll = math.max(0, (scroll.scroll or 0) - dy * 40)
end
-- ────────────────────────────────────────────────────────────────────────────
@ -264,21 +393,6 @@ local function rebuildWorldRoomButtons()
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 })
end
worldControls = {
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sb.h - 194, w = sb.w - 20, h = 34,
onClic = function() worldMode = worldMode == "place" and "jump" or "place"; rebuildWorldRoomButtons() end },
{ text = "RESEED OLD EXITS", x = 10, y = sb.h - 152, w = sb.w - 20, h = 34,
onClic = function()
local now = love.timer.getTime()
if now > worldSeedConfirmationExpires then
worldSeedConfirmationExpires = now + 3
editorStatus = "Click RESEED OLD EXITS again within 3 seconds to replace the current map."
return
end
worldSeedConfirmationExpires = 0
local result = seedWorldFromLegacyExits(gameWorld)
editorStatus = "Seeded " .. result.placed .. " rooms; " .. result.links .. " legacy links (" .. result.conflicts .. " conflicts kept separate)."
rebuildWorldRoomButtons()
end },
{ 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
@ -289,10 +403,20 @@ end
local function drawWorldSidebar()
love.graphics.setColor(gColor.white:set())
love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 6, sb.w - 16, "center")
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)
@ -396,6 +520,7 @@ end
function _initLevelEditor()
gameAssets = getAllAssets()
rebuildBoxPaletteSections()
editorRoomName = ""
--create folder buttons
@ -494,6 +619,25 @@ function _initLevelEditor()
}, color)
end
for _, asset in ipairs({ "player", "static_wall", "switch", "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
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
@ -518,6 +662,7 @@ end
function selectFolder(folder)
selectedFolder = folder
boxPaletteOpen = false
listScroll.scroll = 0
assetListButtons = {}
@ -555,6 +700,12 @@ function selectAsset(asset)
print(selectedAsset)
end
function selectQuickAsset(asset)
selectAsset(asset)
boxPaletteOpen = asset == "box"
boxPaletteScroll.scroll = 0
end
function editAsset(asset)
inputText = ""
editorView = "asset"
@ -681,7 +832,8 @@ function worldView()
editorView = "world"
activeField = nil
selectedFolder = nil
worldMode = "place"
selectedRoom = nil
cloneSourceRoom = nil
worldPage = 1
rebuildWorldRoomButtons()
end
@ -696,6 +848,36 @@ local function newWorldRoomName(cellX, cellY)
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
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
@ -735,24 +917,36 @@ function editorMouseHandler(x, y, button)
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
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", "save", "clearSaves", "world" }) do
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do
local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then
b:onClic(button)
@ -766,18 +960,20 @@ function editorMouseHandler(x, y, 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 = "Room brush cleared. Click the map to open or create a level."
editorStatus = "Placement cancelled. Click the map to open or create a level."
else
selectedRoom = roomButton.name
editorStatus = "Selected '" .. selectedRoom .. "' for placement. Click it again to clear."
editorStatus = "Selected '" .. selectedRoom .. "'. Click a map cell to place it."
end
return
end
@ -798,13 +994,18 @@ function editorMouseHandler(x, y, button)
local cellX, cellY = gameWorld:getCellFromMousePos(x, y)
if not gameWorld:isInBounds(cellX, cellY) then return end
if button == 2 then
local removed, name = gameWorld:removeRoom(cellX, cellY)
if removed then editorStatus = "Removed '" .. name .. "' from the world." end
elseif worldMode == "jump" or not selectedRoom then
openWorldRoom(cellX, cellY)
local name = gameWorld:roomAt(cellX, cellY)
if name then beginCloneDialog(name) end
elseif selectedRoom then
gameWorld:placeRoom(selectedRoom, cellX, cellY)
editorStatus = "Placed '" .. selectedRoom .. "' at " .. cellX .. ", " .. cellY .. "."
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
@ -850,6 +1051,8 @@ function editorTextHandler(text)
worldSearchText = text
worldPage = 1
rebuildWorldRoomButtons()
elseif editorView == "world" and activeField == "cloneName" then
inputText = text
end
end
@ -862,10 +1065,32 @@ function editorCompleteResponse()
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 ~= "world" or activeField ~= "cloneName" then return false end
activeField = nil
cloneSourceRoom = nil
inputText = ""
response = ""
editorStatus = "Clone cancelled."
return true
end
function sortAllAssets()
setAll()
end
@ -882,13 +1107,20 @@ function editorDraw()
-- 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("SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left")
love.graphics.printf(boxPaletteOpen and "BOX VARIANTS" or "SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left")
-- scrolling list, clipped to its section so nothing bleeds into others
if 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
@ -904,12 +1136,22 @@ function editorDraw()
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", "save", "clearSaves", "world" }) do
if uiButtons[name] then uiButtons[name]:draw() end
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do
local button = uiButtons[name]
if button then
button:draw()
local asset = name:match("^quick_(.+)$")
if asset and selectedAsset == asset 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

View file

@ -177,6 +177,8 @@ function love.keypressed(key)
if key == "return" then
response = ""
editorCompleteResponse()
elseif key == "escape" and editorCancelResponse() then
return
elseif key == "backspace" then
local byteoffset = utf8.offset(response, -1)

View file

@ -187,6 +187,30 @@ function World:drawRoomPreview(name, x, y, size)
love.graphics.setScissor()
end
function World:drawHoveredRoomName()
local mouseX, mouseY = love.mouse.getPosition()
local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY)
local name = self:isInBounds(cellX, cellY) and self:roomAt(cellX, cellY)
if not name then return end
local font = love.graphics.getFont()
local padding = 6
local tooltipW = math.min(font:getWidth(name) + padding * 2, width - 8)
local tooltipH = font:getHeight() + padding * 2
local tooltipX = math.min(mouseX + 14, width - tooltipW - 4)
local tooltipY = math.min(mouseY + 14, height - tooltipH - 4)
tooltipX = math.max(4, tooltipX)
tooltipY = math.max(4, tooltipY)
-- Keep the label anchored beside the pointer, while keeping it entirely in
-- the playable map rather than allowing it to spill into the editor panel.
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(name, tooltipX + padding, tooltipY + padding, tooltipW - padding * 2, "left")
end
function World:draw()
local cell = width / self.width
love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1)
@ -200,4 +224,5 @@ function World:draw()
love.graphics.rectangle("line", screenX, screenY, cell, cell)
end
end
self:drawHoveredRoomName()
end