@dolami-inc/react-native-expo-unity 0.1.10 → 0.3.0
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 +90 -20
- package/android/build.gradle +39 -0
- package/android/src/main/java/com/expounity/bridge/NativeCallProxy.java +29 -0
- package/android/src/main/java/expo/modules/unity/ExpoUnityModule.kt +62 -0
- package/android/src/main/java/expo/modules/unity/ExpoUnityView.kt +81 -0
- package/android/src/main/java/expo/modules/unity/UnityBridge.kt +126 -0
- package/app.plugin.js +103 -12
- package/docs/lifecycle.md +10 -1
- package/docs/messaging.md +21 -5
- package/expo-module.config.json +4 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Unity as a Library (UaaL) bridge for React Native / Expo.
|
|
4
4
|
|
|
5
|
-
> ⚠️ **iOS only** — Android support is coming soon.
|
|
6
|
-
|
|
7
5
|
## Install
|
|
8
6
|
|
|
9
7
|
```bash
|
|
@@ -61,9 +59,11 @@ import { postMessage, pauseUnity, resumeUnity, unloadUnity, isInitialized } from
|
|
|
61
59
|
|
|
62
60
|
## Setup
|
|
63
61
|
|
|
64
|
-
### 1. Unity project — add
|
|
62
|
+
### 1. Unity project — add plugins
|
|
63
|
+
|
|
64
|
+
Copy the platform bridge files into your Unity project:
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
**iOS:**
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
# From node_modules after install
|
|
@@ -71,28 +71,70 @@ cp node_modules/@dolami-inc/react-native-expo-unity/plugin/NativeCallProxy.h <U
|
|
|
71
71
|
cp node_modules/@dolami-inc/react-native-expo-unity/plugin/NativeCallProxy.mm <UnityProject>/Assets/Plugins/iOS/
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
**Android:**
|
|
75
|
+
|
|
76
|
+
No plugin files need to be copied — the `NativeCallProxy` Java class ships with the module and is available at runtime automatically. Your Unity C# code calls it via `AndroidJavaClass` (see [Messaging Guide](docs/messaging.md)).
|
|
77
|
+
|
|
78
|
+
### 2. Unity project — build for your target platform
|
|
79
|
+
|
|
80
|
+
#### iOS
|
|
75
81
|
|
|
76
82
|
1. Unity → File → Build Settings → iOS → Build
|
|
77
83
|
2. Open generated Xcode project
|
|
78
84
|
3. Select `NativeCallProxy.h` in Libraries/Plugins/iOS/
|
|
79
85
|
4. Set Target Membership → `UnityFramework` → **Public**
|
|
80
|
-
5.
|
|
86
|
+
5. **Select the `Data` folder** in the Project Navigator
|
|
87
|
+
6. In the right panel under **Target Membership**, check **`UnityFramework`**
|
|
88
|
+
> **This is critical.** Without this, the `Data` folder (which contains `global-metadata.dat` and all Unity assets) will NOT be included inside `UnityFramework.framework`. The app will crash at launch with: `Could not open .../global-metadata.dat — IL2CPP initialization failed`
|
|
89
|
+
7. Build `UnityFramework` scheme
|
|
90
|
+
|
|
91
|
+
#### Android
|
|
92
|
+
|
|
93
|
+
1. Unity → File → Build Settings → Android
|
|
94
|
+
2. Check **Export Project** (do not "Build" directly — you need the Gradle project)
|
|
95
|
+
3. Set Scripting Backend to **IL2CPP**
|
|
96
|
+
4. Set Target Architectures: **ARMv7** and **ARM64**
|
|
97
|
+
5. Click **Export** and save to a directory (e.g. `unity/builds/android`)
|
|
81
98
|
|
|
82
99
|
### 3. Copy build artifacts to your RN project
|
|
83
100
|
|
|
84
|
-
|
|
101
|
+
#### iOS
|
|
102
|
+
|
|
103
|
+
Create `unity/builds/ios/` in your project root and copy the built framework and static libraries:
|
|
85
104
|
|
|
86
105
|
```bash
|
|
87
106
|
mkdir -p unity/builds/ios
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
|
|
108
|
+
# Copy the compiled framework (should already contain Data/ inside after step 2.6)
|
|
109
|
+
cp -R <xcode-build-output>/UnityFramework.framework unity/builds/ios/
|
|
110
|
+
|
|
111
|
+
# Copy static libraries from the Unity Xcode project root
|
|
112
|
+
cp <unity-xcode-project>/*.a unity/builds/ios/
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Verify that `Data/` exists inside the framework:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
ls unity/builds/ios/UnityFramework.framework/Data
|
|
119
|
+
# Should show: Managed/ Resources/ etc.
|
|
90
120
|
```
|
|
91
121
|
|
|
92
122
|
The podspec references these files **directly by path** — nothing is copied or embedded into the npm package. Updating your Unity build is as simple as replacing the contents of `unity/builds/ios/` and re-running `pod install`.
|
|
93
123
|
|
|
94
124
|
> Custom path? Set `EXPO_UNITY_PATH` environment variable pointing to your Unity build directory, or pass `unityPath` to the config plugin (see step 4).
|
|
95
125
|
|
|
126
|
+
#### Android
|
|
127
|
+
|
|
128
|
+
The Unity export directory (containing the `unityLibrary` folder) should be at `unity/builds/android/` in your project root. The config plugin will automatically include the `:unityLibrary` Gradle module.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Verify the structure
|
|
132
|
+
ls unity/builds/android/unityLibrary
|
|
133
|
+
# Should show: libs/ src/ build.gradle etc.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
> Custom path? Set `EXPO_UNITY_ANDROID_PATH` environment variable, or pass `androidUnityPath` to the config plugin.
|
|
137
|
+
|
|
96
138
|
### 4. Add the config plugin to `app.json`
|
|
97
139
|
|
|
98
140
|
```json
|
|
@@ -105,22 +147,37 @@ The podspec references these files **directly by path** — nothing is copied or
|
|
|
105
147
|
}
|
|
106
148
|
```
|
|
107
149
|
|
|
108
|
-
The plugin automatically configures
|
|
150
|
+
The plugin automatically configures:
|
|
151
|
+
|
|
152
|
+
**iOS:**
|
|
109
153
|
- `ENABLE_BITCODE = NO` — Unity does not support bitcode
|
|
110
154
|
- `CLANG_CXX_LANGUAGE_STANDARD = c++17` — required for Unity headers
|
|
111
|
-
- `
|
|
155
|
+
- Embeds `UnityFramework.framework` via a build script phase
|
|
112
156
|
|
|
113
|
-
|
|
157
|
+
**Android:**
|
|
158
|
+
- Includes `:unityLibrary` module in `settings.gradle`
|
|
159
|
+
- Adds `flatDir` repository for Unity's native libs
|
|
160
|
+
- Adds `ndk.abiFilters` for `armeabi-v7a` and `arm64-v8a`
|
|
161
|
+
|
|
162
|
+
If your Unity artifacts are in custom paths:
|
|
114
163
|
|
|
115
164
|
```json
|
|
116
|
-
["@dolami-inc/react-native-expo-unity", {
|
|
165
|
+
["@dolami-inc/react-native-expo-unity", {
|
|
166
|
+
"unityPath": "/absolute/path/to/unity/builds/ios",
|
|
167
|
+
"androidUnityPath": "/absolute/path/to/unity/builds/android"
|
|
168
|
+
}]
|
|
117
169
|
```
|
|
118
170
|
|
|
119
171
|
### 5. Build
|
|
120
172
|
|
|
121
173
|
```bash
|
|
174
|
+
# iOS
|
|
122
175
|
expo prebuild --platform ios --clean
|
|
123
176
|
expo run:ios --device
|
|
177
|
+
|
|
178
|
+
# Android
|
|
179
|
+
expo prebuild --platform android --clean
|
|
180
|
+
expo run:android
|
|
124
181
|
```
|
|
125
182
|
|
|
126
183
|
## Lifecycle
|
|
@@ -171,13 +228,25 @@ public void LoadAvatar(string json) { /* ... */ }
|
|
|
171
228
|
### Unity → RN
|
|
172
229
|
|
|
173
230
|
```csharp
|
|
231
|
+
// iOS — uses extern "C" DllImport
|
|
174
232
|
#if UNITY_IOS && !UNITY_EDITOR
|
|
175
233
|
[DllImport("__Internal")]
|
|
176
234
|
private static extern void sendMessageToMobileApp(string message);
|
|
177
235
|
#endif
|
|
178
236
|
|
|
179
|
-
//
|
|
180
|
-
|
|
237
|
+
// Android — uses AndroidJavaClass
|
|
238
|
+
private static void SendToMobile(string message) {
|
|
239
|
+
#if UNITY_IOS && !UNITY_EDITOR
|
|
240
|
+
sendMessageToMobileApp(message);
|
|
241
|
+
#elif UNITY_ANDROID && !UNITY_EDITOR
|
|
242
|
+
using (var proxy = new AndroidJavaClass("com.expounity.bridge.NativeCallProxy")) {
|
|
243
|
+
proxy.CallStatic("sendMessageToMobileApp", message);
|
|
244
|
+
}
|
|
245
|
+
#endif
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Usage:
|
|
249
|
+
SendToMobile("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/photo.jpg\"}}");
|
|
181
250
|
```
|
|
182
251
|
|
|
183
252
|
```tsx
|
|
@@ -198,16 +267,17 @@ sendMessageToMobileApp("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/pho
|
|
|
198
267
|
|
|
199
268
|
- **Expo SDK 54+**
|
|
200
269
|
- **React Native New Architecture** (Fabric) — old architecture not supported
|
|
201
|
-
- **Physical
|
|
202
|
-
- **Unity build artifacts** — must be copied manually into your project (
|
|
270
|
+
- **Physical device** — iOS: Unity renders only on device, Simulator shows a placeholder. Android: physical device or emulator with ARM support.
|
|
271
|
+
- **Unity build artifacts** — must be exported/copied manually into your project (not bundled via npm)
|
|
203
272
|
|
|
204
273
|
## Platform Support
|
|
205
274
|
|
|
206
275
|
| Platform | Status |
|
|
207
276
|
|---|---|
|
|
208
|
-
| iOS Device |
|
|
209
|
-
| iOS Simulator |
|
|
210
|
-
| Android |
|
|
277
|
+
| iOS Device | Supported |
|
|
278
|
+
| iOS Simulator | Not supported — renders a placeholder view |
|
|
279
|
+
| Android Device | Supported |
|
|
280
|
+
| Android Emulator | Supported (ARM-based emulators only) |
|
|
211
281
|
|
|
212
282
|
## Limitations
|
|
213
283
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
apply plugin: 'expo-module-gradle-plugin'
|
|
4
|
+
|
|
5
|
+
group = 'expo.modules.unity'
|
|
6
|
+
version = '0.2.0'
|
|
7
|
+
|
|
8
|
+
android {
|
|
9
|
+
namespace "expo.modules.unity"
|
|
10
|
+
compileSdk 35
|
|
11
|
+
|
|
12
|
+
defaultConfig {
|
|
13
|
+
minSdk 24
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
compileOptions {
|
|
17
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
18
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
kotlinOptions {
|
|
22
|
+
jvmTarget = "17"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolve unity-classes.jar from the Unity Android export.
|
|
27
|
+
// Users set EXPO_UNITY_PATH or it defaults to <rootProject>/unity/builds/android.
|
|
28
|
+
def unityBuildPath = System.getenv("EXPO_UNITY_PATH") ?: "${rootProject.projectDir}/../unity/builds/android"
|
|
29
|
+
def unityClassesJar = file("${unityBuildPath}/unityLibrary/libs/unity-classes.jar")
|
|
30
|
+
|
|
31
|
+
dependencies {
|
|
32
|
+
implementation project(':expo-modules-core')
|
|
33
|
+
|
|
34
|
+
if (unityClassesJar.exists()) {
|
|
35
|
+
compileOnly files(unityClassesJar)
|
|
36
|
+
} else {
|
|
37
|
+
println "[expo-unity] WARNING: unity-classes.jar not found at ${unityClassesJar}. Android build will fail if Unity symbols are referenced."
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package com.expounity.bridge;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bridge for Unity -> React Native messaging on Android.
|
|
5
|
+
*
|
|
6
|
+
* Unity C# code calls this via AndroidJavaClass:
|
|
7
|
+
* var proxy = new AndroidJavaClass("com.expounity.bridge.NativeCallProxy");
|
|
8
|
+
* proxy.CallStatic("sendMessageToMobileApp", message);
|
|
9
|
+
*
|
|
10
|
+
* The module registers a MessageListener during initialization to receive messages.
|
|
11
|
+
*/
|
|
12
|
+
public class NativeCallProxy {
|
|
13
|
+
|
|
14
|
+
public interface MessageListener {
|
|
15
|
+
void onMessage(String message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private static MessageListener listener;
|
|
19
|
+
|
|
20
|
+
public static void registerListener(MessageListener newListener) {
|
|
21
|
+
listener = newListener;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public static void sendMessageToMobileApp(String message) {
|
|
25
|
+
if (listener != null) {
|
|
26
|
+
listener.onMessage(message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package expo.modules.unity
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
class ExpoUnityModule : Module() {
|
|
7
|
+
|
|
8
|
+
override fun definition() = ModuleDefinition {
|
|
9
|
+
Name("ExpoUnity")
|
|
10
|
+
|
|
11
|
+
Events("onUnityMessage")
|
|
12
|
+
|
|
13
|
+
Function("postMessage") { gameObject: String, methodName: String, message: String ->
|
|
14
|
+
UnityBridge.getInstance().sendMessage(gameObject, methodName, message)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Function("pauseUnity") { pause: Boolean ->
|
|
18
|
+
UnityBridge.getInstance().setPaused(pause)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Function("unloadUnity") {
|
|
22
|
+
UnityBridge.getInstance().unload()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Function("isInitialized") {
|
|
26
|
+
UnityBridge.getInstance().isInitialized
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
View(ExpoUnityView::class) {
|
|
30
|
+
Events("onUnityMessage")
|
|
31
|
+
|
|
32
|
+
Prop("autoUnloadOnUnmount") { view: ExpoUnityView, value: Boolean ->
|
|
33
|
+
view.autoUnloadOnUnmount = value
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
OnActivityEntersBackground {
|
|
38
|
+
val bridge = UnityBridge.getInstance()
|
|
39
|
+
if (bridge.isInitialized) {
|
|
40
|
+
bridge.wasRunningBeforeBackground = true
|
|
41
|
+
bridge.setPaused(true)
|
|
42
|
+
} else {
|
|
43
|
+
bridge.wasRunningBeforeBackground = false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
OnActivityEntersForeground {
|
|
48
|
+
val bridge = UnityBridge.getInstance()
|
|
49
|
+
if (bridge.wasRunningBeforeBackground) {
|
|
50
|
+
bridge.setPaused(false)
|
|
51
|
+
bridge.wasRunningBeforeBackground = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
OnActivityDestroys {
|
|
56
|
+
val bridge = UnityBridge.getInstance()
|
|
57
|
+
if (bridge.isInitialized) {
|
|
58
|
+
bridge.unload()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
package expo.modules.unity
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import android.view.ViewGroup
|
|
6
|
+
import android.widget.FrameLayout
|
|
7
|
+
import expo.modules.kotlin.AppContext
|
|
8
|
+
import expo.modules.kotlin.views.ExpoView
|
|
9
|
+
|
|
10
|
+
class ExpoUnityView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
11
|
+
|
|
12
|
+
companion object {
|
|
13
|
+
private const val TAG = "ExpoUnity"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
val onUnityMessage by EventDispatcher()
|
|
17
|
+
var autoUnloadOnUnmount: Boolean = true
|
|
18
|
+
|
|
19
|
+
init {
|
|
20
|
+
// post {} dispatches to main thread. setupUnity -> initialize runs
|
|
21
|
+
// synchronously when already on main thread, so mountUnityView sees the
|
|
22
|
+
// fully-initialized player without a race condition.
|
|
23
|
+
post { setupUnity() }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private fun setupUnity() {
|
|
27
|
+
val activity = appContext.currentActivity ?: run {
|
|
28
|
+
Log.w(TAG, "No activity available for Unity initialization")
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
val bridge = UnityBridge.getInstance()
|
|
33
|
+
|
|
34
|
+
if (!bridge.isInitialized) {
|
|
35
|
+
bridge.initialize(activity)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
bridge.onMessage = { message ->
|
|
39
|
+
post {
|
|
40
|
+
onUnityMessage(mapOf("message" to message))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
mountUnityView()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun mountUnityView() {
|
|
48
|
+
val playerView = UnityBridge.getInstance().unityPlayerView ?: run {
|
|
49
|
+
Log.w(TAG, "Unity player view not available yet")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Remove from previous parent if needed
|
|
54
|
+
(playerView.parent as? ViewGroup)?.removeView(playerView)
|
|
55
|
+
|
|
56
|
+
addView(
|
|
57
|
+
playerView,
|
|
58
|
+
FrameLayout.LayoutParams(
|
|
59
|
+
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
60
|
+
FrameLayout.LayoutParams.MATCH_PARENT
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override fun onDetachedFromWindow() {
|
|
66
|
+
val bridge = UnityBridge.getInstance()
|
|
67
|
+
bridge.onMessage = null
|
|
68
|
+
|
|
69
|
+
if (bridge.isInitialized) {
|
|
70
|
+
if (autoUnloadOnUnmount) {
|
|
71
|
+
bridge.unload()
|
|
72
|
+
Log.i(TAG, "Auto-unloaded (view detached)")
|
|
73
|
+
} else {
|
|
74
|
+
bridge.setPaused(true)
|
|
75
|
+
Log.i(TAG, "Auto-paused on unmount (autoUnloadOnUnmount=false)")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
super.onDetachedFromWindow()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
package expo.modules.unity
|
|
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.View
|
|
8
|
+
import com.expounity.bridge.NativeCallProxy
|
|
9
|
+
import com.unity3d.player.IUnityPlayerLifecycleEvents
|
|
10
|
+
import com.unity3d.player.UnityPlayer
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Singleton managing the UnityPlayer lifecycle.
|
|
14
|
+
* Android equivalent of ios/UnityBridge.mm.
|
|
15
|
+
*/
|
|
16
|
+
class UnityBridge private constructor() : IUnityPlayerLifecycleEvents, NativeCallProxy.MessageListener {
|
|
17
|
+
|
|
18
|
+
companion object {
|
|
19
|
+
private const val TAG = "ExpoUnity"
|
|
20
|
+
|
|
21
|
+
@Volatile
|
|
22
|
+
private var instance: UnityBridge? = null
|
|
23
|
+
|
|
24
|
+
@JvmStatic
|
|
25
|
+
fun getInstance(): UnityBridge {
|
|
26
|
+
return instance ?: synchronized(this) {
|
|
27
|
+
instance ?: UnityBridge().also { instance = it }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
33
|
+
|
|
34
|
+
var unityPlayer: UnityPlayer? = null
|
|
35
|
+
private set
|
|
36
|
+
|
|
37
|
+
var onMessage: ((String) -> Unit)? = null
|
|
38
|
+
|
|
39
|
+
/** Tracked here (not on the Module) so it survives module recreation. */
|
|
40
|
+
var wasRunningBeforeBackground: Boolean = false
|
|
41
|
+
|
|
42
|
+
val isInitialized: Boolean
|
|
43
|
+
get() = unityPlayer != null
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns the UnityPlayer view for embedding. UnityPlayer extends FrameLayout.
|
|
47
|
+
*/
|
|
48
|
+
val unityPlayerView: View?
|
|
49
|
+
get() = unityPlayer
|
|
50
|
+
|
|
51
|
+
fun initialize(activity: Activity) {
|
|
52
|
+
if (isInitialized) return
|
|
53
|
+
|
|
54
|
+
val runInit = Runnable {
|
|
55
|
+
try {
|
|
56
|
+
val player = UnityPlayer(activity, this)
|
|
57
|
+
unityPlayer = player
|
|
58
|
+
|
|
59
|
+
NativeCallProxy.registerListener(this)
|
|
60
|
+
|
|
61
|
+
Log.i(TAG, "Unity initialized")
|
|
62
|
+
} catch (e: Exception) {
|
|
63
|
+
Log.e(TAG, "Failed to initialize Unity", e)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
68
|
+
runInit.run()
|
|
69
|
+
} else {
|
|
70
|
+
mainHandler.post(runInit)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fun sendMessage(gameObject: String, methodName: String, message: String) {
|
|
75
|
+
if (!isInitialized) return
|
|
76
|
+
UnityPlayer.UnitySendMessage(gameObject, methodName, message)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fun setPaused(paused: Boolean) {
|
|
80
|
+
if (!isInitialized) return
|
|
81
|
+
val action = Runnable {
|
|
82
|
+
if (paused) {
|
|
83
|
+
unityPlayer?.pause()
|
|
84
|
+
} else {
|
|
85
|
+
unityPlayer?.resume()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
89
|
+
action.run()
|
|
90
|
+
} else {
|
|
91
|
+
mainHandler.post(action)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fun unload() {
|
|
96
|
+
if (!isInitialized) return
|
|
97
|
+
Log.i(TAG, "unload called")
|
|
98
|
+
val action = Runnable {
|
|
99
|
+
unityPlayer?.unload()
|
|
100
|
+
Log.i(TAG, "unload completed")
|
|
101
|
+
}
|
|
102
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
103
|
+
action.run()
|
|
104
|
+
} else {
|
|
105
|
+
mainHandler.post(action)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// IUnityPlayerLifecycleEvents
|
|
110
|
+
|
|
111
|
+
override fun onUnityPlayerUnloaded() {
|
|
112
|
+
Log.i(TAG, "onUnityPlayerUnloaded")
|
|
113
|
+
unityPlayer = null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override fun onUnityPlayerQuitted() {
|
|
117
|
+
Log.i(TAG, "onUnityPlayerQuitted")
|
|
118
|
+
unityPlayer = null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// NativeCallProxy.MessageListener (Unity -> RN)
|
|
122
|
+
|
|
123
|
+
override fun onMessage(message: String) {
|
|
124
|
+
onMessage?.invoke(message)
|
|
125
|
+
}
|
|
126
|
+
}
|
package/app.plugin.js
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
withXcodeProject,
|
|
3
|
+
withSettingsGradle,
|
|
4
|
+
withAppBuildGradle,
|
|
5
|
+
} = require('@expo/config-plugins');
|
|
2
6
|
const path = require('path');
|
|
3
7
|
const fs = require('fs');
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Expo Config Plugin for @dolami-inc/react-native-expo-unity.
|
|
7
11
|
*
|
|
12
|
+
* iOS:
|
|
8
13
|
* - Injects required Xcode build settings (bitcode, C++17)
|
|
9
14
|
* - Adds a build phase that embeds UnityFramework.framework into the app
|
|
10
15
|
* bundle at build time (device builds only)
|
|
11
16
|
*
|
|
17
|
+
* Android:
|
|
18
|
+
* - Includes the :unityLibrary Gradle module from the Unity export
|
|
19
|
+
* - Adds flatDir repos for Unity's .aar/.jar libs
|
|
20
|
+
* - Adds the unityLibrary dependency and NDK abiFilters
|
|
21
|
+
*
|
|
12
22
|
* @param {object} config - Expo config
|
|
13
|
-
* @param {
|
|
14
|
-
*
|
|
15
|
-
* Defaults to `<projectRoot>/unity/builds/ios`.
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @param {string} [options.unityPath] — absolute path to the Unity iOS build
|
|
25
|
+
* artifacts directory. Defaults to `<projectRoot>/unity/builds/ios`.
|
|
16
26
|
* Can also be set via the EXPO_UNITY_PATH environment variable.
|
|
27
|
+
* @param {string} [options.androidUnityPath] — absolute path to the Unity
|
|
28
|
+
* Android export directory. Defaults to `<projectRoot>/unity/builds/android`.
|
|
29
|
+
* Can also be set via the EXPO_UNITY_ANDROID_PATH environment variable.
|
|
17
30
|
*/
|
|
18
31
|
const withExpoUnity = (config, options = {}) => {
|
|
19
|
-
|
|
32
|
+
// -- iOS --
|
|
33
|
+
config = withXcodeProject(config, (config) => {
|
|
20
34
|
const xcodeProject = config.modResults;
|
|
21
35
|
const projectRoot = config.modRequest.projectRoot;
|
|
22
36
|
|
|
@@ -25,7 +39,7 @@ const withExpoUnity = (config, options = {}) => {
|
|
|
25
39
|
process.env.EXPO_UNITY_PATH ||
|
|
26
40
|
path.join(projectRoot, 'unity', 'builds', 'ios');
|
|
27
41
|
|
|
28
|
-
//
|
|
42
|
+
// Build settings
|
|
29
43
|
const configurations = xcodeProject.pbxXCBuildConfigurationSection();
|
|
30
44
|
for (const key of Object.keys(configurations)) {
|
|
31
45
|
const configuration = configurations[key];
|
|
@@ -37,16 +51,11 @@ const withExpoUnity = (config, options = {}) => {
|
|
|
37
51
|
settings['CLANG_CXX_LANGUAGE_STANDARD'] = '"c++17"';
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
//
|
|
41
|
-
// UnityFramework is a dynamic framework that must be embedded (copied)
|
|
42
|
-
// into the app bundle's Frameworks/ directory, otherwise dyld fails at
|
|
43
|
-
// launch. We use a shell script build phase instead of vendored_frameworks
|
|
44
|
-
// because the pod source may live in a read-only package manager cache.
|
|
54
|
+
// Embed UnityFramework via build script phase
|
|
45
55
|
const frameworkSrc = path.join(unityPath, 'UnityFramework.framework');
|
|
46
56
|
if (fs.existsSync(frameworkSrc)) {
|
|
47
57
|
const target = xcodeProject.getFirstTarget();
|
|
48
58
|
|
|
49
|
-
// Shell script that copies and codesigns the framework (device only).
|
|
50
59
|
const script = [
|
|
51
60
|
'if [ "${PLATFORM_NAME}" = "iphoneos" ]; then',
|
|
52
61
|
' FRAMEWORK_SRC="' + frameworkSrc + '"',
|
|
@@ -73,6 +82,88 @@ const withExpoUnity = (config, options = {}) => {
|
|
|
73
82
|
|
|
74
83
|
return config;
|
|
75
84
|
});
|
|
85
|
+
|
|
86
|
+
// -- Android: settings.gradle --
|
|
87
|
+
config = withSettingsGradle(config, (config) => {
|
|
88
|
+
const projectRoot = config.modRequest.projectRoot;
|
|
89
|
+
const androidUnityPath =
|
|
90
|
+
options.androidUnityPath ||
|
|
91
|
+
process.env.EXPO_UNITY_ANDROID_PATH ||
|
|
92
|
+
path.join(projectRoot, 'unity', 'builds', 'android');
|
|
93
|
+
|
|
94
|
+
const contents = config.modResults.contents;
|
|
95
|
+
|
|
96
|
+
// Include :unityLibrary module
|
|
97
|
+
const includeSnippet = `include ':unityLibrary'`;
|
|
98
|
+
const projectSnippet = `project(':unityLibrary').projectDir = new File('${androidUnityPath}/unityLibrary')`;
|
|
99
|
+
|
|
100
|
+
if (!contents.includes(includeSnippet)) {
|
|
101
|
+
config.modResults.contents =
|
|
102
|
+
contents +
|
|
103
|
+
`\n// Unity as a Library\n${includeSnippet}\n${projectSnippet}\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Add flatDir repos for Unity's native libs
|
|
107
|
+
const flatDirSnippet = `flatDir { dirs "\${project(':unityLibrary').projectDir}/libs" }`;
|
|
108
|
+
if (!config.modResults.contents.includes(flatDirSnippet)) {
|
|
109
|
+
// Insert into dependencyResolutionManagement.repositories or allprojects.repositories
|
|
110
|
+
const repoBlockRegex =
|
|
111
|
+
/dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
|
112
|
+
const allProjectsRegex = /allprojects\s*\{[\s\S]*?repositories\s*\{/;
|
|
113
|
+
|
|
114
|
+
if (repoBlockRegex.test(config.modResults.contents)) {
|
|
115
|
+
config.modResults.contents = config.modResults.contents.replace(
|
|
116
|
+
repoBlockRegex,
|
|
117
|
+
(match) => `${match}\n ${flatDirSnippet}`
|
|
118
|
+
);
|
|
119
|
+
} else if (allProjectsRegex.test(config.modResults.contents)) {
|
|
120
|
+
config.modResults.contents = config.modResults.contents.replace(
|
|
121
|
+
allProjectsRegex,
|
|
122
|
+
(match) => `${match}\n ${flatDirSnippet}`
|
|
123
|
+
);
|
|
124
|
+
} else {
|
|
125
|
+
// Fallback: append a standalone block
|
|
126
|
+
config.modResults.contents +=
|
|
127
|
+
`\nallprojects {\n repositories {\n ${flatDirSnippet}\n }\n}\n`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return config;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// -- Android: app/build.gradle --
|
|
135
|
+
config = withAppBuildGradle(config, (config) => {
|
|
136
|
+
let contents = config.modResults.contents;
|
|
137
|
+
|
|
138
|
+
// Add unityLibrary dependency
|
|
139
|
+
const depSnippet = `implementation project(':unityLibrary')`;
|
|
140
|
+
if (!contents.includes(depSnippet)) {
|
|
141
|
+
const depsRegex = /dependencies\s*\{/;
|
|
142
|
+
if (depsRegex.test(contents)) {
|
|
143
|
+
contents = contents.replace(
|
|
144
|
+
depsRegex,
|
|
145
|
+
(match) => `${match}\n ${depSnippet}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Add NDK abiFilters
|
|
151
|
+
const abiSnippet = `ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }`;
|
|
152
|
+
if (!contents.includes('abiFilters')) {
|
|
153
|
+
const defaultConfigRegex = /defaultConfig\s*\{/;
|
|
154
|
+
if (defaultConfigRegex.test(contents)) {
|
|
155
|
+
contents = contents.replace(
|
|
156
|
+
defaultConfigRegex,
|
|
157
|
+
(match) => `${match}\n ${abiSnippet}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
config.modResults.contents = contents;
|
|
163
|
+
return config;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return config;
|
|
76
167
|
};
|
|
77
168
|
|
|
78
169
|
module.exports = withExpoUnity;
|
package/docs/lifecycle.md
CHANGED
|
@@ -118,7 +118,16 @@ useFocusEffect(
|
|
|
118
118
|
| Unity paused | Same as running (frozen in RAM) |
|
|
119
119
|
| Unity unloaded | ~80-180MB retained (Unity limitation) |
|
|
120
120
|
|
|
121
|
-
**Paused Unity does not use CPU/GPU.** No battery drain. Only concern is memory pressure —
|
|
121
|
+
**Paused Unity does not use CPU/GPU.** No battery drain. Only concern is memory pressure — the OS may kill your app if system memory is low.
|
|
122
|
+
|
|
123
|
+
## Platform Notes
|
|
124
|
+
|
|
125
|
+
The lifecycle behavior is consistent across iOS and Android:
|
|
126
|
+
|
|
127
|
+
- **iOS:** UnityFramework is loaded from a dynamic framework bundle. The app delegate subscriber handles background/foreground transitions.
|
|
128
|
+
- **Android:** UnityPlayer (which extends `FrameLayout`) is managed by a singleton bridge. Activity lifecycle hooks in the Expo module handle background/foreground transitions.
|
|
129
|
+
|
|
130
|
+
On both platforms, Unity is a singleton — only one instance exists per app process.
|
|
122
131
|
|
|
123
132
|
## Auto vs Manual
|
|
124
133
|
|
package/docs/messaging.md
CHANGED
|
@@ -44,15 +44,25 @@ public static class RNBridge
|
|
|
44
44
|
private static extern void sendMessageToMobileApp(string message);
|
|
45
45
|
#endif
|
|
46
46
|
|
|
47
|
+
/// Send a raw string message to React Native
|
|
48
|
+
public static void Send(string message)
|
|
49
|
+
{
|
|
50
|
+
#if UNITY_IOS && !UNITY_EDITOR
|
|
51
|
+
sendMessageToMobileApp(message);
|
|
52
|
+
#elif UNITY_ANDROID && !UNITY_EDITOR
|
|
53
|
+
using (var proxy = new AndroidJavaClass("com.expounity.bridge.NativeCallProxy"))
|
|
54
|
+
{
|
|
55
|
+
proxy.CallStatic("sendMessageToMobileApp", message);
|
|
56
|
+
}
|
|
57
|
+
#endif
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
/// Send a structured event to React Native
|
|
48
61
|
public static void SendEvent(string eventName, object data = null)
|
|
49
62
|
{
|
|
50
63
|
var msg = new EventMessage { @event = eventName, data = data };
|
|
51
64
|
string json = JsonUtility.ToJson(msg);
|
|
52
|
-
|
|
53
|
-
#if UNITY_IOS && !UNITY_EDITOR
|
|
54
|
-
sendMessageToMobileApp(json);
|
|
55
|
-
#endif
|
|
65
|
+
Send(json);
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
[System.Serializable]
|
|
@@ -68,10 +78,16 @@ public static class RNBridge
|
|
|
68
78
|
// RNBridge.SendEvent("image_taken", new { path = "/tmp/photo.jpg", w = 828, h = 1792 });
|
|
69
79
|
```
|
|
70
80
|
|
|
81
|
+
### Platform Details
|
|
82
|
+
|
|
83
|
+
**iOS:** The `sendMessageToMobileApp` function is defined as an `extern "C"` symbol in the `NativeCallProxy.mm` file that you copy into your Unity project's `Assets/Plugins/iOS/`. At runtime, UnityBridge registers itself as the `NativeCallsProtocol` handler and receives the message.
|
|
84
|
+
|
|
85
|
+
**Android:** The `NativeCallProxy` Java class ships with the module at `com.expounity.bridge.NativeCallProxy`. Unity C# code calls it via `AndroidJavaClass` — no additional plugin files need to be copied. At runtime, `UnityBridge` registers itself as a `MessageListener` and receives the message.
|
|
86
|
+
|
|
71
87
|
## React Native Implementation
|
|
72
88
|
|
|
73
89
|
```tsx
|
|
74
|
-
import { UnityView } from "react-native-expo-unity";
|
|
90
|
+
import { UnityView } from "@dolami-inc/react-native-expo-unity";
|
|
75
91
|
|
|
76
92
|
interface UnityEvent<T = unknown> {
|
|
77
93
|
event: string;
|
package/expo-module.config.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dolami-inc/react-native-expo-unity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Unity as a Library (UaaL) bridge for React Native / Expo",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"src/",
|
|
14
14
|
"ios/",
|
|
15
|
+
"android/",
|
|
15
16
|
"plugin/",
|
|
16
17
|
"docs/",
|
|
17
18
|
"app.plugin.js",
|