العودة إلى المدونة
Tutorial10 دقيقة قراءة

نظام حوار NPC في FiveM: محادثات متفرعة في السكربت

امنح شخصيات NPC في FiveM الخاصة بك شخصية حقيقية. أشجار الحوار، خطوط الصوت، روابط المهام، وأفضل السكريبتات لخوادم لعب الأدوار العميقة المعتمدة على القصة.

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 على الخيارات تتيح لك إظهار أو إخفاء الاختيارات ديناميكيًا بناءً على حالة اللاعب الحالية. يظهر خيار الإصلاح فقط عندما يصل اللاعب في مركبة، وتظهر الخيارات المتعلقة بالمهمة فقط عندما يصل اللاعب إلى المرحلة المناسبة، ويمكن تقييد خيارات VIP للاعبين الذين لديهم أذونات معينة. ال action الحقل يشغل وظائف على جانب الخادم عند اختيار خيار معين، موصلًا اختيارات الحوار بنتائج اللعب.

ظهور وإدارة الشخصيات غير القابلة للعب

يجب أن يتم استدعاء حوارات 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 الخاص بك، أو مقاومته، أو سقوطه بشكل غير طبيعي. بدون هذه الحمايات، قد يواجه اللاعبون مُعطي المهام مستلقيًا على الأرض يرتعش بعد أن صدمته سيارة عابرة، مما يكسر الانغماس الذي عملت على إنشائه.

نظام الكاميرا السينمائية

يعمل استخدام الكاميرا أثناء محادثات الحوار على رفع التجربة من تفاعل قائمة إلى لحظة سينمائية. عند بدء الحوار، أنشئ كاميرا تركز على وجه 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 للخطوط، الألوان، الرسوم المتحركة، والتموضع. أرسل نص كل عقدة حوار إلى إطار 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 الأخرى. أضف اختصارات لوحة المفاتيح بحيث يمكن للاعبين الضغط على أزرار الأرقام لاختيار الخيارات بسرعة دون النقر، مما يشعر بأنه أكثر طبيعية أثناء تدفق المحادثة.

تكامل نظام Quest

تصبح أنظمة الحوار قوية حقًا عند ربطها بإطار مهام. شجرة الحوار 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

NPCs الثابتون الذين يقفون بلا حراك أثناء الحديث يشعرون بالجمود ويكسرون الانغماس. أضف دعم الرسوم المتحركة لنظام الحوار الخاص بك بحيث يقوم NPCs بالإيماء، التعبير، والتفاعل أثناء المحادثات. يحتوي GTA V على مكتبة ضخمة من قواميس الرسوم المتحركة التي تغطي الإيماءات، تعابير الوجه، ولغة الجسد التي يمكنك تشغيلها في نقاط محددة في الحوار. عيّن الرسوم المتحركة على مستوى العقدة بحيث يمكن لكل سطر حوار أن يكون له إيماءة مصاحبة خاصة به. عندما يقدم NPC أخباراً جيدة، شغل رسوم متحركة لموجة يد مفرحة. عندما يناقش شيئاً جاداً، استخدم وضعية الأذرع المتقاطعة الصارمة. للحظات السكون بين ردود اللاعب، كرر رسوم متحركة للتفكير أو الانتظار. يمكنك أيضاً استخدام natives للرسوم المتحركة الوجهية مثل SetFacialIdleAnimOverride لتغيير تعبير استراحة NPC ليتناسب مع مزاج المحادثة، مما يجعلهم يظهرون سعداء أو غاضبين أو خائفين أو مرتبكين. اجمع بين تحريك الجسم والوجه لأكثر العروض إقناعًا، واختبر دائمًا التحريكات داخل اللعبة لأن بعض قوام التحريك تبدو مختلفة على نماذج الشخصيات المختلفة، وما يعمل على شخصية ذكر قد يسبب تقطيعًا أو مظهرًا محرجًا على شخصية أنثى.

الأداء وأفضل الممارسات

نظام حوار يولد عشرات من NPCs عبر الخريطة يحتاج إلى إدارة أداء دقيقة. لا تولد NPCs إلا عندما يكون اللاعبون ضمن مسافة العرض، عادة 50-100 متر، وأزلهم عندما لا يكون هناك لاعبون قريبون. استخدم خيطًا واحدًا لإدارة جميع مسافات توليد NPC بدلاً من إنشاء خيوط منفصلة لكل NPC، حيث يقلل هذا بشكل كبير من الحمل على جانب العميل. خزّن بيانات شجرة الحوار عند بدء المورد بدلاً من قراءة الملفات أثناء المحادثات. حافظ على انتقالات كاميرا الحوار سلسة ولكن لا تنشئ أو تدمر الكاميرات بشكل مفرط لأن عمليات الكاميرا لها تكلفة أداء ملحوظة. عندما يتفاعل عدة لاعبين مع نفس NPC في نفس الوقت، يجب أن يحصل كل لاعب على مثيل حوار خاص به يعمل بشكل مستقل، مما يعني أن حالة الحوار يجب أن تُخزن لكل لاعب بدلاً من كيان NPC. نظف جميع موارد الحوار عندما ينفصل اللاعب أو يبتعد أثناء المحادثة، مدمراً الكاميرا، محرراً تركيز NUI، ومستعيدًا تحكمات اللاعب. اختبر نظام الحوار مع أسوأ السيناريوهات في الاعتبار: تعطل لعبة اللاعب أثناء الحوار، أو إغلاقه بالقوة أثناء تنشيط الكاميرا. يجب أن يكتشف نظامك هذه الحالات وينظفها بسلاسة لمنع بقاء الكاميرات أو تحكمات مقفلة عند إعادة الاتصال.

جاهز للبدء؟

احصل على السكربتات من متجرنا، أو انضم إلى Discord للدعم والتحديثات ونظرة على ما هو قادم.