@hugebug4ever/react-native-unity 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/android/build.gradle +38 -0
  3. package/android/consumer-rules.pro +3 -0
  4. package/android/src/main/AndroidManifest.xml +4 -0
  5. package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityBridge.kt +5 -0
  6. package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityHostView.kt +95 -0
  7. package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityPackage.kt +31 -0
  8. package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityViewManager.kt +30 -0
  9. package/android/src/main/java/com/hugebug4ever/rnunity/UnityPlayerAdapter.kt +58 -0
  10. package/android/src/main/java/com/hugebug4ever/rnunity/UnityRuntimeController.kt +329 -0
  11. package/android/src/main/java/com/hugebug4ever/rnunity/UnityRuntimeModule.kt +46 -0
  12. package/android/src/main/java/com/hugebug4ever/rnunity/UnityRuntimeState.kt +16 -0
  13. package/app.plugin.js +2 -0
  14. package/cli/index.js +140 -0
  15. package/ios/RNUnityNativeBridge.mm +6 -0
  16. package/ios/RNUnityRuntimeController.h +29 -0
  17. package/ios/RNUnityRuntimeController.mm +316 -0
  18. package/ios/RNUnityView.h +17 -0
  19. package/ios/RNUnityView.mm +105 -0
  20. package/ios/UnityRuntime.mm +49 -0
  21. package/lib/commonjs/UnityRuntime.js +53 -0
  22. package/lib/commonjs/UnityRuntime.js.map +1 -0
  23. package/lib/commonjs/UnityView.js +35 -0
  24. package/lib/commonjs/UnityView.js.map +1 -0
  25. package/lib/commonjs/index.js +20 -0
  26. package/lib/commonjs/index.js.map +1 -0
  27. package/lib/commonjs/package.json +1 -0
  28. package/lib/commonjs/specs/NativeUnityRuntime.js +9 -0
  29. package/lib/commonjs/specs/NativeUnityRuntime.js.map +1 -0
  30. package/lib/commonjs/specs/NativeUnityView.js +10 -0
  31. package/lib/commonjs/specs/NativeUnityView.js.map +1 -0
  32. package/lib/commonjs/types.js +6 -0
  33. package/lib/commonjs/types.js.map +1 -0
  34. package/lib/typescript/UnityRuntime.d.ts +12 -0
  35. package/lib/typescript/UnityRuntime.d.ts.map +1 -0
  36. package/lib/typescript/UnityView.d.ts +3 -0
  37. package/lib/typescript/UnityView.d.ts.map +1 -0
  38. package/lib/typescript/index.d.ts +4 -0
  39. package/lib/typescript/index.d.ts.map +1 -0
  40. package/lib/typescript/specs/NativeUnityRuntime.d.ts +15 -0
  41. package/lib/typescript/specs/NativeUnityRuntime.d.ts.map +1 -0
  42. package/lib/typescript/specs/NativeUnityView.d.ts +32 -0
  43. package/lib/typescript/specs/NativeUnityView.d.ts.map +1 -0
  44. package/lib/typescript/types.d.ts +63 -0
  45. package/lib/typescript/types.d.ts.map +1 -0
  46. package/package/UnityRuntime.ts +64 -0
  47. package/package/UnityView.tsx +32 -0
  48. package/package/index.ts +20 -0
  49. package/package/specs/NativeUnityRuntime.ts +17 -0
  50. package/package/specs/NativeUnityView.ts +27 -0
  51. package/package/types.ts +85 -0
  52. package/package.json +90 -0
  53. package/plugin/build/index.js +132 -0
  54. package/plugin/ios/configure_unity.rb +108 -0
  55. package/plugin/src/index.ts +175 -0
  56. package/plugin/tsconfig.json +13 -0
  57. package/react-native-unity.podspec +34 -0
  58. package/react-native.config.js +9 -0
  59. package/unity-package/Editor/Exporter.cs +91 -0
  60. package/unity-package/Editor/Exporter.cs.meta +2 -0
  61. package/unity-package/Editor/ReactNativeUnity.Editor.asmdef +8 -0
  62. package/unity-package/Editor/ReactNativeUnity.Editor.asmdef.meta +7 -0
  63. package/unity-package/Editor.meta +8 -0
  64. package/unity-package/Runtime/ReactNativeBridge.cs +48 -0
  65. package/unity-package/Runtime/ReactNativeBridge.cs.meta +2 -0
  66. package/unity-package/Runtime/ReactNativeUnity.Runtime.asmdef +6 -0
  67. package/unity-package/Runtime/ReactNativeUnity.Runtime.asmdef.meta +7 -0
  68. package/unity-package/Runtime.meta +8 -0
  69. package/unity-package/package.json +11 -0
  70. package/unity-package/package.json.meta +7 -0
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @hugebug4ever/react-native-unity
2
+
3
+ Embeds one process-wide Unity 6000.3 runtime in React Native 0.86-0.87 using Fabric and the New Architecture.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @hugebug4ever/react-native-unity
9
+ ```
10
+
11
+ Add the Expo config plugin. Paths are relative to the app root and show their default values:
12
+
13
+ ```js
14
+ module.exports = {
15
+ expo: {
16
+ newArchEnabled: true,
17
+ plugins: [
18
+ [
19
+ '@hugebug4ever/react-native-unity',
20
+ {
21
+ androidExportPath: 'unity/builds/android/unityLibrary',
22
+ iosExportPath: 'unity/builds/ios',
23
+ },
24
+ ],
25
+ ],
26
+ },
27
+ };
28
+ ```
29
+
30
+ Export Unity, then generate the native app:
31
+
32
+ ```sh
33
+ npx rn-unity export --platform android --unity-project unity --output unity/builds/android
34
+ npx expo prebuild
35
+ npx rn-unity doctor --project . --platform android
36
+ ```
37
+
38
+ On macOS, use `--platform ios` and output `unity/builds/ios`. The iOS plugin consumes the standard `Unity-iPhone.xcodeproj`; an XCFramework is not required.
39
+
40
+ ## API
41
+
42
+ ```tsx
43
+ import { UnityRuntime, UnityView } from '@hugebug4ever/react-native-unity';
44
+
45
+ <UnityView
46
+ style={{ flex: 1 }}
47
+ autoStart
48
+ detachedBehavior="pause"
49
+ onMessage={({ nativeEvent }) => console.log(nativeEvent.message)}
50
+ />
51
+
52
+ await UnityRuntime.start();
53
+ await UnityRuntime.sendMessage({
54
+ gameObject: 'Bridge',
55
+ methodName: 'OnMessage',
56
+ message: 'hello',
57
+ });
58
+ await UnityRuntime.pause();
59
+ await UnityRuntime.resume();
60
+ await UnityRuntime.unload();
61
+ ```
62
+
63
+ `UnityRuntime.addListener` supports `stateChange`, `message`, and `error` and returns a removable subscription. `getState()` and `getDiagnostics()` expose lifecycle and diagnostic state.
64
+
65
+ Only one visible `UnityView` may own the Unity root view. Detaching a view does not unload Unity and pauses it by default. Expo Go is unsupported because the package requires native code; use a development build or EAS native build.
66
+
67
+ `unload()` only succeeds after Unity invokes its native completion callback. In the tested Unity `6000.3.15f1` Android embedded configuration that callback was not delivered, so the runtime deliberately times out with `E_UNLOAD_TIMEOUT`, transitions to terminal `failed`, and requires an app-process restart. It never creates a second Unity instance after an unsafe unload.
68
+
69
+ ## Support Boundary
70
+
71
+ - Unity: `6000.3.x`
72
+ - React Native: `>=0.86 <0.88`
73
+ - iOS deployment target: 15.1
74
+ - Android minSdk: 25
75
+ - Architectures in the sample export: arm64-v8a and x86_64
76
+
77
+ iOS source and standard-export project integration are included, but still require Mac compilation and device validation. Release readiness also requires repeated physical-device lifecycle, GPU/surface, memory, and long-running tests.
@@ -0,0 +1,38 @@
1
+ buildscript {
2
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
3
+ dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20" }
4
+ }
5
+
6
+ apply plugin: "com.android.library"
7
+ apply plugin: "org.jetbrains.kotlin.android"
8
+ apply plugin: "com.facebook.react"
9
+
10
+ react {
11
+ libraryName = "RNUnitySpec"
12
+ codegenJavaPackageName = "com.hugebug4ever.rnunity"
13
+ }
14
+
15
+ android {
16
+ namespace "com.hugebug4ever.rnunity"
17
+ compileSdkVersion rootProject.ext.has("compileSdkVersion") ? rootProject.ext.get("compileSdkVersion") : 36
18
+
19
+ defaultConfig {
20
+ minSdkVersion rootProject.ext.has("minSdkVersion") ? rootProject.ext.get("minSdkVersion") : 24
21
+ targetSdkVersion rootProject.ext.has("targetSdkVersion") ? rootProject.ext.get("targetSdkVersion") : 36
22
+ consumerProguardFiles "consumer-rules.pro"
23
+ }
24
+
25
+ compileOptions {
26
+ sourceCompatibility JavaVersion.VERSION_17
27
+ targetCompatibility JavaVersion.VERSION_17
28
+ }
29
+ kotlinOptions { jvmTarget = "17" }
30
+ }
31
+
32
+ repositories { google(); mavenCentral() }
33
+
34
+ dependencies {
35
+ implementation "com.facebook.react:react-android"
36
+ implementation project(":unityLibrary")
37
+ compileOnly files("${project(':unityLibrary').projectDir}/libs/unity-classes.jar")
38
+ }
@@ -0,0 +1,3 @@
1
+ -keep class com.hugebug4ever.rnunity.** { *; }
2
+ -keep class com.unity3d.player.** { *; }
3
+
@@ -0,0 +1,4 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-feature android:glEsVersion="0x00030000" android:required="false" />
3
+ </manifest>
4
+
@@ -0,0 +1,5 @@
1
+ package com.hugebug4ever.rnunity
2
+
3
+ object RNUnityBridge {
4
+ @JvmStatic fun sendMessage(message: String) = UnityRuntimeController.receiveMessage(message)
5
+ }
@@ -0,0 +1,95 @@
1
+ package com.hugebug4ever.rnunity
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewGroup
6
+ import android.widget.FrameLayout
7
+ import com.facebook.react.bridge.Arguments
8
+ import com.facebook.react.bridge.ReactContext
9
+ import com.facebook.react.uimanager.UIManagerHelper
10
+ import com.facebook.react.uimanager.events.Event
11
+ import java.util.UUID
12
+
13
+ internal class RNUnityHostView(context: Context) : FrameLayout(context) {
14
+ private val reactContext = context as ReactContext
15
+ val hostId: String = UUID.randomUUID().toString()
16
+ var detachedBehavior: String = "pause"
17
+
18
+ override fun onAttachedToWindow() {
19
+ super.onAttachedToWindow()
20
+ requestRuntimeAttach()
21
+ }
22
+
23
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
24
+ super.onSizeChanged(w, h, oldw, oldh)
25
+ if (w > 0 && h > 0) requestRuntimeAttach()
26
+ }
27
+
28
+ override fun onDetachedFromWindow() {
29
+ UnityRuntimeController.detach(hostId)
30
+ super.onDetachedFromWindow()
31
+ }
32
+
33
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
34
+ super.onLayout(changed, left, top, right, bottom)
35
+ val childWidth = right - left
36
+ val childHeight = bottom - top
37
+ for (index in 0 until childCount) {
38
+ layoutUnityChild(getChildAt(index), childWidth, childHeight)
39
+ }
40
+ }
41
+
42
+ private fun layoutUnityChild(child: View, childWidth: Int, childHeight: Int) {
43
+ if (childWidth <= 0 || childHeight <= 0) return
44
+ child.measure(
45
+ MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY),
46
+ MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY),
47
+ )
48
+ child.layout(0, 0, childWidth, childHeight)
49
+ }
50
+
51
+ private fun requestRuntimeAttach() {
52
+ if (isAttachedToWindow && width > 0 && height > 0) UnityRuntimeController.attach(this)
53
+ }
54
+
55
+ fun mountUnityView(view: View) {
56
+ if (view.parent === this) return
57
+ (view.parent as? ViewGroup)?.removeView(view)
58
+ removeAllViews()
59
+ addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
60
+ layoutUnityChild(view, width, height)
61
+ view.requestFocus()
62
+ }
63
+
64
+ fun unmountUnityView(view: View?) {
65
+ if (view != null && view.parent === this) removeView(view)
66
+ }
67
+
68
+ fun emit(name: String, values: Map<String, Any?>) {
69
+ if (id == View.NO_ID) return
70
+ val map = Arguments.createMap()
71
+ values.forEach { (key, value) ->
72
+ when (value) {
73
+ null -> map.putNull(key)
74
+ is String -> map.putString(key, value)
75
+ is Int -> map.putInt(key, value)
76
+ is Number -> map.putDouble(key, value.toDouble())
77
+ is Boolean -> map.putBoolean(key, value)
78
+ }
79
+ }
80
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
81
+ UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
82
+ ?.dispatchEvent(UnityHostEvent(surfaceId, id, name, map))
83
+ }
84
+ }
85
+
86
+ private class UnityHostEvent(
87
+ surfaceId: Int,
88
+ viewTag: Int,
89
+ private val name: String,
90
+ private val payload: com.facebook.react.bridge.WritableMap,
91
+ ) : Event<UnityHostEvent>(surfaceId, viewTag) {
92
+ override fun getEventName(): String = name
93
+ override fun getEventData() = payload
94
+ override fun canCoalesce(): Boolean = false
95
+ }
@@ -0,0 +1,31 @@
1
+ package com.hugebug4ever.rnunity
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.ModuleSpec
5
+ import com.facebook.react.bridge.NativeModule
6
+ import com.facebook.react.bridge.ReactApplicationContext
7
+ import com.facebook.react.module.model.ReactModuleInfo
8
+ import com.facebook.react.module.model.ReactModuleInfoProvider
9
+
10
+ class RNUnityPackage : BaseReactPackage() {
11
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
12
+ if (name == UnityRuntimeModule.NAME) UnityRuntimeModule(reactContext) else null
13
+
14
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
15
+ mapOf(
16
+ UnityRuntimeModule.NAME to ReactModuleInfo(
17
+ UnityRuntimeModule.NAME,
18
+ UnityRuntimeModule.NAME,
19
+ false,
20
+ false,
21
+ false,
22
+ false,
23
+ true,
24
+ ),
25
+ )
26
+ }
27
+
28
+ override fun getViewManagers(reactContext: ReactApplicationContext) =
29
+ listOf(ModuleSpec.viewManagerSpec { RNUnityViewManager() })
30
+ }
31
+
@@ -0,0 +1,30 @@
1
+ package com.hugebug4ever.rnunity
2
+
3
+ import com.facebook.react.bridge.ReactApplicationContext
4
+ import com.facebook.react.uimanager.SimpleViewManager
5
+ import com.facebook.react.uimanager.ThemedReactContext
6
+ import com.facebook.react.uimanager.annotations.ReactProp
7
+
8
+ internal class RNUnityViewManager : SimpleViewManager<RNUnityHostView>() {
9
+ override fun getName() = NAME
10
+ override fun createViewInstance(context: ThemedReactContext) = RNUnityHostView(context)
11
+
12
+ @ReactProp(name = "detachedBehavior")
13
+ fun setDetachedBehavior(view: RNUnityHostView, value: String?) {
14
+ view.detachedBehavior = if (value == "keepRunning") "keepRunning" else "pause"
15
+ }
16
+
17
+ override fun onDropViewInstance(view: RNUnityHostView) {
18
+ UnityRuntimeController.detach(view.hostId)
19
+ super.onDropViewInstance(view)
20
+ }
21
+
22
+ override fun getExportedCustomDirectEventTypeConstants() = mapOf(
23
+ "topReady" to mapOf("registrationName" to "onReady"),
24
+ "topMessage" to mapOf("registrationName" to "onMessage"),
25
+ "topStateChange" to mapOf("registrationName" to "onStateChange"),
26
+ "topError" to mapOf("registrationName" to "onError"),
27
+ )
28
+
29
+ companion object { const val NAME = "RNUnityView" }
30
+ }
@@ -0,0 +1,58 @@
1
+ package com.hugebug4ever.rnunity
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.content.res.Configuration
6
+ import android.view.View
7
+ import android.view.ViewGroup
8
+ import com.unity3d.player.IUnityPlayerLifecycleEvents
9
+ import com.unity3d.player.UnityPlayer
10
+ import com.unity3d.player.UnityPlayerForActivityOrService
11
+
12
+ internal interface UnityPlayerAdapter {
13
+ val rootView: View
14
+ fun start()
15
+ fun pause()
16
+ fun resume()
17
+ fun stop()
18
+ fun unload()
19
+ fun destroy()
20
+ fun configurationChanged(configuration: Configuration)
21
+ fun windowFocusChanged(hasFocus: Boolean)
22
+ fun newIntent(intent: Intent)
23
+ fun sendMessage(gameObject: String, methodName: String, message: String)
24
+ fun viewDiagnostics(): String
25
+ }
26
+
27
+ internal class Unity6000PlayerAdapter(
28
+ activity: Activity,
29
+ listener: IUnityPlayerLifecycleEvents,
30
+ ) : UnityPlayerAdapter {
31
+ private val player = UnityPlayerForActivityOrService(activity, listener)
32
+ override val rootView: View get() = player.frameLayout
33
+ override fun start() = player.onStart()
34
+ override fun pause() = player.onPause()
35
+ override fun resume() = player.onResume()
36
+ override fun stop() = player.onStop()
37
+ override fun unload() = player.unload()
38
+ override fun destroy() = player.destroy()
39
+ override fun configurationChanged(configuration: Configuration) = player.configurationChanged(configuration)
40
+ override fun windowFocusChanged(hasFocus: Boolean) = player.windowFocusChanged(hasFocus)
41
+ override fun newIntent(intent: Intent) = player.newIntent(intent)
42
+ override fun sendMessage(gameObject: String, methodName: String, message: String) =
43
+ UnityPlayer.UnitySendMessage(gameObject, methodName, message)
44
+ override fun viewDiagnostics(): String {
45
+ val root = player.frameLayout
46
+ val surface = player.surfaceView
47
+ return "root=${describe(root)} children=${root.childCount} " +
48
+ "unityView=${describe(player.view)} surface=${describe(surface)} " +
49
+ "surfaceParent=${surface.parent?.javaClass?.simpleName ?: "null"} " +
50
+ "surfaceValid=${surface.holder.surface.isValid}"
51
+ }
52
+
53
+ private fun describe(view: View): String {
54
+ val children = (view as? ViewGroup)?.childCount?.let { "/$it" }.orEmpty()
55
+ return "${view.javaClass.simpleName}$children[${view.width}x${view.height}," +
56
+ "attached=${view.isAttachedToWindow},visibility=${view.visibility}]"
57
+ }
58
+ }
@@ -0,0 +1,329 @@
1
+ package com.hugebug4ever.rnunity
2
+
3
+ import android.app.Activity
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import android.view.ViewGroup
8
+ import com.facebook.react.bridge.Arguments
9
+ import com.facebook.react.bridge.Promise
10
+ import com.facebook.react.bridge.ReactApplicationContext
11
+ import com.facebook.react.modules.core.DeviceEventManagerModule
12
+ import com.unity3d.player.IUnityPlayerLifecycleEvents
13
+ import java.lang.ref.WeakReference
14
+ import org.json.JSONArray
15
+ import org.json.JSONObject
16
+
17
+ internal object UnityRuntimeController {
18
+ private const val TAG = "RNUnity"
19
+ private const val UNLOAD_TIMEOUT_MS = 15_000L
20
+ private val main = Handler(Looper.getMainLooper())
21
+ private var state = UnityRuntimeState.IDLE
22
+ private var generation = 0
23
+ private var lastTransitionAt = System.currentTimeMillis()
24
+ private var lastError: Map<String, Any?>? = null
25
+ private val pauseReasons = linkedSetOf<PauseReason>()
26
+ private var activity = WeakReference<Activity>(null)
27
+ private var reactContext = WeakReference<ReactApplicationContext>(null)
28
+ private var host = WeakReference<RNUnityHostView>(null)
29
+ private var pendingHost = WeakReference<RNUnityHostView>(null)
30
+ private var player: UnityPlayerAdapter? = null
31
+ private var playerLifecycleStarted = false
32
+ private val startPromises = mutableListOf<Promise>()
33
+ private var unloadPromise: Promise? = null
34
+ private var pendingStart: Pair<String, Promise>? = null
35
+ private var defaultDetachedBehavior = "pause"
36
+ private var unloadTimeout: Runnable? = null
37
+
38
+ fun bind(context: ReactApplicationContext, currentActivity: Activity?) = onMain {
39
+ reactContext = WeakReference(context)
40
+ if (currentActivity != null) activity = WeakReference(currentActivity)
41
+ }
42
+
43
+ fun updateActivity(value: Activity?) = onMain { activity = WeakReference(value) }
44
+
45
+ fun start(detachedBehavior: String, promise: Promise) = onMain {
46
+ defaultDetachedBehavior = if (detachedBehavior == "keepRunning") "keepRunning" else "pause"
47
+ when (state) {
48
+ UnityRuntimeState.IDLE -> startNewGeneration(promise)
49
+ UnityRuntimeState.STARTING -> startPromises += promise
50
+ UnityRuntimeState.RUNNING_DETACHED,
51
+ UnityRuntimeState.RUNNING_ATTACHED,
52
+ UnityRuntimeState.PAUSED_DETACHED,
53
+ UnityRuntimeState.PAUSED_ATTACHED -> promise.resolve(null)
54
+ UnityRuntimeState.UNLOADING -> {
55
+ pendingStart?.second?.reject("E_UNLOADING", "A newer start request replaced this queued request")
56
+ pendingStart = defaultDetachedBehavior to promise
57
+ }
58
+ UnityRuntimeState.QUITTED -> reject(promise, "E_PROCESS_RESTART_REQUIRED", "Unity has quit in this process")
59
+ UnityRuntimeState.FAILED -> reject(promise, "E_NATIVE_INVARIANT", "Unity runtime is in a failed state")
60
+ }
61
+ }
62
+
63
+ private fun startNewGeneration(promise: Promise) {
64
+ val currentActivity = activity.get()
65
+ if (currentActivity == null || currentActivity.isFinishing) {
66
+ reject(promise, "E_NO_ACTIVITY", "A resumed Android Activity is required")
67
+ return
68
+ }
69
+ generation += 1
70
+ transition(UnityRuntimeState.STARTING)
71
+ startPromises += promise
72
+ val callbackGeneration = generation
73
+ try {
74
+ player = Unity6000PlayerAdapter(currentActivity, object : IUnityPlayerLifecycleEvents {
75
+ override fun onUnityPlayerUnloaded() = onMain { completeUnload(callbackGeneration) }
76
+ override fun onUnityPlayerQuitted() = onMain { completeQuit(callbackGeneration) }
77
+ })
78
+ transition(UnityRuntimeState.RUNNING_DETACHED)
79
+ if (defaultDetachedBehavior == "pause" && pendingHost.get() == null) addPauseReason(PauseReason.NO_HOST)
80
+ pendingHost.get()?.let { attachNow(it) }
81
+ startPromises.toList().also { startPromises.clear() }.forEach { it.resolve(null) }
82
+ } catch (error: Throwable) {
83
+ fail("E_RUNTIME_START_FAILED", "Unity failed to start", error)
84
+ startPromises.toList().also { startPromises.clear() }
85
+ .forEach { it.reject("E_RUNTIME_START_FAILED", error.message, error) }
86
+ }
87
+ }
88
+
89
+ fun attach(value: RNUnityHostView) = onMain {
90
+ if (!value.isAttachedToWindow || value.width <= 0 || value.height <= 0) return@onMain
91
+ val current = host.get()
92
+ if (current != null && current.hostId != value.hostId) {
93
+ value.emitError("E_HOST_IN_USE", "Another UnityView is already attached")
94
+ return@onMain
95
+ }
96
+ pendingHost = WeakReference(value)
97
+ when (state) {
98
+ UnityRuntimeState.RUNNING_DETACHED,
99
+ UnityRuntimeState.PAUSED_DETACHED -> attachNow(value)
100
+ UnityRuntimeState.RUNNING_ATTACHED,
101
+ UnityRuntimeState.PAUSED_ATTACHED -> if (current == null || current.hostId == value.hostId) attachNow(value)
102
+ else -> Unit
103
+ }
104
+ }
105
+
106
+ private fun attachNow(value: RNUnityHostView) {
107
+ val root = player?.rootView ?: return
108
+ host = WeakReference(value)
109
+ pendingHost = WeakReference(null)
110
+ value.mountUnityView(root)
111
+ removePauseReason(PauseReason.NO_HOST)
112
+ transition(if (pauseReasons.isEmpty()) UnityRuntimeState.RUNNING_ATTACHED else UnityRuntimeState.PAUSED_ATTACHED)
113
+ value.emit("topReady", mapOf("state" to state.wireName, "generation" to generation, "hostId" to value.hostId))
114
+ val lifecycleGeneration = generation
115
+ root.post {
116
+ if (generation != lifecycleGeneration || host.get()?.hostId != value.hostId || playerLifecycleStarted) return@post
117
+ player?.start()
118
+ playerLifecycleStarted = true
119
+ activity.get()?.let { currentActivity ->
120
+ player?.configurationChanged(currentActivity.resources.configuration)
121
+ }
122
+ player?.windowFocusChanged(true)
123
+ player?.resume()
124
+ if (pauseReasons.isNotEmpty()) player?.pause()
125
+ Log.i(TAG, "generation=$generation event=player-lifecycle-started ${player?.viewDiagnostics()}")
126
+ root.postDelayed({
127
+ if (generation == lifecycleGeneration) {
128
+ Log.i(TAG, "generation=$generation event=player-view-settled ${player?.viewDiagnostics()}")
129
+ }
130
+ }, 1_000L)
131
+ }
132
+ }
133
+
134
+ fun detach(hostId: String) = onMain {
135
+ val current = host.get()
136
+ if (current?.hostId != hostId) {
137
+ if (pendingHost.get()?.hostId == hostId) pendingHost = WeakReference(null)
138
+ return@onMain
139
+ }
140
+ current.unmountUnityView(player?.rootView)
141
+ host = WeakReference(null)
142
+ if (current.detachedBehavior == "pause") addPauseReason(PauseReason.NO_HOST)
143
+ transition(if (pauseReasons.isEmpty()) UnityRuntimeState.RUNNING_DETACHED else UnityRuntimeState.PAUSED_DETACHED)
144
+ }
145
+
146
+ fun pause(promise: Promise) = onMain {
147
+ if (!isRunningOrPaused()) return@onMain reject(promise, "E_RUNTIME_NOT_READY", "Unity is not running")
148
+ addPauseReason(PauseReason.USER)
149
+ promise.resolve(null)
150
+ }
151
+
152
+ fun resume(promise: Promise) = onMain {
153
+ if (!isRunningOrPaused()) return@onMain reject(promise, "E_RUNTIME_NOT_READY", "Unity is not running")
154
+ removePauseReason(PauseReason.USER)
155
+ promise.resolve(null)
156
+ }
157
+
158
+ fun setAppBackground(background: Boolean) = onMain {
159
+ if (!isRunningOrPaused()) return@onMain
160
+ if (background) addPauseReason(PauseReason.APP_BACKGROUND) else removePauseReason(PauseReason.APP_BACKGROUND)
161
+ }
162
+
163
+ private fun addPauseReason(reason: PauseReason) {
164
+ val wasRunning = pauseReasons.isEmpty()
165
+ pauseReasons += reason
166
+ if (wasRunning && playerLifecycleStarted) player?.pause()
167
+ syncPausedState()
168
+ }
169
+
170
+ private fun removePauseReason(reason: PauseReason) {
171
+ if (!pauseReasons.remove(reason)) return
172
+ if (pauseReasons.isEmpty() && playerLifecycleStarted) player?.resume()
173
+ syncPausedState()
174
+ }
175
+
176
+ private fun syncPausedState() {
177
+ if (!isRunningOrPaused()) return
178
+ val attached = host.get() != null
179
+ transition(
180
+ if (pauseReasons.isEmpty()) {
181
+ if (attached) UnityRuntimeState.RUNNING_ATTACHED else UnityRuntimeState.RUNNING_DETACHED
182
+ } else {
183
+ if (attached) UnityRuntimeState.PAUSED_ATTACHED else UnityRuntimeState.PAUSED_DETACHED
184
+ },
185
+ )
186
+ }
187
+
188
+ fun unload(promise: Promise) = onMain {
189
+ when (state) {
190
+ UnityRuntimeState.IDLE -> promise.resolve(null)
191
+ UnityRuntimeState.UNLOADING -> reject(promise, "E_UNLOADING", "Unity is already unloading")
192
+ UnityRuntimeState.QUITTED -> reject(promise, "E_PROCESS_RESTART_REQUIRED", "Unity has quit in this process")
193
+ UnityRuntimeState.FAILED -> reject(promise, "E_NATIVE_INVARIANT", "Unity runtime is in a failed state")
194
+ else -> {
195
+ host.get()?.unmountUnityView(player?.rootView)
196
+ host = WeakReference(null)
197
+ pendingHost = WeakReference(null)
198
+ pauseReasons.clear()
199
+ transition(UnityRuntimeState.UNLOADING)
200
+ unloadPromise = promise
201
+ val timeoutGeneration = generation
202
+ unloadTimeout = Runnable {
203
+ if (state == UnityRuntimeState.UNLOADING && generation == timeoutGeneration) {
204
+ fail("E_UNLOAD_TIMEOUT", "Unity did not report unload completion", null)
205
+ unloadPromise?.reject("E_UNLOAD_TIMEOUT", "Unity did not report unload completion")
206
+ unloadPromise = null
207
+ pendingStart?.second?.reject(
208
+ "E_PROCESS_RESTART_REQUIRED",
209
+ "Unity unload timed out; restart the app process before starting Unity again",
210
+ )
211
+ pendingStart = null
212
+ }
213
+ }.also { main.postDelayed(it, UNLOAD_TIMEOUT_MS) }
214
+ player?.unload()
215
+ }
216
+ }
217
+ }
218
+
219
+ private fun completeUnload(callbackGeneration: Int) {
220
+ if (state != UnityRuntimeState.UNLOADING || callbackGeneration != generation) return
221
+ unloadTimeout?.let(main::removeCallbacks)
222
+ unloadTimeout = null
223
+ player = null
224
+ playerLifecycleStarted = false
225
+ transition(UnityRuntimeState.IDLE)
226
+ unloadPromise?.resolve(null)
227
+ unloadPromise = null
228
+ pendingStart?.also { pendingStart = null; defaultDetachedBehavior = it.first; startNewGeneration(it.second) }
229
+ }
230
+
231
+ private fun completeQuit(callbackGeneration: Int) {
232
+ if (callbackGeneration != generation) return
233
+ player = null
234
+ playerLifecycleStarted = false
235
+ transition(UnityRuntimeState.QUITTED)
236
+ unloadPromise?.reject("E_PROCESS_RESTART_REQUIRED", "Unity quit during unload")
237
+ unloadPromise = null
238
+ pendingStart?.second?.reject("E_PROCESS_RESTART_REQUIRED", "Unity has quit in this process")
239
+ pendingStart = null
240
+ }
241
+
242
+ fun sendMessage(gameObject: String, methodName: String, message: String, promise: Promise) = onMain {
243
+ if (!isRunningOrPaused()) return@onMain reject(promise, "E_RUNTIME_NOT_READY", "Unity cannot receive messages in ${state.wireName}")
244
+ try {
245
+ player?.sendMessage(gameObject, methodName, message)
246
+ promise.resolve(null)
247
+ } catch (error: Throwable) {
248
+ reject(promise, "E_NATIVE_INVARIANT", error.message ?: "Unity message failed", error)
249
+ }
250
+ }
251
+
252
+ fun receiveMessage(message: String) = onMain {
253
+ val values = mapOf("generation" to generation, "message" to message)
254
+ emitGlobal("message", values)
255
+ host.get()?.emit("topMessage", values)
256
+ }
257
+
258
+ fun stateName(): String = state.wireName
259
+
260
+ fun diagnostics(): String = JSONObject().apply {
261
+ put("state", state.wireName)
262
+ put("generation", generation)
263
+ host.get()?.let { put("hostId", it.hostId) }
264
+ put("pauseReasons", JSONArray(pauseReasons.map { it.name }))
265
+ put("lastTransitionAt", lastTransitionAt)
266
+ put("lastError", lastError?.let(::JSONObject) ?: JSONObject.NULL)
267
+ put("unityVersion", "6000.3.x")
268
+ put("packageVersion", "0.1.0-next.14")
269
+ }.toString()
270
+
271
+ private fun isRunningOrPaused() = state in setOf(
272
+ UnityRuntimeState.RUNNING_DETACHED, UnityRuntimeState.RUNNING_ATTACHED,
273
+ UnityRuntimeState.PAUSED_DETACHED, UnityRuntimeState.PAUSED_ATTACHED,
274
+ )
275
+
276
+ private fun transition(next: UnityRuntimeState) {
277
+ if (state == next) return
278
+ state = next
279
+ lastTransitionAt = System.currentTimeMillis()
280
+ Log.i(TAG, "generation=$generation state=${state.wireName} event=transition")
281
+ val values = mapOf("state" to state.wireName, "generation" to generation)
282
+ emitGlobal("stateChange", values)
283
+ host.get()?.emit("topStateChange", values)
284
+ }
285
+
286
+ private fun fail(code: String, message: String, cause: Throwable?) {
287
+ transition(UnityRuntimeState.FAILED)
288
+ lastError = errorValues(code, message, cause)
289
+ emitGlobal("error", lastError!!)
290
+ host.get()?.emit("topError", lastError!!)
291
+ Log.e(TAG, "generation=$generation state=failed code=$code", cause)
292
+ }
293
+
294
+ private fun RNUnityHostView.emitError(code: String, message: String) =
295
+ emit("topError", errorValues(code, message, null))
296
+
297
+ private fun errorValues(code: String, message: String, cause: Throwable?) = mapOf(
298
+ "code" to code,
299
+ "message" to message,
300
+ "state" to state.wireName,
301
+ "generation" to generation,
302
+ "nativeCause" to cause?.javaClass?.simpleName,
303
+ )
304
+
305
+ private fun emitGlobal(name: String, values: Map<String, Any?>) {
306
+ val context = reactContext.get() ?: return
307
+ if (!context.hasActiveReactInstance()) return
308
+ val map = Arguments.createMap()
309
+ values.forEach { (key, value) ->
310
+ when (value) {
311
+ null -> map.putNull(key)
312
+ is String -> map.putString(key, value)
313
+ is Int -> map.putInt(key, value)
314
+ is Number -> map.putDouble(key, value.toDouble())
315
+ is Boolean -> map.putBoolean(key, value)
316
+ }
317
+ }
318
+ context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
319
+ .emit("RNUnity:$name", map)
320
+ }
321
+
322
+ private fun reject(promise: Promise, code: String, message: String, cause: Throwable? = null) {
323
+ if (cause == null) promise.reject(code, message) else promise.reject(code, message, cause)
324
+ }
325
+
326
+ private fun onMain(block: () -> Unit) {
327
+ if (Looper.myLooper() == Looper.getMainLooper()) block() else main.post(block)
328
+ }
329
+ }