2015-03-25 01:38:53 -04:00
require " level_editor/object_attributes "
require " level_editor/gui "
2026-08-11 12:24:54 -04:00
require " level_editor/layout "
2026-08-13 12:45:41 -04:00
require " level_editor/note_editor "
2026-08-21 15:56:53 -04:00
require " mark " -- Mark.icon() supplies the MARK tool button art
2015-03-25 01:38:53 -04:00
editorView = " room " --can be "off", "room", "world", "asset"
2026-08-11 16:24:51 -04:00
selectedColor = " white "
2015-03-25 01:38:53 -04:00
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 " )
2026-08-07 01:39:47 -04:00
local clearSavesSprite = love.graphics . newImage ( " art/_ui/clear_saves.png " )
2026-08-13 12:45:41 -04:00
local notepadSprite = love.graphics . newImage ( " art/_debug/notepad.png " )
2015-03-25 01:38:53 -04:00
local selectedFolder = nil
local selectedAsset = nil
local selectedRoom = nil
2015-03-25 04:22:10 -04:00
local gameAssets = { }
2015-03-25 01:38:53 -04:00
local inputText = " "
local assetProperties = { }
2015-03-25 03:58:37 -04:00
local hierarchy = { }
2015-03-25 01:38:53 -04:00
local activeField = nil --this is for whenwe are doing text input things
2026-08-21 10:57:32 -04:00
-- True when the keyboard is captured by a text field (room name, asset search,
-- clone name, etc.) or the note editor. Callers use this to keep typed keys —
-- notably `m` — from being stolen as global shortcuts like the map toggle.
function editorTextFieldActive ( )
return activeField ~= nil or noteEditorActive ( )
end
2015-03-25 01:38:53 -04:00
local uiButtons = { }
local folderButtons = { }
2026-08-11 12:24:54 -04:00
local assetListButtons = { } -- flat list of the selected folder's assets (scrolls)
local listScroll = { scroll = 0 } -- shared vertical scroll for the room list
2015-03-25 01:38:53 -04:00
local editingAssetButtons = { }
local labels = { }
2026-08-07 01:39:47 -04:00
local editorRoomName = nil
local roomNameField = nil
local editorStatus = nil
local clearSaveConfirmationExpires = 0
2026-08-07 03:22:28 -04:00
local clearSaveConfirmationScope = nil
2026-08-07 01:39:47 -04:00
local worldRoomButtons = { }
local worldControls = { }
local worldPage = 1
local worldPageSize = 5
2026-08-07 09:08:34 -04:00
local assetSearchText = " "
local assetSearchButtons = { }
local worldSearchText = " "
local worldSearchBox = { x = 10 , y = 34 , w = 0 , h = 28 }
2026-08-11 16:24:51 -04:00
local cloneSourceRoom = nil
2026-08-11 14:11:57 -04:00
local roomBrush = nil
2026-08-11 22:55:58 -04:00
local wireBrush = nil
local selectedCircuitId = nil
local wireCameraX , wireCameraY = nil , nil
local wireDirtyRooms = { }
local wireOriginalWires = nil
2026-08-11 16:24:51 -04:00
local boxPaletteOpen = false
local boxPaletteScroll = { scroll = 0 }
local boxPaletteSections = { }
2026-08-11 14:11:57 -04:00
2026-08-21 16:34:01 -04:00
-- Adhesion applied to any moveable block placed from the room view. "regular"
-- means no adhesion; the others attach as a per-instance property (see
-- Room:createEntity). `adhesionRect` is recomputed each frame in
-- layoutRoomControls so draw and hit-testing agree; the menu expands upward.
local adhesionOptions = { " regular " , " sticky " , " gluey " }
local selectedAdhesion = " regular "
local adhesionMenuOpen = false
local adhesionRect = nil
2026-08-13 12:45:41 -04:00
-- 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
2026-08-21 15:56:53 -04:00
-- Mark authoring is entirely independent of notes (they only share the debug
-- overlay toggle). `markPlacing` is true after the MARK tool is picked, until a
-- grid cell is clicked to drop one; naming then happens in a small screen-space
-- modal (`markNameMode` = "create" | "edit"), with `editingMark` the target.
local markPlacing = false
local markNameMode = nil
local editingMark = nil
2026-08-11 14:11:57 -04:00
-- 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
2026-08-12 02:58:01 -04:00
return currentRoom : getCellFromMousePos ( x , y )
2026-08-11 14:11:57 -04:00
end
local function paintRoomCell ( cell , button )
if button == 1 and selectedAsset then
2026-08-21 16:34:01 -04:00
-- adhesion is a no-op on non-moveable assets (Room:createEntity only reads
-- it in the moveable branch), so it is always safe to pass along.
local adhesion = selectedAdhesion ~= " regular " and selectedAdhesion or nil
currentRoom : attemptPlaceAtCell ( selectedAsset , gameAssets [ selectedAsset ] , cell , selectedColor , nil , adhesion )
2026-08-11 14:11:57 -04:00
elseif button == 2 then
2026-08-12 02:58:01 -04:00
currentRoom : attemptDeleteAtCell ( cell , selectedColor )
2026-08-11 14:11:57 -04:00
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
2026-08-07 09:08:34 -04:00
2026-08-11 12:24:54 -04:00
-- ── 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
2026-08-11 16:24:51 -04:00
local function rebuildBoxPaletteSections ( )
2026-08-21 16:34:01 -04:00
-- Adhesion (sticky/gluey) is now a placement modifier chosen with the dropdown
-- next to the colour picker, not a separate asset, so the palette lists only
-- the base box shapes (plus the barrier).
2026-08-11 16:24:51 -04:00
boxPaletteSections = { }
2026-08-21 16:34:01 -04:00
local names = { }
for name in pairs ( gameAssets ) do
if name : match ( " ^box " ) then table.insert ( names , name ) end
2026-08-11 16:24:51 -04:00
end
2026-08-21 16:34:01 -04:00
table.sort ( names )
table.insert ( boxPaletteSections , { prefix = " box " , names = names } )
2026-08-11 20:29:35 -04:00
if gameAssets.player_blocker then
table.insert ( boxPaletteSections , { prefix = " barrier " , names = { " player_blocker " } } )
end
2026-08-11 16:24:51 -04:00
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 ( ) )
2026-08-21 16:34:01 -04:00
love.graphics . draw ( sprite , drawX , drawY , 0 , scale , scale )
2026-08-11 16:24:51 -04:00
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
2026-08-11 12:24:54 -04:00
-- 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 )
2026-08-11 16:24:51 -04:00
local controlsH = 130
2026-08-11 12:24:54 -04:00
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
2026-08-13 12:45:41 -04:00
-- 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
2026-08-11 12:24:54 -04:00
-- 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 ]
2026-08-11 16:24:51 -04:00
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
2026-08-11 22:55:58 -04:00
else
b.w , b.h = 72 , target
2026-08-11 12:24:54 -04:00
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
2026-08-21 16:34:01 -04:00
-- Colour swatches with the adhesion dropdown packed to their right, centered
-- as one group. adhesionRect feeds both the dropdown draw and its hit-test.
do
local target = 22
local dropdownW = 78
local btns , total = { } , 0
for _ , name in ipairs ( { " white " , " red " , " green " , " blue " } ) do
local b = sizeBtn ( name , target )
if b then table.insert ( btns , b ) ; total = total + b.w end
end
total = total + hgap * # btns + dropdownW
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
adhesionRect = { x = x , y = y , w = dropdownW , h = target }
y = y + target + vgap
end
2026-08-11 22:55:58 -04:00
placeRow ( { " quick_player " , " quick_static_wall " , " quick_switch " , " quick_door " , " quick_wired_switch " , " quick_wired_door " , " quick_box " } , 22 )
2026-08-21 15:56:53 -04:00
placeRow ( { " save " , " clearSaves " , " world " , " wire " , " note " , " mark " } , 28 )
2026-08-11 12:24:54 -04:00
end
2026-08-21 16:34:01 -04:00
-- Stacked option rows for the open adhesion menu, expanding upward from the
-- closed control so it never spills past the bottom edge. Shared by draw + click.
local function adhesionMenuRects ( )
if not adhesionRect then return { } end
local rects = { }
for index , name in ipairs ( adhesionOptions ) do
-- topmost option is the last one drawn highest above the closed control
local offset = ( # adhesionOptions - index + 1 ) * adhesionRect.h
rects [ index ] = {
name = name ,
x = adhesionRect.x ,
y = adhesionRect.y - offset ,
w = adhesionRect.w ,
h = adhesionRect.h ,
}
end
return rects
end
local function drawAdhesionDropdown ( )
if not adhesionRect then return end
local r = adhesionRect
love.graphics . setColor ( gColor.white : set ( ) )
love.graphics . setLineWidth ( 2 )
love.graphics . rectangle ( " line " , r.x , r.y , r.w , r.h )
local caret = adhesionMenuOpen and " v " or " ^ "
love.graphics . printf ( selectedAdhesion : upper ( ) .. caret , r.x , r.y + ( r.h - font : getHeight ( ) ) / 2 , r.w , " center " )
if adhesionMenuOpen then
for _ , option in ipairs ( adhesionMenuRects ( ) ) do
love.graphics . setColor ( 0 , 0 , 0 , 0.9 )
love.graphics . rectangle ( " fill " , option.x , option.y , option.w , option.h )
love.graphics . setColor ( option.name == selectedAdhesion and 1 or 0.6 , 0.85 , option.name == selectedAdhesion and 0.2 or 0.6 , 1 )
love.graphics . rectangle ( " line " , option.x , option.y , option.w , option.h )
love.graphics . setColor ( gColor.white : set ( ) )
love.graphics . printf ( option.name : upper ( ) , option.x , option.y + ( option.h - font : getHeight ( ) ) / 2 , option.w , " center " )
end
end
end
-- Returns true when the click was consumed by the dropdown (toggle or a choice).
local function adhesionDropdownClick ( x , y )
if not adhesionRect then return false end
if adhesionMenuOpen then
for _ , option in ipairs ( adhesionMenuRects ( ) ) do
if Layout.hit ( option , x , y ) then
selectedAdhesion = option.name
adhesionMenuOpen = false
return true
end
end
end
if Layout.hit ( adhesionRect , x , y ) then
adhesionMenuOpen = not adhesionMenuOpen
return true
end
if adhesionMenuOpen then adhesionMenuOpen = false end
return false
end
2026-08-11 12:24:54 -04:00
-- 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 ( )
2026-08-11 16:24:51 -04:00
local showBack = assetSearchText ~= " " or selectedFolder or boxPaletteOpen
2026-08-11 12:24:54 -04:00
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 }
2026-08-11 16:24:51 -04:00
if boxPaletteOpen then
s.boxPicker = buildBoxPicker ( s.listRect )
else
s.items = currentRoomList ( )
s.list = Layout.list ( listScroll , s.listRect , 40 , # s.items , 2 )
end
2026-08-11 12:24:54 -04:00
layoutRoomControls ( s.controls , s )
return s
end
-- Mouse wheel scrolls the room list; clamped by Layout.list next frame.
function editorScrollRoomList ( dy )
2026-08-11 16:24:51 -04:00
local scroll = boxPaletteOpen and boxPaletteScroll or listScroll
scroll.scroll = math.max ( 0 , ( scroll.scroll or 0 ) - dy * 40 )
2026-08-11 12:24:54 -04:00
end
-- ────────────────────────────────────────────────────────────────────────────
2026-08-07 09:08:34 -04:00
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 = { }
2026-08-11 12:24:54 -04:00
listScroll.scroll = 0
2026-08-07 09:08:34 -04:00
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 ,
name = " asset " ,
text = asset ,
x = 30 ,
y = 45 + ( index - 1 ) * 62 ,
onClic = { left = function ( ) selectAsset ( asset ) end }
} )
end
end
2026-08-07 01:39:47 -04:00
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 = { }
2026-08-07 09:08:34 -04:00
local names = { }
for _ , name in ipairs ( gameWorld : listRoomNames ( ) ) do
if fuzzyMatches ( name , worldSearchText ) then table.insert ( names , name ) end
end
2026-08-07 01:39:47 -04:00
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
2026-08-11 12:24:54 -04:00
table.insert ( worldRoomButtons , { name = roomName , x = 10 , y = 70 + row * 68 , w = sb.w - 20 , h = 60 } )
2026-08-07 01:39:47 -04:00
end
worldControls = {
2026-08-11 12:24:54 -04:00
{ text = " < PAGE " .. worldPage .. " / " .. pages .. " > " , x = 10 , y = sb.h - 110 , w = sb.w - 20 , h = 34 ,
2026-08-07 01:39:47 -04:00
onClic = function ( buttonX )
2026-08-11 12:24:54 -04:00
if buttonX < sb.w / 2 then worldPage = math.max ( 1 , worldPage - 1 ) else worldPage = math.min ( pages , worldPage + 1 ) end
2026-08-07 01:39:47 -04:00
rebuildWorldRoomButtons ( )
end } ,
}
end
local function drawWorldSidebar ( )
love.graphics . setColor ( gColor.white : set ( ) )
2026-08-11 12:24:54 -04:00
worldSearchBox.w = sb.w - 20
2026-08-11 16:24:51 -04:00
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
2026-08-07 01:39:47 -04:00
for _ , button in ipairs ( worldRoomButtons ) do
2026-08-07 20:56:42 -04:00
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 )
2026-08-07 01:39:47 -04:00
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
2026-08-07 20:56:42 -04:00
love.graphics . setColor ( 1 , 1 , 1 , 1 )
2026-08-07 01:39:47 -04:00
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
2026-08-11 12:24:54 -04:00
-- 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
2026-08-07 01:39:47 -04:00
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
2026-08-11 20:09:07 -04:00
local savePlayerPositions = not love.keyboard . isDown ( " lshift " , " rshift " )
if not currentRoom : saveToSource ( name , savePlayerPositions ) then
2026-08-07 01:39:47 -04:00
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
2026-08-11 16:37:11 -04:00
-- 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
2026-08-07 01:39:47 -04:00
gameWorld : invalidatePreview ( name )
if editorView == " world " then rebuildWorldRoomButtons ( ) end
currentRoom.name = name
2026-08-11 21:49:32 -04:00
gameWorld : setLevel ( currentRoom )
2026-08-07 01:39:47 -04:00
editorRoomName = name
roomNameField : editText ( name )
activeField = nil
2026-08-11 20:09:07 -04:00
editorStatus = " Saved source level ' " .. name .. " ' " .. ( savePlayerPositions and " with player positions. " or " without player positions. " )
2026-08-11 22:55:58 -04:00
-- 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. " )
2026-08-07 01:39:47 -04:00
end
local function clearRuntimeSaves ( )
local now = love.timer . getTime ( )
2026-08-07 03:22:28 -04:00
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
2026-08-07 01:39:47 -04:00
clearSaveConfirmationExpires = now + 3
2026-08-07 03:22:28 -04:00
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
2026-08-07 01:39:47 -04:00
return
end
2026-08-07 03:22:28 -04:00
local removed = clearRuntimeSaveData ( clearAll and nil or roomName )
2026-08-11 16:37:11 -04:00
suppressRuntimeAutosave = true
2026-08-07 01:39:47 -04:00
clearSaveConfirmationExpires = 0
2026-08-07 03:22:28 -04:00
clearSaveConfirmationScope = nil
2026-08-07 01:39:47 -04:00
if roomName then
currentRoom = Room : new { name = roomName }
2026-08-11 21:49:32 -04:00
gameWorld : setLevel ( currentRoom )
2026-08-07 01:39:47 -04:00
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
2015-03-25 01:38:53 -04:00
function _initLevelEditor ( )
2015-03-25 04:22:10 -04:00
gameAssets = getAllAssets ( )
2026-08-11 16:24:51 -04:00
rebuildBoxPaletteSections ( )
2026-08-07 01:39:47 -04:00
editorRoomName = " "
2015-03-25 01:38:53 -04:00
--create folder buttons
local counter = 1
2015-03-25 03:58:37 -04:00
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
2026-08-07 09:08:34 -04:00
local folders = { }
for folder in pairs ( hierarchy ) do table.insert ( folders , folder ) end
table.sort ( folders )
for _ , folder in ipairs ( folders ) do
2015-03-25 01:38:53 -04:00
local button = createUIElement {
name = " folder " ,
text = folder ,
x = 30 ,
2026-08-11 12:24:54 -04:00
y = ( sb.h - 50 ) * ( counter / 15 ) ,
2015-03-25 01:38:53 -04:00
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
2026-08-07 01:39:47 -04:00
saveRoomFromEditor ( )
2015-03-25 01:38:53 -04:00
end
end ,
right = function ( )
end
} ,
2026-08-11 12:24:54 -04:00
x = sb.w / 4 ,
y = sb.h * ( 9 / 10 )
} , " save " )
2015-03-25 01:38:53 -04:00
_addUIButton ( createUIElement {
name = " world " ,
sprite = love.graphics . newImage ( " art/_ui/world.png " ) ,
onClic = {
left = function ( )
2026-08-07 01:39:47 -04:00
if editorView == " world " then assetsView ( ) else worldView ( ) end
2015-03-25 01:38:53 -04:00
end ,
right = function ( )
end
} ,
2026-08-11 12:24:54 -04:00
x = sb.w * ( 3 / 4 ) ,
y = sb.h * ( 9 / 10 )
2026-08-07 01:39:47 -04:00
} , " world " )
_addUIButton ( createUIElement {
name = " clearSaves " ,
sprite = clearSavesSprite ,
onClic = {
left = clearRuntimeSaves ,
} ,
2026-08-11 12:24:54 -04:00
x = sb.w / 2 ,
y = sb.h * ( 9 / 10 ) ,
scale = editorScale / 3
} , " clearSaves " )
2015-03-25 01:38:53 -04:00
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
} ,
2026-08-11 12:24:54 -04:00
x = sb.w * ( _ / 5 ) ,
y = sb.h * ( 8 / 10 )
} , color )
2015-03-25 01:38:53 -04:00
end
2026-08-11 22:55:58 -04:00
for _ , asset in ipairs ( { " player " , " static_wall " , " switch " , " door " , " wired_switch " , " wired_door " , " box " } ) do
2026-08-11 16:24:51 -04:00
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 ,
onClic = { left = function ( ) selectQuickAsset ( asset ) end } ,
x = 0 ,
y = 0 ,
} , " quick_ " .. asset )
end
end
2026-08-11 22:55:58 -04:00
_addUIButton ( createUIElement {
name = " wire " ,
text = " WIRES " ,
onClic = { left = function ( )
if editorView == " wire " then assetsView ( ) else wireView ( ) end
end } ,
x = 0 ,
y = 0 ,
} , " wire " )
2026-08-13 12:45:41 -04:00
_addUIButton ( createUIElement {
name = " note " ,
sprite = notepadSprite ,
onClic = { left = function ( )
if editorView == " room " then beginNoteCreate ( ) end
end } ,
x = 0 ,
y = 0 ,
} , " note " )
2026-08-21 15:56:53 -04:00
_addUIButton ( createUIElement {
name = " mark " ,
sprite = Mark.icon ( ) ,
onClic = { left = function ( )
if editorView == " room " then beginMarkCreate ( ) end
end } ,
x = 0 ,
y = 0 ,
} , " mark " )
2026-08-07 01:39:47 -04:00
roomNameField = createUIElement {
name = " roomName " ,
text = editorRoomName ,
x = 25 ,
2026-08-11 12:24:54 -04:00
y = sb.h * ( 7 / 10 ) ,
2026-08-07 01:39:47 -04:00
onClic = {
left = beginRoomNameEdit ,
}
}
_addUIButton ( roomNameField , " roomName " )
end
function setEditorRoomName ( name )
editorRoomName = name or " "
if roomNameField then roomNameField : editText ( editorRoomName ) end
2015-03-25 01:38:53 -04:00
end
function setSelectedColor ( color )
selectedColor = color
end
function selectFolder ( folder )
selectedFolder = folder
2026-08-11 16:24:51 -04:00
boxPaletteOpen = false
2026-08-11 12:24:54 -04:00
listScroll.scroll = 0
assetListButtons = { }
2015-03-25 01:38:53 -04:00
2026-08-07 09:08:34 -04:00
table.sort ( hierarchy [ selectedFolder ] )
for _ , asset in ipairs ( hierarchy [ selectedFolder ] ) do
2015-03-25 01:38:53 -04:00
local sprites = { }
2015-03-25 03:58:37 -04:00
for color , location in pairs ( gameAssets [ asset ] . sprites ) do
2015-03-25 01:38:53 -04:00
sprites [ color ] = love.graphics . newImage ( location )
end
2026-08-11 12:24:54 -04:00
-- x/y are placeholders; the room-view layout repositions every item
-- each frame from the list section (see buildRoomLayout).
table.insert ( assetListButtons , createUIElement {
2015-03-25 01:38:53 -04:00
sprites = sprites ,
name = " asset " ,
text = asset ,
x = 30 ,
2026-08-11 12:24:54 -04:00
y = 0 ,
2015-03-25 01:38:53 -04:00
onClic = {
2026-08-11 12:24:54 -04:00
left = function ( ) selectAsset ( asset ) end ,
2026-08-11 15:07:11 -04:00
right = function ( )
if not gameAssets [ asset ] . synthetic then editAsset ( asset ) end
end ,
2015-03-25 01:38:53 -04:00
}
2026-08-11 12:24:54 -04:00
} )
2015-03-25 01:38:53 -04:00
end
end
function selectAsset ( asset )
print ( " selecting " , asset )
selectedAsset = asset
2026-08-21 15:56:53 -04:00
exitNoteMode ( ) -- picking any real asset closes the note text field
markPlacing = false -- ...and disarms a pending mark placement
2015-03-25 01:38:53 -04:00
print ( selectedAsset )
end
2026-08-13 12:45:41 -04:00
-- 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
2026-08-21 15:56:53 -04:00
markPlacing = false
2026-08-13 12:45:41 -04:00
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
2026-08-21 15:56:53 -04:00
-- ---- Marks -------------------------------------------------------------------
-- Mark names must be globally unique (an NPC is sent to one by name). Keep the
-- typed name if free, otherwise suffix _2, _3, ... `exclude` is the mark being
-- renamed, so editing a mark without changing its name is a no-op.
local function uniqueMarkName ( name , exclude )
if name == " " then return name end
local function taken ( candidate )
if gameWorld and gameWorld.markByName then
local found = gameWorld : markByName ( candidate )
return found ~= nil and found ~= exclude
end
for _ , mk in ipairs ( currentRoom.marks or { } ) do
if mk ~= exclude and mk.markName == candidate then return true end
end
return false
end
if not taken ( name ) then return name end
local i = 2
while taken ( name .. " _ " .. i ) do i = i + 1 end
return name .. " _ " .. i
end
function markNameEditorActive ( ) return markNameMode ~= nil end
-- Open the screen-space name modal for `mark`; `mode` is "create" (a just-placed
-- mark still needing a name) or "edit" (renaming an existing one).
local function openMarkNameEditor ( mark , mode )
exitNoteMode ( )
markNameMode = mode
editingMark = mark
activeField = " markName "
inputText = mark.markName or " "
response = inputText
end
local function resetMarkNameEditor ( )
markNameMode = nil
editingMark = nil
if activeField == " markName " then activeField = nil end
inputText = " "
response = " "
end
function commitMarkName ( )
if editingMark then editingMark.markName = uniqueMarkName ( inputText , editingMark ) end
resetMarkNameEditor ( )
end
function cancelMarkName ( )
-- Abandoning a brand-new, still-unnamed mark removes it (it can't be sent to).
if markNameMode == " create " and editingMark and editingMark.markName == " " then
for i = # currentRoom.marks , 1 , - 1 do
if currentRoom.marks [ i ] == editingMark then table.remove ( currentRoom.marks , i ) end
end
end
resetMarkNameEditor ( )
end
-- The MARK tool: arm placement. The next grid click drops a mark and opens the
-- name modal on it.
function beginMarkCreate ( )
if editorView ~= " room " then return end
exitNoteMode ( )
boxPaletteOpen = false
markPlacing = true
editorStatus = " Click a cell to place a mark. "
end
-- Left-clicking an existing mark reopens its name modal.
function beginMarkEdit ( mark )
openMarkNameEditor ( mark , " edit " )
end
-- Debug destination picker. Left-clicking an NPC in the room view opens a
-- filterable list of every Mark name; picking one sends that NPC there via its
-- Walker (Npc:sendTo). Lets you exercise NPC pathfinding without an Ink story.
-- Drawn over the play area in screen space (see editorDrawMarkPicker), so its
-- geometry uses window coordinates, not the sidebar's virtual space.
local markPicker = nil -- { npc = npc } while open
function markPickerActive ( ) return markPicker ~= nil end
local function markNamesMatching ( filter )
local seen , names = { } , { }
local rooms = ( gameWorld and gameWorld.levels ) or { currentRoom }
for _ , room in pairs ( rooms ) do
for _ , mk in ipairs ( room.marks or { } ) do
if mk.markName ~= " " and not seen [ mk.markName ] and fuzzyMatches ( mk.markName , filter ) then
seen [ mk.markName ] = true
table.insert ( names , mk.markName )
end
end
end
table.sort ( names )
return names
end
local function openMarkPicker ( npc )
markPicker = { npc = npc }
activeField = " markFilter "
inputText = " "
response = " "
end
function closeMarkPicker ( )
markPicker = nil
if activeField == " markFilter " then activeField = nil end
inputText = " "
response = " "
end
local function sendToMark ( name )
if markPicker and markPicker.npc and markPicker.npc . sendTo then
markPicker.npc : sendTo ( name )
editorStatus = " Sent NPC to ' " .. name .. " '. "
end
closeMarkPicker ( )
end
-- Panel + hit-tested rows, computed identically for draw and click, in screen
-- coordinates centered over the play area (left of the sidebar).
local function markPickerLayout ( )
local names = markNamesMatching ( inputText )
local rowH , headerH , w = 26 , 44 , 320
local h = headerH + math.max ( 1 , # names ) * rowH + 12
local x = math.max ( 10 , math.floor ( ( sideBarBox.x - w ) / 2 ) )
local y = math.max ( 10 , math.floor ( ( height - h ) / 2 ) )
local rows = { }
for i , name in ipairs ( names ) do
rows [ i ] = { name = name , x = x + 8 , y = y + headerH + ( i - 1 ) * rowH , w = w - 16 , h = rowH - 2 }
end
return { x = x , y = y , w = w , h = h , headerH = headerH , rows = rows , names = names }
end
function editorDrawMarkPicker ( )
if not ( DEBUG and markPicker ) then return end
local L = markPickerLayout ( )
local prevFont = love.graphics . getFont ( )
love.graphics . setFont ( font )
love.graphics . setColor ( 0 , 0 , 0 , 0.92 )
love.graphics . rectangle ( " fill " , L.x , L.y , L.w , L.h )
love.graphics . setColor ( 0.4 , 1 , 0.9 , 1 )
love.graphics . rectangle ( " line " , L.x , L.y , L.w , L.h )
love.graphics . printf ( " SEND NPC TO (type to filter, Enter = top, Esc cancels) " , L.x + 8 , L.y + 6 , L.w - 16 , " left " )
love.graphics . setColor ( 1 , 1 , 1 , 1 )
love.graphics . printf ( " > " .. inputText , L.x + 8 , L.y + 24 , L.w - 16 , " left " )
if # L.names == 0 then
love.graphics . setColor ( 0.7 , 0.7 , 0.7 , 1 )
love.graphics . printf ( " (no marks — place some with the MARK tool) " , L.x + 8 , L.y + L.headerH + 4 , L.w - 16 , " left " )
love.graphics . setFont ( prevFont )
return
end
local mx , my = love.mouse . getPosition ( )
for _ , r in ipairs ( L.rows ) do
local hover = mx >= r.x and mx <= r.x + r.w and my >= r.y and my <= r.y + r.h
love.graphics . setColor ( 0.15 , hover and 0.5 or 0.15 , hover and 0.45 or 0.15 , hover and 0.6 or 0.4 )
love.graphics . rectangle ( " fill " , r.x , r.y , r.w , r.h )
love.graphics . setColor ( 1 , 1 , 1 , 1 )
love.graphics . printf ( r.name , r.x + 6 , r.y + 5 , r.w - 12 , " left " )
end
love.graphics . setFont ( prevFont )
end
-- Screen-space click while the picker is open: choose a row, or dismiss on an
-- outside click. Returns true when the click was consumed.
function markPickerMousepressed ( x , y , button )
if not markPicker then return false end
local L = markPickerLayout ( )
if button == 1 then
for _ , r in ipairs ( L.rows ) do
if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then
sendToMark ( r.name )
return true
end
end
end
closeMarkPicker ( )
return true
end
-- The mark name modal, drawn over the play area in screen space. Independent of
-- the note editor; it just reads the shared `inputText` filled via editorText-
-- Handler. Target-red to match the mark icon.
function editorDrawMarkNameEditor ( )
if not ( DEBUG and markNameMode ) then return end
local w , h = 340 , 92
local x = math.max ( 10 , math.floor ( ( sideBarBox.x - w ) / 2 ) )
local y = math.max ( 10 , math.floor ( ( height - h ) / 2 ) )
local prevFont = love.graphics . getFont ( )
love.graphics . setFont ( font )
love.graphics . setColor ( 0 , 0 , 0 , 0.92 )
love.graphics . rectangle ( " fill " , x , y , w , h )
love.graphics . setColor ( 1 , 0.35 , 0.25 , 1 )
love.graphics . rectangle ( " line " , x , y , w , h )
local title = markNameMode == " edit " and " RENAME MARK " or " NAME THIS MARK "
love.graphics . printf ( title .. " (Enter = ok, Esc cancels) " , x + 10 , y + 8 , w - 20 , " left " )
love.graphics . setColor ( 1 , 1 , 1 , 1 )
love.graphics . rectangle ( " line " , x + 10 , y + 34 , w - 20 , 30 )
love.graphics . printf ( " > " .. inputText , x + 16 , y + 42 , w - 32 , " left " )
love.graphics . setFont ( prevFont )
end
-- Any click while naming commits the current text (a click is a blur = save).
function markNameEditorMousepressed ( _ , _ , _ )
if not markNameMode then return false end
commitMarkName ( )
return true
end
2026-08-11 16:24:51 -04:00
function selectQuickAsset ( asset )
selectAsset ( asset )
boxPaletteOpen = asset == " box "
boxPaletteScroll.scroll = 0
end
2015-03-25 01:38:53 -04:00
function editAsset ( asset )
inputText = " "
editorView = " asset "
selectedAsset = asset
2015-03-25 03:58:37 -04:00
assetProperties = gameAssets [ selectedAsset ]
2015-03-25 01:38:53 -04:00
local cancelButton = createUIElement {
name = " cancel " ,
sprite = cancelSprite ,
2026-08-11 12:24:54 -04:00
x = sb.w / 3 ,
y = sb.h - 75 ,
2015-03-25 01:38:53 -04:00
onClic = {
left = function ( self )
assetsView ( )
end ,
right = function ( self ) return end
}
}
local confirmButton = createUIElement {
name = " confirm " ,
sprite = confirmSprite ,
2026-08-11 12:24:54 -04:00
x = sb.w * 2 / 3 ,
y = sb.h - 75 ,
2015-03-25 01:38:53 -04:00
onClic = {
left = function ( self )
saveAsset ( )
end ,
right = function ( self ) return end
}
}
editingAssetButtons [ cancelButton ] = cancelButton
editingAssetButtons [ confirmButton ] = confirmButton
local assetSprites = { }
2015-03-25 03:58:37 -04:00
for color , path in pairs ( assetProperties.sprites ) do
2015-03-25 01:38:53 -04:00
assetSprites [ color ] = love.graphics . newImage ( path )
end
local assetButton = createUIElement {
name = asset ,
sprites = assetSprites ,
2026-08-11 12:24:54 -04:00
x = sb.w / 2 - 18 ,
2015-03-25 01:38:53 -04:00
y = 80 ,
}
local classFieldLabel = {
2026-08-11 12:24:54 -04:00
draw = function ( ) love.graphics . printf ( " CLASS " , 0 , 125 , sb.w , " center " ) end
2015-03-25 01:38:53 -04:00
}
labels [ classFieldLabel ] = classFieldLabel
local classButton = createUIElement {
name = " class " ,
text = assetProperties.class or " " ,
x = 25 ,
y = 150 ,
onClic = {
left = function ( )
activeField = " class "
end
}
}
2015-03-25 03:58:37 -04:00
local preloadLabel = {
2026-08-11 12:24:54 -04:00
draw = function ( ) love.graphics . printf ( " preload " , 0 , 225 , sb.w , " center " ) end
2015-03-25 03:58:37 -04:00
}
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
}
}
2015-03-25 01:38:53 -04:00
editingAssetButtons [ assetButton ] = assetButton
editingAssetButtons.class = classButton
2015-03-25 03:58:37 -04:00
editingAssetButtons.preload = preloadButton
2015-03-25 01:38:53 -04:00
end
function saveAsset ( )
2015-03-25 03:58:37 -04:00
gameAssets [ selectedAsset ] = assetProperties
2026-08-07 00:56:33 -04:00
-- 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 ) )
2015-03-25 01:38:53 -04:00
assetsView ( )
end
function _addUIButton ( button , name )
local index = name or button
uiButtons [ index ] = button
end
function folderView ( )
selectedFolder = nil
2026-08-11 12:24:54 -04:00
assetListButtons = { }
listScroll.scroll = 0
2015-03-25 01:38:53 -04:00
labels = { }
end
function assetsView ( )
editingAssetButtons = { }
editorView = " room "
2026-08-11 22:55:58 -04:00
wireBrush = nil
2015-03-25 01:38:53 -04:00
labels = { }
end
2026-08-11 22:55:58 -04:00
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
2026-08-13 12:45:41 -04:00
exitNoteMode ( )
2026-08-11 22:55:58 -04:00
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. "
2026-08-12 00:12:26 -04:00
or " Drag from a wired door and release on a switch. "
2026-08-11 22:55:58 -04:00
end
2015-03-25 01:38:53 -04:00
function worldView ( )
2026-08-13 12:45:41 -04:00
exitNoteMode ( )
2026-08-07 01:39:47 -04:00
editorView = " world "
2026-08-11 22:55:58 -04:00
wireBrush = nil
2026-08-07 01:39:47 -04:00
activeField = nil
selectedFolder = nil
2026-08-11 16:24:51 -04:00
selectedRoom = nil
cloneSourceRoom = nil
2026-08-07 01:39:47 -04:00
worldPage = 1
rebuildWorldRoomButtons ( )
end
2026-08-11 22:55:58 -04:00
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
2026-08-12 00:12:26 -04:00
editorStatus = " Start by dragging from a wired door. "
2026-08-11 22:55:58 -04:00
return
end
2026-08-12 00:12:26 -04:00
-- 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.
2026-08-11 22:55:58 -04:00
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
2026-08-07 01:39:47 -04:00
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
2015-03-25 01:38:53 -04:00
2026-08-11 16:24:51 -04:00
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
2026-08-11 22:55:58 -04:00
-- 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
2026-08-11 16:24:51 -04:00
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
2026-08-07 01:39:47 -04:00
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
2026-08-11 21:49:32 -04:00
gameWorld : setLevel ( currentRoom )
2026-08-07 03:22:28 -04:00
gameWorld : setLastRoom ( currentRoom.name )
2026-08-07 01:39:47 -04:00
setEditorRoomName ( currentRoom.name )
assetsView ( )
2015-03-25 01:38:53 -04:00
end
function editorMouseHandler ( x , y , button )
2026-08-21 15:56:53 -04:00
-- The mark modals (destination picker, name entry) are drawn over the play
-- area in raw screen coords, so they get first refusal before any sidebar/grid
-- handling.
if markPicker and markPickerMousepressed ( x , y , button ) then return end
if markNameMode and markNameEditorMousepressed ( x , y , button ) then return end
2026-08-12 14:52:06 -04:00
if x >= sideBarBox.x and x < sideBarBox.x + sideBarBox.w then
2026-08-11 12:24:54 -04:00
-- map from real window coords into the sidebar's fixed virtual space
-- (same transform used to scale the sidebar canvas up in love.draw)
2026-08-12 14:52:06 -04:00
x = ( x - sideBarBox.x ) * sb.w / sideBarBox.w
2026-08-11 12:24:54 -04:00
y = y * sb.h / sideBarBox.h
2015-03-25 01:38:53 -04:00
if editorView == " room " then
2026-08-11 12:24:54 -04:00
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 ( )
2026-08-11 16:24:51 -04:00
elseif boxPaletteOpen then
boxPaletteOpen = false
boxPaletteScroll.scroll = 0
2026-08-11 12:24:54 -04:00
end
return
end
if Layout.hit ( s.searchBox , x , y ) then
2026-08-11 16:24:51 -04:00
boxPaletteOpen = false
2026-08-07 09:08:34 -04:00
beginSearch ( " assetSearch " )
return
2015-03-25 01:38:53 -04:00
end
2026-08-13 12:45:41 -04:00
-- 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
2026-08-11 16:24:51 -04:00
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
2026-08-11 12:24:54 -04:00
end
2026-08-21 16:34:01 -04:00
-- adhesion dropdown (sits next to the colour picker; its open menu
-- overlaps the area above the controls, so test it before the buttons)
if button == 1 and adhesionDropdownClick ( x , y ) then return end
2026-08-11 12:24:54 -04:00
-- control cluster (only the buttons that live there)
2026-08-21 15:56:53 -04:00
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 " , " mark " } ) do
2026-08-11 12:24:54 -04:00
local b = uiButtons [ name ]
if b and b : clicIsInBox ( x , y ) then
b : onClic ( button )
return
2015-03-25 01:38:53 -04:00
end
end
2026-08-11 22:55:58 -04:00
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
2015-03-25 01:38:53 -04:00
elseif editorView == " asset " then
for _ , uiElement in pairs ( editingAssetButtons ) do
if uiElement : clicIsInBox ( x , y ) then
uiElement : onClic ( button )
end
end
2026-08-07 01:39:47 -04:00
elseif editorView == " world " then
2026-08-11 16:24:51 -04:00
if activeField == " cloneName " then return end
2026-08-07 09:08:34 -04:00
if buttonContains ( worldSearchBox , x , y ) then
beginSearch ( " worldSearch " )
return
end
2026-08-07 01:39:47 -04:00
for _ , roomButton in ipairs ( worldRoomButtons ) do
if buttonContains ( roomButton , x , y ) then
2026-08-11 16:24:51 -04:00
if button ~= 1 then return end
2026-08-07 01:39:47 -04:00
if selectedRoom == roomButton.name then
selectedRoom = nil
2026-08-11 16:24:51 -04:00
editorStatus = " Placement cancelled. Click the map to open or create a level. "
2026-08-07 01:39:47 -04:00
else
selectedRoom = roomButton.name
2026-08-11 16:24:51 -04:00
editorStatus = " Selected ' " .. selectedRoom .. " '. Click a map cell to place it. "
2026-08-07 01:39:47 -04:00
end
return
end
end
for _ , control in ipairs ( worldControls ) do
if buttonContains ( control , x , y ) then
2026-08-07 09:08:34 -04:00
control.onClic ( x )
2026-08-07 01:39:47 -04:00
return
end
end
if uiButtons.world : clicIsInBox ( x , y ) then uiButtons.world : onClic ( button ) end
2015-03-25 01:38:53 -04:00
end
2026-08-06 23:54:49 -04:00
else
-- click landed on the grid (game area): place the selected asset
2026-08-11 14:11:57 -04:00
if editorView == " room " then
2026-08-13 12:45:41 -04:00
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
2026-08-21 15:56:53 -04:00
-- Placing a new mark: drop a blank one, then open its name modal.
if cell and markPlacing then
markPlacing = false
local mark = currentRoom : attemptPlaceAtCell ( " mark " , nil , cell , nil , " " )
if mark then openMarkNameEditor ( mark , " create " ) end
return
end
-- Left-clicking an existing note/mark opens it for editing instead of
-- painting over it; a Walker NPC opens the debug destination picker.
2026-08-13 12:45:41 -04:00
if cell then
local note = currentRoom : noteAtCell ( cell )
if note then beginNoteEdit ( note ) ; return end
2026-08-21 15:56:53 -04:00
local mark = currentRoom : markAtCell ( cell )
if mark then beginMarkEdit ( mark ) ; return end
local npc = currentRoom : npcAtCell ( cell )
if npc and npc.sendTo then openMarkPicker ( npc ) ; return end
2026-08-13 12:45:41 -04:00
end
2026-08-11 14:11:57 -04:00
beginRoomBrush ( x , y , button )
2026-08-11 22:55:58 -04:00
elseif editorView == " wire " then
beginWireBrush ( x , y , button )
2026-08-07 01:39:47 -04:00
elseif editorView == " world " then
local cellX , cellY = gameWorld : getCellFromMousePos ( x , y )
if not gameWorld : isInBounds ( cellX , cellY ) then return end
if button == 2 then
2026-08-11 16:24:51 -04:00
local name = gameWorld : roomAt ( cellX , cellY )
if name then beginCloneDialog ( name ) end
2026-08-07 01:39:47 -04:00
elseif selectedRoom then
2026-08-11 16:24:51 -04:00
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 )
2026-08-07 01:39:47 -04:00
end
2026-08-06 23:54:49 -04:00
end
2015-03-25 01:38:53 -04:00
end
end
2026-08-11 14:11:57 -04:00
function editorMouseMoved ( x , y )
2026-08-13 12:45:41 -04:00
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
2026-08-11 22:55:58 -04:00
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
2026-08-11 14:11:57 -04:00
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 )
2026-08-13 12:45:41 -04:00
noteEditorMousereleased ( )
2026-08-11 14:11:57 -04:00
if roomBrush and roomBrush.button == button then roomBrush = nil end
2026-08-11 22:55:58 -04:00
if wireBrush and wireBrush.button == button then wireBrush = nil end
2026-08-11 14:11:57 -04:00
end
2015-03-25 01:38:53 -04:00
function editorTextHandler ( text )
2026-08-21 15:56:53 -04:00
if markPicker or markNameMode then
inputText = text
return
end
2015-03-25 01:38:53 -04:00
if editorView == " asset " and activeField then
inputText = text
editingAssetButtons [ activeField ] : editText ( text )
2026-08-07 01:39:47 -04:00
elseif editorView == " room " and activeField == " roomName " then
inputText = text
roomNameField : editText ( text )
2026-08-07 09:08:34 -04:00
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 ( )
2026-08-11 16:24:51 -04:00
elseif editorView == " world " and activeField == " cloneName " then
inputText = text
2015-03-25 01:38:53 -04:00
end
end
function editorCompleteResponse ( )
2026-08-21 15:56:53 -04:00
if markPicker then
local names = markNamesMatching ( inputText )
if names [ 1 ] then sendToMark ( names [ 1 ] ) else closeMarkPicker ( ) end
return
end
if markNameMode then commitMarkName ( ) ; return end
2026-08-07 01:39:47 -04:00
if editorView == " asset " and activeField then
2015-03-25 01:38:53 -04:00
assetProperties [ activeField ] = inputText
2026-08-07 01:39:47 -04:00
elseif editorView == " room " and activeField == " roomName " then
editorRoomName = inputText
2026-08-07 09:08:34 -04:00
elseif editorView == " room " and activeField == " assetSearch " then
assetSearchText = inputText
elseif editorView == " world " and activeField == " worldSearch " then
worldSearchText = inputText
2026-08-11 16:24:51 -04:00
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
2015-03-25 01:38:53 -04:00
end
activeField = nil
end
2026-08-11 16:24:51 -04:00
function editorCancelResponse ( )
2026-08-21 15:56:53 -04:00
if markPicker then closeMarkPicker ( ) ; return true end
if markNameMode then cancelMarkName ( ) ; return true end
if markPlacing then markPlacing = false ; editorStatus = " Mark placement cancelled. " ; return true end
2026-08-11 22:55:58 -04:00
if editorView == " wire " then discardWireRooms ( ) ; return true end
2026-08-11 16:24:51 -04:00
if editorView ~= " world " or activeField ~= " cloneName " then return false end
activeField = nil
cloneSourceRoom = nil
inputText = " "
response = " "
editorStatus = " Clone cancelled. "
return true
end
2015-03-25 01:38:53 -04:00
function sortAllAssets ( )
setAll ( )
end
function createUIElement ( t )
return GUI : new ( t )
end
2026-08-13 12:45:41 -04:00
-- 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
2015-03-25 01:38:53 -04:00
function editorDraw ( )
if editorView == " room " then
2026-08-11 12:24:54 -04:00
local s = buildRoomLayout ( )
2026-08-07 09:08:34 -04:00
love.graphics . setColor ( gColor.white : set ( ) )
2026-08-11 12:24:54 -04:00
-- 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 )
2026-08-11 16:24:51 -04:00
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
2015-03-25 01:38:53 -04:00
end
2026-08-11 12:24:54 -04:00
love.graphics . rectangle ( " line " , s.searchBox . x , s.searchBox . y , s.searchBox . w , s.searchBox . h )
2026-08-11 20:29:35 -04:00
love.graphics . printf ( boxPaletteOpen and " QUICK PALETTE " or " SEARCH: " .. assetSearchText , s.searchBox . x + 6 , s.searchBox . y + 9 , s.searchBox . w - 12 , " left " )
2026-08-11 16:24:51 -04:00
2026-08-13 12:45:41 -04:00
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
2026-08-11 16:24:51 -04:00
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
2026-08-11 14:11:57 -04:00
end
2015-03-25 01:38:53 -04:00
end
2026-08-11 16:24:51 -04:00
Layout.unclip ( )
Layout.scrollbar ( s.list )
2015-03-25 01:38:53 -04:00
end
2026-08-11 12:24:54 -04:00
-- control cluster
love.graphics . setColor ( gColor.white : set ( ) )
love.graphics . printf ( " LEVEL NAME " , s.controls . x , s.labelY , s.controls . w , " center " )
2026-08-21 15:56:53 -04:00
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 " , " mark " } ) do
2026-08-11 16:24:51 -04:00
local button = uiButtons [ name ]
2026-08-11 22:55:58 -04:00
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
2026-08-21 15:56:53 -04:00
if ( asset and selectedAsset == asset )
or ( name == " note " and noteMode ~= nil )
or ( name == " mark " and ( markPlacing or markNameMode ~= nil ) ) then
2026-08-11 16:24:51 -04:00
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
2026-08-11 12:24:54 -04:00
end
2026-08-21 16:34:01 -04:00
-- adhesion dropdown, drawn last so its open menu sits above the controls
drawAdhesionDropdown ( )
2026-08-13 12:45:41 -04:00
-- 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
2026-08-11 12:24:54 -04:00
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
2026-08-11 22:55:58 -04:00
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 (
2026-08-12 00:12:26 -04:00
" 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 \n Left-drag draws. Right-drag erases. Independent wires may share a cell without connecting. \n \n Arrow keys pan by one room. " ,
2026-08-11 22:55:58 -04:00
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
2015-03-25 01:38:53 -04:00
elseif editorView == " asset " then
for _ , button in pairs ( editingAssetButtons ) do
button : draw ( )
end
2026-08-07 01:39:47 -04:00
elseif editorView == " world " then
drawWorldSidebar ( )
2015-03-25 01:38:53 -04:00
end
for _ , label in pairs ( labels ) do
label : draw ( )
end
2026-08-11 12:24:54 -04:00
-- The room view draws its status inside its own strip (see the room branch);
-- the other views keep the simple centered line.
2026-08-11 22:55:58 -04:00
if editorStatus and editorView ~= " room " and editorView ~= " wire " then
2026-08-07 01:39:47 -04:00
love.graphics . setColor ( gColor.white : set ( ) )
2026-08-11 12:24:54 -04:00
love.graphics . printf ( editorStatus , 10 , sb.h * ( 6 / 10 ) , sb.w - 20 , " center " )
2026-08-07 01:39:47 -04:00
end
2015-03-25 01:38:53 -04:00
end