Cover Image for 我如何在 2 天内使用 React Native 制作视觉小说
[Android][Game][React Native]
2024年4月8日

我如何在 2 天内使用 React Native 制作视觉小说

在周末,我决定开始一个新项目,并将我的移动游戏想法付诸实践。最重要的是,我选择了视觉小说作为游戏类型。在本文中,我将分享我在两天内创建游戏的经验,并讨论 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。在那里,我创建了一个图表,以便可视化情节分支和场景的顺序。

miro 中的方案

使用神经网络生成游戏图像:

有许多服务可以使用神经网络生成图像。许多服务被积极宣传并被呈现为很酷的东西。通常,它们有一个 Web 界面,允许您直接在浏览器中创建图像,而无需安装或配置任何东西。然而,尝试了一些之后,我感到失望。这些图像不适合用于游戏。无论我如何构建生成查询,我都找不到适合在学校设置的移动视觉小说的东西。

跳过论证,我只建议您使用 Stable Diffusion 或 NovelAI。这就是您所需要的。

由于时间限制和探索主题的资源有限,我使用了 Civitai 网站。使用其中一个现成的用户模型,我为自己生成了一些艺术作品,免费,无需短信,直接在浏览器中。我只需注册即可。以下是我生成后获得的图像之一。

生成的图像

在 React Native 中为视觉小说编写代码:

一如既往,确保安装了 Node.js。如果没有,请安装:

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('../images/hallway.jpeg')}
    />
  );
}

让我们为场景添加内容。我们将传递文本和教师的图像给 BaseScene,他将说出这个文本。 我们将图像作为子元素添加到 BaseScene。

const Scene1 = () => {
  const image = (
    <Image
      source={require('../images/teacher.png')}
      containerStyle={{
        position: 'absolute',
        bottom: 70,
      }}
    />
  );
  const text = '你好';
  const textAuthor = '老师';

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

在场景中,有不止一个对话和角色参与。 让我们添加一个名为 steps 的对象,它将存储此场景的步骤 - 对话。我们将图像和文本移动到此对象的字段中。此外,我们将在场景中添加另一个对话。

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

const steps = {
  [Step.first]: {
    text: '班级,我们有一个新学生。来吧,请介绍一下自己',
    textAuthor: '老师',
    children: (
      <Image
        source={require('../images/teacher.png')}
        containerStyle={{
          position: 'absolute',
          bottom: 70,
        }}
      />
    ),
  },
  [Step.second]: {
    text: '大家好,我是{{name}}',
    textAuthor: '主角',
    children: (
      <Image
        source={require('../images/teacher.png')}
        containerStyle={{
          position: 'absolute',
          bottom: 70,
        }}
      />
    ),
  },
};

让我们添加 useState 状态。它将存储当前对话 ID,并且我们将在场景中添加对话之间的过渡。过渡的触发器将是屏幕上的按压。

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

  const currentStep = steps[stepID];

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

某些步骤可能包含玩家的问题。让我们添加玩家输入自己名字的能力。为此,我们将添加 Step.third,其中将有一个模态窗口,带有一个 Input 组件,用于输入玩家的名字。

...
const [name, setName] = useState('主角');
...
const steps = {
  ...
  [Step.third]: {
    text: '输入你的名字...',
    textAuthor: '系统',
    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="主角"
                onChangeText={text => setName(text)}
              />
              <Pressable
                style={{
                  ...
                }}
                onPress={() => {
                  setStepId(...);
                }}>
                <Text
                  style={{
                    ...
                  }}
                >保存</Text>
              </Pressable>
            </View>
          </View>
        </Modal>
      )
    }
  }

很好,但如果用户关闭游戏怎么办?我们需要保存游戏状态,以便可以从上次保存的地方继续。为此,让我们添加 AsyncStorage 并将当前场景步骤 ID、场景编号和用户输入(目前仅为玩家的名字)保存到其中。

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);
};
...

接下来,我们需要在重新打开应用程序时检索保存的数据。让我们在 App 组件中添加 useEffect

useEffect(() => {
  const getData = async (itemName: string, setFunction: Function) => {
    try {
      const value = await AsyncStorage.getItem(itemName);
      if (value !== null) {
        setFunction(value as any);
      }
    } catch (e) {
      // 读取值时出错
    }
  };

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

让我们为游戏添加音乐。我们将使用 react-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 上下载。 这是一款名为 "One Step in high School" 的视觉小说游戏。 在其中,我们将作为一名新转学到新学校的学生开始我们的旅程。 我们将认识我们的老师和同学,与他们一起去学校食堂,并度过美好时光。此游戏包含音乐和关键动作的音效,例如走廊中的脚步声或敲门声,以便更深入地沉浸在游戏中。整个游戏脚本是使用神经网络编写的。试试看,并分享您对这款游戏的反馈。

继续阅读

加入我们的社区