Wróć do bloga
Tutorial10 min czytania

System dialogów NPC FiveM: Skryptowanie rozgałęzionych rozmów

Nadaj swoim NPC w FiveM prawdziwą osobowość. Drzewa dialogowe, linie głosowe, punkty zaczepienia zadań i najlepsze skrypty dla serwerów roleplay z głęboką fabułą.

Agency Scripts

Założyciel i główny deweloper w Agency Scripts

Dlaczego dialogi NPC mają znaczenie

Systemy dialogowe NPC przekształcają statyczne pedy w żywe postacie, które nadają serwerowi charakter i prowadzą graczy przez różne aktywności. Bez dialogów NPC są jedynie punktami interakcji otwierającymi menu. Dzięki odpowiedniemu systemowi dialogowemu stają się zleceniodawcami zadań, narratorami, wyrazistymi właścicielami sklepów i informatorami, którzy reagują różnie w zależności od reputacji lub pracy gracza. System dialogowy otwiera możliwości dla unikalnego lore serwera, rozgałęzionych wątków fabularnych i wyborów graczy wpływających na przyszłe interakcje. Pomyśl, o ile bardziej angażujące jest, gdy gracz podchodzi do mechanika NPC, który wita go po imieniu, komentuje stan jego auta i oferuje różne usługi zależnie od tego, czy to stały klient, czy nowy gość. Taki poziom immersji angażuje graczy na Twoim serwerze i daje im powody do eksploracji poza standardowymi pętlami grindu.

Struktura danych drzewa dialogów

Podstawą każdego systemu dialogów jest struktura danych definiująca przebieg rozmowy. Drzewo dialogowe składa się z węzłów, gdzie każdy węzeł zawiera tekst NPC oraz listę opcji odpowiedzi gracza, które prowadzą do innych węzłów. Taka struktura drzewa obsługuje rozgałęzione rozmowy, ścieżki warunkowe oparte na stanie gracza oraz pętle powracające do wcześniejszych punktów rozmowy. Zaprojektuj węzły dialogowe w sposób oparty na danych, aby administracja serwera mogła tworzyć nowe rozmowy bez dotykania kodu Lua. Oto praktyczny format drzewa dialogowego:

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

Ten condition funkcja w opcjach pozwala dynamicznie pokazywać lub ukrywać wybory na podstawie aktualnego stanu gracza. Opcja naprawy pojawia się tylko wtedy, gdy gracz przyjechał pojazdem, opcje związane z misjami pokazują się tylko wtedy, gdy gracz osiągnął odpowiedni etap, a opcje VIP mogą być ograniczone do graczy z określonymi uprawnieniami. Ta action pole wyzwala funkcje po stronie serwera po wybraniu określonej opcji, łącząc wybory dialogowe z konsekwencjami w rozgrywce.

Spawnowanie i zarządzanie NPC

NPC do dialogów muszą być niezawodnie spawnowani, umieszczani w stałych lokalizacjach i odporni na chaos panujący w świecie GTA. Podczas spawnowania dialogowego NPC musisz zażądać modelu, utworzyć peda, ustawić go jako mission entity, aby systemy czyszczenia GTA go nie usunęły, zamrozić jego pozycję, aby się nie oddalał, i uczynić go nieśmiertelnym, by gracze nie mogli zabić zleceniodawcy zadań. Użyj scentralizowanego menedżera NPC, który spawnuje pedy, gdy gracze są w pobliżu, i despawnuje je, gdy w zasięgu nie ma żadnego gracza, aby oszczędzać pamięć na obciążonych serwerach:

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

Połączenie SetBlockingOfNonTemporaryEvents i FreezeEntityPosition dba o to, aby zdarzenia otoczenia, takie jak pobliskie eksplozje, pościgi policyjne czy agresywni gracze, nie powodowały ucieczki, walki ani stanu ragdoll u Twojego NPC. Bez tych zabezpieczeń gracze mogliby zastać zleceniodawcę leżącego na ziemi i drgającego po potrąceniu przez przejeżdżający samochód, co całkowicie niszczy immersję, nad którą pracowałeś.

Kinowy system kamer

Praca kamery podczas dialogów zmienia zwykłą interakcję w menu w filmowe doświadczenie. Gdy rozpoczyna się dialog, utwórz kamerę skupioną na twarzy NPC z lekkim przesunięciem, wykorzystując głębię ostrości do rozmycia tła i skupienia uwagi na rozmowie. Przełączaj ujęcia kamery w miarę postępu rozmowy, pokazując gracza przy dokonywaniu wyboru i wracając do NPC, gdy ten odpowiada. Natywny system kamer w GTA daje pełną kontrolę nad pozycją, obrotem, polem widzenia i głębią ostrości:

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

Ten PointCamAtPedBone native jest szczególnie przydatny, ponieważ blokuje punkt skupienia kamery na głowie NPC bez względu na drobne ruchy animacji, zachowując spójny kadr przez całą rozmowę. Czas trwania przejścia w RenderScriptCams tworzą płynne przejścia kamery zamiast gwałtownych cięć, dlatego warto poeksperymentować z wartościami między 500ms a 1000ms, aby dobrać odpowiednie tempo dla swojego serwera.

System wyświetlania napisów

System napisów prezentuje tekst dialogowy NPC w atrakcyjny wizualnie sposób na dole ekranu, naśladując sposób wyświetlania rozmów w grach fabularnych. Zamiast wyrzucać cały blok tekstu naraz, wdróż efekt maszyny do pisania odkrywający znaki pojedynczo, co stwarza wrażenie, że NPC aktywnie mówi. Użyj NUI do wyświetlania napisów, ponieważ daje ci to pełną kontrolę CSS nad czcionkami, kolorami, animacjami i pozycjonowaniem. Wysyłaj tekst każdego węzła dialogowego do ramki NUI w momencie jego aktywacji, wraz z imieniem NPC i wszelkimi tagami emocji, które powinny wpływać na styl wyświetlania:

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

Ostyluj kontener napisów za pomocą półprzezroczystego, ciemnego tła, zaokrąglonych rogów i subtelnej ramki z gradientem pasującym do motywu Twojego serwera. Umieść go na dole na środku ekranu z odpowiednim paddingiem, aby nie nakładał się na minimapę ani inne elementy HUD. Dodaj skróty klawiszowe, aby gracze mogli naciskać klawisze numeryczne do szybkiego wybierania opcji bez klikania, co sprawia bardziej naturalne wrażenie podczas rozmowy.

Integracja z systemem questów

Systemy dialogowe zyskują prawdziwą moc po połączeniu z frameworkiem zadań. Drzewka dialogowego action pole w opcjach odpowiedzi zapewnia punkt zaczepienia, w którym wykonywana jest logika questu. Gdy gracz akceptuje misję poprzez dialog, action handler powinien utworzyć wpis questu w dzienniku zadań gracza, ustawić wszelkie wymagane waypoints lub cele oraz śledzić postępy poprzez kolejne interakcje dialogowe. Zapisuj postęp questu w bazie danych dla każdego gracza, aby mógł się rozłączyć i wznowić w miejscu, w którym przerwał. Projektuj questy jako maszyny stanów, gdzie każdy stan odpowiada węzłowi dialogu i zestawowi celów, które muszą zostać ukończone, zanim dostępny będzie kolejny dialog:

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

Użyj etapu zadania, aby dynamicznie modyfikować dostępne węzły dialogowe. Gdy gracz wróci do Tony'ego po zrealizowaniu dostawy, system dialogowy sprawdza etap zadania i wyświetla dialog końcowy z nagrodą zamiast początkowego powitania. Tworzy to naturalny przebieg rozmowy, w którym NPC zauważają postępy gracza i odpowiednio reagują, dzięki czemu świat wydaje się dynamiczny i żywy.

Animacje i ekspresje NPC

Statyczne NPC, które stoją całkowicie nieruchomo podczas rozmowy, wydają się zrobotyzowane i psują immersję. Dodaj obsługę animacji do swojego systemu dialogów, aby NPC gestykulowali, wyrażali emocje i reagowali podczas rozmów. GTA V ma ogromną bibliotekę słowników animacji obejmujących gesty, mimikę twarzy i mowę ciała, które możesz wyzwalać w określonych momentach dialogu. Przypisuj animacje na poziomie węzłów, aby każda linijka dialogu mogła mieć własny towarzyszący gest. Gdy NPC przekazuje dobre wieści, odtwórz wesołą animację machania ręką. Gdy omawia coś poważnego, użyj surowej pozy ze skrzyżowanymi ramionami. W momentach bezczynności między odpowiedziami gracza zapętl animację myślenia lub czekania. Możesz także użyć funkcji natywnych animacji twarzy, takich jak SetFacialIdleAnimOverride aby zmienić domyślny wyraz twarzy NPC, dopasowując go do nastroju rozmowy, sprawiając, że postać wygląda na szczęśliwą, złą, przestraszoną lub zdezorientowaną. Łącz animacje ciała i twarzy, aby uzyskać jak najbardziej przekonujący efekt, i zawsze testuj animacje w grze, ponieważ niektóre słowniki animacji wyglądają inaczej na różnych modelach pedów, a to, co działa na męskim pedzie, może przenikać lub wyglądać niezręcznie na żeńskim.

Wydajność i dobre praktyki

System dialogów spawnujący dziesiątki NPC na mapie wymaga starannego zarządzania wydajnością. Spawnuj NPC tylko wtedy, gdy gracze znajdują się w zasięgu renderowania, zwykle 50-100 metrów, i usuwaj ich, gdy w pobliżu nie ma graczy. Używaj jednego wątku do zarządzania dystansem spawnu wszystkich NPC zamiast tworzyć osobne wątki dla każdego z nich, co drastycznie zmniejsza obciążenie po stronie klienta. Cachuj dane drzew dialogowych przy starcie zasobu, zamiast czytać pliki w trakcie rozmów. Dbaj o płynne przejścia kamery dialogowej, ale unikaj nadmiernego tworzenia i niszczenia kamer, ponieważ operacje na kamerach generują zauważalny koszt wydajnościowy. Gdy wielu graczy wchodzi w interakcję z tym samym NPC jednocześnie, każdy gracz powinien otrzymać własną instancję dialogu działającą niezależnie, co oznacza, że stan dialogu musi być przechowywany per gracz, a nie na encji NPC. Czyść wszystkie zasoby dialogu, gdy gracz rozłączy się lub odejdzie w trakcie rozmowy: niszcz kamerę, zwalniaj fokus NUI i przywracaj sterowanie graczowi. Przetestuj system dialogów pod kątem najgorszego scenariusza: gra gracza crashuje w trakcie rozmowy lub gracz używa alt-F4 przy aktywnej kamerze. Twój system powinien wykrywać takie sytuacje i prawidłowo czyścić stan, aby zapobiec zawieszonym kamerom lub zablokowanemu sterowaniu po ponownym połączeniu.

Gotowy, aby zacząć?

Kup skrypty w naszym sklepie lub dołącz do Discorda, aby uzyskać wsparcie, aktualizacje i sprawdzić nadchodzące nowości.