chroma_solstice/room.lua

1283 lines
44 KiB
Lua
Raw Normal View History

2015-03-11 20:11:29 -04:00
Room = class("Room")
2015-03-11 21:41:02 -04:00
require "libs/TSerial"
2015-03-11 21:08:15 -04:00
require "player"
2015-03-12 05:13:04 -04:00
require "artifact"
2015-03-14 05:36:02 -04:00
require "npc"
require "art"
require "assorted"
2015-03-15 05:26:47 -04:00
require "switch"
2015-03-15 07:08:35 -04:00
require "door"
2026-08-11 22:55:58 -04:00
require "wired_switch"
require "wired_door"
2015-03-17 02:15:28 -04:00
require "dialogue_manager"
2015-03-25 01:38:53 -04:00
require "generic_moveable"
require "generic_unmoveable"
2026-08-11 20:29:35 -04:00
require "generic_player_blocker"
2026-08-13 12:45:41 -04:00
require "note"
require "mark"
2015-03-11 20:11:29 -04:00
local Movement = require "movement"
2026-08-11 22:55:58 -04:00
local Wire = require "wire"
2015-03-11 21:08:15 -04:00
function Room:initialize(t)
2015-03-15 15:34:17 -04:00
self.player = {}
2015-03-15 05:26:47 -04:00
self.switches = { red = {}, green = {}, blue = {} }
2015-03-16 10:17:06 -04:00
self.doors = {}
2026-08-11 22:55:58 -04:00
self.wiredSwitches = {}
self.wiredDoors = {}
self.wires = {}
2015-03-15 05:26:47 -04:00
self.collideables = { red = {}, green = {}, blue = {} }
2026-08-11 20:29:35 -04:00
self.playerBlockers = { red = {}, green = {}, blue = {} }
2026-08-07 00:31:35 -04:00
self.npcs = {}
-- Per-colour NPC copy residents, mirroring self.player: each room draws and
-- collides the copies currently standing in it (see npc.lua / Walker).
self.npcCopies = { red = {}, green = {}, blue = {} }
2026-08-13 12:45:41 -04:00
self.notes = {}
self.marks = {}
2015-03-17 01:59:14 -04:00
self.activeColors = {red = false, green = false, blue = false}
self.colorsPlayerHas = {}
if t.name then
print("loaded!!")
2026-08-07 01:57:48 -04:00
self:load(t.name, t.runtime) -- registers objects (and any saved players) from the .sav
2015-03-25 01:38:53 -04:00
print(t.name)
2026-08-07 00:31:35 -04:00
2015-03-25 01:38:53 -04:00
if t.player then
2026-08-07 17:49:13 -04:00
-- The carried colors are the sole source of truth for what's active.
-- Copies that live in this room (loaded from the sav) start INACTIVE —
-- you must walk onto one to merge it; it never joins you automatically.
self.activeColors = {red = false, green = false, blue = false}
2026-08-07 00:31:35 -04:00
-- entering from another room: place the carried colors at the entry point
2015-03-25 01:38:53 -04:00
for _, color in ipairs(t.player.activeColors) do
2026-08-07 00:31:35 -04:00
if self.player[color] then
self.player[color].x, self.player[color].y = t.player.x, t.player.y
else
self:registerPlayer(Player:new(t.player.x, t.player.y, color), color)
end
2015-03-25 01:38:53 -04:00
self:setActiveColor(color, true)
end
2015-03-17 01:59:14 -04:00
end
2026-08-07 00:31:35 -04:00
2015-03-17 01:59:14 -04:00
self.name = t.name
else
self.colorsPlayerHas = {"red", "green", "blue"}
2015-03-12 03:04:09 -04:00
2015-03-17 01:59:14 -04:00
for _, color in ipairs(t.player.activeColors) do
self.player[color] = Player:new(t.player.x, t.player.y, color)
end
2015-03-15 15:34:17 -04:00
2015-03-17 01:59:14 -04:00
self.activeColors = {}
for _, color in ipairs(t.player.activeColors) do self.activeColors[color] = true end
2015-03-11 21:08:15 -04:00
2015-03-17 01:59:14 -04:00
-- for color, coords in pairs(t.player) do
-- self.player[color] = Player:new(coords.x, coords.y, color)
-- self.activeColors[color] = true
-- end
2015-03-25 01:38:53 -04:00
-- for i=1, 1 do
-- local x, y = randomPosition()
-- -- local door = Door:new(x, y, self.colorsPlayerHas)
-- -- table.insert(self.doors, door)
2015-03-11 20:11:29 -04:00
2015-03-25 01:38:53 -04:00
-- for color, active in pairs(self.activeColors) do
-- table.insert(self.collideables[color], Wall:new(x, y, color))
-- end
-- end
2015-03-12 05:13:04 -04:00
2015-03-25 01:38:53 -04:00
-- for i=0, 5 do
2015-03-17 01:59:14 -04:00
-- local x, y = randomPosition()
2015-03-25 01:38:53 -04:00
-- for color, active in pairs(self.activeColors) do
-- table.insert(self.collideables[color], Box:new(x, y, color))
-- end
2015-03-17 01:59:14 -04:00
-- end
2015-03-15 05:26:47 -04:00
2015-03-25 01:38:53 -04:00
for i = 0, 5 do
local x, y = randomPosition()
print(x, y)
local c = randomColor()
table.insert(self.switches[c], Switch:new(x, y, c))
end
2015-03-17 01:59:14 -04:00
for color, active in pairs(self.activeColors) do
local x, y = 6, 3
-- if active then self.collideables[color]["n: " .. x .. ", " .. y] = Ass:new(x, y, color, "animals/processed/", "me_rach") end
2015-03-14 05:36:02 -04:00
2015-03-17 01:59:14 -04:00
local x, y = 1, 1
2015-03-25 01:38:53 -04:00
-- if active then table.insert(self.collideables[color], Ass:new(x, y, color, "nature/", "palm_scaled_reduced")) end
2015-03-14 05:36:02 -04:00
2015-03-17 01:59:14 -04:00
-- local x, y = 7, 7
-- if active then self.collideables[color]["n: " .. x .. ", " .. y] = Ass:new(x, y, color, "food/processed/", "beer_mug") end
2015-03-14 05:36:02 -04:00
2015-03-17 01:59:14 -04:00
-- self.collideables[color]["fpp"] = Artifact:new(6, 6, color)
end
end
2015-03-14 05:36:02 -04:00
2015-03-17 01:59:14 -04:00
self.switchMatrices = {}
self.collidableMatrices = {} --{red = {{...}{...}{...}}, ...
2026-08-11 20:29:35 -04:00
self.playerBlockerMatrices = {}
2015-03-17 01:59:14 -04:00
self:createSwitchMatrix()
self:createCollideablesMatrix()
2026-08-11 20:29:35 -04:00
self:createPlayerBlockerMatrix()
2026-08-07 17:49:13 -04:00
-- Resolve switches/doors up front so the very first frame renders correct
-- lock state: a block already sitting on a switch presses it before anything
-- is drawn, instead of waiting for the first move's tick.
self:resolveSwitchesAndDoors()
2015-03-11 20:11:29 -04:00
end
2015-03-11 21:41:02 -04:00
function Room:update(dt)
2015-03-15 15:34:17 -04:00
for color, player in pairs(self.player) do
player:update(dt)
end
2015-03-11 20:11:29 -04:00
end
2026-08-12 03:02:07 -04:00
-- A room's view is defined by the colour player copies that are in that room,
-- independent of which of those copies is currently being controlled. A caller
-- may supply another set for World's porous boundary without altering sprites
-- or colour blending.
function Room:getRenderChannels()
if gameWorld and gameWorld.roomBrowseMode then return {"red", "green", "blue"} end
local channels = {}
for _, color in ipairs({"red", "green", "blue"}) do
if self.player[color] then table.insert(channels, color) end
end
2026-08-12 12:27:52 -04:00
-- No player copy lives here, so the view would be black. In debug/dev mode
-- render every channel instead, letting whole (player-less) levels be seen.
if #channels == 0 and DEBUG then return {"red", "green", "blue"} end
2026-08-12 03:02:07 -04:00
return channels
end
function Room:draw(drawChannels)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
return gameWorld:drawRoomNeighborhood(self)
end
2026-08-12 03:02:07 -04:00
drawChannels = drawChannels or self:getRenderChannels()
self:drawWiresAt(0, 0, width / gridWidth, nil, false, drawChannels)
2026-08-12 03:02:07 -04:00
-- Draw only the channels active in this view. Each object just exposes sprites per
2026-08-07 00:40:45 -04:00
-- channel via Entity:draw(channel); a door registered on all three channels
-- naturally draws its red sprite on the red pass, etc. No per-object logic.
2026-08-12 02:55:29 -04:00
for _, channel in ipairs(drawChannels) do
2026-08-07 00:40:45 -04:00
if self.player[channel] then self.player[channel]:draw(channel) end
for _, entity in pairs(self.collideables[channel]) do entity:draw(channel) end
-- The zoomed-out neighbourhood already draws every room directly. Calling
-- the legacy seam helper from each miniature room duplicates foreign
-- entities, which can screen-blend red and green into a false yellow.
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
gameWorld:drawOverlaps(self, channel)
end
2026-08-11 20:29:35 -04:00
for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end
2026-08-07 00:40:45 -04:00
for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end
2026-08-11 22:55:58 -04:00
for _, switch in ipairs(self.wiredSwitches) do switch:draw(channel) end
-- NPC copies render exactly like player copies: each room draws only the
-- copies currently standing in it, on this channel. A copy that has walked
-- into a red-only room shows only its red layer there.
for _, copy in ipairs(self.npcCopies[channel]) do copy:draw(channel) end
2026-08-07 00:31:35 -04:00
end
2026-08-13 12:45:41 -04:00
-- Debug non-collidables (notes, marks, …) are editor-only overlays: shown only
-- under DEBUG while the overlay toggle is on. Drawn once (not per channel) in
-- alpha so each icon reads as itself instead of screen-adding. Each kind draws
-- itself in its own pass; they share the toggle, not any code.
if DEBUG and NOTES_VISIBLE then
2026-08-13 12:45:41 -04:00
local mode, alphaMode = love.graphics.getBlendMode()
love.graphics.setBlendMode("alpha")
for _, note in ipairs(self.notes) do note:draw() end
for _, mark in ipairs(self.marks) do mark:draw() end
2026-08-13 12:45:41 -04:00
love.graphics.setBlendMode(mode, alphaMode)
end
2015-03-12 03:04:09 -04:00
end
2026-08-11 22:55:58 -04:00
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
2026-08-12 03:02:07 -04:00
function Room:drawWiresAt(originX, originY, cellSize, selectedCircuit, editorColors, renderChannels)
2026-08-11 22:55:58 -04:00
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")
2026-08-12 02:55:29 -04:00
local showAllChannels = gameWorld and gameWorld.roomBrowseMode
local visibleColors = {red = showAllChannels, green = showAllChannels, blue = showAllChannels}
if not showAllChannels then
2026-08-12 03:02:07 -04:00
for _, color in ipairs(renderChannels or self:getRenderChannels()) do visibleColors[color] = true end
2026-08-12 02:55:29 -04:00
end
2026-08-11 22:55:58 -04:00
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)
2026-08-12 00:30:32 -04:00
local previousShader = love.graphics.getShader()
if not editorColors and switchColors and wireNeonShader then
local powered = gameWorld:wirePoweredAt(self, localCell, circuitId)
wireNeonShader:send("wirePower", powered and 1 or 0)
wireNeonShader:send("wireTime", love.timer.getTime())
wireNeonShader:send("wirePhase", (#circuitId * 0.137) + x * 0.071 + y * 0.113)
love.graphics.setShader(wireNeonShader)
end
2026-08-11 22:55:58 -04:00
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
2026-08-12 00:30:32 -04:00
love.graphics.setShader(previousShader)
2026-08-11 22:55:58 -04:00
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)
end
2026-08-08 00:09:44 -04:00
-- Dialogables (npcs) block every channel, so stamp them into each matrix.
for _, npc in ipairs(self.npcs) do
for _, cell in ipairs(npc:getOccupiedCells()) do
for color in pairs(self.collidableMatrices) do
self.collidableMatrices[color][cell.y][cell.x] = npc
end
end
end
2015-03-12 03:04:09 -04:00
end
function Room:createSwitchMatrix()
for color, switches in pairs(self.switches) do
self.switchMatrices[color] = self:createEntityMatrix(switches)
end
end
2026-08-11 20:29:35 -04:00
function Room:createPlayerBlockerMatrix()
for color, blockers in pairs(self.playerBlockers) do
self.playerBlockerMatrices[color] = self:createEntityMatrix(blockers)
end
end
function Room:createEntityMatrix(entities)
local matrix = {}
2015-03-12 03:04:09 -04:00
--lets populate it w/ empty stuff first
for i = 1, gridWidth do
local row = {}
for i = 1, gridHeight do
table.insert(row, false)
2015-03-12 03:04:09 -04:00
end
table.insert(matrix, row)
2015-03-12 03:04:09 -04:00
end
2015-03-17 01:59:14 -04:00
--assumes that entities table is set up li_e {index = ent1, anotherIndex = ent2, ...}
for _, entity in pairs(entities) do
for _, occupiedCells in pairs(entity:getOccupiedCells()) do
--we store a table reference so we can call a method on it during a chec_
if self:isInBounds(occupiedCells) then matrix[occupiedCells.y][occupiedCells.x] = entity end
end
end
-- printMatrix(matrix)
2015-03-17 01:59:14 -04:00
return matrix
2015-03-12 03:04:09 -04:00
end
function Room:getCellInMatrix(matrix, cell)
return matrix[cell.y][cell.x]
2015-03-12 03:04:09 -04:00
end
-- Kept for isolated room tests/editor previews. The live game goes through
-- World:movePlayer so no gameplay decision is room-local.
function Room:movePlayerLocal(key)
2015-03-15 15:34:17 -04:00
local moved = false
2015-03-16 08:47:08 -04:00
local previousPositions = {} --these are so we can perform the overlap chec_
local newPositions = {}
2015-03-15 15:34:17 -04:00
for _, color in pairs(self:getActiveColors()) do
local entitiesToPush, entitiesToPull = {}, {}
local pos = self.player[color]:getGridPos()
2015-03-16 08:47:08 -04:00
previousPositions[color] = pos
2015-03-15 15:34:17 -04:00
local x, y = pos.x, pos.y
local dir = {x = 0, y = 0}
local axis
if key == "up" then
dir.y, axis = -1, "y"
elseif key == "down" then
dir.y, axis = 1, "y"
elseif key == "left" then
dir.x, axis = -1, "x"
elseif key == "right" then
dir.x, axis = 1, "x"
end
2015-03-15 15:34:17 -04:00
local move = true
-- PUSH ---------------------------------------------------------------
2015-03-12 03:04:09 -04:00
2015-03-15 15:34:17 -04:00
local initialTargetCell = {
x = pos.x + dir.x, y = pos.y + dir.y
} -- this is for reference for each color as we change the targetCells table
2015-03-12 03:04:09 -04:00
local useWorld = gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self
local context = useWorld and gameWorld:movementContext(self, color)
local worldTarget = useWorld and context:worldCell(initialTargetCell)
2015-03-16 07:43:02 -04:00
local initialTargetEntity
2015-03-17 01:59:14 -04:00
if useWorld then
initialTargetEntity = context:entityAt(worldTarget)
elseif self:isInBounds(initialTargetCell) then
initialTargetEntity = self:getCellInMatrix(self.collidableMatrices[color], initialTargetCell)
end
2015-03-16 07:43:02 -04:00
if initialTargetEntity then initialTargetEntity:bump() end
2026-08-11 20:29:35 -04:00
local playerBlocker
if useWorld then
playerBlocker = context:playerBlockerAt(worldTarget)
elseif self:isInBounds(initialTargetCell) and self.playerBlockerMatrices and self.playerBlockerMatrices[color] then
2026-08-11 20:29:35 -04:00
playerBlocker = self:getCellInMatrix(self.playerBlockerMatrices[color], initialTargetCell)
end
2015-03-16 07:39:54 -04:00
if useWorld and not context:isInBounds(worldTarget) then
move = true
elseif not useWorld and not self:isInBounds(initialTargetCell) then
2015-03-16 07:39:54 -04:00
-- let's us move out of bounds to Change Rooms without pulling a bloc_
2015-03-15 15:34:17 -04:00
move = true
2026-08-11 20:29:35 -04:00
elseif playerBlocker and playerBlocker:blocksPlayer() then
move = false
elseif useWorld then
move, entitiesToPush = Movement.findMovableGroup({worldTarget}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
2015-03-15 15:34:17 -04:00
else
move, entitiesToPush = Movement.findMovableGroup(
{initialTargetCell}, dir,
function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
function(cell) return self:isInBounds(cell) end
)
end
-- PULL ---------------------------------------------------------------
2015-03-15 15:34:17 -04:00
local initialTargetCell = {x = pos.x - dir.x, y = pos.y - dir.y}
local pull = false
if useWorld and context:isInBounds(context:worldCell(initialTargetCell)) then
pull, entitiesToPull = Movement.findMovableGroup({context:worldCell(initialTargetCell)}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
elseif not useWorld and self:isInBounds(initialTargetCell) then
pull, entitiesToPull = Movement.findMovableGroup(
{initialTargetCell}, dir,
function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
function(cell) return self:isInBounds(cell) end
)
end
2015-03-15 15:34:17 -04:00
if move then
if useWorld then
local group = {}
for entity in pairs(entitiesToPush) do group[entity] = entity end
if pull then for entity in pairs(entitiesToPull) do group[entity] = entity end end
gameWorld:moveGroup(group, dir)
else
for _, entity in pairs(entitiesToPush) do entity:move(axis, dir[axis]) end
end
if pull == true and not useWorld then
for _, entity in pairs(entitiesToPull) do
-- A connected component can wrap around the player and be found by
-- both searches. Every entity still moves exactly one grid cell.
if not entitiesToPush[entity] then
entity:move(axis, dir[axis])
end
end
end
2015-03-15 15:34:17 -04:00
self.player[color]:move(axis, dir[axis])
moved = true
end
2015-03-16 08:47:08 -04:00
newPositions[color] = self.player[color]:getGridPos()
2015-03-15 15:34:17 -04:00
end
if moved then
2026-08-12 12:27:52 -04:00
if Sound then Sound.play("playerMove") end
if useWorld then gameWorld:playerJoinCheck(self, previousPositions, newPositions)
else self:playerJoinCheck(previousPositions, newPositions) end
self:tick()
end
end
function Room:movePlayer(key)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then
return gameWorld:movePlayer(self, key)
end
return self:movePlayerLocal(key)
end
function Room:switchPlayerColorLocal(color)
-- Number keys can request a colour the player has not collected yet. Do
-- nothing in that case: there is no corresponding player object to switch
-- to (or use as the sound source).
local targetPlayer = self.player[color]
if not targetPlayer then return false end
2026-08-12 00:30:13 -04:00
local activeColors = self:getActiveColors()
local canJoin = not self:colorIsActive(color) and #activeColors > 0
if canJoin then
local target = targetPlayer:getGridPos()
for _, activeColor in ipairs(activeColors) do
local activePlayer = self.player[activeColor]
local activePos = activePlayer and activePlayer:getGridPos()
if not activePos or activePos.x ~= target.x or activePos.y ~= target.y then
canJoin = false
break
end
end
end
local canSwitch = canJoin or not self:onlyColorIsActive(color)
if canJoin then
self:setActiveColor(color, true)
elseif canSwitch then
for _, c in ipairs(self:getPossibleColors()) do
self:setActiveColor(c, c == color)
end
end
2015-03-15 15:34:17 -04:00
targetPlayer:playSwitchSound(canSwitch)
return canSwitch
end
function Room:switchPlayerColor(color)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then
return gameWorld:switchPlayerColor(self, color)
end
return self:switchPlayerColorLocal(color)
end
function Room:setActiveColor(color, bool)
self.activeColors[color] = bool
2015-03-11 21:08:15 -04:00
end
2015-03-15 15:34:17 -04:00
function Room:getActiveColors()
local colors = {}
for color, active in pairs(self.activeColors) do
if active then
table.insert(colors, color)
end
end
return colors
end
2015-03-16 08:47:08 -04:00
function Room:colorIsActive(color)
if self.activeColors[color] then
return true
end
return false
end
2015-03-15 15:34:17 -04:00
function Room:getInactiveColors()
2026-08-07 00:31:35 -04:00
-- only colors the player actually has a copy of but that aren't active right
-- now (a left-behind copy). Colors the player hasn't acquired have no copy to
-- merge with, so they must not appear here.
2015-03-15 15:34:17 -04:00
local colors = {}
2026-08-07 00:31:35 -04:00
for _, color in ipairs(self.colorsPlayerHas) do
if not self.activeColors[color] then
2015-03-15 15:34:17 -04:00
table.insert(colors, color)
end
end
return colors
end
function Room:getPossibleColors()
local colors = {}
for _, color in ipairs(self.colorsPlayerHas) do
table.insert(colors, color)
end
return colors
end
function Room:onlyColorIsActive(color)
local activeColors = self:getActiveColors()
if #activeColors == 1 and activeColors[1] == color then
return true
else
return false
end
end
function Room:tickLocal()
2015-03-12 03:04:09 -04:00
for color, entityArray in pairs(self.collideables) do
for _, entity in pairs(entityArray) do
entity:tick()
end
end
self:createCollideablesMatrix()
2026-08-11 20:29:35 -04:00
self:createPlayerBlockerMatrix()
self:createSwitchMatrix()
2026-08-07 17:49:13 -04:00
self:resolveSwitchesAndDoors()
end
function Room:tick()
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then return gameWorld:tick(self) end
self:tickLocal()
2026-08-07 17:49:13 -04:00
self:nextRoomCheck()
end
-- Run the switch press check and push the result to every door. Assumes the
-- collidable/switch matrices are already current.
function Room:resolveSwitchesAndDoors()
2015-03-15 07:08:35 -04:00
local setDoorColors = self:switchCheck()
for _, door in pairs(self.doors) do
door:setColors(setDoorColors, self:doorCanClose(door))
end
2015-03-11 21:08:15 -04:00
end
-- Match World:doorCanClose for ordinary room-local doors. All colour layers
-- count, while other layers of the same doorway do not obstruct each other.
function Room:doorCanClose(door)
for _, cell in ipairs(door:getOccupiedCells()) do
for _, player in pairs(self.player or {}) do
if player:occupiesCell(cell) then return false end
end
for _, entities in pairs(self.collideables or {}) do
for _, entity in ipairs(entities) do
if entity ~= door and not entity.isDoor and entity:occupiesCell(cell) and
not (entity.canPassOver and entity:canPassOver()) then
return false
end
end
end
end
return true
end
2015-03-16 08:47:08 -04:00
function Room:playerJoinCheck(previousPositions, newPositions)
local activate = {}
local deactivate = {}
local activeColors = self:getActiveColors()
for _, color in ipairs(activeColors) do
if #activeColors > 1 then
if Entity.positionsAreEqual(previousPositions[color], newPositions[color]) then
table.insert(deactivate, color)
end
end
end
2015-03-15 15:34:17 -04:00
for _, inactiveColor in ipairs(self:getInactiveColors()) do
local overlapping = 0
local activeColors = self:getActiveColors()
local inactivePos = self:getPlayerPos(inactiveColor)
for _, activeColor in ipairs(activeColors) do
local activePos = self:getPlayerPos(activeColor)
if activePos.x == inactivePos.x and activePos.y == inactivePos.y then
overlapping = overlapping + 1
end
end
if overlapping == #activeColors then
2015-03-16 08:47:08 -04:00
table.insert(activate, inactiveColor)
2015-03-15 15:34:17 -04:00
end
end
2015-03-16 08:47:08 -04:00
for _, color in ipairs(activate) do
2015-03-15 15:34:17 -04:00
self:setActiveColor(color, true)
end
2015-03-16 08:47:08 -04:00
for _, color in ipairs(deactivate) do
self:setActiveColor(color, false)
end
2015-03-15 15:34:17 -04:00
end
function Room:getPlayerPos(color)
return self.player[color]:getGridPos()
end
2015-03-16 10:17:06 -04:00
function Room:getPlayerObject(color)
return self.player[color]
end
function Room:getDoors()
return self.doors
end
function Room:getCollideables()
return self.collideables
end
function Room:getSwitches()
return self.switches
end
2015-03-15 07:08:35 -04:00
function Room:switchCheck()
local doorsToUnlock = {}
for color, switchArray in pairs(self.switches) do
local onSwitches = {}
for _, switch in pairs(switchArray) do
local targetCell
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then
local cell = gameWorld:worldCell(self, switch:getGridPos())
local row = cell and gameWorld.collidableMatrices[color][cell.y]
targetCell = row and row[cell.x]
else
targetCell = self:getCellInMatrix(self.collidableMatrices[color], switch:getGridPos())
end
2026-08-07 02:01:39 -04:00
local player = self.player[color]
-- Players are not stored in collidableMatrices, but a colour copy
-- standing on its matching switch presses it just like a block does.
if targetCell or (player and player:occupiesCell(switch:getGridPos())) then
--switch is on
switch:activate()
table.insert(onSwitches, switch) --doesn't rly matter what we put here as long as it's true-y
2015-03-15 13:54:47 -04:00
else
switch:deactivate()
end
end
2015-03-15 07:08:35 -04:00
if #onSwitches == #switchArray then doorsToUnlock[color] = true else doorsToUnlock[color] = false end
end
2015-03-15 07:08:35 -04:00
return doorsToUnlock
end
function Room:isInBounds(cell)
if cell.x > 0 and cell.x <= gridWidth and
cell.y > 0 and cell.y <= gridHeight then
return true
end
return false
end
2015-03-11 21:08:15 -04:00
function Room:nextRoomCheck()
2015-03-15 15:34:17 -04:00
for _, color in ipairs(self:getActiveColors()) do
2026-08-07 00:31:35 -04:00
local player = self.player[color]
if player and not self:isInBounds(player:getGridPos()) then
nextRoom(player:getGridPos())
2015-03-15 15:34:17 -04:00
return
end
2015-03-15 05:26:47 -04:00
end
2015-03-11 21:08:15 -04:00
end
2015-03-17 01:59:14 -04:00
2026-08-07 00:56:33 -04:00
-- Instantiate one object from the flat {class,color,x,y} schema. `class` names an
-- art asset, and its .meta declares the behaviour ("door"/"switch"/"player"/
2026-08-11 20:29:35 -04:00
-- "moveable"/"sticky"/"skewer"/"spiky"/"player_blocker"); anything without a .meta defaults to immoveable. npc is the one
2026-08-07 00:56:33 -04:00
-- keyword handled directly since it has no art.
2026-08-11 22:55:58 -04:00
function Room:createEntity(class, color, x, y, data)
data = data or {}
2026-08-07 00:56:33 -04:00
if class == "npc" then
2026-08-08 00:09:44 -04:00
local entity = Sign:new(x, y)
self:registerNpc(entity)
return entity
2026-08-07 00:56:33 -04:00
end
2026-08-11 20:10:03 -04:00
if class == "jorge" then
local entity = Jorge:new(x, y)
self:registerNpc(entity)
return entity
end
2026-08-13 12:45:41 -04:00
if class == "note" then
local entity = Note:new(x, y, data.text)
self:registerNote(entity)
return entity
end
if class == "mark" then
local entity = Mark:new(x, y, data.name or data.markName)
self:registerMark(entity)
return entity
end
2026-08-07 00:56:33 -04:00
-- Legacy: adhesion used to be baked into separate asset classes
-- (spiky_/skewer_/sticky_<box>). Collapse them to the base asset carrying an
-- adhesion string so old .sav files still load. spiky/old-sticky both become
-- the new soft "sticky"; skewer becomes the rigid "gluey".
local adhesion = data.adhesion
local legacyAdhesion = { spiky = "sticky", skewer = "gluey", sticky = "sticky" }
for prefix, mapped in pairs(legacyAdhesion) do
local base = class:match("^" .. prefix .. "_(.+)")
if base then class = base; adhesion = adhesion or mapped; break end
end
2026-08-07 00:56:33 -04:00
local props = globalAssetProperties and globalAssetProperties[class]
local behavior = props and props.class or "immoveable"
2026-08-07 00:31:35 -04:00
local entity
2026-08-07 00:56:33 -04:00
2026-08-11 22:55:58 -04:00
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
2026-08-07 01:57:48 -04:00
-- Old migrated rooms stored a colourless shared door. Expand that legacy
-- record into ordinary colour-layer door objects on load.
if not color then
for _, doorColor in ipairs({"red", "green", "blue"}) do
if props.sprites[doorColor] then self:createEntity(class, doorColor, x, y) end
end
return
end
entity = Door:new(x, y, color)
self:registerDoor(entity, color)
2026-08-07 00:56:33 -04:00
elseif behavior == "switch" then
2026-08-07 00:31:35 -04:00
entity = Switch:new(x, y, color)
self:registerSwitch(entity, color)
2026-08-07 00:56:33 -04:00
elseif behavior == "player" then
2026-08-07 00:31:35 -04:00
entity = Player:new(x, y, color)
self:registerPlayer(entity, color)
self:setActiveColor(color, true)
2026-08-07 00:56:33 -04:00
elseif props and props.sprites and props.sprites[color] then
2026-08-07 01:05:08 -04:00
local sprite = loadSprite(props.sprites[color])
-- Reuse the cell shape precomputed by getAllAssets (nil/empty falls back
-- to the full sprite rectangle inside Entity), so an L/U/notched block
-- collides on its real cells and its gaps read as empty space.
local cellShape = props.cells and props.cells[color]
if cellShape and #cellShape == 0 then cellShape = nil end
2026-08-11 20:29:35 -04:00
if behavior == "player_blocker" then
entity = GenericPlayerBlocker:new(x, y, color, sprite, cellShape)
self:registerPlayerBlocker(entity, color)
elseif behavior == "moveable" then
entity = GenericMoveable:new(x, y, color, sprite, class, cellShape, adhesion)
2026-08-07 00:56:33 -04:00
else
entity = GenericUnmoveable:new(x, y, color, sprite, cellShape)
2026-08-07 00:31:35 -04:00
end
2026-08-11 20:29:35 -04:00
if behavior ~= "player_blocker" then
self:registerCollidable(entity, {color})
end
2015-03-17 01:59:14 -04:00
end
2026-08-07 00:56:33 -04:00
2026-08-07 00:31:35 -04:00
if entity then entity.saveClass = class end
return entity
2015-03-17 01:59:14 -04:00
end
function Room:registerCollidable(entityPointer, colorArray)
if type(colorArray) ~= "table" then colorArray = {colorArray} end
for _, color in ipairs(colorArray) do
table.insert(self.collideables[color], entityPointer)
end
end
2026-08-11 20:29:35 -04:00
function Room:registerPlayerBlocker(entityPointer, color)
table.insert(self.playerBlockers[color], entityPointer)
end
2026-08-07 01:57:48 -04:00
function Room:registerDoor(entityPointer, color)
2015-03-17 01:59:14 -04:00
table.insert(self.doors, entityPointer)
2026-08-07 01:57:48 -04:00
self:registerCollidable(entityPointer, color)
2015-03-17 01:59:14 -04:00
end
function Room:registerSwitch(entityPointer, color)
table.insert(self.switches[color], entityPointer)
end
2026-08-11 22:55:58 -04:00
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
2015-03-17 01:59:14 -04:00
function Room:registerPlayer(entityPointer, color)
table.insert(self.colorsPlayerHas, color)
self.player[color] = entityPointer
end
2026-08-08 00:09:44 -04:00
function Room:registerNpc(entity)
-- The NPC entity is a controller kept here for identity / dialogue / serialize.
-- Its visible, collidable presence is three per-colour copies (Walker) that
-- each belong to whatever room they stand in; initCopies seeds them here at the
-- NPC's home cell, registered into this room's npcCopies lists.
entity.room = self
if entity.initCopies then entity:initCopies(self) end
2026-08-08 00:09:44 -04:00
table.insert(self.npcs, entity)
2026-08-07 00:31:35 -04:00
end
2026-08-13 12:45:41 -04:00
function Room:registerNote(entity)
-- Notes are like switches: a passable object kept in their own registry, so
-- serialize's note loop round-trips them and movement never sees them (their
-- "none" collision means canPassOver, so they are not stamped into a matrix).
table.insert(self.notes, entity)
end
-- The note (if any) under a given grid cell — used by the editor's hover/click.
function Room:noteAtCell(cell)
if not cell then return nil end
for _, note in ipairs(self.notes) do
if note:occupiesCell(cell) then return note end
end
return nil
end
function Room:registerMark(entity)
-- Marks, like notes, are passable annotations kept in their own registry so
-- serialize round-trips them and movement never stamps them into a matrix.
-- The difference is their name is meaningful: NPCs are sent here by it.
table.insert(self.marks, entity)
end
-- The mark (if any) under a given grid cell — used by the editor's hover/click.
function Room:markAtCell(cell)
if not cell then return nil end
for _, mark in ipairs(self.marks) do
if mark:occupiesCell(cell) then return mark end
end
return nil
end
-- The npc (if any) with a copy under a given grid cell in this room — used by the
-- editor's debug destination picker (click an NPC to send it to a mark). Checks
-- the visible copies, so clicking a walked-away copy still finds its controller.
function Room:npcAtCell(cell)
if not cell then return nil end
for _, color in ipairs({"red", "green", "blue"}) do
for _, copy in ipairs(self.npcCopies[color]) do
if copy:occupiesCell(cell) then return copy.npc end
end
end
return nil
end
-- Code-side mark creation: register a named destination through the same path
-- authored marks use, so it collides/renders in debug identically. Flagged
-- `programmatic` so Room:serialize skips it — code recreates it on every load.
function Room:addMark(name, x, y)
local entity = self:createEntity("mark", nil, x, y, { name = name })
if entity then entity.programmatic = true end
return entity
end
2015-03-25 01:38:53 -04:00
function Room:getCellFromMousePos(mouseX, mouseY)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
return gameWorld:screenCellInRoom(self, mouseX, mouseY)
end
return {
x = math.floor(mouseX * gridWidth / width) + 1,
y = math.floor(mouseY * gridHeight / height) + 1,
}
2015-03-25 01:38:53 -04:00
end
function Room:attemptDeleteAtCell(cell, color)
if not cell then return end
2026-08-06 23:54:49 -04:00
-- "white" (or nil) deletes every channel; otherwise just the picked one
local colors
if not color or color == "white" then
colors = {"red", "green", "blue"}
else
colors = {color}
2015-03-25 01:38:53 -04:00
end
2026-08-06 23:54:49 -04:00
2026-08-07 01:57:48 -04:00
local removedEntities = {}
2026-08-06 23:54:49 -04:00
for _, c in ipairs(colors) do
-- player
if self.player[c] and self.player[c]:occupiesCell(cell) then
self.player[c] = nil
2026-08-07 00:31:35 -04:00
self:setActiveColor(c, false)
for i = #self.colorsPlayerHas, 1, -1 do
if self.colorsPlayerHas[i] == c then table.remove(self.colorsPlayerHas, i) end
end
2026-08-06 23:54:49 -04:00
end
-- collidables (iterate backwards so table.remove is safe)
local entityArray = self.collideables[c]
for index = #entityArray, 1, -1 do
if entityArray[index]:occupiesCell(cell) then
2026-08-07 01:57:48 -04:00
removedEntities[entityArray[index]] = true
2026-08-06 23:54:49 -04:00
table.remove(entityArray, index)
2015-03-25 01:38:53 -04:00
end
end
2026-08-11 20:29:35 -04:00
-- Player-only blockers are rendered entities, but use their own collision
-- matrix so blocks can overlap them.
local blockerArray = self.playerBlockers[c]
for index = #blockerArray, 1, -1 do
if blockerArray[index]:occupiesCell(cell) then
table.remove(blockerArray, index)
end
end
2026-08-06 23:54:49 -04:00
-- switches
local switchArray = self.switches[c]
for index = #switchArray, 1, -1 do
if switchArray[index]:occupiesCell(cell) then
table.remove(switchArray, index)
2015-03-25 01:38:53 -04:00
end
end
end
2026-08-07 01:57:48 -04:00
-- Behaviour registries hold references to the same objects as the colour
-- layers. Clean references by identity; no class gets bespoke delete rules.
for index = #self.doors, 1, -1 do
if removedEntities[self.doors[index]] then table.remove(self.doors, index) end
end
2026-08-11 22:55:58 -04:00
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
-- An NPC is a controller plus per-colour copies spread across rooms; deleting
-- one means tearing down all of them (Walker:destroy), regardless of the
-- currently selected editor colour.
local npc = self:npcAtCell(cell)
if npc and npc.destroy then npc:destroy() end
2026-08-13 12:45:41 -04:00
-- Notes are non-collidable and colourless, so a right-click removes them
-- regardless of the currently selected editor colour.
for index = #self.notes, 1, -1 do
if self.notes[index]:occupiesCell(cell) then table.remove(self.notes, index) end
end
-- Marks behave like notes for deletion: colourless, passable, removed on any
-- selected colour.
for index = #self.marks, 1, -1 do
if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end
end
2015-03-25 01:38:53 -04:00
self:tick()
end
function Room:attemptDelete(mouseX, mouseY, color)
return self:attemptDeleteAtCell(self:getCellFromMousePos(mouseX, mouseY), color)
end
function Room:attemptPlaceAtCell(name, props, cell, color, text, adhesion)
2026-08-13 12:45:41 -04:00
-- Notes are colourless, single-instance annotations (no per-channel sprites),
-- so they place once carrying their text rather than one copy per colour. One
-- note per cell: drop any note already here before adding the new one.
if name == "note" then
if not cell then return end
for index = #self.notes, 1, -1 do
if self.notes[index]:occupiesCell(cell) then table.remove(self.notes, index) end
end
self:createEntity("note", nil, cell.x, cell.y, { text = text })
self:tick()
return
end
-- Marks are colourless, single-instance named destinations. One mark per cell:
-- drop any mark already here before adding the new one. `text` carries the name.
if name == "mark" then
if not cell then return end
for index = #self.marks, 1, -1 do
if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end
end
local mark = self:createEntity("mark", nil, cell.x, cell.y, { name = text })
self:tick()
return mark
end
2026-08-06 23:54:49 -04:00
if not props or not props.sprites then return end
if not cell then return end
2026-08-11 20:10:03 -04:00
-- Jorge is one dialogable NPC with three display channels, not three
-- independently placeable color-layer entities.
if name == "jorge" then
self:createEntity(name, nil, cell.x, cell.y)
self:tick()
return
end
2015-03-25 01:38:53 -04:00
2026-08-06 23:54:49 -04:00
-- "white" (or nil) means every channel; otherwise just the picked channel
local colors
if not color or color == "white" then
colors = {"red", "green", "blue"}
else
colors = {color}
end
2026-08-07 00:31:35 -04:00
-- one entity per placed channel, via the same path level-loading uses; the
-- additive blend combines the channels into full color
2026-08-06 23:54:49 -04:00
for _, c in ipairs(colors) do
2026-08-07 00:31:35 -04:00
if props.sprites[c] then
self:createEntity(name, c, cell.x, cell.y, { adhesion = adhesion })
2026-08-06 23:54:49 -04:00
end
end
self:tick()
2015-03-25 01:38:53 -04:00
end
function Room:attemptPlace(name, props, mouseX, mouseY, color)
return self:attemptPlaceAtCell(name, props, self:getCellFromMousePos(mouseX, mouseY), color)
end
2026-08-07 01:57:48 -04:00
function Room:load(name, preferRuntime)
-- Editor/startup loads must use the project source level. Runtime saves are
-- only consulted for an in-progress room-to-room game transition, otherwise
-- an old runtime autosave can hide an editor Save after restart.
local room
if preferRuntime then room = love.filesystem.read("rooms/" .. name .. ".sav") end
if not room then room = readFromSource("rooms/" .. name .. ".sav") end
2026-08-06 23:54:49 -04:00
if not room then
-- no save yet: start with a blank room to build in the editor
print("no save for '" .. name .. "', starting blank")
return
end
2015-03-17 01:59:14 -04:00
self:deserialize(TSerial.unpack(room))
end
function Room:deserialize(saveTable)
2026-08-07 00:31:35 -04:00
for _, obj in ipairs(saveTable.objects or {}) do
2026-08-11 22:55:58 -04:00
self:createEntity(obj.class, obj.color, obj.x, obj.y, obj)
2026-08-07 20:56:42 -04:00
-- 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).
if obj.class == "player" and obj.active ~= nil then
self:setActiveColor(obj.color, obj.active)
end
2015-03-17 01:59:14 -04:00
end
2026-08-11 22:55:58 -04:00
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
2015-03-17 01:59:14 -04:00
end
function Room:save()
2015-03-17 01:59:14 -04:00
local name = self.name or "test"
local room = TSerial.pack(self:serialize(), nil, true)
2026-08-07 01:39:47 -04:00
local success = love.filesystem.write("rooms/" .. name .. ".sav", room)
2026-08-12 17:20:11 -04:00
if success then print("Saved runtime: " .. love.filesystem.getSaveDirectory() .. "/rooms/" .. name .. ".sav") end
2015-03-17 01:59:14 -04:00
if not success then error "great job u fool" end
end
2026-08-07 01:39:47 -04:00
-- This is intentionally separate from Room:save. It is the editor's explicit
-- "write this level into the project" action; gameplay autosaves must never
-- call it.
function Room:saveToSource(name, savePlayerPositions)
2026-08-07 01:39:47 -04:00
name = name or self.name or "test"
2026-08-07 03:22:28 -04:00
local saveTable = self:serialize(savePlayerPositions ~= false)
local room = TSerial.pack(saveTable, nil, true)
2026-08-07 01:39:47 -04:00
return writeToSource("rooms/" .. name .. ".sav", room)
end
2026-08-11 22:55:58 -04:00
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
2026-08-07 03:22:28 -04:00
function Room:serialize(includePlayers)
2026-08-07 00:31:35 -04:00
local objects = {}
2015-03-16 10:17:06 -04:00
2026-08-07 00:31:35 -04:00
local function emit(class, color, entity)
local pos = entity:getGridPos()
2026-08-11 22:55:58 -04:00
local object = { class = class, color = color, x = pos.x, y = pos.y }
if entity.circuitId then object.circuit = entity.circuitId end
if entity.adhesion then object.adhesion = entity.adhesion end
2026-08-11 22:55:58 -04:00
table.insert(objects, object)
2015-03-16 10:17:06 -04:00
end
2026-08-07 03:22:28 -04:00
if includePlayers ~= false then
for color, player in pairs(self.player) do
2026-08-07 20:56:42 -04:00
local pos = player:getGridPos()
-- Persist which copies were active so a resume restores exactly the
-- channels that were on at save time. A colour deactivated during play
-- (see playerJoinCheck) must come back as an inactive copy, not turn on.
table.insert(objects, {
class = "player", color = color, x = pos.x, y = pos.y,
active = self.activeColors[color] and true or false,
})
2026-08-07 03:22:28 -04:00
end
2015-03-16 10:17:06 -04:00
end
2026-08-07 00:31:35 -04:00
for color, collideableArray in pairs(self.collideables) do
2015-03-16 10:17:06 -04:00
for _, collideable in pairs(collideableArray) do
2026-08-07 01:57:48 -04:00
emit(collideable.saveClass or "unmoveable", color, collideable)
2015-03-16 10:17:06 -04:00
end
end
2026-08-11 20:29:35 -04:00
for color, blockerArray in pairs(self.playerBlockers) do
for _, blocker in pairs(blockerArray) do
emit(blocker.saveClass or "player_blocker", color, blocker)
end
end
2026-08-07 00:31:35 -04:00
for color, switchArray in pairs(self.switches) do
2015-03-16 10:17:06 -04:00
for _, switch in ipairs(switchArray) do
2026-08-07 00:31:35 -04:00
emit("switch", color, switch)
2015-03-16 10:17:06 -04:00
end
end
2026-08-11 22:55:58 -04:00
for _, switch in ipairs(self.wiredSwitches) do
emit("wired_switch", switch.color:getname(), switch)
end
2026-08-07 00:31:35 -04:00
for _, npc in ipairs(self.npcs) do
2026-08-11 20:10:03 -04:00
table.insert(objects, { class = npc.saveClass or "npc", x = npc.x, y = npc.y })
2026-08-07 00:31:35 -04:00
end
2026-08-13 12:45:41 -04:00
for _, note in ipairs(self.notes) do
table.insert(objects, { class = "note", x = note.x, y = note.y, text = note.text })
end
-- Programmatic marks are recreated from code on every load, so persisting them
-- would duplicate them. Only authored (editor-placed) marks are written.
for _, mark in ipairs(self.marks) do
if not mark.programmatic then
table.insert(objects, { class = "mark", x = mark.x, y = mark.y, name = mark.markName })
end
end
2026-08-11 22:55:58 -04:00
return { objects = objects, wires = self:serializeWires() }
end
2015-03-15 05:26:47 -04:00
function nextRoom(pos)
if gameWorld and currentRoom then return gameWorld:nextRoom(currentRoom, pos) end
2015-03-15 05:26:47 -04:00
local exit
local newPos = {x = pos.x, y = pos.y}
if pos.x > gridWidth then
exit = "right"
newPos.x = 1
elseif pos.x < 1 then
exit = "left"
newPos.x = gridWidth
elseif pos.y > gridHeight then
exit = "bottom"
newPos.y = 1
elseif pos.y < 1 then
exit = "top"
newPos.y = gridHeight
end
2015-03-12 03:04:09 -04:00
2026-08-07 01:39:47 -04:00
local currentName = currentRoom.name
local nextName = gameWorld and currentName and gameWorld:getNeighbor(currentName, exit)
2026-08-12 12:27:52 -04:00
if Sound and nextName and nextName ~= currentName then Sound.play("levelChange") end
currentRoom:save()
2026-08-07 01:39:47 -04:00
-- Missing map neighbours intentionally wrap back into the room just exited.
-- A placed neighbour gets the same carried player state at its opposite edge.
local playerState = {x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors()}
currentRoom = gameWorld and gameWorld.levels[nextName or currentName] or Room:new{name = nextName or currentName, runtime = true}
if currentRoom then
currentRoom.activeColors = {red = false, green = false, blue = false}
for _, color in ipairs(playerState.activeColors) do
if currentRoom.player[color] then currentRoom.player[color].x, currentRoom.player[color].y = playerState.x, playerState.y
else currentRoom:registerPlayer(Player:new(playerState.x, playerState.y, color), color) end
currentRoom:setActiveColor(color, true)
end
gameWorld:playerJoinCheck(currentRoom)
end
2026-08-07 03:22:28 -04:00
if gameWorld then gameWorld:setLastRoom(currentRoom.name) end
2026-08-07 01:39:47 -04:00
if currentRoom.name then setEditorRoomName(currentRoom.name) end
2015-03-17 01:59:14 -04:00
end