Интеграция Unity кода в React Native. Часть 2

Всем привет! На связи снова команда dev.family. Мы продолжаем разбирать тему интеграции Unity-проекта в приложение, написанное на React Native.

В предыдущей серии…

Мы начали с того, что поместили игру на Unity в наше приложение. Как это было, можно почитать в предыдущей статье. Но пока части кода не взаимодействуют друг с другом, значит, работа не закончена. У нас есть кнопка «Save Result». Под нее было бы неплохо написать логику, чтобы показать, что у нас получилось. Спойлер: этим и не только займемся прямо сейчас.

Что будет дальше

Во второй части статьи мы возьмем текущую связку React Native + Unity и сделаем так, чтобы одна часть кода могла получать и обрабатывать сообщения с другой. И наоборот.

Продолжаем наше Unity-journey!

Предупреждение

В материалах статьи мы разобрали процессы работы над тестовым приложением. Это не панацея и не четкое указание, как именно нужно писать код. Но оно классно сработало для нас, и мы будем рады, если вдохновим вас на решение схожих задач в разработке.

Подготовка приложения

Для начала поработаем с кодом на React Native.

const UnityScreen: React.FC> = ({ route, }) => { // Start const unityRef = useRef<UnityView>(null); const {messageToUnity} = route.params; useEffect(() => { if (messageToUnity) { unityRef.current?.postMessage('', '', messageToUnity); } }, [messageToUnity]); const handleUnityMessage = (message: string) => { console.log(message); }; //End return ( <UnityView ref={unityRef} //@ts-expect-error UnityView needs a 'flex: 1' style to show full screen view style={styles.flex} onUnityMessage={e => handleUnityMessage(e.nativeEvent.message)} // and this line /> ); };

Давайте разберем, что именно мы добавили:

  • unityRef – ссылка на наш UnityView, чтобы взаимодействовать с ним;
  • messageToUnity – сообщение при навигации с нашего начального экрана. Именно его мы будем передавать в метод postMessage в Unity;
  • useEffect – проверка наличия messageToUnity или изменений в нем с его дальнейшей передачей в Unity;
  • postMessage – передача сообщения в Unity по gameObject, в methodName наше сообщение;
  • handleUnityMessage – наш метод для обработки сообщения с Unity.

Далее передаем наш unityRef и вызываем handleUnityMessage в UnityView.

Чтобы избежать лишние подсвечивания, добавим:

type RootStackParamList = { [RootRoutes.HOME]: undefined; [RootRoutes.UNITY]: {messageToUnity?: string}; // added messageToUnity };

Теперь на начальный экран (HomeScreen) добавим список с нашими результатами, где будем хранить 10 лучших попыток в игре.

// score data type type Score = { date: string; score: number; }; const HomeScreen: React.FC<RootStackScreenProps<RootRoutes.HOME>> = ({ navigation, }) => { const [scores, setScores] = useState<Score[]>([]); // scores to display in list const insets = useSafeAreaInsets(); //List item to render const renderScore: ListRenderItem<Score> = useCallback(({item, index}) => { return ( <View style={styles.score}> <Text style={styles.scoreText}>{index + 1}.</Text> <Text style={[styles.scoreText, styles.flex]}>{item.score}</Text> <Text style={styles.scoreDate}>{item.date}</Text> </View> ); }, []); return ( <View style={[styles.screen, {paddingBottom: Math.max(insets.bottom, 15)}]}> <Text style={styles.welcomeText}> Hello, from{' '} <Text style={[styles.welcomeText, styles.purple]}>dev.family</Text> team </Text> {/** scoreboard */} <Text style={styles.welcomeText}>Scores 🏆:</Text> <FlatList data={scores} renderItem={renderScore} keyExtractor={i => i.date} style={styles.list} contentContainerStyle={styles.listContent} ListEmptyComponent=<Text>You have no scoreboard yet</Text> <TouchableOpacity style={styles.button} onPress={() => { navigation.navigate(RootRoutes.UNITY, {messageToUnity: ''}); }}> <Text style={styles.buttonText}>Go Unity</Text> </TouchableOpacity> </View> ); };

Теперь перейдем в Unity Editor.

Подготовка игры

Перед тем, как настроить получение сообщений, давайте добавим новое поле, которое будет отображать наш лучший результат.

Интеграция Unity кода в React Native. Часть 2

Тут вставим два текстовых поля:

  • Best – просто текст;
  • Best Score Text в Canvas – для хранения значения нашего лучшего результата.

Вот, как это выглядит на экране:

Интеграция Unity кода в React Native. Часть 2

Теперь давайте обсудим, что мы хотим получить от отправки сообщений в/из Unity:

  1. Нажатие на кнопку «Save result» должно перенести нас на главный экран, но предварительно передать наши очки с Unity (onUnityMessage), после чего обработать и записать их в нашу статистику.
  2. Запись лучшего результата в поле bestScore (как раз по вызову нашего метода postMessage) при следующем попадании на экран с игрой, и так по кругу.

Но для начала необходимо разобраться, как отправлять и обрабатывать сообщения с Unity.

Отправка сообщений с Unity

Первое, что потребуется, – добавить скрипт. Назовем его MessageToReactScript. В него мы вставим следующий код, он хранится на странице GitHub самой библиотеки:

using System; using System.Collections; using System.Collections.Generic; using System.Runtime.InteropServices; using UnityEngine.UI; using UnityEngine; public class NativeAPI { #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] public static extern void sendMessageToMobileApp(string message); #endif } public class ButtonBehavior : MonoBehaviour { public void ButtonPressed() { if (Application.platform == RuntimePlatform.Android) { using (AndroidJavaClass jc = new AndroidJavaClass("com.azesmwayreactnativeunity.ReactNativeUnityViewManager")) { jc.CallStatic("sendMessageToMobileApp", "The button has been tapped!"); } } else if (Application.platform == RuntimePlatform.IPhonePlayer) { #if UNITY_IOS && !UNITY_EDITOR NativeAPI.sendMessageToMobileApp("The button has been tapped!"); #endif } } }

Это скрипт понадобится для отправки сообщения с Unity.

Теперь немного модифицируем его, чтобы получить нужный результат:

using System; using System.Collections; using System.Collections.Generic; using System.Runtime.InteropServices; using UnityEngine.UI; using UnityEngine; public class NativeAPI { #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] public static extern void sendMessageToMobileApp(string message); #endif } //Score class public class Score { public int score; public string date; public Score(int score, string date) { this.score = score; this.date = date; } } public class MessageToReactScript : MonoBehaviour { private Text _score; public void ButtonPressed() { //getting current date in ISO format var date = DateTime.Now.ToString("o"); //getting current score from UI _score = GameObject.FindGameObjectWithTag("Score").GetComponent(); //creating an instance of Score class Score score = new(int.Parse(_score.text), date); //pare score object to json (we sending a string) string scoreJSON = JsonUtility.ToJson(score); print(scoreJSON); if (Application.platform == RuntimePlatform.Android) { using (AndroidJavaClass jc = new AndroidJavaClass("com.azesmwayreactnativeunity.ReactNativeUnityViewManager")) { //send message to android jc.CallStatic("sendMessageToMobileApp", scoreJSON); } } else if (Application.platform == RuntimePlatform.IPhonePlayer) { #if UNITY_IOS && !UNITY_EDITOR //send message to iOS NativeAPI.sendMessageToMobileApp(scoreJSON); #endif } } }

Обратите внимание: в примере представлена возможность отправлять только строку. Поэтому будем передавать все в JSON формате, чтобы не усложнять себе жизнь.

Далее добавляем новый класс, описывающий наш рекорд. У него есть два поля – date и score. Текущие счет и дата записываются непосредственно в score. Все, что нужно сделать дальше, – обернуть Score instance в JSON и отправить в мобильное приложение.

Скрипт готов. Теперь создаем новый GameObject ReactBridge:

Интеграция Unity кода в React Native. Часть 2

Помещаем в него наш скрипт:

Интеграция Unity кода в React Native. Часть 2

Переходим в Canvas и находим кнопку «Save Result», которую создали еще в прошлый раз. Помещаем в нее наш метод ButtonPressed из сниппета описанного выше: вначале добавляем GameObject ReactBridge, выбираем script MessageToReactScript и далее сам метод ButtonPressed():

Интеграция Unity кода в React Native. Часть 2

Теперь при нажатии на кнопку «Save Result» мы будем отправлять JSON c нашим результатом в мобильное приложение.

На этом основная подготовка к отправке сообщения с Unity закончена. Теперь нужно собрать билд и переустановить Unity билд в Android и iOS.

ВАЖНО!
В прошлой части мы добавили в Plugins папку iOS, куда поместили файлы NativeCallProxy.mm и NativeCallProxy.h. с описанием функций, которые будем вызывать. Если они будут отличаться от тех, что мы вызываем в MessageToReactScript, или вовсе отсутствовать, то при сборке UnityFramework в Xcode получим ошибку, что такого метода не существует. Поэтому лучше перепроверить все соответствия заранее.

Для работы можно использовать файлы из репозитория библиотеки, которые лежат здесь.

Обработка сообщений с Unity в мобильной части

Давайте синхронизируемся. На данный момент у нас есть:

  • пример Unity-проекта, которое отправляет сообщение с результатом в наше мобильное приложение.
  • само мобильное приложение, которое, в свою очередь, обрабатывает сообщение с Unity.

Как пересобрать билд и поставить его в мобильное приложение, мы уже рассказали в первой части статьи.

Далее приступим к обработке сообщения. Для начала поставим alert, чтобы убедиться, что при нажатии на кнопку наше сообщение действительно отправляется.

const handleUnityMessage = (message: string) => { //alert to show Unity message data alert(message); const score = JSON.parse(message) as Score; console.log({score}); };

Если делать все, как было описано выше, получим следующий результат:

Что мы видим? Сообщение отправляется, в нем отображаются данные о счете и времени, когда он был получен. Теперь давайте запишем их в наш стейт scores. Но для того, чтобы данные не были потеряны при перезагрузке приложения, мы запишем их в async-storage, перед этим установив саму библиотеку.

Вводим следующие команды:

yarn add @react-native-async-storage/async-storage npx pod-install или cd ios && pod install && cd ..

Возможно, кто-то предпочитает использовать для хранилища более быстрые варианты по типу mmkv, но здесь нам это не критично. Поэтому нам вполне хватит async-storage.

Теперь снова поработаем с кодом на React Native – перейдем на главный экран и запишем в список результатов новое значение.

Для этого перепишем обработку сообщения Unity следующим образом:

const handleUnityMessage = (message: string) => { //alert to show Unity message data const score = JSON.parse(message) as Score; //parse message to Score Object if (score) { unityRef.current?.unloadUnity();//unload Unity View navigation.navigate(RootRoutes.HOME, {score}); //going to Home Screen with recent score } };

Тут мы парсим наш JSON с результатом и после переходим на HomeScreen, передавая в параметрах сам результат.

В наши типы навигации мы также можем добавить данное изменение (оно поможет избежать ошибок от typescript):

type RootStackParamList = { [RootRoutes.HOME]: {score?: Score}; // can get Score from Unity Screen [RootRoutes.UNITY]: {messageToUnity?: string}; // added messageToUnity };

Далее мы модифицируем HomeScreen, чтобы при получении результата через навигацию, он записывал нам в storage и state новые данные. Важный момент: в нашем случае мы храним только 10 лучших результатов в порядке убывания. Вы же можете отображать сколько угодно в любой удобной последовательности.

Вот, что у нас вышло:

//func to setup scores from async storage on app open (we have no scores) const setupScores = async () => { const scoresJSON = await AsyncStorage.getItem('scores'); if (scoresJSON) { setScores(JSON.parse(scoresJSON) as Score[]); } }; //setting up existed scores useEffect(() => { if (!scores.length) { setupScores(); } }); const setNewScores = async (score: Score) => { //creating new scores with new one, includes filter & sort to show only 10 best results const newScores = [...scores, score] .sort((a, b) => b.score - a.score) .slice(0, 10); //setting new scores to async storage await AsyncStorage.setItem('scores', JSON.stringify(newScores)); //setting new scores to scores' state setScores(newScores); //clean navigation score param navigation.setParams({score: undefined}); }; useEffect(() => { if (route.params?.score) { setNewScores(route.params.score); } }, [route.params]);

В данном блоке кода мы делаем следующее:

  1. Достаем наши прошлые результаты и записываем их, если они имеются.
  2. В useEffect смотрим, что у нас нет данных (в scores). Если это так, то записываем предыдущие сохраненные в кэше данные.
  3. Записываем новые результаты, учитывая полученные через навигацию, и чистим параметры.
  4. Смотрим в useEffect, появился ли в параметрах новый результат, и если так, то переписываем результаты.

Также мы немного работали с UI: добавили обработку даты, чтобы не выводить ее в формате ISO (он смотрится не очень, согласитесь?) и чуть изменили стили. Вы можете сделать то же самое, или поменять интерфейс, как вам угодно.

На выходе мы получили такой результат:

Теперь пересобираем все для Android и проверяем, что все работает, как и должно:

Большая половина нашего функционала готова, и мы смело можем передавать данные с Unity в React Native. Теперь время поработать над другой, не менее важной задачей, – передачей данных из React Native в Unity.

Обработка сообщений с React Native в Unity части

Surprise-surprise! Оказывается, для отправки сообщений с React Native у нас уже была заготовка готового блока кода:

const {messageToUnity} = route.params; useEffect(() => { if (messageToUnity) { unityRef.current?.postMessage('', '', messageToUnity);// right here } }, [messageToUnity]);

Здесь нужно посмотреть, есть ли в параметрах навигации messageToUnity. Если такой имеется, то вызвать функцию postMessage. Она, как видно из описания, принимает в себя следующие аргументы:

(gameObject: string, methodName: string, message: string)

Следовательно, необходимо передать сюда наш игровой объект, указать его функцию и message.

Для этого воспользуемся объектом LogicManager и создадим ему новую функцию для обработки данного сообщения.

Далее переместимся в Unity и внесем последние изменения: в LogicManagerScript добавим новое поле, куда поместим BestScore, а затем создадим непосредственно саму функцию, которая будет изменять его значение:

using UnityEngine; using UnityEngine.UI; using UnityEngine.SceneManagement; using TMPro; public class LogicManagerScript : MonoBehaviour { private int _score; [SerializeField] private Text _scoreText; [SerializeField] private GameObject _gameOverScreen; //best score field where we write value from RN part public TextMeshProUGUI bestScoreText; [SerializeField] private GameObject _startScreen; [SerializeField] private GameObject _game; [ContextMenu("Increase Score")] public void IncreaseScore(int number) { _score += number; _scoreText.text = _score.ToString(); } //rewrite bestScoreText value with RN message public void SetBestScore(string message) { bestScoreText.text = message; } public void RestartGame() { SceneManager.LoadScene(SceneManager.GetActiveScene().name); } public void GameOver() { _gameOverScreen.SetActive(true); } public void StartGame() { _startScreen.SetActive(false); _game.SetActive(true); } }

Тут мы добавили:

  • bestScoreText – текст, который достаем с UI;
  • public void SetBestScore – функцию, которая принимает и помещает в текстовое значение bestScoreText message, передаваемый из React Native посредством функции postMessage.

Далее в Unity Editor поместим в наш LogicManagerScript (который находится внутри GameObject LogicManager ) текстовый элемент, чтобы значение нашего BestScoreText менялось.

Интеграция Unity кода в React Native. Часть 2

Собственно на этом вся наша работа в Unity закончена. Но вы всегда можете добавить что-то еще или улучшить написанный код.

Осталось только в очередной раз пересобрать билд 🙂

Возвращаемся в React Native. Сначала изменим нашу функцию для перехода на страницу с Unity так, чтобы при наличии результатов мы доставали наивысшие из них и передавали в нашу игру.

Добавляем следующую функцию в HomeScreen:

const goUnity = () => { let messageToUnity = '0';// set default value to 0 if (scores.length) { // if we have scores select max value // element with 0 index is the highest because we’ve sorted our scores messageToUnity = scores[0].score.toString(); } //go to Unity with max score = messageToUnity navigation.navigate(RootRoutes.UNITY, {messageToUnity}); };

После этого наш экран с Unity всегда будет ждать message. Далее передаем эту функцию в onPress нашей кнопки «Go Unity»:

<TouchableOpacity style={styles.button} onPress={goUnity}>//added a method <Text style={styles.buttonText}>Go Unity</Text> </TouchableOpacity>

Добавляем значения в функцию для отправки сообщения в Unity. В UnityScreen изменяем/добавляем следующее:

const {messageToUnity} = route.params; // getting our message from route params //creating message object (not necessary) const message = { gameObject: 'LogicManager', method: 'SetBestScore', message: messageToUnity, }; //on getting message from route posting it to Unity useEffect(() => { if (messageToUnity) { unityRef.current?.postMessage( message.gameObject, message.method, message.message, ); } }, [messageToUnity]);

В данном примере мы указываем, что gameObject и methodName, которым мы передаем наш message – это LogicManager и SetBestScore, соответственно. В то же время наш message хранит в себе наилучший результат, передаваемый с экрана HomeScreen (наш message – это строка, которая может хранить в себе не только текст, но и JSON-объекты).

А теперь давайте запустим приложения на iOS и Android и проверим, что все работает как мы и ожидали:

iOS:

Android:

Всё работает 🙌

ВАЖНО!

При передаче сообщения в Unity мы столкнулись с небольшой проблемой: после отправки сообщения ничего не происходило. Оказалось, что дело в нашем начальном экране в Unity. Так как это была отдельная сцена, там не было LogicManager, и наш текст не отображался. Поэтому мы немного изменили код и поместили начальный экран в Canvas сцены самой игры. Это не самое красивое решение, но нам оно подошло. Поэтому, когда будете вызывать функцию postMessage, убедитесь, что открыта нужная сцена.

Заключение

Нам понадобилось всего две подробных статьи, чтобы разобрать вариант интеграции Unity-проекта в приложение, написанное на React Native. Мы пошагово расписали алгоритм, по которому работали сами. Надеемся, наш опыт облегчит решение похожих задач или вдохновит на новые проекты. Все примеры кода вы найдете в блоке с ссылками ниже. Вы можете узнать о процессе чуть больше, изучив материалы про плагины в Unity.

А здесь представляем вам конечный код React Native (да, мы что-то меняли на ходу):

import React, { useCallback, useEffect, useRef, useState } from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { FlatList, ListRenderItem, StatusBar, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { SafeAreaProvider, useSafeAreaInsets, } from 'react-native-safe-area-context'; import UnityView from '@azesmway/react-native-unity'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; enum RootRoutes { HOME = 'Home', UNITY = 'Unity', } type RootStackParamList = { [RootRoutes.HOME]: { score?: Score }; [RootRoutes.UNITY]: { messageToUnity: string }; // added messageToUnity }; type RootStackScreenProps = NativeStackScreenProps; const Stack = createNativeStackNavigator(); // score data type type Score = { date: string; score: number; }; const HomeScreen: React.FC> = ({ navigation, route, }) => { const [scores, setScores] = useState([]); // scores to display in list const insets = useSafeAreaInsets(); // func to setup scores from async storage on app open (we have no scores) const setupScores = async () => { const scoresJSON = await AsyncStorage.getItem('scores'); if (scoresJSON) { setScores(JSON.parse(scoresJSON) as Score[]); } }; // setting up existed scores useEffect(() => { if (!scores.length) { setupScores(); } }, []); const setNewScores = async (score: Score) => { // creating new scores with new one, includes filter & sort to show only 10 best results const newScores = [...scores, score] .sort((a, b) => b.score - a.score) .slice(0, 10); // setting new scores to async storage await AsyncStorage.setItem('scores', JSON.stringify(newScores)); // setting new scores to scores' state setScores(newScores); // clean navigation score param navigation.setParams({ score: undefined }); }; useEffect(() => { if (route.params?.score) { setNewScores(route.params.score); } }, [route.params]); const goUnity = () => { let messageToUnity = '0'; if (scores.length) { messageToUnity = scores[0].score.toString(); } navigation.navigate(RootRoutes.UNITY, { messageToUnity }); }; // List item to render const renderScore: ListRenderItem = useCallback(({ item, index }) => { return ( <View style={styles.score}> <Text style={styles.scoreText}>{index + 1}.</Text> <Text style={[styles.scoreText, styles.flex]}>{item.score}</Text> <Text style={styles.scoreDate}>{new Date(item.date).toLocaleString()}</Text> </View> ); }, []); return ( <View style={[styles.screen, { paddingBottom: Math.max(insets.bottom, 15) }]}> <Text style={styles.welcomeText}> Hello, from{' '} <Text style={[styles.welcomeText, styles.purple]}>dev.family</Text> team </Text> {/* scoreboard */} <Text style={styles.welcomeText}>Scores 🏆:</Text> {!!scores.length && ( <View style={[styles.row, styles.scoreInfo]}> <Text style={[styles.scoreText, styles.flex]}>Score</Text> <Text style={styles.scoreText}>Date</Text> </View> )} <FlatList data={scores} renderItem={renderScore} keyExtractor={(i) => i.date} style={styles.list} contentContainerStyle={styles.listContent} ListEmptyComponent={<Text>You have no scoreboard yet</Text>} /> <TouchableOpacity style={styles.button} onPress={goUnity}> <Text style={styles.buttonText}>Go Unity</Text> </TouchableOpacity> </View> ); }; const UnityScreen: React.FC> = ({ route, navigation }) => { // Start const unityRef = useRef<UnityView>(null); const { messageToUnity } = route.params; const message = { gameObject: 'LogicManager', method: 'SetBestScore', message: messageToUnity, }; useEffect(() => { if (messageToUnity) { unityRef.current?.postMessage(message.gameObject, message.method, message.message); } }, [messageToUnity]); const handleUnityMessage = (json: string) => { // alert to show Unity message data const score = JSON.parse(json) as Score; if (score) { // unityRef.current?.unloadUnity(); navigation.navigate(RootRoutes.HOME, { score }); unityRef.current?.unloadUnity(); } }; // End return ( <View style={styles.flex}> <UnityView ref={unityRef} //@ts-expect-error UnityView needs a 'flex: 1' style to show full screen view style={styles.flex} onUnityMessage={(e) => handleUnityMessage(e.nativeEvent.message)} // and this line /> </View> ); }; const App = () => { return ( <View style={styles.flex}> <StatusBar backgroundColor={'#FFF'} barStyle="dark-content" /> <SafeAreaProvider> <NavigationContainer> <Stack.Screen name={RootRoutes.HOME} component={HomeScreen} /> <Stack.Screen name={RootRoutes.UNITY} component={UnityScreen} /> </Stack.Navigator> </NavigationContainer> </SafeAreaProvider> </View> ); }; const styles = StyleSheet.create({ screen: { flex: 1, paddingHorizontal: 16, gap: 30, paddingTop: 25, }, button: { width: '100%', backgroundColor: 'purple', justifyContent: 'center', alignItems: 'center', height: 50, borderRadius: 16, marginTop: 'auto', }, purple: { color: 'purple' }, buttonText: { color: '#FFF', fontSize: 16, fontWeight: '600', }, welcomeText: { fontSize: 24, color: 'black', fontWeight: '600', }, flex: { flex: 1, }, row: { flexDirection: 'row' }, scoreInfo: { paddingHorizontal: 14 }, score: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingBottom: 6, borderBottomWidth: 1, borderColor: '#bcbcbc', }, scoreText: { fontSize: 18, fontWeight: '500', color: 'black', }, scoreDate: { color: '#262626', fontSize: 16, fontWeight: '400', }, list: { flex: 1, }, listContent: { flexGrow: 1, paddingBottom: 20, gap: 12, }, }); export default App;

На связи была команда dev.family, еще спишемся ;)

Ссылки

  • Репозиторий, в котором можно просмотреть код мобильного приложения: тут.
  • Репозиторий, в котором вы можете найти код игры: тут.
  • Библиотека: тут.

Другие важные статьи о разработке:

Начать дискуссию