Zurück zum Blog
Tutorial10 Min. Lesezeit

FiveM-NPC-Dialogsystem: Verzweigte Gespräche per Skript

Verleihe deinen FiveM-NPCs Persönlichkeit. Entdecke Dialogbäume, Sprachzeilen, Quest-Anknüpfungen und Top-Skripte für erzählerische Rollenspielserver.

Agency Scripts

Gründer und leitender Entwickler bei Agency Scripts

Warum NPC-Dialoge wichtig sind

NPC-Dialogsysteme verwandeln statische Peds in lebendige Charaktere, die deinem Server Persönlichkeit verleihen und Spieler durch Aktivitäten führen. Ohne Dialoge sind NPCs kaum mehr als Interaktionsziele, die Menüs öffnen. Mit einem ausgereiften Dialogsystem werden sie zu Auftraggebern, Geschichtenerzählern, Ladenbesitzern mit Charakter und Informanten, die je nach Ruf oder Job des Spielers unterschiedlich reagieren. Ein Dialogsystem eröffnet Möglichkeiten für serverspezifische Hintergrundgeschichten, verzweigte Handlungsstränge und Spielerentscheidungen, die künftige Interaktionen beeinflussen. Stell dir vor, wie viel fesselnder es ist, wenn ein Spieler zu einem Mechaniker-NPC geht, der ihn mit Namen begrüßt, den Zustand seines Autos kommentiert und verschiedene Dienstleistungen anbietet, je nachdem, ob er Stammkunde oder zum ersten Mal da ist. Diese immersive Erfahrung sorgt dafür, dass Spieler deinem Server treu bleiben, und gibt ihnen Gründe, über die üblichen Grind-Schleifen hinaus die Welt zu erkunden.

Datenstruktur für Dialogbäume

Die Grundlage jedes Dialogsystems ist die Datenstruktur, die den Gesprächsverlauf festlegt. Ein Dialogbaum besteht aus Knoten, wobei jeder Knoten den Text des NPCs und eine Liste von Antwortmöglichkeiten des Spielers enthält, die zu anderen Knoten führen. Diese Baumstruktur unterstützt verzweigte Gespräche, bedingte Pfade abhängig vom Spielerstatus und Schleifen, die zu früheren Gesprächspunkten zurückführen. Lege deine Dialogknoten datenbasiert an, damit Servermitarbeiter neue Gespräche erstellen können, ohne Lua-Code anzufassen. Hier ist ein praktisches Format für einen Dialogbaum:

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

Die condition Mit der Funktion „on options“ kannst du Auswahlmöglichkeiten dynamisch ein- oder ausblenden, abhängig vom aktuellen Status des Spielers. Eine Reparaturoption wird nur angezeigt, wenn der Spieler in einem Fahrzeug angekommen ist, questbezogene Optionen erscheinen nur, wenn der Spieler die richtige Stufe erreicht hat, und VIP-Optionen lassen sich auf Spieler mit bestimmten Berechtigungen beschränken. Das action Feld löst serverseitige Funktionen aus, wenn eine bestimmte Option ausgewählt wird, und verbindet so Dialogentscheidungen mit Spielergebnissen.

NPCs spawnen und verwalten

Dialog-NPCs müssen zuverlässig gespawnt, an festen Positionen platziert und vor dem Chaos geschützt werden, das die Welt von GTA auf sie loslassen kann. Beim Spawnen eines Dialog-NPCs musst du das Modell anfordern, den Ped erstellen, ihn als Mission Entity festlegen, damit GTAs Bereinigungssysteme ihn nicht löschen, seine Position einfrieren, damit er nicht davonwandert, und ihn unverwundbar machen, damit Spieler deinen Auftraggeber nicht töten können. Verwende einen zentralen NPC-Manager, der Peds spawnt, wenn Spieler in der Nähe sind, und sie despawnt, wenn sich keine Spieler in Reichweite befinden, um auf gut besuchten Servern Speicher zu sparen:

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

Die Kombination aus SetBlockingOfNonTemporaryEvents und FreezeEntityPosition sorgt dafür, dass Umgebungsereignisse wie nahe Explosionen, Verfolgungsjagden der Polizei oder aggressive Spieler deinen NPC weder zur Flucht noch zum Zurückschlagen oder Taumeln bringen. Ohne diese Schutzmaßnahmen könnten Spieler einem Auftraggeber begegnen, der nach einem Zusammenstoß mit einem vorbeifahrenden Auto am Boden liegt und zuckt. Das würde die Immersion, die du aufgebaut hast, vollständig zerstören.

Cinematic-Kamerasystem

Kameraarbeit während Dialogen hebt das Erlebnis von einer Menüinteraktion auf die Ebene eines filmischen Moments. Wenn ein Dialog beginnt, erstellst du eine Kamera, die mit leichtem Versatz auf das Gesicht des NPCs gerichtet ist. Nutze Tiefenschärfe, um den Hintergrund unscharf zu machen und den Fokus auf das Gespräch zu lenken. Wechsle im Gespräch zwischen verschiedenen Kamerawinkeln, indem du bei einer Auswahl zum Spieler und bei der Antwort zurück zum NPC schneidest. Das native Kamerasystem von GTA gibt dir volle Kontrolle über Position, Rotation, Sichtfeld und Tiefenschärfe:

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

Die PointCamAtPedBone native ist besonders leistungsstark, weil damit der Kamerafokus auf dem Kopf des NPCs bleibt, unabhängig von leichten Animationsbewegungen. So bleibt der Bildausschnitt während des gesamten Gesprächs gleichmäßig. Die Übergangsdauern in RenderScriptCams sorgen für weiche Kameraüberblendungen statt abrupter Schnitte. Experimentiere mit Werten zwischen 500ms und 1000ms, um das richtige Tempo für deinen Server zu finden.

Untertitelsystem

Ein Untertitelsystem zeigt den Dialogtext des NPCs ansprechend am unteren Bildschirmrand an, ähnlich wie in storybasierten Spielen. Statt den gesamten Textblock auf einmal einzublenden, solltest du einen Schreibmaschineneffekt verwenden, bei dem die Zeichen nacheinander erscheinen. Dadurch entsteht der Eindruck, dass der NPC aktiv spricht. Nutze NUI für die Untertitelanzeige, da du damit volle CSS-Kontrolle über Schriftarten, Farben, Animationen und Positionierung hast. Sende den Text jedes Dialogknotens zusammen mit dem Namen des NPCs und etwaigen Emotionstags an den NUI-Frame, sobald der Knoten aktiv wird:

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

Gestalte den Untertitelcontainer mit einem halbtransparenten dunklen Hintergrund, abgerundeten Ecken und einem dezenten Farbverlauf am Rand, der zum Design deines Servers passt. Positioniere ihn mit ausreichend Abstand mittig am unteren Bildschirmrand, damit er weder die Minimap noch andere HUD-Elemente überlagert. Füge Tastenkürzel hinzu, mit denen Spieler Optionen schnell über die Zifferntasten auswählen können, statt zu klicken. Das fühlt sich im Gespräch natürlicher an.

Quest-System-Integration

Dialogsysteme werden erst richtig leistungsstark, wenn sie mit einem Quest-Framework verbunden sind. Das Feld action bei Antwortoptionen im Dialogbaum bietet den Anknüpfungspunkt zur Ausführung der Quest-Logik. Wenn ein Spieler über einen Dialog eine Mission annimmt, sollte der Action-Handler einen Quest-Eintrag im Questlog des Spielers erstellen, erforderliche Wegpunkte oder Ziele einrichten und den Fortschritt über nachfolgende Dialoginteraktionen hinweg verfolgen. Speichere den Questfortschritt pro Spieler in der Datenbank, damit sie die Verbindung trennen und später dort weitermachen können, wo sie aufgehört haben. Entwickle Quests als Zustandsmaschinen, bei denen jeder Zustand einem Dialogknoten und einer Reihe von Zielen entspricht, die abgeschlossen werden müssen, bevor der nächste Dialog verfügbar wird:

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

Verwende die Queststufe, um dynamisch anzupassen, welche Dialogknoten verfügbar sind. Wenn ein Spieler nach Abschluss der Lieferung zu Tony zurückkehrt, prüft das Dialogsystem die Queststufe und zeigt einen Abschlussdialog mit Belohnung anstelle der anfänglichen Begrüßung. So entsteht ein natürlicher Gesprächsverlauf, in dem NPCs den Fortschritt des Spielers anerkennen und entsprechend reagieren. Dadurch wirkt die Welt reaktionsfähig und lebendig.

NPC-Animationen und Gesichtsausdrücke

Regungslose NPCs, die beim Sprechen vollkommen stillstehen, wirken mechanisch und zerstören die Immersion. Ergänze dein Dialogsystem um Animationsunterstützung, damit NPCs während Gesprächen gestikulieren, Emotionen zeigen und reagieren. GTA V verfügt über eine umfangreiche Bibliothek von Animations-Dictionaries mit Gesten, Gesichtsausdrücken und Körpersprache, die du an bestimmten Stellen im Dialog auslösen kannst. Weise Animationen auf Knotenebene zu, damit jede Dialogzeile eine passende Geste haben kann. Wenn der NPC gute Neuigkeiten überbringt, spiele eine fröhliche Winken-Animation ab. Wenn er etwas Ernstes bespricht, verwende eine strenge Pose mit verschränkten Armen. Für Pausen zwischen den Antworten des Spielers kannst du eine nachdenkliche oder wartende Animation in einer Schleife abspielen. Du kannst auch native Funktionen für Gesichtsanimationen wie SetFacialIdleAnimOverride verwenden, um den ruhenden Gesichtsausdruck des NPCs an die Stimmung des Gesprächs anzupassen. So wirkt er glücklich, wütend, ängstlich oder verwirrt. Kombiniere Körper- und Gesichtsanimationen für möglichst überzeugende Darstellungen und teste Animationen immer im Spiel, da manche Animations-Dictionaries auf verschiedenen Ped-Modellen anders aussehen. Was bei einem männlichen Ped funktioniert, kann bei einem weiblichen Ped zu Clipping führen oder unpassend aussehen.

Performance und Best Practices

Ein Dialogsystem, das Dutzende NPCs über die Karte verteilt spawnt, erfordert sorgfältiges Performance-Management. Spawne NPCs nur, wenn sich Spieler innerhalb der Rendering-Distanz befinden, normalerweise 50-100 Meter, und despawne sie, wenn sich keine Spieler in der Nähe befinden. Verwende einen einzigen Thread, um die Spawn-Distanzen aller NPCs zu verwalten, statt für jeden NPC einen eigenen Thread zu erstellen. Das reduziert den clientseitigen Overhead erheblich. Lade Dialogbaumdaten beim Start der Ressource in den Cache, statt Dateien während eines Gesprächs zu lesen. Halte Kameraübergänge im Dialog flüssig, aber erstelle und zerstöre Kameras nicht übermäßig oft, da Kameravorgänge messbare Performance-Kosten verursachen. Wenn mehrere Spieler gleichzeitig mit demselben NPC interagieren, sollte jeder Spieler eine eigene, unabhängig laufende Dialoginstanz erhalten. Der Dialogstatus muss daher pro Spieler und nicht auf dem NPC-Entity gespeichert werden. Räume alle Dialogressourcen auf, wenn der Spieler die Verbindung trennt oder das Gespräch vorzeitig verlässt. Zerstöre die Kamera, gib den NUI-Fokus frei und stelle die Spielersteuerung wieder her. Teste dein Dialogsystem mit dem schlimmsten möglichen Fall im Hinterkopf: Das Spiel des Spielers stürzt mitten im Dialog ab oder er beendet es mit Alt+F4, während eine Kamera aktiv ist. Dein System sollte solche Fälle erkennen und ordnungsgemäß bereinigen, damit nach dem erneuten Verbinden keine Kameras aktiv oder Steuerungen gesperrt bleiben.

Bereit loszulegen?

Hol dir die Scripts in unserem Shop, oder komm auf den Discord für Support, Updates und einen Blick auf das, was kommt.