使用 react-three-fiber 制作 2D RPG 游戏 react-three-fiber 游戏演示

2025-06-05

使用 react-three-fiber 制作 2D RPG 游戏

react-three-fiber 游戏演示

在本文中,我们将仔细研究@coldi发布的一款开源演示。Coldi 使用 React 和 React-three-fiber 开发了一款名为《Colmen's Quest》 (绝对值得一看)的游戏。他非常慷慨地将自己开发的核心引擎分享给了社区。

科尔门的探索

使用 ThreeJS 这样的 3D 库来制作 2D 游戏听起来可能有点奇怪,但实际上并不少见。例如,流行的 3D 游戏引擎 Unity 就经常用于制作像《空洞骑士》这样的 2D 游戏。

说到 Unity,Coldi 使用的游戏架构也受到了 Unity 的启发,并围绕着我们稍后会讨论的GameObject
组件概念进行了解决。 将 react-three-fiber 添加到开发栈中,可以为使用 React 制作 WebGL 游戏提供绝佳的开发体验。

这个项目非常有价值,值得学习。通过本文的探索,我们将学习到很多游戏开发技巧、React-Three-Fiber 以及 React 的基础知识。我们还会尝试通过对示例进行一些调整来应用新学到的知识。让我们开始吧!

游戏演示

R3F 2D游戏演示

演示链接

我们先来分析一下这个演示中包含的元素和功能。
我们有:

  • 🗺 一张地图
  • 🚶‍♂️ 可以用鼠标或键盘移动的角色
    • 鼠标移动比较棘手,因为它需要计算前方的路径
  • 🧱 碰撞系统
    • 防止撞到墙壁或物体
  • 👉 交互系统
    • 可以取披萨,还可以与电脑和咖啡机互动
  • 📽 场景系统
    • 从一个房间移动到另一个房间

我们可以从这里克隆演示开始:

GitHub 徽标 coldi / r3f-游戏演示

关于如何使用 React 和 react-three-fiber 制作简单的基于图块的游戏的演示

react-three-fiber 游戏演示

游戏演示

这个 repo 展示了使用 React 和react-three-fiber制作的自上而下的 2d 游戏的示例实现

我使用核心功能创建了Colmen's Quest,并想让您了解如何使用 React 制作游戏。

这绝不是构建游戏的最佳方式,这只是我的方式。😊

我建议您将此代码作为灵感来源,而不是以此为基础构建游戏的起点。我也不打算以任何方式维护此代码库。

开始

您可以通过开始游戏yarn && yarn start,然后打开浏览器

为了更好地理解我所使用的架构,您可能需要阅读Twitter 上的这个帖子

👉 此外,Florent Lagrede(@flagrede在撰写...方面做得非常出色。

文件夹架构

文件夹结构

  • @core:所有可重复使用且不特定于当前演示的内容
  • 组件:包含更适合当前演示的逻辑的组件。
  • 实体:描述游戏世界中的元素(披萨、植物、玩家……)。所有这些元素都是GameObject。我们将在下文中进一步解释这个概念。
  • 场景:代表游戏中的不同房间。场景是 的集合GameObject。演示中有两个场景(办公室和其他)。

游戏架构

游戏架构

组件架构如下:



    <Game>
        <AssetLoader urls={urls} placeholder="Loading assets ...">
            <SceneManager defaultScene="office">
                <Scene id="office">
                    <OfficeScene />
                </Scene>
                <Scene id="other">
                    <OtherScene />
                </Scene>
            </SceneManager>
        </AssetLoader>
    </Game>


Enter fullscreen mode Exit fullscreen mode

我们将对每一个进行解释。

架构 - 顶部

游戏

该组件有4个主要特点:

  • GameObject在游戏内注册所有内容
  • 全球国家
  • 渲染Canvas组件react-three-fiber
  • 将上下文传递给其所有子级,并包含全局状态和查找/注册的方法GameObject

资源加载器

Image该组件将使用web对象加载游戏的所有图像和音频资源Audio。在资源加载过程中,它还会在画布上显示一个 HTML 叠加层。

场景管理器

该组件保存当前显示的状态Scene。它还setScene通过公开一个方法Context来更新当前场景。

场景

该组件除了显示其子组件外,还会当前场景发生变化时GameObject分派事件。scene-initscene-ready

文件中还存在一个级别系统,但演示中并未使用该系统。

架构 - 底部

现在我们将更深入地了解一下代码内部OfficeScene



    <>
        <GameObject name="map">
            <ambientLight />
            <TileMap data={mapData} resolver={resolveMapTile} definesMapSize />
        </GameObject>
        <GameObject x={16} y={5}>
            <Collider />
            <Interactable />
            <ScenePortal name="exit" enterDirection={[-1, 0]} target="other/start" />
        </GameObject>
        <Player x={6} y={3} />
    </>


Enter fullscreen mode Exit fullscreen mode

我们之前看到的组件GameObject是架构中最重要的部分。它几乎代表了游戏世界中的所有元素。例如,对于OfficeScene上面的组件,我们有 3 个GameObject

  • 地图
  • 场景改变者
  • 玩家

GameObject保存状态信息,例如positionenabled/disabled或其layer在游戏中的状态(例如:地面、障碍物、物品、角色……)。它们GameObject还可以包含其他组件。它们
GameObject还可以包含 Coldi 调用的其他组件Scripts。这些脚本可以保存交互、碰撞或移动等逻辑。基本上,游戏对象是由这些可重用组件Scripts和其他组件组成的GameObject。这是一个非常强大的 API,因为您只需将组件放入其中即可描述游戏对象的行为组件。

游戏对象

我们将进一步探索GameObject之前看到的 3 个:

地图

此组件将根据实体映射字符串创建场景的地图。例如,Office 映射字符串如下所示:



# # # # # # # # # # # # # # # # #
# · W T # T · · W T · W · · · T #
# · · · · · · · · · · · · · · o ·
# o · · # · · · # # # # · · # # #
# # # # # · · · # W o W · · T W #
# C C C # · · · T · · · · · · · #
# o · · · · · · · · · · · · · o #
# # # # # # # # # # # # # # # # #


Enter fullscreen mode Exit fullscreen mode

里面OfficeScene有一个名为 的函数resolveMapTile,它将每个角色映射到一个游戏实体。实体GameObject与游戏世界中的真实元素相匹配。
在本例中,我们有以下实体映射:

实体

  • # : 墙
  • 。 : 地面
  • W:工作站
  • C:咖啡机
  • T:植物

然后,子组件将负责根据实体映射字符串和函数TileMap返回映射基resolveMapTile

最终的地图是一个二维网格,每个单元包含一个或多个GameObject组件。

实体 - 工作站

工作站

让我们仔细看看实体是什么样子的。我们先来看Workstation一个。



export default function Workstation(props: GameObjectProps) {
    return (
        <GameObject {...props}>
            <Sprite {...spriteData.objects} state="workstation-1" />
            <Collider />
            <Interactable />
            <WorkstationScript />
        </GameObject>
    );
}


Enter fullscreen mode Exit fullscreen mode

我们可以看到GameObject我们正在讨论的组件以及一些定义其行为的子组件( Sprite和) ColliderInteractableWorkstationScript

雪碧

Sprite 组件负责显示游戏中的所有图形元素。到目前为止
我们还没有深入讨论过react-three-fiber,但大多数视觉渲染都发生在这个组件中。

在 ThreeJS 中,元素是通过mesh对象来渲染的。网格是几何体和材质的组合。

在我们的几何例子中,我们使用 1x1 维度的简单平面:



THREE.PlaneBufferGeometry(1, 1)


Enter fullscreen mode Exit fullscreen mode

对于材质,我们仅应用 Threejs 基本材质:



<meshBasicMaterial attach="material" {...materialProps}>
    <texture ref={textureRef} attach="map" {...textureProps} />
</meshBasicMaterial>


Enter fullscreen mode Exit fullscreen mode

然而,如果使用简单的基本材质,我们只会看到一个简单的正方形。我们的精灵实际上是通过赋予<texture>对象来显示的,对象会将精灵应用到<meshBasicMaterial>

总而言之,这个演示的视觉渲染主要是应用了纹理的 2D 平面,以及从顶部观察它们的相机。

对撞机

此组件负责处理碰撞。它有两个功能:

  • 存储使用者的可行走状态(是否可以踏上)GameObject。默认情况下,Collider初始化为不可行走。
  • 每当发生碰撞时,监听并触发事件来执行一些逻辑。

该组件还使用钩子useComponentRegistry将自身注册到其GameObject。这允许游戏中的其他元素(例如玩家)知道该游戏对象是一个障碍物。

目前我们只是在地图上添加了一个障碍物,让我们继续下一个组件。

可互动

此组件负责处理玩家与游戏中其他元素交互时的逻辑。当玩家与其他元素发生碰撞时,就会发生交互(这就是之前需要 的GameObject原因)。Collider

Interactable有几种方法:

  • 交互:由GameObject发起交互
  • onInteract:由GameObject接收交互的执行
  • canInteract:是否可以与其交互

Interactable组件作为将Collider自身注册到其GameObject

工作站脚本


function WorkstationScript() {
    const { getComponent } = useGameObject();
    const workState = useRef(false);

    useGameObjectEvent<InteractionEvent>('interaction', () => {
        workState.current = !workState.current;

        if (workState.current) {
            getComponent<SpriteRef>('Sprite').setState('workstation-2');
        } else {
            getComponent<SpriteRef>('Sprite').setState('workstation-1');
        }

        return waitForMs(400);
    });

    return null;
}


Enter fullscreen mode Exit fullscreen mode

最后,我们有一个特定于此实体的脚本来处理一些逻辑。
我们可以看到,这个脚本正在监听interaction之前的事件。每当事件发生时,它就会切换电脑的精灵图。

工作站交互

锻炼

我们将添加一个伪装成植物的怪物实体。在对象精灵表资源中,我们可以看到演示中未使用的两种植物。我们的
目标是使用它们创建一个名为 ZombiePlant 的新实体,并将其放置在另一个场景中。

当与实体交互时,植物应该从一个精灵切换到另一个精灵。

我们还必须更改实体映射字符串resolveMapTile内部函数OtherScene

僵尸植物

解决方案

场景改变者



        <GameObject x={16} y={5}>
            <Collider />
            <Interactable />
            <ScenePortal name="exit" enterDirection={[-1, 0]} target="other/start" />
        </GameObject>


Enter fullscreen mode Exit fullscreen mode

现在我们来看看处理场景变化的组件。
当玩家踩到它时,这个组件就会被触发。

为了创造这种效果,场景变换器有 3 个子组件:

  • 对撞机
  • 可互动
  • 场景门户

我们已经熟悉了一些元素,例如InteractableCollider。这向我们展示了GameObject该架构的可复用性。让我们来看看 ScenePortal。

场景门户

场景门户

该组件负责在玩家与其交互时执行场景切换。
它具有以下属性:

  • name:门户的名称
  • target:玩家将被传送到的目的地(场景 + 传送门)。此参数是一个字符串,其模板如下:sceneName/portalName
  • enterDirection:玩家进入新场景时应该面向的方向;

interaction该组件通过钩子监听事件useInteraction。当它收到交互时,会检查该事件是否来自玩家。如果是,port则会调用该函数。它会在全局游戏状态中更改当前场景。之后,组件将等待SceneInitEvent并将SceneReadyEvent玩家移动到正确的位置和方向。

工作流示例

让我们尝试直观地了解 ScenePortal 的整个工作流程:

门户工作流

玩家

玩家

我们现在来探索GameObject游戏中最大的角色,也就是那个Player
玩家角色GameObject看起来是这样的:



    <GameObject name="player" displayName="Player" layer="character" {...props}>
        <Moveable />
        <Interactable />
        <Collider />
        <CharacterScript>
            <Sprite {...spriteData.player} />
        </CharacterScript>
        <CameraFollowScript />
        <PlayerScript />
    </GameObject>


Enter fullscreen mode Exit fullscreen mode

我们仍然熟悉InteractableCollider
让我们看看新组件的作用是什么。

活动

此组件仅暴露 API,不监听任何事件。这意味着会有另一个GameObject组件调用 Movable 的 API 来GameObject使用它来移动对象(在本例中是 Player)。

最重要的方法是这个move方法。它以 targetPosition 作为参数,检查该位置是否为碰撞点,如果不是,则将对象移动GameObject到该位置。

它还会触发许多可以在其他地方使用的事件。事件序列如下:

移动架构

该方法还move使用animejs库将玩家精灵从一个位置动画到另一个位置。

角色脚本



    useGameLoop(time => {
        // apply wobbling animation
        wobble();

        // apply breathe animation
        if (!movementActive.current) {
            // breathe animation while standing still
            const breathIntensity = 20;
            scaleRef.current.scale.setY(1 + Math.sin(time / 240) / breathIntensity);
        } else {
            // no breathe animation while moving
            scaleRef.current.scale.setY(1);
        }
    });


Enter fullscreen mode Exit fullscreen mode

此组件负责为玩家精灵添加一些动画。脚本处理如下:

  • 沿当前移动方向翻转精灵(使用attempt-move我们之前看到的事件)
  • wobble移动时 应用效果
    • 这个效果是在useGameLoophook 内部实现的。它内部使用了useFramereact-three-fiber 的 hook。这个 hook 非常有用,因为它允许我们在每一帧上执行更新。
  • 添加脚步精灵和移动时的声音
  • 使动画在移动时弹跳(使用moving我们之前看到的事件)

总而言之,该组件通过监听来自Moveable组件的移动事件来执行精灵动画。

播放器脚本

实体的最后一部分PlayerPlayerScript
此组件处理玩家可以执行的逻辑。它将处理光标和键盘输入。

键盘控制

有 4 个钩子useKeyPress函数用于将监听器添加到参数中指定的按键。每当按下列出的按键时,这些钩子函数都会返回一个布尔值。这些布尔值随后会在useGameLoop我们之前看到的 内部进行检查,并计算出下一个位置。新位置设置在 的本地状态中PlayerScript

光标控制

阿斯塔

这部分有点棘手。键盘控制可以让玩家逐格移动,但光标可以将其移动到多个格子。这意味着在移动之前必须计算到达选定位置的整个路径。

为了实现这一点,该方法使用了一种流行的路径查找算法,名为A star (或 A*)。该算法在考虑碰撞的情况下计算网格中两点之间的最短路径。

至于键盘事件,新的位置会被更新到本地PlayerScript状态中。此外,在这种情况下,路径也会以可视化的方式显示出来。在 render 方法中,有PlayerPathOverlay一个组件负责执行此操作。

调任新职位

在这两种情况下,我们都看到新位置在组件的本地状态中更新。
有一个 useEffect 监听该变化并尝试移动GameObject。还记得Moveable之前的组件 吗?这里我们获取它并调用它的move方法。如果无法移动,该方法将返回false。在这种情况下,我们将尝试与GameObject玩家无法到达的位置 进行交互。

锻炼

这是一个很大的部分,但现在我们应该了解游戏对象如何协同工作,现在让我们尝试制作一个新的东西。

还记得我们的ZombiePlant实体吗?我们将为其添加一些新功能:

  • 当玩家与其互动时:应该从玩家身上反弹(就像玩家攻击它一样)
  • 每当发生互动时:应该播放音效(例如,我们可以重复使用进食)
  • 第三次互动时,僵尸植物应该消失

僵尸植物2

解决方案

结论

就这样,我们已经完成了大部分演示!
希望大家通过这次演示学习到了很多东西(我的确学到了很多)。再次感谢@coldi与社区分享这个演示。
正如他所说,很多东西都可以用不同的方式实现。例如,碰撞系统可以用像 这样的物理引擎来实现react-use-cannon
这仍然是一个如何使用 制作游戏的绝佳示例react-three-fiber

希望这能给你一些想法来制作你自己的游戏!

如果您对前端、react-three-fiber 或 gamedev 感兴趣,我将在这里发布有关这些主题的更多内容。

感谢阅读,祝您编码愉快。

文章来源:https://dev.to/flagrede/making-a-2d-rpg-game-with-react-tree-fibre-4af1
PREV
征求意见:Quirk,一款开源认知行为疗法应用。欢迎来到我的私人生活。等等,慢点,恐慌症到底是什么?认知行为疗法与你的大脑。它是迄今为止最有效的。等等,什么是CBT?就是这样。目前的应用又丑又贵,而且笨重。ShowDev:Quirk,一款开源CBT应用。如果你对此感兴趣,请关注项目状态、评论。
NEXT
React-toastify v8 现已上线