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 "
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 " )
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
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
-- 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-12 02:58:01 -04:00
currentRoom : attemptPlaceAtCell ( selectedAsset , gameAssets [ selectedAsset ] , cell , selectedColor )
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 ( )
boxPaletteSections = { }
for _ , prefix in ipairs ( { " box " , " spiky " , " sticky " , " skewer " } ) do
local names = { }
local pattern = prefix == " box " and " ^box " or " ^ " .. prefix .. " _box "
for name in pairs ( gameAssets ) do
if name : match ( pattern ) then table.insert ( names , name ) end
end
table.sort ( names )
table.insert ( boxPaletteSections , { prefix = prefix , names = names } )
end
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 ( ) )
if props.goopy and drawGoopySprite then
drawGoopySprite ( sprite , drawX , drawY , scale )
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite ( sprite , props.cells and props.cells [ color ] , drawX , drawY , scale )
elseif props.spiky and drawSpikySprite then
drawSpikySprite ( sprite , props.cells and props.cells [ color ] , drawX , drawY , scale )
else
love.graphics . draw ( sprite , drawX , drawY , 0 , scale , scale )
end
end
end
local function drawBoxPicker ( picker )
Layout.clip ( picker.rect )
for _ , section in ipairs ( picker.sections ) do
love.graphics . setColor ( gColor.white : set ( ) )
love.graphics . print ( section.prefix , picker.rect . x , picker.rect . y + section.y - picker.scroll )
end
for _ , item in ipairs ( picker.items ) do
local y = picker.rect . y + item.y - picker.scroll
if y + item.h > picker.rect . y and y < picker.rect . y + picker.rect . h then
love.graphics . setColor ( 1 , 1 , 1 , 0.45 )
love.graphics . rectangle ( " line " , item.x , y , item.w , item.h )
drawPaletteAsset ( item.name , item.x , y , item.w )
if item.name == selectedAsset then
love.graphics . setColor ( 1 , 0.85 , 0.2 , 1 )
love.graphics . setLineWidth ( 2 )
love.graphics . rectangle ( " line " , item.x + 1 , y + 1 , item.w - 2 , item.h - 2 )
end
end
end
Layout.unclip ( )
Layout.scrollbar ( picker )
end
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
-- 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
placeRow ( { " white " , " red " , " green " , " blue " } , 22 )
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 )
placeRow ( { " save " , " clearSaves " , " world " , " wire " } , 28 )
2026-08-11 12:24:54 -04:00
end
-- Build the full room-view layout (sections + a scroll list + a back/search
-- header) without drawing. Shared by editorDraw and editorMouseHandler.
local function buildRoomLayout ( )
local s = roomSections ( )
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 ,
2026-08-11 15:07:11 -04:00
goopy = gameAssets [ asset ] . goopy ,
skewer = gameAssets [ asset ] . skewer ,
2026-08-11 20:08:58 -04:00
spiky = gameAssets [ asset ] . spiky ,
2026-08-11 15:07:11 -04:00
cells = gameAssets [ asset ] . cells ,
2026-08-07 09:08:34 -04:00
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 ,
goopy = properties.goopy ,
skewer = properties.skewer ,
spiky = properties.spiky ,
cells = properties.cells ,
onClic = { left = function ( ) selectQuickAsset ( asset ) end } ,
x = 0 ,
y = 0 ,
} , " quick_ " .. asset )
end
end
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-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 ,
2026-08-11 15:07:11 -04:00
goopy = gameAssets [ asset ] . goopy ,
skewer = gameAssets [ asset ] . skewer ,
2026-08-11 20:08:58 -04:00
spiky = gameAssets [ asset ] . spiky ,
2026-08-11 15:07:11 -04:00
cells = gameAssets [ asset ] . cells ,
2015-03-25 01:38:53 -04:00
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
print ( selectedAsset )
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 15:07:11 -04:00
goopy = assetProperties.goopy ,
skewer = assetProperties.skewer ,
2026-08-11 20:08:58 -04:00
spiky = assetProperties.spiky ,
2026-08-11 15:07:11 -04:00
cells = assetProperties.cells ,
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
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-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-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-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
-- control cluster (only the buttons that live there)
2026-08-11 22:55:58 -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 " } ) 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
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-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 )
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 )
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-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-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
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
if s.boxPicker then
drawBoxPicker ( s.boxPicker )
else
-- Scrolling list, clipped to its section so nothing bleeds into others.
Layout.clip ( s.listRect )
for i , item in ipairs ( s.items ) do
if s.list . visible ( i ) then
item.x , item.y = s.list . pos ( i )
item.w = s.listRect . w
item : draw ( )
if item.name == " asset " and item.text == selectedAsset then
love.graphics . setColor ( 1 , 0.85 , 0.2 , 1 )
love.graphics . setLineWidth ( 3 )
love.graphics . rectangle ( " line " , item.x + 1 , item.y + 1 , item.w - 2 , item.h - 2 )
end
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-11 22:55:58 -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 " } ) 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-11 16:24:51 -04:00
if asset and selectedAsset == asset then
love.graphics . setColor ( 1 , 0.85 , 0.2 , 1 )
love.graphics . setLineWidth ( 2 )
love.graphics . rectangle ( " line " , button.x - 1 , button.y - 1 , button.w + 2 , button.h + 2 )
end
end
2026-08-11 12:24:54 -04:00
end
-- status strip between the list and the controls
if editorStatus then
Layout.clip ( s.status )
love.graphics . setColor ( gColor.white : set ( ) )
love.graphics . printf ( editorStatus , s.status . x , s.status . y + 4 , s.status . w , " center " )
Layout.unclip ( )
end
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