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

View file

@ -53,6 +53,8 @@ function love.load()
if gameWorld.levels[initialRoom] then
gameWorld.levels[initialRoom] = currentRoom
gameWorld:rebuildCollision()
gameWorld:resolveCircuits()
gameWorld:rebuildCollision()
end
gameWorld:setLastRoom(currentRoom.name)
setEditorRoomName(currentRoom.name)
@ -76,7 +78,7 @@ function love.quit()
end
function love.update(dt)
if editorView == "world" then
if editorView == "world" or editorView == "wire" then
Input:stopMoveRepeat()
else
local repeatedMove = Input:handler(dt)
@ -94,6 +96,8 @@ function love.draw()
love.graphics.setBlendMode("screen")
if editorView == "world" then
gameWorld:draw()
elseif editorView == "wire" then
gameWorld:drawWireEditor(wireEditorCamera())
else
currentRoom:draw()
end
@ -173,10 +177,12 @@ function love.keypressed(key)
return
end
if editorView ~= "world" and Input.isPlayerMove(key) then
if editorView == "wire" and editorWireKeypressed(key) then return end
if editorView ~= "world" and editorView ~= "wire" and Input.isPlayerMove(key) then
Input:beginMove(key)
gameWorld:movePlayer(currentRoom, key)
elseif editorView ~= "world" and Input.isPlayerSwitch(key) then
elseif editorView ~= "world" and editorView ~= "wire" and Input.isPlayerSwitch(key) then
local c = Input.playerColor(key)
gameWorld:switchPlayerColor(currentRoom, c)
end

206
room.lua
View file

@ -8,6 +8,8 @@ require "art"
require "assorted"
require "switch"
require "door"
require "wired_switch"
require "wired_door"
require "dialogue_manager"
require "generic_moveable"
require "generic_sticky"
@ -17,11 +19,15 @@ require "generic_unmoveable"
require "generic_player_blocker"
local Movement = require "movement"
local Wire = require "wire"
function Room:initialize(t)
self.player = {}
self.switches = { red = {}, green = {}, blue = {} }
self.doors = {}
self.wiredSwitches = {}
self.wiredDoors = {}
self.wires = {}
self.collideables = { red = {}, green = {}, blue = {} }
self.playerBlockers = { red = {}, green = {}, blue = {} }
self.npcs = {}
@ -125,6 +131,7 @@ function Room:update(dt)
end
function Room:draw()
self:drawWiresAt(0, 0, width / gridWidth, nil, false)
-- draw only the channels the player has. Each object just exposes sprites per
-- channel via Entity:draw(channel); a door registered on all three channels
@ -135,10 +142,117 @@ function Room:draw()
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then gameWorld:drawOverlaps(self, channel) end
for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end
for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end
for _, switch in ipairs(self.wiredSwitches) do switch:draw(channel) end
for _, npc in ipairs(self.npcs) do npc:draw(channel) end
end
end
function Room:wireTracksAt(cell, create)
local row = self.wires[cell.y]
if not row and create then row = {}; self.wires[cell.y] = row end
local tracks = row and row[cell.x]
if not tracks and create then tracks = {}; row[cell.x] = tracks end
return tracks
end
function Room:setWireMask(cell, circuitId, mask)
if not self:isInBounds(cell) then return false end
local tracks = self:wireTracksAt(cell, true)
tracks[circuitId] = mask or 0
return true
end
function Room:wireCircuitAt(cell)
local tracks = self:wireTracksAt(cell)
local ids = {}
for circuitId in pairs(tracks or {}) do table.insert(ids, circuitId) end
table.sort(ids)
return ids[1]
end
function Room:removeWire(cell, circuitId)
local tracks = self:wireTracksAt(cell)
if not tracks or tracks[circuitId] == nil then return false end
tracks[circuitId] = nil
if not next(tracks) then self.wires[cell.y][cell.x] = nil end
if not next(self.wires[cell.y]) then self.wires[cell.y] = nil end
return true
end
function Room:drawWiresAt(originX, originY, cellSize, selectedCircuit, editorColors)
local ids = {}
for _, row in pairs(self.wires) do
for _, tracks in pairs(row) do
for circuitId in pairs(tracks) do ids[circuitId] = true end
end
end
local ordered = {}
for circuitId in pairs(ids) do table.insert(ordered, circuitId) end
table.sort(ordered)
love.graphics.setBlendMode("alpha")
local visibleColors = {red = false, green = false, blue = false}
for _, color in ipairs(self.colorsPlayerHas or {}) do visibleColors[color] = true end
local editorIsOn = editorView ~= nil and editorView ~= "off"
for _, circuitId in ipairs(ordered) do
local selected = not selectedCircuit or selectedCircuit == circuitId
for y, row in pairs(self.wires) do
for x, tracks in pairs(row) do
local mask = tracks[circuitId]
if mask ~= nil then
local localCell = {x = x, y = y}
local switchColors = gameWorld and gameWorld.wireSwitchColorsAt and
gameWorld:wireSwitchColorsAt(self, localCell, circuitId)
local drawColors = {}
if editorColors then
-- Connected circuits retain their ID colour in the wiring tool;
-- switchless fragments are neutral so orphans are obvious.
table.insert(drawColors, switchColors and Wire.color(circuitId) or {0.52, 0.52, 0.58})
elseif switchColors then
-- Draw literal RGB passes taken from the switch entities at this
-- run's sole terminal. The room's player channels decide which
-- passes exist, exactly as they do for every other entity.
for _, channel in ipairs({"red", "green", "blue"}) do
if switchColors[channel] and visibleColors[channel] then
local channelColor = gColor[channel]
table.insert(drawColors, {channelColor.r, channelColor.g, channelColor.b})
end
end
elseif editorIsOn then
table.insert(drawColors, {0.52, 0.52, 0.58})
end
if #drawColors > 0 then
local cx = originX + (x - 0.5) * cellSize
local cy = originY + (y - 0.5) * cellSize
local lines = {}
for _, direction in ipairs(Wire.directions) do
if Wire.has(mask, direction.bit) then
table.insert(lines, {cx, cy, cx + direction.x * cellSize / 2, cy + direction.y * cellSize / 2})
end
end
if #lines == 0 then table.insert(lines, {cx, cy, cx, cy}) end
-- The dark under-stroke makes later tracks cut a readable gap in
-- earlier tracks when two independent circuits cross.
love.graphics.setColor(0.03, 0.03, 0.05, selected and 1 or 0.7)
love.graphics.setLineWidth(math.max(1.5, cellSize * 0.10))
for _, line in ipairs(lines) do love.graphics.line(line) end
local wireBlend = (editorColors or not switchColors) and "alpha" or "screen"
love.graphics.setBlendMode(wireBlend)
love.graphics.setLineWidth(math.max(1, cellSize * 0.045))
for _, color in ipairs(drawColors) do
love.graphics.setColor(color[1], color[2], color[3], selected and 1 or 0.32)
for _, line in ipairs(lines) do love.graphics.line(line) end
love.graphics.circle("fill", cx, cy, math.max(0.75, cellSize * 0.0275))
end
end
end
end
end
end
love.graphics.setBlendMode("screen")
end
function Room:createCollideablesMatrix()
for color, entities in pairs(self.collideables) do
self.collidableMatrices[color] = self:createEntityMatrix(entities)
@ -538,7 +652,8 @@ end
-- art asset, and its .meta declares the behaviour ("door"/"switch"/"player"/
-- "moveable"/"sticky"/"skewer"/"spiky"/"player_blocker"); anything without a .meta defaults to immoveable. npc is the one
-- keyword handled directly since it has no art.
function Room:createEntity(class, color, x, y)
function Room:createEntity(class, color, x, y, data)
data = data or {}
if class == "npc" then
local entity = Sign:new(x, y)
self:registerNpc(entity)
@ -554,7 +669,31 @@ function Room:createEntity(class, color, x, y)
local behavior = props and props.class or "immoveable"
local entity
if behavior == "door" then
if behavior == "wired_door" then
if not color then return end
local circuitId = data.circuit
-- The wire terminal owns identity. A replacement door adopts the route
-- already present at its cell; additional RGB layers of a white door adopt
-- the first door layer's circuit as well.
if not circuitId then
circuitId = self:wireCircuitAt({x = x, y = y})
end
if not circuitId then
for _, existingDoor in ipairs(self.wiredDoors) do
if existingDoor:occupiesCell({x = x, y = y}) then
circuitId = existingDoor.circuitId
break
end
end
end
if not circuitId and gameWorld then circuitId = gameWorld:newCircuitId() end
circuitId = circuitId or ("wire_" .. tostring(os.time()) .. "_" .. tostring(math.random(1000000)))
entity = WiredDoor:new(x, y, color, circuitId)
self:registerWiredDoor(entity, color)
elseif behavior == "wired_switch" then
entity = WiredSwitch:new(x, y, color)
self:registerWiredSwitch(entity)
elseif behavior == "door" then
-- Old migrated rooms stored a colourless shared door. Expand that legacy
-- record into ordinary colour-layer door objects on load.
if not color then
@ -622,6 +761,15 @@ function Room:registerSwitch(entityPointer, color)
table.insert(self.switches[color], entityPointer)
end
function Room:registerWiredDoor(entityPointer, color)
table.insert(self.wiredDoors, entityPointer)
self:registerCollidable(entityPointer, color)
end
function Room:registerWiredSwitch(entityPointer)
table.insert(self.wiredSwitches, entityPointer)
end
function Room:registerPlayer(entityPointer, color)
table.insert(self.colorsPlayerHas, color)
self.player[color] = entityPointer
@ -691,6 +839,15 @@ function Room:attemptDelete(mouseX, mouseY, color)
for index = #self.doors, 1, -1 do
if removedEntities[self.doors[index]] then table.remove(self.doors, index) end
end
for index = #self.wiredDoors, 1, -1 do
if removedEntities[self.wiredDoors[index]] then table.remove(self.wiredDoors, index) end
end
for index = #self.wiredSwitches, 1, -1 do
if self.wiredSwitches[index]:occupiesCell(cell) and
(not color or color == "white" or self.wiredSwitches[index].color:getname() == color) then
table.remove(self.wiredSwitches, index)
end
end
-- NPCs are stamped into every collision layer, but are kept in their own
-- registry rather than in self.collideables. Remove them directly so a
-- right-click works no matter which editor colour is currently selected.
@ -746,7 +903,7 @@ end
function Room:deserialize(saveTable)
for _, obj in ipairs(saveTable.objects or {}) do
self:createEntity(obj.class, obj.color, obj.x, obj.y)
self:createEntity(obj.class, obj.color, obj.x, obj.y, obj)
-- createEntity turns every loaded player on; honour a saved active flag so
-- copies that were off at save time stay off (older savs lack the field and
-- keep the old all-on behaviour).
@ -754,6 +911,11 @@ function Room:deserialize(saveTable)
self:setActiveColor(obj.color, obj.active)
end
end
for _, wire in ipairs(saveTable.wires or {}) do
if wire.x and wire.y and wire.circuit then
self:setWireMask({x = wire.x, y = wire.y}, wire.circuit, wire.edges or 0)
end
end
end
function Room:save()
@ -774,12 +936,43 @@ function Room:saveToSource(name, savePlayerPositions)
return writeToSource("rooms/" .. name .. ".sav", room)
end
function Room:serializeWires()
local wires = {}
for y, row in pairs(self.wires) do
for x, tracks in pairs(row) do
for circuitId, edges in pairs(tracks) do
table.insert(wires, {x = x, y = y, circuit = circuitId, edges = edges})
end
end
end
table.sort(wires, function(a, b)
if a.y ~= b.y then return a.y < b.y end
if a.x ~= b.x then return a.x < b.x end
return a.circuit < b.circuit
end)
return wires
end
-- Cross-room wire editing may touch a neighbour loaded from runtime state.
-- Merge only its authored wire slice so saving a route cannot accidentally
-- bake that neighbour's runtime block/player positions into the source level.
function Room:saveWiresToSource()
if not self.name then return false end
local raw = readFromSource("rooms/" .. self.name .. ".sav")
local ok, saveTable = raw and pcall(TSerial.unpack, raw)
if not ok or type(saveTable) ~= "table" then saveTable = self:serialize(false) end
saveTable.wires = self:serializeWires()
return writeToSource("rooms/" .. self.name .. ".sav", TSerial.pack(saveTable, nil, true))
end
function Room:serialize(includePlayers)
local objects = {}
local function emit(class, color, entity)
local pos = entity:getGridPos()
table.insert(objects, { class = class, color = color, x = pos.x, y = pos.y })
local object = { class = class, color = color, x = pos.x, y = pos.y }
if entity.circuitId then object.circuit = entity.circuitId end
table.insert(objects, object)
end
if includePlayers ~= false then
@ -812,12 +1005,15 @@ function Room:serialize(includePlayers)
emit("switch", color, switch)
end
end
for _, switch in ipairs(self.wiredSwitches) do
emit("wired_switch", switch.color:getname(), switch)
end
for _, npc in ipairs(self.npcs) do
table.insert(objects, { class = npc.saveClass or "npc", x = npc.x, y = npc.y })
end
return { objects = objects }
return { objects = objects, wires = self:serializeWires() }
end
function nextRoom(pos)

207
rooms/room_08_13.sav Normal file
View file

@ -0,0 +1,207 @@
{
objects={
{
color="green",
x=3,
y=5,
active=true,
class="player"
},
{
color="blue",
x=3,
y=5,
active=true,
class="player"
},
{
color="red",
x=3,
y=5,
active=true,
class="player"
},
{
color="green",
x=2,
y=4,
class="box"
},
{
color="blue",
x=2,
y=9,
class="box"
},
{
color="blue",
x=3,
y=6,
class="box"
},
{
color="red",
x=2,
y=9,
class="box"
},
{
color="red",
x=3,
y=6,
class="box"
},
{
color="red",
x=3,
y=9,
class="wired_switch"
},
{
color="green",
x=3,
y=9,
class="wired_switch"
},
{
color="blue",
x=3,
y=9,
class="wired_switch"
},
{
color="green",
x=3,
y=4,
class="wired_switch"
}
},
wires={
{
edges=6,
x=4,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=5,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=6,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=12,
x=7,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=6,
x=10,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=11,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=2,
x=3,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=9,
x=4,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=3,
x=7,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=8,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=9,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=9,
x=10,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=2,
x=3,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=4,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=5,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=6,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=7,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=8,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=9,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=10,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=11,
y=9,
circuit="wire_6a7bdb2a_0001"
}
}
}

217
rooms/room_09_13.sav Normal file
View file

@ -0,0 +1,217 @@
{
objects={
{
color="green",
x=3,
y=7,
active=true,
class="player"
},
{
color="blue",
x=3,
y=7,
active=true,
class="player"
},
{
color="red",
x=3,
y=7,
active=true,
class="player"
},
{
color="green",
x=9,
y=8,
circuit="wire_6a7bdb2a_0001",
class="wired_door"
},
{
color="green",
x=5,
y=8,
class="box"
},
{
color="blue",
x=9,
y=8,
circuit="wire_6a7bdb2a_0001",
class="wired_door"
},
{
color="blue",
x=7,
y=4,
circuit="wire_6a7bd969_000a",
class="wired_door"
},
{
color="red",
x=9,
y=8,
circuit="wire_6a7bdb2a_0001",
class="wired_door"
},
{
color="green",
x=5,
y=7,
class="wired_switch"
}
},
wires={
{
edges=12,
x=1,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=3,
x=1,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=2,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=3,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=4,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=5,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=6,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=8,
x=7,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=4,
x=7,
y=4,
circuit="wire_6a7bdf16_0001"
},
{
edges=5,
x=7,
y=5,
circuit="wire_6a7bdf16_0001"
},
{
edges=6,
x=5,
y=6,
circuit="wire_6a7bdf16_0001"
},
{
edges=10,
x=6,
y=6,
circuit="wire_6a7bdf16_0001"
},
{
edges=9,
x=7,
y=6,
circuit="wire_6a7bdf16_0001"
},
{
edges=0,
x=5,
y=7,
circuit="wire_6a7bd969_000a"
},
{
edges=1,
x=5,
y=7,
circuit="wire_6a7bdf16_0001"
},
{
edges=4,
x=9,
y=8,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=1,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=2,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=3,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=4,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=5,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=6,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=7,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=8,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=9,
x=9,
y=9,
circuit="wire_6a7bdb2a_0001"
}
}
}

4
rooms/room_13_08.sav Normal file
View file

@ -0,0 +1,4 @@
{
objects={
}
}

4
rooms/room_14_07.sav Normal file
View file

@ -0,0 +1,4 @@
{
objects={
}
}

View file

@ -1,255 +1,369 @@
{
objects={
{
color="blue",
class="door",
color="blue",
x=6,
y=1
},
{
color="red",
class="static_wall",
color="blue",
x=1,
y=1
},
{
class="static_wall",
color="blue",
x=2,
y=1
},
{
class="static_wall",
color="blue",
x=3,
y=1
},
{
class="static_wall",
color="blue",
x=4,
y=1
},
{
class="static_wall",
color="blue",
x=5,
y=1
},
{
class="static_wall",
color="blue",
x=7,
y=1
},
{
class="static_wall",
color="blue",
x=8,
y=1
},
{
class="static_wall",
color="blue",
x=9,
y=1
},
{
class="static_wall",
color="blue",
x=10,
y=1
},
{
class="static_wall",
color="blue",
x=11,
y=1
},
{
class="static_wall",
color="blue",
x=1,
y=11
},
{
class="static_wall",
color="blue",
x=11,
y=11
},
{
class="spiky_box_2x1_11",
color="blue",
x=1,
y=6
},
{
class="box",
color="blue",
x=3,
y=6
},
{
class="box_1x2_1-1",
color="blue",
x=4,
y=6
},
{
class="spiky_box_1x2_1-1",
color="blue",
x=5,
y=6
},
{
class="box_2x2_11-10",
color="blue",
x=6,
y=5
},
{
class="spiky_box_2x1_11",
color="blue",
x=7,
y=6
},
{
class="spiky_box_2x1_11",
color="blue",
x=9,
y=6
},
{
class="door",
color="red",
x=6,
y=1
},
{
color="green",
class="static_wall",
color="red",
x=1,
y=1
},
{
class="static_wall",
color="red",
x=2,
y=1
},
{
class="static_wall",
color="red",
x=3,
y=1
},
{
class="static_wall",
color="red",
x=4,
y=1
},
{
class="static_wall",
color="red",
x=5,
y=1
},
{
class="static_wall",
color="red",
x=7,
y=1
},
{
class="static_wall",
color="red",
x=8,
y=1
},
{
class="static_wall",
color="red",
x=9,
y=1
},
{
class="static_wall",
color="red",
x=10,
y=1
},
{
class="static_wall",
color="red",
x=11,
y=1
},
{
class="static_wall",
color="red",
x=1,
y=11
},
{
class="static_wall",
color="red",
x=11,
y=11
},
{
class="spiky_box_2x1_11",
color="red",
x=1,
y=6
},
{
class="box",
color="red",
x=3,
y=6
},
{
class="box_1x2_1-1",
color="red",
x=4,
y=6
},
{
class="spiky_box_1x2_1-1",
color="red",
x=5,
y=6
},
{
class="box_2x2_11-10",
color="red",
x=6,
y=5
},
{
class="spiky_box_2x1_11",
color="red",
x=7,
y=6
},
{
class="spiky_box_2x1_11",
color="red",
x=9,
y=6
},
{
class="door",
color="green",
x=6,
y=1
},
{
color="blue",
class="static_wall",
x=1,
y=1
},
{
color="red",
class="static_wall",
x=1,
y=1
},
{
color="green",
class="static_wall",
x=1,
y=1
},
{
color="blue",
class="static_wall",
color="green",
x=2,
y=1
},
{
color="red",
class="static_wall",
x=2,
y=1
},
{
color="green",
class="static_wall",
x=2,
y=1
},
{
color="blue",
class="static_wall",
x=3,
y=1
},
{
color="green",
class="static_wall",
color="green",
x=4,
y=1
},
{
class="static_wall",
color="green",
x=5,
y=1
},
{
class="static_wall",
color="green",
x=7,
y=1
},
{
class="static_wall",
color="green",
x=8,
y=1
},
{
class="static_wall",
color="green",
x=9,
y=1
},
{
class="static_wall",
color="green",
x=10,
y=1
},
{
class="static_wall",
color="green",
x=11,
y=1
},
{
class="static_wall",
color="green",
x=1,
y=11
},
{
class="static_wall",
color="green",
x=11,
y=11
},
{
class="spiky_box_2x1_11",
color="green",
x=3,
y=1
},
{
color="red",
class="static_wall",
x=3,
y=1
},
{
color="red",
class="static_wall",
x=4,
y=1
y=6
},
{
class="spiky_box_2x1_11",
color="green",
class="static_wall",
x=4,
y=1
},
{
color="blue",
class="static_wall",
x=4,
y=1
},
{
color="blue",
class="static_wall",
x=5,
y=1
},
{
color="red",
class="static_wall",
x=5,
y=1
},
{
color="green",
class="static_wall",
x=5,
y=1
},
{
color="green",
class="static_wall",
x=7,
y=1
},
{
color="blue",
class="static_wall",
x=7,
y=1
},
{
color="red",
class="static_wall",
x=7,
y=1
},
{
color="red",
class="static_wall",
x=8,
y=1
},
{
color="green",
class="static_wall",
x=8,
y=1
},
{
color="blue",
class="static_wall",
x=8,
y=1
},
{
color="red",
class="static_wall",
x=9,
y=1
},
{
color="green",
class="static_wall",
x=9,
y=1
},
{
color="blue",
class="static_wall",
x=9,
y=1
},
{
color="blue",
class="static_wall",
x=10,
y=1
},
{
color="red",
class="static_wall",
x=10,
y=1
},
{
color="green",
class="static_wall",
x=10,
y=1
},
{
color="red",
class="static_wall",
x=11,
y=1
},
{
color="green",
class="static_wall",
x=11,
y=1
},
{
color="blue",
class="static_wall",
x=11,
y=1
},
{
color="red",
class="static_wall",
x=1,
y=11
},
{
color="blue",
class="static_wall",
x=1,
y=11
y=6
},
{
class="box",
color="green",
class="static_wall",
x=1,
y=11
},
{
color="red",
class="static_wall",
x=11,
y=11
},
{
color="blue",
class="static_wall",
x=11,
y=11
x=5,
y=6
},
{
class="box_1x2_1-1",
color="green",
class="static_wall",
x=11,
y=11
},
{
color="red",
class="switch",
x=6,
y=6
},
{
color="blue",
class="switch",
x=6,
class="spiky_box_1x2_1-1",
color="green",
x=7,
y=6
},
{
class="box_2x2_11-10",
color="green",
class="switch",
x=6,
x=8,
y=5
},
{
class="spiky_box_2x1_11",
color="green",
x=9,
y=6
},
{
class="spiky_box_2x1_11",
color="green",
x=11,
y=6
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,212 +1,232 @@
{
height=18,
lastRoom="white_primary",
rooms={
{
name="3",
x=10,
name="3",
y=10
},
{
x=12,
name="5",
x=12,
y=10
},
{
name="village_1",
x=12,
name="village_1",
y=9
},
{
name="village_3",
x=13,
y=8
},
{
x=11,
name="village_4",
x=11,
y=8
},
{
name="yellow_1",
x=11,
name="yellow_1",
y=7
},
{
name="yellow_3",
x=11,
name="yellow_3",
y=6
},
{
name="yellow_2",
x=12,
name="yellow_2",
y=7
},
{
name="yellow_4",
x=11,
name="yellow_4",
y=5
},
{
name="yellow_5",
x=11,
name="yellow_5",
y=4
},
{
name="yellow_6",
x=10,
name="yellow_6",
y=5
},
{
x=1,
name="default",
x=1,
y=1
},
{
x=3,
name="old_yellow_3",
x=3,
y=1
},
{
name="test_patt",
x=5,
name="test_patt",
y=1
},
{
x=7,
name="test_patt_update",
x=7,
y=1
},
{
x=9,
name="white_asymmetrical",
x=9,
y=1
},
{
name="white_ok",
x=11,
name="white_ok",
y=1
},
{
name="white_primary",
x=13,
name="white_primary",
y=1
},
{
name="yellow_arches",
x=15,
name="yellow_arches",
y=1
},
{
name="yellow_asymmetrical",
x=17,
name="yellow_asymmetrical",
y=1
},
{
name="yellow_cc_moustache",
x=1,
name="yellow_cc_moustache",
y=3
},
{
name="yellow_color_changing_levvel_1",
x=3,
name="yellow_color_changing_levvel_1",
y=3
},
{
name="yellow_smile",
x=7,
name="yellow_smile",
y=3
},
{
name="2",
x=9,
name="2",
y=10
},
{
name="room_11_09",
x=11,
name="room_11_09",
y=9
},
{
name="yellow_skull",
x=9,
name="yellow_skull",
y=3
},
{
name="jorge_room",
x=5,
name="jorge_room",
y=5
},
{
name="4",
x=11,
name="4",
y=10
},
{
name="room_01_05",
x=1,
name="room_01_05",
y=5
},
{
name="ok",
x=2,
name="ok",
y=8
},
{
name="room_05_18",
x=5,
name="room_05_18",
y=18
},
{
name="village_start",
x=9,
name="village_start",
y=11
},
{
name="spiky_test",
x=4,
name="spiky_test",
y=8
},
{
x=5,
name="corner_trimino_test",
x=5,
y=7
},
{
name="tetromino_loop_t",
x=6,
name="tetromino_loop_t",
y=7
},
{
name="corner_trimino_test_copy",
x=5,
name="corner_trimino_test_copy",
y=6
},
{
name="blocker_test",
x=7,
name="blocker_test",
y=7
},
{
name="spiky_test_2",
x=5,
name="spiky_test_2",
y=8
},
{
name="spiky_test_3",
x=5,
name="spiky_test_3",
y=9
},
{
name="jorge2",
x=3,
name="jorge2",
y=14
},
{
name="room_08_07",
x=8,
name="room_08_07",
y=7
},
{
x=13,
name="village_3",
y=8
},
{
x=12,
name="village_2",
y=8
},
{
x=14,
name="room_14_07",
y=7
},
{
x=9,
name="room_09_13",
y=13
},
{
x=8,
name="room_08_13",
y=13
}
},
width=18
lastRoom="room_09_13",
width=18,
height=18
}

206
tests/test_wire.lua Normal file
View file

@ -0,0 +1,206 @@
package.path = "./?.lua;" .. package.path
class = require "libs/middleclass"
local Wire = require "wire"
require "world"
gridWidth, gridHeight = 2, 2
local passed = 0
local function test(name, fn)
local ok, err = pcall(fn)
if not ok then
io.stderr:write("FAIL: " .. name .. "\n" .. err .. "\n")
os.exit(1)
end
passed = passed + 1
end
local function assertTrue(value, message)
if not value then error(message or "expected true", 2) end
end
local function assertFalse(value, message)
if value then error(message or "expected false", 2) end
end
local function switch(color, x, y)
local entity = {x = x, y = y, activated = false}
entity.color = {getname = function() return color end}
function entity:getGridPos() return {x = self.x, y = self.y} end
function entity:activate() self.activated = true end
function entity:deactivate() self.activated = false end
return entity
end
local function door(circuitId, x, y)
local entity = {circuitId = circuitId, x = x, y = y, powered = false}
function entity:getGridPos() return {x = self.x, y = self.y} end
function entity:setPowered(powered) self.powered = powered end
return entity
end
local function makeWorld(secondSwitch)
local blueSwitch = switch("blue", 2, 1)
local wiredDoor = door("circuit_a", 1, 1)
local roomA = {
name = "a", player = {}, wiredSwitches = {}, wiredDoors = {wiredDoor},
wires = {[1] = {
[1] = {circuit_a = Wire.EAST},
[2] = {circuit_a = Wire.WEST + Wire.EAST},
}},
}
local roomB = {
name = "b", player = {}, wiredSwitches = {blueSwitch}, wiredDoors = {},
wires = {[1] = {
[1] = {circuit_a = Wire.WEST + Wire.EAST},
[2] = {circuit_a = Wire.WEST},
}},
}
local function wireCircuitAt(room, cell)
local tracks = room.wires[cell.y] and room.wires[cell.y][cell.x]
for circuitId in pairs(tracks or {}) do return circuitId end
end
roomA.wireCircuitAt = wireCircuitAt
roomB.wireCircuitAt = wireCircuitAt
if secondSwitch then table.insert(roomB.wiredSwitches, secondSwitch) end
local instance = setmetatable({
levels = {a = roomA, b = roomB},
roomByName = {a = {name = "a", x = 1, y = 1}, b = {name = "b", x = 2, y = 1}},
collidableMatrices = {red = {}, green = {}, blue = {}},
}, {__index = World})
function instance:roomCell(cell)
if cell.x <= gridWidth then return roomA, {x = cell.x, y = cell.y} end
return roomB, {x = cell.x - gridWidth, y = cell.y}
end
local function wireTracksAt(room, cell, create)
local row = room.wires[cell.y]
if not row and create then row = {}; room.wires[cell.y] = row end
local tracks = row and row[cell.x]
if not tracks and create then tracks = {}; row[cell.x] = tracks end
return tracks
end
roomA.wireTracksAt = wireTracksAt
roomB.wireTracksAt = wireTracksAt
return instance, roomA, roomB, wiredDoor, blueSwitch
end
test("direction masks add and remove without duplicating bits", function()
local mask = Wire.add(0, Wire.EAST)
mask = Wire.add(mask, Wire.EAST)
assertTrue(mask == Wire.EAST)
assertTrue(Wire.has(mask, Wire.EAST))
assertFalse(Wire.has(mask, Wire.WEST))
assertTrue(Wire.remove(mask, Wire.EAST) == 0)
end)
test("orphaned and overlapping wire cells remain selectable without a door", function()
local world, roomA = makeWorld()
roomA.wires[1][1].orphan_b = Wire.SOUTH
local top = world:circuitAt({x = 1, y = 1})
assertTrue(top == "orphan_b", "top orphaned circuit was not selectable")
local preferred = world:circuitAt({x = 1, y = 1}, "circuit_a")
assertTrue(preferred == "circuit_a", "selected overlapping circuit was not preferred")
end)
test("a white switch powers every layer of a white door", function()
local world, roomA, roomB = makeWorld()
roomA.wiredDoors = {
door("circuit_a", 1, 1),
door("old_green_id", 1, 1),
door("old_blue_id", 1, 1),
}
roomB.wiredSwitches = {
switch("red", 2, 1),
switch("green", 2, 1),
switch("blue", 2, 1),
}
world.collidableMatrices.red[1] = {[4] = {}}
world.collidableMatrices.green[1] = {[4] = {}}
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
for _, wiredDoor in ipairs(roomA.wiredDoors) do
assertTrue(wiredDoor.circuitId == "circuit_a", "door layer did not adopt terminal circuit")
assertTrue(world.circuitState.doorPowered[wiredDoor], "white door layer remained locked")
end
local color = world:wireColorAt(roomA, {x = 1, y = 1}, "circuit_a")
assertTrue(color[1] == 1 and color[2] == 1 and color[3] == 1, "white switch did not produce white wire")
local redGreenOnly = world:wireColorAt(roomA, {x = 1, y = 1}, "circuit_a", {red = true, green = true})
assertTrue(redGreenOnly[1] == 1 and redGreenOnly[2] == 1 and redGreenOnly[3] == 0,
"white wire did not obey the room's visible player channels")
end)
test("world matrices join a circuit across a room boundary", function()
local world, _, _, wiredDoor, blueSwitch = makeWorld()
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
assertTrue(world.wireMatrix[1][2].circuit_a ~= nil)
assertTrue(world.wireMatrix[1][3].circuit_a ~= nil)
assertTrue(world.wiredDoorMatrix[1][1][1].entity == wiredDoor)
assertTrue(world.wiredSwitchMatrix[1][4][1].entity == blueSwitch)
assertTrue(world.circuitState.switchPressed[blueSwitch])
assertTrue(world.circuitState.doorPowered[wiredDoor])
assertTrue(wiredDoor.powered)
assertTrue(world:wireColorAt(world.levels.a, {x = 1, y = 1}, "circuit_a", {red = true, green = true}) == nil,
"blue wire leaked into a room without a blue player channel")
local visibleBlue = world:wireColorAt(world.levels.a, {x = 1, y = 1}, "circuit_a", {blue = true})
assertTrue(visibleBlue[1] == 0 and visibleBlue[2] == 0 and visibleBlue[3] == 1,
"blue wire did not render in a room with a blue player channel")
end)
test("wire color is re-derived when its terminal switch changes", function()
local world, roomA, roomB = makeWorld()
world:resolveCircuits()
local colors = world:wireSwitchColorsAt(roomA, {x = 1, y = 1}, "circuit_a")
assertTrue(colors.blue and not colors.red)
roomB.wiredSwitches = {switch("red", 2, 1)}
world:resolveCircuits()
colors = world:wireSwitchColorsAt(roomA, {x = 1, y = 1}, "circuit_a")
assertTrue(colors.red and not colors.blue, "wire retained the deleted terminal switch color")
end)
test("a switchless wire component has no gameplay color", function()
local world, roomA, roomB = makeWorld()
roomB.wiredSwitches = {}
world:resolveCircuits()
assertTrue(world:wireSwitchColorsAt(roomA, {x = 1, y = 1}, "circuit_a") == nil)
assertTrue(world:wireColorAt(roomA, {x = 1, y = 1}, "circuit_a") == nil)
end)
test("a door requires every independent switch run at its terminal", function()
local greenSwitch = switch("green", 1, 2)
local world, roomA, _, wiredDoor = makeWorld()
table.insert(roomA.wiredSwitches, greenSwitch)
roomA.wires[1][1].circuit_b = Wire.SOUTH
roomA.wires[2] = {[1] = {circuit_b = Wire.NORTH}}
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
assertFalse(world.circuitState.doorPowered[wiredDoor])
world.collidableMatrices.green[2] = {[1] = {}}
world:resolveCircuits()
assertTrue(world.circuitState.doorPowered[wiredDoor])
end)
test("one run cannot terminate at two different switches", function()
local second = switch("red", 1, 2)
local world, _, roomB = makeWorld(second)
world:resolveCircuits()
local rooms, err = world:addWireConnection({x = 3, y = 1}, {x = 3, y = 2}, "circuit_a")
assertTrue(#rooms == 0 and err ~= nil, "second switch terminal was accepted")
assertTrue(roomB.wires[2] == nil or roomB.wires[2][1] == nil,
"rejected second terminal left a connected wire cell")
local colors = world:wireSwitchColorsAt(world.levels.a, {x = 1, y = 1}, "circuit_a")
assertTrue(colors.blue and not colors.red, "run stopped deriving color from its sole terminal switch")
end)
test("matching IDs do not bridge a gap or one-sided room edge", function()
local world, _, roomB, wiredDoor = makeWorld()
world.collidableMatrices.blue[1] = {[4] = {}}
roomB.wires[1][1].circuit_a = Wire.EAST -- no reciprocal WEST edge
world:resolveCircuits()
assertFalse(world.circuitState.doorPowered[wiredDoor])
end)
io.write("PASS: " .. passed .. " wire tests\n")

55
wire.lua Normal file
View file

@ -0,0 +1,55 @@
local Wire = {
NORTH = 1,
EAST = 2,
SOUTH = 4,
WEST = 8,
}
Wire.directions = {
{bit = Wire.NORTH, opposite = Wire.SOUTH, x = 0, y = -1},
{bit = Wire.EAST, opposite = Wire.WEST, x = 1, y = 0},
{bit = Wire.SOUTH, opposite = Wire.NORTH, x = 0, y = 1},
{bit = Wire.WEST, opposite = Wire.EAST, x = -1, y = 0},
}
function Wire.has(mask, bit)
return math.floor((mask or 0) / bit) % 2 == 1
end
function Wire.add(mask, bit)
mask = mask or 0
return Wire.has(mask, bit) and mask or mask + bit
end
function Wire.remove(mask, bit)
mask = mask or 0
return Wire.has(mask, bit) and mask - bit or mask
end
function Wire.directionBetween(a, b)
local dx, dy = b.x - a.x, b.y - a.y
for _, direction in ipairs(Wire.directions) do
if direction.x == dx and direction.y == dy then return direction end
end
end
-- Circuit colours are deliberately independent from the RGB gameplay layers.
-- A stable hash makes a route keep the same editor colour after save/load.
local palette = {
{1.00, 0.78, 0.20},
{0.20, 0.92, 0.92},
{1.00, 0.42, 0.72},
{0.62, 1.00, 0.32},
{0.72, 0.55, 1.00},
{1.00, 0.55, 0.22},
}
function Wire.color(circuitId)
local hash = 0
for index = 1, #(circuitId or "") do
hash = (hash * 33 + circuitId:byte(index)) % 65521
end
return palette[hash % #palette + 1]
end
return Wire

30
wired_door.lua Normal file
View file

@ -0,0 +1,30 @@
require "door"
local Wire = require "wire"
WiredDoor = class("WiredDoor", Door)
function WiredDoor:initialize(x, y, color, circuitId)
Door.initialize(self, x, y, color)
self.circuitId = circuitId
self.wired = true
end
function WiredDoor:setPowered(powered)
if powered then
if self.locked then self:unlock() end
elseif not self.locked then
self:lock()
end
self:refreshSprites()
end
function WiredDoor:draw(channel)
Entity.draw(self, channel)
if not self.sprites[channel] then return end
local p = self:getDrawPos()
local color = Wire.color(self.circuitId)
love.graphics.setColor(color[1], color[2], color[3], 1)
love.graphics.setLineWidth(math.max(1, drawScale))
love.graphics.circle("line", p.x + 8 * drawScale, p.y + 8 * drawScale, 3 * drawScale)
end

17
wired_switch.lua Normal file
View file

@ -0,0 +1,17 @@
require "switch"
WiredSwitch = class("WiredSwitch", Switch)
function WiredSwitch:initialize(x, y, color)
Switch.initialize(self, x, y, color)
self.wired = true
end
function WiredSwitch:draw(channel)
Entity.draw(self, channel)
if not self.sprites[channel] then return end
local p = self:getDrawPos()
love.graphics.setColor(1, 1, 1, 0.9)
love.graphics.setLineWidth(math.max(1, drawScale))
love.graphics.rectangle("line", p.x + 5 * drawScale, p.y + 5 * drawScale, 6 * drawScale, 6 * drawScale)
end

409
world.lua
View file

@ -1,10 +1,11 @@
World = class("World")
local Movement = require "movement"
local Wire = require "wire"
-- The world is deliberately only a layout: rooms continue to own their puzzle
-- state. Keeping the two separate lets the editor move a room without
-- rewriting its level file.
-- Rooms own authored slices (objects and wire fragments). World composes those
-- slices into authoritative live collision and circuit matrices, so moving a
-- room changes placement without rewriting its level file.
function World:initialize()
self.width, self.height = 18, 18
self.rooms = {}
@ -14,6 +15,7 @@ function World:initialize()
self:load()
self:loadRuntimeState()
self.levels = {}
self.circuitSerial = 0
end
@ -22,6 +24,8 @@ function World:loadLevels()
-- through the same runtime-first loader, not just the initially visible one.
for _, placed in ipairs(self.rooms) do self.levels[placed.name] = Room:new{name = placed.name, runtime = true} end
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
-- The editor can replace a level with a freshly loaded authored Room. Keep the
@ -30,9 +34,26 @@ function World:setLevel(room)
if not room or not room.name or not self:roomLocation(room.name) then return false end
self.levels[room.name] = room
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
return true
end
function World:newCircuitId()
local used = {}
for _, room in pairs(self.levels or {}) do
for _, door in ipairs(room.wiredDoors or {}) do used[door.circuitId] = true end
for _, row in pairs(room.wires or {}) do
for _, tracks in pairs(row) do for circuitId in pairs(tracks) do used[circuitId] = true end end
end
end
repeat
self.circuitSerial = self.circuitSerial + 1
local id = string.format("wire_%08x_%04x", os.time() % 0xffffffff, self.circuitSerial)
if not used[id] then return id end
until false
end
-- Runtime state is one logical world snapshot: each level slice is written to
-- LÖVE's save directory, and this companion file says which slice was active.
-- The authored world layout remains source-only for the editor.
@ -128,7 +149,13 @@ end
function World:placeRoom(name, x, y)
if not name or not self:isInBounds(x, y) then return false end
self:_putRoom(name, x, y)
return self:save()
local saved = self:save()
if self.levels then
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
return saved
end
function World:removeRoom(x, y)
@ -138,7 +165,13 @@ function World:removeRoom(x, y)
self.roomByName[room.name] = nil
table.remove(self.rooms, i)
if self.lastRoom == room.name then self.lastRoom = nil end
return self:save(), room.name
local saved = self:save()
if self.levels then
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
return saved, room.name
end
end
return false
@ -238,6 +271,292 @@ function World:roomCell(cell)
return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight}
end
local function cellKey(cell)
return cell.x .. ":" .. cell.y
end
function World:isSwitchPressed(room, switch)
local color = switch.color:getname()
local cell = self:worldCell(room, switch:getGridPos())
local row = cell and self.collidableMatrices[color][cell.y]
local target = row and row[cell.x]
local player = room.player[color]
return target ~= nil or (player and player:occupiesCell(switch:getGridPos())) or false
end
function World:rebuildCircuitMatrices()
self.wireMatrix = {}
self.wiredSwitchMatrix = {}
self.wiredDoorMatrix = {}
local function cellAt(matrix, cell, create)
local row = matrix[cell.y]
if not row and create then row = {}; matrix[cell.y] = row end
local value = row and row[cell.x]
return value, row
end
local function addDevice(matrix, cell, device, room)
local devices, row = cellAt(matrix, cell, true)
if not devices then devices = {}; row[cell.x] = devices end
table.insert(devices, {entity = device, room = room})
end
for name, room in pairs(self.levels or {}) do
if self:roomLocation(name) then
for _, switch in ipairs(room.wiredSwitches or {}) do
addDevice(self.wiredSwitchMatrix, self:worldCell(room, switch:getGridPos()), switch, room)
end
for _, door in ipairs(room.wiredDoors or {}) do
-- A terminal painted beneath the door is authoritative. This also
-- repairs older white doors whose RGB layers were assigned separate
-- IDs before layered placement shared a circuit.
door.circuitId = room:wireCircuitAt(door:getGridPos()) or door.circuitId
addDevice(self.wiredDoorMatrix, self:worldCell(room, door:getGridPos()), door, room)
end
for y, sourceRow in pairs(room.wires or {}) do
for x, sourceTracks in pairs(sourceRow) do
local worldCell = self:worldCell(room, {x = x, y = y})
local tracks, row = cellAt(self.wireMatrix, worldCell, true)
if not tracks then tracks = {}; row[worldCell.x] = tracks end
for circuitId, mask in pairs(sourceTracks) do
tracks[circuitId] = {mask = mask, room = room, localCell = {x = x, y = y}}
end
end
end
end
end
end
-- Rebuild the electrical graph from room-local fragments projected onto the
-- world grid. IDs keep overlapping routes distinct; reciprocal direction bits
-- are what actually make two neighbouring cells electrically continuous.
function World:resolveCircuits()
if not self.collidableMatrices then return end
self:rebuildCircuitMatrices()
local circuits = {}
local switchesAt = {}
local switchPressed = {}
for y, row in pairs(self.wiredSwitchMatrix) do
for x, devices in pairs(row) do
local key = cellKey({x = x, y = y})
for _, device in ipairs(devices) do
local switch, room = device.entity, device.room
local pressed = self:isSwitchPressed(room, switch)
switchPressed[switch] = pressed
-- Entity flags are derived caches used by the existing sprite API.
-- World.circuitState below remains the authoritative logical state.
if pressed then switch:activate() else switch:deactivate() end
switchesAt[key] = switchesAt[key] or {}
table.insert(switchesAt[key], switch)
end
end
end
for y, row in pairs(self.wireMatrix) do
for x, tracks in pairs(row) do
local worldCell = {x = x, y = y}
local key = cellKey(worldCell)
for circuitId, track in pairs(tracks) do
circuits[circuitId] = circuits[circuitId] or {}
circuits[circuitId][key] = {cell = worldCell, mask = track.mask}
end
end
end
local components = {}
for circuitId, nodes in pairs(circuits) do
components[circuitId] = {}
local visited = {}
for key, node in pairs(nodes) do
if not visited[key] then
local component = {
switches = {}, nodes = {}, switchColors = {red = false, green = false, blue = false},
terminalCells = {}, valid = false,
}
local pending, nextNode = {node}, 1
visited[key] = component
while nextNode <= #pending do
local current = pending[nextNode]
nextNode = nextNode + 1
local currentKey = cellKey(current.cell)
component.nodes[currentKey] = true
local degree = 0
for _, direction in ipairs(Wire.directions) do
if Wire.has(current.mask, direction.bit) then
local adjacentKey = cellKey({x = current.cell.x + direction.x, y = current.cell.y + direction.y})
local adjacent = nodes[adjacentKey]
if adjacent and Wire.has(adjacent.mask, direction.opposite) then
degree = degree + 1
if not visited[adjacentKey] then
visited[adjacentKey] = component
table.insert(pending, adjacent)
end
end
end
end
-- A switch connects only when the run literally terminates on its
-- cell. Merely crossing or passing through a switch does not claim it.
if degree <= 1 and switchesAt[currentKey] then
component.terminalCells[currentKey] = switchesAt[currentKey]
end
end
local terminalCount, terminalSwitches = 0
for _, terminal in pairs(component.terminalCells) do
terminalCount = terminalCount + 1
terminalSwitches = terminal
end
-- Multiple RGB entities at one cell are one logical (possibly white)
-- switch. Two different switch cells can never belong to one wire run.
component.valid = terminalCount == 1
component.terminalCount = terminalCount
if component.valid then
for _, switch in ipairs(terminalSwitches) do
component.switches[switch] = true
component.switchColors[switch.color:getname()] = true
end
end
components[circuitId][key] = component
for componentKey in pairs(component.nodes) do components[circuitId][componentKey] = component end
end
end
end
local doorPowered = {}
for y, row in pairs(self.wiredDoorMatrix) do
for x, devices in pairs(row) do
for _, device in ipairs(devices) do
local door = device.entity
local position = {x = x, y = y}
local key = cellKey(position)
local tracks = self.wireMatrix[y] and self.wireMatrix[y][x]
local runCount, powered = 0, tracks ~= nil
for circuitId in pairs(tracks or {}) do
runCount = runCount + 1
local component = components[circuitId] and components[circuitId][key]
if not component or not component.valid then
powered = false
else
for switch in pairs(component.switches) do
if not switchPressed[switch] then powered = false end
end
end
end
powered = powered and runCount > 0
doorPowered[door] = powered
-- locked/collision is a projection of world state onto the physical
-- room entity so the existing movement renderer can consume it.
door:setPowered(powered)
end
end
end
self.circuits = circuits
self.circuitComponents = components
self.circuitState = {
switchPressed = switchPressed,
doorPowered = doorPowered,
components = components,
}
end
function World:wireSwitchColorsAt(room, localCell, circuitId)
local worldCell = self:worldCell(room, localCell)
local component = worldCell and self.circuitComponents and
self.circuitComponents[circuitId] and self.circuitComponents[circuitId][cellKey(worldCell)]
if not component or not component.valid then return nil end
local colors = component.switchColors
if not colors.red and not colors.green and not colors.blue then return nil end
return colors
end
function World:wireColorAt(room, localCell, circuitId, visibleColors)
local colors = self:wireSwitchColorsAt(room, localCell, circuitId)
if not colors then return nil end
visibleColors = visibleColors or {red = true, green = true, blue = true}
local color = {
colors.red and visibleColors.red and 1 or 0,
colors.green and visibleColors.green and 1 or 0,
colors.blue and visibleColors.blue and 1 or 0,
}
if color[1] == 0 and color[2] == 0 and color[3] == 0 then return nil end
return {
color[1], color[2], color[3],
}
end
function World:ensureWireAt(cell, circuitId)
local room, localCell = self:roomCell(cell)
if not room then return {} end
local tracks = room:wireTracksAt(localCell, true)
if tracks[circuitId] == nil then tracks[circuitId] = 0 end
self:resolveCircuits()
return {room}
end
function World:addWireConnection(from, to, circuitId)
local direction = Wire.directionBetween(from, to)
if not direction then return {} end
local fromRoom, fromCell = self:roomCell(from)
local toRoom, toCell = self:roomCell(to)
if not fromRoom or not toRoom then return {} end
local fromTracks = fromRoom:wireTracksAt(fromCell, true)
local toTracks = toRoom:wireTracksAt(toCell, true)
local oldFrom, oldTo = fromTracks[circuitId], toTracks[circuitId]
fromTracks[circuitId] = Wire.add(fromTracks[circuitId], direction.bit)
toTracks[circuitId] = Wire.add(toTracks[circuitId], direction.opposite)
self:resolveCircuits()
local component = self.circuitComponents[circuitId] and self.circuitComponents[circuitId][cellKey(from)]
if component and component.terminalCount > 1 then
fromTracks[circuitId], toTracks[circuitId] = oldFrom, oldTo
if oldFrom == nil and not next(fromTracks) then fromRoom.wires[fromCell.y][fromCell.x] = nil end
if oldTo == nil and not next(toTracks) then toRoom.wires[toCell.y][toCell.x] = nil end
self:resolveCircuits()
return {}, "A wire run can terminate at only one switch. Start another run from the door."
end
return fromRoom == toRoom and {fromRoom} or {fromRoom, toRoom}
end
function World:removeWireAt(cell, circuitId)
local room, localCell = self:roomCell(cell)
if not room then return {} end
local tracks = room:wireTracksAt(localCell)
local mask = tracks and tracks[circuitId]
if mask == nil then return {} end
local changed, seen = {room}, {[room] = true}
for _, direction in ipairs(Wire.directions) do
if Wire.has(mask, direction.bit) then
local neighborRoom, neighborCell = self:roomCell({x = cell.x + direction.x, y = cell.y + direction.y})
local neighborTracks = neighborRoom and neighborRoom:wireTracksAt(neighborCell)
if neighborTracks and neighborTracks[circuitId] ~= nil then
neighborTracks[circuitId] = Wire.remove(neighborTracks[circuitId], direction.opposite)
if not seen[neighborRoom] then seen[neighborRoom] = true; table.insert(changed, neighborRoom) end
end
end
end
room:removeWire(localCell, circuitId)
self:resolveCircuits()
return changed
end
function World:wiredDoorAt(cell)
local room, localCell = self:roomCell(cell)
if not room then return nil end
for _, door in ipairs(room.wiredDoors or {}) do
if door:occupiesCell(localCell) then return door, room end
end
end
function World:circuitAt(cell, preferredCircuit)
local room, localCell = self:roomCell(cell)
local tracks = room and room:wireTracksAt(localCell)
if not tracks then return nil end
if preferredCircuit and tracks[preferredCircuit] ~= nil then return preferredCircuit end
local ids = {}
for circuitId in pairs(tracks) do table.insert(ids, circuitId) end
table.sort(ids)
return ids[#ids]
end
function World:movementContext(room, color)
local proxies = {}
local world = self
@ -373,6 +692,8 @@ end
function World:tick(room)
room:tickLocal()
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
self:nextRoomCheck(room)
end
@ -421,6 +742,8 @@ function World:nextRoom(room, pos)
end
self:playerJoinCheck(currentRoom)
self.lastRoom = currentRoom.name
self:resolveCircuits()
self:rebuildCollision()
setEditorRoomName(currentRoom.name)
end
@ -451,6 +774,7 @@ function World:moveGroup(group, direction)
end
self:rebuildCollision()
for room in pairs(changed) do room:resolveSwitchesAndDoors() end
self:resolveCircuits()
self:rebuildCollision()
for room in pairs(changed) do self:markDirty(room) end
end
@ -499,8 +823,8 @@ function World:invalidatePreview(name)
self.previewCache[name] = nil
end
function World:drawRoomPreview(name, x, y, size)
local room = self:getRoomData(name)
function World:drawRoomPreview(name, x, y, size, roomData)
local room = roomData or self:getRoomData(name)
if not room then return end
local scale = size / (gridWidth * 16)
love.graphics.setScissor(x, y, size, size)
@ -539,6 +863,77 @@ function World:drawRoomPreview(name, x, y, size)
love.graphics.setScissor()
end
function World:drawWireEditor(centerRoomX, centerRoomY, selectedCircuit)
local roomsAcross = 3
local roomSize = width / roomsAcross
local cellSize = roomSize / gridWidth
local firstRoomX, firstRoomY = centerRoomX - 1, centerRoomY - 1
love.graphics.setColor(0.035, 0.035, 0.055, 1)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.setBlendMode("alpha")
love.graphics.setColor(0.28, 0.28, 0.38, 0.28)
love.graphics.setLineWidth(1)
for cell = 0, roomsAcross * gridWidth do
local p = cell * cellSize
love.graphics.line(p, 0, p, height)
love.graphics.line(0, p, width, p)
end
love.graphics.setBlendMode("screen")
for viewY = 0, roomsAcross - 1 do
for viewX = 0, roomsAcross - 1 do
local roomX, roomY = firstRoomX + viewX, firstRoomY + viewY
local name = self:isInBounds(roomX, roomY) and self:roomAt(roomX, roomY)
local sx, sy = viewX * roomSize, viewY * roomSize
if name then
local level = self.levels[name]
self:drawRoomPreview(name, sx, sy, roomSize, level and level:serialize())
if level then
level:drawWiresAt(sx, sy, cellSize, selectedCircuit, true)
love.graphics.setBlendMode("alpha")
for _, door in ipairs(level.wiredDoors or {}) do
local pos, color = door:getGridPos(), Wire.color(door.circuitId)
love.graphics.setColor(color[1], color[2], color[3], selectedCircuit == door.circuitId and 1 or 0.65)
love.graphics.setLineWidth(2)
love.graphics.circle("line", sx + (pos.x - 0.5) * cellSize, sy + (pos.y - 0.5) * cellSize, cellSize * 0.22)
end
for _, switch in ipairs(level.wiredSwitches or {}) do
local pos = switch:getGridPos()
love.graphics.setColor(1, 1, 1, switch.activated and 1 or 0.65)
love.graphics.setLineWidth(1)
love.graphics.rectangle("line", sx + (pos.x - 0.68) * cellSize, sy + (pos.y - 0.68) * cellSize, cellSize * 0.36, cellSize * 0.36)
end
love.graphics.setBlendMode("screen")
end
end
love.graphics.setBlendMode("alpha")
love.graphics.setColor(name and 0.8 or 0.18, name and 0.8 or 0.18, name and 0.9 or 0.25, 1)
love.graphics.setLineWidth(viewX == 1 and viewY == 1 and 3 or 1)
love.graphics.rectangle("line", sx, sy, roomSize, roomSize)
if name then
love.graphics.setColor(1, 1, 1, 0.75)
love.graphics.print(name, sx + 4, sy + 3)
end
love.graphics.setBlendMode("screen")
end
end
self.wireEditorProjection = {
firstWorldX = (firstRoomX - 1) * gridWidth + 1,
firstWorldY = (firstRoomY - 1) * gridHeight + 1,
cellSize = cellSize,
}
end
function World:wireEditorCellAt(mouseX, mouseY, centerRoomX, centerRoomY)
local cellSize = width / 3 / gridWidth
local firstWorldX = (centerRoomX - 2) * gridWidth + 1
local firstWorldY = (centerRoomY - 2) * gridHeight + 1
return {
x = firstWorldX + math.floor(mouseX / cellSize),
y = firstWorldY + math.floor(mouseY / cellSize),
}
end
function World:drawHoveredRoomName()
local mouseX, mouseY = love.mouse.getPosition()
local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY)