返回博客
Tutorial4 分钟阅读

FiveM NPC对话系统:脚本分支对话

赋予您的 FiveM NPC 真实个性。对话树、语音台词、任务钩子及最佳脚本,适合深度剧情驱动的角色扮演服务器。

Agency Scripts

Agency Scripts 创始人兼首席开发者

为什么 NPC 对话很重要

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 function on 选项允许你根据玩家当前状态动态显示或隐藏选择。只有当玩家乘车到达时才显示修理选项,只有当玩家达到正确阶段时才显示任务相关选项,VIP 选项可以限制特定权限的玩家使用。 action 字段在选择特定选项时触发服务器端函数,将对话选择与游戏结果连接。

生成和管理NPC

对话NPC需要可靠生成,放置在固定位置,并且免受GTA世界混乱的影响。生成对话NPC时,需要请求模型,创建ped,将其设置为任务实体以防止GTA清理系统删除,冻结其位置以防走动,并使其无敌以防玩家杀死任务给予者。使用集中式NPC管理器,在玩家附近生成ped,无玩家范围内时销毁,以节省繁忙服务器的内存:

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

结合 SetBlockingOfNonTemporaryEventsFreezeEntityPosition 确保环境事件如附近爆炸、警察追逐或攻击性玩家不会导致您的NPC逃跑、反击或进入布娃娃状态。没有这些保护,玩家可能会遇到被路过车辆撞击后躺在地上抽搐的任务给予者,完全破坏您努力营造的沉浸感。

电影摄像机系统

对话期间的摄像头工作将体验从菜单交互提升为电影时刻。对话开始时,创建一个聚焦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 创建平滑的摄像机淡入淡出效果,而非突兀切换,建议尝试 500ms 到 1000ms 之间的值,以找到适合您服务器节奏的感觉。

字幕显示系统

字幕系统以视觉吸引的方式在屏幕底部呈现NPC对话文本,模仿剧情驱动游戏的对话显示。不是一次性显示整段文本,而是实现打字机效果,逐字显示,营造NPC正在主动说话的错觉。使用NUI显示字幕,因为它允许完全控制字体、颜色、动画和位置的CSS。每当对话节点激活时,将文本、NPC名字及任何情绪标签发送至NUI框架,影响显示样式:

// 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 传递好消息时,播放愉快的挥手动画;讨论严肃话题时,使用严肃的双臂交叉姿势。玩家回应间隙,循环思考或等待动画。也可使用面部动画 natives,如 SetFacialIdleAnimOverride 更改 NPC 的休息表情以匹配对话情绪,使其显得高兴、生气、害怕或困惑。结合身体和面部动画以获得最逼真的表现,并始终在游戏中测试动画,因为某些动画字典在不同的行人模型上表现不同,适用于男性行人的动画可能会在女性行人上出现剪裁或不自然。

性能与最佳实践

跨地图生成数十个 NPC 的对话系统需谨慎管理性能。仅在玩家处于渲染距离内(通常50-100米)生成 NPC,玩家远离时销毁。使用单线程管理所有 NPC 生成距离,避免为每个 NPC 创建线程,显著降低客户端开销。对话树数据在资源启动时缓存,避免对话中读取文件。保持对话摄像机切换流畅,但避免频繁创建销毁摄像机,因为摄像机操作有明显性能成本。多玩家同时与同一 NPC 互动时,每位玩家应拥有独立对话实例,状态存储于玩家而非 NPC 实体。玩家断开连接或中途离开对话时,清理所有对话资源,销毁摄像机,释放 NUI 焦点,恢复玩家控制。测试系统时考虑最坏情况:玩家游戏中途崩溃或 Alt-F4 退出时,系统应能检测并优雅清理,防止摄像机残留或控制锁定。

准备好开始了吗?

在我们的商店获取脚本,或加入 Discord 获取支持、更新以及新功能预告。