Three实战3D射击游戏#1

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

1、简介

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

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

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

1.1 Web-GameDev 的相关性

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

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

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

1.2 现代浏览器中的游戏性能

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

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

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

1.3 各就各位!

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

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

现在,让我们开始吧!

2、设置项目并安装包

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

npm create vite@latest
  • 选择React库;
  • 选择 JavaScript。

安装额外的 npm 包。

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

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

代码请访问这里

3、自定义画布显示

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

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

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

代码请访问这里

4、地板表面

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

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

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

代码请访问这里

5、基本照明

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

代码请访问这里

6、地板表面的纹理

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

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

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

代码请访问这里

7、相机移动

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

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

代码请访问这里

8、添加物理模拟

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

<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 组件中。

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

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

代码请访问这里

9、地板作为一个物理对象

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

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

代码请访问这里

10、让角色服从物理定律

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

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

将 Player 组件放置在Physics 组件内。

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

代码请访问这里

11、移动角色 - 创建钩子

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

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

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

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

代码请访问这里

12、移动角色 - 实现钩子

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

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

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

代码说明:

  • const playerRef = useRef(); 为玩家对象创建链接。 此链接将允许与场景中的玩家对象直接交互。
  • const { forward, backward, left, right, jump } = usePersonControls(); 当使用钩子时,会返回一个具有布尔值的对象,该布尔值指示玩家当前按下了哪些控制按钮。
  • useFrame((state) => { ... }); 在动画的每一帧上都会调用该钩子。 在此钩子内,玩家的位置和线速度会更新。
  • if (!playerRef.current) return; 检查玩家对象是否存在。 如果没有玩家对象,该函数将停止执行以避免错误。
  • const velocity=playerRef.current.linvel(); 获取玩家当前的线速度。
  • frontVector.set(0, 0,backward-forward); 根据按下的按钮设置向前/向后运动矢量。
  • sideVector.set(left-right, 0, 0); 设置左/右移动矢量。
  • Direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED); 通过减去运动向量、对结果进行归一化(使向量长度为 1)并乘以运动速度常数来计算玩家运动的最终向量。
  • playerRef.current.wakeUp(); “唤醒”玩家对象以确保它对更改做出反应。 如果不使用此方法,一段时间后对象将“休眠”并且不会对位置变化做出反应。
  • playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z }); 根据计算出的运动方向设置玩家新的线速度,并保持当前的垂直速度(以免影响跳跃或跌倒)。

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

代码请访问这里

13、移动角色 - 跳跃

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

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


代码说明:

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

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

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

14、将相机移动到角色后面

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

代码说明:

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

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

15、立方体的生成

为了使场景不会感觉完全空虚,让我们添加立方体生成。 在 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>
    );
}

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

代码请访问这里

17、将模型导入到项目中

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

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

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

npm i -D gltf-pipeline

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

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

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

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

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

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

代码请访问这里

18、现场展示武器模型

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

代码请访问这里

19、添加阴影

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

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

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

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

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

让我们为 Player 添加castShadow。

为 Cube 添加castShadow和receiveShadow。

代码请访问这里

20、添加阴影 - 修正阴影剪切

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

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

代码请访问这里

21、将武器绑定到角色

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

import {WeaponModel} from "./WeaponModel.jsx";

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

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

代码请访问这里

22、行走时武器摆动的动画

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

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

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

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

代码说明:

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

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

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

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

代码说明:

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

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

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

23、反冲动画

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

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

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

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

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

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

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

代码请访问这里

24、不活动期间的动画

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

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

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

25、结束语

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


原文链接:Creating Your Own 3D Shooter Using the React and Three.js Stack — Part 1

BimAnt翻译整理,转载请标明出处