1474 lines
47 KiB
Lua
1474 lines
47 KiB
Lua
require "level_editor/object_attributes"
|
|
require "level_editor/gui"
|
|
require "level_editor/layout"
|
|
|
|
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 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 = {}
|
|
|
|
-- 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
|
|
local cell = currentRoom:getCellFromMousePos(x, y)
|
|
cell.x = constrain(cell.x, 1, gridWidth)
|
|
cell.y = constrain(cell.y, 1, gridHeight)
|
|
return cell
|
|
end
|
|
|
|
local function paintRoomCell(cell, button)
|
|
-- Use the centre of the cell so Room's existing pointer-based edit helpers
|
|
-- perform the same operation as a normal click.
|
|
local x = (cell.x - .5) * width / gridWidth
|
|
local y = (cell.y - .5) * height / gridHeight
|
|
if button == 1 and selectedAsset then
|
|
currentRoom:attemptPlace(selectedAsset, gameAssets[selectedAsset], x, y, selectedColor)
|
|
elseif button == 2 then
|
|
currentRoom:attemptDelete(x, y, 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
|
|
|
|
-- 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" }, 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")
|
|
|
|
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
|
|
print(selectedAsset)
|
|
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
|
|
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()
|
|
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 > 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()
|
|
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", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire" }) 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
|
|
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 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)
|
|
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
|
|
|
|
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 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" }) 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 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
|
|
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 == "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
|