Android XR

تحسين التجارب الغامرة باستخدام محرّكات ثلاثية الأبعاد في Jetpack Compose للواقع الممتد

سماعات

لمحة

لمحة عن المحاضرة

تعرَّف على كيفية دمج محرّكات الألعاب الثلاثية الأبعاد (Godot وUnity) مع Jetpack Compose في قاعدة رموز برمجية واحدة للأجهزة الجوّالة وAndroid XR. سنستكشف كيفية تضمين مشاهد ثلاثية الأبعاد في حاويات مكانية، وإعداد الاتصال بين Kotlin والمحرك، مع إلقاء نظرة حصرية على كيفية إنشاء NBA لتجربة كرة السلة المباشرة على Android XR.

ملخّص

  • تتيح إضافة OpenXR Spatial Containers لمحرّكات العرض الثلاثي الأبعاد مشاركة المساحة مع واجهة مستخدم Android الأصلية. بدلاً من الاستحواذ على السماء، تعمل محرّكات الوقت الفعلي داخل مساحة ثلاثية الأبعاد مُدارة إلى جانب اللوحات المكانية في Jetpack XR.
  • يمكن للمطوّرين تضمين محرّكات الوقت الفعلي مباشرةً في تطبيقات Jetpack Compose كمكتبات AAR. يتيح هذا الأسلوب لنظام Android الأساسي معالجة منطق النشاط التجاري وواجهة المستخدم بينما يعرض المحرّك المكوّنات الثلاثية الأبعاد.
  • يتطلب تضمين Godot تبعية AAR وقاعدة إصدار AAPT للاحتفاظ بمواد العرض المخفية. بعد ذلك، تعالج فئة فرعية من GodotPlugin الاتصال الثنائي الاتجاه مع Kotlin.
  • يجب أن تظلّ حلقات واجهة المستخدم في Compose منفصلة عن حلقات تكرار المحرّك المستمرة. تؤدي إعادة توجيه تحديثات المحرّك بشكل عشوائي إلى إعادة إنشاء مستمرة وتأخُّر في واجهة المستخدم.
  • يدمج تطبيق NBA منصة Unity كمكتبة مضمّنة في عملية منفصلة باستخدام لغة AIDL للتواصل بين العمليات، ما يحمي التطبيق المضيف من الأعطال.

الموارد

Gemini Notebook

المواضيع

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • اتّحاد

النص

تنزيل النص

مقدمة حول Android XR

  • مرحبًا،

  • اسمي "لوك هوبكنز".

  • أنا مهندس علاقات المطوّرين في فريق Android XR.

  • في حديثنا اليوم، سنتناول كيفية تضمين محرّكات الألعاب، مثل Godot وUnity، في مشروع Android.

  • سينضم إليّ لاحقًا "جوش" و"رودريغو" من NBA وBeyond Sports لمناقشة كيفية تنفيذ Unity كمكتبة في تطبيق NBA.

  • في هذه الجلسة، سنتعمّق في كيفية دمج Godot في تطبيق Jetpack Compose، ثم سيستعرض "جوش" و"رودريغو" تطبيق NBA.

  • ‫Android XR هو امتداد لمنظومة Android المتكاملة، يوسّع نطاق مجموعة كبيرة من أشكال الأجهزة لتقديم تجارب مألوفة ومحبوبة في بُعد جديد.

  • لقد سهّلنا عليك نقل تطبيقاتك الحالية المتوافقة مع الواقع الممتد وAndroid إلى هذه المساحة الجديدة، ونستفيد من تكنولوجيات Android الحالية التي تستخدمها حاليًا في تطوير تطبيقاتك.

  • ‫Android XR هو نظام أساسي موحّد مصمّم لمجموعة كبيرة من الأجهزة.

  • تتيح أجهزة Android XR أنواعًا مختلفة من التجارب.

  • لدينا تجارب معزَّزة للصوت والنظارات بشاشة عرض، ولدينا تجارب غامرة لسماعات الرأس والنظارات السلكية بنظام الواقع الممتد.

  • اليوم، سنتناول كيفية إنشاء تجارب غامرة للنظارات السلكية وسمّاعات الرأس التي تعمل بنظام الواقع الممتد.

  • يمكن إنشاء تجارب غامرة من خلال حزمة تطوير البرامج (SDK) لـ Jetpack XR أو باستخدام OpenXR مع محرّك في الوقت الفعلي.

  • نحن نتيح استخدام Unity وGodot وUnreal لكي تتمكّن من إنشاء تجارب غامرة لنظام التشغيل Android بغض النظر عن الأداة التي تستخدمها.

  • في هذه الجلسة، سأشرح كيف يمكنك الاستفادة من واجهة برمجة التطبيقات SceneCore في Jetpack، إلى جانب محرّك Godot وإضافة OpenXR الجديدة، لتحقيق ذلك.

  • يمكنك هنا رؤية Godot يعمل جنبًا إلى جنب مع لوحات Jetpack المكانية التي تعمل في الوقت نفسه.

  • لاحظ كيف يتم عرض Godot كمحتوى مكاني، ما يوفّر حجمًا ثلاثي الأبعاد إلى جانب واجهة مستخدم Compose ثنائية الأبعاد داخل حاوية مكانية.

  • يتيح هذا الأسلوب للمطوّرين تشغيل عمليات محاكاة فيزيائية وبرامج تظليل مخصّصة باستخدام محرّك الوقت الفعلي الذي يختارونه، مع تطبيق ممارسات تطوير Android الأصلية على بقية التطبيق.

  • باستخدام Android XR وXREAL AURA، يمكنك أيضًا إنشاء تجارب مثل هذه.

  • في هذه الجلسة، سنتعلّم كيفية إنشاء هذه التجربة باستخدام Godot و"استوديو Android"، ثم سنستمع إلى شركائنا في NBA وBeyond Sports حول كيفية إنشائها باستخدام Unity كمكتبة.

الحاويات المكانية

  • في السابق، كان إنشاء تجربة واقع ممتد باستخدام "OpenXR" يعني إنشاء تجربة تعرض كل بكسل.

  • هذا يعني أنه إذا احتاج المطور إلى عرض نشاط ثانٍ، فلن يتمكن من الحصول على كليهما في وقت واحد.

  • لحلّ هذه المشكلة، أصدرت مجموعة العمل OpenXR في Khronos، بالتعاون مع العديد من الشركات الرائدة في المجال، إضافة OpenXR Spatial Containers.

  • يمكنك اعتبار "الحاويات المكانية" بمثابة OpenXR في صندوق.

  • بدلاً من أن يمتلك محرّكك صندوق السماء بالكامل، يتم منحه مساحة ثلاثية الأبعاد يديرها النظام ويمكن أن تعمل جنبًا إلى جنب مع اللوحات المكانية في Jetpack XR والحاويات المكانية الأخرى وأنشطة Android العادية.

  • لنفترض أنّك تريد الاستفادة من Android XR من خلال عرض لوحتَين مكانيتَين في آنٍ واحد أثناء تشغيل لعبة غامرة.

  • يتم عرض كل شيء في المحرّك، ما يتيح في حالات الاستخدام هذه تشغيل محرّر Godot وحاوية مكانية في الوقت نفسه.

  • اعتبارًا من المستوى 4 من واجهة برمجة التطبيقات Spatial API، نفّذت Google ميزة "الحاويات المكانية" في Android XR.

  • تتيح "الحاويات المكانية" وضعَي عرض مهمَّين.

  • الأول هو الوضع المحدود.

  • يوفّر ذلك حجمًا ثلاثي الأبعاد محدّدًا مع العديد من ميزات التجنّب المضمّنة.

  • يمكن للمستخدم الإمساك بالحاوية المكانية وتدويرها وتحديد موضعها وتصغيرها، كما أنّها تتيح عرض العمق المجسّم، ما يمنحك نافذة سحرية أو تأثيرًا ثلاثي الأبعاد يظهر أثناء اقتصاص حواف الحاوية أيضًا.

  • والثاني هو الوضع غير المحدود.

  • إذا قرّر المستخدم أنّه يريد تجربة غامرة بالكامل، يمكن لتطبيقك طلب الانتقال بسلاسة من وضع المساحة المحدودة إلى وضع المساحة الكاملة، ثم يمكنك أيضًا العودة إلى وضع المساحة المحدودة.

  • من خلال إضافة الحاويات المكانية، يفتح Android XR الباب أمام أفكار جديدة وواسعة النطاق.

  • لنفترض أنّك تريد إدراج واجهة مستخدم Android في لعبتك، أو إنشاء تظليل مخصّص لعرض محتوى ثلاثي الأبعاد في تطبيقك.

  • قبل طرح ميزة "الحاويات المكانية"، كان من المستحيل تشغيل كل من Compose ومحرّك ألعاب في الوقت نفسه.

  • وعند النظر إلى تطوير التطبيقات في محركات الوقت الفعلي، فقد عشنا بشكل فعال في نموذجين مختلفين أيضًا.

  • بالنسبة إلى مطوّري تطبيقات Android الأصلية، يتم إنشاء وقت تشغيل التطبيق حول Jetpack Compose.

  • وهو مصمَّم لتوفير واجهة مستخدم سريعة ومتجاوبة، وكفاءة في استهلاك البطارية، وتنسيقات ديناميكية، وإمكانية وصول إلى النظام، وإدخال نص سلس.

  • وتكون في وضع الخمول عندما لا يحدث أي شيء، ويمكن توقّع سلوكها.

  • بالنسبة إلى مطوّري محرّكات الألعاب، فإنّ المجال هو العرض في الوقت الفعلي، حيث تتألق أدوات مثل Godot وUnity.

  • ولا تنتظر فقط تفاعل المستخدم.

  • وتشغّل حلقة مستمرة من التحديثات والعرض.

  • ويمكن أن يكون ذلك بمعدّل 60 أو 90 أو 100 لقطة في الثانية.

  • وتُجري هذه المحركات العمليات الحسابية الفيزيائية، وتُنشئ تظليلات معقّدة، وتُصمّم الرسوم المتحركة الهيكلية، وتُنتج الصوت المكاني، وكل ذلك بأداء مذهل.

طُرق دمج محرّكات العرض الثلاثي الأبعاد

  • إذًا، ماذا يحدث إذا أردت الاستفادة من أفضل ما في العالمين؟

  • في السابق، كان على المطوّرين تنفيذ أحد هذين النهجَين.

  • الخيار الأول هو إعادة كتابة تطبيقك بالكامل داخل محرّك حقيقي مثل Godot أو Unity.

  • يعني التخلّي عن قاعدة الرموز البرمجية الحالية فقدان الكثير من واجهة مستخدم Compose الأصلية، وإعادة تنفيذ النص الأساسي والتنقّل، وكتابة برامج تضمين حول واجهات برمجة تطبيقات Android، مثل تحويل النص إلى كلام.

  • الخيار (ب) هو إنشاء مسار عرض رسومات ثلاثية الأبعاد منخفض المستوى من البداية داخل تطبيقك الأصلي باستخدام واجهات برمجة التطبيقات Vulkan وOpenXR الأولية.

  • بدلاً من تقديم ميزات المنتج، أنت تبني محرّكك الخاص.

  • لذا، يمكنني اليوم أن أوضّح لك كيفية الاستفادة إلى أقصى حدّ من Godot وUnity كمكتبتَين.

  • ومع إضافة الحاويات المكانية، يمكنك الآن الاستفادة من أفضل ما في الميزتين.

  • ويمكننا تحقيق ذلك من خلال تضمين محرّك في الوقت الفعلي مباشرةً داخل مكتبة في تطبيق Jetpack.

  • يمكنك الاحتفاظ ببنية تطبيق Android في مرحلة الإنتاج، ومنطق النشاط التجاري، والعرض السلس للمحتوى المكاني الثلاثي الأبعاد داخل تطبيقك.

  • وهذا يعني أيضًا أنّه يمكنك استخدام حزمة APK واحدة لجميع أشكال الأجهزة.

  • إذا كنت لا تريد عرض محتوى ثلاثي الأبعاد فقط، يمكنك اختيار عدم تحميل المكتبة في النشاط أو الجزء والحفاظ على وضوح قاعدة الرموز البرمجية، ما يتيح لك إتاحة العديد من الأجهزة.

  • الفلسفة واضحة، وهي فصل التطبيق وجعل المحرك في الوقت الفعلي مكتبة.

  • بدلاً من الاختيار بين تطبيق Android أو محرّك، يمكنك التعامل مع المحرّك كمكتبة برامج مضمّنة، تُعرف أيضًا باسم تبعية AAR.

  • يمكنك سحب هذه المكتبة مباشرةً إلى إصدار Gradle، تمامًا مثل أي مكتبة أخرى.

  • يظل رمز Android الأصلي هو العقل المطلق للتطبيق الذي يستضيف المكوّنات الثلاثية الأبعاد.

  • تتولى Jetpack Compose XR تنفيذ المهام التي تجيدها، مثل عرض واجهة مستخدم سلسة ومتاحة للجميع وإدارة حالة التطبيق.

  • وعند الحاجة إلى عنصر ثلاثي الأبعاد، يستضيف التطبيق طريقة عرض مضمّنة يتم عرضها من خلال محرّك اللعبة نفسه.

  • سنتعمّق اليوم في تفاصيل آلية عمل هذه الميزة في محرّك Godot.

  • سأبدأ بآليات تضمين Kotlin وإنشاء اتصال ثنائي الاتجاه بينها وبين GDScript.

  • بعد ذلك، سنلقي نظرة على إضافة OpenXR الجديدة، وهي Spatial Container، التي ستتيح لك الاستفادة من الجيل الجديد من ميزات تعدّد المهام والتنسيق المكاني.

  • لنلقِ نظرة سريعة على العرض التوضيحي الذي عرضته من قبل.

  • لقد أنشأت هذه اللعبة باستخدام Godot و"استوديو Android".

  • يعرض هذا المقطع Jetpack Compose للواقع الممتد الذي يعرض لوحات مكانية لواجهة مستخدم ثنائية الأبعاد أصلية للوحة النتائج والإشعارات وأهم اللحظات.

  • بالإضافة إلى ذلك، يعرض Godot محاكاة لمباراة كرة قدم.

التواصل بين Kotlin وGodot

  • يمكنك هنا الاطّلاع على تفاصيل حول كيفية التواصل من Kotlin إلى GDScript.

  • للقيام بذلك، يوفر Godot بنية المكونات الإضافية.

  • يمكنك كتابة فئة Kotlin خفيفة الوزن تعمل على توسيع GodotPlugin.

  • تعمل هذه الفئة كواجهة بين جهاز Java الافتراضي ووقت تشغيل C++‎ في Godot.

  • لهذا السبب، تكون التفاعلات بين تطبيقك وGodot خفيفة جدًا.

  • تتيح بنية Godot الاتصال ثنائي الاتجاه بين Godot وKotlin.

  • عند حدوث حدث في لعبة على Godot، مثل هدف أو خطأ، يمكن لـ Godot استدعاء دوال Kotlin مباشرةً وتعديل واجهة مستخدم Compose في الوقت الفعلي.

  • بعد ذلك، عندما يتفاعل المستخدم مع مشغّل الوسائط، لنفترض مثلاً لإيقاف المحتوى مؤقتًا أو اختيار لقطات مميّزة مختلفة، تُرسل Kotlin إشارة إلى Godot، ويتم تنفيذ إجراءات محرّك اللعبة المقابلة.

  • بعد تجاوز مكوّن Godot الإضافي، يندرج الأداء والإدارة ببساطة ضمن نطاقات النطاق.

  • اتّبِع أفضل الممارسات العادية لتطبيقات Android وأفضل الممارسات العادية لمشاريع Godot.

  • سنلقي الآن نظرة على البنية.

  • لنتعرّف على كيفية ربط هذه الميزة بمحرّك Godot في تطبيق Android.

  • يتم توزيع وقت تشغيل محرّك Godot عبر Maven كملف AAR.

  • ويتم الدمج في الإصدار باتّباع ممارسات Android العادية.

  • هناك خطوة مهمة يجب الانتباه إليها على الفور، وهي ما أسميه فخ مواد العرض.

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • في مشروع Godot، يتم تخزين المشاهد وملفات الإعدادات معًا.

  • وعند تضمين Godot، تتم إضافة مشروع Godot مباشرةً داخل مجلد مواد العرض.

  • هذا يعني أنّ جميع الشبكات الثلاثية الأبعاد المستورَدة والأنسجة المضغوطة والمجمَّعة يتم تخزينها كعناصر وقت التشغيل داخل ملف مخفي باسم ‎ .godot.

  • سيحذف Android تلقائيًا هذا المجلد عند إنشاء التطبيق، لذا سيتجاهل أي دليل يحتوي على نقطة.

  • لذلك، إذا قمت بمزامنة تطبيقك وإنشائه بشكل طبيعي، فسيتم إنشاء APK، ولكنه سيتعطل ويتم تحميله لأن هذه الأشياء لم يتم تجميعها في APK.

  • لحلّ هذه المشكلة، يمكنك إلغاء خيارات AAPT بشكل صريح داخل ملف build.gradle والسماح بظهور النقاط البادئة من Godot.

  • بعد ذلك، يمكنك تشغيل مشهد Godot مرة واحدة في محرِّر Godot، ومن هناك يمكنك إنشاء كل شيء آخر من "استوديو Android".

  • لإتاحة الحاويات المكانية وأحدث إصدار من OpenXR، أنصح باستخدام Godot 4.8 والإصدارات الأحدث، وإضافات مورّدي Godot 5.2، وأداة تحميل Khronos OpenXR 1.1.63.

    dependencies {
        // Godot 4.8 Android Library + Godot OpenXR Vendors plugin + Khronos OpenXR Loader
        implementation("org.godotengine:godot:4.8.0.dev-SNAPSHOT")
        implementation("org.godotengine:godot-openxr-vendors-androidxr:5.1.0-stable")
        implementation("org.khronos.openxr:openxr_loader_for_android:1.1.63")
    
        // Jetpack XR SDK (Compose for XR, SceneCore, Runtime)
        implementation("androidx.xr.compose:compose:1.0.0-beta02")
        implementation("androidx.xr.scenecore:scenecore:1.0.0")
        implementation("androidx.xr.runtime:runtime:1.0.0")

عرض Godot في Jetpack Compose

  • بعد تجميع مواد العرض، كيف يتم عرضها في Godot وCompose؟

  • توفّر مكتبة Godot طريقة مصنع تُسمى onInitRenderView.

    @Composable
    fun GodotView(godotHost: GodotHost, modifier: Modifier = Modifier) {
        // 1. Initialize Godot's native render surface
        val renderView = remember(godotHost) {
            godotHost.godot!!.onInitRenderView(godotHost)
        }
        // 2. Host the SurfaceView inside Jetpack Compose
        AndroidView(
            modifier = modifier,
            factory = { renderView }
        )
    }
  • يؤدي ذلك إلى عرض SurfaceView يعرضه محرّك Godot فقط.

  • لذا، يمكنك تضمين ذلك في دالة مركّبة، ويمكن الآن تغيير حجم Godot وإضافة مساحة متروكة له ووضعه في أي مكان داخل لوحة الكيانات.

    Subspace {
            SpatialPanel(
                modifier = SubspaceModifier
                    .width(960.dp)
                    .height(540.dp)
                    .offset(z = (-200).dp)
                    .movable()
    ,
        ) {
            GodotView(
                godotHost = godotHost,
                modifier = Modifier.fillMaxSize(),
            )
        }
    }
  • لا يمكن للمحرّك نفسه إدارة مراحل النشاط.

    @Composable
    fun GodotView(
       godotHost: GodotHost,
       modifier: Modifier = Modifier,
       lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    ) {
    
       DisposableEffect(lifecycleOwner, godotHost) {
           val observer = LifecycleEventObserver { _, event ->
               val engine = godotHost.godot ?: return@LifecycleEventObserver
               when (event) {
                   Lifecycle.Event.ON_START -> engine.onStart(godotHost)
    ….
                   Lifecycle.Event.ON_DESTROY -> engine.onDestroy(godotHost)
                   else -> {}
               }
           }
    
           lifecycleOwner.lifecycle.addObserver(observer)
    
           onDispose {
               lifecycleOwner.lifecycle.removeObserver(observer)
           }
       }
    
    }
  • لذلك، يجب أن ينفّذ نشاطك واجهة GodotHost.

  • سيضمن ذلك إرسال أي من دورات الحياة التي تحدث ضمن نشاطك، سواء كانت متوقفة مؤقتًا أو مستأنفة، إلى محرّك Godot نفسه، والآن أصبح على دراية بدورة الحياة.

  • بعد ذلك، علينا إضافة تطبيقنا الأصلي ومحرّكنا للتواصل بسلاسة.

  • لإجراء ذلك، يمكننا استخدام واجهة GodotHost.

    class MainActivity : ComponentActivity(), GodotHost {
        override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
            setOf(SoccerGodotPlugin(engine))
    }
  • يمكننا تحديد مكوّن Godot الإضافي، وهنا يمكننا التأكّد من أنّه عند بدء تشغيل Godot، يتم تسجيل المكوّن الإضافي من جانب Compose.

  • بعد تسجيل المكوّن الإضافي، لنُعِدّ Android للتواصل من خلال إرسال إشارة إلى Godot.

  • في رمز Kotlin، سننشئ مثيلاً من GodotPlugin.

  • سنطلب من نشاطنا تسجيل المكوّن الإضافي في مكتبة Godot.

  • بعد ذلك، في Kotlin، ننشئ GodotPlugin ونسجّل اسمه ونُعلم Godot بجميع الإشارات التي نريد استخدامها ضمن هذه الحزمة.

    class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
        override fun getPluginName() = "SoccerPlugin"
        // 1. Signals sent from Android Compose UI -> Godot
        override fun getPluginSignals() = setOf(
            SignalInfo("camera_mode_changed", String::class.java),
            SignalInfo("match_command", String::class.java),
        )
    
    }
  • بعد ذلك، لدينا نص Godot Script، حيث يمكنك الحصول على مثيل لهذا المكوّن الإضافي.

    var soccer_plugin: Object = null
    
    func _ready() -> void:
        if Engine.has_singleton("SoccerPlugin"):
            soccer_plugin = Engine.get_singleton("SoccerPlugin")
    
            # Listen for signals from Jetpack Compose UI
            soccer_plugin.connect("camera_mode_changed", _on_camera_mode_changed)
            soccer_plugin.connect("match_command", _on_match_command)
  • يمكننا الاطّلاع على ما تم تسجيله، ثم يمكننا ربط الدوال بالإشارات التي سجّلناها.

  • بعد استدعاء دالة مثل emitSignal، يمكنك تمريرها من رمز Kotlin البرمجي، وستنتقل مباشرةً إلى Godot.

    class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
    
        fun sendCommandToGodot(command: String) {
            emitSignal("match_command", command)
        }
    }
  • لكي يتواصل محرّك Godot مع Kotlin، لنفترض أنّ هدفًا قد تحقّق، يمكننا استدعاء دالة Kotlin مباشرةً من نص Godot البرمجي، وذلك باستخدام مثيل المكوّن الإضافي فقط.

    func _on_goal_scored(minute: int, home: int, away: int) -> void:
    
        if soccer_plugin:
            soccer_plugin.onMatchEvent("GOAL", minute, home, away)
  • لذا، عليك أولاً إضافة التعليق التوضيحي UsedByGodot إلى دالة Kotlin.

    @UsedByGodot
    fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {
    
            _uiState.update { state ->
                state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
            }
        }
  • يتولّى المحرّك معالجة JNI بشكل غير مرئي.

  • ويمكننا بعد ذلك استدعاء الدالة مباشرةً من Godot Script.

  • هناك نصيحة سريعة يجب تذكّرها عند استخدام هذه الدالة، وهي أنّ الدالة UsedByGodot تتطلّب مطابقة تامة للسلسلة.

  • لذلك، إذا كان نص Godot البرمجي يستدعي onMatchEvent، يجب أن يكون اسم Kotlin متطابقًا تمامًا، وبالتالي لن يتم تحويله تلقائيًا إلى تنسيق Camel Case أو تنسيق snake case.

  • لذا، عليك فقط التأكّد من أنّ الطريقتَين متطابقتان تمامًا.

  • بعد ربط المكوّن الإضافي، نصل إلى الجزء الأكثر أهمية في هذه المحادثة، وهو ما أسمّيه النموذج الذهني.

  • هنا تعمل محرّكات الألعاب وJetpack Compose على بنيتَين مختلفتَين تمامًا.

  • يعمل محرّك الألعاب على أساس معدّل إطارات مستمر.

  • سواء غيّرت أي شيء أم لا، تتم عملية التحديث في محرّك اللعبة 60 أو 90 أو 100 مرة في الثانية، ما يوفّر عرضًا سلسًا وغنيًا في الوقت الفعلي.

  • وتستفيد محرّكات مثل Godot من دورات وحدة معالجة الرسومات ووحدة المعالجة المركزية بشكل فعّال.

  • من ناحية أخرى، يتعامل Jetpack Compose مع الأمور بشكل تفاعلي ويعتمد على الحالة.

  • وعندما لا يتغيّر التطبيق، يستمر Compose في العمل حرفيًا، ويستهلك 0 وحدة معالجة مركزية.

  • إذا كنت تريد ربط هذه العناصر لتشغيل بيئتي تشغيل بدون استراتيجية واضحة، فإن هذه الفلسفات تميل إلى التصادم.

  • لذا، دعونا نرى كيف- دعونا نرى كيف نتغلب على هذه المشكلة من خلال ما أسميه تسرب القراد.

  • هنا لدينا تجربة ثلاثية الأبعاد، لنقل مؤقت لوحة النتائج.

  • في نص المحرك، هناك متغير مؤقت يتغير باستمرار.

  • وإذا أرسلت هذا المتغيّر إلى Compose بدون التحقّق منه، لنفترض 90 مرة في الثانية، ستدخل في حلقة إعادة الإنشاء هذه 90 مرة في الثانية.

  • هذا يعني أنّ Compose سيعيد تقييم الدوال المركّبة وإعادة قياس التنسيقات وإعادة رسم شجرات واجهة المستخدم 90 مرة في الثانية.

  • وستلاحظون انخفاضًا في عدد اللقطات وتأخّرًا في واجهة المستخدم، وسيتم تفعيل ميزة "التحكّم الحراري" بسرعة كبيرة.

  • لذا، القاعدة الذهبية هي عدم السماح لحلقة المحرك المستمرة بإملاء حلقة واجهة المستخدم التفاعلية.

  • في هذا المثال، بدلاً من أن يحدّث محرّك Godot مؤقّت لوحة النتائج باستمرار، أحرص على أن يسحب Android هذه البيانات مرة واحدة كل ثانية عند الحاجة إليها.

    LaunchedEffect(uiState.phase) {
        while (uiState.phase == MatchPhase.PLAYING) {
            delay(1000L) // Update scoreboard timer at 1 Hz, not 90 Hz
            SoccerGodotPlugin.tickLocalClock()
        }
    }
    ...
    @UsedByGodot
    fun onMatchEvent(eventTypeStr: String, matchMinute: Int, ...) {
        // Recompose once when threshold is crossed to display the popup banner
        _uiState.update { it.copy(activeBannerEvent = event, ...) }
    }
  • أخيرًا، تذكَّر دائمًا حدود سلسلة المحادثات.

  • يتم تنفيذ حلقة محرّك ألعاب Godot باستمرار على سلسلة عرض خاصة بها.

  • يجب تنفيذ واجهات مستخدم Android في سلسلة التعليمات الرئيسية لنظام Android.

  • لذا، تأكَّد دائمًا من أنّ المكوّن الإضافي Kotlin يرسل الأحداث باستخدام Dispatchers.Main أو العناصر الأساسية للحالة الآمنة قبل تعديل حالات نموذج العرض.

    private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    mainScope.launch {
       _uiState.update { current ->
            current.copy(
               homeScore = homeScore,
               awayScore = awayScore,
               matchSeconds = matchMinute.coerceIn(0, 90),
               phase = updatedPhase,
               events = newEventsList,
               activeBannerEvent = event
           )
       }
  • من خلال إبقاء العمل عالي التردد في المحرك والدلالات المنفصلة في Compose، يمكنك الحصول على إطارات عرض ثلاثية الأبعاد سلسة للغاية بدون التأثير سلبًا في عمر البطارية أو أدوات تسهيل الاستخدام أو أداء تطبيقك الأصلي.

  • لنتعرّف الآن على كيفية إضافة Spatial Containers إلى مشروعك في Godot.

  • أولاً، لنبدأ بإعداد التطبيق ليكون في وضع "المساحة الكاملة"، وتفعيل "الحاويات المكانية" مع تلميحات الحدود، ثم ضبط مكتبة تحميل XR في ملف Android Manifest.

    <uses-native-library
               android:name="libopenxr.google.so"
               android:required="false" />
    
               <!-- 1. Launch Activity in Full-Space Managed XR mode -->
               <property
                   android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
                   android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_MANAGED" />
    
               <!-- 2. OpenXR Spatial Container bounds mode hints -->
               <property
                   android:name="org.khronos.openxr.PROPERTY_ACTIVITY_HINT_XR_SPATIAL_CONTAINER_USED_BOUNDS_MODES"
                   android:value="" />
  • يحتوي محرّك Godot على إعدادات مشاريع الواقع الممتد الخاصة به، حيث يمكنني تفعيل OpenXR ومظلِّلات الواقع الممتد والحاويات المكانية من خلال المحرِّر فقط.

  • هذا كل ما عليك فعله لتفعيل ميزة "الحاويات المكانية" في Godot.

  • بعد أن تعرّفت على كيفية استخدام محرّك Godot مع Android XR و"الحاويات المكانية"، وأفضل الممارسات لاستخدام "الحاويات المكانية" مع "اللوحات المكانية" وJXR، ننصحك بالاطّلاع على مزيد من المعلومات على الموقع الإلكتروني للمطوّرين.

  • لقد استعرضنا للتو مثالاً على كيفية استخدام "الحاويات المكانية" في محرّك Godot وAndroid XR، وأنا متأكد من أنّ الكثير منكم يرغب في معرفة رأي مطوّر يعمل حاليًا على "الحاويات المكانية" لتجربة Android XR.

  • لذا، أودّ بعد ذلك دعوة "جوش" و"رودريغو" من NBA، وهما من أوائل الشركاء الذين يستخدمون Spatial Containers في تطبيقاتهم.

نظرة عامة على ميزة NBA Court View

  • شكرًا، فريد.

  • مرحبًا بالجميع.

  • اسمي "جوش"، وأنا مطوّر برامج Android في NBA.

  • أنا هنا اليوم مع زميلي "رودريغو سانشيز"، وهو مطوّر برامج Unity في شركة Beyond Sports.

  • واليوم، نودّ أن نوضّح لك كيف استخدمنا Unity كمكتبة لإنشاء ميزة Court View في تطبيق NBA.

  • في NBA، نسعى دائمًا إلى تقديم تجارب جديدة لمشجّعينا.

  • لذلك، يسرّنا تقديم ميزة "عرض الملعب" (Court View) بالتعاون مع Google وBeyond Sports.

  • توفّر ميزة Court View محاكاة ثلاثية الأبعاد لمباريات NBA، ما يتيح للمشتركين في League Pass مشاهدة المباريات من زوايا جديدة لا يمكنهم رؤيتها في فيديوهات البث العادية.

  • يمكنك تغيير حجم الملعب وتدويره ووضعه في أي مكان تريده في مساحتك.

  • تحرَّك حوله وشاهِد المباراة من زوايا فريدة.

  • ويمكنك استخدام تطبيق NBA كالمعتاد، بدءًا من الاطّلاع على الإحصاءات المتزامنة وحتى مشاهدة البث المباشر.

  • كيف يتم إعداد كل ذلك؟

  • يتم عرض ميزة Court View في Unity ويستخدمها تطبيق Android من خلال Unity as a Library.

  • بعد ذلك، ننشئ ملف Android Archive واحدًا، تتم استضافته على Maven Central، ويمكن لتطبيق NBA سحبه كاعتمادية واحدة.

  • وقد سمح لنا ذلك بتقسيم العمل بين جانب Android وجانب Unity.

  • وبالتالي، يمكن لكل منّا التركيز على ما يعرفه بشكل أفضل، والعمل بالتوازي لتسريع عملية التطوير بشكل عام.

  • على سبيل المثال، بينما كان فريقنا يعمل على تجربة المساحة الكاملة في Compose، تمكّن فريق Unity من التركيز على تغيير حجم الملعب الثلاثي الأبعاد وتنسيقه.

  • والآن، سأترك الكلمة لـ "رودريغو" ليتحدث أكثر عن مشروع Unity وكيفية استخدام Unity as a Library.

دمج Unity كمكتبة

  • شكرًا، رامي.

  • نحن في Beyond Sports نعمل مع الرابطة الوطنية لكرة السلة (NBA) منذ بضع سنوات على تمثيل المرئيات الثلاثية الأبعاد ببيانات حقيقية من المباريات.

  • من خلال هذا المشروع، عدّلنا المحاكاة الثلاثية الأبعاد لتتوافق مع Android XR.

  • تم إنشاء المحاكاة على محرّك Unity باستخدام لغة C#‎.

  • نصدّر المحاكاة إلى Android باستخدام Unity كمكتبة، وننشئ طبقة تواصل بجانبها باستخدام Kotlin.

  • قبل تجميع هاتين الوحدتين معًا كاعتمادية Maven واحدة لتضمينها في تطبيق NBA.

  • على الشاشة، يمكنك مشاهدة فيديو قصير لنفس المحاكاة التي عرضها جوش سابقًا، والتي يتم تشغيلها على المحرر.

  • لتحقيق ذلك، نستخدم حزمة Android XR Extensions في Unity.

  • لنستعرض معًا خطوات هذه العملية.

  • لذا، قبل إنشاء اللعبة في Unity، تأكَّد من اختيار الإعدادات والمكوّنات الإضافية الصحيحة.

  • احصل على حزمة XR Plug-in Management من Unity وحزمة Android XR Extensions for Unity من Google.

  • اضبطها في إعدادات "إدارة المكوّنات الإضافية لتقنيات الواقع الممتد" (XR Plug-in Management) لاستخدام مزوّد إضافات Android XR‏ OpenXR.

  • في حالتنا، أردنا استخدام "الحاويات المكانية" (Spatial Containers)، لذا فعّلنا هذه الميزة وأوقفنا ميزة "الواقع الممتد الغامر" (immersive XR) من إعدادات إدارة الجلسات.

  • لم نستخدم أي حزمة إدخال XR، لأنّ جميع عمليات الإدخال تتم بشكلٍ أصلي خارج Unity.

  • بعد ذلك، في إعدادات المشغّل، عليك ضبط مشروعك لاستخدام المستوى 24 أو أعلى من واجهة برمجة التطبيقات كحدّ أدنى، ويجب ضبط نقطة دخول التطبيق على Game Activity الأحدث.

  • يُرجى العِلم أنّه يجب عدم وضع علامة في المربّع بجانب "النشاط".

  • نحن مهتمون فقط بإنشاء إصدارات لمعالجات ARM64، لذا نختار هذه البنية المستهدفة، وفي قسم الدقة، نفعّل خيار "التشغيل في الخلفية" (Run in Background) لتتمكّن من تنفيذ مهام متعددة باستخدام تطبيقات أخرى مع إبقاء محاكاة الأبعاد الثلاثية قيد التشغيل في الخلفية.

  • ونفعّل أيضًا ميزة "النشاط القابل لتغيير الحجم" (Resizeable Activity)، لأنّها مطلوبة لعرض مربّعات الحوار، مثل طلبات الحصول على إذن النظام.

  • أخيرًا، ننصح بعرض تطبيقات Android XR باستخدام Universal Render Pipeline وVulkan Graphics API.

  • في مادة عرض URP، يجب إيقاف HDR والمعالجة اللاحقة.

  • بعد إكمال كل هذه الخطوات، ستكون جاهزًا لإنشاء إصدار.

  • انتقِل إلى نافذة الإصدار، وإذا كنت تستخدم الإصدار 6.1 من Unity أو إصدارًا أحدث، يمكنك اختيار ملف الإصدار الشخصي لنظام Android XR، ما يتيح لك فصل الإعدادات إذا كنت تنشئ أيضًا إصدارًا لأجهزة Android الجوّالة.

  • قبل الإنشاء، تأكَّد من تفعيل خيار تصدير المشروع.

  • بعد انتهاء Unity من عملية الإنشاء، ستحصل على مشروع Android صالح للعمل يمكن تجميعه في "استوديو Android" وتشغيله على جهاز مثل أي حزمة APK أخرى من Unity.

  • بعد فتح استوديو Android، عليك تحويل الإصدار الذي تم تصديره إلى مكتبة لاستخدامها في تطبيق آخر.

    // The Unity build
    include(":unityLibrary")
    project(":unityLibrary").projectDir = File("..\\Libraries\\unityBuild\\unityLibrary")
    
    // Custom communication IPC module
    include(":nativeCommunications")
    project(":nativeCommunications").projectDir = File("..\\Libraries\\nativeCommunications")
    
    // If using XR features
    include(":unityLibrary:xrmanifest.androidlib")
    
    // Used for merging libraries, exporting and publishing.
    include(":packageExporter")
    
    dependencyResolutionManagement {
       repositories {
    flatDir { // Required to load unity correctly
       dirs("${project(":unityLibrary").projectDir}/libs")
    }
    }
  • في مشروعنا هنا، لدينا أربع وحدات مختلفة: وحدة APK الرئيسية للاختبار كعنصر نائب وهمي لتطبيق NBA، ووحدة مكتبة Unity التي تحتوي على الإصدار الذي تم تصديره، ووحدة nativeCommunications التي تحتوي على طبقة الاتصال التي أنشأناها بين الأنشطة، ووحدة packageExporter التي نستخدمها لدمج ملفات AAR في حزمة واحدة لإرسالها إلى Maven.

  • يمكن دمج عدة ملفات AAR باستخدام Android Fused Library أو طرق قديمة من إنشاء المنتدى، مثل Fat AAR.

  • يمكنك الحفاظ على بساطة البنية والتواصل من خلال إبقاء Unity والنشاط الرئيسي قيد التشغيل في العملية نفسها.

  • وقد قررنا عدم القيام بذلك، وقررنا فصل نشاط Unity في عملية خاصة به كإجراء وقائي في حالة تعطل جزء Unity، فلن يتأثر تطبيق NBA ويمكنه التعامل بأمان مع إغلاق Unity بسلاسة دون أن يتعطل نفسه.

  • يفرض علينا هذا القرار اعتماد نظام أكثر فعالية للتواصل بين العمليات.

  • بما أنّ الأنشطة تتم في عمليات مختلفة، لا تتم مشاركة الذاكرة بينها، ولا تعمل العديد من طرق الاتصال بين العمليات (IPC) الشائعة في Android أو لا تفي بالمتطلبات.

  • نستخدم لغة تعريف واجهة نظام Android، أو AIDL، للتواصل بين النشاط الرئيسي لتطبيق NBA ونشاط Unity المضمّن.

  • بعد ذلك، يعتمد الاتصال بين Android وUnity أيضًا على الواجهات، وذلك باستخدام AndroidJavaProxy المضمّن في Unity لتسجيل واجهة Kotlin في تنفيذ واجهة C# ‎.

  • تركّز وحدة الاتصالات بشكل أساسي على ما نسمّيه "جسر النشاط".

    // Main Activity
    
    val intent = Intent(context, AIDLBridgeService::class.java)
    context.startService(intent)
    context.bindService(intent, connection, 0)
    
    fun onStartMatchClicked() {
       bridge.runWithService { service ->
           service.startMatch("match_id")
       }
    }
    
    // AIDL Bridge Service
    
    private val remoteBinder = object : IBridgeService.Stub() {
    override fun startMatch(matchID: String?) {
       addToCallbackQueue { nativeInterface?.startMatch(matchID) }
    }
    }
  • يتمتع كلا النشاطين بإمكانية الوصول إلى جسر النشاط وطرق الأداة المساعدة داخله.

  • يحتوي الجسر على مرجع لخدمة تنفّذ واجهات AIDL التي حدّدناها، كما يوفّر طريقة سهلة لربط الأنشطة بالعملية، عذرًا، بالخدمة وإدارة مراحل نشاطها داخليًا.

  • يمكن بدء محاكاة من خلال استدعاء نمرّر فيه معرّف المباراة كمَعلمة.

  • بعد ذلك، يمرر الجسر من خلال AIDL، ثم إلى تطبيق Unity، حيث يحصل C# على أمر ويبدأ في تكوين المشهد.

    public class NativeInterfaceListener : AndroidJavaProxy {
    [UsedImplicitly]
    public void startMatch(string matchID) {
       UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
    }
    }
  • نحتاج إلى انتظار Unity حتى يصبح نشطًا وجاهزًا للضغط على الأوامر التي نمررها إليه.

  • سيكون نشاط Unity الذي يغلّف التطبيق نشطًا قبل Unity نفسه.

  • في حال إرسال الأوامر في وقت مبكر جدًا، يتم تخزينها في قائمة انتظار معاودة الاتصال.

    fun onStartMatchClickedInternal() {
       if (isServiceBound) {
           AIDLBridgeService.startMatch("match_id")
       } else {
           delayedCallbacksQueue.add(action)
       }
    }
  • بعد انتهاء Unity من عملية الإقلاع والتهيئة، والوصول إلى حالة نعتبرها آمنة، نرسل إشارة من C# ‎ تفيد بأنّ Unity جاهز.

    // After bootload and initialization
    
    using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
    using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
    instance.Call("onUnityReady");
  • بعد ذلك، نكرّر العملية على قائمة الانتظار ونعيد توجيه جميع الطلبات المخزّنة.

    @Suppress("unused") //  Unity implicit call
    fun onUnityReady() {
       bridge.activityBridgeServiceInternal?.onUnityReady()
    }
    
    delayedCallbacksQueue.forEach { it(activityBridgeService) }
  • يوضّح هذا المخطّط البياني بشكل أفضل طريقة عمل الاتصال بين العمليات.

  • كما ترون، باستخدام الجسر بين الأنشطة، يمكننا التعامل مع عمليات الاستدعاء التي ذكرناها.

  • أعددنا StateFlows التي يتم تعديلها من Unity لمراقبة القيم، مثل حالة المصادقة أو الطابع الزمني للحالة الحالية للمباراة.

  • بالإضافة إلى ذلك، ندير أيضًا تشفير بيانات الاعتماد، ونعيد توجيه أي أخطاء من Unity حتى يتمكّن فريق NBA من تصحيح الأخطاء بسهولة أكبر وتحديد أفضل طريقة للتعامل معها على مستوى التطبيق.

  • لقد انتهى وقتي هنا.

  • سأترك الآن المجال لـ "جوش" ليتحدّث أكثر عن كيفية دمج حزمة Court View Unity في تطبيق NBA.

  • نستخدم تبعيات Gradle هذه لدمج Unity كمكتبة مع تطبيق Jetpack Compose: وقت تشغيل XR للوصول إلى الجلسة الثلاثية الأبعاد، وJetpack SceneCore لاستضافة نشاط Unity وتبديل البيئات المكانية، وJetpack Compose for XR للوصول إلى اللوحات المكانية في رمز Compose.

    dependencies {
      implementation("androidx.xr.runtime:runtime:1.0.0")
      implementation("androidx.xr.scenecore:scenecore:1.0.0")
      implementation("androidx.xr.compose:compose:1.0.0-beta02")
    }
  • بعد ذلك، لنتعرّف على كيفية عرض "عرض الملعب" في Jetpack Compose.

    // Step 1: Request Full Space
    LocalSession.current?.scene?.requestFullSpace()
    
    // Step 2: A shared Subspace for NBA app content and embedded Unity Activity.
    override fun onCreate(savedInstanceState: Bundle?){
        setContent {
            Subspace {
               SpatialPanel { AppContent() }
    
               // Court View
               val intent = courtViewFeature.intent
               val controller = rememberSpatialActivityPanelController(intent)
               SpatialActivityPanel(controller)
            }
        }
    }
  • قبل إطلاق ميزة "عرض الملعب"، نتيح لك تجربة المساحة الكاملة.

  • هذا الإجراء مطلوب لعرض اللوحة المكانية التي تحتوي على Unity.

  • بعد ذلك، نرسل معلومات اللعبة عبر AIDL لتشغيل ميزة "عرض الملعب" للعبة معيّنة.

  • بعد ذلك، يتم تشغيل Unity.

  • يرسل أرشيف Android هذا كـ intent إلى نشاط Unity، ونقوم بتغليف هذا في SpatialActivityPanelController.

  • بعد ذلك، نمرّر وحدة التحكّم هذه إلى SpatialActivityPanel.

  • تحتوي هذه اللوحة على "عرض المحكمة"، وتتم مشاركتها في المساحة الفرعية مع اللوحة المكانية الرئيسية.

  • يتيح لنا ذلك التحكّم في كليهما في آنٍ واحد بدون أي مشاكل.

  • هذا كل ما في الأمر

  • سأعيد إرسالها إلى "لوك".

الخاتمة والمراجع

  • شكرًا جزيلاً.

  • كما ترون، تمكّن فريق NBA من تسريع تطوير ميزة Court View من خلال تقسيم عملية التطوير بين Unity وJetpack Compose.

  • لمزيد من المعلومات حول كيفية بدء استخدام الحاويات المكانية واستخدامها في تطبيقات Android XR، يُرجى الاطّلاع على مستندات المطوّرين على developer.android.com/develop/xr.

  • شكرًا جزيلاً على انضمامكم إلينا اليوم، ونحن متحمّسون لرؤية ما ستبتكرونه.

الرمز

مقتطفات الرمز البرمجي

مقتطف Kotlin Script في الدقيقة 09:30 في فيديو "Communication between Kotlin and Godot"
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
مقتطف من نص Kotlin البرمجي في الطابع الزمني 10:27 في فيديو "التواصل بين Kotlin وGodot"
dependencies {
    // Godot 4.8 Android Library + Godot OpenXR Vendors plugin + Khronos OpenXR Loader
    implementation("org.godotengine:godot:4.8.0.dev-SNAPSHOT")
    implementation("org.godotengine:godot-openxr-vendors-androidxr:5.1.0-stable")
    implementation("org.khronos.openxr:openxr_loader_for_android:1.1.63")

    // Jetpack XR SDK (Compose for XR, SceneCore, Runtime)
    implementation("androidx.xr.compose:compose:1.0.0-beta02")
    implementation("androidx.xr.scenecore:scenecore:1.0.0")
    implementation("androidx.xr.runtime:runtime:1.0.0")
مقتطف Kotlin في الدقيقة 10:48 في فيديو "عرض Godot في Jetpack Compose"
@Composable
fun GodotView(godotHost: GodotHost, modifier: Modifier = Modifier) {
    // 1. Initialize Godot's native render surface
    val renderView = remember(godotHost) {
        godotHost.godot!!.onInitRenderView(godotHost)
    }
    // 2. Host the SurfaceView inside Jetpack Compose
    AndroidView(
        modifier = modifier,
        factory = { renderView }
    )
}
مقتطف Kotlin في الدقيقة 10:55 في فيديو "عرض Godot في Jetpack Compose"
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
مقتطف Kotlin في الدقيقة 11:05 في فيديو "عرض Godot في Jetpack Compose"
@Composable
fun GodotView(
   godotHost: GodotHost,
   modifier: Modifier = Modifier,
   lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {

   DisposableEffect(lifecycleOwner, godotHost) {
       val observer = LifecycleEventObserver { _, event ->
           val engine = godotHost.godot ?: return@LifecycleEventObserver
           when (event) {
               Lifecycle.Event.ON_START -> engine.onStart(godotHost)
….
               Lifecycle.Event.ON_DESTROY -> engine.onDestroy(godotHost)
               else -> {}
           }
       }

       lifecycleOwner.lifecycle.addObserver(observer)

       onDispose {
           lifecycleOwner.lifecycle.removeObserver(observer)
       }
   }

}
مقتطف Kotlin في الدقيقة 11:29 في فيديو "عرض Godot في Jetpack Compose"
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
مقتطف Kotlin في الدقيقة 11:52 في فيديو "عرض Godot في Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
    override fun getPluginName() = "SoccerPlugin"
    // 1. Signals sent from Android Compose UI -> Godot
    override fun getPluginSignals() = setOf(
        SignalInfo("camera_mode_changed", String::class.java),
        SignalInfo("match_command", String::class.java),
    )

}
مقتطف نصي عند الطابع الزمني 12:01 في فيديو "عرض Godot في Jetpack Compose"
var soccer_plugin: Object = null

func _ready() -> void:
    if Engine.has_singleton("SoccerPlugin"):
        soccer_plugin = Engine.get_singleton("SoccerPlugin")

        # Listen for signals from Jetpack Compose UI
        soccer_plugin.connect("camera_mode_changed", _on_camera_mode_changed)
        soccer_plugin.connect("match_command", _on_match_command)
مقتطف Kotlin في الدقيقة 12:10 في فيديو "عرض Godot في Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
مقتطف نصي عند الطابع الزمني 12:20 في فيديو "عرض Godot في Jetpack Compose"
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
مقتطف Kotlin في الدقيقة 12:30 في فيديو "عرض Godot في Jetpack Compose"
@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {

        _uiState.update { state ->
            state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
        }
    }
مقتطف Kotlin في الدقيقة 14:35 في فيديو "عرض Godot في Jetpack Compose"
LaunchedEffect(uiState.phase) {
    while (uiState.phase == MatchPhase.PLAYING) {
        delay(1000L) // Update scoreboard timer at 1 Hz, not 90 Hz
        SoccerGodotPlugin.tickLocalClock()
    }
}
...
@UsedByGodot
fun onMatchEvent(eventTypeStr: String, matchMinute: Int, ...) {
    // Recompose once when threshold is crossed to display the popup banner
    _uiState.update { it.copy(activeBannerEvent = event, ...) }
}
مقتطف Kotlin في الدقيقة 14:57 في فيديو "عرض Godot في Jetpack Compose"
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
mainScope.launch {
   _uiState.update { current ->
        current.copy(
           homeScore = homeScore,
           awayScore = awayScore,
           matchSeconds = matchMinute.coerceIn(0, 90),
           phase = updatedPhase,
           events = newEventsList,
           activeBannerEvent = event
       )
   }
مقتطف XML في الدقيقة 15:22 في فيديو "عرض Godot في Jetpack Compose"
<uses-native-library
           android:name="libopenxr.google.so"
           android:required="false" />

           <!-- 1. Launch Activity in Full-Space Managed XR mode -->
           <property
               android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
               android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_MANAGED" />

           <!-- 2. OpenXR Spatial Container bounds mode hints -->
           <property
               android:name="org.khronos.openxr.PROPERTY_ACTIVITY_HINT_XR_SPATIAL_CONTAINER_USED_BOUNDS_MODES"
               android:value="" />
مقتطف من نص Kotlin البرمجي في الدقيقة 20:41 في فيديو "دمج Unity كمكتبة"
// The Unity build
include(":unityLibrary")
project(":unityLibrary").projectDir = File("..\\Libraries\\unityBuild\\unityLibrary")

// Custom communication IPC module
include(":nativeCommunications")
project(":nativeCommunications").projectDir = File("..\\Libraries\\nativeCommunications")

// If using XR features
include(":unityLibrary:xrmanifest.androidlib")

// Used for merging libraries, exporting and publishing.
include(":packageExporter")

dependencyResolutionManagement {
   repositories {
flatDir { // Required to load unity correctly
   dirs("${project(":unityLibrary").projectDir}/libs")
}
}
مقتطف Kotlin في الدقيقة 22:11 في فيديو "دمج Unity كمكتبة"
// Main Activity

val intent = Intent(context, AIDLBridgeService::class.java)
context.startService(intent)
context.bindService(intent, connection, 0)

fun onStartMatchClicked() {
   bridge.runWithService { service ->
       service.startMatch("match_id")
   }
}

// AIDL Bridge Service

private val remoteBinder = object : IBridgeService.Stub() {
override fun startMatch(matchID: String?) {
   addToCallbackQueue { nativeInterface?.startMatch(matchID) }
}
}
مقتطف C# ‎ في الدقيقة 22:36 في فيديو "دمج Unity كمكتبة"
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
مقتطف Kotlin في الدقيقة 22:54 في فيديو "دمج Unity كمكتبة"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
مقتطف C# ‎ في الدقيقة 22:57 في "دمج Unity كمكتبة"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
مقتطف Kotlin في الدقيقة 23:06 في فيديو "دمج Unity كمكتبة"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
مقتطف من نص Kotlin البرمجي في الدقيقة 23:50 في فيديو "دمج Unity كمكتبة"
dependencies {
  implementation("androidx.xr.runtime:runtime:1.0.0")
  implementation("androidx.xr.scenecore:scenecore:1.0.0")
  implementation("androidx.xr.compose:compose:1.0.0-beta02")
}
مقتطف Kotlin في الدقيقة 24:10 في فيديو "دمج Unity كمكتبة"
// Step 1: Request Full Space
LocalSession.current?.scene?.requestFullSpace()

// Step 2: A shared Subspace for NBA app content and embedded Unity Activity.
override fun onCreate(savedInstanceState: Bundle?){
    setContent {
        Subspace {
           SpatialPanel { AppContent() }

           // Court View
           val intent = courtViewFeature.intent
           val controller = rememberSpatialActivityPanelController(intent)
           SpatialActivityPanel(controller)
        }
    }
}