World = class("World") local Movement = require "movement" -- The world is deliberately only a layout: rooms continue to own their puzzle -- state. Keeping the two separate lets the editor move a room without -- rewriting its level file. function World:initialize() self.width, self.height = 18, 18 self.rooms = {} self.roomByName = {} self.previewCache = {} self.lastRoom = nil self:load() self:loadRuntimeState() self.levels = {} end function World:loadLevels() -- Gameplay saves go to LÖVE's runtime directory. Seed every world slice -- through the same runtime-first loader, not just the initially visible one. for _, placed in ipairs(self.rooms) do self.levels[placed.name] = Room:new{name = placed.name, runtime = true} end self:rebuildCollision() end -- The editor can replace a level with a freshly loaded authored Room. Keep the -- global simulation pointing at that exact instance, not a stale preload. function World:setLevel(room) if not room or not room.name or not self:roomLocation(room.name) then return false end self.levels[room.name] = room self:rebuildCollision() return true end -- Runtime state is one logical world snapshot: each level slice is written to -- LÖVE's save directory, and this companion file says which slice was active. -- The authored world layout remains source-only for the editor. function World:loadRuntimeState() local raw = love.filesystem.read("rooms/world.sav") if not raw then return end local ok, state = pcall(TSerial.unpack, raw) if ok and type(state) == "table" and state.lastRoom and self:roomLocation(state.lastRoom) then self.lastRoom = state.lastRoom end end function World:saveRuntime(dirtyRooms) for room in pairs(dirtyRooms or {}) do room:save() end return love.filesystem.write("rooms/world.sav", TSerial.pack({lastRoom = currentRoom and currentRoom.name}, nil, true)) end function World:markDirty(room) self.dirtyRooms = self.dirtyRooms or {} self.dirtyRooms[room] = true end function World:flushRuntime() self:saveRuntime(self.dirtyRooms) self.dirtyRooms = {} end function World:listRoomNames() local names = {} for _, file in ipairs(love.filesystem.getDirectoryItems("rooms")) do local name = file:match("^(.+)%.sav$") if name and name ~= "world" then table.insert(names, name) end end table.sort(names) return names end function World:load() local raw = readFromSource("rooms/world.sav") if not raw then return end local ok, save = pcall(TSerial.unpack, raw) if not ok or type(save) ~= "table" then return end self.lastRoom = save.lastRoom for _, room in ipairs(save.rooms or {}) do if room.name and self:isInBounds(room.x, room.y) then self:_putRoom(room.name, room.x, room.y) end end end function World:save() local rooms = {} for _, room in ipairs(self.rooms) do table.insert(rooms, { name = room.name, x = room.x, y = room.y }) end return writeToSource("rooms/world.sav", TSerial.pack({ width = self.width, height = self.height, lastRoom = self.lastRoom, rooms = rooms }, nil, true)) end function World:isInBounds(x, y) return x and y and x >= 1 and x <= self.width and y >= 1 and y <= self.height end function World:roomAt(x, y) for _, room in ipairs(self.rooms) do if room.x == x and room.y == y then return room.name end end return nil end function World:roomLocation(name) return self.roomByName[name] end function World:_putRoom(name, x, y) local existing = self.roomByName[name] if existing then for i = #self.rooms, 1, -1 do if self.rooms[i] == existing then table.remove(self.rooms, i) end end end for i = #self.rooms, 1, -1 do if self.rooms[i].x == x and self.rooms[i].y == y then self.roomByName[self.rooms[i].name] = nil table.remove(self.rooms, i) end end local room = { name = name, x = x, y = y } table.insert(self.rooms, room) self.roomByName[name] = room return room end function World:placeRoom(name, x, y) if not name or not self:isInBounds(x, y) then return false end self:_putRoom(name, x, y) return self:save() end function World:removeRoom(x, y) for i = #self.rooms, 1, -1 do local room = self.rooms[i] if room.x == x and room.y == y then self.roomByName[room.name] = nil table.remove(self.rooms, i) if self.lastRoom == room.name then self.lastRoom = nil end return self:save(), room.name end end return false end function World:renameRoom(oldName, newName) local room = self:roomLocation(oldName) if not room or oldName == newName then return false end self.roomByName[oldName] = nil room.name = newName self.roomByName[newName] = room if self.lastRoom == oldName then self.lastRoom = newName end self:invalidatePreview(oldName) self:invalidatePreview(newName) return self:save() end function World:setLastRoom(name) if name and self.lastRoom ~= name then self.lastRoom = name end return true end function World:getNeighbor(name, exit) local room = self:roomLocation(name) if not room then return nil end local directions = { left = { x = -1, y = 0 }, right = { x = 1, y = 0 }, top = { x = 0, y = -1 }, bottom = { x = 0, y = 1 }, } local direction = directions[exit] if not direction then return nil end return self:roomAt(room.x + direction.x, room.y + direction.y) end -- All placed levels share these sparse matrices. Rendering still uses the -- selected Room, whose ordinary local matrices remain unchanged. function World:rebuildCollision() self.collidableMatrices = {red = {}, green = {}, blue = {}} self.playerBlockerMatrices = {red = {}, green = {}, blue = {}} self.entityRooms = {} local function put(matrix, cell, entity) matrix[cell.y] = matrix[cell.y] or {} matrix[cell.y][cell.x] = entity end for name, room in pairs(self.levels) do local location = self:roomLocation(name) if location then for color, entities in pairs(room.collideables) do for _, entity in pairs(entities) do self.entityRooms[entity] = room for _, cell in ipairs(entity:getOccupiedCells()) do put(self.collidableMatrices[color], { x = (location.x - 1) * gridWidth + cell.x, y = (location.y - 1) * gridHeight + cell.y, }, entity) end end end -- NPCs are local room state, but globally collidable: a movement lookup -- must find them so its normal bump() path can open dialogue. for _, npc in ipairs(room.npcs) do self.entityRooms[npc] = room for _, cell in ipairs(npc:getOccupiedCells()) do local worldCell = { x = (location.x - 1) * gridWidth + cell.x, y = (location.y - 1) * gridHeight + cell.y, } for color in pairs(self.collidableMatrices) do put(self.collidableMatrices[color], worldCell, npc) end end end for color, entities in pairs(room.playerBlockers) do for _, entity in pairs(entities) do for _, cell in ipairs(entity:getOccupiedCells()) do put(self.playerBlockerMatrices[color], { x = (location.x - 1) * gridWidth + cell.x, y = (location.y - 1) * gridHeight + cell.y, }, entity) end end end end end end function World:worldCell(room, cell) local location = self:roomLocation(room.name) return location and {x = (location.x - 1) * gridWidth + cell.x, y = (location.y - 1) * gridHeight + cell.y} end function World:roomCell(cell) local roomX = math.floor((cell.x - 1) / gridWidth) + 1 local roomY = math.floor((cell.y - 1) / gridHeight) + 1 local name = self:roomAt(roomX, roomY) local room = name and self.levels[name] return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight} end function World:movementContext(room, color) local proxies = {} local world = self local context = {} function context:worldCell(cell) return world:worldCell(room, cell) end function context:isInBounds(cell) return world:roomCell(cell) ~= nil end function context:entityAt(cell) local row = world.collidableMatrices[color][cell.y] local entity = row and row[cell.x] if not entity then return nil end if proxies[entity] then return proxies[entity] end local owner = world.entityRooms[entity] local position = world:worldCell(owner, entity:getGridPos()) local proxy = {entity = entity, room = owner, color = color, x = position.x, y = position.y} function proxy:canMove() return self.entity:canMove() end function proxy:canPassOver() return self.entity:canPassOver() end function proxy:isSticky() return self.entity:isSticky() end function proxy:isSkewer() return self.entity:isSkewer() end function proxy:isSpiky() return self.entity:isSpiky() end function proxy:bump() return self.entity:bump() end function proxy:getOccupiedCells(offset) offset = offset or {x = 0, y = 0} local cells = {} for _, shapeCell in ipairs(self.entity:getCellShape()) do table.insert(cells, {x = self.x + shapeCell.x - 1 + offset.x, y = self.y + shapeCell.y - 1 + offset.y}) end return cells end proxies[entity] = proxy return proxy end function context:playerBlockerAt(cell) local row = world.playerBlockerMatrices[color][cell.y] return row and row[cell.x] end return context end -- Player copies live in room files, but joining is a world-coordinate rule. -- This also lets a copy rejoin immediately when the player crosses into its -- room, rather than waiting for one more local move. function World:playerJoinCheck(room, previousPositions, newPositions) local activeColors = room:getActiveColors() local deactivate, activate = {}, {} if previousPositions and newPositions and #activeColors > 1 then for _, color in ipairs(activeColors) do if Entity.positionsAreEqual(previousPositions[color], newPositions[color]) then table.insert(deactivate, color) end end end for color, player in pairs(room.player) do if not room:colorIsActive(color) then local playerCell = self:worldCell(room, player:getGridPos()) local overlaps = playerCell and #activeColors > 0 for _, activeColor in ipairs(activeColors) do local activeCell = self:worldCell(room, room.player[activeColor]:getGridPos()) if not activeCell or activeCell.x ~= playerCell.x or activeCell.y ~= playerCell.y then overlaps = false; break end end if overlaps then table.insert(activate, color) end end end for _, color in ipairs(activate) do room:setActiveColor(color, true) end for _, color in ipairs(deactivate) do room:setActiveColor(color, false) end end function World:movePlayer(room, key) local directions = { up = {x = 0, y = -1, axis = "y"}, down = {x = 0, y = 1, axis = "y"}, left = {x = -1, y = 0, axis = "x"}, right = {x = 1, y = 0, axis = "x"}, } local dir = directions[key] if not dir then return end local moved, previous, current = false, {}, {} for _, color in ipairs(room:getActiveColors()) do local player = room.player[color] local pos = player:getGridPos() previous[color] = pos local context = self:movementContext(room, color) local target = context:worldCell({x = pos.x + dir.x, y = pos.y + dir.y}) local targetEntity = context:entityAt(target) if targetEntity then targetEntity:bump() end local blocker = context:playerBlockerAt(target) local canMove, pushing = true, {} if context:isInBounds(target) and not (blocker and blocker:blocksPlayer()) then canMove, pushing = Movement.findMovableGroup({target}, dir, function(cell) return context:entityAt(cell) end, function(cell) return context:isInBounds(cell) end) elseif blocker then canMove = false end local pulling, pullingGroup = false, {} local behind = context:worldCell({x = pos.x - dir.x, y = pos.y - dir.y}) if context:isInBounds(behind) then pulling, pullingGroup = Movement.findMovableGroup({behind}, dir, function(cell) return context:entityAt(cell) end, function(cell) return context:isInBounds(cell) end) end if canMove then local group = {} for entity in pairs(pushing) do group[entity] = entity end if pulling then for entity in pairs(pullingGroup) do group[entity] = entity end end self:moveGroup(group, dir) player:move(dir.axis, dir[dir.axis]) moved = true end current[color] = player:getGridPos() end if moved then self:markDirty(room) self:playerJoinCheck(room, previous, current) self:tick(room) self:flushRuntime() end end function World:switchPlayerColor(room, color) local player = room.player[color] if not player then return false end local activeColors = room:getActiveColors() local canSwitch = #activeColors ~= 1 or activeColors[1] ~= color if canSwitch then for _, possibleColor in ipairs(room:getPossibleColors()) do room:setActiveColor(possibleColor, possibleColor == color) end end player:playSwitchSound(canSwitch) if canSwitch then self:markDirty(room); self:flushRuntime() end return canSwitch end function World:tick(room) room:tickLocal() self:rebuildCollision() self:nextRoomCheck(room) end function World:nextRoomCheck(room) for _, color in ipairs(room:getActiveColors()) do local player = room.player[color] if player and not room:isInBounds(player:getGridPos()) then self:nextRoom(room, player:getGridPos()) return end end end function World:nextRoom(room, pos) local exit, entry = nil, {x = pos.x, y = pos.y} if pos.x > gridWidth then exit, entry.x = "right", 1 elseif pos.x < 1 then exit, entry.x = "left", gridWidth elseif pos.y > gridHeight then exit, entry.y = "bottom", 1 elseif pos.y < 1 then exit, entry.y = "top", gridHeight end local nextName = self:getNeighbor(room.name, exit) or room.name local activeColors = room:getActiveColors() self:markDirty(room) local destination = self.levels[nextName] for _, color in ipairs(activeColors) do local player = room.player[color] -- Active copies travel with the player. Inactive copies are deliberately -- left in their room so World:playerJoinCheck can merge them later. room.player[color] = nil for i = #room.colorsPlayerHas, 1, -1 do if room.colorsPlayerHas[i] == color then table.remove(room.colorsPlayerHas, i) end end if destination.player[color] then for i = #destination.colorsPlayerHas, 1, -1 do if destination.colorsPlayerHas[i] == color then table.remove(destination.colorsPlayerHas, i) end end end destination.player[color] = player table.insert(destination.colorsPlayerHas, color) end room.activeColors = {red = false, green = false, blue = false} currentRoom = destination self:markDirty(currentRoom) for _, color in ipairs(activeColors) do currentRoom.player[color].x, currentRoom.player[color].y = entry.x, entry.y currentRoom:setActiveColor(color, true) end self:playerJoinCheck(currentRoom) self.lastRoom = currentRoom.name setEditorRoomName(currentRoom.name) end function World:moveGroup(group, direction) local changed = {} for proxy in pairs(group) do changed[proxy.room] = true for _, cell in ipairs(proxy:getOccupiedCells()) do local room = self:roomCell(cell) if room then changed[room] = true end end for _, cell in ipairs(proxy:getOccupiedCells(direction)) do local room = self:roomCell(cell) if room then changed[room] = true end end local destination, localCell = self:roomCell({x = proxy.x + direction.x, y = proxy.y + direction.y}) if destination then if destination ~= proxy.room then for i = #proxy.room.collideables[proxy.color], 1, -1 do if proxy.room.collideables[proxy.color][i] == proxy.entity then table.remove(proxy.room.collideables[proxy.color], i); break end end table.insert(destination.collideables[proxy.color], proxy.entity) self.entityRooms[proxy.entity] = destination changed[proxy.room], changed[destination] = true, true end proxy.entity.x, proxy.entity.y = localCell.x, localCell.y end end self:rebuildCollision() for room in pairs(changed) do room:resolveSwitchesAndDoors() end self:rebuildCollision() for room in pairs(changed) do self:markDirty(room) end end function World:drawOverlaps(room, color) local location = self:roomLocation(room.name) if not location then return end local drawn = {} for y = 1, gridHeight do local row = self.collidableMatrices[color][(location.y - 1) * gridHeight + y] if row then for x = 1, gridWidth do local entity = row[(location.x - 1) * gridWidth + x] local owner = entity and self.entityRooms[entity] if entity and owner ~= room and not drawn[entity] then drawn[entity] = true local ownerLocation = self:roomLocation(owner.name) love.graphics.push() love.graphics.translate((ownerLocation.x - location.x) * width, (ownerLocation.y - location.y) * height) entity:draw(color) love.graphics.pop() end end end end end function World:getCellFromMousePos(mouseX, mouseY) return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1 end function World:getRoomData(name) -- The current room is live: its preview reflects unsaved edits immediately. if currentRoom and currentRoom.name == name then return currentRoom:serialize() end if not self.previewCache[name] then local raw = readFromSource("rooms/" .. name .. ".sav") if not raw then return nil end local ok, room = pcall(TSerial.unpack, raw) if not ok then return nil end self.previewCache[name] = room end return self.previewCache[name] end function World:invalidatePreview(name) self.previewCache[name] = nil end function World:drawRoomPreview(name, x, y, size) local room = self:getRoomData(name) if not room then return end local scale = size / (gridWidth * 16) love.graphics.setScissor(x, y, size, size) for _, object in ipairs(room.objects or {}) do if object.class == "npc" then love.graphics.setColor(1, 1, 1, 1) love.graphics.print("?", x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale) else local props = globalAssetProperties[object.class] if props and props.sprites then local colors if object.class == "door" then colors = object.color and { object.color } or { "red", "green", "blue" } else colors = { object.color } end for _, color in ipairs(colors) do local path = props.sprites[color] if path then love.graphics.setColor(gColor[color]:set()) local sprite = loadSprite(path) if props.goopy and drawGoopySprite then drawGoopySprite(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale) elseif props.skewer and drawSkewerSprite then drawSkewerSprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale) elseif props.spiky and drawSpikySprite then drawSpikySprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale) else love.graphics.draw(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale) end end end end end end love.graphics.setScissor() end function World:drawHoveredRoomName() local mouseX, mouseY = love.mouse.getPosition() local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY) local name = self:isInBounds(cellX, cellY) and self:roomAt(cellX, cellY) if not name then return end local font = love.graphics.getFont() local padding = 6 local tooltipW = math.min(font:getWidth(name) + padding * 2, width - 8) local tooltipH = font:getHeight() + padding * 2 local tooltipX = math.min(mouseX + 14, width - tooltipW - 4) local tooltipY = math.min(mouseY + 14, height - tooltipH - 4) tooltipX = math.max(4, tooltipX) tooltipY = math.max(4, tooltipY) -- Keep the label anchored beside the pointer, while keeping it entirely in -- the playable map rather than allowing it to spill into the editor panel. love.graphics.setColor(0, 0, 0, 0.9) love.graphics.rectangle("fill", tooltipX, tooltipY, tooltipW, tooltipH) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("line", tooltipX, tooltipY, tooltipW, tooltipH) love.graphics.printf(name, tooltipX + padding, tooltipY + padding, tooltipW - padding * 2, "left") end function World:draw() local cell = width / self.width love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1) love.graphics.rectangle("fill", 0, 0, width, height) for y = 1, self.height do for x = 1, self.width do local screenX, screenY = (x - 1) * cell, (y - 1) * cell local name = self:roomAt(x, y) if name then self:drawRoomPreview(name, screenX, screenY, cell) end love.graphics.setColor(name and 1 or 70 / 255, name and 1 or 70 / 255, name and 1 or 90 / 255, 1) love.graphics.rectangle("line", screenX, screenY, cell, cell) end end self:drawHoveredRoomName() end