@dolami-inc/react-native-expo-unity 0.1.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/LICENSE +21 -0
- package/README.md +220 -0
- package/app.plugin.js +82 -0
- package/docs/lifecycle.md +131 -0
- package/docs/messaging.md +117 -0
- package/expo-module.config.json +7 -0
- package/ios/ExpoUnity.podspec +52 -0
- package/ios/ExpoUnityAppDelegateSubscriber.swift +36 -0
- package/ios/ExpoUnityModule.swift +39 -0
- package/ios/ExpoUnityView.swift +115 -0
- package/ios/UnityBridge.h +26 -0
- package/ios/UnityBridge.mm +180 -0
- package/package.json +49 -0
- package/plugin/NativeCallProxy.h +15 -0
- package/plugin/NativeCallProxy.mm +20 -0
- package/src/ExpoUnity.ts +43 -0
- package/src/UnityView.tsx +56 -0
- package/src/index.ts +4 -0
- package/src/types.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fahrezi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# react-native-expo-unity
|
|
2
|
+
|
|
3
|
+
Unity as a Library (UaaL) bridge for React Native / Expo.
|
|
4
|
+
|
|
5
|
+
> ⚠️ **iOS only** — Android support is coming soon.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install react-native-expo-unity
|
|
11
|
+
# or
|
|
12
|
+
yarn add react-native-expo-unity
|
|
13
|
+
# or
|
|
14
|
+
bun add react-native-expo-unity
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { UnityView, type UnityViewRef } from "react-native-expo-unity";
|
|
21
|
+
|
|
22
|
+
const unityRef = useRef<UnityViewRef>(null);
|
|
23
|
+
|
|
24
|
+
<UnityView
|
|
25
|
+
ref={unityRef}
|
|
26
|
+
style={{ flex: 1 }}
|
|
27
|
+
onUnityMessage={(e) => console.log(e.message)}
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
// Send message to Unity
|
|
31
|
+
unityRef.current?.postMessage("GameObject", "Method", "payload");
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## API
|
|
35
|
+
|
|
36
|
+
### `<UnityView />`
|
|
37
|
+
|
|
38
|
+
| Prop | Type | Default | Description |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| `onUnityMessage` | `(e: { message: string }) => void` | — | Message from Unity |
|
|
41
|
+
| `autoUnloadOnUnmount` | `boolean` | `true` | Unload Unity when view unmounts. Set `false` to pause only (keeps state). |
|
|
42
|
+
| `style` | `ViewStyle` | — | Must have dimensions (e.g. `flex: 1`) |
|
|
43
|
+
| `ref` | `UnityViewRef` | — | Imperative methods |
|
|
44
|
+
|
|
45
|
+
### Ref Methods
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
unityRef.current?.postMessage(gameObject, methodName, message)
|
|
49
|
+
unityRef.current?.pauseUnity()
|
|
50
|
+
unityRef.current?.resumeUnity()
|
|
51
|
+
unityRef.current?.unloadUnity()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Standalone Functions
|
|
55
|
+
|
|
56
|
+
Same as ref methods, callable anywhere (operates on the singleton):
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { postMessage, pauseUnity, resumeUnity, unloadUnity, isInitialized } from "react-native-expo-unity";
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Setup
|
|
63
|
+
|
|
64
|
+
### 1. Unity project — add plugin
|
|
65
|
+
|
|
66
|
+
Copy the plugin files into your Unity project:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# From node_modules after install
|
|
70
|
+
cp node_modules/react-native-expo-unity/plugin/NativeCallProxy.h <UnityProject>/Assets/Plugins/iOS/
|
|
71
|
+
cp node_modules/react-native-expo-unity/plugin/NativeCallProxy.mm <UnityProject>/Assets/Plugins/iOS/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2. Unity project — build iOS
|
|
75
|
+
|
|
76
|
+
1. Unity → File → Build Settings → iOS → Build
|
|
77
|
+
2. Open generated Xcode project
|
|
78
|
+
3. Select `NativeCallProxy.h` in Libraries/Plugins/iOS/
|
|
79
|
+
4. Set Target Membership → `UnityFramework` → **Public**
|
|
80
|
+
5. Build `UnityFramework` scheme
|
|
81
|
+
|
|
82
|
+
### 3. Copy build artifacts to your RN project
|
|
83
|
+
|
|
84
|
+
Create `unity/builds/ios/` in your project root and copy:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
mkdir -p unity/builds/ios
|
|
88
|
+
cp -R <unity-build>/UnityFramework.framework unity/builds/ios/
|
|
89
|
+
cp <unity-build>/{baselib.a,il2cpp.a,libGameAssembly.a} unity/builds/ios/
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> Custom path? Set `EXPO_UNITY_PATH` environment variable to your Unity build directory.
|
|
93
|
+
|
|
94
|
+
### 4. Add the config plugin to `app.json`
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"expo": {
|
|
99
|
+
"plugins": [
|
|
100
|
+
"react-native-expo-unity"
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The plugin automatically configures the required Xcode build settings:
|
|
107
|
+
- `ENABLE_BITCODE = NO` — Unity does not support bitcode
|
|
108
|
+
- `CLANG_CXX_LANGUAGE_STANDARD = c++17` — required for Unity headers
|
|
109
|
+
- `FRAMEWORK_SEARCH_PATHS` — adds the Unity build artifacts directory
|
|
110
|
+
|
|
111
|
+
If your Unity artifacts are in a custom path, pass the option:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
["react-native-expo-unity", { "unityPath": "/absolute/path/to/unity/builds/ios" }]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 5. Build
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
expo prebuild --platform ios --clean
|
|
121
|
+
expo run:ios --device
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Lifecycle
|
|
125
|
+
|
|
126
|
+
Unity is a **singleton** — one instance for the entire app.
|
|
127
|
+
|
|
128
|
+
| State | Memory | Re-entry |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| Running | ~200-500MB+ (depends on scene/assets) | Already running |
|
|
131
|
+
| Paused | Same (frozen in memory, no CPU/GPU usage) | `resumeUnity()` — instant, state preserved |
|
|
132
|
+
| Unloaded | ~80-180MB retained (Unity limitation) | Remount `<UnityView />` — ~1-2s reinit, state reset |
|
|
133
|
+
|
|
134
|
+
### Auto behavior
|
|
135
|
+
|
|
136
|
+
| Event | What happens |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `<UnityView />` mounts | Unity initializes and starts rendering |
|
|
139
|
+
| `<UnityView />` unmounts | Unity unloads (or pauses if `autoUnloadOnUnmount={false}`) |
|
|
140
|
+
| App → background | Unity pauses |
|
|
141
|
+
| App → foreground | Unity resumes |
|
|
142
|
+
|
|
143
|
+
### Manual control
|
|
144
|
+
|
|
145
|
+
Screen focus/blur is **not** automatic — handle with `useFocusEffect`:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
useFocusEffect(
|
|
149
|
+
useCallback(() => {
|
|
150
|
+
unityRef.current?.resumeUnity();
|
|
151
|
+
return () => unityRef.current?.pauseUnity();
|
|
152
|
+
}, [])
|
|
153
|
+
);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Messaging
|
|
157
|
+
|
|
158
|
+
### RN → Unity
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
unityRef.current?.postMessage("GameManager", "LoadAvatar", '{"id":"avatar_01"}');
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```csharp
|
|
165
|
+
// Unity C# — on "GameManager" GameObject
|
|
166
|
+
public void LoadAvatar(string json) { /* ... */ }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Unity → RN
|
|
170
|
+
|
|
171
|
+
```csharp
|
|
172
|
+
#if UNITY_IOS && !UNITY_EDITOR
|
|
173
|
+
[DllImport("__Internal")]
|
|
174
|
+
private static extern void sendMessageToMobileApp(string message);
|
|
175
|
+
#endif
|
|
176
|
+
|
|
177
|
+
// Recommended: JSON format
|
|
178
|
+
sendMessageToMobileApp("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/photo.jpg\"}}");
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
<UnityView onUnityMessage={(e) => {
|
|
183
|
+
const msg = JSON.parse(e.message);
|
|
184
|
+
// msg.event, msg.data
|
|
185
|
+
}} />
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
> See [Messaging Guide](docs/messaging.md) for recommended patterns.
|
|
189
|
+
|
|
190
|
+
## Docs
|
|
191
|
+
|
|
192
|
+
- [Lifecycle Deep Dive](docs/lifecycle.md) — navigation scenarios, state management, trade-offs
|
|
193
|
+
- [Messaging Guide](docs/messaging.md) — recommended JSON format, Unity C# + RN examples
|
|
194
|
+
|
|
195
|
+
## Requirements
|
|
196
|
+
|
|
197
|
+
- **Expo SDK 54+**
|
|
198
|
+
- **React Native New Architecture** (Fabric) — old architecture not supported
|
|
199
|
+
- **Physical iOS device** — Unity renders only on device; Simulator shows a placeholder view
|
|
200
|
+
- **Unity build artifacts** — must be copied manually into your project (~2GB, not bundled via npm)
|
|
201
|
+
|
|
202
|
+
## Platform Support
|
|
203
|
+
|
|
204
|
+
| Platform | Status |
|
|
205
|
+
|---|---|
|
|
206
|
+
| iOS Device | ✅ Supported |
|
|
207
|
+
| iOS Simulator | ⚠️ Not supported — renders a placeholder view |
|
|
208
|
+
| Android | 🚧 Coming soon |
|
|
209
|
+
|
|
210
|
+
## Limitations
|
|
211
|
+
|
|
212
|
+
- **Single instance** — only one Unity view at a time, cannot run multiple
|
|
213
|
+
- **Full-screen rendering only** — Unity renders full-screen within its view (Unity limitation)
|
|
214
|
+
- **Memory retention** — after `unloadUnity()`, Unity retains 80-180MB in memory (Unity limitation)
|
|
215
|
+
- **No reload after quit** — if Unity calls `Application.Quit()` on iOS, it cannot be restarted without restarting the app
|
|
216
|
+
- **No hot reload** — native code changes require a full rebuild
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const { withXcodeProject } = require('@expo/config-plugins');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expo Config Plugin for react-native-expo-unity.
|
|
6
|
+
*
|
|
7
|
+
* Automatically injects the Xcode build settings required for
|
|
8
|
+
* Unity as a Library (UaaL) to link and run correctly.
|
|
9
|
+
*
|
|
10
|
+
* @param {import('@expo/config-plugins').ExpoConfig} config
|
|
11
|
+
* @param {{ unityPath?: string }} options
|
|
12
|
+
* unityPath — path to the Unity iOS build artifacts directory.
|
|
13
|
+
* Defaults to `$(PROJECT_DIR)/unity/builds/ios`.
|
|
14
|
+
* Can also be set via the EXPO_UNITY_PATH environment variable.
|
|
15
|
+
*/
|
|
16
|
+
const withExpoUnity = (config, options = {}) => {
|
|
17
|
+
return withXcodeProject(config, (config) => {
|
|
18
|
+
const xcodeProject = config.modResults;
|
|
19
|
+
|
|
20
|
+
const unityPath =
|
|
21
|
+
options.unityPath ||
|
|
22
|
+
process.env.EXPO_UNITY_PATH ||
|
|
23
|
+
'$(PROJECT_DIR)/unity/builds/ios';
|
|
24
|
+
|
|
25
|
+
const configurations = xcodeProject.pbxXCBuildConfigurationSection();
|
|
26
|
+
|
|
27
|
+
for (const key of Object.keys(configurations)) {
|
|
28
|
+
const configuration = configurations[key];
|
|
29
|
+
if (typeof configuration !== 'object' || !configuration.buildSettings) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const settings = configuration.buildSettings;
|
|
34
|
+
|
|
35
|
+
// Unity as a Library does not support bitcode.
|
|
36
|
+
settings['ENABLE_BITCODE'] = 'NO';
|
|
37
|
+
|
|
38
|
+
// Unity headers require C++17.
|
|
39
|
+
settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17';
|
|
40
|
+
|
|
41
|
+
// Add the Unity framework directory to the search paths so that
|
|
42
|
+
// UnityFramework.framework can be found at build time.
|
|
43
|
+
addFrameworkSearchPath(settings, unityPath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return config;
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Appends `unityPath` to FRAMEWORK_SEARCH_PATHS without duplicating it.
|
|
52
|
+
*
|
|
53
|
+
* The xcode npm package stores this setting as either:
|
|
54
|
+
* - undefined (not set)
|
|
55
|
+
* - a plain string: `"$(inherited)"`
|
|
56
|
+
* - a parenthesised list: `("$(inherited)", "/some/path")`
|
|
57
|
+
*
|
|
58
|
+
* @param {Record<string, any>} settings
|
|
59
|
+
* @param {string} unityPath
|
|
60
|
+
*/
|
|
61
|
+
function addFrameworkSearchPath(settings, unityPath) {
|
|
62
|
+
const quoted = `"${unityPath}"`;
|
|
63
|
+
const existing = settings['FRAMEWORK_SEARCH_PATHS'];
|
|
64
|
+
|
|
65
|
+
if (!existing) {
|
|
66
|
+
settings['FRAMEWORK_SEARCH_PATHS'] = `(${quoted}, "$(inherited)")`;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const asStr = String(existing);
|
|
71
|
+
|
|
72
|
+
// Already present — nothing to do.
|
|
73
|
+
if (asStr.includes(unityPath)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strip surrounding parens if present, then rebuild.
|
|
78
|
+
const inner = asStr.replace(/^\(|\)$/g, '').trim();
|
|
79
|
+
settings['FRAMEWORK_SEARCH_PATHS'] = `(${quoted}, ${inner})`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = withExpoUnity;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Lifecycle Deep Dive
|
|
2
|
+
|
|
3
|
+
## Unity States
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Not Initialized ──mount──→ Running ──pause──→ Paused
|
|
7
|
+
↑ │
|
|
8
|
+
└────resume─────────┘
|
|
9
|
+
│
|
|
10
|
+
unload
|
|
11
|
+
│
|
|
12
|
+
↓
|
|
13
|
+
Unloaded ──mount──→ Running (fresh state)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **Pause/Resume** — cheap, instant, state preserved (avatar, scene, camera position)
|
|
17
|
+
- **Unload** — frees most memory, but ~80-180MB retained by Unity. Next mount = full reinit (~1-2s), state reset.
|
|
18
|
+
|
|
19
|
+
## Navigation Scenarios
|
|
20
|
+
|
|
21
|
+
Stack: `Gallery → AR Camera → Add Caption`
|
|
22
|
+
|
|
23
|
+
### Gallery → AR Camera (push)
|
|
24
|
+
|
|
25
|
+
| What happens | Detail |
|
|
26
|
+
|---|---|
|
|
27
|
+
| AR Camera mounts | `<UnityView />` renders |
|
|
28
|
+
| Unity initializes | First time: ~1-2s. If paused (not unloaded): instant resume |
|
|
29
|
+
|
|
30
|
+
### AR Camera → Add Caption (push)
|
|
31
|
+
|
|
32
|
+
| What happens | Detail |
|
|
33
|
+
|---|---|
|
|
34
|
+
| AR Camera stays mounted | React Navigation keeps stack screens alive |
|
|
35
|
+
| AR Camera loses focus | `useFocusEffect` cleanup fires |
|
|
36
|
+
| You call `pauseUnity()` | Unity freezes, state preserved |
|
|
37
|
+
|
|
38
|
+
### Add Caption → AR Camera (back)
|
|
39
|
+
|
|
40
|
+
| What happens | Detail |
|
|
41
|
+
|---|---|
|
|
42
|
+
| AR Camera regains focus | `useFocusEffect` fires |
|
|
43
|
+
| You call `resumeUnity()` | Unity resumes instantly, all state preserved |
|
|
44
|
+
|
|
45
|
+
### AR Camera → Gallery (back)
|
|
46
|
+
|
|
47
|
+
| What happens | Detail |
|
|
48
|
+
|---|---|
|
|
49
|
+
| AR Camera unmounts | Component removed from stack |
|
|
50
|
+
| `autoUnloadOnUnmount=true` | Unity unloads, memory freed, state reset |
|
|
51
|
+
| `autoUnloadOnUnmount=false` | Unity pauses only, state preserved, memory stays |
|
|
52
|
+
|
|
53
|
+
## Patterns
|
|
54
|
+
|
|
55
|
+
### Pattern A: Fresh state every time
|
|
56
|
+
|
|
57
|
+
Unity resets when user leaves. Safest, but slower re-entry.
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
<UnityView
|
|
61
|
+
ref={unityRef}
|
|
62
|
+
style={{ flex: 1 }}
|
|
63
|
+
autoUnloadOnUnmount={true} // default
|
|
64
|
+
onUnityMessage={handleMessage}
|
|
65
|
+
/>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
useFocusEffect(
|
|
70
|
+
useCallback(() => {
|
|
71
|
+
unityRef.current?.resumeUnity();
|
|
72
|
+
return () => unityRef.current?.pauseUnity();
|
|
73
|
+
}, [])
|
|
74
|
+
);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Pattern B: Keep state alive
|
|
78
|
+
|
|
79
|
+
Unity stays in memory for instant re-entry. Better UX, higher memory.
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
<UnityView
|
|
83
|
+
ref={unityRef}
|
|
84
|
+
style={{ flex: 1 }}
|
|
85
|
+
autoUnloadOnUnmount={false} // pause instead of unload
|
|
86
|
+
onUnityMessage={handleMessage}
|
|
87
|
+
/>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
useFocusEffect(
|
|
92
|
+
useCallback(() => {
|
|
93
|
+
unityRef.current?.resumeUnity();
|
|
94
|
+
return () => unityRef.current?.pauseUnity();
|
|
95
|
+
}, [])
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Pattern C: Force reset on blur
|
|
100
|
+
|
|
101
|
+
Guarantee fresh state even if the component doesn't unmount.
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
useFocusEffect(
|
|
105
|
+
useCallback(() => {
|
|
106
|
+
unityRef.current?.resumeUnity();
|
|
107
|
+
return () => unityRef.current?.unloadUnity(); // force unload
|
|
108
|
+
}, [])
|
|
109
|
+
);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Memory
|
|
113
|
+
|
|
114
|
+
| State | Typical memory |
|
|
115
|
+
|---|---|
|
|
116
|
+
| App without Unity | ~100-200MB |
|
|
117
|
+
| Unity running | ~200-500MB+ (depends on scene/assets) |
|
|
118
|
+
| Unity paused | Same as running (frozen in RAM) |
|
|
119
|
+
| Unity unloaded | ~80-180MB retained (Unity limitation) |
|
|
120
|
+
|
|
121
|
+
**Paused Unity does not use CPU/GPU.** No battery drain. Only concern is memory pressure — iOS may kill your app if system memory is low.
|
|
122
|
+
|
|
123
|
+
## Auto vs Manual
|
|
124
|
+
|
|
125
|
+
| Behavior | Handled by | You need to |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| Initialize on mount | Auto | Nothing |
|
|
128
|
+
| Unload/pause on unmount | Auto (configurable via `autoUnloadOnUnmount`) | Nothing |
|
|
129
|
+
| App background/foreground | Auto (pause/resume) | Nothing |
|
|
130
|
+
| Screen focus/blur | **Manual** | Use `useFocusEffect` |
|
|
131
|
+
| Force unload mid-session | **Manual** | Call `unloadUnity()` |
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Messaging Guide
|
|
2
|
+
|
|
3
|
+
The bridge passes raw strings between Unity and React Native. You can use any format, but we recommend **JSON** for consistency.
|
|
4
|
+
|
|
5
|
+
## Recommended Format
|
|
6
|
+
|
|
7
|
+
All messages as JSON with `event` + `data`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"event": "event_name",
|
|
12
|
+
"data": { ... }
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Unity → RN Examples
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{ "event": "unity_ready", "data": {} }
|
|
20
|
+
{ "event": "image_taken", "data": { "path": "/tmp/photo.jpg", "w": 828, "h": 1792 } }
|
|
21
|
+
{ "event": "session_close", "data": { "reason": "user_request" } }
|
|
22
|
+
{ "event": "avatar_loaded", "data": { "id": "avatar_01", "name": "Cat" } }
|
|
23
|
+
{ "event": "error", "data": { "code": "CAMERA_DENIED", "message": "Camera permission denied" } }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### RN → Unity Examples
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{ "event": "load_avatar", "data": { "id": "avatar_01" } }
|
|
30
|
+
{ "event": "set_config", "data": { "quality": "high", "ar_enabled": true } }
|
|
31
|
+
{ "event": "take_photo", "data": {} }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Unity C# Implementation
|
|
35
|
+
|
|
36
|
+
```csharp
|
|
37
|
+
using System.Runtime.InteropServices;
|
|
38
|
+
using UnityEngine;
|
|
39
|
+
|
|
40
|
+
public static class RNBridge
|
|
41
|
+
{
|
|
42
|
+
#if UNITY_IOS && !UNITY_EDITOR
|
|
43
|
+
[DllImport("__Internal")]
|
|
44
|
+
private static extern void sendMessageToMobileApp(string message);
|
|
45
|
+
#endif
|
|
46
|
+
|
|
47
|
+
/// Send a structured event to React Native
|
|
48
|
+
public static void SendEvent(string eventName, object data = null)
|
|
49
|
+
{
|
|
50
|
+
var msg = new EventMessage { @event = eventName, data = data };
|
|
51
|
+
string json = JsonUtility.ToJson(msg);
|
|
52
|
+
|
|
53
|
+
#if UNITY_IOS && !UNITY_EDITOR
|
|
54
|
+
sendMessageToMobileApp(json);
|
|
55
|
+
#endif
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
[System.Serializable]
|
|
59
|
+
private class EventMessage
|
|
60
|
+
{
|
|
61
|
+
public string @event;
|
|
62
|
+
public object data;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Usage:
|
|
67
|
+
// RNBridge.SendEvent("unity_ready");
|
|
68
|
+
// RNBridge.SendEvent("image_taken", new { path = "/tmp/photo.jpg", w = 828, h = 1792 });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## React Native Implementation
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import { UnityView } from "react-native-expo-unity";
|
|
75
|
+
|
|
76
|
+
interface UnityEvent<T = unknown> {
|
|
77
|
+
event: string;
|
|
78
|
+
data: T;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseUnityMessage<T = unknown>(raw: string): UnityEvent<T> | null {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(raw);
|
|
84
|
+
} catch {
|
|
85
|
+
console.warn("[Unity] Invalid message:", raw);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Usage:
|
|
91
|
+
<UnityView
|
|
92
|
+
onUnityMessage={(e) => {
|
|
93
|
+
const msg = parseUnityMessage(e.message);
|
|
94
|
+
if (!msg) return;
|
|
95
|
+
|
|
96
|
+
switch (msg.event) {
|
|
97
|
+
case "unity_ready":
|
|
98
|
+
console.log("Unity is ready");
|
|
99
|
+
break;
|
|
100
|
+
case "image_taken":
|
|
101
|
+
handleImageTaken(msg.data as { path: string; w: number; h: number });
|
|
102
|
+
break;
|
|
103
|
+
case "error":
|
|
104
|
+
handleError(msg.data as { code: string; message: string });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Why JSON?
|
|
112
|
+
|
|
113
|
+
- Consistent structure for all messages
|
|
114
|
+
- Easy to parse on both sides
|
|
115
|
+
- Type-safe with interfaces/classes
|
|
116
|
+
- Extensible — add fields without breaking existing handlers
|
|
117
|
+
- Debuggable — readable in logs
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
# Resolve the Unity build artifacts directory.
|
|
6
|
+
# By default, looks for `unity/builds/ios/` in the app's project root.
|
|
7
|
+
# Override with EXPO_UNITY_PATH environment variable.
|
|
8
|
+
unity_ios_dir = ENV['EXPO_UNITY_PATH'] || File.join(Pod::Config.instance.project_root.to_s, 'unity', 'builds', 'ios')
|
|
9
|
+
|
|
10
|
+
Pod::Spec.new do |s|
|
|
11
|
+
s.name = 'ExpoUnity'
|
|
12
|
+
s.version = package['version']
|
|
13
|
+
s.summary = package['description']
|
|
14
|
+
s.description = package['description']
|
|
15
|
+
s.license = package['license']
|
|
16
|
+
s.author = package['author']
|
|
17
|
+
s.homepage = package['homepage']
|
|
18
|
+
s.platforms = { :ios => '15.1' }
|
|
19
|
+
s.source = { :git => package['repository']['url'], :tag => "v#{s.version}" }
|
|
20
|
+
s.static_framework = true
|
|
21
|
+
|
|
22
|
+
s.dependency 'ExpoModulesCore'
|
|
23
|
+
|
|
24
|
+
s.source_files = '**/*.{h,m,mm,swift}'
|
|
25
|
+
s.exclude_files = 'UnityFramework.framework/**/*'
|
|
26
|
+
|
|
27
|
+
# Copy Unity build artifacts from the app's project into the pod at install time
|
|
28
|
+
s.prepare_command = <<-CMD
|
|
29
|
+
if [ -d "#{unity_ios_dir}" ]; then
|
|
30
|
+
cp -R "#{unity_ios_dir}/UnityFramework.framework" . 2>/dev/null || true
|
|
31
|
+
fi
|
|
32
|
+
CMD
|
|
33
|
+
|
|
34
|
+
# Link UnityFramework if present
|
|
35
|
+
if File.exist?(File.join(unity_ios_dir, 'UnityFramework.framework'))
|
|
36
|
+
s.vendored_frameworks = 'UnityFramework.framework'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
s.pod_target_xcconfig = {
|
|
40
|
+
'HEADER_SEARCH_PATHS' => [
|
|
41
|
+
'"${PODS_TARGET_SRCROOT}/UnityFramework.framework/Headers"',
|
|
42
|
+
"\"#{unity_ios_dir}/UnityFramework.framework/Headers\""
|
|
43
|
+
].join(' '),
|
|
44
|
+
'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17',
|
|
45
|
+
'GCC_PREPROCESSOR_DEFINITIONS' => 'UNITY_FRAMEWORK=1',
|
|
46
|
+
'ENABLE_BITCODE' => 'NO'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
s.user_target_xcconfig = {
|
|
50
|
+
'ENABLE_BITCODE' => 'NO'
|
|
51
|
+
}
|
|
52
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
// Handles app lifecycle events for Unity
|
|
5
|
+
// Auto pause/resume when app goes to background/foreground
|
|
6
|
+
public class ExpoUnityAppDelegateSubscriber: ExpoAppDelegateSubscriber {
|
|
7
|
+
// Track if Unity was running before going to background
|
|
8
|
+
// so we don't resume if it was already paused by the developer
|
|
9
|
+
private static var wasRunningBeforeBackground = false
|
|
10
|
+
|
|
11
|
+
public func applicationWillResignActive(_ application: UIApplication) {
|
|
12
|
+
let bridge = UnityBridge.shared()
|
|
13
|
+
if bridge.isInitialized() {
|
|
14
|
+
ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground = true
|
|
15
|
+
bridge.pause(true)
|
|
16
|
+
NSLog("[ExpoUnity] Auto-paused (app entering background)")
|
|
17
|
+
} else {
|
|
18
|
+
ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground = false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public func applicationDidBecomeActive(_ application: UIApplication) {
|
|
23
|
+
if ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground {
|
|
24
|
+
UnityBridge.shared().pause(false)
|
|
25
|
+
ExpoUnityAppDelegateSubscriber.wasRunningBeforeBackground = false
|
|
26
|
+
NSLog("[ExpoUnity] Auto-resumed (app entering foreground)")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public func applicationWillTerminate(_ application: UIApplication) {
|
|
31
|
+
if UnityBridge.shared().isInitialized() {
|
|
32
|
+
UnityBridge.shared().unload()
|
|
33
|
+
NSLog("[ExpoUnity] Auto-unloaded (app terminating)")
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class ExpoUnityModule: Module {
|
|
4
|
+
public func definition() -> ModuleDefinition {
|
|
5
|
+
Name("ExpoUnity")
|
|
6
|
+
|
|
7
|
+
// Send a message from RN to Unity
|
|
8
|
+
Function("postMessage") { (gameObject: String, methodName: String, message: String) in
|
|
9
|
+
UnityBridge.shared().sendMessage(gameObject, methodName: methodName, message: message)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Pause / resume Unity
|
|
13
|
+
Function("pauseUnity") { (pause: Bool) in
|
|
14
|
+
UnityBridge.shared().pause(pause)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Unload Unity (free memory)
|
|
18
|
+
Function("unloadUnity") {
|
|
19
|
+
UnityBridge.shared().unload()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check if Unity is initialized
|
|
23
|
+
Function("isInitialized") { () -> Bool in
|
|
24
|
+
return UnityBridge.shared().isInitialized()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Event sent from Unity → RN
|
|
28
|
+
Events("onUnityMessage")
|
|
29
|
+
|
|
30
|
+
// The native Unity view
|
|
31
|
+
View(ExpoUnityView.self) {
|
|
32
|
+
Events("onUnityMessage")
|
|
33
|
+
|
|
34
|
+
Prop("autoUnloadOnUnmount") { (view: ExpoUnityView, value: Bool) in
|
|
35
|
+
view.autoUnloadOnUnmount = value
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
class ExpoUnityView: ExpoView {
|
|
5
|
+
private let onUnityMessage = EventDispatcher()
|
|
6
|
+
var autoUnloadOnUnmount: Bool = true
|
|
7
|
+
|
|
8
|
+
required init(appContext: AppContext? = nil) {
|
|
9
|
+
super.init(appContext: appContext)
|
|
10
|
+
|
|
11
|
+
#if targetEnvironment(simulator)
|
|
12
|
+
setupSimulatorPlaceholder()
|
|
13
|
+
#else
|
|
14
|
+
DispatchQueue.main.async { [weak self] in
|
|
15
|
+
self?.setupUnity()
|
|
16
|
+
}
|
|
17
|
+
#endif
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
required init?(coder: NSCoder) {
|
|
21
|
+
fatalError("init(coder:) has not been implemented")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// MARK: - Simulator
|
|
25
|
+
|
|
26
|
+
#if targetEnvironment(simulator)
|
|
27
|
+
private func setupSimulatorPlaceholder() {
|
|
28
|
+
backgroundColor = .black
|
|
29
|
+
|
|
30
|
+
let label = UILabel()
|
|
31
|
+
label.text = "Unity is not available\non iOS Simulator"
|
|
32
|
+
label.textAlignment = .center
|
|
33
|
+
label.numberOfLines = 0
|
|
34
|
+
label.textColor = UIColor(white: 0.6, alpha: 1)
|
|
35
|
+
label.font = .systemFont(ofSize: 14)
|
|
36
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
37
|
+
addSubview(label)
|
|
38
|
+
|
|
39
|
+
NSLayoutConstraint.activate([
|
|
40
|
+
label.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
41
|
+
label.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
42
|
+
label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16),
|
|
43
|
+
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
|
|
44
|
+
])
|
|
45
|
+
}
|
|
46
|
+
#endif
|
|
47
|
+
|
|
48
|
+
// MARK: - Device
|
|
49
|
+
|
|
50
|
+
#if !targetEnvironment(simulator)
|
|
51
|
+
private func setupUnity() {
|
|
52
|
+
let bridge = UnityBridge.shared()
|
|
53
|
+
|
|
54
|
+
if !bridge.isInitialized() {
|
|
55
|
+
bridge.initialize()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
bridge.onMessage = { [weak self] message in
|
|
59
|
+
DispatchQueue.main.async {
|
|
60
|
+
self?.onUnityMessage([
|
|
61
|
+
"message": message
|
|
62
|
+
])
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mountUnityView()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func mountUnityView() {
|
|
70
|
+
guard let unityView = UnityBridge.shared().unityRootView() else {
|
|
71
|
+
NSLog("[ExpoUnity] Unity root view not available yet")
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if let unityWindow = UnityBridge.shared().unityWindow() {
|
|
76
|
+
if let myWindow = self.window, unityWindow != myWindow {
|
|
77
|
+
unityWindow.isHidden = true
|
|
78
|
+
unityWindow.isUserInteractionEnabled = false
|
|
79
|
+
myWindow.makeKeyAndVisible()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
unityView.frame = self.bounds
|
|
84
|
+
if unityView.superview != self {
|
|
85
|
+
self.addSubview(unityView)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override func layoutSubviews() {
|
|
90
|
+
super.layoutSubviews()
|
|
91
|
+
|
|
92
|
+
if let unityView = UnityBridge.shared().unityRootView(),
|
|
93
|
+
unityView.superview == self {
|
|
94
|
+
unityView.frame = self.bounds
|
|
95
|
+
} else {
|
|
96
|
+
mountUnityView()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override func removeFromSuperview() {
|
|
101
|
+
let bridge = UnityBridge.shared()
|
|
102
|
+
bridge.onMessage = nil
|
|
103
|
+
|
|
104
|
+
if autoUnloadOnUnmount && bridge.isInitialized() {
|
|
105
|
+
bridge.unload()
|
|
106
|
+
NSLog("[ExpoUnity] Auto-unloaded (view removed from superview)")
|
|
107
|
+
} else if !autoUnloadOnUnmount && bridge.isInitialized() {
|
|
108
|
+
bridge.pause(true)
|
|
109
|
+
NSLog("[ExpoUnity] Auto-paused on unmount (autoUnloadOnUnmount=false)")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
super.removeFromSuperview()
|
|
113
|
+
}
|
|
114
|
+
#endif
|
|
115
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <UIKit/UIKit.h>
|
|
3
|
+
|
|
4
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
5
|
+
|
|
6
|
+
typedef void (^UnityMessageCallback)(NSString * _Nonnull message);
|
|
7
|
+
|
|
8
|
+
@interface UnityBridge : NSObject
|
|
9
|
+
|
|
10
|
+
@property (nonatomic, copy, nullable) UnityMessageCallback onMessage;
|
|
11
|
+
|
|
12
|
+
+ (instancetype)shared;
|
|
13
|
+
|
|
14
|
+
- (BOOL)isInitialized;
|
|
15
|
+
- (void)initialize;
|
|
16
|
+
- (void)sendMessage:(NSString *)gameObject
|
|
17
|
+
methodName:(NSString *)methodName
|
|
18
|
+
message:(NSString *)message;
|
|
19
|
+
- (void)pause:(BOOL)pause;
|
|
20
|
+
- (void)unload;
|
|
21
|
+
- (nullable UIView *)unityRootView;
|
|
22
|
+
- (nullable UIWindow *)unityWindow;
|
|
23
|
+
|
|
24
|
+
@end
|
|
25
|
+
|
|
26
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#import "UnityBridge.h"
|
|
2
|
+
|
|
3
|
+
// ------------------------------------------------------------------
|
|
4
|
+
// UnityBridge — singleton that owns the UnityFramework lifecycle.
|
|
5
|
+
// Called from Swift via @objc interop.
|
|
6
|
+
//
|
|
7
|
+
// On Simulator, all methods are no-ops because Unity as a Library
|
|
8
|
+
// does not support the iOS Simulator target.
|
|
9
|
+
// ------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
#if TARGET_OS_SIMULATOR
|
|
12
|
+
|
|
13
|
+
// MARK: - Simulator stubs
|
|
14
|
+
|
|
15
|
+
@implementation UnityBridge
|
|
16
|
+
|
|
17
|
+
static UnityBridge *_shared = nil;
|
|
18
|
+
|
|
19
|
+
+ (instancetype)shared {
|
|
20
|
+
static dispatch_once_t onceToken;
|
|
21
|
+
dispatch_once(&onceToken, ^{
|
|
22
|
+
_shared = [[UnityBridge alloc] init];
|
|
23
|
+
});
|
|
24
|
+
return _shared;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
- (BOOL)isInitialized { return NO; }
|
|
28
|
+
- (void)initialize { NSLog(@"[ExpoUnity] Unity is not available on iOS Simulator"); }
|
|
29
|
+
- (nullable UIView *)unityRootView { return nil; }
|
|
30
|
+
- (nullable UIWindow *)unityWindow { return nil; }
|
|
31
|
+
- (void)sendMessage:(NSString *)gameObject methodName:(NSString *)methodName message:(NSString *)message {}
|
|
32
|
+
- (void)pause:(BOOL)pause {}
|
|
33
|
+
- (void)unload {}
|
|
34
|
+
|
|
35
|
+
@end
|
|
36
|
+
|
|
37
|
+
#else // !TARGET_OS_SIMULATOR
|
|
38
|
+
|
|
39
|
+
// MARK: - Device implementation
|
|
40
|
+
|
|
41
|
+
#import <UnityFramework/UnityFramework.h>
|
|
42
|
+
#import <UnityFramework/NativeCallProxy.h>
|
|
43
|
+
|
|
44
|
+
#ifdef DEBUG
|
|
45
|
+
#include <mach-o/ldsyms.h>
|
|
46
|
+
#endif
|
|
47
|
+
|
|
48
|
+
@interface UnityBridge () <NativeCallsProtocol, UnityFrameworkListener>
|
|
49
|
+
|
|
50
|
+
@property (nonatomic, strong, nullable) UnityFramework *ufwInternal;
|
|
51
|
+
|
|
52
|
+
@end
|
|
53
|
+
|
|
54
|
+
@implementation UnityBridge
|
|
55
|
+
|
|
56
|
+
static UnityBridge *_shared = nil;
|
|
57
|
+
|
|
58
|
+
+ (instancetype)shared {
|
|
59
|
+
static dispatch_once_t onceToken;
|
|
60
|
+
dispatch_once(&onceToken, ^{
|
|
61
|
+
_shared = [[UnityBridge alloc] init];
|
|
62
|
+
});
|
|
63
|
+
return _shared;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
- (BOOL)isInitialized {
|
|
67
|
+
return self.ufwInternal != nil && [self.ufwInternal appController] != nil;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
- (void)initialize {
|
|
71
|
+
if ([self isInitialized]) return;
|
|
72
|
+
|
|
73
|
+
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
|
|
74
|
+
bundlePath = [bundlePath stringByAppendingString:@"/Frameworks/UnityFramework.framework"];
|
|
75
|
+
|
|
76
|
+
NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];
|
|
77
|
+
if (![bundle isLoaded]) [bundle load];
|
|
78
|
+
|
|
79
|
+
UnityFramework *ufw = [bundle.principalClass getInstance];
|
|
80
|
+
if (![ufw appController]) {
|
|
81
|
+
#ifdef DEBUG
|
|
82
|
+
[ufw setExecuteHeader:&_mh_dylib_header];
|
|
83
|
+
#else
|
|
84
|
+
[ufw setExecuteHeader:&_mh_execute_header];
|
|
85
|
+
#endif
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
[ufw setDataBundleId:[bundle.bundleIdentifier cStringUsingEncoding:NSUTF8StringEncoding]];
|
|
89
|
+
|
|
90
|
+
// Boot Unity
|
|
91
|
+
NSArray *args = [[NSProcessInfo processInfo] arguments];
|
|
92
|
+
int argc = (int)args.count;
|
|
93
|
+
char **argv = (char **)malloc((argc + 1) * sizeof(char *));
|
|
94
|
+
for (int i = 0; i < argc; i++) {
|
|
95
|
+
argv[i] = strdup([args[i] UTF8String]);
|
|
96
|
+
}
|
|
97
|
+
argv[argc] = NULL;
|
|
98
|
+
|
|
99
|
+
[ufw runEmbeddedWithArgc:1 argv:argv appLaunchOpts:nil];
|
|
100
|
+
[ufw appController].quitHandler = ^{ NSLog(@"[ExpoUnity] Unity quit handler called"); };
|
|
101
|
+
|
|
102
|
+
// Register for callbacks
|
|
103
|
+
[ufw registerFrameworkListener:self];
|
|
104
|
+
[NSClassFromString(@"FrameworkLibAPI") registerAPIforNativeCalls:self];
|
|
105
|
+
|
|
106
|
+
self.ufwInternal = ufw;
|
|
107
|
+
|
|
108
|
+
// Hide Unity's window — we embed its rootView in our own view
|
|
109
|
+
UIWindow *unityWindow = [ufw appController].window;
|
|
110
|
+
if (unityWindow) {
|
|
111
|
+
unityWindow.hidden = YES;
|
|
112
|
+
unityWindow.userInteractionEnabled = NO;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
NSLog(@"[ExpoUnity] Unity initialized");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
- (nullable UIView *)unityRootView {
|
|
119
|
+
if (![self isInitialized]) return nil;
|
|
120
|
+
return [self.ufwInternal appController].rootView;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
- (nullable UIWindow *)unityWindow {
|
|
124
|
+
if (![self isInitialized]) return nil;
|
|
125
|
+
return [self.ufwInternal appController].window;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
- (void)sendMessage:(NSString *)gameObject
|
|
129
|
+
methodName:(NSString *)methodName
|
|
130
|
+
message:(NSString *)message {
|
|
131
|
+
if (![self isInitialized]) return;
|
|
132
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
133
|
+
[self.ufwInternal sendMessageToGOWithName:[gameObject UTF8String]
|
|
134
|
+
functionName:[methodName UTF8String]
|
|
135
|
+
message:[message UTF8String]];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
- (void)pause:(BOOL)pause {
|
|
140
|
+
if (![self isInitialized]) return;
|
|
141
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
142
|
+
[self.ufwInternal pause:pause];
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
- (void)unload {
|
|
147
|
+
NSLog(@"[ExpoUnity] unload called, isInitialized=%d", [self isInitialized]);
|
|
148
|
+
if (![self isInitialized]) return;
|
|
149
|
+
UIWindow *mainWindow = [[[UIApplication sharedApplication] delegate] window];
|
|
150
|
+
if (mainWindow) [mainWindow makeKeyAndVisible];
|
|
151
|
+
[self.ufwInternal unloadApplication];
|
|
152
|
+
NSLog(@"[ExpoUnity] unloadApplication called");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// MARK: - NativeCallsProtocol (Unity → RN)
|
|
156
|
+
|
|
157
|
+
- (void)sendMessageToMobileApp:(NSString *)message {
|
|
158
|
+
if (self.onMessage) {
|
|
159
|
+
self.onMessage(message);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// MARK: - UnityFrameworkListener
|
|
164
|
+
|
|
165
|
+
- (void)unityDidUnload:(NSNotification *)notification {
|
|
166
|
+
NSLog(@"[ExpoUnity] unityDidUnload notification received");
|
|
167
|
+
[self.ufwInternal unregisterFrameworkListener:self];
|
|
168
|
+
self.ufwInternal = nil;
|
|
169
|
+
NSLog(@"[ExpoUnity] ufwInternal set to nil, ready for re-initialize");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
- (void)unityDidQuit:(NSNotification *)notification {
|
|
173
|
+
NSLog(@"[ExpoUnity] Unity did quit");
|
|
174
|
+
[self.ufwInternal unregisterFrameworkListener:self];
|
|
175
|
+
self.ufwInternal = nil;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@end
|
|
179
|
+
|
|
180
|
+
#endif // !TARGET_OS_SIMULATOR
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dolami-inc/react-native-expo-unity",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Unity as a Library (UaaL) bridge for React Native / Expo",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"plugin": "./app.plugin.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"clean": "rm -rf build"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/",
|
|
14
|
+
"ios/",
|
|
15
|
+
"plugin/",
|
|
16
|
+
"docs/",
|
|
17
|
+
"app.plugin.js",
|
|
18
|
+
"expo-module.config.json"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/fahreziadh/react-native-expo-unity.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"expo",
|
|
26
|
+
"react-native",
|
|
27
|
+
"unity",
|
|
28
|
+
"unity-as-a-library",
|
|
29
|
+
"uaal",
|
|
30
|
+
"bridge",
|
|
31
|
+
"ar"
|
|
32
|
+
],
|
|
33
|
+
"author": "Fahrezi <fahrezi@dolami.is>",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/fahreziadh/react-native-expo-unity/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/fahreziadh/react-native-expo-unity#readme",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"expo": ">=54.0.0",
|
|
41
|
+
"react": "*",
|
|
42
|
+
"react-native": "*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/react": "~19.1.10",
|
|
46
|
+
"expo-module-scripts": "~4.0.0",
|
|
47
|
+
"typescript": "~5.8.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
@protocol NativeCallsProtocol
|
|
4
|
+
@required
|
|
5
|
+
|
|
6
|
+
- (void) sendMessageToMobileApp:(NSString*)message;
|
|
7
|
+
|
|
8
|
+
@end
|
|
9
|
+
|
|
10
|
+
__attribute__ ((visibility("default")))
|
|
11
|
+
@interface FrameworkLibAPI : NSObject
|
|
12
|
+
|
|
13
|
+
+(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi;
|
|
14
|
+
|
|
15
|
+
@end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import "NativeCallProxy.h"
|
|
3
|
+
|
|
4
|
+
@implementation FrameworkLibAPI
|
|
5
|
+
|
|
6
|
+
id<NativeCallsProtocol> api = NULL;
|
|
7
|
+
+(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi
|
|
8
|
+
{
|
|
9
|
+
api = aApi;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@end
|
|
13
|
+
|
|
14
|
+
extern "C"
|
|
15
|
+
{
|
|
16
|
+
void sendMessageToMobileApp(const char* message)
|
|
17
|
+
{
|
|
18
|
+
return [api sendMessageToMobileApp:[NSString stringWithUTF8String:message]];
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/ExpoUnity.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { requireNativeModule } from "expo";
|
|
2
|
+
|
|
3
|
+
const ExpoUnityModule = requireNativeModule("ExpoUnity");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Send a message to a Unity GameObject.
|
|
7
|
+
*/
|
|
8
|
+
export function postMessage(
|
|
9
|
+
gameObject: string,
|
|
10
|
+
methodName: string,
|
|
11
|
+
message: string
|
|
12
|
+
): void {
|
|
13
|
+
ExpoUnityModule.postMessage(gameObject, methodName, message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pause Unity rendering and execution.
|
|
18
|
+
*/
|
|
19
|
+
export function pauseUnity(): void {
|
|
20
|
+
ExpoUnityModule.pauseUnity(true);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resume Unity rendering and execution.
|
|
25
|
+
*/
|
|
26
|
+
export function resumeUnity(): void {
|
|
27
|
+
ExpoUnityModule.pauseUnity(false);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Unload Unity and free memory.
|
|
32
|
+
* After this, Unity must be re-initialized (remount the view).
|
|
33
|
+
*/
|
|
34
|
+
export function unloadUnity(): void {
|
|
35
|
+
ExpoUnityModule.unloadUnity();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if Unity is currently initialized.
|
|
40
|
+
*/
|
|
41
|
+
export function isInitialized(): boolean {
|
|
42
|
+
return ExpoUnityModule.isInitialized();
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { requireNativeView } from "expo";
|
|
2
|
+
import { forwardRef, useImperativeHandle, useCallback, useRef } from "react";
|
|
3
|
+
import type { ViewProps } from "react-native";
|
|
4
|
+
import type { UnityMessageEvent } from "./types";
|
|
5
|
+
import { postMessage, pauseUnity, resumeUnity, unloadUnity } from "./ExpoUnity";
|
|
6
|
+
|
|
7
|
+
// Native view from Expo Modules
|
|
8
|
+
const NativeUnityView = requireNativeView("ExpoUnity");
|
|
9
|
+
|
|
10
|
+
export interface UnityViewProps extends ViewProps {
|
|
11
|
+
onUnityMessage?: (event: UnityMessageEvent) => void;
|
|
12
|
+
/**
|
|
13
|
+
* If true (default), Unity will automatically unload when the view unmounts.
|
|
14
|
+
* If false, Unity will only pause on unmount — state is preserved for faster re-mount.
|
|
15
|
+
* Use false when users frequently navigate back and forth to the Unity screen.
|
|
16
|
+
*/
|
|
17
|
+
autoUnloadOnUnmount?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UnityViewRef {
|
|
21
|
+
postMessage: (gameObject: string, methodName: string, message: string) => void;
|
|
22
|
+
pauseUnity: () => void;
|
|
23
|
+
resumeUnity: () => void;
|
|
24
|
+
unloadUnity: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const UnityView = forwardRef<UnityViewRef, UnityViewProps>(
|
|
28
|
+
({ onUnityMessage, autoUnloadOnUnmount = true, ...props }, ref) => {
|
|
29
|
+
const nativeRef = useRef(null);
|
|
30
|
+
|
|
31
|
+
useImperativeHandle(ref, () => ({
|
|
32
|
+
postMessage,
|
|
33
|
+
pauseUnity,
|
|
34
|
+
resumeUnity,
|
|
35
|
+
unloadUnity,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const handleUnityMessage = useCallback(
|
|
39
|
+
(event: { nativeEvent: UnityMessageEvent }) => {
|
|
40
|
+
onUnityMessage?.(event.nativeEvent);
|
|
41
|
+
},
|
|
42
|
+
[onUnityMessage]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<NativeUnityView
|
|
47
|
+
ref={nativeRef}
|
|
48
|
+
onUnityMessage={handleUnityMessage}
|
|
49
|
+
autoUnloadOnUnmount={autoUnloadOnUnmount}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
UnityView.displayName = "UnityView";
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED