-- BRAIN.ROBLOX.LUA - AI-Driven NPC Brain for Roblox -- Analogous to brain.lsl (Second Life). Place as a Script inside an NPC Model. -- Model name format: "NPCName.AreaName" -- Set a StringAttribute "ServerURL" on the Model to your Nexus server URL. -- -- Required model structure: -- Model (named "NPCName.AreaName", with StringAttribute ServerURL) -- HumanoidRootPart (or any BasePart as primary) -- Humanoid -- ClickDetector (auto-created if missing) -- Animation objects (optional, named to match anim= commands) local Players = game:GetService("Players") local HttpService = game:GetService("HttpService") local Chat = game:GetService("Chat") -- ============================================ -- CONFIGURATION -- ============================================ local DEBUG = false local model = script.Parent local rootPart = model:FindFirstChild("HumanoidRootPart") or (model:IsA("Model") and model.PrimaryPart or nil) or model:FindFirstChildWhichIsA("BasePart") local humanoid = model:FindFirstChildOfClass("Humanoid") local SERVER_URL = model:GetAttribute("ServerURL") or "" local NPC_NAME = "" local CURRENT_AREA = "" local TIMEOUT = 300 -- seconds -- ============================================ -- COLORS -- ============================================ local RED = Color3.new(1, 0, 0) local WHITE = Color3.new(1, 1, 1) local GREEN = Color3.new(0, 1, 0) local YELLOW = Color3.new(1, 1, 0) local BLUE = Color3.new(0.3, 0.7, 1) -- ============================================ -- STATE (forward-declared so functions can cross-reference) -- ============================================ local isConversing = false local currentPlayer: Player? = nil local conversationTimer: thread? = nil local listenConnection: RBXScriptConnection? = nil local billboardGui: BillboardGui? = nil -- Forward declarations local endConversation: (boolean) -> () local resetConversationTimeout: () -> () -- ============================================ -- BILLBOARD LABEL -- ============================================ local function setLabel(text: string, color: Color3) if not billboardGui then local gui = Instance.new("BillboardGui") gui.Name = "StatusBillboard" gui.Size = UDim2.new(0, 220, 0, 70) gui.StudsOffset = Vector3.new(0, 3.5, 0) gui.AlwaysOnTop = true gui.Parent = rootPart or model:FindFirstChildWhichIsA("BasePart") or (model:IsA("BasePart") and model or nil) or model local label = Instance.new("TextLabel") label.Name = "Label" label.Size = UDim2.fromScale(1, 1) label.BackgroundTransparency = 1 label.TextColor3 = WHITE label.TextScaled = true label.TextWrapped = true label.Font = Enum.Font.GothamBold label.Parent = gui billboardGui = gui end local label = billboardGui:FindFirstChild("Label") :: TextLabel? if label then label.Text = text label.TextColor3 = color end end -- ============================================ -- PART COLOR INDICATOR (like llSetColor in LSL) -- ============================================ local function setColor(color: Color3) for _, part in model:GetDescendants() do if part:IsA("BasePart") and part.Name ~= "HumanoidRootPart" then part.Color = color end end end -- ============================================ -- NPC SPEECH -- ============================================ local function npcSay(message: string) local part = rootPart or model:FindFirstChildWhichIsA("BasePart") or (model:IsA("BasePart") and model or nil) if part then Chat:Chat(part, message, Enum.ChatColor.Blue) end if DEBUG then print("[" .. NPC_NAME .. "] " .. message) end end local function ownerSay(message: string) print("[NPC:" .. NPC_NAME .. "] " .. message) end -- ============================================ -- JSON HELPERS -- ============================================ local function escapeJSON(s: string): string s = s:gsub("\\", "\\\\") s = s:gsub('"', '\\"') s = s:gsub("\n", "\\n") s = s:gsub("\r", "\\r") s = s:gsub("\t", "\\t") return s end -- Minimal flat-JSON key extractor (handles string values only) local function extractJSON(body: string, key: string): string -- Match "key": "value" allowing escaped quotes inside value local val = body:match('"' .. key .. '":%s*"(.-[^\\])"') or body:match('"' .. key .. '":%s*"()"') -- empty string if val then val = val:gsub('\\"', '"') val = val:gsub("\\n", "\n") val = val:gsub("\\\\", "\\") end -- Also handle boolean/number values (returned as-is without quotes) if not val then val = body:match('"' .. key .. '":%s*([%w%.%-]+)') end return val or "" end -- ============================================ -- ANIMATIONS -- ============================================ local animTracks: { [string]: AnimationTrack } = {} local function playAnim(animName: string) if not humanoid then return end animName = animName:match("^%s*(.-)%s*$") -- trim whitespace local animObj = model:FindFirstChild(animName) if not animObj or not animObj:IsA("Animation") then if DEBUG then ownerSay("Animation not found: " .. animName) end return end -- Stop previous track for this name if still playing if animTracks[animName] then animTracks[animName]:Stop() end local track = humanoid:LoadAnimation(animObj) track:Play() animTracks[animName] = track end -- ============================================ -- SL COMMAND PROCESSING -- ============================================ local function processSLCommands(slCommands: string) -- Parse [key=value;key=value] style blocks from sl_commands field for key, value in slCommands:gmatch("([%w]+)=([^;%]]+)") do local k = key:lower() if k == "anim" or k == "emote" then playAnim(value) elseif k == "llsettext" then setLabel(value, WHITE) elseif k == "face" then if DEBUG then ownerSay("face command (no-op in Roblox): " .. value) end elseif k == "teleport" then if DEBUG then ownerSay("teleport command: " .. value) end elseif k == "lookup" then if DEBUG then ownerSay("lookup command: " .. value) end end end end -- ============================================ -- RESPONSE PARSING -- ============================================ local function parseAnimations(text: string) local animName = text:match("%[anim=([^%]]+)%]") if animName then playAnim(animName) end end local function cleanText(text: string): string text = text:gsub("%[%a+=[^%]]*%]", "") -- strip [tag=value] blocks text = text:match("^%s*(.-)%s*$") -- trim return text end local function handleChatResponse(body: string) setColor(WHITE) resetConversationTimeout() if DEBUG then ownerSay("=== RESPONSE RECEIVED === len=" .. #body) end local npcResponse = extractJSON(body, "npc_response") if npcResponse == "" then if DEBUG then ownerSay("ERROR: npc_response empty") end return end parseAnimations(npcResponse) local slCommands = extractJSON(body, "sl_commands") if slCommands ~= "" then processSLCommands(slCommands) end npcResponse = cleanText(npcResponse) if npcResponse ~= "" then npcSay(npcResponse) else if DEBUG then ownerSay("ERROR: cleaned response empty") end end end -- ============================================ -- HTTP HELPERS -- ============================================ local function httpPost(endpoint: string, data: string): (boolean, string, number) local ok, result = pcall(HttpService.RequestAsync, HttpService, { Url = SERVER_URL .. endpoint, Method = "POST", Headers = { ["Content-Type"] = "application/json" }, Body = data, }) if ok then return true, (result :: any).Body, (result :: any).StatusCode end return false, tostring(result), 0 end local function httpGet(endpoint: string): (boolean, string, number) local ok, result = pcall(HttpService.RequestAsync, HttpService, { Url = SERVER_URL .. endpoint, Method = "GET", }) if ok then return true, (result :: any).Body, (result :: any).StatusCode end return false, tostring(result), 0 end -- ============================================ -- CONVERSATION TIMEOUT -- ============================================ resetConversationTimeout = function() if conversationTimer then task.cancel(conversationTimer) conversationTimer = nil end if not isConversing then return end conversationTimer = task.delay(TIMEOUT, function() if isConversing then ownerSay("Timeout: ending conversation with " .. (currentPlayer and currentPlayer.Name or "?")) endConversation(true) end end) end -- ============================================ -- CONVERSATION MANAGEMENT -- ============================================ endConversation = function(sayGoodbye: boolean) if not isConversing then return end local playerName = currentPlayer and currentPlayer.Name or "" -- Notify server asynchronously local leaveData = string.format( '{"player_name":"%s","npc_name":"%s","area":"%s","action":"leaving","message":"Avatar leaving","status":"end"}', escapeJSON(playerName), escapeJSON(NPC_NAME), escapeJSON(CURRENT_AREA) ) task.spawn(function() httpPost("/api/leave_npc", leaveData) end) if conversationTimer then task.cancel(conversationTimer) conversationTimer = nil end if listenConnection then listenConnection:Disconnect() listenConnection = nil end if sayGoodbye and playerName ~= "" then npcSay("È stato un piacere parlare con te, " .. playerName .. "!") end setColor(WHITE) currentPlayer = nil isConversing = false setLabel("Touch to talk to\n" .. NPC_NAME, GREEN) end local function sendMessage(msg: string, playerName: string) if not isConversing then if DEBUG then ownerSay("ERROR: Not conversing") end return end setColor(RED) resetConversationTimeout() local chatData = string.format( '{"message":"%s","player_name":"%s","npc_name":"%s","area":"%s"}', escapeJSON(msg), escapeJSON(playerName), escapeJSON(NPC_NAME), escapeJSON(CURRENT_AREA) ) if DEBUG then ownerSay("Sending /api/chat...") end task.spawn(function() local ok, body, status = httpPost("/api/chat", chatData) if ok and status == 200 then handleChatResponse(body) else setColor(WHITE) if DEBUG then ownerSay("HTTP error " .. tostring(status)) ownerSay("Body: " .. body:sub(1, 200)) end npcSay("Scusa, non posso risponderti ora.") end end) end local function startConversation(player: Player) if isConversing then return end currentPlayer = player isConversing = true setLabel("Conversing with\n" .. player.Name, BLUE) local senseData = string.format( '{"name":"%s","npcname":"%s","area":"%s"}', escapeJSON(player.Name), escapeJSON(NPC_NAME), escapeJSON(CURRENT_AREA) ) if DEBUG then ownerSay("Sending /sense for " .. player.Name) end task.spawn(function() local ok, body, status = httpPost("/sense", senseData) if ok and status == 200 then handleChatResponse(body) resetConversationTimeout() -- Listen for player chat messages (server-side via Player.Chatted) listenConnection = player.Chatted:Connect(function(message: string) sendMessage(message, player.Name) end) else isConversing = false currentPlayer = nil setLabel("Touch to talk to\n" .. NPC_NAME, GREEN) if DEBUG then ownerSay("Sense failed: " .. tostring(status)) end end end) end -- ============================================ -- INITIALIZATION -- ============================================ local function init() if SERVER_URL == "" or not SERVER_URL:match("^https?://") then setLabel("ERROR: ServerURL\nattribute not set", RED) ownerSay("ERROR: Set StringAttribute 'ServerURL' on Model (e.g., http://212.227.64.143:5000)") return end -- Parse "NPCName.AreaName" from model name local dotPos = model.Name:find("%.") if dotPos and dotPos > 1 then NPC_NAME = model.Name:sub(1, dotPos - 1) CURRENT_AREA = model.Name:sub(dotPos + 1) ownerSay("NPC: " .. NPC_NAME .. " | Area: " .. CURRENT_AREA) ownerSay("Server: " .. SERVER_URL) else ownerSay("ERROR: Model name must be 'NPCName.AreaName'") setLabel("ERROR: Invalid\nmodel name", RED) return end setLabel("Verifying\n" .. NPC_NAME .. "...", YELLOW) -- Check server health, then verify NPC exists task.spawn(function() local ok, body, status = httpGet("/health") if not ok or status ~= 200 then setLabel("Server offline\n" .. NPC_NAME, RED) ownerSay("ERROR: Server not reachable (status " .. tostring(status) .. ")") return end local version = extractJSON(body, "version") ownerSay("Server online" .. (version ~= "" and (" v" .. version) or "")) local verifyData = string.format( '{"npc_name":"%s","area":"%s"}', escapeJSON(NPC_NAME), escapeJSON(CURRENT_AREA) ) local vok, vbody, vstatus = httpPost("/api/npc/verify", verifyData) if vok and vstatus == 200 then local found = extractJSON(vbody, "found") if found == "true" then local caps = "" if extractJSON(vbody, "has_teleport") == "true" then caps = caps .. "TP|" end if extractJSON(vbody, "has_llsettext") == "true" then caps = caps .. "TXT|" end if extractJSON(vbody, "has_notecard") == "true" then caps = caps .. "NC" end setLabel("Touch to talk to\n" .. NPC_NAME .. "\n[" .. caps .. "]", GREEN) ownerSay("NPC verified! Click to talk.") else setLabel("NPC not found\n" .. NPC_NAME, RED) ownerSay("ERROR: NPC '" .. NPC_NAME .. "' not found in database!") end else setLabel("Verify failed\n" .. NPC_NAME, RED) ownerSay("ERROR: Verify request failed (status " .. tostring(vstatus) .. ")") end end) end -- ============================================ -- CLICK DETECTION (replaces touch_start) -- ============================================ local clickDetector = model:FindFirstChildOfClass("ClickDetector") if not clickDetector then clickDetector = Instance.new("ClickDetector") clickDetector.MaxActivationDistance = 10 clickDetector.Parent = rootPart or model end clickDetector.MouseClick:Connect(function(player: Player) -- Second click by same player ends conversation if isConversing and currentPlayer == player then endConversation(true) return end -- Ignore click if NPC is busy with someone else if isConversing then return end startConversation(player) end) -- End conversation when player leaves the game Players.PlayerRemoving:Connect(function(player: Player) if isConversing and currentPlayer == player then endConversation(false) end end) -- ============================================ -- RUN -- ============================================ init()