使用 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 的基础知识。我们还会尝试通过对示例进行一些调整来应用新学到的知识。让我们开始吧!
游戏演示

演示链接
我们先来分析一下这个演示中包含的元素和功能。
我们有:
- 🗺 一张地图
- 🚶♂️ 可以用鼠标或键盘移动的角色
- 🧱 碰撞系统
- 👉 交互系统
- 📽 场景系统
我们可以从这里克隆演示开始:
关于如何使用 React 和 react-three-fiber 制作简单的基于图块的游戏的演示
文件夹架构

- @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>
我们将对每一个进行解释。
架构 - 顶部
游戏
该组件有4个主要特点:
GameObject
在游戏内注册所有内容
- 全球国家
- 渲染
Canvas
组件react-three-fiber
- 将上下文传递给其所有子级,并包含全局状态和查找/注册的方法
GameObject
资源加载器
Image
该组件将使用web对象加载游戏的所有图像和音频资源Audio
。在资源加载过程中,它还会在画布上显示一个 HTML 叠加层。
场景管理器
该组件保存当前显示的状态Scene
。它还setScene
通过公开一个方法Context
来更新当前场景。
场景
该组件除了显示其子组件外,还会在当前场景发生变化时GameObject
分派事件。scene-init
scene-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} />
</>
我们之前看到的组件GameObject
是架构中最重要的部分。它几乎代表了游戏世界中的所有元素。例如,对于OfficeScene
上面的组件,我们有 3 个GameObject
:
GameObject
保存状态信息,例如position
、enabled/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 #
# # # # # # # # # # # # # # # # #
里面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>
);
}
我们可以看到GameObject
我们正在讨论的组件以及一些定义其行为的子组件( Sprite
、和) Collider
。Interactable
WorkstationScript
雪碧
Sprite 组件负责显示游戏中的所有图形元素。到目前为止
我们还没有深入讨论过react-three-fiber
,但大多数视觉渲染都发生在这个组件中。
在 ThreeJS 中,元素是通过mesh
对象来渲染的。网格是几何体和材质的组合。
在我们的几何例子中,我们使用 1x1 维度的简单平面:
THREE.PlaneBufferGeometry(1, 1)
对于材质,我们仅应用 Threejs 基本材质:
<meshBasicMaterial attach="material" {...materialProps}>
<texture ref={textureRef} attach="map" {...textureProps} />
</meshBasicMaterial>
然而,如果使用简单的基本材质,我们只会看到一个简单的正方形。我们的精灵实际上是通过赋予<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;
}
最后,我们有一个特定于此实体的脚本来处理一些逻辑。
我们可以看到,这个脚本正在监听interaction
之前的事件。每当事件发生时,它就会切换电脑的精灵图。

锻炼
我们将添加一个伪装成植物的怪物实体。在对象精灵表资源中,我们可以看到演示中未使用的两种植物。我们的
目标是使用它们创建一个名为 ZombiePlant 的新实体,并将其放置在另一个场景中。
当与实体交互时,植物应该从一个精灵切换到另一个精灵。
我们还必须更改实体映射字符串和resolveMapTile
内部函数OtherScene
。

解决方案
场景改变者
<GameObject x={16} y={5}>
<Collider />
<Interactable />
<ScenePortal name="exit" enterDirection={[-1, 0]} target="other/start" />
</GameObject>
现在我们来看看处理场景变化的组件。
当玩家踩到它时,这个组件就会被触发。
为了创造这种效果,场景变换器有 3 个子组件:
我们已经熟悉了一些元素,例如Interactable
和Collider
。这向我们展示了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>
我们仍然熟悉Interactable
和Collider
。
让我们看看新组件的作用是什么。
活动
此组件仅暴露 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);
}
});
此组件负责为玩家精灵添加一些动画。脚本处理如下:
- 沿当前移动方向翻转精灵(使用
attempt-move
我们之前看到的事件)
wobble
移动时 应用效果
- 这个效果是在
useGameLoop
hook 内部实现的。它内部使用了useFrame
react-three-fiber 的 hook。这个 hook 非常有用,因为它允许我们在每一帧上执行更新。
- 添加脚步精灵和移动时的声音
- 使动画在移动时弹跳(使用
moving
我们之前看到的事件)
总而言之,该组件通过监听来自Moveable
组件的移动事件来执行精灵动画。
播放器脚本
实体的最后一部分Player
,PlayerScript
。
此组件处理玩家可以执行的逻辑。它将处理光标和键盘输入。
键盘控制
有 4 个钩子useKeyPress
函数用于将监听器添加到参数中指定的按键。每当按下列出的按键时,这些钩子函数都会返回一个布尔值。这些布尔值随后会在useGameLoop
我们之前看到的 内部进行检查,并计算出下一个位置。新位置设置在 的本地状态中PlayerScript
。
光标控制

这部分有点棘手。键盘控制可以让玩家逐格移动,但光标可以将其移动到多个格子。这意味着在移动之前必须计算到达选定位置的整个路径。
为了实现这一点,该方法使用了一种流行的路径查找算法,名为A star (或 A*)。该算法在考虑碰撞的情况下计算网格中两点之间的最短路径。
至于键盘事件,新的位置会被更新到本地PlayerScript
状态中。此外,在这种情况下,路径也会以可视化的方式显示出来。在 render 方法中,有PlayerPathOverlay
一个组件负责执行此操作。
调任新职位
在这两种情况下,我们都看到新位置在组件的本地状态中更新。
有一个 useEffect 监听该变化并尝试移动GameObject
。还记得Moveable
之前的组件 吗?这里我们获取它并调用它的move
方法。如果无法移动,该方法将返回false
。在这种情况下,我们将尝试与GameObject
玩家无法到达的位置 进行交互。
锻炼
这是一个很大的部分,但现在我们应该了解游戏对象如何协同工作,现在让我们尝试制作一个新的东西。
还记得我们的ZombiePlant
实体吗?我们将为其添加一些新功能:
- 当玩家与其互动时:应该从玩家身上反弹(就像玩家攻击它一样)
- 每当发生互动时:应该播放音效(例如,我们可以重复使用进食)
- 第三次互动时,僵尸植物应该消失

解决方案
结论
就这样,我们已经完成了大部分演示!
希望大家通过这次演示学习到了很多东西(我的确学到了很多)。再次感谢@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