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

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)