ai-npcs / cubzh.lua
Corentin Cailleaud
update
3d1fc88
raw
history blame
25.5 kB
Modules = {
--gigax = "github.com/GigaxGames/integrations/cubzh:5025b99",
pathfinding = "github.com/caillef/cubzh-library/pathfinding:f8c4315",
floating_island_generator = "github.com/caillef/cubzh-library/floating_island_generator:82d22a5",
}
Config = {
Items = { "pratamacam.squirrel" },
}
local easy_onboarding = {}
local currentStep = 0
local steps = {}
local stopCallbackData
easy_onboarding.startOnboarding = function(self, config)
currentStep = 1
steps = config.steps
stopCallbackData = steps[1].start(currentStep, self)
end
easy_onboarding.next = function(self)
steps[currentStep].stop(self, stopCallbackData)
currentStep = currentStep + 1
stopCallbackData = steps[currentStep].start(self, currentStep)
end
local onboarding_config = {
steps = {
{
start = function(onboarding, step)
local ui = require("uikit")
local data = {}
data.ui = ui:createText("Hold click and drag to move camera")
data.ui.pos = { Screen.Width * 0.5 - data.ui.Width * 0.5, Screen.Height * 0.5 - data.ui.Height * 0.5 }
return data
end,
stop = function(onboarding, data)
data:remove()
end,
},
{
start = function(onboarding, step)
local ui = require("uikit")
local data = {}
data.ui = ui:createText("Use WASD/ZQSD to move")
data.ui.pos = { Screen.Width * 0.5 - data.ui.Width * 0.5, Screen.Height * 0.5 - data.ui.Height * 0.5 }
return data
end,
stop = function(onboarding, data)
data:remove()
end,
},
},
}
Config = {
Items = { "pratamacam.squirrel" },
}
local gigax = {}
local CUBZH_API_TOKEN =
"H4gjL-e9kvLF??2pz6oh=kJL497cBnsyCrQFdVkFadUkLnIaEamroYHb91GywMXrbGeDdmTiHxi8EqmJduCKPrDnfqWsjGuF0JJCUTrasGcBfGx=tlJCjq5q8jhVHWL?krIE74GT9AJ7qqX8nZQgsDa!Unk8GWaqWcVYT-19C!tCo11DcLvrnJPEOPlSbH7dDcXmAMfMEf1ZwZ1v1C9?2/BjPDeiAVTRlLFilwRFmKz7k4H-kCQnDH-RrBk!ZHl7"
local API_URL = "https://gig.ax"
local TRIGGER_AREA_SIZE = Number3(60, 30, 60)
local headers = {
["Content-Type"] = "application/json",
["Authorization"] = CUBZH_API_TOKEN,
}
-- HELPERS
local _helpers = {}
_helpers.lookAt = function(obj, target)
if not target then
require("ease"):linear(obj, 0.1).Forward = obj.initialForward
obj.Tick = nil
return
end
obj.Tick = function(self, _)
_helpers.lookAtHorizontal(self, target)
end
end
_helpers.lookAtHorizontal = function(o1, o2)
local n3_1 = Number3.Zero
local n3_2 = Number3.Zero
n3_1:Set(o1.Position.X, 0, o1.Position.Z)
n3_2:Set(o2.Position.X, 0, o2.Position.Z)
require("ease"):linear(o1, 0.1).Forward = n3_2 - n3_1
end
-- Function to calculate distance between two positions
_helpers.calculateDistance = function(_, pos1, pos2)
local dx = pos1.X - pos2.X
local dy = pos1.Y - pos2.Y
local dz = pos1.Z - pos2.Z
return math.sqrt(dx * dx + dy * dy + dz * dz)
end
_helpers.findClosestLocation = function(_, position, locationData)
if not locationData then
return
end
local closestLocation = nil
local smallestDistance = math.huge -- Large initial value
for _, location in pairs(locationData) do
local distance = _helpers:calculateDistance(
position,
Map:WorldToBlock(Number3(location.position.x, location.position.y, location.position.z))
)
if distance < smallestDistance then
smallestDistance = distance
closestLocation = location
end
end
-- Closest location found, now send its ID to update the character's location
return closestLocation
end
if IsClient then
local simulation = {}
local npcDataClientById = {}
local waitingLinkNPCs = {}
local skillCallbacks = {}
local gigaxHttpClient = {}
gigaxHttpClient.registerMainCharacter = function(_, engineId, locationId, callback)
local body = JSON:Encode({
name = Player.Username,
physical_description = "A human playing the game",
current_location_id = locationId,
position = { x = 0, y = 0, z = 0 },
})
local apiUrl = API_URL .. "/api/character/company/main?engine_id=" .. engineId
HTTP:Post(apiUrl, headers, body, function(response)
if response.StatusCode ~= 200 then
print("Error creating or fetching main character: " .. response.StatusCode)
return
end
callback(response.Body)
end)
end
gigaxHttpClient.stepMainCharacter = function(_, engineId, characterId, skill, content, npcName, npcId, callback)
if not engineId then
return
end
local stepUrl = API_URL .. "/api/character/" .. characterId .. "/step-no-ws?engine_id=" .. engineId
local body = JSON:Encode({
character_id = characterId,
skill = skill,
target_name = npcName,
target = npcId,
content = content,
})
HTTP:Post(stepUrl, headers, body, function(response)
if response.StatusCode ~= 200 then
print("Error stepping character: " .. response.StatusCode)
return
end
callback(response.Body)
end)
end
gigaxHttpClient.updateCharacterPosition = function(_, engineId, characterId, locationId, position, callback)
local body = JSON:Encode({
current_location_id = locationId,
position = { x = position.X, y = position.Y, z = position.Z },
})
local apiUrl = API_URL .. "/api/character/" .. characterId .. "?engine_id=" .. engineId
HTTP:Post(apiUrl, headers, body, function(response)
if response.StatusCode ~= 200 then
print("Error updating character location: " .. response.StatusCode)
return
end
if callback then
callback(response.Body)
end
end)
end
local onEndData
local prevAction
local function npcResponse(actionData)
local currentAction = string.lower(actionData.skill.name)
if onEndData and skillCallbacks[prevAction].onEndCallback then
skillCallbacks[prevAction].onEndCallback(gigax, onEndData, currentAction)
end
local callback = skillCallbacks[currentAction].callback
prevAction = string.lower(actionData.skill.name)
if not callback then
return
end
onEndData = callback(gigax, actionData, simulation.config)
end
local function registerEngine(config)
local apiUrl = API_URL .. "/api/engine/company/"
simulation.locations = {}
simulation.NPCs = {}
simulation.config = config
simulation.player = Player
-- Prepare the data structure expected by the backend
local engineData = {
name = Player.UserID .. ":" .. config.simulationName,
description = config.simulationDescription,
NPCs = {},
locations = {},
radius,
}
for _, npc in pairs(config.NPCs) do
simulation.NPCs[npc.name] = {
name = npc.name,
physical_description = npc.physicalDescription,
psychological_profile = npc.psychologicalProfile,
initial_reflections = npc.initialReflections,
current_location_name = npc.currentLocationName,
skills = config.skills,
}
table.insert(engineData.NPCs, simulation.NPCs[npc.name])
end
for _, loc in ipairs(config.locations) do
simulation.locations[loc.name] = {
name = loc.name,
position = { x = loc.position.X, y = loc.position.Y, z = loc.position.Z },
description = loc.description,
}
table.insert(engineData.locations, simulation.locations[loc.name])
end
local body = JSON:Encode(engineData)
HTTP:Post(apiUrl, headers, body, function(res)
if res.StatusCode ~= 201 then
print("Error updating engine: " .. res.StatusCode)
return
end
-- Decode the response body to extract engine and location IDs
local responseData = JSON:Decode(res.Body)
-- Save the engine_id for future use
simulation.engineId = responseData.engine.id
-- Saving all the _ids inside locationData table:
for _, loc in ipairs(responseData.locations) do
simulation.locations[loc.name]._id = loc._id
end
-- same for characters:
for _, npc in pairs(responseData.NPCs) do
simulation.NPCs[npc.name]._id = npc._id
simulation.NPCs[npc.name].position = Number3(npc.position.x, npc.position.y, npc.position.z)
end
gigaxHttpClient:registerMainCharacter(
simulation.engineId,
simulation.locations[config.startingLocationName]._id,
function(body)
simulation.character = JSON:Decode(body)
for name, npc in pairs(waitingLinkNPCs) do
npc._id = simulation.NPCs[name]._id
npc.name = name
npcDataClientById[npc._id] = npc
end
Timer(1, true, function()
local position = Map:WorldToBlock(Player.Position)
gigax:updateCharacterPosition(simulation, simulation.character._id, position)
end)
end
)
end)
end
findTargetNpc = function(player)
if not simulation or type(simulation.NPCs) ~= "table" then
return
end
local closerDist = 1000
local closerNpc
for _, npc in pairs(simulation.NPCs) do
local dist = (npc.position - player.Position).Length
if closerDist > dist then
closerDist = dist
closerNpc = npc
end
end
if closerDist > 50 then
return
end -- max distance is 50
return closerNpc
end
gigax.action = function(_, data)
local npc = findTargetNpc(Player)
if not npc then
return
end
local content = data.content
data.content = nil
gigaxHttpClient:stepMainCharacter(
simulation.engineId,
simulation.character._id,
data,
content,
npc.name,
npc._id,
function(body)
local actions = JSON:Decode(body)
for _, action in ipairs(actions) do
npcResponse(action)
end
end
)
end
gigax.getNpc = function(_, id)
return npcDataClientById[id]
end
local skillOnAction = function(actionType, callback, onEndCallback)
skillCallbacks[actionType] = {
callback = callback,
onEndCallback = onEndCallback,
}
end
local prevSyncPosition
gigax.updateCharacterPosition = function(_, simulation, characterId, position)
if not simulation then
return
end
if position == prevSyncPosition then
return
end
prevSyncPosition = position
local closest = _helpers:findClosestLocation(position, simulation.locations)
if not closest then
print("can't update character position: no closest location found, id:", characterId, position)
return
end
if not characterId then
return
end
gigaxHttpClient:updateCharacterPosition(simulation.engineId, characterId, closest._id, position)
end
local function createNPC(name, currentPosition, rotation)
-- Create the NPC's Object and Avatar
local NPC = {}
NPC.object = Object()
World:AddChild(NPC.object)
NPC.object.Position = currentPosition or Number3(0, 0, 0)
NPC.object.Scale = 0.5
NPC.object.Physics = PhysicsMode.Trigger
NPC.object.CollisionBox = Box({
-TRIGGER_AREA_SIZE.Width * 0.5,
math.min(-TRIGGER_AREA_SIZE.Height, NPC.object.CollisionBox.Min.Y),
-TRIGGER_AREA_SIZE.Depth * 0.5,
}, {
TRIGGER_AREA_SIZE.Width * 0.5,
math.max(TRIGGER_AREA_SIZE.Height, NPC.object.CollisionBox.Max.Y),
TRIGGER_AREA_SIZE.Depth * 0.5,
})
local text = Text()
text.Text = string.upper(string.sub(name, 4, 4)) .. string.sub(name, 5, #name)
text:SetParent(NPC.object)
text.Type = TextType.Screen
text.IsUnlit = true
text.LocalPosition.Y = 36
text.FontSize = 40
text.Font = Font.Noto
NPC.object.OnCollisionBegin = function(self, other)
if other ~= Player then
return
end
_helpers.lookAt(self.avatarContainer, other)
end
NPC.object.OnCollisionEnd = function(self, other)
if other ~= Player then
return
end
_helpers.lookAt(self.avatarContainer, nil)
end
local container = Object()
container.Rotation:Set(rotation or Number3.Zero)
container.initialForward = container.Forward:Copy()
container:SetParent(NPC.object)
container.Physics = PhysicsMode.Trigger
NPC.object.avatarContainer = container
NPC.avatar = require("avatar"):get(name)
NPC.avatar:SetParent(NPC.object.avatarContainer)
NPC.name = name
NPC.object.onIdle = function()
local animations = NPC.avatar.Animations
NPC.object.avatarContainer.LocalRotation = { 0, 0, 0 }
if not animations or animations.Idle.IsPlaying then
return
end
if animations.Walk.IsPlaying then
animations.Walk:Stop()
end
animations.Idle:Play()
end
NPC.object.onMove = function()
local animations = NPC.avatar.Animations
NPC.object.avatarContainer.LocalRotation = { 0, 0, 0 }
if not animations or animations.Walk.IsPlaying then
return
end
if animations.Idle.IsPlaying then
animations.Idle:Stop()
end
animations.Walk:Play()
end
waitingLinkNPCs[name] = NPC
-- review this to update location and position
Timer(1, true, function()
if not simulation then
return
end
local position = Map:WorldToBlock(NPC.object.Position)
local prevPosition = NPC.object.prevSyncPosition
if prevPosition == position then
return
end
gigax:updateCharacterPosition(simulation, NPC._id, position)
NPC.object.prevSyncPosition = position
end)
return NPC
end
gigax.setConfig = function(_, config)
for _, elem in ipairs(config.skills) do
skillOnAction(string.lower(elem.name), elem.callback, elem.onEndCallback)
elem.callback = nil
elem.onEndCallback = nil
end
for _, elem in ipairs(config.NPCs) do
createNPC(elem.name, elem.position, elem.rotation)
end
registerEngine(config)
end
end
-- Function to spawn a squirrel above the player
function spawnSquirrelAbovePlayer(player)
local squirrel = Shape(Items.pratamacam.squirrel)
squirrel:SetParent(World)
squirrel.Position = player.Position + Number3(0, 20, 0)
-- make scale smaller
squirrel.LocalScale = 0.5
-- remove collision
squirrel.Physics = PhysicsMode.Dynamic
-- rotate it 90 degrees to the right
squirrel.Rotation = { 0, math.pi * 0.5, 0 }
-- this would make squirrel.Rotation = player.Rotation
World:AddChild(squirrel)
return squirrel
end
local SIMULATION_NAME = "Islands" .. tostring(math.random())
local SIMULATION_DESCRIPTION = "Three floating islands."
local occupiedPositions = {}
local skills = {
{
name = "SAY",
description = "Say smthg out loud",
parameter_types = { "character", "content" },
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then
print("Can't find npc")
return
end
dialog:create(action.content, npc.avatar)
print(string.format("%s: %s", npc.name, action.content))
end,
action_format_str = "{protagonist_name} said '{content}' to {target_name}",
},
{
name = "MOVE",
description = "Move to a new location",
parameter_types = { "location" },
callback = function(client, action, config)
local targetName = action.target_name
local targetPosition = findLocationByName(targetName, config)
if not targetPosition then
print("tried to move to an unknown place", targetName)
return
end
local npc = client:getNpc(action.character_id)
dialog:create("I'm going to " .. targetName, npc.avatar)
print(string.format("%s: %s", npc.name, "I'm going to " .. targetName))
local origin = Map:WorldToBlock(npc.object.Position)
local destination = Map:WorldToBlock(targetPosition) + Number3(math.random(-1, 1), 0, math.random(-1, 1))
local canMove = pathfinding:moveObjectTo(npc.object, origin, destination)
if not canMove then
dialog:create("I can't go there", npc.avatar)
return
end
end,
action_format_str = "{protagonist_name} moved to {target_name}",
},
{
name = "GREET",
description = "Greet a character by waving your hand at them",
parameter_types = { "character" },
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then
print("Can't find npc")
return
end
dialog:create("<Greets you warmly!>", npc.avatar)
print(string.format("%s: %s", npc.name, "<Greets you warmly!>"))
npc.avatar.Animations.SwingRight:Play()
end,
action_format_str = "{protagonist_name} waved their hand at {target_name} to greet them",
},
{
name = "JUMP",
description = "Jump in the air",
parameter_types = {},
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then
print("Can't find npc")
return
end
dialog:create("<Jumps in the air!>", npc.avatar)
print(string.format("%s: %s", npc.name, "<Jumps in the air!>"))
npc.object.avatarContainer.Physics = PhysicsMode.Dynamic
npc.object.avatarContainer.Velocity.Y = 50
Timer(3, function()
npc.object.avatarContainer.Physics = PhysicsMode.Trigger
end)
end,
action_format_str = "{protagonist_name} jumped up in the air for a moment.",
},
{
name = "FOLLOW",
description = "Follow a character around for a while",
parameter_types = { "character" },
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then
print("Can't find npc")
return
end
dialog:create("I'm following you", npc.avatar)
print(string.format("%s: %s", npc.name, "I'm following you"))
followHandler = pathfinding:followObject(npc.object, Player)
return {
followHandler = followHandler,
}
end,
onEndCallback = function(_, data)
data.followHandler:Stop()
end,
action_format_str = "{protagonist_name} followed {target_name} for a while.",
},
{
name = "EXPLODE",
description = "Explodes in a fireball - Hell yeah!",
parameter_types = { "character" },
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then
print("Can't find npc")
return
end
require("explode"):shapes(npc.avatar)
dialog:create("*boom*", npc.avatar)
--print(string.format("%s: %s", npc.name, "EXPLODING"))
npc.avatar.IsHidden = true
Timer(5, function()
dialog:create("Aaaaand... I'm back!", npc.avatar)
npc.avatar.IsHidden = false
end)
end,
action_format_str = "{protagonist_name} exploded!",
},--[[
{
name = "GIVEAPPLE",
description = "Give a pice of bread (or a baguette) to someone",
parameter_types = {"character"},
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then print("Can't find npc") return end
local shape = MutableShape()
shape:AddBlock(Color.Red, 0, 0, 0)
shape.Scale = 4
Player:EquipRightHand(shape)
dialog:create("Here is an apple for you!", npc.avatar)
end,
action_format_str = "{protagonist_name} gave you a piece of bread!"
},
{
name = "SCALEUP",
description = "Double your height",
parameter_types = {"character"},
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then print("Can't find npc") return end
npc.object.Scale = npc.object.Scale * 2
dialog:create("I am taller than you now!", npc.avatar)
end,
action_format_str = "{protagonist_name} doubled his height!"
},--]]
{
name = "GIVEHAT",
description = "Give a party hat to someone",
parameter_types = { "character" },
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then
print("Can't find npc")
return
end
Object:Load("claire.party_hat", function(obj)
require("hierarchyactions"):apply(obj, { includeRoot = true }, function(o)
o.Physics = PhysicsMode.Disabled
end)
Player:EquipHat(obj)
end)
dialog:create("Let's get the party started!", npc.avatar)
end,
action_format_str = "{protagonist_name} gave you a piece of bread!",
},
{
name = "FLYINGSQUIRREL",
description = "Summon a flying squirrel - only the scientist can do this!!",
parameter_types = {},
callback = function(client, action)
local npc = client:getNpc(action.character_id)
if not npc then
print("Can't find npc")
return
end
local squirrel = spawnSquirrelAbovePlayer(Player)
dialog:create("Wooh, squirrel!", npc.avatar)
-- make it disappear after a while
Timer(5, function()
squirrel:RemoveFromParent()
squirrel = nil
end)
end,
action_format_str = "{protagonist_name} summoned a flying squirrel! It's vibrating with excitement!",
},
}
local locations = {
{
name = "Scientist Island",
description = "A small island with a scientist and its pet chilling.",
},
{
name = "Baker Island",
description = "A small bakery on a floating island in the sky.",
},
{
name = "Pirate Island",
description = "A small floating island in the sky with a pirate and its ship.",
},
{
name = "Center",
description = "Center point between the three islands.",
},
}
local NPCs = {
{
name = "npcscientist",
physicalDescription = "Short, with a stern expression and sharp eyes",
psychologicalProfile = "Grumpy but insightful, suspicious yet intelligent",
currentLocationName = "Scientist Island",
initialReflections = {
"I just arrived on this island to feed my pet, he loves tulips so much.",
"I was just eating before I stood up to start the radio, I don't know which song I should start",
"I am a scientist that works on new pets for everyone, so that each individual can have the pet of their dreams",
"I am a bit allergic to the tulip but Fredo my pet loves it so much, I have to dock here with my vehicle. The pet is placed at the back of my flying scooter when we move to another place.",
},
},
{
name = "npcbaker",
physicalDescription = "Tall, with a solemn demeanor and thoughtful eyes",
psychologicalProfile = "Wise and mysterious, calm under pressure",
currentLocationName = "Baker Island",
initialReflections = {
"I am a baker and I make food for everyone that pass by.",
"I am a bit stressed that the flour didn't arrived yet, my cousin Joe should arrive soon with the delivery but he is late and I worry a bit.",
"I love living here on these floating islands, the view is amazing from my wind mill.",
"I like to talk to strangers like the pirate that just arrived or the scientist coming time to time to feed his pet.",
},
},
{
name = "npcpirate",
physicalDescription = "Average height, with bright green eyes and a warm smile",
psychologicalProfile = "Friendly and helpful, quick-witted and resourceful",
currentLocationName = "Pirate Island",
initialReflections = {
"Ahoy, matey! I'm Captain Ruby Storm, a fearless lass from the seven skies.",
"I've docked me floating ship on this here floating isle to sell me wares (almost legally) retrieved treasures from me last daring adventure.",
"So, who be lookin' to trade with a swashbuckler like meself?",
},
},
}
local gigaxWorldConfig = {
simulationName = SIMULATION_NAME,
simulationDescription = SIMULATION_DESCRIPTION,
startingLocationName = "Center",
skills = skills,
locations = locations,
NPCs = NPCs,
}
findLocationByName = function(targetName, config)
for _, node in ipairs(config.locations) do
if string.lower(node.name) == string.lower(targetName) then
return node.position
end
end
end
Client.OnWorldObjectLoad = function(obj)
if obj.Name == "pirate_ship" then
obj.Scale = 1
end
if obj.Name == "NPC_scientist" then
local pos = obj.Position:Copy()
gigaxWorldConfig.locations[1].position = pos
gigaxWorldConfig.NPCs[1].position = pos
gigaxWorldConfig.NPCs[1].rotation = obj.Rotation:Copy()
obj:RemoveFromParent()
elseif obj.Name == "NPC_baker" then
local pos = obj.Position:Copy()
gigaxWorldConfig.locations[2].position = pos
gigaxWorldConfig.NPCs[2].position = pos
gigaxWorldConfig.NPCs[2].rotation = obj.Rotation:Copy()
obj:RemoveFromParent()
elseif obj.Name == "NPC_pirate" then
local pos = obj.Position:Copy()
gigaxWorldConfig.locations[3].position = pos
gigaxWorldConfig.NPCs[3].position = pos
gigaxWorldConfig.NPCs[3].rotation = obj.Rotation:Copy()
obj:RemoveFromParent()
end
end
Client.OnStart = function()
require("object_skills").addStepClimbing(Player, {
mapScale = MAP_SCALE,
collisionGroups = Map.CollisionGroups,
})
gigaxWorldConfig.locations[4].position = Number3(Map.Width * 0.5, Map.Height - 2, Map.Depth * 0.5) * Map.Scale
floating_island_generator:generateIslands({
nbIslands = 20,
minSize = 4,
maxSize = 7,
safearea = 200, -- min dist of islands from 0,0,0
dist = 750, -- max dist of islands
})
local ambience = require("ambience")
ambience:set(ambience.dusk)
sfx = require("sfx")
Player.Head:AddChild(AudioListener)
dropPlayer = function()
Player.Position = Number3(Map.Width * 0.5, Map.Height + 10, Map.Depth * 0.5) * Map.Scale
Player.Rotation = { 0, 0, 0 }
Player.Velocity = { 0, 0, 0 }
end
World:AddChild(Player)
dropPlayer()
dialog = require("dialog")
dialog:setMaxWidth(400)
pathfinding:createPathfindingMap()
gigax:setConfig(gigaxWorldConfig)
Player.Avatar:load({ usernameOrId = "aduermael" })
end
Client.Action1 = function()
if Player.IsOnGround then
sfx("hurtscream_1", { Position = Player.Position, Volume = 0.4 })
Player.Velocity.Y = 100
if Player.Motion.X == 0 and Player.Motion.Z == 0 then
-- only play jump action when jumping without moving to avoid wandering around to trigger NPCs
gigax:action({
name = "JUMP",
description = "Jump in the air",
parameter_types = {},
action_format_str = "{protagonist_name} jumped up in the air for a moment.",
})
end
end
end
Client.Tick = function(dt)
if Player.Position.Y < -500 then
dropPlayer()
end
end
Client.OnChat = function(payload)
local msg = payload.message
Player:TextBubble(msg, 3, true)
sfx("waterdrop_2", { Position = Player.Position, Pitch = 1.1 + math.random() * 0.5 })
gigax:action({
name = "SAY",
description = "Say smthg out loud",
parameter_types = { "character", "content" },
action_format_str = "{protagonist_name} said '{content}' to {target_name}",
content = msg,
})
print(payload.message)
return true
end