@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.
- package/README.md +77 -0
- package/android/build.gradle +38 -0
- package/android/consumer-rules.pro +3 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityBridge.kt +5 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityHostView.kt +95 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityPackage.kt +31 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/RNUnityViewManager.kt +30 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/UnityPlayerAdapter.kt +58 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/UnityRuntimeController.kt +329 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/UnityRuntimeModule.kt +46 -0
- package/android/src/main/java/com/hugebug4ever/rnunity/UnityRuntimeState.kt +16 -0
- package/app.plugin.js +2 -0
- package/cli/index.js +140 -0
- package/ios/RNUnityNativeBridge.mm +6 -0
- package/ios/RNUnityRuntimeController.h +29 -0
- package/ios/RNUnityRuntimeController.mm +316 -0
- package/ios/RNUnityView.h +17 -0
- package/ios/RNUnityView.mm +105 -0
- package/ios/UnityRuntime.mm +49 -0
- package/lib/commonjs/UnityRuntime.js +53 -0
- package/lib/commonjs/UnityRuntime.js.map +1 -0
- package/lib/commonjs/UnityView.js +35 -0
- package/lib/commonjs/UnityView.js.map +1 -0
- package/lib/commonjs/index.js +20 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/specs/NativeUnityRuntime.js +9 -0
- package/lib/commonjs/specs/NativeUnityRuntime.js.map +1 -0
- package/lib/commonjs/specs/NativeUnityView.js +10 -0
- package/lib/commonjs/specs/NativeUnityView.js.map +1 -0
- package/lib/commonjs/types.js +6 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/typescript/UnityRuntime.d.ts +12 -0
- package/lib/typescript/UnityRuntime.d.ts.map +1 -0
- package/lib/typescript/UnityView.d.ts +3 -0
- package/lib/typescript/UnityView.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +4 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/specs/NativeUnityRuntime.d.ts +15 -0
- package/lib/typescript/specs/NativeUnityRuntime.d.ts.map +1 -0
- package/lib/typescript/specs/NativeUnityView.d.ts +32 -0
- package/lib/typescript/specs/NativeUnityView.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +63 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package/UnityRuntime.ts +64 -0
- package/package/UnityView.tsx +32 -0
- package/package/index.ts +20 -0
- package/package/specs/NativeUnityRuntime.ts +17 -0
- package/package/specs/NativeUnityView.ts +27 -0
- package/package/types.ts +85 -0
- package/package.json +90 -0
- package/plugin/build/index.js +132 -0
- package/plugin/ios/configure_unity.rb +108 -0
- package/plugin/src/index.ts +175 -0
- package/plugin/tsconfig.json +13 -0
- package/react-native-unity.podspec +34 -0
- package/react-native.config.js +9 -0
- package/unity-package/Editor/Exporter.cs +91 -0
- package/unity-package/Editor/Exporter.cs.meta +2 -0
- package/unity-package/Editor/ReactNativeUnity.Editor.asmdef +8 -0
- package/unity-package/Editor/ReactNativeUnity.Editor.asmdef.meta +7 -0
- package/unity-package/Editor.meta +8 -0
- package/unity-package/Runtime/ReactNativeBridge.cs +48 -0
- package/unity-package/Runtime/ReactNativeBridge.cs.meta +2 -0
- package/unity-package/Runtime/ReactNativeUnity.Runtime.asmdef +6 -0
- package/unity-package/Runtime/ReactNativeUnity.Runtime.asmdef.meta +7 -0
- package/unity-package/Runtime.meta +8 -0
- package/unity-package/package.json +11 -0
- 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,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
|
+
}
|