wired switches and doors

This commit is contained in:
Your Name 2026-08-11 22:55:58 -04:00
parent ee7d36e72b
commit bc4976b324
16 changed files with 2575 additions and 835 deletions

View file

@ -44,6 +44,11 @@ local worldSearchText = ""
local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 }
local cloneSourceRoom = nil
local roomBrush = nil
local wireBrush = nil
local selectedCircuitId = nil
local wireCameraX, wireCameraY = nil, nil
local wireDirtyRooms = {}
local wireOriginalWires = nil
local boxPaletteOpen = false
local boxPaletteScroll = { scroll = 0 }
local boxPaletteSections = {}
@ -273,6 +278,8 @@ local function layoutRoomControls(c, s)
end
b.scale = math.min(target / spriteW, target / spriteH)
b.w, b.h = spriteW * b.scale, spriteH * b.scale
else
b.w, b.h = 72, target
end
return b
end
@ -296,8 +303,8 @@ local function layoutRoomControls(c, s)
end
placeRow({ "white", "red", "green", "blue" }, 22)
placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world" }, 28)
placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world", "wire" }, 28)
end
-- Build the full room-view layout (sections + a scroll list + a back/search
@ -497,6 +504,69 @@ local function saveRoomFromEditor()
roomNameField:editText(name)
activeField = nil
editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or " without player positions.")
-- A circuit stroke can touch neighbouring rooms in one gesture. Save those
-- authored fragments together so a cross-room route cannot be half-written.
for room in pairs(wireDirtyRooms) do
if room ~= currentRoom and room.name then
room:saveWiresToSource()
gameWorld:invalidatePreview(room.name)
clearRuntimeSaveData(room.name)
end
end
wireDirtyRooms = {}
end
local function copyWires(wires)
local copy = {}
for y, row in pairs(wires or {}) do
copy[y] = {}
for x, tracks in pairs(row) do
copy[y][x] = {}
for circuitId, mask in pairs(tracks) do copy[y][x][circuitId] = mask end
end
end
return copy
end
local function resumeWireRoom(status)
local name = wireCameraX and wireCameraY and gameWorld:roomAt(wireCameraX, wireCameraY)
local room = name and gameWorld.levels[name]
if not room then
room = currentRoom
status = status .. " The centered world cell is empty, so the previous room remains open."
end
currentRoom = room
gameWorld:setLevel(currentRoom)
gameWorld:setLastRoom(currentRoom.name)
setEditorRoomName(currentRoom.name)
wireOriginalWires = nil
wireDirtyRooms = {}
assetsView()
editorStatus = status
end
local function saveWireRooms()
local saved = 0
for room in pairs(wireDirtyRooms) do
local success = room == currentRoom and room:saveToSource(room.name) or room:saveWiresToSource()
if room.name and success then
saved = saved + 1
gameWorld:invalidatePreview(room.name)
clearRuntimeSaveData(room.name)
end
end
suppressRuntimeAutosave = true
local status = saved > 0 and ("Saved wire fragments in " .. saved .. " room(s).") or "No wire changes to save."
resumeWireRoom(status)
end
local function discardWireRooms()
for room, wires in pairs(wireOriginalWires or {}) do room.wires = copyWires(wires) end
gameWorld:resolveCircuits()
gameWorld:rebuildCollision()
resumeWireRoom("Discarded wire changes.")
end
local function clearRuntimeSaves()
@ -631,7 +701,7 @@ function _initLevelEditor()
}, color)
end
for _, asset in ipairs({ "player", "static_wall", "switch", "door", "box" }) do
for _, asset in ipairs({ "player", "static_wall", "switch", "door", "wired_switch", "wired_door", "box" }) do
local properties = gameAssets[asset]
if properties then
local sprites = {}
@ -650,6 +720,16 @@ function _initLevelEditor()
end
end
_addUIButton(createUIElement{
name = "wire",
text = "WIRES",
onClic = { left = function()
if editorView == "wire" then assetsView() else wireView() end
end },
x = 0,
y = 0,
}, "wire")
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
@ -839,11 +919,31 @@ end
function assetsView()
editingAssetButtons = {}
editorView = "room"
wireBrush = nil
labels = {}
end
function wireView()
local location = currentRoom and gameWorld:roomLocation(currentRoom.name)
if not location then
editorStatus = "Place this room in the world before drawing cross-room wires."
return
end
editorView = "wire"
activeField = nil
roomBrush = nil
wireCameraX, wireCameraY = location.x, location.y
wireOriginalWires = {}
for _, room in pairs(gameWorld.levels or {}) do wireOriginalWires[room] = copyWires(room.wires) end
wireDirtyRooms = {}
editorStatus = selectedCircuitId
and "Circuit selected. Drag to draw; right-drag erases; arrow keys pan."
or "Click a wired door to select its circuit, then drag to its switches."
end
function worldView()
editorView = "world"
wireBrush = nil
activeField = nil
selectedFolder = nil
selectedRoom = nil
@ -852,6 +952,118 @@ function worldView()
rebuildWorldRoomButtons()
end
local function wireControls()
local pad, gap, h = 12, 8, 40
local w = (sb.w - pad * 2 - gap) / 2
return {
save = {x = pad, y = sb.h - pad - h, w = w, h = h, text = "SAVE"},
discard = {x = pad + w + gap, y = sb.h - pad - h, w = w, h = h, text = "DISCARD"},
}
end
local function rememberWireRooms(rooms)
for _, room in ipairs(rooms or {}) do wireDirtyRooms[room] = true end
end
local function wireCellAt(x, y)
if x < 0 or x >= width or y < 0 or y >= height then return nil end
if not wireCameraX or not wireCameraY then return nil end
local cell = gameWorld:wireEditorCellAt(x, y, wireCameraX, wireCameraY)
local room = gameWorld:roomCell(cell)
return room and cell or nil
end
local function paintWireStep(from, to, button)
if button == 1 then
local rooms, err = gameWorld:addWireConnection(from, to, selectedCircuitId)
rememberWireRooms(rooms)
if err then editorStatus = err end
else
rememberWireRooms(gameWorld:removeWireAt(to, selectedCircuitId))
end
end
local function paintWireLine(from, to, button)
local current = {x = from.x, y = from.y}
local function walk(axis, target)
local step = current[axis] < target and 1 or -1
while current[axis] ~= target do
local nextCell = {x = current.x, y = current.y}
nextCell[axis] = nextCell[axis] + step
local room = gameWorld:roomCell(nextCell)
if not room then return false end
paintWireStep(current, nextCell, button)
current = nextCell
end
return true
end
-- Manhattan interpolation keeps every authored segment orthogonal even if
-- the mouse skips diagonally across several cells between move events.
if not walk("x", to.x) then return current end
walk("y", to.y)
return current
end
local function beginWireBrush(x, y, button)
if button ~= 1 and button ~= 2 then return end
local cell = wireCellAt(x, y)
if not cell then return end
if button == 1 then
local door = gameWorld:wiredDoorAt(cell)
if door then
-- Each stroke leaving a door is its own run and may terminate at one
-- logical switch. Multiple runs can meet at the door, whose state is the
-- AND of those independently coloured switch terminals.
selectedCircuitId = gameWorld:newCircuitId()
editorStatus = "Started " .. selectedCircuitId .. ". End this run on one switch."
else
-- A wire is a selectable terminal even after its door is deleted.
-- Clicking empty space keeps the current circuit so a new branch can
-- still begin away from the existing route.
local circuitAtCell = gameWorld:circuitAt(cell)
if circuitAtCell then
selectedCircuitId = circuitAtCell
editorStatus = "Selected orphaned circuit " .. selectedCircuitId .. "."
end
end
if not selectedCircuitId then
editorStatus = "Start by clicking a wired door."
return
end
rememberWireRooms(gameWorld:ensureWireAt(cell, selectedCircuitId))
else
-- Prefer the selected circuit when several routes overlap here; otherwise
-- adopt the top route. Repeated right-clicks can therefore remove every
-- orphan at an overlapping cell without needing a door.
local circuitAtCell = gameWorld:circuitAt(cell, selectedCircuitId)
if not circuitAtCell then
editorStatus = "There is no wire at this cell to erase."
return
end
selectedCircuitId = circuitAtCell
rememberWireRooms(gameWorld:removeWireAt(cell, selectedCircuitId))
editorStatus = "Erasing " .. selectedCircuitId .. "."
end
wireBrush = {button = button, lastCell = cell}
end
function editorWireKeypressed(key)
if editorView ~= "wire" then return false end
local directions = {
left = {-1, 0}, right = {1, 0}, up = {0, -1}, down = {0, 1},
}
local direction = directions[key]
if not direction then return false end
wireCameraX = constrain(wireCameraX + direction[1], 1, gameWorld.width)
wireCameraY = constrain(wireCameraY + direction[2], 1, gameWorld.height)
wireBrush = nil
return true
end
function wireEditorCamera()
return wireCameraX, wireCameraY, selectedCircuitId
end
local function newWorldRoomName(cellX, cellY)
local base = string.format("room_%02d_%02d", cellX, cellY)
local name, suffix = base, 2
@ -872,6 +1084,23 @@ local function cloneWorldRoom(sourceName, cloneName)
editorStatus = "A level named '" .. cloneName .. "' already exists."
return false
end
-- A clone is a new puzzle slice, so remap every circuit identity found in
-- it. Keeping the originals would let the clone silently join the source's
-- cross-room wires when both are placed in the same world.
local ok, saveTable = pcall(TSerial.unpack, source)
if ok and type(saveTable) == "table" then
local remapped = {}
local function remap(circuitId)
if not circuitId then return nil end
if not remapped[circuitId] then remapped[circuitId] = gameWorld:newCircuitId() end
return remapped[circuitId]
end
for _, object in ipairs(saveTable.objects or {}) do
if object.circuit then object.circuit = remap(object.circuit) end
end
for _, wire in ipairs(saveTable.wires or {}) do wire.circuit = remap(wire.circuit) end
source = TSerial.pack(saveTable, nil, true)
end
if not writeToSource("rooms/" .. cloneName .. ".sav", source) then
editorStatus = "Could not create clone '" .. cloneName .. "'."
return false
@ -961,13 +1190,17 @@ function editorMouseHandler(x, y, button)
end
-- control cluster (only the buttons that live there)
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire" }) do
local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then
b:onClic(button)
return
end
end
elseif editorView == "wire" then
local controls = wireControls()
if buttonContains(controls.save, x, y) then saveWireRooms(); return end
if buttonContains(controls.discard, x, y) then discardWireRooms(); return end
elseif editorView == "asset" then
for _, uiElement in pairs(editingAssetButtons) do
if uiElement:clicIsInBox(x, y) then
@ -1005,6 +1238,8 @@ function editorMouseHandler(x, y, button)
-- click landed on the grid (game area): place the selected asset
if editorView == "room" then
beginRoomBrush(x, y, button)
elseif editorView == "wire" then
beginWireBrush(x, y, button)
elseif editorView == "world" then
local cellX, cellY = gameWorld:getCellFromMousePos(x, y)
if not gameWorld:isInBounds(cellX, cellY) then return end
@ -1027,6 +1262,15 @@ function editorMouseHandler(x, y, button)
end
function editorMouseMoved(x, y)
if wireBrush then
if editorView ~= "wire" then wireBrush = nil; return end
local cell = wireCellAt(x, y)
if not cell then wireBrush = nil; return end
if cell.x ~= wireBrush.lastCell.x or cell.y ~= wireBrush.lastCell.y then
wireBrush.lastCell = paintWireLine(wireBrush.lastCell, cell, wireBrush.button)
end
return
end
if not roomBrush then return end
if editorView ~= "room" then
roomBrush = nil
@ -1048,6 +1292,7 @@ end
function editorMouseReleased(_, _, button)
if roomBrush and roomBrush.button == button then roomBrush = nil end
if wireBrush and wireBrush.button == button then wireBrush = nil end
end
function editorTextHandler(text)
@ -1097,6 +1342,7 @@ function editorCompleteResponse()
end
function editorCancelResponse()
if editorView == "wire" then discardWireRooms(); return true end
if editorView ~= "world" or activeField ~= "cloneName" then return false end
activeField = nil
cloneSourceRoom = nil
@ -1156,11 +1402,15 @@ function editorDraw()
-- control cluster
love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center")
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire" }) do
local button = uiButtons[name]
if button then
button:draw()
local asset = name:match("^quick_(.+)$")
if button then
button:draw()
local asset = name:match("^quick_(.+)$")
if asset and asset:match("^wired_") then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("W", button.x + button.w - 7, button.y - 4)
end
if asset and selectedAsset == asset then
love.graphics.setColor(1, 0.85, 0.2, 1)
love.graphics.setLineWidth(2)
@ -1176,6 +1426,32 @@ function editorDraw()
love.graphics.printf(editorStatus, s.status.x, s.status.y + 4, s.status.w, "center")
Layout.unclip()
end
elseif editorView == "wire" then
local circuitColor = selectedCircuitId and require("wire").color(selectedCircuitId)
love.graphics.setColor(gColor.white:set())
love.graphics.printf("CIRCUIT WIRING", 10, 16, sb.w - 20, "center")
if selectedCircuitId then
love.graphics.setColor(circuitColor[1], circuitColor[2], circuitColor[3], 1)
love.graphics.circle("fill", 28, 65, 9)
love.graphics.setColor(gColor.white:set())
love.graphics.printf(selectedCircuitId, 46, 54, sb.w - 58, "left")
else
love.graphics.printf("No circuit selected", 10, 54, sb.w - 20, "center")
end
love.graphics.printf(
"Click a door to start a new run, or a wire to edit that run. Each run ends at one switch; start another run from the door for another switch.\n\nLeft-drag draws. Right-drag erases. Independent wires may share a cell without connecting.\n\nArrow keys pan by one room.",
24, 100, sb.w - 48, "left"
)
if editorStatus then
love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 24, sb.h * 0.55, sb.w - 48, "center")
end
local controls = wireControls()
for _, control in pairs(controls) do
love.graphics.setColor(gColor.white:set())
love.graphics.rectangle("line", control.x, control.y, control.w, control.h)
love.graphics.printf(control.text, control.x, control.y + 11, control.w, "center")
end
elseif editorView == "asset" then
for _, button in pairs(editingAssetButtons) do
button:draw()
@ -1189,7 +1465,7 @@ function editorDraw()
end
-- The room view draws its status inside its own strip (see the room branch);
-- the other views keep the simple centered line.
if editorStatus and editorView ~= "room" then
if editorStatus and editorView ~= "room" and editorView ~= "wire" then
love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center")
end

View file

@ -59,5 +59,23 @@ function getAllAssets()
end
end
-- Wired devices intentionally reuse the familiar switch/door artwork. Their
-- white terminal marker is drawn by the entity class, while these synthetic
-- asset records make them available to the ordinary palette and brush.
local function synthetic(name, source, behavior)
local original = globalAssetProperties[source]
if not original then return end
globalAssetProperties[name] = {
directory = original.directory,
sprites = original.sprites,
cells = original.cells,
class = behavior,
preload = false,
synthetic = true,
}
end
synthetic("wired_switch", "switch", "wired_switch")
synthetic("wired_door", "door", "wired_door")
return BoxVariants.addTo(globalAssetProperties)
end