Rebuild level editor sidebar with a fixed virtual-space layout

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>
This commit is contained in:
Your Name 2026-08-11 12:24:54 -04:00
parent 03532d29e4
commit 5b7ea9ec20
4 changed files with 336 additions and 144 deletions

View file

@ -1,11 +1,11 @@
require "level_editor/object_attributes" require "level_editor/object_attributes"
require "level_editor/gui" require "level_editor/gui"
require "level_editor/layout"
editorView = "room" --can be "off", "room", "world", "asset" editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "red" selectedColor = "red"
local bacSprite = love.graphics.newImage("art/_ui/bac.png") 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 folderSprite = love.graphics.newImage("art/_ui/folder.png")
local cancelSprite = love.graphics.newImage("art/_ui/cancel.png") local cancelSprite = love.graphics.newImage("art/_ui/cancel.png")
local confirmSprite = love.graphics.newImage("art/_ui/confirm.png") local confirmSprite = love.graphics.newImage("art/_ui/confirm.png")
@ -14,7 +14,6 @@ local clearSavesSprite = love.graphics.newImage("art/_ui/clear_saves.png")
local selectedFolder = nil local selectedFolder = nil
local selectedAsset = nil local selectedAsset = nil
local selectedRoom = nil local selectedRoom = nil
local selectedRow = 1
local gameAssets = {} local gameAssets = {}
local inputText = "" local inputText = ""
local assetProperties = {} local assetProperties = {}
@ -25,7 +24,8 @@ local activeField = nil --this is for whenwe are doing text input things
local uiButtons = {} local uiButtons = {}
local folderButtons = {} local folderButtons = {}
local assetButtons = { {} } --table for rows so we can do stuff 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 editingAssetButtons = {}
local labels = {} local labels = {}
@ -45,6 +45,110 @@ local assetSearchButtons = {}
local worldSearchText = "" local worldSearchText = ""
local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 } 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) local function fuzzyMatches(value, query)
query = (query or ""):lower():gsub("%s+", "") query = (query or ""):lower():gsub("%s+", "")
if query == "" then return true end if query == "" then return true end
@ -66,6 +170,7 @@ end
local function rebuildAssetSearchButtons() local function rebuildAssetSearchButtons()
assetSearchButtons = {} assetSearchButtons = {}
listScroll.scroll = 0
if assetSearchText == "" then return end if assetSearchText == "" then return end
local names = {} local names = {}
for asset in pairs(gameAssets) do for asset in pairs(gameAssets) do
@ -103,12 +208,12 @@ local function rebuildWorldRoomButtons()
for index = first, math.min(first + worldPageSize - 1, #names) do for index = first, math.min(first + worldPageSize - 1, #names) do
local roomName = names[index] local roomName = names[index]
local row = index - first local row = index - first
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sideBarBox.w - 20, h = 60 }) table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 })
end end
worldControls = { worldControls = {
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sideBarBox.h - 194, w = sideBarBox.w - 20, h = 34, { 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 }, 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, { text = "RESEED OLD EXITS", x = 10, y = sb.h - 152, w = sb.w - 20, h = 34,
onClic = function() onClic = function()
local now = love.timer.getTime() local now = love.timer.getTime()
if now > worldSeedConfirmationExpires then if now > worldSeedConfirmationExpires then
@ -121,9 +226,9 @@ local function rebuildWorldRoomButtons()
editorStatus = "Seeded " .. result.placed .. " rooms; " .. result.links .. " legacy links (" .. result.conflicts .. " conflicts kept separate)." editorStatus = "Seeded " .. result.placed .. " rooms; " .. result.links .. " legacy links (" .. result.conflicts .. " conflicts kept separate)."
rebuildWorldRoomButtons() rebuildWorldRoomButtons()
end }, end },
{ text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sideBarBox.h - 110, w = sideBarBox.w - 20, h = 34, { text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sb.h - 110, w = sb.w - 20, h = 34,
onClic = function(buttonX) onClic = function(buttonX)
if buttonX < sideBarBox.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end if buttonX < sb.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end
rebuildWorldRoomButtons() rebuildWorldRoomButtons()
end }, end },
} }
@ -131,8 +236,8 @@ end
local function drawWorldSidebar() local function drawWorldSidebar()
love.graphics.setColor(gColor.white:set()) 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") 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 = sideBarBox.w - 20 worldSearchBox.w = sb.w - 20
love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h) 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") love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left")
for _, button in ipairs(worldRoomButtons) do for _, button in ipairs(worldRoomButtons) do
@ -147,6 +252,10 @@ local function drawWorldSidebar()
love.graphics.rectangle("line", control.x, control.y, control.w, control.h) 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") love.graphics.printf(control.text, control.x, control.y + 9, control.w, "center")
end 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() uiButtons.world:draw()
end end
@ -254,7 +363,7 @@ function _initLevelEditor()
name = "folder", name = "folder",
text = folder, text = folder,
x = 30, x = 30,
y = (sideBarBox.h - 50) * (counter / 15), y = (sb.h - 50) * (counter / 15),
sprite = folderSprite, sprite = folderSprite,
onClic = { onClic = {
left = function(self) left = function(self)
@ -283,9 +392,9 @@ function _initLevelEditor()
end end
}, },
x = sideBarBox.w / 4, x = sb.w / 4,
y = sideBarBox.h * (9 / 10) y = sb.h * (9 / 10)
}) }, "save")
_addUIButton(createUIElement{ _addUIButton(createUIElement{
name = "world", name = "world",
@ -298,8 +407,8 @@ function _initLevelEditor()
end end
}, },
x = sideBarBox.w * (3 / 4), x = sb.w * (3 / 4),
y = sideBarBox.h * (9 / 10) y = sb.h * (9 / 10)
}, "world") }, "world")
_addUIButton(createUIElement{ _addUIButton(createUIElement{
@ -308,10 +417,10 @@ function _initLevelEditor()
onClic = { onClic = {
left = clearRuntimeSaves, left = clearRuntimeSaves,
}, },
x = sideBarBox.w / 2, x = sb.w / 2,
y = sideBarBox.h * (9 / 10), y = sb.h * (9 / 10),
scale = drawScale / 3 scale = editorScale / 3
}) }, "clearSaves")
for _, color in ipairs({"white", "red", "green", "blue"}) do for _, color in ipairs({"white", "red", "green", "blue"}) do
_addUIButton(createUIElement{ _addUIButton(createUIElement{
@ -327,16 +436,16 @@ function _initLevelEditor()
end end
}, },
x = sideBarBox.w * (_ / 5), x = sb.w * (_ / 5),
y = sideBarBox.h * (8 / 10) y = sb.h * (8 / 10)
}) }, color)
end end
roomNameField = createUIElement{ roomNameField = createUIElement{
name = "roomName", name = "roomName",
text = editorRoomName, text = editorRoomName,
x = 25, x = 25,
y = sideBarBox.h * (7 / 10), y = sb.h * (7 / 10),
onClic = { onClic = {
left = beginRoomNameEdit, left = beginRoomNameEdit,
} }
@ -356,74 +465,29 @@ end
function selectFolder(folder) function selectFolder(folder)
selectedFolder = folder selectedFolder = folder
listScroll.scroll = 0
local bacButton = _addUIButton(createUIElement{ assetListButtons = {}
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]) table.sort(hierarchy[selectedFolder])
for _, asset in ipairs(hierarchy[selectedFolder]) do 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 = {} local sprites = {}
for color, location in pairs(gameAssets[asset].sprites) do for color, location in pairs(gameAssets[asset].sprites) do
sprites[color] = love.graphics.newImage(location) sprites[color] = love.graphics.newImage(location)
end end
local assetButton = createUIElement{ -- 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, sprites = sprites,
name = "asset", name = "asset",
text = asset, text = asset,
x = 30, x = 30,
y = (sideBarBox.h - 225) * (counter / limit) + 20, y = 0,
onClic = { onClic = {
left = function() left = function() selectAsset(asset) end,
print(asset) right = function() editAsset(asset) end,
selectAsset(asset)
end,
right = function()
editAsset(asset)
end
} }
} })
table.insert(assetButtons[row], assetButton)
counter = counter + 1
end end
end end
@ -444,8 +508,8 @@ function editAsset(asset)
local cancelButton = createUIElement{ local cancelButton = createUIElement{
name = "cancel", name = "cancel",
sprite = cancelSprite, sprite = cancelSprite,
x = sideBarBox.w / 3, x = sb.w / 3,
y = sideBarBox.h - 75, y = sb.h - 75,
onClic = { onClic = {
left = function(self) left = function(self)
assetsView() assetsView()
@ -457,8 +521,8 @@ function editAsset(asset)
local confirmButton = createUIElement{ local confirmButton = createUIElement{
name = "confirm", name = "confirm",
sprite = confirmSprite, sprite = confirmSprite,
x = sideBarBox.w * 2 / 3, x = sb.w * 2 / 3,
y = sideBarBox.h - 75, y = sb.h - 75,
onClic = { onClic = {
left = function(self) left = function(self)
saveAsset() saveAsset()
@ -478,12 +542,12 @@ function editAsset(asset)
local assetButton = createUIElement{ local assetButton = createUIElement{
name = asset, name = asset,
sprites = assetSprites, sprites = assetSprites,
x = sideBarBox.w / 2 - 18, x = sb.w / 2 - 18,
y = 80, y = 80,
} }
local classFieldLabel = { local classFieldLabel = {
draw = function() love.graphics.printf("CLASS", 0, 125, sideBarBox.w, "center") end draw = function() love.graphics.printf("CLASS", 0, 125, sb.w, "center") end
} }
labels[classFieldLabel] = classFieldLabel labels[classFieldLabel] = classFieldLabel
@ -501,7 +565,7 @@ function editAsset(asset)
} }
local preloadLabel = { local preloadLabel = {
draw = function() love.graphics.printf("preload", 0, 225, sideBarBox.w, "center") end draw = function() love.graphics.printf("preload", 0, 225, sb.w, "center") end
} }
labels[preloadLabel] = preloadLabel labels[preloadLabel] = preloadLabel
@ -540,10 +604,9 @@ function _addUIButton(button, name)
end end
function folderView() function folderView()
selectedRow = 1
selectedFolder = nil selectedFolder = nil
assetButtons = {{}} assetListButtons = {}
uiButtons.bacToFolderView = nil listScroll.scroll = 0
labels = {} labels = {}
end end
@ -594,39 +657,45 @@ end
function editorMouseHandler(x, y, button) function editorMouseHandler(x, y, button)
if x > width then if x > width then
--we need to adjust for sidebar coords -- map from real window coords into the sidebar's fixed virtual space
x = x - width -- (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 if editorView == "room" then
if buttonContains({ x = 10, y = 5, w = sideBarBox.w - 20, h = 28 }, x, y) then local s = buildRoomLayout()
beginSearch("assetSearch")
-- 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 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 end
for _, uiElement in pairs(uiButtons) do if Layout.hit(s.searchBox, x, y) then
if uiElement:clicIsInBox(x, y) then beginSearch("assetSearch")
uiElement:onClic(button) 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
end end
elseif editorView == "asset" then elseif editorView == "asset" then
@ -722,24 +791,43 @@ end
function editorDraw() function editorDraw()
if editorView == "room" then if editorView == "room" then
local s = buildRoomLayout()
love.graphics.setColor(gColor.white:set()) 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") -- 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.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", 0, sideBarBox.h * (7 / 10) - 24, sideBarBox.w, "center") love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center")
for _, button in pairs(uiButtons) do for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do
button:draw() if uiButtons[name] then uiButtons[name]:draw() end
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
-- 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 end
elseif editorView == "asset" then elseif editorView == "asset" then
for _, button in pairs(editingAssetButtons) do for _, button in pairs(editingAssetButtons) do
@ -752,8 +840,10 @@ function editorDraw()
for _, label in pairs(labels) do for _, label in pairs(labels) do
label:draw() label:draw()
end end
if editorStatus then -- 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.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 10, sideBarBox.h * (6 / 10), sideBarBox.w - 20, "center") love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center")
end end
end end

View file

@ -26,7 +26,7 @@ function GUI:initialize(t)
if t.sprite then if t.sprite then
print(self.name) print(self.name)
self.scale = t.scale or drawScale * 2 / 3 self.scale = t.scale or editorScale * 2 / 3
self.w = t.sprite:getWidth() * self.scale self.w = t.sprite:getWidth() * self.scale
self.h = t.sprite:getHeight() * self.scale self.h = t.sprite:getHeight() * self.scale
self.sprite = t.sprite self.sprite = t.sprite
@ -41,22 +41,22 @@ function GUI:initialize(t)
h = sprite:getHeight() h = sprite:getHeight()
end end
if w > h then if w > h then
self.scale = (drawScale * (2 / 3)) / (w / 16) self.scale = (editorScale * (2 / 3)) / (w / 16)
elseif w < h then elseif w < h then
self.scale = (drawScale * (2 / 3)) / (h / 16) self.scale = (editorScale * (2 / 3)) / (h / 16)
else else
self.scale = (drawScale * (2 / 3)) / (w / 16) self.scale = (editorScale * (2 / 3)) / (w / 16)
end end
self.w = sideBarBox.w - (self.x * 2) self.w = sb.w - (self.x * 2)
self.h = h * self.scale self.h = h * self.scale
else else
-- this is text -- this is text
self.x, self.y = t.x, t.y self.x, self.y = t.x, t.y
self.w, self.h = sideBarBox.w - self.x * 2, 50 self.w, self.h = sb.w - self.x * 2, 50
end end
if self.name == "folder" then if self.name == "folder" then
self.w = sideBarBox.w - self.x * 2 self.w = sb.w - self.x * 2
end end
end end
@ -106,10 +106,15 @@ function GUI:draw()
if self.text then if self.text then
love.graphics.setColor(gColor.white:set()) love.graphics.setColor(gColor.white:set())
if not self.sprites and not self.sprite then if not self.sprites and not self.sprite then
-- text-only element (e.g. the level-name field): center in its rect
love.graphics.rectangle("line", box.x, box.y, box.w, box.h) love.graphics.rectangle("line", box.x, box.y, box.w, box.h)
end local th = love.graphics.getFont():getHeight()
love.graphics.printf(self.text, box.x, box.y + (box.h - th) / 2, box.w, "center")
else
-- label sitting to the right of an icon
love.graphics.print(self.text, box.x + 50, box.y + 15) love.graphics.print(self.text, box.x + 50, box.y + 15)
end end
end
end end

63
level_editor/layout.lua Normal file
View file

@ -0,0 +1,63 @@
-- A dead-simple layout helper for the editor sidebar. Everything works in the
-- sidebar's local coordinate space (0,0 = top-left of sideBarCanvas) — the same
-- space editorDraw / editorMouseHandler already use — so section rects double as
-- love.graphics.setScissor rects and as hit boxes. No dependencies, no state of
-- its own beyond the scroll table the caller hands in.
Layout = {}
-- point-in-rect
function Layout.hit(r, x, y)
return x >= r.x and x < r.x + r.w and y >= r.y and y < r.y + r.h
end
-- Clip drawing to a section. setScissor ignores the active graphics transform,
-- so multiply the virtual-space rect by editorRenderScale to hit real canvas
-- pixels. Clamp to non-negative: a too-narrow/short sidebar (e.g. a square
-- window with no room for it) would otherwise hand setScissor a negative size.
function Layout.clip(r)
local s = editorRenderScale or 1
love.graphics.setScissor(r.x * s, r.y * s, math.max(0, r.w * s), math.max(0, r.h * s))
end
function Layout.unclip() love.graphics.setScissor() end
-- A vertical scrolling list confined to rect `r`. `state` is any table the
-- caller keeps between frames to hold `state.scroll`; it is clamped here.
-- Returns helpers to place / cull / hit-test `count` items of height `itemH`.
function Layout.list(state, r, itemH, count, gap)
gap = gap or 6
local step = itemH + gap
local content = count * step
local maxScroll = math.max(0, content - r.h)
state.scroll = math.max(0, math.min(state.scroll or 0, maxScroll))
local scroll = state.scroll
return {
rect = r, itemH = itemH, step = step, count = count, maxScroll = maxScroll, scroll = scroll,
-- top-left corner of item i (1-based), in sidebar space
pos = function(i) return r.x, r.y + (i - 1) * step - scroll end,
-- is item i at all inside the visible window? (cheap cull)
visible = function(i)
local y = r.y + (i - 1) * step - scroll
return y + itemH > r.y and y < r.y + r.h
end,
-- index at a sidebar-space point, or nil if outside the list / no item
at = function(x, y)
if not Layout.hit(r, x, y) then return nil end
local i = math.floor((y - r.y + scroll) / step) + 1
if i >= 1 and i <= count then return i end
return nil
end,
}
end
-- Thin scrollbar thumb on the right edge of a list, drawn only when it scrolls.
function Layout.scrollbar(list)
if list.maxScroll <= 0 then return end
local r = list.rect
local thumb = math.max(16, r.h * (r.h / (r.h + list.maxScroll)))
local ty = r.y + (r.h - thumb) * (list.scroll / list.maxScroll)
local pr, pg, pb, pa = love.graphics.getColor()
love.graphics.setColor(255, 255, 255, 90)
love.graphics.rectangle("fill", r.x + r.w - 4, ty, 3, thumb)
love.graphics.setColor(pr, pg, pb, pa)
end

View file

@ -17,7 +17,10 @@ require "level_editor/editor"
-- Ink dialogue: ship the pre-parsed book (LÖVE has no lpeg to parse .ink at -- Ink dialogue: ship the pre-parsed book (LÖVE has no lpeg to parse .ink at
-- runtime; the narrator parser degrades to false without it, runtime is fine). -- runtime; the narrator parser degrades to false without it, runtime is fine).
package.path = package.path .. ";libs/narrator/?.lua" -- Use LOVE's require path (rooted at the game dir) so this resolves no matter
-- what the shell's CWD is; plain package.path is CWD-relative and breaks when
-- launched as `love /path/to/game` from elsewhere.
love.filesystem.setRequirePath(love.filesystem.getRequirePath() .. ";libs/narrator/?.lua")
narrator = require "narrator.narrator" narrator = require "narrator.narrator"
startSignBook = require "stories.start_sign" startSignBook = require "stories.start_sign"
@ -101,9 +104,16 @@ function love.draw()
love.graphics.draw(gameCanvas, 0, 0) love.graphics.draw(gameCanvas, 0, 0)
love.graphics.setCanvas() love.graphics.setCanvas()
-- Render the sidebar at native panel resolution: the canvas is panel-sized,
-- and editorDraw works in the fixed virtual space, scaled up by a transform.
-- Blitting the canvas 1:1 afterwards means no rescaling of the final image
-- (crisp, nearest-neighbor throughout — nothing is averaged).
love.graphics.setCanvas(sideBarCanvas) love.graphics.setCanvas(sideBarCanvas)
love.graphics.clear() love.graphics.clear()
love.graphics.push()
love.graphics.scale(editorRenderScale, editorRenderScale)
editorDraw() editorDraw()
love.graphics.pop()
love.graphics.setCanvas() love.graphics.setCanvas()
love.graphics.setBlendMode("alpha") love.graphics.setBlendMode("alpha")
@ -164,6 +174,13 @@ function love.textinput(t)
editorTextHandler(response) editorTextHandler(response)
end end
function love.wheelmoved(dx, dy)
-- Scroll the editor's asset/folder list when the cursor is over the sidebar.
if editorView == "room" and love.mouse.getX() > width then
editorScrollRoomList(dy)
end
end
function love.resize(w, h) function love.resize(w, h)
_computeDimensions() _computeDimensions()
drawScale = width / gridWidth / 16 drawScale = width / gridWidth / 16
@ -199,6 +216,21 @@ function _computeDimensions()
end end
sideBarBox = {x = width, y = 0, w = love.graphics.getWidth() - width, h = height} sideBarBox = {x = width, y = 0, w = love.graphics.getWidth() - width, h = height}
-- The editor sidebar is authored in a fixed virtual space and scaled up to
-- fill the real panel, so every element keeps the same size relative to the
-- panel at any window size (and fonts scale with it). Width is constant;
-- height follows the real panel's aspect ratio so the upscale is uniform
-- (no stretching). `editorScale` is the constant sprite scale in that space.
editorScale = 3
local virtualW = 600
local aspect = sideBarBox.w > 0 and (sideBarBox.h / sideBarBox.w) or 2
sb = {w = virtualW, h = virtualW * aspect}
-- Factor that maps the virtual layout space onto real panel pixels. The
-- sidebar is rasterized at this density (native res), so it stays crisp at
-- any window size instead of scaling a fixed low-res canvas.
editorRenderScale = sideBarBox.w > 0 and (sideBarBox.w / sb.w) or 1
end end
-- (Re)create the render targets at the current window size. -- (Re)create the render targets at the current window size.
@ -206,6 +238,7 @@ function _initCanvases()
gameCanvas = love.graphics.newCanvas() gameCanvas = love.graphics.newCanvas()
finalCanvas = love.graphics.newCanvas() finalCanvas = love.graphics.newCanvas()
sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h)) sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h))
sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled
end end
function _initGridVars() function _initGridVars()
@ -222,13 +255,14 @@ end
function _initSounds() function _initSounds()
staticWave = sfxr.newSound() staticWave = sfxr.newSound()
staticWave.wavetype = 3 staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr
staticWave.frequency.start = .1 staticWave.frequency.start = .1
staticWave.envelope.release = 0 staticWave.envelope.release = 0
staticWave.envelope.sustain = 1 staticWave.envelope.sustain = 1
staticWave.envelope.decay = 0 staticWave.envelope.decay = 0
static = love.audio.newSource((staticWave:generateSoundData())) static = love.audio.newSource((staticWave:generateSoundData()))
static:setLooping(true) static:setLooping(true)
static:setVolume(.15) static:setVolume(.04)
static:setFilter({type = "lowpass", highgain = .25}) -- roll off highs + upper-mids
static:play() static:play()
end end