chroma_solstice/level_editor/editor.lua
2026-08-07 20:56:55 -04:00

759 lines
21 KiB
Lua

require "level_editor/object_attributes"
require "level_editor/gui"
editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "red"
local bacSprite = love.graphics.newImage("art/_ui/bac.png")
local forwardSprite = love.graphics.newImage("art/_ui/forward.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 selectedRow = 1
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 assetButtons = { {} } --table for rows so we can do stuff
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 }
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 = {}
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 = sideBarBox.w - 20, h = 60 })
end
worldControls = {
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sideBarBox.h - 194, w = sideBarBox.w - 20, h = 34,
onClic = function() worldMode = worldMode == "place" and "jump" or "place"; rebuildWorldRoomButtons() end },
{ text = "RESEED OLD EXITS", x = 10, y = sideBarBox.h - 152, w = sideBarBox.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 = sideBarBox.h - 110, w = sideBarBox.w - 20, h = 34,
onClic = function(buttonX)
if buttonX < sideBarBox.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, sideBarBox.w - 16, "center")
worldSearchBox.w = sideBarBox.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
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 = (sideBarBox.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 = sideBarBox.w / 4,
y = sideBarBox.h * (9 / 10)
})
_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 = sideBarBox.w * (3 / 4),
y = sideBarBox.h * (9 / 10)
}, "world")
_addUIButton(createUIElement{
name = "clearSaves",
sprite = clearSavesSprite,
onClic = {
left = clearRuntimeSaves,
},
x = sideBarBox.w / 2,
y = sideBarBox.h * (9 / 10),
scale = drawScale / 3
})
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 = sideBarBox.w * (_ / 5),
y = sideBarBox.h * (8 / 10)
})
end
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
x = 25,
y = sideBarBox.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
local bacButton = _addUIButton(createUIElement{
name = "bacToFolderView",
sprite = bacSprite,
x = sideBarBox.w / 2,
y = 37.5,
onClic = {
left = function(self)
print(self, self.x)
self = nil
folderView(self)
end,
right = function(self) return end
}
}, "bacToFolderView")
local row = 1
local counter = 1
local limit = 10
table.sort(hierarchy[selectedFolder])
for _, asset in ipairs(hierarchy[selectedFolder]) do
if counter % limit == 0 then
counter = 1
local forwardButton = createUIElement{
name = "forward",
sprite = forwardSprite,
x = sideBarBox.w / 2,
y = sideBarBox.h - 250,
onClic = {
left = function()
selectedRow = selectedRow + 1
end,
right = function()
end
}
}
table.insert(assetButtons[row], forwardButton)
row = row + 1
table.insert(assetButtons, {})
end
local sprites = {}
for color, location in pairs(gameAssets[asset].sprites) do
sprites[color] = love.graphics.newImage(location)
end
local assetButton = createUIElement{
sprites = sprites,
name = "asset",
text = asset,
x = 30,
y = (sideBarBox.h - 225) * (counter / limit) + 20,
onClic = {
left = function()
print(asset)
selectAsset(asset)
end,
right = function()
editAsset(asset)
end
}
}
table.insert(assetButtons[row], assetButton)
counter = counter + 1
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 = sideBarBox.w / 3,
y = sideBarBox.h - 75,
onClic = {
left = function(self)
assetsView()
end,
right = function(self) return end
}
}
local confirmButton = createUIElement{
name = "confirm",
sprite = confirmSprite,
x = sideBarBox.w * 2 / 3,
y = sideBarBox.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 = sideBarBox.w / 2 - 18,
y = 80,
}
local classFieldLabel = {
draw = function() love.graphics.printf("CLASS", 0, 125, sideBarBox.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, sideBarBox.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()
selectedRow = 1
selectedFolder = nil
assetButtons = {{}}
uiButtons.bacToFolderView = nil
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
--we need to adjust for sidebar coords
x = x - width
if editorView == "room" then
if buttonContains({ x = 10, y = 5, w = sideBarBox.w - 20, h = 28 }, x, y) then
beginSearch("assetSearch")
return
elseif assetSearchText ~= "" then
for _, uiElement in ipairs(assetSearchButtons) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
return
end
end
elseif not selectedFolder then
for _, uiElement in pairs(folderButtons) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
return
end
end
else
for _, uiElement in pairs(assetButtons[selectedRow]) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
return
end
end
end
for _, uiElement in pairs(uiButtons) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
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
love.graphics.setColor(gColor.white:set())
love.graphics.rectangle("line", 10, 5, sideBarBox.w - 20, 28)
love.graphics.printf("ASSET SEARCH: " .. assetSearchText, 16, 12, sideBarBox.w - 32, "left")
love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", 0, sideBarBox.h * (7 / 10) - 24, sideBarBox.w, "center")
for _, button in pairs(uiButtons) do
button:draw()
end
if assetSearchText ~= "" then
for _, asset in ipairs(assetSearchButtons) do asset:draw() end
elseif not selectedFolder then
for _, folder in pairs(folderButtons) do
folder:draw()
end
else
for _, entity in ipairs(assetButtons[selectedRow]) do
entity:draw()
end
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
if editorStatus then
love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 10, sideBarBox.h * (6 / 10), sideBarBox.w - 20, "center")
end
end