paint-brush
使用 React 和 Three.js 堆栈创建您自己的 3D 射击游戏 — 第 1 部分by@varlab
781
781

使用 React 和 Three.js 堆栈创建您自己的 3D 射击游戏 — 第 1 部分

Ivan Zhukov17m2023/10/21
Read on Terminal Reader

在网络技术和交互式应用程序积极发展的时代,3D 图形变得越来越重要和需求。但如何在不失去Web开发优势的情况下创建3D应用程序呢?在本文中,我们将了解如何将 Three.js 的强大功能与 React 的灵活性相结合,在浏览器中创建您自己的游戏。 本文将向您介绍 React Three Fiber 库,并教您如何创建交互式 3D 游戏。
featured image - 使用 React 和 Three.js 堆栈创建您自己的 3D 射击游戏 — 第 1 部分
Ivan Zhukov HackerNoon profile picture
0-item
1-item

在现代 Web 开发中,经典应用程序和 Web 应用程序之间的界限每天都在变得模糊。今天,我们不仅可以创建交互式网站,还可以在浏览器中创建成熟的游戏。让这成为可能的工具之一是React Three Fiber库 - 一个使用React技术基于Three.js创建 3D 图形的强大工具。

关于 React 三纤维堆栈

React Three FiberThree.js 的包装器,它使用React的结构和原理在 Web 上创建 3D 图形。该堆栈允许开发人员将Three.js的强大功能与React的便利性和灵活性结合起来,使创建应用程序的过程更加直观和有组织。


React Three Fiber的核心理念是,您在场景中创建的所有内容都是React组件。这允许开发人员应用熟悉的模式和方法。

React Three Fiber的主要优点之一是它易于与React生态系统集成。使用此库时仍然可以轻松集成任何其他React工具。

Web-GameDev 的相关性

Web-GameDev近年来发生了重大变化,从简单的 2D Flash游戏发展到可与桌面应用程序相媲美的复杂 3D 项目。受欢迎程度和功能的增长使得 Web-GameDev 成为一个不容忽视的领域。


网页游戏的主要优势之一是其可访问性。玩家无需下载和安装任何其他软件 - 只需单击浏览器中的链接即可。这简化了游戏的发行和推广,使它们可供世界各地的广大受众使用。


最后,网页游戏开发可以成为开发人员尝试使用熟悉的技术进行游戏开发的好方法。借助可用的工具和库,即使没有 3D 图形经验,也可以创建有趣且高质量的项目!

现代浏览器中的游戏性能

现代浏览器已经走过了漫长的道路,从相当简单的网络浏览工具发展到用于运行复杂应用程序和游戏的强大平台。 ChromeFirefoxEdge主流浏览器都在不断优化和开发,以确保高性能,使其成为开发复杂应用程序的理想平台。


WebGL是推动基于浏览器的游戏发展的关键工具之一。该标准允许开发人员使用硬件图形加速,从而显着提高了 3D 游戏的性能。与其他 webAPI 一起, WebGL为直接在浏览器中创建令人印象深刻的 Web 应用程序开辟了新的可能性。


然而,在为浏览器开发游戏时,考虑各种性能方面至关重要:资源优化、内存管理和针对不同设备的适配都是影响项目成功的关键点。

各就各位!

然而,文字和理论是一回事,但实践经验又是另一回事。要真正理解和领会网页游戏开发的全部潜力,最好的方法就是沉浸在开发过程中。因此,作为网页游戏开发成功的例子,我们将创建自己的游戏。这个过程将使我们学习开发的关键方面,面对实际问题并找到解决方案,并看到网页游戏开发平台可以多么强大和灵活。


在一系列文章中,我们将了解如何使用该库的功能创建第一人称射击游戏,并深入探索令人兴奋的网页游戏开发世界!


最终演示


GitHub上的存储库


现在,让我们开始吧!

设置项目并安装包

首先,我们需要一个React项目模板。那么让我们从安装开始吧。


 npm create vite@latest


  • 选择React库;
  • 选择JavaScript


安装额外的 npm 包。


 npm install three @react-three/fiber @react-three/drei @react three/rapier zustand @tweenjs/tween.js


然后从我们的项目中删除所有不必要的内容。


部分代码

自定义画布显示

main.jsx文件中,添加将作为范围显示在页面上的 div 元素。插入Canvas组件并设置相机的视野。在Canvas组件内放置App组件。


main.jsx


让我们向index.css添加样式,以将UI 元素拉伸到屏幕的整个高度,并将范围显示为屏幕中心的圆圈。


索引.css


App组件中我们添加了一个Sky组件,它将以天空的形式显示在我们的游戏场景中作为背景。


应用程序.jsx


在场景中显示天空


部分代码

地板表面

让我们创建一个Ground组件并将其放置在App组件中。


应用程序.jsx


Ground中,创建一个平坦的表面元素。在 Y 轴上向下移动,使该平面位于相机的视野内。并在 X 轴上翻转平面,使其水平。


地面.jsx


即使我们指定灰色作为材质颜色,平面仍显示为全黑。


现场平坦


部分代码

基本照明

默认情况下,场景中没有照明,因此让我们添加一个光源ambientLight ,它从各个方向照亮对象,并且没有定向光束。作为参数设置发光强度。


应用程序.jsx


照明平面


部分代码

地板表面的纹理

为了使地板表面看起来不均匀,我们将添加纹理。以沿着表面重复的单元格的形式制作地板表面的图案。

资源文件夹中添加带有纹理的 PNG 图像。


添加纹理


要在场景中加载纹理,让我们使用@react- Three/drei包中的useTexture钩子。作为钩子的参数,我们将传递导入到文件中的纹理图像。设置图像在水平轴上的重复次数。


地面.jsx


平面上的纹理


部分代码

相机移动

使用@react-two/drei包中的PointerLockControls组件,将光标固定在屏幕上,这样当你移动鼠标时它不会移动,但会改变相机在场景中的位置。


应用程序.jsx


相机运动演示


让我们对Ground组件进行一些小的编辑。


地面.jsx


部分代码

添加物理

为了清楚起见,让我们向场景添加一个简单的立方体。


 <mesh position={[0, 3, -5]}> <boxGeometry /> </mesh> 


场景中的立方体


现在他只是悬在太空中。


使用@react-two/rapier包中的Physics组件将“物理”添加到场景中。作为参数,配置重力场,我们在其中设置沿轴的重力。


 <Physics gravity={[0, -20, 0]}> <Ground /> <mesh position={[0, 3, -5]}> <boxGeometry /> </mesh> </Physics>


然而,我们的立方体位于物理组件内部,但它什么也没发生。为了使立方体表现得像一个真实的物理对象,我们需要将其包装在@react- Three/rapier包中的RigidBody组件中。


应用程序.jsx


之后,我们会立即看到每次页面重新加载时,立方体都会在重力的影响下下落。


立方体坠落


但现在还有另一个任务 - 有必要使地板成为立方体可以与之交互的物体,并且超过它就不会掉落。


部分代码

地板作为一个物理对象

让我们回到Ground组件并添加一个RigidBody组件作为地板表面的包装。


地面.jsx


现在,当掉落时,立方体像真实的物理物体一样留在地板上。


立方体在平面上掉落


部分代码

让角色服从物理定律

让我们创建一个Player组件来控制场景中的角色。


该角色与添加的立方体是同一物理对象,因此它必须与地板表面以及场景中的立方体进行交互。这就是我们添加RigidBody组件的原因。让我们将角色制作成胶囊的形式。


播放器.jsx


Player组件放置在Physics 组件内。


应用程序.jsx


现在我们的角色已经出现在现场了。


胶囊形态的角色


部分代码

移动角色 - 创建钩子

角色将使用WASD键进行控制,并使用空格键进行跳跃。

通过我们自己的react-hook,我们实现了移动角色的逻辑。


让我们创建一个hooks.js文件并在其中添加一个新的usePersonControls函数。


让我们以 {"keycode": "action to be Perform"} 的格式定义一个对象。接下来,添加用于按下和释放键盘按键的事件处理程序。当处理程序被触发时,我们将确定当前正在执行的操作并更新其活动状态。作为最终结果,该钩子将返回一个格式为 {"action in Progress": "status"} 的对象。


钩子.js


部分代码

移动角色 - 实现钩子

实现usePersonControls钩子后,应该在控制角色时使用它。在Player组件中,我们将添加运动状态跟踪并更新角色运动方向的向量。


我们还将定义存储移动方向状态的变量。


播放器.jsx


要更新角色的位置,让我们使用@react- Three/Fiber包提供的Frame 。该钩子的工作原理与requestAnimationFrame类似,每秒执行函数主体约 60 次。


播放器.jsx


代码说明:

1. const playerRef = useRef();为玩家对象创建链接。此链接将允许与场景中的玩家对象直接交互。

2. const { 向前、向后、向左、向右、跳跃 } = usePersonControls();当使用钩子时,会返回一个具有布尔值的对象,该布尔值指示玩家当前按下了哪些控制按钮。

3. useFrame((状态) => { ... });在动画的每一帧上都会调用该钩子。在此钩子内,玩家的位置和线速度会更新。

4. if (!playerRef.current) 返回;检查玩家对象是否存在。如果没有玩家对象,该函数将停止执行以避免错误。

5. const速度=playerRef.current.linvel();获取玩家当前的线速度。

6. frontVector.set(0, 0, 向后-向前);根据按下的按钮设置向前/向后运动矢量。

7. sideVector.set(左-右, 0, 0);设置左/右移动矢量。

8. Direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED);通过减去运动向量、对结果进行归一化(使向量长度为 1)并乘以运动速度常数来计算玩家运动的最终向量。

9.playerRef.current.wakeUp(); “唤醒”玩家对象以确保它对更改做出反应。如果不使用此方法,一段时间后对象将“休眠”并且不会对位置变化做出反应。

10.playerRef.current.setLinvel({ x: 方向.x, y: 速度.y, z: 方向.z });根据计算出的运动方向设置玩家新的线速度,并保持当前的垂直速度(以免影响跳跃或跌倒)。


结果,当按下WASD键时,角色开始在场景中移动。他还可以与立方体进行交互,因为它们都是物理对象。


人物动作


部分代码

移动角色 - 跳跃

为了实现跳跃,我们使用@dimforge/rapier3d-compat@react- Three/rapier包中的功能。在此示例中,我们检查角色是否在地面上并且已按下跳跃键。在本例中,我们在 Y 轴上设置角色的方向和加速力。


对于玩家,我们将在所有轴上添加质量和块旋转,以便他在与场景中的其他物体碰撞时不会在不同方向上摔倒。


播放器.jsx


代码说明:

  1. const world = 剑杆.world;访问Rapier物理引擎场景。它包含所有物理对象并管理它们的交互。
  1. const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 }));这就是“光线投射”(raycasting)发生的地方。创建一条从玩家当前位置开始并指向 y 轴的射线。该光线被“投射”到场景中,以确定它是否与场景中的任何对象相交。
  1. const 接地 = ray && ray.collider && Math.abs(ray.toi) <= 1.5;如果玩家在地面上,则检查条件:
  • ray -射线是否被创建;
  • ray.collider - 射线是否与场景中的任何物体发生碰撞;
  • Math.abs(ray.toi) - 光线的“曝光时间”。如果该值小于或等于给定值,则可能表明玩家距离表面足够近,可以被视为“在地面上”。


您还需要修改Ground组件,以便通过添加将与场景中其他对象交互的物理对象来确定“着陆”状态的光线追踪算法正常工作。


地面.jsx


让我们将相机抬高一点,以便更好地观察场景。


main.jsx


角色跳跃


部分代码

将摄像机移动到角色后面

为了移动相机,我们将获取玩家的当前位置,并在每次刷新帧时更改相机的位置。为了使角色精确地沿着相机所指向的轨迹移动,我们需要添加applyEuler


播放器.jsx


代码说明:

applyEuler方法根据指定的欧拉角对向量应用旋转。在这种情况下,相机旋转应用于方向矢量。这用于匹配相对于相机方向的运动,以便玩家沿着相机旋转的方向移动。


让我们稍微调整Player的大小,使其相对于立方体更高,增加CapsuleCollider的大小并修复“跳跃”逻辑。


播放器.jsx


移动相机


部分代码

立方体的生成

为了使场景不会感觉完全空虚,让我们添加立方体生成。在 json 文件中,列出每个立方体的坐标,然后将它们显示在场景上。为此,创建一个文件cubes.json ,我们将在其中列出一个坐标数组。


 [ [0, 0, -7], [2, 0, -7], [4, 0, -7], [6, 0, -7], [8, 0, -7], [10, 0, -7] ]


Cube.jsx文件中,创建一个Cubes组件,它将循环生成立方体。而Cube组件将直接生成对象。


 import {RigidBody} from "@react-three/rapier"; import cubes from "./cubes.json"; export const Cubes = () => { return cubes.map((coords, index) => <Cube key={index} position={coords} />); } const Cube = (props) => { return ( <RigidBody {...props}> <mesh castShadow receiveShadow> <meshStandardMaterial color="white" /> <boxGeometry /> </mesh> </RigidBody> ); }


让我们通过删除之前的单个立方体来将创建的立方体组件添加到应用程序组件中。


应用程序.jsx


立方体的生成


部分代码

将模型导入到项目中

现在让我们向场景添加 3D 模型。让我们为角色添加武器模型。让我们从寻找 3D 模型开始。我们以这个为例。


下载 GLTF 格式的模型并将存档解压到项目的根目录中。

为了获得将模型导入场景所需的格式,我们需要安装gltf-pipeline附加包。


npm i -D gltf-pipeline


使用gltf-pipeline包,将模型从GLTF 格式重新转换为GLB 格式,因为在此格式中,所有模型数据都放置在一个文件中。我们指定公共文件夹作为生成文件的输出目录。


gltf-pipeline -i weapon/scene.gltf -o public/weapon.glb


然后我们需要生成一个包含该模型标记的反应组件,以将其添加到场景中。让我们使用@react- Three/Fiber开发人员的官方资源


转到转换器将要求您加载转换后的Weapon.glb文件。


使用拖放或资源管理器搜索,找到该文件并下载。


改装型号


在转换器中,我们将看到生成的反应组件,我们将其代码传输到新文件WeaponModel.jsx中的项目,将组件的名称更改为与文件相同的名称。


部分代码

现场展示武器模型

现在让我们将创建的模型导入到场景中。在App.jsx文件中添加WeaponModel组件。


应用程序.jsx


导入模型演示


部分代码

添加阴影

此时,在我们的场景中,没有任何对象正在投射阴影。

要在场景上启用阴影,您需要将阴影属性添加到Canvas组件。


main.jsx


接下来,我们需要添加一个新的光源。尽管场景中已经有了环境光,但它无法为对象创建阴影,因为它没有定向光束。因此,让我们添加一个名为orientationLight 的新光源并对其进行配置。启用“投射”阴影模式的属性是castShadow 。正是这个参数的添加,表明这个物体可以给其他物体投射阴影。


应用程序.jsx


之后,我们给Ground组件添加另一个属性receiveShadow ,这意味着场景中的组件可以接收并显示自身的阴影。


地面.jsx


模型投下阴影


类似的属性应该添加到场景中的其他对象:立方体和玩家。对于立方体,我们将添加castShadowreceiveShadow ,因为它们都可以投射和接收阴影,而对于玩家,我们将仅添加castShadow


让我们为Player添加castShadow


播放器.jsx


Cube添加castShadowreceiveShadow


立方体.jsx


场景中的所有物体都投射阴影


部分代码

添加阴影 - 修正阴影剪切

现在如果你仔细观察,你会发现投射阴影的表面积相当小。而当超出这个区域时,影子就被简单地切断了。


阴影裁剪


原因是默认情况下相机仅捕获来自orientationLight的显示阴影的一小部分区域。我们可以通过为orientationLight组件添加额外的属性shadow-camera-(top,bottom,left,right)来扩展这个区域的可见性。添加这些属性后,阴影会变得稍微模糊。为了提高质量,我们将添加shadow-mapSize属性。


应用程序.jsx


部分代码

将武器绑定到角色

现在让我们添加第一人称武器显示。创建一个新的武器组件,其中将包含武器行为逻辑和 3D 模型本身。


 import {WeaponModel} from "./WeaponModel.jsx"; export const Weapon = (props) => { return ( <group {...props}> <WeaponModel /> </group> ); }


让我们将此组件放置在与角色的RigidBody相同的水平上,并在useFrame挂钩中,我们将根据相机值的位置设置位置和旋转角度。


播放器.jsx


第一人称武器模型展示


部分代码

行走时武器摆动的动画

为了使角色的步态更加自然,我们将在移动时添加武器的轻微摆动。为了创建动画,我们将使用已安装的tween.js库。


Weapon组件将被包装在一个组标签中,以便您可以通过useRef挂钩添加对它的引用。


播放器.jsx


让我们添加一些useState来保存动画。


播放器.jsx


让我们创建一个函数来初始化动画。


播放器.jsx


代码说明:

  1. const twSwayingAnimation = new TWEEN.Tween(currentPosition) ...创建对象从当前位置“摆动”到新位置的动画。
  1. const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) ...创建第一个动画完成后对象返回到其起始位置的动画。
  1. twSwayingAnimation.chain(twSwayingBackAnimation);连接两个动画,以便当第一个动画完成时,第二个动画自动开始。


useEffect中我们调用动画初始化函数。


播放器.jsx


现在有必要确定运动发生的时刻。这可以通过确定角色方向的当前向量来完成。


如果角色发生移动,我们将刷新动画并在完成后再次运行。


播放器.jsx


代码说明:

  1. const isMoving = Direction.length() > 0;这里检查对象的运动状态。如果方向向量的长度大于0,则表示物体有运动方向。
  1. if (isMoving && isSwayingAnimationFinished) { ... }如果对象正在移动并且“摆动”动画已完成,则执行此状态。


App组件中,我们添加一个useFrame来更新补间动画。


应用程序.jsx


TWEEN.update()更新TWEEN.js库中的所有活动动画。在每个动画帧上调用此方法以确保所有动画顺利运行。


部分代码:

反冲动画

我们需要定义射击的时刻 - 即按下鼠标按钮的时刻。让我们添加useState来存储此状态, useRef来存储对武器对象的引用,以及两个用于按下和释放鼠标按钮的事件处理程序。


武器.jsx


武器.jsx


武器.jsx


让我们在单击鼠标按钮时实现反冲动画。为此,我们将使用tween.js库。


让我们定义反冲力和动画持续时间的常量。


武器.jsx


与武器摆动动画一样,我们为反冲和返回起始位置动画添加了两个 useState 状态,以及一个具有动画结束状态的状态。


武器.jsx


让我们创建函数来获取反冲动画的随机向量-generateRecoilOffsetgenerateNewPositionOfRecoil


武器.jsx


创建一个函数来初始化反冲动画。我们还将添加useEffect ,其中我们将指定“镜头”状态作为依赖项,以便在每次镜头时再次初始化动画并生成新的结束坐标。


武器.jsx


武器.jsx


useFrame中,我们添加一个检查“按住”鼠标键以进行射击,以便在释放按键之前射击动画不会停止。


武器.jsx


反冲动画


部分代码

不活动期间的动画

实现角色“不动”的动画,让游戏没有“挂”的感觉。


为此,我们通过useState添加一些新状态。


播放器.jsx


让我们修复“摆动”动画的初始化以使用状态中的值。这个想法是,不同的状态:行走或停止,将使用不同的动画值,并且每次动画都会首先初始化。


播放器.jsx


空闲动画


结论

在这一部分中,我们实现了场景生成和角色移动。我们还添加了武器模型、射击时和闲置时的反冲动画。在下一部分中,我们将继续完善我们的游戏,添加新功能。


也发布在这里