Capítulo 2: Persistência e Estrutura
O módulo de persistência é a espinha dorsal de qualquer sistema que pretende manter dados entre sessões. Sem ele, waypoints criados evaporam no momento em que o executor fecha ou o jogador desconecta.
Estrutura de Dados
A primeira decisão crítica é definir como os dados serão organizados. Uma tabela Lua simples não basta — precisa ser serializável, versionada e preparada para casos específicos como múltiplos sub-mapas dentro do mesmo jogo.
local DataStructure = { version = 1, gameId = 0, subMapId = "default", waypoints = {}, settings = { teleportMethod = "instant", tweenSpeed = 50 } }
O campo version existe por uma razão prática: se a estrutura mudar no futuro, o sistema precisa saber como migrar dados antigos. Sem versionamento, qualquer alteração na estrutura quebra saves existentes.
O gameId armazena o PlaceId do Roblox. Cada jogo possui um identificador único, e os waypoints de um jogo não devem contaminar outro.
O subMapId resolve um problema específico de jogos como Blox Fruits, onde existem múltiplas "seas" — First Sea, Second Sea, Third Sea. Tecnicamente é o mesmo PlaceId, mas o jogador está em contextos completamente diferentes. Teleportar para um waypoint da Third Sea enquanto está na First Sea resultaria em comportamento imprevisível.
O array waypoints contém os pontos salvos. Cada waypoint segue esta estrutura:
local WaypointEntry = { id = "uuid-string", name = "Nome do Waypoint", position = { x = 0, y = 0, z = 0, rx = 0, ry = 0, rz = 0 }, timestamp = 0, subMapId = "default" }
A posição é armazenada como componentes separados em vez de CFrame direto porque JSON não serializa userdata do Roblox. O CFrame precisa ser decomposto em posição XYZ e rotação, depois reconstruído na leitura.
O timestamp serve para ordenação e também permite funcionalidades futuras como expiração automática de waypoints antigos.
Serialização de CFrame
CFrame é um tipo de dado específico do Roblox que combina posição e rotação em uma matriz 4x4. JSON não entende isso. A solução é converter para uma representação primitiva.
local function SerializeCFrame(cf) local x, y, z = cf:GetComponents() local rx, ry, rz = cf:ToEulerAnglesXYZ() return { x = x, y = y, z = z, rx = rx, ry = ry, rz = rz } end local function DeserializeCFrame(data) if not data then return nil end local position = Vector3.new(data.x, data.y, data.z) local rotation = CFrame.fromEulerAnglesXYZ(data.rx, data.ry, data.rz) return CFrame.new(position) * rotation end
A função SerializeCFrame extrai os componentes de posição usando GetComponents() e converte a rotação para ângulos de Euler. Euler não é a representação mais precisa — quaternions seriam melhores — mas para waypoints a precisão é suficiente e a legibilidade do código aumenta.
DeserializeCFrame faz o caminho inverso: recebe a tabela com primitivos e reconstrói o CFrame original. A multiplicação de CFrame.new(position) com a rotação combina ambos em um único CFrame usável.
Geração de ID Único
Cada waypoint precisa de um identificador único para operações de edição e exclusão. Sem isso, waypoints com nomes iguais causariam ambiguidade.
local function GenerateUUID() local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' return string.gsub(template, '[xy]', function(c) local v = (c == 'x') and math.random(0, 15) or math.random(8, 11) return string.format('%x', v) end) end
Esta implementação gera um UUID v4 simplificado. Não é criptograficamente seguro, mas para identificação local de waypoints é mais que suficiente. A probabilidade de colisão em uso normal é estatisticamente irrelevante.
Detecção de PlaceId
O PlaceId identifica qual jogo está sendo executado. A API do Roblox expõe isso diretamente:
local function GetGameId() return game.PlaceId end
Simples assim. O game.PlaceId retorna um número inteiro que identifica unicamente aquele "place" no Roblox. Jogos diferentes, PlaceIds diferentes.
Detecção de Sub-Mapas
Este é o problema mais complexo do módulo de persistência. Jogos como Blox Fruits usam um único PlaceId mas contêm áreas drasticamente diferentes que funcionam como mapas separados.
A detecção precisa ser específica por jogo. Não existe uma solução universal porque cada desenvolvedor estrutura seu jogo de forma diferente.
Para Blox Fruits especificamente, a abordagem mais confiável é verificar a posição do jogador combinada com assets específicos de cada sea:
local BLOX_FRUITS_PLACE_ID = 2753915549 local SEA_BOUNDARIES = { first_sea = { min = Vector3.new(-15000, -500, -15000), max = Vector3.new(15000, 2000, 15000), landmarks = {"Starter Island", "Marine Fortress", "Jungle"} }, second_sea = { min = Vector3.new(-5000, -500, -5000), max = Vector3.new(25000, 3000, 25000), landmarks = {"Kingdom of Rose", "Graveyard", "Snow Island"} }, third_sea = { min = Vector3.new(-10000, -500, -10000), max = Vector3.new(30000, 4000, 30000), landmarks = {"Port Town", "Hydra Island", "Great Tree"} } } local function DetectBloxFruitsSea() local workspace = game:GetService("Workspace") -- Verificar landmarks específicos for seaName, seaData in pairs(SEA_BOUNDARIES) do for _, landmark in ipairs(seaData.landmarks) do if workspace:FindFirstChild(landmark, true) then return seaName end end end -- Fallback: verificar posição do jogador local player = game:GetService("Players").LocalPlayer if player and player.Character then local hrp = player.Character:FindFirstChild("HumanoidRootPart") if hrp then local pos = hrp.Position for seaName, seaData in pairs(SEA_BOUNDARIES) do if pos.X >= seaData.min.X and pos.X <= seaData.max.X and pos.Y >= seaData.min.Y and pos.Y <= seaData.max.Y and pos.Z >= seaData.min.Z and pos.Z <= seaData.max.Z then return seaName end end end end return "unknown" end
A função primeiro tenta encontrar landmarks — objetos específicos que só existem em determinada sea. Se encontrar "Marine Fortress" no workspace, o jogador está na First Sea. Isso é mais confiável que verificação de posição porque posições podem mudar com atualizações do jogo.
O fallback de posição existe caso os landmarks não sejam encontrados ou tenham sido renomeados. As boundaries são aproximadas e precisam de ajuste baseado em testes reais.
Função de Detecção Genérica
Para jogos que não são Blox Fruits, o sistema usa uma detecção genérica que pode ser expandida:
local SUB_MAP_DETECTORS = { [BLOX_FRUITS_PLACE_ID] = DetectBloxFruitsSea, -- Adicionar outros jogos conforme necessário } local function GetSubMapId() local gameId = GetGameId() local detector = SUB_MAP_DETECTORS[gameId] if detector then local success, result = pcall(detector) if success and result then return result end end return "default" end
A tabela SUB_MAP_DETECTORS mapeia PlaceIds para funções de detecção específicas. Se o jogo atual não tem detector registrado, retorna "default" — todos os waypoints ficam no mesmo contexto.
O pcall protege contra erros nas funções de detecção. Se algo falhar, o sistema continua funcionando com o fallback.
Caminho do Arquivo
Os dados são salvos em arquivos no sistema de arquivos do executor. O caminho precisa ser único por jogo e sub-mapa:
local SAVE_FOLDER = "WaypointSystem" local function GetSaveFilePath() local gameId = GetGameId() local subMapId = GetSubMapId() return string.format("%s/%d_%s.json", SAVE_FOLDER, gameId, subMapId) end local function EnsureFolderExists() if not isfolder(SAVE_FOLDER) then makefolder(SAVE_FOLDER) end end
O formato {PlaceId}_{SubMapId}.json garante separação completa dos dados. Waypoints da First Sea de Blox Fruits ficam em 2753915549_first_sea.json, completamente isolados dos waypoints da Second Sea.
A função EnsureFolderExists cria a pasta base se não existir. Executores modernos como Delta suportam isfolder e makefolder.
Módulo de Dados Principal
Com todas as peças auxiliares definidas, o módulo principal pode ser construído:
local DataModule = {} local currentData = nil local isDirty = false local function CreateDefaultData() return { version = 1, gameId = GetGameId(), subMapId = GetSubMapId(), waypoints = {}, settings = { teleportMethod = "instant", tweenSpeed = 50 } } end
A variável currentData mantém os dados carregados em memória. Todas as operações trabalham com essa referência em vez de ler/escrever arquivo constantemente.
O flag isDirty indica se os dados foram modificados desde o último save. Isso permite otimização: só salvar quando necessário.
Função LoadData
function DataModule.LoadData() EnsureFolderExists() local filePath = GetSaveFilePath() if not isfile(filePath) then currentData = CreateDefaultData() isDirty = true return currentData end local success, content = pcall(function() return readfile(filePath) end) if not success or not content or content == "" then warn("[WaypointSystem] Falha ao ler arquivo, criando novo") currentData = CreateDefaultData() isDirty = true return currentData end local decodeSuccess, decoded = pcall(function() return game:GetService("HttpService"):JSONDecode(content) end) if not decodeSuccess or not decoded then warn("[WaypointSystem] JSON inválido, criando novo") currentData = CreateDefaultData() isDirty = true return currentData end -- Validação de estrutura if type(decoded.waypoints) ~= "table" then decoded.waypoints = {} end if type(decoded.settings) ~= "table" then decoded.settings = { teleportMethod = "instant", tweenSpeed = 50 } end -- Migração de versão (para futuro) if decoded.version ~= 1 then decoded = MigrateData(decoded) end currentData = decoded isDirty = false return currentData end
A função segue um fluxo defensivo. Primeiro verifica se o arquivo existe. Se não existe, cria dados novos — isso é o comportamento esperado na primeira execução.
Se o arquivo existe, tenta ler o conteúdo. O pcall captura erros de I/O como permissões ou arquivo corrompido.
O JSON é decodificado usando HttpService:JSONDecode. Novamente com pcall porque JSON malformado lança exceção.
A validação de estrutura garante que mesmo com dados parcialmente corrompidos, o sistema não quebra. Campos ausentes são inicializados com valores padrão.
O stub de migração de versão está preparado para futuras alterações na estrutura de dados.
Função SaveData
function DataModule.SaveData() if not currentData then warn("[WaypointSystem] Tentativa de salvar sem dados carregados") return false end EnsureFolderExists() local filePath = GetSaveFilePath() -- Atualizar metadados currentData.gameId = GetGameId() currentData.subMapId = GetSubMapId() local encodeSuccess, encoded = pcall(function() return game:GetService("HttpService"):JSONEncode(currentData) end) if not encodeSuccess or not encoded then warn("[WaypointSystem] Falha ao codificar JSON") return false end local writeSuccess = pcall(function() writefile(filePath, encoded) end) if not writeSuccess then warn("[WaypointSystem] Falha ao escrever arquivo") return false end isDirty = false return true end
SaveData é mais direto. Codifica os dados atuais em JSON e escreve no arquivo. Os metadados são atualizados antes do save para garantir consistência.
O retorno booleano permite que o código chamador saiba se a operação teve sucesso e possa reagir apropriadamente — talvez mostrando um erro na UI.
Funções de Manipulação de Waypoints
O módulo precisa expor operações CRUD (Create, Read, Update, Delete) para waypoints:
function DataModule.AddWaypoint(name) if not currentData then DataModule.LoadData() end local player = game:GetService("Players").LocalPlayer if not player or not player.Character then return nil, "Personagem não encontrado" end local hrp = player.Character:FindFirstChild("HumanoidRootPart") if not hrp then return nil, "HumanoidRootPart não encontrado" end local waypoint = { id = GenerateUUID(), name = name or "Waypoint", position = SerializeCFrame(hrp.CFrame), timestamp = os.time(), subMapId = GetSubMapId() } table.insert(currentData.waypoints, waypoint) isDirty = true return waypoint, nil end
A função retorna dois valores: o waypoint criado e uma mensagem de erro. Se o waypoint foi criado, erro é nil. Se falhou, waypoint é nil e erro contém a razão. Este padrão é comum em Lua e facilita tratamento de erros no código chamador.
O waypoint recebe o subMapId atual no momento da criação. Isso permite que no futuro a UI possa filtrar waypoints por sub-mapa ou alertar o usuário que está tentando teleportar para uma sea diferente.
function DataModule.RemoveWaypoint(waypointId) if not currentData then return false end for i, waypoint in ipairs(currentData.waypoints) do if waypoint.id == waypointId then table.remove(currentData.waypoints, i) isDirty = true return true end end return false end
A remoção busca pelo ID único e remove da tabela. Retorna booleano indicando se encontrou e removeu.
function DataModule.GetWaypoints() if not currentData then DataModule.LoadData() end -- Retornar cópia ordenada por timestamp (mais antigo primeiro) local sorted = {} for _, waypoint in ipairs(currentData.waypoints) do table.insert(sorted, waypoint) end table.sort(sorted, function(a, b) return (a.timestamp or 0) < (b.timestamp or 0) end) return sorted end
GetWaypoints retorna uma cópia ordenada. A ordenação por timestamp garante que waypoints mais antigos aparecem primeiro na lista, conforme especificado nos requisitos.
Retornar uma cópia em vez da referência direta previne modificações acidentais nos dados internos. O código da UI pode manipular a lista retornada sem afetar currentData.
function DataModule.GetWaypointById(waypointId) if not currentData then return nil end for _, waypoint in ipairs(currentData.waypoints) do if waypoint.id == waypointId then return waypoint end end return nil end
Busca direta por ID para quando a UI precisa de um waypoint específico, por exemplo, antes de teleportar.
Funções de Configuração
As configurações de teleporte também são persistidas:
function DataModule.GetSettings() if not currentData then DataModule.LoadData() end return currentData.settings end function DataModule.SetTeleportMethod(method) if not currentData then DataModule.LoadData() end if method ~= "instant" and method ~= "tween" then return false end currentData.settings.teleportMethod = method isDirty = true return true end function DataModule.SetTweenSpeed(speed) if not currentData then DataModule.LoadData() end speed = tonumber(speed) if not speed or speed < 10 or speed > 500 then return false end currentData.settings.tweenSpeed = speed isDirty = true return true end
A validação de teleportMethod aceita apenas valores conhecidos. Isso previne estados inválidos por input malformado.
O tweenSpeed tem limites mínimo e máximo. Velocidade muito baixa seria frustrante, muito alta seria indistinguível de instantâneo.
Auto-Save e Cleanup
O sistema precisa salvar automaticamente em momentos críticos:
local autoSaveConnection = nil function DataModule.StartAutoSave(intervalSeconds) if autoSaveConnection then autoSaveConnection:Disconnect() end intervalSeconds = intervalSeconds or 30 autoSaveConnection = game:GetService("RunService").Heartbeat:Connect(function() -- Implementação simplificada usando contador end) end function DataModule.StopAutoSave() if autoSaveConnection then autoSaveConnection:Disconnect() autoSaveConnection = nil end end
O auto-save periódico previne perda de dados se o executor fechar inesperadamente. O intervalo padrão de 30 segundos é um balanço entre segurança e performance de I/O.
local function OnPlayerRemoving() if isDirty then DataModule.SaveData() end end game:GetService("Players").LocalPlayer.AncestryChanged:Connect(function(_, parent) if not parent then OnPlayerRemoving() end end)
O save ao desconectar é crítico. AncestryChanged com parent nil indica que o LocalPlayer foi removido — o jogador está saindo.
Testes do Módulo
Antes de integrar com a UI, o módulo precisa ser validado:
local function RunTests() print("[WaypointSystem] Iniciando testes...") -- Teste 1: Load em arquivo inexistente local data = DataModule.LoadData() assert(data ~= nil, "LoadData retornou nil") assert(type(data.waypoints) == "table", "waypoints não é tabela") print("✓ LoadData funciona com arquivo inexistente") -- Teste 2: Adicionar waypoint local wp, err = DataModule.AddWaypoint("Teste") assert(wp ~= nil, "Falha ao criar waypoint: " .. (err or "")) assert(wp.id ~= nil, "Waypoint sem ID") assert(wp.name == "Teste", "Nome incorreto") print("✓ AddWaypoint funciona") -- Teste 3: Save e Load local saveResult = DataModule.SaveData() assert(saveResult == true, "SaveData falhou") print("✓ SaveData funciona") -- Teste 4: Verificar persistência currentData = nil -- Forçar reload local reloaded = DataModule.LoadData() assert(#reloaded.waypoints > 0, "Waypoints perdidos após reload") print("✓ Persistência funciona") -- Teste 5: Remover waypoint local removeResult = DataModule.RemoveWaypoint(wp.id) assert(removeResult == true, "RemoveWaypoint falhou") assert(#DataModule.GetWaypoints() == 0, "Waypoint não foi removido") print("✓ RemoveWaypoint funciona") -- Cleanup DataModule.SaveData() print("[WaypointSystem] Todos os testes passaram!") end
Os testes cobrem o fluxo básico: criar dados novos, adicionar waypoint, salvar, recarregar e verificar persistência, remover waypoint. Se algum assert falhar, o erro indica exatamente onde o problema está.
Estrutura Final do Módulo
O módulo completo exporta estas funções:
return { LoadData = DataModule.LoadData, SaveData = DataModule.SaveData, AddWaypoint = DataModule.AddWaypoint, RemoveWaypoint = DataModule.RemoveWaypoint, GetWaypoints = DataModule.GetWaypoints, GetWaypointById = DataModule.GetWaypointById, GetSettings = DataModule.GetSettings, SetTeleportMethod = DataModule.SetTeleportMethod, SetTweenSpeed = DataModule.SetTweenSpeed, StartAutoSave = DataModule.StartAutoSave, StopAutoSave = DataModule.StopAutoSave, DeserializeCFrame = DeserializeCFrame, GetSubMapId = GetSubMapId }
A interface é mínima e clara. Cada função tem uma responsabilidade única. O módulo não depende de UI — pode ser testado independentemente.
DeserializeCFrame e GetSubMapId são exportados porque o módulo de teleporte precisará deles. O primeiro para converter posição salva em CFrame usável, o segundo para verificar se o waypoint pertence ao sub-mapa atual antes de teleportar.
O módulo de persistência está completo. Os dados sobrevivem entre sessões, são separados por jogo e sub-mapa, e a API está pronta para a UI consumir.
A próxima etapa é construir a interface que permitirá ao usuário interagir com essas funções — criar waypoints, visualizar a lista, ajustar configurações de teleporte. O DataModule será importado e suas funções chamadas em resposta a eventos da UI.
Os testes confirmam que SaveData() e LoadData() funcionam corretamente. O arquivo JSON é criado no diretório do executor, contendo a estrutura completa com waypoints, configurações e metadados. O ciclo de persistência está validado e
Comments (0)
No comments yet. Be the first to share your thoughts!