Назад к блогу
Tutorial10 мин чтения

FiveM NPC Dialogue System: Ветвящиеся диалоги скрипта

Дайте вашим NPC в FiveM настоящую личность. Диалоговые деревья, голосовые реплики, квестовые хуки и лучшие скрипты для глубоких сюжетных ролевых серверов.

Agency Scripts

Основатель и ведущий разработчик Agency Scripts

Почему важен диалог NPC

Диалоговые системы NPC превращают статичных пешеходов в живых персонажей, которые придают вашему серверу индивидуальность и направляют игроков в ходе активности. Без диалогов NPC , это просто объекты взаимодействия, открывающие меню. С правильной диалоговой системой они становятся дающими квесты, рассказчиками, владельцами магазинов с характером и информаторами, которые реагируют по-разному в зависимости от репутации или профессии игрока. Диалоговая система создаёт возможности для серверного лора, ветвящихся сюжетных линий и выборов игроков, влияющих на будущие взаимодействия. Подумайте, насколько интереснее, когда игрок подходит к механику NPC, который приветствует его по имени, комментирует состояние машины и предлагает разные услуги в зависимости от того, постоянный ли он клиент или впервые на сервере. Такой уровень погружения удерживает игроков на сервере и даёт им причины исследовать больше, чем стандартные циклы гринда.

Структура данных дерева диалогов

Основа любой системы диалогов , это структура данных, определяющая поток разговора. Диалоговое дерево состоит из узлов, где каждый узел содержит текст NPC и список вариантов ответов игрока, ведущих к другим узлам. Такая структура поддерживает ветвящиеся диалоги, условные пути в зависимости от состояния игрока и циклы, возвращающие к предыдущим точкам разговора. Проектируйте узлы диалога как управляемые данными, чтобы персонал сервера мог создавать новые диалоги без изменения Lua-кода. Вот практический формат диалогового дерева:

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' },
                }
            },
        }
    },
}

Параметр condition функция на опциях позволяет динамически показывать или скрывать выборы в зависимости от текущего состояния игрока. Опция ремонта появляется только если игрок прибыл на автомобиле, квестовые опции показываются только на нужной стадии, а VIP-опции могут быть ограничены игроками с определенными правами. The action поле запускает серверные функции при выборе определенной опции, связывая выборы в диалоге с игровыми результатами.

Спавн и управление NPC

NPC с диалогами должны надежно появляться, размещаться в постоянных местах и быть защищены от хаоса, который может происходить в мире GTA. При спавне NPC с диалогом необходимо запросить модель, создать педа, установить его как миссионный объект, чтобы системы очистки GTA не удаляли его, зафиксировать позицию, чтобы он не уходил, и сделать его неуязвимым, чтобы игроки не могли убить вашего квестодателя. Используйте централизованный менеджер NPC, который спавнит пэдов, когда игроки рядом, и удаляет их, когда игроков в зоне нет, чтобы экономить память на загруженных серверах:

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

Комбинация SetBlockingOfNonTemporaryEvents и FreezeEntityPosition обеспечивает, что окружающие события, такие как взрывы поблизости, полицейские погони или агрессивные игроки, не заставляют вашего NPC убегать, драться или падать в ragdoll. Без этих защит игроки могут столкнуться с NPC, лежащим на земле и дергающимся после удара проезжающей машиной, что полностью нарушит погружение, над созданием которого вы работали.

Кинематографическая камера

Работа камеры во время диалогов поднимает опыт с взаимодействия с меню до кинематографического момента. Когда начинается диалог, создайте камеру, которая фокусируется на лице NPC с небольшим смещением, используя глубину резкости для размытия фона и привлечения внимания к разговору. Переключайтесь между углами камеры по мере развития разговора, переключаясь на игрока, когда он делает выбор, и обратно на NPC, когда тот отвечает. Нативная система камеры GTA дает полный контроль над позицией, вращением, полем зрения и глубиной резкости:

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

Параметр PointCamAtPedBone native особенно мощен, потому что фиксирует фокус камеры на голове NPC независимо от небольших движений анимации, сохраняя постоянную композицию во время разговора. Продолжительность переходов в RenderScriptCams создавайте плавные переходы камеры вместо резких срезов, и вам следует экспериментировать со значениями между 500мс и 1000мс, чтобы найти подходящее ощущение для темпа вашего сервера.

Система отображения субтитров

Система субтитров отображает текст диалогов NPC в визуально привлекательном виде в нижней части экрана, имитируя, как в сюжетных играх показывают разговоры. Вместо вывода всего текста сразу реализуйте эффект печатной машинки, который показывает символы по одному, создавая иллюзию, что NPC активно говорит. Используйте NUI для отображения субтитров, так как это даёт полный контроль CSS над шрифтами, цветами, анимациями и позиционированием. Отправляйте текст каждого узла диалога в NUI-окно при его активации вместе с именем NPC и любыми тегами эмоций, которые должны влиять на стиль отображения:

// 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);
    });
}

Стилизуйте контейнер субтитров с полупрозрачным тёмным фоном, скруглёнными углами и тонкой градиентной рамкой, соответствующей теме вашего сервера. Разместите его в нижней центральной части экрана с достаточными отступами, чтобы он не перекрывал миникарту или другие элементы HUD. Добавьте горячие клавиши, чтобы игроки могли быстро выбирать опции нажатием цифр, без кликов, что ощущается более естественно во время диалогов.

Интеграция системы квестов

Диалоговые системы становятся по-настоящему мощными при подключении к системе квестов. Дерево диалогов action поле в опциях ответа предоставляет точку подключения, где выполняется логика квеста. Когда игрок принимает миссию через диалог, обработчик действия должен создать запись квеста в журнале игрока, настроить необходимые путевые точки или цели и отслеживать прогресс через последующие взаимодействия в диалоге. Храните прогресс квеста в базе данных для каждого игрока, чтобы он мог отключиться и продолжить с того места, где остановился. Проектируйте квесты как конечные автоматы, где каждое состояние соответствует узлу диалога и набору целей, которые должны быть выполнены перед доступом к следующему диалогу:

-- 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,
}

Используйте этап квеста для динамического изменения доступных узлов диалога. Когда игрок возвращается к Tony после выполнения доставки, система диалогов проверяет этап квеста и показывает диалог завершения с наградой вместо начального приветствия. Это создаёт естественный поток разговора, где NPC признают прогресс игрока и реагируют соответственно, делая мир отзывчивым и живым.

Анимации и выражения NPC

Статичные NPC, которые стоят неподвижно во время разговора, выглядят роботами и нарушают погружение. Добавьте поддержку анимаций в вашу систему диалогов, чтобы NPC жестикулировали, выражали эмоции и реагировали во время бесед. В GTA V есть огромная библиотека словарей анимаций, охватывающая жесты, мимику и язык тела, которые можно запускать в определённые моменты диалога. Назначайте анимации на уровне узла, чтобы каждая строка диалога имела сопровождающий жест. Когда NPC сообщает хорошие новости, проигрывайте радостную анимацию маха рукой. Когда обсуждается что-то серьёзное, используйте строгую позу со скрещёнными руками. Для пауз между ответами игрока зацикливайте анимацию размышления или ожидания. Также можно использовать нативы лицевой анимации, такие как SetFacialIdleAnimOverride изменять выражение лица NPC в состоянии покоя, чтобы оно соответствовало настроению разговора, делая их счастливыми, злыми, испуганными или озадаченными. Комбинируйте анимации тела и лица для наиболее убедительных выступлений и всегда тестируйте анимации в игре, потому что некоторые словари анимаций выглядят по-разному на разных моделях пэдов, и то, что работает на мужском пэде, может обрезаться или выглядеть неудобно на женском пэде.

Производительность и лучшие практики

Система диалогов, создающая десятки NPC по всей карте, требует тщательного управления производительностью. Спавните NPC только когда игроки находятся в зоне рендеринга, обычно 50-100 метров, и удаляйте их, когда игроков поблизости нет. Используйте один поток для управления расстояниями спавна всех NPC, а не создавайте отдельные потоки на каждого, что значительно снижает нагрузку на клиент. Кэшируйте данные дерева диалогов при запуске ресурса, а не читайте файлы во время разговоров. Делайте переходы камеры плавными, но не создавайте и не уничтожайте камеры слишком часто, так как операции с камерой имеют заметную нагрузку. Когда несколько игроков взаимодействуют с одним NPC одновременно, каждый получает собственный экземпляр диалога, работающий независимо, то есть состояние диалога хранится на игрока, а не на сущности NPC. Очищайте все ресурсы диалога при отключении игрока или уходе во время разговора: уничтожайте камеру, освобождайте фокус NUI и восстанавливайте управление игроком. Тестируйте систему диалогов в худшем сценарии: игра игрока крашится во время диалога или он закрывает игру alt-F4 с активной камерой. Система должна обнаруживать такие случаи и корректно очищать ресурсы, чтобы не оставлять камеры или заблокированное управление при повторном подключении.

Готовы начать?

Возьмите скрипты в нашем магазине или заходите в Discord за поддержкой, обновлениями и анонсами.