Volver al blog
Tutorial12 min de lectura

Sistema de diálogo con NPC para FiveM: conversaciones ramificadas

Dales personalidad a tus NPC de FiveM. Árboles de diálogo, voces, misiones y scripts para servidores de rol con historias profundas.

Agency Scripts

Fundador y desarrollador principal en Agency Scripts

Por qué es importante el diálogo de los NPC

Los sistemas de diálogo de NPC transforman los peds estáticos en personajes vivos que dan personalidad a tu servidor y guían a los jugadores en distintas actividades. Sin diálogos, los NPC no son más que objetivos de interacción que abren menús. Con un sistema de diálogo adecuado, se convierten en personajes que dan misiones, cuentan historias, atienden tiendas con carácter e informan a los jugadores, reaccionando de forma diferente según su reputación o trabajo. Un sistema de diálogo ofrece oportunidades para crear historias propias del servidor, tramas ramificadas y decisiones de los jugadores que afectan a futuras interacciones. Piensa en lo mucho más atractiva que resulta la experiencia cuando un jugador se acerca a un NPC mecánico que lo saluda por su nombre, comenta el estado de su coche y le ofrece distintos servicios según sea cliente habitual o venga por primera vez. Este nivel de inmersión mantiene a los jugadores interesados en tu servidor y les da motivos para explorar más allá de las rutinas habituales.

Estructura de datos del árbol de diálogo

La base de cualquier sistema de diálogo es la estructura de datos que define el flujo de la conversación. Un árbol de diálogo consta de nodos, y cada nodo contiene el texto del NPC y una lista de opciones de respuesta del jugador que llevan a otros nodos. Esta estructura de árbol permite conversaciones ramificadas, rutas condicionales basadas en el estado del jugador y ciclos que vuelven a puntos anteriores de la conversación. Diseña los nodos de diálogo para que se basen en datos, así el personal del servidor podrá crear nuevas conversaciones sin tocar el código Lua. Aquí tienes un formato práctico para un árbol de diálogo:

Config.Dialogues = {
    ['mechanic_greeting'] = {
        npcName = 'Tony the Mechanic',
        nodes = {
            ['start'] = {
                text = "Hey there! Car giving you trouble, or are you just here for a tune-up?",
                animation = 'WORLD_HUMAN_WELDING',
                options = {
                    {
                        label = "I need repairs",
                        next = 'repairs',
                        condition = function(player)
                            return IsPlayerInVehicle(player)
                        end
                    },
                    {
                        label = "What services do you offer?",
                        next = 'services'
                    },
                    {
                        label = "Just browsing, thanks",
                        next = 'goodbye'
                    },
                }
            },
            ['repairs'] = {
                text = "Let me take a look... Yeah, your engine's seen better days. I can fix it up for $500. What do you say?",
                options = {
                    { label = "Fix it up", next = 'repair_accept', action = 'repair_vehicle' },
                    { label = "Too expensive", next = 'haggle' },
                    { label = "Never mind", next = 'goodbye' },
                }
            },
            ['haggle'] = {
                text = "Look, parts aren't cheap. But since you seem like a decent person, I can do $350. Final offer.",
                options = {
                    { label = "Deal!", next = 'repair_accept', action = 'repair_vehicle_discount' },
                    { label = "I'll pass", next = 'goodbye' },
                }
            },
            ['services'] = {
                text = "I do repairs, custom paint jobs, performance tuning, and tire changes. What catches your eye?",
                options = {
                    { label = "Tell me about tuning", next = 'tuning_info' },
                    { label = "Back to start", next = 'start' },
                }
            },
            ['repair_accept'] = {
                text = "Alright, give me a minute... Done! She's running smooth now. Take care of her out there.",
                options = {
                    { label = "Thanks, Tony!", next = 'end' },
                }
            },
            ['goodbye'] = {
                text = "No worries. Come back anytime you need help with your ride!",
                options = {
                    { label = "See you around", next = 'end' },
                }
            },
        }
    },
}

El condition La función en options permite mostrar u ocultar opciones dinámicamente según el estado actual del jugador. Una opción de reparación solo aparece si el jugador llegó en un vehículo, las opciones relacionadas con misiones solo se muestran cuando el jugador ha alcanzado la fase adecuada y las opciones VIP pueden restringirse a jugadores con ciertos permisos. El action campo ejecuta funciones del lado del servidor cuando se selecciona una opción específica, conectando las decisiones del diálogo con los resultados del juego.

Generación y administración de NPC

Los NPC de diálogo deben generarse de forma fiable, colocarse en ubicaciones consistentes y mantenerse a salvo del caos que puede desatar el mundo de GTA. Al generar un NPC de diálogo, debes solicitar el modelo, crear el ped, marcarlo como entidad de misión para que los sistemas de limpieza de GTA no lo eliminen, congelar su posición para que no se aleje y hacerlo invencible para que los jugadores no puedan matar al personaje que da las misiones. Usa un administrador centralizado de NPC que genere peds cuando haya jugadores cerca y los elimine cuando no haya ningún jugador en la zona, para ahorrar memoria en servidores concurridos:

local spawnedNPCs = {}

function SpawnDialogueNPC(npcId, config)
    if spawnedNPCs[npcId] then return end

    local model = GetHashKey(config.model)
    RequestModel(model)
    while not HasModelLoaded(model) do Wait(10) end

    local ped = CreatePed(0, model, config.coords.x, config.coords.y,
        config.coords.z, config.heading, false, true)

    SetEntityInvincible(ped, true)
    SetBlockingOfNonTemporaryEvents(ped, true)
    FreezeEntityPosition(ped, true)
    SetPedFleeAttributes(ped, 0, false)
    SetPedCombatAttributes(ped, 46, true)
    SetPedCanRagdoll(ped, false)
    SetEntityAsMissionEntity(ped, true, true)
    SetModelAsNoLongerNeeded(model)

    -- Play idle animation if configured
    if config.scenario then
        TaskStartScenarioInPlace(ped, config.scenario, 0, true)
    end

    spawnedNPCs[npcId] = { entity = ped, config = config }
    return ped
end

La combinación de SetBlockingOfNonTemporaryEvents y FreezeEntityPosition garantiza que los eventos del entorno, como explosiones cercanas, persecuciones policiales o jugadores agresivos, no hagan que tu NPC huya, se defienda ni caiga al suelo de forma descontrolada. Sin estas protecciones, los jugadores podrían encontrar a un personaje que da misiones tendido en el suelo, temblando después de que lo atropelle un coche, lo que rompería por completo la inmersión que te has esforzado en crear.

Sistema de cámara cinematográfica

El trabajo de cámara durante las conversaciones de diálogo transforma la experiencia de una interacción con un menú en un momento cinematográfico. Cuando comienza un diálogo, crea una cámara que enfoque la cara del NPC con un ligero desplazamiento y usa la profundidad de campo para desenfocar el fondo y centrar la atención en la conversación. Cambia de ángulo a medida que avanza la conversación, mostrando al jugador cuando elige y volviendo al NPC cuando responde. El sistema de cámara nativo de GTA te da control total sobre la posición, la rotación, el campo de visión y la profundidad de campo:

local dialogueCam = nil

function StartDialogueCamera(npcPed)
    local npcCoords = GetEntityCoords(npcPed)
    local npcHeading = GetEntityHeading(npcPed)
    local playerPed = PlayerPedId()

    -- Calculate camera position offset from NPC face
    local angleRad = math.rad(npcHeading + 160)
    local camX = npcCoords.x + (math.sin(angleRad) * 1.5)
    local camY = npcCoords.y + (math.cos(angleRad) * 1.5)
    local camZ = npcCoords.z + 0.6

    dialogueCam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
    SetCamCoord(dialogueCam, camX, camY, camZ)
    PointCamAtPedBone(dialogueCam, npcPed, 31086, 0.0, 0.0, 0.1, true)  -- Head bone

    -- Depth of field for cinematic look
    SetCamNearDof(dialogueCam, 0.5)
    SetCamFarDof(dialogueCam, 3.5)
    SetCamDofStrength(dialogueCam, 0.6)
    SetCamUseShallowDofMode(dialogueCam, true)

    SetCamFov(dialogueCam, 40.0)  -- Tighter shot
    SetCamActive(dialogueCam, true)
    RenderScriptCams(true, true, 800, true, false)

    -- Disable player controls during dialogue
    SetPlayerControl(PlayerId(), false, 0)

    -- Make player face NPC
    TaskTurnPedToFaceEntity(playerPed, npcPed, 1000)
end

function StopDialogueCamera()
    if dialogueCam then
        RenderScriptCams(false, true, 500, true, false)
        DestroyCam(dialogueCam, true)
        dialogueCam = nil
        SetPlayerControl(PlayerId(), true, 0)
    end
end

El PointCamAtPedBone El nativo es especialmente potente porque mantiene el enfoque de la cámara en la cabeza del NPC, independientemente de sus pequeños movimientos de animación, y conserva un encuadre uniforme durante toda la conversación. Las duraciones de transición en RenderScriptCams crean transiciones de cámara fluidas en lugar de cortes bruscos. Prueba valores entre 500ms y 1000ms para encontrar el ritmo adecuado para tu servidor.

Sistema de visualización de subtítulos

Un sistema de subtítulos muestra el texto de diálogo del NPC de forma atractiva en la parte inferior de la pantalla, imitando cómo los juegos narrativos presentan las conversaciones. En lugar de mostrar todo el bloque de texto de una vez, implementa un efecto de máquina de escribir que revela los caracteres uno a uno, creando la ilusión de que el NPC está hablando. Usa NUI para mostrar los subtítulos, ya que te permite controlar por completo las fuentes, los colores, las animaciones y la posición con CSS. Envía el texto de cada nodo de diálogo al marco de NUI cuando se active, junto con el nombre del NPC y cualquier etiqueta de emoción que deba afectar al estilo de visualización:

// Subtitle display JavaScript (html/subtitles.js)
let typewriterTimeout = null;

window.addEventListener('message', (event) => {
    const data = event.data;

    if (data.action === 'showDialogue') {
        clearTimeout(typewriterTimeout);
        const container = document.getElementById('subtitle-container');
        const nameEl = document.getElementById('npc-name');
        const textEl = document.getElementById('dialogue-text');
        const optionsEl = document.getElementById('dialogue-options');

        container.style.display = 'block';
        nameEl.textContent = data.npcName;
        textEl.textContent = '';
        optionsEl.innerHTML = '';

        // Typewriter effect
        let charIndex = 0;
        const fullText = data.text;

        function typeNext() {
            if (charIndex < fullText.length) {
                textEl.textContent += fullText[charIndex];
                charIndex++;
                typewriterTimeout = setTimeout(typeNext, 30);
            } else {
                // Show options after text completes
                showOptions(data.options);
            }
        }
        typeNext();
    }

    if (data.action === 'hideDialogue') {
        document.getElementById('subtitle-container').style.display = 'none';
    }
});

function showOptions(options) {
    const optionsEl = document.getElementById('dialogue-options');
    options.forEach((opt, index) => {
        const btn = document.createElement('button');
        btn.className = 'dialogue-option';
        btn.innerHTML = `${index + 1} ${opt.label}`;
        btn.onclick = () => {
            fetch(`https://${GetParentResourceName()}/selectOption`, {
                method: 'POST',
                body: JSON.stringify({ index: index })
            });
        };
        optionsEl.appendChild(btn);
    });
}

Aplica al contenedor de subtítulos un fondo oscuro semitransparente, esquinas redondeadas y un borde con un degradado sutil que combine con la temática de tu servidor. Colócalo en la parte inferior central de la pantalla, con suficiente relleno para que no se superponga al minimapa ni a otros elementos del HUD. Añade atajos de teclado para que los jugadores puedan pulsar las teclas numéricas y elegir opciones rápidamente sin hacer clic, algo más natural durante una conversación.

Integración con el sistema de misiones

Los sistemas de diálogo se vuelven realmente potentes cuando se conectan a un framework de misiones. El campo action del árbol de diálogo en las opciones de respuesta proporciona el punto de conexión donde se ejecuta la lógica de las misiones. Cuando un jugador acepta una misión mediante el diálogo, el controlador de acciones debe crear una entrada de misión en el registro de misiones del jugador, configurar los puntos de ruta u objetivos necesarios y hacer un seguimiento del progreso durante las siguientes interacciones de diálogo. Guarda el progreso de la misión en la base de datos por jugador para que pueda desconectarse y continuar donde lo dejó. Diseña las misiones como máquinas de estados, donde cada estado corresponde a un nodo de diálogo y a un conjunto de objetivos que deben completarse antes de que el siguiente diálogo esté disponible:

-- Server-side quest actions triggered by dialogue choices
local QuestActions = {
    ['accept_delivery_job'] = function(src, npcId)
        local Player = QBCore.Functions.GetPlayer(src)
        local citizenid = Player.PlayerData.citizenid

        -- Create quest entry
        MySQL.insert(
            'INSERT INTO player_quests (citizenid, quest_id, stage, started_at) VALUES (?, ?, ?, NOW())',
            {citizenid, 'tony_delivery_1', 'pickup'}
        )

        -- Set waypoint for pickup location
        TriggerClientEvent('quest:client:setWaypoint', src, {
            coords = vector3(482.5, -1311.2, 29.2),
            blipSprite = 501,
            blipColor = 5,
            label = 'Package Pickup'
        })

        TriggerClientEvent('QBCore:Notify', src, 'Quest started: Special Delivery', 'success')
    end,

    ['complete_delivery'] = function(src, npcId)
        local Player = QBCore.Functions.GetPlayer(src)
        local citizenid = Player.PlayerData.citizenid

        MySQL.update(
            'UPDATE player_quests SET stage = ?, completed_at = NOW() WHERE citizenid = ? AND quest_id = ?',
            {'completed', citizenid, 'tony_delivery_1'}
        )

        Player.Functions.AddMoney('cash', 1500, 'quest-delivery-reward')
        TriggerClientEvent('QBCore:Notify', src, 'Quest complete! Reward: $1,500', 'success')
    end,
}

Usa la fase de la misión para modificar dinámicamente qué nodos de diálogo están disponibles. Cuando un jugador vuelve con Tony después de completar la entrega, el sistema de diálogo comprueba la fase de la misión y muestra un diálogo de finalización con una recompensa en lugar del saludo inicial. Así se crea un flujo de conversación natural en el que los NPC reconocen el progreso del jugador y reaccionan en consecuencia, haciendo que el mundo parezca dinámico y vivo.

Animaciones y expresiones de los NPC

Los NPC estáticos que permanecen completamente inmóviles mientras hablan parecen robóticos y rompen la inmersión. Añade compatibilidad con animaciones a tu sistema de diálogo para que los NPC gesticulen, expresen emociones y reaccionen durante las conversaciones. GTA V cuenta con una enorme biblioteca de diccionarios de animaciones que incluye gestos, expresiones faciales y lenguaje corporal, que puedes activar en puntos específicos del diálogo. Asigna las animaciones a nivel de nodo para que cada línea de diálogo pueda tener su propio gesto. Cuando el NPC dé buenas noticias, reproduce una animación alegre saludando con la mano. Cuando hable de algo serio, usa una postura severa con los brazos cruzados. En los momentos de espera entre respuestas del jugador, repite una animación de pensar o esperar. También puedes usar natives de animación facial como SetFacialIdleAnimOverride para cambiar la expresión habitual del NPC y adaptarla al tono de la conversación, haciendo que parezca feliz, enfadado, asustado o confundido. Combina animaciones corporales y faciales para conseguir las actuaciones más convincentes, y prueba siempre las animaciones dentro del juego, porque algunos diccionarios de animaciones se ven diferentes según el modelo de ped, y algo que funciona con un ped masculino podría atravesar el modelo o verse raro con uno femenino.

Rendimiento y buenas prácticas

Un sistema de diálogo que genera decenas de NPC por todo el mapa requiere una gestión cuidadosa del rendimiento. Genera NPC solo cuando los jugadores estén dentro de la distancia de renderizado, normalmente entre 50 y 100 metros, y elimínalos cuando no haya jugadores cerca. Usa un solo hilo para gestionar las distancias de generación de todos los NPC, en lugar de crear hilos separados para cada uno, ya que esto reduce considerablemente la sobrecarga del lado del cliente. Guarda en caché los datos del árbol de diálogo al iniciar el resource, en lugar de leer archivos durante las conversaciones. Mantén fluidas las transiciones de cámara del diálogo, pero no crees y destruyas cámaras en exceso, ya que las operaciones de cámara tienen un coste de rendimiento medible. Cuando varios jugadores interactúen simultáneamente con el mismo NPC, cada jugador debe tener su propia instancia de diálogo independiente, lo que significa que el estado del diálogo debe guardarse por jugador y no en la entidad del NPC. Limpia todos los recursos del diálogo cuando el jugador se desconecte o se aleje en mitad de la conversación, destruyendo la cámara, liberando el foco de NUI y restaurando los controles del jugador. Prueba tu sistema de diálogo pensando en el peor escenario: el juego del jugador se bloquea a mitad del diálogo o pulsa Alt+F4 mientras hay una cámara activa. Tu sistema debe detectar estos casos y limpiarlo correctamente para evitar que queden cámaras activas o controles bloqueados cuando el jugador vuelva a conectarse.

¿Listo para empezar?

Consigue los scripts en nuestra tienda, o únete al Discord para soporte, novedades y un adelanto de lo que viene.