Room = class("Room") require "libs/TSerial" require "player" require "artifact" require "npc" require "art" require "assorted" require "switch" require "door" require "wired_switch" require "wired_door" require "dialogue_manager" require "generic_moveable" require "generic_sticky" require "generic_skewer" require "generic_spiky" 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 = {} self.activeColors = {red = false, green = false, blue = false} self.colorsPlayerHas = {} if t.name then print("loaded!!") self:load(t.name, t.runtime) -- registers objects (and any saved players) from the .sav print(t.name) if t.player then -- 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} -- entering from another room: place the carried colors at the entry point for _, color in ipairs(t.player.activeColors) do 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 self:setActiveColor(color, true) end end self.name = t.name else self.colorsPlayerHas = {"red", "green", "blue"} for _, color in ipairs(t.player.activeColors) do self.player[color] = Player:new(t.player.x, t.player.y, color) end self.activeColors = {} for _, color in ipairs(t.player.activeColors) do self.activeColors[color] = true end -- for color, coords in pairs(t.player) do -- self.player[color] = Player:new(coords.x, coords.y, color) -- self.activeColors[color] = true -- end -- for i=1, 1 do -- local x, y = randomPosition() -- -- local door = Door:new(x, y, self.colorsPlayerHas) -- -- table.insert(self.doors, door) -- for color, active in pairs(self.activeColors) do -- table.insert(self.collideables[color], Wall:new(x, y, color)) -- end -- end -- for i=0, 5 do -- local x, y = randomPosition() -- for color, active in pairs(self.activeColors) do -- table.insert(self.collideables[color], Box:new(x, y, color)) -- end -- end 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 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 local x, y = 1, 1 -- if active then table.insert(self.collideables[color], Ass:new(x, y, color, "nature/", "palm_scaled_reduced")) end -- local x, y = 7, 7 -- if active then self.collideables[color]["n: " .. x .. ", " .. y] = Ass:new(x, y, color, "food/processed/", "beer_mug") end -- self.collideables[color]["fpp"] = Artifact:new(6, 6, color) end end self.switchMatrices = {} self.collidableMatrices = {} --{red = {{...}{...}{...}}, ... self.playerBlockerMatrices = {} self:createSwitchMatrix() self:createCollideablesMatrix() self:createPlayerBlockerMatrix() -- 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() end function Room:update(dt) for color, player in pairs(self.player) do player:update(dt) end end function Room:draw() if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and not gameWorld.drawingRoomNeighborhood then return gameWorld:drawRoomNeighborhood(self) end 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 -- naturally draws its red sprite on the red pass, etc. No per-object logic. local drawChannels = gameWorld and gameWorld.roomBrowseMode and {"red", "green", "blue"} or self.colorsPlayerHas for _, channel in ipairs(drawChannels) do 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 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 showAllChannels = gameWorld and gameWorld.roomBrowseMode local visibleColors = {red = showAllChannels, green = showAllChannels, blue = showAllChannels} if not showAllChannels then for _, color in ipairs(self.colorsPlayerHas or {}) do visibleColors[color] = true end 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) 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 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 love.graphics.setShader(previousShader) 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 -- 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 end function Room:createSwitchMatrix() for color, switches in pairs(self.switches) do self.switchMatrices[color] = self:createEntityMatrix(switches) end end 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 = {} --lets populate it w/ empty stuff first for i = 1, gridWidth do local row = {} for i = 1, gridHeight do table.insert(row, false) end table.insert(matrix, row) end --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) return matrix end function Room:getCellInMatrix(matrix, cell) return matrix[cell.y][cell.x] 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) local moved = false local previousPositions = {} --these are so we can perform the overlap chec_ local newPositions = {} for _, color in pairs(self:getActiveColors()) do local entitiesToPush, entitiesToPull = {}, {} local pos = self.player[color]:getGridPos() previousPositions[color] = pos 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 local move = true -- PUSH --------------------------------------------------------------- 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 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) local initialTargetEntity if useWorld then initialTargetEntity = context:entityAt(worldTarget) elseif self:isInBounds(initialTargetCell) then initialTargetEntity = self:getCellInMatrix(self.collidableMatrices[color], initialTargetCell) end if initialTargetEntity then initialTargetEntity:bump() end local playerBlocker if useWorld then playerBlocker = context:playerBlockerAt(worldTarget) elseif self:isInBounds(initialTargetCell) and self.playerBlockerMatrices and self.playerBlockerMatrices[color] then playerBlocker = self:getCellInMatrix(self.playerBlockerMatrices[color], initialTargetCell) end if useWorld and not context:isInBounds(worldTarget) then move = true elseif not useWorld and not self:isInBounds(initialTargetCell) then -- let's us move out of bounds to Change Rooms without pulling a bloc_ move = true 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) 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 --------------------------------------------------------------- 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 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 self.player[color]:move(axis, dir[axis]) moved = true end newPositions[color] = self.player[color]:getGridPos() end if moved then 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 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 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 end 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 function Room:colorIsActive(color) if self.activeColors[color] then return true end return false end function Room:getInactiveColors() -- 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. local colors = {} for _, color in ipairs(self.colorsPlayerHas) do if not self.activeColors[color] then 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() for color, entityArray in pairs(self.collideables) do for _, entity in pairs(entityArray) do entity:tick() end end self:createCollideablesMatrix() self:createPlayerBlockerMatrix() self:createSwitchMatrix() 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() 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() local setDoorColors = self:switchCheck() for _, door in pairs(self.doors) do door:setColors(setDoorColors, self:doorCanClose(door)) end 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 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 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 table.insert(activate, inactiveColor) end end for _, color in ipairs(activate) do self:setActiveColor(color, true) end for _, color in ipairs(deactivate) do self:setActiveColor(color, false) end end function Room:getPlayerPos(color) return self.player[color]:getGridPos() end 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 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 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 else switch:deactivate() end end if #onSwitches == #switchArray then doorsToUnlock[color] = true else doorsToUnlock[color] = false end end 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 function Room:nextRoomCheck() for _, color in ipairs(self:getActiveColors()) do local player = self.player[color] if player and not self:isInBounds(player:getGridPos()) then nextRoom(player:getGridPos()) return end end end -- 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"/ -- "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, data) data = data or {} if class == "npc" then local entity = Sign:new(x, y) self:registerNpc(entity) return entity end if class == "jorge" then local entity = Jorge:new(x, y) self:registerNpc(entity) return entity end local props = globalAssetProperties and globalAssetProperties[class] local behavior = props and props.class or "immoveable" local entity 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 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) elseif behavior == "switch" then entity = Switch:new(x, y, color) self:registerSwitch(entity, color) elseif behavior == "player" then entity = Player:new(x, y, color) self:registerPlayer(entity, color) self:setActiveColor(color, true) elseif props and props.sprites and props.sprites[color] then 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 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) elseif behavior == "sticky" then entity = GenericSticky:new(x, y, color, sprite, class, cellShape) elseif behavior == "skewer" then entity = GenericSkewer:new(x, y, color, sprite, class, cellShape) elseif behavior == "spiky" then entity = GenericSpiky:new(x, y, color, sprite, class, cellShape) else entity = GenericUnmoveable:new(x, y, color, sprite, cellShape) end if behavior ~= "player_blocker" then self:registerCollidable(entity, {color}) end end if entity then entity.saveClass = class end return entity 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 function Room:registerPlayerBlocker(entityPointer, color) table.insert(self.playerBlockers[color], entityPointer) end function Room:registerDoor(entityPointer, color) table.insert(self.doors, entityPointer) self:registerCollidable(entityPointer, color) end 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 end function Room:registerNpc(entity) -- npcs are dialogable entities: they carry .x/.y like any Entity, so -- serialize's npc loop still round-trips them, and they get stamped into the -- collision matrix (see createCollideablesMatrix) so a bump is a rejected move. table.insert(self.npcs, entity) end function Room:getCellFromMousePos(mouseX, mouseY) local cell = {} cell.x = math.ceil(mouseX * gridWidth / width) cell.y = math.ceil(mouseY * gridHeight / height) return cell end function Room:attemptDelete(mouseX, mouseY, color) local cell = self:getCellFromMousePos(mouseX, mouseY) -- "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} end local removedEntities = {} for _, c in ipairs(colors) do -- player if self.player[c] and self.player[c]:occupiesCell(cell) then self.player[c] = nil self:setActiveColor(c, false) for i = #self.colorsPlayerHas, 1, -1 do if self.colorsPlayerHas[i] == c then table.remove(self.colorsPlayerHas, i) end end 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 removedEntities[entityArray[index]] = true table.remove(entityArray, index) end end -- 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 -- switches local switchArray = self.switches[c] for index = #switchArray, 1, -1 do if switchArray[index]:occupiesCell(cell) then table.remove(switchArray, index) end end end -- 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 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. for index = #self.npcs, 1, -1 do if self.npcs[index]:occupiesCell(cell) then table.remove(self.npcs, index) end end self:tick() end function Room:attemptPlace(name, props, mouseX, mouseY, color) if not props or not props.sprites then return end local cell = self:getCellFromMousePos(mouseX, mouseY) -- 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 -- "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 -- one entity per placed channel, via the same path level-loading uses; the -- additive blend combines the channels into full color for _, c in ipairs(colors) do if props.sprites[c] then self:createEntity(name, c, cell.x, cell.y) end end self:tick() end 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 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 self:deserialize(TSerial.unpack(room)) end function Room:deserialize(saveTable) for _, obj in ipairs(saveTable.objects or {}) do 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). if obj.class == "player" and obj.active ~= nil then 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() local name = self.name or "test" local room = TSerial.pack(self:serialize(), nil, true) local success = love.filesystem.write("rooms/" .. name .. ".sav", room) if not success then error "great job u fool" end end -- 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) name = name or self.name or "test" local saveTable = self:serialize(savePlayerPositions ~= false) local room = TSerial.pack(saveTable, nil, true) 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() 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 for color, player in pairs(self.player) do 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, }) end end for color, collideableArray in pairs(self.collideables) do for _, collideable in pairs(collideableArray) do emit(collideable.saveClass or "unmoveable", color, collideable) end end for color, blockerArray in pairs(self.playerBlockers) do for _, blocker in pairs(blockerArray) do emit(blocker.saveClass or "player_blocker", color, blocker) end end for color, switchArray in pairs(self.switches) do for _, switch in ipairs(switchArray) do 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, wires = self:serializeWires() } end function nextRoom(pos) if gameWorld and currentRoom then return gameWorld:nextRoom(currentRoom, pos) end 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 local currentName = currentRoom.name local nextName = gameWorld and currentName and gameWorld:getNeighbor(currentName, exit) currentRoom:save() -- 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 if gameWorld then gameWorld:setLastRoom(currentRoom.name) end if currentRoom.name then setEditorRoomName(currentRoom.name) end end