Replace the fraction-of-window positioning (which overlapped and didn't scale) with a small layout system: the sidebar is authored in one fixed virtual coordinate space and rendered at native panel resolution, so element sizes stay constant relative to the panel at any window size and fonts scale with it. - level_editor/layout.lua: sections, scrolling list (with clip + hit-test by point), scrollbar. Clip rects are scaled to real canvas pixels. - Room view split into non-overlapping divs: search/back header, a scrollable asset list (replaces the old row-paging + forward buttons), a status strip, and a compact bottom control cluster. - Mouse wheel scrolls the list; clicks map from window coords into the virtual space via the same transform used to render. - Sidebar canvas is panel-sized and blitted 1:1 (nearest filter, never averaged) to stay crisp; editorDraw runs under a scale transform. - Center text-only fields (level name) in their rect. Also carried in main.lua (share the file, can't split cleanly here): narrator require path fixed via love.filesystem.setRequirePath; sfxr static uses waveform=NOISE again after the field rename; low-pass + lower volume on the static loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
849 lines
26 KiB
Lua
849 lines
26 KiB
Lua
require "level_editor/object_attributes"
|
|
require "level_editor/gui"
|
|
require "level_editor/layout"
|
|
|
|
editorView = "room" --can be "off", "room", "world", "asset"
|
|
selectedColor = "red"
|
|
|
|
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 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 worldSeedConfirmationExpires = 0
|
|
local worldMode = "place"
|
|
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 }
|
|
|
|
-- ── 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
|
|
|
|
-- 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 = 104
|
|
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
|
|
|
|
-- 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
|
|
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({ "save", "clearSaves", "world" }, 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
|
|
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 }
|
|
|
|
s.items = currentRoomList()
|
|
s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2)
|
|
|
|
layoutRoomControls(s.controls, s)
|
|
return s
|
|
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)
|
|
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
|
|
if index > 8 then break end
|
|
local sprites = {}
|
|
for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end
|
|
table.insert(assetSearchButtons, createUIElement{
|
|
sprites = sprites,
|
|
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 = 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
|
|
rebuildWorldRoomButtons()
|
|
end },
|
|
}
|
|
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
|
|
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")
|
|
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 = love.keyboard.isDown("lshift", "rshift")
|
|
if not currentRoom:saveToSource(name, savePlayerPositions, oldName) 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
|
|
gameWorld:invalidatePreview(name)
|
|
if editorView == "world" then rebuildWorldRoomButtons() end
|
|
currentRoom.name = name
|
|
editorRoomName = name
|
|
roomNameField:editText(name)
|
|
activeField = nil
|
|
editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or ".")
|
|
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)
|
|
clearSaveConfirmationExpires = 0
|
|
clearSaveConfirmationScope = nil
|
|
if roomName then
|
|
currentRoom = Room:new{ name = roomName }
|
|
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()
|
|
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
|
|
|
|
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
|
|
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,
|
|
name = "asset",
|
|
text = asset,
|
|
x = 30,
|
|
y = 0,
|
|
onClic = {
|
|
left = function() selectAsset(asset) end,
|
|
right = function() editAsset(asset) end,
|
|
}
|
|
})
|
|
end
|
|
end
|
|
|
|
function selectAsset(asset)
|
|
print("selecting", asset)
|
|
selectedAsset = asset
|
|
print(selectedAsset)
|
|
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,
|
|
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"
|
|
labels = {}
|
|
end
|
|
|
|
function worldView()
|
|
editorView = "world"
|
|
activeField = nil
|
|
selectedFolder = nil
|
|
worldMode = "place"
|
|
worldPage = 1
|
|
rebuildWorldRoomButtons()
|
|
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 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:setLastRoom(currentRoom.name)
|
|
setEditorRoomName(currentRoom.name)
|
|
assetsView()
|
|
end
|
|
|
|
function editorMouseHandler(x, y, button)
|
|
if x > width 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 - width) * 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()
|
|
end
|
|
return
|
|
end
|
|
|
|
if Layout.hit(s.searchBox, x, y) then
|
|
beginSearch("assetSearch")
|
|
return
|
|
end
|
|
|
|
-- scrolling list item
|
|
local i = s.list.at(x, y)
|
|
if i and s.items[i] then
|
|
s.items[i]:onClic(button)
|
|
return
|
|
end
|
|
|
|
-- control cluster (only the buttons that live there)
|
|
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do
|
|
local b = uiButtons[name]
|
|
if b and b:clicIsInBox(x, y) then
|
|
b:onClic(button)
|
|
return
|
|
end
|
|
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 buttonContains(worldSearchBox, x, y) then
|
|
beginSearch("worldSearch")
|
|
return
|
|
end
|
|
for _, roomButton in ipairs(worldRoomButtons) do
|
|
if buttonContains(roomButton, x, y) then
|
|
if selectedRoom == roomButton.name then
|
|
selectedRoom = nil
|
|
editorStatus = "Room brush cleared. Click the map to open or create a level."
|
|
else
|
|
selectedRoom = roomButton.name
|
|
editorStatus = "Selected '" .. selectedRoom .. "' for placement. Click it again to clear."
|
|
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" and button == 1 and selectedAsset then
|
|
currentRoom:attemptPlace(selectedAsset, gameAssets[selectedAsset], x, y, selectedColor)
|
|
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 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)
|
|
elseif selectedRoom then
|
|
gameWorld:placeRoom(selectedRoom, cellX, cellY)
|
|
editorStatus = "Placed '" .. selectedRoom .. "' at " .. cellX .. ", " .. cellY .. "."
|
|
end
|
|
end
|
|
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()
|
|
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
|
|
end
|
|
activeField = nil
|
|
end
|
|
|
|
function sortAllAssets()
|
|
setAll()
|
|
end
|
|
|
|
function createUIElement(t)
|
|
return GUI:new(t)
|
|
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)
|
|
local sc = (s.back.h - 8) / bacSprite:getHeight()
|
|
love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc)
|
|
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")
|
|
|
|
-- 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()
|
|
end
|
|
end
|
|
Layout.unclip()
|
|
Layout.scrollbar(s.list)
|
|
|
|
-- 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
|
|
end
|
|
|
|
-- status strip between the list and the controls
|
|
if 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 == "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" then
|
|
love.graphics.setColor(gColor.white:set())
|
|
love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center")
|
|
end
|
|
end
|