World = class("World") -- 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() 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 return self:save() 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 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(255, 255, 255, 255) 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()) love.graphics.draw(loadSprite(path), x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale) end end end end end love.graphics.setScissor() end function World:draw() local cell = width / self.width love.graphics.setColor(12, 12, 20, 255) 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 255 or 70, name and 255 or 70, name and 255 or 90, 255) love.graphics.rectangle("line", screenX, screenY, cell, cell) end end end