السلام عليكم ورحمة الله
تعرفنا في المقال السابق على كيفية عرض اللغة العربية في محرك الألعاب Unity، وناقشنا وحللنا عدة مشاكل واجهتنا..
ولكن بالطبع لن نقدم ألعبانا باللغة العربية فقط، فغالبًا ما نريد تقديمها بلغات متعددة..
لذلك، وكما وعدتكم، سنتعرف في هذا المقال على طريقة إضافة خيار للاعب ليقوم بتغيير لغة اللعبة إلى ما يناسبه، وسنبتكر حلول لتعديل الترجمات لكل نص بكل بسهولة، وخاصة بالعربية.
التخطيط والتحليل لبناء النظام البرمجي
نحن في العادة، عندما نحتاج لإضافة نص ما، نذهب إلى ال Text Component ونكتب فيها ما نريد باللغة التي نريدها.
ولدعم أكثر من لغة، ربما يخطر ببالنا أن نذهب إلى كل عنصر Text ونضيف ترجمات لكل لغة للنص، لكن هذا سيشتتنا كثيرًا إذا ما أصبحت اللعبة كبيرة نوعًا ما..
ما أقترحه هو أن نقوم بحفظ جميع النصوص المستخدمة باللعبة في مكان خارجي، مع ترجماتها لكل لغة مدعومة، ونعيّن رمزًا أو مفتاحًا مميزًا لكل نص..
ثم لكل عنصر Text، نعيّن المفتاح الخاص به، وعند بدء اللعبة، نحضر النص المرافق لهذا المفتاح، على حسب اللغة المختار حاليًا.
ولجعل النظام مرتبًا أكثر، سأضيف خاصية الأقسام، أي أنه لكل قسم في اللعبة، سيكون هناك ملف يحفظ النصوص وترجماتها الخاصة به. (خطوة اختيارية)
هذه الرسمة توضح طريقة حفظ البيانات بشكل سلس:
يمكننا أن نستنتج أننا نحتاج إلى صنف (class) ليحوي معلومات كل نص، وهذه المعلومات هي المفتاح والترجمات.
ثم سنحتاج إلى ملف برمجي، نعطيه المفتاح المطلوب، ليعطينا ترجمة النص المرافق للمفتاح على حسب اللغة المختارة.
وبالطبع، هذا الملف البرمجي سيكون المسؤول عن تحديد اللغة الحالية المختارة، أو اللغة الافتراضية.
هذه هي العناصر الأساسية، لنبدأ بالملف البرمجي المنظم لهذه العمليات.
كتابة الملف البرمجي: مدير توطين النصوص (تعدد اللغات)
في هندسة البرمجيات، هناك ما يسمى ب DesignPattern أو أسلوب ومنهج للتصميم ، وهو عبارة عن ترتيب معد مسبقًا لنظام برمجي، بحيث يكون معتمدًا عليه من قبل المبرمجين بأنه يحل مشكلة معينة.
أحد أشهر أنماط التصميم المستخدمة في برمجة الألعاب هو Singelton أو المفرد، حيث يكون لدينا نسخة أو كائن واحد، فقط واحد، من class معين في البرنامج بأكمله.
بالنسبة لحالتنا، سنستفيد من هذا لأنه، منطقيًا، لن ندعم استخدام أكثر من لغة في نفس الوقت.
وأيضًا، هذا الأسلوب يجعل ملف النصوص سهل الوصول من قبل أي كائن في اللعبة – حيث أنني سأعرف الكائن instance كنسخة مشتركة static، لذا لن أحتاج إلى تقديم مرجع له لكل عنصر Text في اللعبة.
أليس ال Singelton منهج سيء للتصميم؟
هناك بعض المخاوف المتعلقة باستخدام هذا الأسلوب، حيث إن الكثير قد يستعمله بكثرة وبغير مواضعه، ولكن في حالتنا هذه تحديدًا، فاستخدامه لا بأس به.
من سلبياته هو أنه عند تعريف كائن او متغير يمكن الوصول إليه من أي مكان في المشروع، قد تصل إليه بعض العناصر التي هي بالأصل منفصلة عنه تمامًا،
مثل كائن يريد تشغيل صوت عند اصطدام، قد يقوم بالوصول لمشغل الأصوات، إذا كان مصمم ك singelton، وتشغيل الصوت من داخل ملف لبرمجة الفيزياء.
(بالإضافة إلى سلبيات أخرى مثل الوصول والتعديل من أكثر من معالج CPU)
ولكن في حالتنا، نحن نريد الوصول إلى النصوص من أي مكان ممكن.. ولا يهمنا ما إذا وصلتَ إليه، أي ال singelton، من ملف برمجي للفيزياء أو للإضاءة، لأنه في النهاية مجرد حافظ للبيانات وليس فيه الكثير من الخصائص لتعديلها..
وبخاصة أنه بنفسه لا يقوم بأي تأثير على أي كائن في اللعبة بشكل مباشر أو غير مباشر.
لنطبقه:
بداخل مشروع Unity، أنشأت ملفًا برمجيًا سميته LocalizationsMaster، بداخل namespace Localization، وطبقت أسلوب المفرد أولاً:
namespace Localization { public class LocalizationsMaster : MonoBehaviour { //النسخة المشتركة للملف البرمجي static LocalizationsMaster instance; //دالة لاسترجاع هذه النسخة من أي مكان في المشروع public static LocalizationsMaster getInstance() { return instance; } void Awake() { //إذا لم تكن النسخة معرفة بعد، فهذا يعني أن هذا الملف //الوحيد الموجود في المشروع if(instance == null) { instance = this; } else { //وجود أكثر من نسخة يعني أنه تم قراءة عنصر من الملف //سابقًا، لذا دمر هذا العنصر الجديد Destroy(this); } //جعل الملف يعيش طوال حياة المشروع DontDestroyOnLoad(gameObject); } } }
لاحظ أن الملف سيبقى نفسه موجودًا في كل المشاهد scenes، وهذا ما تفعله دالة DontDestroyOnLoad.
لذلك يجب عليك التأكد أنه موجود من أول مشهد في اللعبة.
في نفس الملف، لنعرّف Enum للغات المتوفرة في مشروعنا، وبعض الخواص للغة المختارة:
namespace Localization { public class LocalizationsMaster : MonoBehaviour { //خاصية اللغة المختارة حاليًا //لاحظ أنه لا يمكن تغييرها من خارج الملف البرمجي public Language CurrentLanguage { get; private set; } //تحديد اللغة بناءً على اللغة الافتراضية للجهاز؟ public bool useSystemLangauge; ... void Awake() { ... if (useSystemLangauge) { //قم بتعيين اللغة المختارة حسب اللغة الافتراضية //..لكن فقط إذا كانت اللغة متوفرة لدينا. if (Application.systemLanguage == SystemLanguage.English) CurrentLanguage = Localization.Language.English; else if(Application.systemLanguage == SystemLanguage.Arabic) CurrentLanguage = Localization.Language.Arabic; else CurrentLanguage = Localization.Language.English; } } } //اللفات التي سندعمها في هذا المقال، يمكنك إضافة قدر ما تريد public enum Language { English, Arabic } }
لنضيف القدرة على تغيير اللغة الحالية من الخارج، وهذا ما تفعله الدالة التالية:
... public class LocalizationsMaster : MonoBehaviour { ... //ستفيدنا هذه لتعيين اللغة من عناصرالواجهة public void SetLanguageString(string language) { if (language.Contains("Arabic")) CurrentLanguage = Localization.Language.Arabic; else { CurrentLanguage = Localization.Language.English; } } public void SetLanguageEnum(Language language) { CurrentLanguage = language; } ... } ...
أضفت الملف إلى كائن جديد في المشهد الأساسي في المشروع:
والآن لكي نطبّق دالة استرجاع النص المطلوب حسب المفتاح المقدّم، علينا أولاً حفظ بيانات النصوص في مكان ما، أجل، هذا ما يذكرني بالميزة المفضلة لدي في محرك Unity..
لنستكشف الـScriptableObject
كيف سنحفظ البيانات؟
إذا كانت لديك تجربة سابقة في المحرك، قد تتسائل لما لا أقوم فقط بتعريف مصفوفة من صنف يحتوي على حقول للمعلومات المطلوبة، وتعبئتها من ال Inspector.
السبب هو أنه في هذه الحالة، سيصعب التعامل مع البيانات وتعديلها، فهي ستكون موجودة فقط في الكائن الذي سنضيف إليه الملف البرمجي LocalizationsMaster
وماذا إذا أردنا إنشاء عدة أقسام للترجمات، مثلاُ قسم للواجهة الرئيسية وقسم للمرحلة الأولى وقسم للثانية وهكذا – هل سنضيف هذه الأقسام جميعها إلى ذاك الكائن؟
قد تقترح حفظ البيانات في ملفات خارجية، ولكن تحميل هذه الملفات من الذاكرة إلى اللعبة سيأخذ وقتًا وذاكرة أكبر من ما نحتاجه حقًا.
خواص ال ScriptableObject
الScriptableObject عبارة عن وعاء للبيانات يمكن استخدامه لحفظ مقادير ضخمة منها، أو كما يٌعَرفه هذا المستند.
يتم ذلك عن طريقة تعريف ملف برمجي يرث من هذا الصنف (ScriptableObject)، وإضافة الحقول والخصائص المطلوبة، ثم إنشاء نسخ من هذا الملف.
هذه النسخ هي ما ستحتوي على البيانات، والمميز فيها أنها تُنشئ وتُعَبّئ بشكل مستقل عن الملف البرمجي الأساسي، حيث تعتبر موارد في المشروع.
ومن أهم الأسباب لاستخدامه كأداة لحفظ البيانات، 1) تخفيف استهلاك الذاكرة 2) سهولة إنشاء مراجع للنسخ في أي ملف في المشروع.
وهو ما يُلبي احتياجتنا بشكل مثالي.
لنبني ملفنا البرمجي لحفظ البيانات
قمت بإنشاء ملف برمجي جديد، سميته LocalizationSO:
using UnityEditor; using UnityEngine; using Localization; //هذه التعليمة تساعدنا في إنشاء نسخ من هذا الملف //حيث تعرّف عنصر جددي في قائمة Create //كما سنشاهد في الخطوة التالية [CreateAssetMenu(fileName = "Localized Strings",menuName = "Localization")] //يجب أن يرث الملف البرمجي من ScriptableObject بدلاً من MonoBehaviour public class LocalizationSO : ScriptableObject { //مصفوفة معلومات النصوص [SerializeField] //هذه التعليمة تجعل unity قادر على تحميل وعرض المتغير في المحرر inspector KeyString[] strings; //صنف لمعلومات النص، يحتوى على المفتاح والترجمات [System.Serializable]//هذه التعليمة تجعل Unity قادر على التعامل مع الصنف المخصص وحفظه في ال metaData public class KeyString { public string key; public LocalizedText[] localizations; } //صنف لكل ترجمة للنص // يحتوي على النص وتعريف بلغته [System.Serializable] public class LocalizedText { //لكي نكتب نصوص بأكثر من سطر (3 كحد أقصى) [Multiline(3)] public string localizedString; public Language language; } //دالة لاسترجاع النص حسب المفتاح واللغة الحالية //لاحظ أن صنف ال ScriptableObject يعمل كأي ملف برمجي //حيث الدوال والمتغيرات المعرّفة يمكن استعمالها لأي نسخة منشئة منه public string GetString(string key,Localization.Language language) { for (int s = 0; s < strings.Length; s++) { //نبحث في المصفوفة الأساسية عن المفتاح المطلوب if (strings[s].key == key) { //للترجمات المعرفة لهذا المفتاح، نحصل على التي تناسب اللغة الحالية for (int l = 0; l < strings[s].localizations.Length; l++) { if (strings[s].localizations[l].language == language) { return strings[s].localizations[l].localizedString; } } //بحال لم نجد ترجمة لهذا النص باللغة الجالية return "Language: " + language + "Not Found, For Key: " + key; } } //وبحال لم نجد المفتاح من الأساس :) return "Key: " + key + " Not Found"; } }
كما حددنا سابقًا، يحتوي الملف على الحقول الأساسية المطلوبة، ويستخدم ال Enum الذي أضفناه في الخطوة الأولى.
عبارة CreateAssetMenu في أعلى الملف تجعل عملية إنشاء نسخة من ال ScriptableObject سهلة جدًا.
فقط، بعد حفظ الملف، نذهب في واجهة المحرر إلى Assets -> Create -> Localization :
والآن نضغط على النسخة التي تم إنشائها، ونلقي نظرة على ال Inspector (قمت بتغيير اسم النسخة إلى MainSectionLocalization)
هنا سنضيف النصوص مع مفاتيحها وترجماتها لكل لغة، وذلك عن طريق إضافة عناصر للمصفوفة الأساسية.
ولكل عنصر نحدد المفتاح، ثم نضيف عناصر للمصفوفة المتعششة (الأبناء)، مع كتابة النص وتحديد لغته.
هكذا أصبحت النسخة لدي بعد تعبئتها ببعض النصوص للتجربة:
لا تقلق من موضوع عرض اللغة العربية، فقد تعودنا على ذلك، سنتجاهل المشكلة حاليًا ونحلها في آخر قسم من المقال.
تطبيق الترجمات على النصوص في اللعبة:
في مدير توطين النصوص، LocalizationsMaster، سنقوم بإضافة مرجع لنسخة الترجمات.
ولكنني، وكما ذكرت سابقًا، سأرتب النسخ بحيث أعين واحدة لكل قسم أو مشهد في اللعبة، ثم عند الاسترجاع، نبحث عن النص في النسخة للقسم المخصص. وهذا القسم المخصص يتم تعيينه من قبل عنصر النص Text.
public class LocalizationsMaster : MonoBehaviour { [SerializeField] SectionLocalizedTexts[] sectionsLocalizedText; //صنف لكل قسم، يحتوي تعريف Enum للقسم //ونسخة ال ScriptableObject التي تحتوي على النصوص والترجمات [System.Serializable] public class SectionLocalizedTexts { public TextSections textSection; public LocalizationSO localizationScriptableObject; } ... public string GetText(string key, TextSections textSection = TextSections.MainUI) { for(int s =0; s < sectionsLocalizedText.Length; s++) { if (sectionsLocalizedText[s].textSection == textSection) return sectionsLocalizedText[s].localizationScriptableObject.GetString(key, CurrentLanguage); } return sectionsLocalizedText[0].localizationScriptableObject.GetString(key, CurrentLanguage); } //نضيف هنا جميع الأقسام التي نريدها في اللعبة //أو نستخدم فسم واحد فقط، إذا لم تعجبنا فكرة الأقسام. public enum TextSections { MainUI } }
نذهب إلى الكائن الذي أضفنا إليه الملف البرمجي LocalizationsMaster سابقًا، ونقوم بتعبئته كما يلي:
الآن علينا تطبيق النصوص على عناصر Text الموجودة في اللعبة، أو على عناصر TextMeshPro، وهو ما سأطبق عليه هنا، ولكن الطريقة نفسها تعمل لل Text الأساسي.
سأفعل ذلك عن طريق إضافة ملف برمجي جديد إلى عنصر TextMeshPro UGUI موجود في المشهد.
سيأخذ هذا الملف متغير المفتاح، نقوم بتحديده من خصائصه على الكائن، ثم بداخل الملف عند بداية اللعبة، نحصل على النص المرافق لهذا المفتاح ونعينه للعنصر.
وفي الحالة الخاصة التي تكون اللغة فيها عربية، نقوم بإصلاح النص باستخدام الملف البرمجي FixArabicTMProUGUI الذي كتبناه في المقال عن اللغة العربية.
لذا تأكد من وجوده في مشروعك (أحضره من هنا في قسم الملحقات.)
الملف البرمجي LocalizeTextMPro:
أنشأت ملفًا برمجيًا جديدًا سميته LocalizeTextMPro، سيكون المسؤول عن ترجمة النصوص في العناصر TMProUGUI:
using UnityEngine; using Localization; using TMPro; //فقط لأنني لا أحب كتابة أسماء طويلة، اختصرت الاسم هنا ^-^ using LM = Localization.LocalizationsMaster; //يجب أن يحتوى الكائن على عنصر TextMeshProUGUI [RequireComponent(typeof(TextMeshProUGUI))] public class LocalizeTextMPro : MonoBehaviour { //حقول للمفتاح والقسم الخاص بالنص [SerializeField] string StringKey; [SerializeField] Localization.TextSections textSection; private string requiredText; TextMeshProUGUI tmproUGUI; void Start() { tmproUGUI = GetComponent<TextMeshProUGUI>(); //نحصل على النص المطلوب من مدير التوطين، حسب المفتاح والقسم //المحددين. requiredText = LM.getInstance().GetText(StringKey, textSection); //نعرض النص في العنصر tmproUGUI tmproUGUI.text = requiredText; CheckArabicLanguage(); } //الحالة الخاصة التي تكون اللغة فيها عربية - أو حتى فارسية، إذا أردنا. private void CheckArabicLanguage() { if (LM.getInstance().CurrentLanguage == Language.Arabic) { //هذا هو الملف البرمجي الذي كتبناه في المقال السابق //تأكد من وجوده في مشروعك. //هنا نتأكد من وجوده في الكائن if (!GetComponent<FixArabicTMProUGUI>()) { gameObject.AddComponent<FixArabicTMProUGUI>(); } //هذه الدالة هي المسؤولة عن إعدادالنص المراد إصلاحه وسنضيقها إلى الملف FixArabicTMProUGUI في الخطوة التالية GetComponent<FixArabicTMProUGUI>().UpdateText(requiredText); } //إذا لم تكن اللغة عربية، أزل هذا الملف، لأنه سيؤذي النص والأداء else if (GetComponent<FixArabicTMProUGUI>()) Destroy(GetComponent<FixArabicTMProUGUI>()); } }
لدعم العنصر Text، فقط نقوم باستدعاء الدالة UpdateText نفسها للملف SetArabicFixedText (من المقال نفسه) ونعطها النص المطلوب.
الآن اذهب إلى المحرر لهذا العنصر، وقم بتعيين المفتاح والقسم:
ثم جرب تشغيل اللعبة، سترى النص يملئ بالنص المحدد في نسخة ال LocalizationSO حسب اللغة الافتراضية للجهاز:
بناء واجهة لاختيار اللغة وتحديث عناصر النصوص باستخدام Events:
سوف أختصر شرح عملية بناء الواجهة، يمكنني التوسع فيها في مقال لاحق، وفي المقال هنا ستجد عدد من المصادر التعليمية، من بينها شروحات للواجهات.
أنشأت زرين، أحدهم لاختيار اللغة العربية والآخر للإنجليزية، واستخدمت خاصية الأحداث في كل منهما لاستدعاء دالة من الكائن الذي يحتوي على الملف LocalizationsMaster
في مشروعي، سميت هذا الكائن ب Localization، ومرّرته إلى الحدث OnClick لكلا الزرين، ثم استخدمت الدالة SetLangaugeString من الملف LocalizationsMaster
أخيرًا، مرّرت الكلمة Arabic لزر اللغة العربية، و English لزر الإنجليزية.
كل هذا موضح بالصورة الآتية:
انتهينا، أليس كذلك؟
ليس هذا فقط ما عنيته بالأحداث أو Events.. فبعد تغيير اللغة، يجب أن نخبر جميع عناصر النصوص الموجودة والمفعّلة حاليًا بتغيير نصها حالاً إلى الترجمة باللغة الجديدة.
ولفعل ذلك، سأستخدم منهج التصميم Observer أو EventQueue.
دون ان أخوض في تفاصيله، فهي لمقال آخر، يساعدنا هذا الأسلوب في إخبار الكائنات أو العناصر بأن حدث أو تغيير ما قد حصل، ليقوموا بالاستجابة له.
يمكن التفكير به كأننا ننشئ محطات الراديو، هذه المحطات هي ال events، من يهتم بها يسجل الاشتراك،
عن طريق ال listener، ليسمع أي تسجيل نرسله بشكل عام،
التطبيق: في نفس الملف المدير LocalizationsMaster، وبداخل ال namespace أو فضاء الاسم Localization..
نضيف التعديلات الآتية والـclass الجديد LocalizationEvents الآتي:
namespace Localization { public class LocalizationsMaster : MonoBehaviour { //داخل دالة SetLanguageString المعرفة مسبقًا: public void SetLanguageString(string language) { .... LocalizationEvents.CallChangeLanguage(CurrentLanguage); } } ... //كلاس جديد مختص بالـEvents //عرفته كمشترك لكي تبقى نسخة واحد فقط في المشروع public static class LocalizationEvents { //Delegate تقوم بتوظيف دالة مكان الحدث، أي أنه عندما يُستدعى الحدث، يمكن سماعه باستخدام دالة. public delegate void LanguageEvent(Language language); //الحدث المشترك الذي أستدعيه عند تغيير اللغة //لاحظ أن نوعه LanguageEvent //مما يعني أنه سيكسب الخصائص المعرفة في الأعلى لها. public static event LanguageEvent LanguageChanged; //نمرر اللغة الجديدة لهذه الدالة عند تغيير اللغة public static void CallChangeLanguage(Language newLanguage) { //إذا كان هناك أحد ما مشترك بالحدث، قم باستدعائه LanguageChanged?.Invoke(newLanguage); } } ... }
لكي تشترك عناصر النصوص في هذا الحدث، نذهب إلى الملف البرمجي LocalizeTextMPro ونقوم بهذه التعديلات:
... void Start() { //الاشتراك في الحدث. بمعنى آخر، الاستماع لكل ما يصدر منه. Localization.LocalizationEvents.LanguageChanged += LanguageChanged; } private void LanguageChanged(Language language) { requiredText = LM.getInstance().GetText(StringKey, textSection); tmproUGUI.text = requiredText; CheckArabicLanguage(); } ...
تنبيه عن فقد المرجع عند تدمير الكائن LocalizeTextMPro
والنتيجة
الآن بعد تشغيل اللعبة قم بتجربة تغيير اللغة:
رائع، من دون اتباع أسلوب تسلسل الأحداث، كنّا اضطررنا إلى البحث عن كل عنصر في المشهد وإخباره بالتغيير.
للتعرف على المزيد عن استخدامه في برمجة الألعاب، يمكنك قراءة هذا الجزء من الكتاب المجاني Game Programming Patterns.
العودة لقضية اللغة العربية:
الخطوة الأولى: التعديل على النص المعروض
نستطيع أن نقوم بإضافة محرر نص آخر، Text Area، أسفل محرر النص الأصلي، الذي نكتب فيه الترجمات لكل لغة.
ثم بعد إضافته، نتأكد ما إذا كان العنصر في المصفوفة هو ترجمة للغة العربية، لنقوم بإصلاح النص المكتوب في محرر النص الأول، وتطبيقه على محرر النص الجديد.
للقيام بهذه العملية، من الأفضل لنا استعمال ال CustomEditor وال PropertyDrawers.
الأول يُستعمل لإنشاء محررات بشكل مخصص، والثاني للتعديل على طريقة عرض عناصر من صنف مخصص معين في جميع أنحاء المشروع، وهو ما سنستخدمه هنا.
هذه هي النتيجة النهائية:
بداخل الملف البرمجي LocalizationSO، الخاص بال ScriptableObject، نقوم في الأسفل بتعريف class جديد:
... //هذه التعليمة تخبر المحرك بأن هذا الكلاس مخصص للصنف //LocalizationSO.LocalizedText [CustomPropertyDrawer(typeof(LocalizationSO.LocalizedText))] //يجب أن نرث من PropertyDrawer لنعدل على طريقة العرض public class LocalizedTextEditor : PropertyDrawer { //متغير لحفظ قيمة الارتفاع الافتراضية float height = EditorGUIUtility.singleLineHeight; //هذه الدالة سيتم استدعائها لكل عنصر في المصفوفة، نقوم هنا بإعادة ///كتابتها لنغير الارتفاع حسب ما نحتاج public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { } //نسخة مسؤولة عن تحديد خواص رأسية الخصائص GUIContent title = new GUIContent(); //هذه الدالة هي ما تقوم بالعرض، وبداخلها سنقوم بتكوين العناصر حسب //ما نرغب public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { } }
هذه الدوال موجودة في الأصل، وهي ما سنستخدمها للتعديل على طريقة عرض العناصر.
بالنسبة للدالة OnGUI:
... // نحضر القيم المطلوبة : النص واللغة SerializedProperty localizedText = property.FindPropertyRelative("localizedString"); SerializedProperty language = property.FindPropertyRelative("language"); //نحفظ النص كمتغير نصي (أجل :3 string correctedArabicText = localizedText.stringValue; if (language.enumValueIndex == (int)Language.Arabic) { //نقوم بإصلاح النص إذا كان للغة العربية //انبته هنا أننا لم نعين قيمته بعد التصحيح لمحرر النص بعد correctedArabicText = ArabicSupport.ArabicFixer.Fix(localizedText.stringValue, true, false); } //للعنوان، نختصره إذا كان طويلاً title.text = correctedArabicText.Length > 10 ? correctedArabicText.Substring(0, 10) + "..." : correctedArabicText; //نبدأ بالخاصية //تسمح لنا هذه الدالة بعرض الخواص الموجودة في العناصر في //الواجهة بكل سهولة label = EditorGUI.BeginProperty(position, title, property); //مبدئيًا، نحدد الارتفاع بالقيمة الافتراضية position.height = EditorGUIUtility.singleLineHeight; //نضيف القدرة على إظهار وإخفاء العناصر //القيمة الأخيرة تحدد ما إذا كنا نستطيع الضغظ على الاسم للإخفاء //والإظهار property.isExpanded = EditorGUI.Foldout(position, property.isExpanded, label,true); ...
قمنا هنا بتجهيز المعلومات والعناصر، بقي علينا عرضها بالترتيب الصحيح، لنكمل:
... //فقط إذا كانت العناصر ظاهرة if (property.isExpanded) { //نحرك كل عنصر في المصفوفة إلى اليمين قليلاً EditorGUI.indentLevel = 10; //الصنف Rect هو المسؤول عن أبعاد العناصر //هنا نقوم فقط بتحريكه للأسفل، ونعين الارتفاع كثلاثة أضعاف //الارتفاع الافتراضي (أي ثلاث سطور //تذكر أن الزيادة الموجبة الرأسية تحرك العنصر للأسفل //أجل، هذه أحد غرائب برمجة الواجهات Rect localizedRect = new Rect(position.x, position.y + height * 1f + 4f, position.width, height * 3f); //نقوم بعرض خاصية النص بالأبعاد المحددة //هذه هي فائدة استخدام دالة EditorGUI.BeginProperty EditorGUI.PropertyField(localizedRect, localizedText, new GUIContent("Localized String")); //نقوم بالعملية ذاتها لعنصر اختيار اللغة //لاحظ الفرق في موقع الإزاحة الرأسية Rect languageRect = new Rect(position.x, position.y + height * 4f + 6f, position.width, height); EditorGUI.PropertyField(languageRect, language, new GUIContent("Language")); //والآن الخطوة الأهم، للغة العربية: if (language.enumValueIndex == (int)Language.Arabic) { //أبعاد جديدة لعنصر عرض النص المصحح //نفس الموقع الأفقي //وإنزاله للأسفل أكثر Rect arabicCorrectedLabel = new Rect(position.x , position.y + height * 5f + 8f, localizedRect.width, height * 3f); //بعض خواص التنسيق البسيطة GUIStyle RightAlign = EditorStyles.textArea; RightAlign.alignment = TextAnchor.MiddleCenter; //إنشاء خاصية لعرض النص //Selectable تعني أنه يمكن نسخ النص، ولكن لا يمكن التعديل عليه //لاحظ هنا أنني الآن مرّرت النص المصحح EditorGUI.SelectableLabel(arabicCorrectedLabel, correctedArabicText, RightAlign); } } EditorGUI.EndProperty(); //نقوم بتطبيق التغييرات على الواجهة property.serializedObject.ApplyModifiedProperties(); //نهاية الدالة ...
جميل، لنجرب الآن الذهاب إلى المحرر، من المفترض أن تعمل من دون مشاكل:
نسيت أمرًا مهمًا، أتذكرون الدالة GetPropertyHeight التي تكلمنا عنها سابقًا؟ إنها هي المسؤولة عن تحديد الارتفاع،
وهي السبب في أنه لا يكفي للعناصر، فالارتفاع الافتراضي فقط 16px. لذلك يجب علينا تعديلها.
داخل الدالة GetPropertyHeight:
... //إذا لم يتم الضغظ على عنصر المصفوفة لعرض محتواه if (!property.isExpanded) return EditorGUIUtility.singleLineHeight; //نبحث عن خاصية اللغة لعنصر المصفوفة الحالي SerializedProperty language = property.FindPropertyRelative("language"); //إذا كانت اللغة العربية، نقوم بزيادة الارتفاع بما يعادل 9 أضعاف القيمة الافتراضية //وهذا لأن طول محرر النص 3، مع طول اختيار اللغة 1، ومحرر النص الآخر 3، و1 للعنوان يصبحوا 8 if (language.enumValueIndex == (int)Language.Arabic) return EditorGUIUtility.singleLineHeight * 8f + 6f; //وإلا، فلن نعرض محرر النص الثاني، لذا ننقص 3 قيم للارتفاع، مع مسافة قليلة بين القيم else return EditorGUIUtility.singleLineHeight * 5f + 4f; ...
رائع، أصبحت تعرض بطريقة صحيحة:
يمكننا الآن التعديل على النص الأول، ثم مشاهدة النص الآخر يتأقلم ويتغير ويُعرض بشكل صحيح دائمًا :).
يمكننا أيضًا الوقوف هنا، فقد أصبح الوضع أفضل.
لكنني أفضّل الاعتماد على طريقة أخرى..
الخطوة الثانية (اختيارية) : الاستيراد من ملف XML
لم يعجبني التعديل على النصوص من داخل محرك Unity، وبخاصة أنني لم أستطع التخلص من مشكلة اللغة العربية بالكامل.
لذا ما سأقوم به هو بناء ملف خارجي بصيغة ال XML، يحتوى على جميع النصوص لقسم معين.. ثم سأقوم بإضافته إلى نسخة ال ScriptableObject.
سيتم ذلك عن طريق الضغط على زر في واجهة المحرر لل Scriptable Object، عندها سنقوم بأخذ البيانات من الملف ثم تطبيقها على المصفوفات.
ما هو ال XML؟
صيغة لحفظ البيانات والمعلومات ونقلها. اسمها يأتي من eXtensible Markup Language أو لغة الترميز القابلة للتوسع، حيث تعتمد على الأوصاف tags التي تكتب بداخل الأقواس <>، وهي تشبه اللغة HTML كثيرًا من ناحية طريقة تركيب “الجُمل” فيها، ولكنها مختلفة تمامًا من ناحية توجهها وفائدتها. إحدى الفروق المميزة هي أن الأوصاف فيها غير محددة مسبقًا، حيث يمكنك اختراع أو كتابة أسماء للأوصاف من عندك (وهذا ما يجعلها مناسبة جدًا لحفظ البيانات)
سأقوم أولاً بتحديد بنية الملف، وهي في الحقيقة بالضبط مثل الصيغة التي استخدمناها عند برمجة الأصناف (classes):
<Localization> <Key name="Text Key"> <English>- English Text</English> <Arabic>-النص بالعربية </Arabic> </Key> </Localization>
أنشأت ملفًا جديدًا، مع الصيغة .xml في النهاية، وأضفته إلى المشروع..
ثم بعد إضافة المعلومات إلى الملف، أصبح كالآتي لديّ:
<?xml version="1.0" encoding="utf-16"?> <Localization> <Key name="Welcome"> <English>- Hey there, how are you?</English> <Arabic>- مرحبًا، كيف حالك؟</Arabic> </Key> <Key name="StartGame"> <English>Start Game</English> <Arabic>ابدأ اللعبة</Arabic> </Key> <Key name="Options"> <Arabic>الإعدادات</Arabic> </Key> </Localization>
لا، لم أخطئ في القيمة الأخيرة، أردت إضافة لعة واحدة فقط لمفتاح ال Options لكي أرى ماذا سيحدث.
كل ما علينا فعله هو تحويل هذه البيانات إلى صيغة يفهمها محرك Unity، ليقوم بدوره بتحويلها إلى نسخ من الأنواع KeyString و LocalizedText.
يجب أولاً أن نحصل على مرجع للملف، وسنفعل ذلك عن طريق استخدام ال Text Asset Class.
في الملف البرمجي LocalizationSO، نضيف هذا التابع في الأعلى:
... [SerializeField] TextAsset XmlOfStrings; ...
لنأخذ النص من الملف سنستخدم عناصر ودوال ال XML الأساسية في اللغة C#.
الدالة الآتية تقوم باستخراج جميع بيانات ال XML، وتقوم بتحويلها إو إصافتها إلى المصفوفات من الأنواع التي نريدها:
[ExecuteInEditMode] //يجب إضافة هذه التعليمة لكي تعمل الدالة من دون //تشغيل اللعبة public void GetStringsFromXML() { //إذا لم يتم تعيين أي ملف في ال inspector if (XmlOfStrings == null) return; //ننشئ نسخة من XmlDocument ونعطيها نص الملف XmlDocument xMLDocument = new XmlDocument(); xMLDocument.LoadXml(XmlOfStrings.text); if(xMLDocument == null) {//إذا حدث خطأ ما لسبب ما (قد يكون الملف غير مخصص لل xml) Debug.LogError("Not Found: " + XmlOfStrings.name); return; } //هذه العقدة تقع بعد وصف ال Localization، وهي التي تحتوي على عناصر //النصوص XmlNode MainXmlNode = xMLDocument.ChildNodes.Item(1); //نعرف مصفوفة جديدة بعدد العناصر الموجودة في الملف KeyString[] newStrings = new KeyString[MainXmlNode.ChildNodes.Count]; int currCount = 0; //لكل عنصر نص في المستند foreach (XmlNode xmlNode in MainXmlNode.ChildNodes) { //نتأكد أنه يحتوي على المفتاح if(xmlNode.LocalName == "Key") { KewStrings[currCount] = new KeyString(); //نحصل على المفتاح newStrings[currCount].key = xmlNode.Attributes.GetNamedItem("name").Value; int index = 0; //نبني مصفوفة للترجمات بعدد الأبناء المتوفرة newStrings[currCount].localizations = new LocalizedText[xmlNode.ChildNodes.Count]; //لكل ابن موجود foreach (XmlNode localizedVersions in xmlNode) { Language language; //نحاول تحويل اللغة إلى النوع Enum المخصص للغات //إن لم تنجح هذه العملية فهذا يعني أن اللغة غير مدعومة //ويجب إضافتها لل Enum if(!Enum.TryParse( localizedVersions.LocalName, out language)) { return; } newStrings[currCount].localizations[index] = new LocalizedText(); //نحضر النص ونعينه newStrings[currCount].localizations[index].localizedString = localizedVersions.InnerText; //نعين اللغة newStrings[currCount].localizations[index].language = language; index++; } currCount++; } } //نبدل المصفوفة الموجودة بالجديدة التي بنيناها strings = newStrings; }
ثم سننشئ Custom Editor خاص بملف ال LocalizationSO، نضيف في زرًا يستدعي الدالة عند الضغط عليه:
[CustomEditor(typeof(LocalizationSO))] public class LocalizationSOEditor : UnityEditor.Editor { public override void OnInspectorGUI() { //ال target هي نسخة الملف البرمجي المرتبط بهذا المحرر LocalizationSO localizationSO = (LocalizationSO)target; //ننشئ زرًا جديدًأ if (GUILayout.Button("Get Items From XML Document")) { //نستدعي الدالة التي كتبناها للتو //(تأكد أن هذه التعليمة موجودة في أعلاها [ExecuteInEditMode]) localizationSO.GetStringsFromXML(); } base.OnInspectorGUI(); serializedObject.ApplyModifiedProperties(); } }
والنتيجة 🙂
أصبح بإمكاننا الآن نقل البيانات من الملف إلى نسخة ال LocalizationSO ، بعدها يمكننا الاستغناء عن ملف ال XML، فالنسخة لم تعد تحتاج له، بما أن البيانات قد حفظت فيها !
الملحقات
جميع الملفات البرمجية التي كتبناها تجدها هنا، مع بعض الإضافات.
ختامًا، أتمنى أن أكون قد وفيت في الشرح، وأن أكون قد حققت الإفادة المرجوّة. ولا تردد في سؤالي في التعليقات إذا ما واجهتك مشكلة معينة.