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(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) 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