@dolami-inc/react-native-expo-unity 0.2.0 → 0.3.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 +73 -21
- 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 +98 -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,23 +71,35 @@ 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)).
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
### 2. Unity project — build for your target platform
|
|
79
|
+
|
|
80
|
+
#### iOS
|
|
79
81
|
|
|
80
82
|
1. Unity → File → Build Settings → iOS → Build
|
|
81
83
|
2. Open generated Xcode project
|
|
82
84
|
3. Select `NativeCallProxy.h` in Libraries/Plugins/iOS/
|
|
83
85
|
4. Set Target Membership → `UnityFramework` → **Public**
|
|
84
86
|
5. **Select the `Data` folder** in the Project Navigator
|
|
85
|
-
6. In the right panel under **Target Membership**, check **`UnityFramework`**
|
|
86
|
-
>
|
|
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`
|
|
87
89
|
7. Build `UnityFramework` scheme
|
|
88
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`)
|
|
98
|
+
|
|
89
99
|
### 3. Copy build artifacts to your RN project
|
|
90
100
|
|
|
101
|
+
#### iOS
|
|
102
|
+
|
|
91
103
|
Create `unity/builds/ios/` in your project root and copy the built framework and static libraries:
|
|
92
104
|
|
|
93
105
|
```bash
|
|
@@ -111,6 +123,18 @@ The podspec references these files **directly by path** — nothing is copied or
|
|
|
111
123
|
|
|
112
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).
|
|
113
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
|
+
|
|
114
138
|
### 4. Add the config plugin to `app.json`
|
|
115
139
|
|
|
116
140
|
```json
|
|
@@ -123,22 +147,37 @@ The podspec references these files **directly by path** — nothing is copied or
|
|
|
123
147
|
}
|
|
124
148
|
```
|
|
125
149
|
|
|
126
|
-
The plugin automatically configures
|
|
150
|
+
The plugin automatically configures:
|
|
151
|
+
|
|
152
|
+
**iOS:**
|
|
127
153
|
- `ENABLE_BITCODE = NO` — Unity does not support bitcode
|
|
128
154
|
- `CLANG_CXX_LANGUAGE_STANDARD = c++17` — required for Unity headers
|
|
129
|
-
- `
|
|
155
|
+
- Embeds `UnityFramework.framework` via a build script phase
|
|
156
|
+
|
|
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`
|
|
130
161
|
|
|
131
|
-
If your Unity artifacts are in
|
|
162
|
+
If your Unity artifacts are in custom paths:
|
|
132
163
|
|
|
133
164
|
```json
|
|
134
|
-
["@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
|
+
}]
|
|
135
169
|
```
|
|
136
170
|
|
|
137
171
|
### 5. Build
|
|
138
172
|
|
|
139
173
|
```bash
|
|
174
|
+
# iOS
|
|
140
175
|
expo prebuild --platform ios --clean
|
|
141
176
|
expo run:ios --device
|
|
177
|
+
|
|
178
|
+
# Android
|
|
179
|
+
expo prebuild --platform android --clean
|
|
180
|
+
expo run:android
|
|
142
181
|
```
|
|
143
182
|
|
|
144
183
|
## Lifecycle
|
|
@@ -189,13 +228,25 @@ public void LoadAvatar(string json) { /* ... */ }
|
|
|
189
228
|
### Unity → RN
|
|
190
229
|
|
|
191
230
|
```csharp
|
|
231
|
+
// iOS — uses extern "C" DllImport
|
|
192
232
|
#if UNITY_IOS && !UNITY_EDITOR
|
|
193
233
|
[DllImport("__Internal")]
|
|
194
234
|
private static extern void sendMessageToMobileApp(string message);
|
|
195
235
|
#endif
|
|
196
236
|
|
|
197
|
-
//
|
|
198
|
-
|
|
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\"}}");
|
|
199
250
|
```
|
|
200
251
|
|
|
201
252
|
```tsx
|
|
@@ -216,16 +267,17 @@ sendMessageToMobileApp("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/pho
|
|
|
216
267
|
|
|
217
268
|
- **Expo SDK 54+**
|
|
218
269
|
- **React Native New Architecture** (Fabric) — old architecture not supported
|
|
219
|
-
- **Physical
|
|
220
|
-
- **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)
|
|
221
272
|
|
|
222
273
|
## Platform Support
|
|
223
274
|
|
|
224
275
|
| Platform | Status |
|
|
225
276
|
|---|---|
|
|
226
|
-
| iOS Device |
|
|
227
|
-
| iOS Simulator |
|
|
228
|
-
| 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) |
|
|
229
281
|
|
|
230
282
|
## Limitations
|
|
231
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,37 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
withXcodeProject,
|
|
3
|
+
withSettingsGradle,
|
|
4
|
+
withProjectBuildGradle,
|
|
5
|
+
withAppBuildGradle,
|
|
6
|
+
} = require('@expo/config-plugins');
|
|
2
7
|
const path = require('path');
|
|
3
8
|
const fs = require('fs');
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Expo Config Plugin for @dolami-inc/react-native-expo-unity.
|
|
7
12
|
*
|
|
13
|
+
* iOS:
|
|
8
14
|
* - Injects required Xcode build settings (bitcode, C++17)
|
|
9
15
|
* - Adds a build phase that embeds UnityFramework.framework into the app
|
|
10
16
|
* bundle at build time (device builds only)
|
|
11
17
|
*
|
|
18
|
+
* Android:
|
|
19
|
+
* - Includes the :unityLibrary Gradle module in settings.gradle
|
|
20
|
+
* - Adds flatDir repos for Unity's .aar/.jar libs in root build.gradle
|
|
21
|
+
* - Adds the unityLibrary dependency and NDK abiFilters in app/build.gradle
|
|
22
|
+
*
|
|
12
23
|
* @param {object} config - Expo config
|
|
13
|
-
* @param {
|
|
14
|
-
*
|
|
15
|
-
* Defaults to `<projectRoot>/unity/builds/ios`.
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {string} [options.unityPath] — absolute path to the Unity iOS build
|
|
26
|
+
* artifacts directory. Defaults to `<projectRoot>/unity/builds/ios`.
|
|
16
27
|
* Can also be set via the EXPO_UNITY_PATH environment variable.
|
|
28
|
+
* @param {string} [options.androidUnityPath] — absolute path to the Unity
|
|
29
|
+
* Android export directory. Defaults to `<projectRoot>/unity/builds/android`.
|
|
30
|
+
* Can also be set via the EXPO_UNITY_ANDROID_PATH environment variable.
|
|
17
31
|
*/
|
|
18
32
|
const withExpoUnity = (config, options = {}) => {
|
|
19
|
-
|
|
33
|
+
// -- iOS --
|
|
34
|
+
config = withXcodeProject(config, (config) => {
|
|
20
35
|
const xcodeProject = config.modResults;
|
|
21
36
|
const projectRoot = config.modRequest.projectRoot;
|
|
22
37
|
|
|
@@ -25,7 +40,7 @@ const withExpoUnity = (config, options = {}) => {
|
|
|
25
40
|
process.env.EXPO_UNITY_PATH ||
|
|
26
41
|
path.join(projectRoot, 'unity', 'builds', 'ios');
|
|
27
42
|
|
|
28
|
-
//
|
|
43
|
+
// Build settings
|
|
29
44
|
const configurations = xcodeProject.pbxXCBuildConfigurationSection();
|
|
30
45
|
for (const key of Object.keys(configurations)) {
|
|
31
46
|
const configuration = configurations[key];
|
|
@@ -37,16 +52,11 @@ const withExpoUnity = (config, options = {}) => {
|
|
|
37
52
|
settings['CLANG_CXX_LANGUAGE_STANDARD'] = '"c++17"';
|
|
38
53
|
}
|
|
39
54
|
|
|
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.
|
|
55
|
+
// Embed UnityFramework via build script phase
|
|
45
56
|
const frameworkSrc = path.join(unityPath, 'UnityFramework.framework');
|
|
46
57
|
if (fs.existsSync(frameworkSrc)) {
|
|
47
58
|
const target = xcodeProject.getFirstTarget();
|
|
48
59
|
|
|
49
|
-
// Shell script that copies and codesigns the framework (device only).
|
|
50
60
|
const script = [
|
|
51
61
|
'if [ "${PLATFORM_NAME}" = "iphoneos" ]; then',
|
|
52
62
|
' FRAMEWORK_SRC="' + frameworkSrc + '"',
|
|
@@ -73,6 +83,82 @@ const withExpoUnity = (config, options = {}) => {
|
|
|
73
83
|
|
|
74
84
|
return config;
|
|
75
85
|
});
|
|
86
|
+
|
|
87
|
+
// -- Android: settings.gradle (include :unityLibrary module) --
|
|
88
|
+
config = withSettingsGradle(config, (config) => {
|
|
89
|
+
const projectRoot = config.modRequest.projectRoot;
|
|
90
|
+
const androidUnityPath =
|
|
91
|
+
options.androidUnityPath ||
|
|
92
|
+
process.env.EXPO_UNITY_ANDROID_PATH ||
|
|
93
|
+
path.join(projectRoot, 'unity', 'builds', 'android');
|
|
94
|
+
|
|
95
|
+
const contents = config.modResults.contents;
|
|
96
|
+
|
|
97
|
+
// Include :unityLibrary module
|
|
98
|
+
const includeSnippet = `include ':unityLibrary'`;
|
|
99
|
+
const projectSnippet = `project(':unityLibrary').projectDir = new File('${androidUnityPath}/unityLibrary')`;
|
|
100
|
+
|
|
101
|
+
if (!contents.includes(includeSnippet)) {
|
|
102
|
+
config.modResults.contents =
|
|
103
|
+
contents +
|
|
104
|
+
`\n// Unity as a Library\n${includeSnippet}\n${projectSnippet}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return config;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// -- Android: build.gradle (flatDir repos for Unity's native libs) --
|
|
111
|
+
// This must go in the root build.gradle (not settings.gradle) because
|
|
112
|
+
// `allprojects` is not a valid method in settings.gradle on Gradle 8+.
|
|
113
|
+
config = withProjectBuildGradle(config, (config) => {
|
|
114
|
+
const flatDirSnippet = `flatDir { dirs "\${project(':unityLibrary').projectDir}/libs" }`;
|
|
115
|
+
if (!config.modResults.contents.includes(flatDirSnippet)) {
|
|
116
|
+
const allProjectsRegex = /allprojects\s*\{[\s\S]*?repositories\s*\{/;
|
|
117
|
+
|
|
118
|
+
if (allProjectsRegex.test(config.modResults.contents)) {
|
|
119
|
+
config.modResults.contents = config.modResults.contents.replace(
|
|
120
|
+
allProjectsRegex,
|
|
121
|
+
(match) => `${match}\n ${flatDirSnippet}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return config;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// -- Android: app/build.gradle --
|
|
130
|
+
config = withAppBuildGradle(config, (config) => {
|
|
131
|
+
let contents = config.modResults.contents;
|
|
132
|
+
|
|
133
|
+
// Add unityLibrary dependency
|
|
134
|
+
const depSnippet = `implementation project(':unityLibrary')`;
|
|
135
|
+
if (!contents.includes(depSnippet)) {
|
|
136
|
+
const depsRegex = /dependencies\s*\{/;
|
|
137
|
+
if (depsRegex.test(contents)) {
|
|
138
|
+
contents = contents.replace(
|
|
139
|
+
depsRegex,
|
|
140
|
+
(match) => `${match}\n ${depSnippet}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Add NDK abiFilters
|
|
146
|
+
const abiSnippet = `ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }`;
|
|
147
|
+
if (!contents.includes('abiFilters')) {
|
|
148
|
+
const defaultConfigRegex = /defaultConfig\s*\{/;
|
|
149
|
+
if (defaultConfigRegex.test(contents)) {
|
|
150
|
+
contents = contents.replace(
|
|
151
|
+
defaultConfigRegex,
|
|
152
|
+
(match) => `${match}\n ${abiSnippet}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
config.modResults.contents = contents;
|
|
158
|
+
return config;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return config;
|
|
76
162
|
};
|
|
77
163
|
|
|
78
164
|
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.1",
|
|
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",
|