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

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)