note blocks and sticky test

This commit is contained in:
Your Name 2026-08-13 12:45:41 -04:00
parent 1bc1cdb11f
commit d829b4972f
11 changed files with 3389 additions and 118 deletions

View file

@ -1,6 +1,7 @@
require "level_editor/object_attributes"
require "level_editor/gui"
require "level_editor/layout"
require "level_editor/note_editor"
editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "white"
@ -10,6 +11,7 @@ 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 notepadSprite = love.graphics.newImage("art/_debug/notepad.png")
local selectedFolder = nil
local selectedAsset = nil
@ -53,6 +55,12 @@ local boxPaletteOpen = false
local boxPaletteScroll = { scroll = 0 }
local boxPaletteSections = {}
-- Note authoring state. `noteMode` is nil normally, "create" while placing a new
-- note (click a cell to drop it and finish), or "edit" while retyping an
-- existing note (with Save / Discard). `editingNote` is the note being edited.
local noteMode = nil
local editingNote = nil
-- 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.
@ -241,6 +249,19 @@ local function roomSections()
return { search = search, listRect = list, status = status, controls = controls }
end
-- Save / Discard buttons for note edit mode, laid out in the status strip. Runs
-- for both drawing and hit-testing so the two never disagree.
local function noteEditButtonRects(s)
local gap = 8
local h = math.min(28, s.status.h)
local w = (s.status.w - gap) / 2
local y = s.status.y + (s.status.h - h) / 2
return {
save = { x = s.status.x, y = y, w = w, h = h },
discard = { x = s.status.x + w + gap, y = y, w = w, h = h },
}
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.
@ -297,7 +318,7 @@ local function layoutRoomControls(c, s)
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)
placeRow({ "save", "clearSaves", "world", "wire", "note" }, 28)
end
-- Build the full room-view layout (sections + a scroll list + a back/search
@ -723,6 +744,16 @@ function _initLevelEditor()
y = 0,
}, "wire")
_addUIButton(createUIElement{
name = "note",
sprite = notepadSprite,
onClic = { left = function()
if editorView == "room" then beginNoteCreate() end
end },
x = 0,
y = 0,
}, "note")
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
@ -783,9 +814,50 @@ end
function selectAsset(asset)
print("selecting", asset)
selectedAsset = asset
exitNoteMode() -- picking any real asset closes the note text field
print(selectedAsset)
end
-- Close the note text box and return to whatever the sidebar was showing before.
function exitNoteMode()
noteMode = nil
editingNote = nil
noteEditorBlur()
end
-- Start authoring a new note: focus a blank text field. The author types, then
-- clicks a grid cell to drop the note there (that placement is the "done").
function beginNoteCreate()
if editorView ~= "room" then return end
boxPaletteOpen = false
noteMode = "create"
editingNote = nil
noteEditorSetText("")
noteEditorFocus()
end
-- Left-clicking an existing note reopens it for editing, with Save / Discard.
function beginNoteEdit(note)
noteMode = "edit"
editingNote = note
noteEditorSetText(note.text)
noteEditorFocus()
end
function saveNoteEdit()
if editingNote then editingNote.text = noteEditorText() end
exitNoteMode()
end
function discardNoteEdit()
exitNoteMode()
end
-- Keyboard Escape while the field is focused: discard the edit / cancel placing.
function cancelNoteMode()
exitNoteMode()
end
function selectQuickAsset(asset)
selectAsset(asset)
boxPaletteOpen = asset == "box"
@ -922,6 +994,7 @@ function wireView()
editorStatus = "Place this room in the world before drawing cross-room wires."
return
end
exitNoteMode()
editorView = "wire"
activeField = nil
roomBrush = nil
@ -935,6 +1008,7 @@ function wireView()
end
function worldView()
exitNoteMode()
editorView = "world"
wireBrush = nil
activeField = nil
@ -1169,6 +1243,22 @@ function editorMouseHandler(x, y, button)
return
end
-- While authoring a note, the field takes over the list area: a click
-- there positions the caret / starts a selection (its rect is set each
-- frame by noteEditorDraw). In edit mode, Save / Discard live in the
-- status strip.
if noteMode then
if noteMode == "edit" and button == 1 then
local nb = noteEditButtonRects(s)
if Layout.hit(nb.save, x, y) then saveNoteEdit(); return end
if Layout.hit(nb.discard, x, y) then discardNoteEdit(); return end
end
if noteEditorMousepressed(x, y) then return end
-- The field covers the list area; swallow other clicks there so the
-- hidden asset list underneath can't be selected by accident.
if Layout.hit(s.listRect, x, y) then return end
end
if s.boxPicker then
local asset = s.boxPicker.at(x, y)
if asset then
@ -1185,7 +1275,7 @@ function editorMouseHandler(x, y, button)
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
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", "note" }) do
local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then
b:onClic(button)
@ -1232,6 +1322,20 @@ function editorMouseHandler(x, y, button)
else
-- click landed on the grid (game area): place the selected asset
if editorView == "room" then
local cell = button == 1 and roomCellAt(x, y)
-- Placing a new note: drop it here carrying the typed text, then finish
-- (the drop is the "done"), returning the sidebar to its prior view.
if cell and noteMode == "create" then
currentRoom:attemptPlaceAtCell("note", nil, cell, selectedColor, noteEditorText())
exitNoteMode()
return
end
-- Left-clicking an existing note opens it for editing instead of
-- painting over it.
if cell then
local note = currentRoom:noteAtCell(cell)
if note then beginNoteEdit(note); return end
end
beginRoomBrush(x, y, button)
elseif editorView == "wire" then
beginWireBrush(x, y, button)
@ -1257,6 +1361,11 @@ function editorMouseHandler(x, y, button)
end
function editorMouseMoved(x, y)
if noteEditorActive() and x >= sideBarBox.x then
local vx = (x - sideBarBox.x) * sb.w / sideBarBox.w
local vy = y * sb.h / sideBarBox.h
if noteEditorMousemoved(vx, vy) then return end
end
if wireBrush then
if editorView ~= "wire" then wireBrush = nil; return end
local cell = wireCellAt(x, y)
@ -1286,6 +1395,7 @@ function editorMouseMoved(x, y)
end
function editorMouseReleased(_, _, button)
noteEditorMousereleased()
if roomBrush and roomBrush.button == button then roomBrush = nil end
if wireBrush and wireBrush.button == button then wireBrush = nil end
end
@ -1355,6 +1465,36 @@ function createUIElement(t)
return GUI:new(t)
end
-- Hover readout for note blocks, drawn in screen space over the play area (not
-- the sidebar). Mirrors World:drawHoveredRoomName's pointer-anchored tooltip so
-- it matches the overworld hover style. Only meaningful while notes are shown.
function editorDrawNoteHover()
if not (DEBUG and NOTES_VISIBLE) then return end
if editorView ~= "room" then return end
local mouseX, mouseY = love.mouse.getPosition()
if mouseX < 0 or mouseX >= width or mouseY < 0 or mouseY >= height then return end
local note = currentRoom:noteAtCell(currentRoom:getCellFromMousePos(mouseX, mouseY))
if not note or note.text == "" then return end
local pad = 6
local maxW = math.min(280, width - 8)
local _, wrapped = font:getWrap(note.text, maxW - pad * 2)
local textW = 0
for _, line in ipairs(wrapped) do textW = math.max(textW, font:getWidth(line)) end
local tooltipW = math.min(textW + pad * 2, maxW)
local tooltipH = #wrapped * font:getHeight() + pad * 2
local tooltipX = math.max(4, math.min(mouseX + 14, width - tooltipW - 4))
local tooltipY = math.max(4, math.min(mouseY + 14, height - tooltipH - 4))
love.graphics.setColor(0, 0, 0, 0.9)
love.graphics.rectangle("fill", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("line", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.printf(note.text, tooltipX + pad, tooltipY + pad, tooltipW - pad * 2, "left")
end
function editorDraw()
if editorView == "room" then
local s = buildRoomLayout()
@ -1373,7 +1513,17 @@ function editorDraw()
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
if noteMode then
-- Authoring a note: the text field replaces the asset list. In create
-- mode you click a grid cell to drop it; in edit mode Save / Discard sit
-- in the status strip below.
local labelH = 18
local hint = noteMode == "edit" and "EDIT NOTE" or "NEW NOTE (click grid to place)"
love.graphics.setColor(gColor.white:set())
love.graphics.printf(hint, s.listRect.x, s.listRect.y, s.listRect.w, "left")
noteEditorSetRect(s.listRect.x, s.listRect.y + labelH, s.listRect.w, s.listRect.h - labelH)
noteEditorDraw()
elseif s.boxPicker then
drawBoxPicker(s.boxPicker)
else
-- Scrolling list, clipped to its section so nothing bleeds into others.
@ -1397,7 +1547,7 @@ function editorDraw()
-- 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
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", "note" }) do
local button = uiButtons[name]
if button then
button:draw()
@ -1406,7 +1556,7 @@ function editorDraw()
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
if (asset and selectedAsset == asset) or (name == "note" and noteMode ~= nil) 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)
@ -1414,8 +1564,17 @@ function editorDraw()
end
end
-- status strip between the list and the controls
if editorStatus then
-- status strip between the list and the controls: Save / Discard while
-- editing an existing note, otherwise the ordinary status line.
if noteMode == "edit" then
local nb = noteEditButtonRects(s)
love.graphics.setColor(gColor.white:set())
for label, r in pairs({ SAVE = nb.save, DISCARD = nb.discard }) do
love.graphics.setLineWidth(2)
love.graphics.rectangle("line", r.x, r.y, r.w, r.h)
love.graphics.printf(label, r.x, r.y + (r.h - font:getHeight()) / 2, r.w, "center")
end
elseif 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")

View file

@ -0,0 +1,299 @@
-- A small, self-contained multi-line text field for the level editor sidebar.
-- LÖVE ships no GUI widgets, so this rolls the minimum needed to feel like a
-- normal text box: a caret, a selection, mouse click/drag positioning, and the
-- usual keyboard shortcuts (arrows, word-jump, home/end, select-all, clipboard).
--
-- Text is kept as an array of one-character strings (utf8-aware) with the caret
-- and selection anchor expressed as caret indices (0..#chars, a position between
-- characters). Everything else is derived from that.
local utf8 = require("utf8")
local pad = 6
local note = {
active = false,
chars = {}, -- array of single utf8 characters
caret = 0, -- 0..#chars
anchor = nil, -- selection start (caret index) or nil
rect = { x = 0, y = 0, w = 0, h = 0 },
dragging = false,
}
-- ── helpers ─────────────────────────────────────────────────────────────────
local function toChars(str)
local t = {}
local i, len = 1, #str
while i <= len do
local nexti = utf8.offset(str, 2, i) or (len + 1)
t[#t + 1] = str:sub(i, nexti - 1)
i = nexti
end
return t
end
local function clamp(v, lo, hi)
if v < lo then return lo elseif v > hi then return hi else return v end
end
local function isWordChar(c)
return c ~= nil and c:match("[%w_]") ~= nil
end
-- Line ranges as caret-index pairs: line covers caret indices s..e, and its text
-- is chars[s+1 .. e]. A blank final line after a trailing newline is included.
local function buildLines()
local lines, start = {}, 0
for k = 1, #note.chars do
if note.chars[k] == "\n" then
lines[#lines + 1] = { s = start, e = k - 1 }
start = k
end
end
lines[#lines + 1] = { s = start, e = #note.chars }
return lines
end
local function lineIndexOf(caret, lines)
for i, line in ipairs(lines) do
if caret >= line.s and caret <= line.e then return i end
end
return #lines
end
-- Pixel width from a line's start up to `col` characters into it.
local function colWidth(line, col)
local s = table.concat(note.chars, "", line.s + 1, line.s + col)
return font:getWidth(s)
end
-- ── selection / mutation ─────────────────────────────────────────────────────
local function selectionRange()
if not note.anchor or note.anchor == note.caret then return nil end
return math.min(note.anchor, note.caret), math.max(note.anchor, note.caret)
end
local function deleteRange(lo, hi)
for k = hi, lo + 1, -1 do table.remove(note.chars, k) end
note.caret = lo
note.anchor = nil
end
local function deleteSelection()
local lo, hi = selectionRange()
if not lo then return false end
deleteRange(lo, hi)
return true
end
local function insertText(str)
deleteSelection()
local ins = toChars(str)
for offset, c in ipairs(ins) do
table.insert(note.chars, note.caret + offset, c)
end
note.caret = note.caret + #ins
note.anchor = nil
end
-- Move the caret, extending or clearing the selection depending on shift.
local function moveCaret(pos, keepSelection)
if keepSelection then
if not note.anchor then note.anchor = note.caret end
else
note.anchor = nil
end
note.caret = clamp(pos, 0, #note.chars)
end
local function wordLeft(i)
local j = i
while j > 0 and not isWordChar(note.chars[j]) do j = j - 1 end
while j > 0 and isWordChar(note.chars[j]) do j = j - 1 end
return j
end
local function wordRight(i)
local n = #note.chars
local j = i
while j < n and not isWordChar(note.chars[j + 1]) do j = j + 1 end
while j < n and isWordChar(note.chars[j + 1]) do j = j + 1 end
return j
end
-- Map a point (sidebar-virtual coords) to the nearest caret index.
local function caretFromPoint(px, py)
local lines = buildLines()
local lineH = font:getHeight()
local row = clamp(math.floor((py - (note.rect.y + pad)) / lineH) + 1, 1, #lines)
local line = lines[row]
local target = px - (note.rect.x + pad)
local best, bestDist = 0, math.huge
for col = 0, line.e - line.s do
local dist = math.abs(colWidth(line, col) - target)
if dist < bestDist then best, bestDist = col, dist end
end
return line.s + best
end
-- ── public API (globals, matching the editor's module style) ─────────────────
function noteEditorActive()
return note.active and editorView == "room"
end
function noteEditorFocus()
note.active = true
end
function noteEditorBlur()
note.active = false
note.dragging = false
end
function noteEditorText()
return table.concat(note.chars)
end
function noteEditorSetText(str)
note.chars = toChars(str or "")
note.caret = #note.chars
note.anchor = nil
end
function noteEditorSetRect(x, y, w, h)
note.rect.x, note.rect.y, note.rect.w, note.rect.h = x, y, w, h
end
-- Click landed somewhere in the sidebar (virtual coords). Returns true if it hit
-- the field (so the caller stops looking for another control).
function noteEditorMousepressed(x, y)
local r = note.rect
if x < r.x or x > r.x + r.w or y < r.y or y > r.y + r.h then return false end
note.active = true
note.caret = caretFromPoint(x, y)
note.anchor = note.caret
note.dragging = true
return true
end
function noteEditorMousemoved(x, y)
if not note.dragging then return false end
note.caret = caretFromPoint(x, y)
return true
end
function noteEditorMousereleased()
note.dragging = false
if note.anchor == note.caret then note.anchor = nil end
end
function noteEditorTextinput(t)
insertText(t)
end
function noteEditorKeypressed(key)
local ctrl = love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui")
local shift = love.keyboard.isDown("lshift", "rshift")
if key == "escape" then
noteEditorBlur()
elseif key == "return" or key == "kpenter" then
insertText("\n")
elseif key == "backspace" then
if not deleteSelection() then
local to = ctrl and wordLeft(note.caret) or note.caret - 1
if to >= 0 and note.caret > 0 then deleteRange(math.max(0, to), note.caret) end
end
elseif key == "delete" then
if not deleteSelection() then
local to = ctrl and wordRight(note.caret) or note.caret + 1
if note.caret < #note.chars then deleteRange(note.caret, math.min(#note.chars, to)) end
end
elseif key == "left" then
moveCaret(ctrl and wordLeft(note.caret) or note.caret - 1, shift)
elseif key == "right" then
moveCaret(ctrl and wordRight(note.caret) or note.caret + 1, shift)
elseif key == "up" or key == "down" then
local lines = buildLines()
local row = lineIndexOf(note.caret, lines)
local col = note.caret - lines[row].s
local target = clamp(row + (key == "up" and -1 or 1), 1, #lines)
local line = lines[target]
moveCaret(line.s + math.min(col, line.e - line.s), shift)
elseif key == "home" then
if ctrl then
moveCaret(0, shift)
else
local lines = buildLines()
moveCaret(lines[lineIndexOf(note.caret, lines)].s, shift)
end
elseif key == "end" then
if ctrl then
moveCaret(#note.chars, shift)
else
local lines = buildLines()
moveCaret(lines[lineIndexOf(note.caret, lines)].e, shift)
end
elseif ctrl and key == "a" then
note.anchor = 0
note.caret = #note.chars
elseif ctrl and (key == "c" or key == "x") then
local lo, hi = selectionRange()
if lo then
love.system.setClipboardText(table.concat(note.chars, "", lo + 1, hi))
if key == "x" then deleteRange(lo, hi) end
end
elseif ctrl and key == "v" then
insertText(love.system.getClipboardText() or "")
end
end
-- Rendered inside the room-view sidebar (virtual space), clipped to its rect.
function noteEditorDraw()
local r = note.rect
local lines = buildLines()
local lineH = font:getHeight()
local ox, oy = r.x + pad, r.y + pad
love.graphics.setColor(0, 0, 0, 0.85)
love.graphics.rectangle("fill", r.x, r.y, r.w, r.h)
love.graphics.setColor(gColor.white:set())
love.graphics.setLineWidth(noteEditorActive() and 2 or 1)
love.graphics.rectangle("line", r.x, r.y, r.w, r.h)
Layout.clip({ x = r.x, y = r.y, w = r.w, h = r.h })
-- selection highlight
local lo, hi = selectionRange()
if lo then
love.graphics.setColor(0.25, 0.45, 0.9, 0.6)
for i, line in ipairs(lines) do
local segStart, segEnd = math.max(lo, line.s), math.min(hi, line.e)
if segEnd > segStart then
local x1 = ox + colWidth(line, segStart - line.s)
local x2 = ox + colWidth(line, segEnd - line.s)
love.graphics.rectangle("fill", x1, oy + (i - 1) * lineH, x2 - x1, lineH)
end
end
end
love.graphics.setColor(gColor.white:set())
for i, line in ipairs(lines) do
local text = table.concat(note.chars, "", line.s + 1, line.e)
love.graphics.print(text, ox, oy + (i - 1) * lineH)
end
-- caret (blinking) — only while focused
if noteEditorActive() and (love.timer.getTime() % 1) < 0.5 then
local row = lineIndexOf(note.caret, lines)
local cx = ox + colWidth(lines[row], note.caret - lines[row].s)
local cy = oy + (row - 1) * lineH
love.graphics.setLineWidth(1)
love.graphics.line(cx, cy, cx, cy + lineH)
end
Layout.unclip()
end