Как я создал визуальную новеллу для Android за 2 дня

Cover Image for Как я создал визуальную новеллу для Android за 2 дня

На выходных я решил заняться новым проектом и воплотить в жизнь свою идею мобильной игры. Что особенно интересно, в качестве жанра игры я выбрал визуальную новеллу. В этой статье я поделюсь своим опытом создания игры за два дня, а также расскажу, как ChatGPT и Civitai помогли мне со сценарием и артами для игры.

React Native:

При разработке мобильной игры я решил использовать React Native. Этот выбор обусловлен моим увлечением работе с этим фреймворком. Несмотря на наличие множества альтернативных технологий, я отдал предпочтение React Native из-за его множества преимуществ и особенностей.

React Native позволяет создавать кроссплатформенные приложения, совместимые как с iOS, так и с Android. Это значительно экономит время и ресурсы, поскольку одна и та же база кода может использоваться для обеих платформ.

Основные принципы React Native очень похожи на принципы ReactJS. Он предназначен для разработки на JavaScript и TypeScript. Для начала работы с React Native требуются только базовые знания JavaScript и React.

Как написал сценарий при помощи ChatGPT:

Для создания сценария визуальной новеллы я взаимодействовал с ChatGPT в несколько этапов. Сначала я подал на вход идею создания визуальной новеллы и ее тему.

Первый запрос в ChatGPT

На этом этапе ChatGPT предоставил мне несколько дополнительных идей. Далее я начал писать различные запросы, связанные с тематикой игры, пытаясь получить примерный план сценария игры. И мне это удалось.

Второй запрос в ChatGPT

ChatGPT изложил план, похожий на тот, который мне был нужен. Каждый этап плана напоминал отдельные сцены игры. Однако мне все еще нужно было доработать эти сцены. Я кратко описал каждую сцену в ChatGPT. Я получил ответ, разбитый на кадры и диалоги. Если меня что-то не устраивало, я просил ChatGPT это исправить, уточнив, что именно не так и как должно быть. С первого раза не получилось, но после некоторого повторения мне удалось создать вполне приличный сценарий.

Последний запрос в ChatGPT

В определенный момент сценарий разветвился, что усложнило работу, поэтому мне пришлось перенести в Миро готовые сцены и кадры. Там создал диаграмму, чтобы визуализировать ветки сюжета и последовательность сцен.

Схема в miro

Генерация картинок для игры при помощи нейросети:

Существует множество сервисов для генерации изображений с помощью нейронных сетей. Многие из них активно рекламируются и преподносятся как нечто крутое. Обычно они имеют веб-интерфейс, позволяющий создавать изображения прямо в браузере, ничего не устанавливая и не настраивая. Однако, попробовав некоторые из них, я был разочарован. Изображения не подходили для использования в игре. Как бы я ни составлял запрос на генерацию, мне не удалось найти ничего подходящего для мобильной визуальной новеллы, действие которой происходит в школе.

Пропуская аргументацию, просто посоветую использовать Stable Diffusion или NovelAI. Это все, что вам нужно.

Из-за нехватки времени и ограниченных ресурсов для изучения темы я воспользовался сайтом Civitai. Взяв одну из готовых пользовательских моделей, я сгенерировал для себя несколько артов, бесплатно, без СМС и прямо в браузере. Мне просто нужно было зарегистрироваться. Вот одно из изображений, которое я получил после генерации.

Сгенерированная картинка

Написание кода для визуальной новеллы на ReactNative:

Как всегда ставим node если его нет.

brew install node

Установите Android Studio и Android SDK отсюда.

Установите переменную среды ANDROID_HOME.

Для разработки iOS вам необходимо установить Xcode.

Однако в этой статье мы опустим детали настройки среды разработки. Подробное руководство можно найти на сайте React Native. Начнем эту статью с создания и запуска проекта с помощью React Native CLI.

Создаем проект из шаблона:

npx react-native@latest init AwesomeProject

Запускаем его:

npx react-native start

Приступим к написанию кода визуальной новеллы. Создаем компонент BaseScene, который будет обрабатывать логику отображения сцен в игре. Сценой будет считаться конкретная локация, где происходит действие в визуальном романе.

interface SceneProps {
  backgroundImage: ImageProps['source'];
}

const BaseScene: React.FC<SceneProps> = ({
  backgroundImage
}) => (
  <View>
    <Image
      source={backgroundImage}
      style={{width, height: '100%', resizeMode: 'cover'}}
    />
  </View>
);

Всю сцену обернем в компонент Pressable, чтобы нажатие на экран было тригером для открытие следующего кадра игры, диалога или следующей сцены.

interface SceneProps {
  backgroundImage: ImageProps['source'];
  onPress?(): void;
}

const BaseScene: React.FC<SceneProps> = ({
  backgroundImage,
  onPress
}) => (
  <View>
    <Pressable onPress={onPress} style={{flex: 1}}>
      <Image
        source={backgroundImage}
        style={{width, height: '100%', resizeMode: 'cover'}}
      />
    </Pressable>
  </View>
);

Добавим отображение текста и автора текста.

interface SceneProps {
  backgroundImage: ImageProps['source'];
  onPress?(): void;
  text?: string;
  textAuthor?: string;
}

const BaseScene: React.FC<SceneProps> = ({
  backgroundImage,
  onPress,
  text,
  textAuthor
}) => (
  <View
    style={{
      position: 'relative',
      flex: 1,
    }}>
    <Pressable onPress={onPress} style={{flex: 1}}>
      <Image
        source={backgroundImage}
        style={{width, height: '100%', resizeMode: 'cover'}}
      />
      {text && (
        <View
          style={{
            position: 'absolute',
            bottom: 50,
            backgroundColor: 'black',
            padding: 8,
            width: '100%',
          }}>
          {textAuthor && (
            <View
              style={{
                position: 'absolute',
                backgroundColor: 'black',
                top: -36,
                height: 36,
                padding: 8,
                borderTopRightRadius: 6,
              }}>
              <Text style={{color: 'white', fontSize: 16}}>{textAuthor}</Text>
            </View>
          )}
          <Text style={{color: 'white', fontSize: 20}}>{text}</Text>
        </View>
      )}
    </Pressable>
  </View>
);

Отображение текста в игре

Далее опишем одну из игровых сцен. Это будет Scene1, или сцена в школьном коридоре. Мы будем использовать компонент BaseScene, описанный выше. Передадим ему изображение школьного коридора.

const Scene1 = () => {

  return (
    <BaseScene
      backgroundImage={require('../assets/hallway.jpeg')}
    />
  );
}

Давайте добавим контент в сцену. Мы передадим в BaseScene текст и изображение преподавателя, который будет этот текст говорить. Мы добавим изображение как children в BaseScene.

const Scene1 = () => {
  const image = (
    <Image
      source={require('../assets/teacher.png')}
      containerStyle={{
        position: 'absolute',
        bottom: 70,
      }}
    />
  );
  const text = 'Hello';
  const textAuthor = 'Teacher';

  return (
    <BaseScene
      backgroundImage={require('../assets/hallway.jpeg')}
      text={text}
      textAuthor={textAuthor}
    >
      {image}
    </BaseScrene>
  );
}

В этой сцене будет более одного диалога и персонажа. Давайте добавим объект под названием steps, в котором будут храниться шаги — диалоги для этой сцены. Мы переместим изображение и текст в поля этого объекта. Также мы добавим в сцену еще один диалог.

enum Step {
  first = 'first',
  second = 'second'
}

const steps = {
  [Step.first]: {
    text: 'Class, we have a new student. Come on, introduce yourself, please',
    textAuthor: 'Teacher',
    children: (
      <Image
        source={require('../assets/teacher.png')}
        containerStyle={{
          position: 'absolute',
          bottom: 70,
        }}
      />
    ),
  },
  [Step.second]: {
    text: 'Hello everyone, I'm {{name}}',
    textAuthor: 'Hero',
    children: (
      <Image
        source={require('../assets/teacher.png')}
        containerStyle={{
          position: 'absolute',
          bottom: 70,
        }}
      />
    ),
  },
};

Давайте добавим состояние useState. Он сохранит идентификатор текущего диалога, и мы добавим переходы между диалогами в сцене. Триггером перехода будет нажатие на экран.

const Scene1 = () => {
  const [stepID, setStepID] = useState(Step.first);
  const steps = {
    [Step.first]: {
      ...
      onPress: () => {
        setStepID(Step.second);
      },
    },
    ...
  };

  const currentStep = steps[stepID];

  return (
    <BaseScene
      backgroundImage={require('../assets/hallway.jpeg')}
      {...currentStep}
    />
  );
}

Некоторые шаги могут содержать вопросы к игроку. Давайте добавим возможность игроку вводить свое имя. Для этого добавим Step.third, в котором будет модальное окно с компонентом Input для ввода имени игрока.

...
const [name, setName] = useState('Hero');
...
const steps = {
  ...
  [Step.third]: {
    text: 'Enter your name...',
    textAuthor: 'System',
    children: (
      <Modal animationType="slide" transparent={true} visible>
        <View
          style={{
            ...
          }}>
          <View
            style={{
              ...
            }}>
            <Text style={{color: 'white', fontSize: 16}}>
              {t('screen2.createName.title')}
            </Text>
            <TextInput
              style={{
                ...
              }}
              placeholderTextColor="gray"
              placeholder="Hero"
              onChangeText={text => setName(text)}
            />
            <Pressable
              style={{
                ...
              }}
              onPress={() => {
                setStepId(...);
            }}>
              <Text
                style={{
                  ...
                }}
              >Save</Text>
            </Pressable>
          </View>
        </View>
      </Modal>
    )
  }
}

Отлично, а что если пользователь закроет игру? Нам нужно сохранить состояние игры, чтобы мы могли продолжить с последнего сохранения. Для этого давайте добавим AsyncStorage и сохраним в нем идентификатор текущего шага сцены, номер сцены и введенные пользователем данные (в настоящее время только имя игрока).

import AsyncStorage from '@react-native-async-storage/async-storage';

...
const saveStepID = (newStepID: Step) => {
  const storeData = async (value: string) => {
    try {
      await AsyncStorage.setItem('stepID', value);
      setStepID(value);
    } catch (e) {
      ...
    }
  };

  storeData(newScreen);
};
...

Далее нам нужно получить сохраненные данные при повторном открытии приложения. Давайте добавим useEffect в компонент App.

useEffect(() => {
  const getData = async (itemName: string, setFunction: Function) => {
    try {
      const value = await AsyncStorage.getItem(itemName);
      if (value !== null) {
        setFunction(value as any);
      }
    } catch (e) {
      // error reading value
    }
  };

  getData('stepID', setStepID);
  getData('sceneID', setSceneID);
  ...
}, []);

Давайте добавим музыку в игру. Мы будем использовать пакет response-native-sound.

useEffect(() => {
    Sound.setCategory('Playback');

    const music = new Sound('school.mp3', Sound.MAIN_BUNDLE, error => {
      if (error) {
        console.log('failed to load the sound', error);
        return;
      }

      musicLoadedRef.current = true;
      music.setVolume(0.5);

      const playMusic = () => {
        music.play(playMusic);
      };

      playMusic();
    });

    return () => {
      music.reset();
      music.stop();
    };
  }, []);

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

import { AppState, ... } from 'react-native';

...
const appState = useRef(AppState.currentState);

useEffect(() => {
  ...
  const subscription = AppState.addEventListener('change', nextAppState => {
    if (appState.current === 'active' && nextAppState !== 'active') {
      music.stop();
    } else if (
      appState.current !== 'active' &&
      nextAppState === 'active' &&
      musicLoadedRef.current
    ) {
      const playMusic = () => {
        music.play(playMusic);
      };

      playMusic();
    }

    appState.current = nextAppState;
  });
  ...
}, [])
...

Затем я добавил локализацию на другие языки с помощью react-i18next. Я добавил больше сцен, шагов внутри сцен с возможностью выбора различных вариантов развития сюжета. Я реализовал анимированные переходы между сценами и шаги внутри сцен с помощью Animated. Я добавил звуковые эффекты шагов, стука дверей для более глубокого погружения в игру. Я включил в игру вступительный и конечный экран и предоставил возможность оценить игру в Google Play. Если вас интересует более подробное описание того, как я это сделал, напишите мне в социальных сетях и я опубликую более подробное описание процесса создания игры.

Поиск музыки для игры:

Основную музыкальную тему игры я создал с помощью приложения GarageBand.

Звуки открытия дверей, шагов, стука в дверь и шума школы я нашел на сайте freesound. Главное при поиске музыки и звуков обратите внимание на тип лицензии, чтобы он позволял использовать ее для ваших нужд. Например лицнезия Creative Commons Zero (CC0) позволяет использовать материал в общественном достоянии без ограничений.

Об игре:

Игра доступна для скачивания в Google Play по этой ссылке. Это игра в жанре визуальная новелла и названиется она "Один шаг в новой школе". В ней мы начинаем наш путь как ученик недавно переведенный в новую школу. Мы познакомимся со своим учителем и однокласниками. Сходим с ними в школьный буфет и хорошо проведем время. В этой игре есть музыка, озвучка основных действий, например шагов по коридору или стука в дверь для более глубокого погружения в игру. Весь сценарий игры написан при помощи нейросети. Попробуйте поиграть и напишите ваше мнение об этой игре.