在现代 Web 开发中,经典应用程序和 Web 应用程序之间的界限每天都在变得模糊。今天,我们不仅可以创建交互式网站,还可以在浏览器中创建成熟的游戏。让这成为可能的工具之一是 库 - 一个使用 技术基于 创建 3D 图形的强大工具。 React Three Fiber React Three.js 关于 React 三纤维堆栈 是 包装器,它使用 的结构和原理在 Web 上创建 3D 图形。该堆栈允许开发人员将 的强大功能与 的便利性和灵活性结合起来,使创建应用程序的过程更加直观和有组织。 React Three Fiber Three.js 的 React Three.js React 的核心理念是,您在场景中创建的所有内容都是 组件。这允许开发人员应用熟悉的模式和方法。 React Three Fiber React 的主要优点之一是它易于与 生态系统集成。使用此库时仍然可以轻松集成任何其他 工具。 React Three Fiber React React Web-GameDev 的相关性 近年来发生了重大变化,从简单的 2D 游戏发展到可与桌面应用程序相媲美的复杂 3D 项目。受欢迎程度和功能的增长使得 Web-GameDev 成为一个不容忽视的领域。 Web-GameDev Flash 网页游戏的主要优势之一是其可访问性。玩家无需下载和安装任何其他软件 - 只需单击浏览器中的链接即可。这简化了游戏的发行和推广,使它们可供世界各地的广大受众使用。 最后,网页游戏开发可以成为开发人员尝试使用熟悉的技术进行游戏开发的好方法。借助可用的工具和库,即使没有 3D 图形经验,也可以创建有趣且高质量的项目! 现代浏览器中的游戏性能 现代浏览器已经走过了漫长的道路,从相当简单的网络浏览工具发展到用于运行复杂应用程序和游戏的强大平台。 、 、 主流浏览器都在不断优化和开发,以确保高性能,使其成为开发复杂应用程序的理想平台。 Chrome Firefox Edge 等 是推动基于浏览器的游戏发展的关键工具之一。该标准允许开发人员使用硬件图形加速,从而显着提高了 3D 游戏的性能。与其他 webAPI 一起, 为直接在浏览器中创建令人印象深刻的 Web 应用程序开辟了新的可能性。 WebGL WebGL 然而,在为浏览器开发游戏时,考虑各种性能方面至关重要:资源优化、内存管理和针对不同设备的适配都是影响项目成功的关键点。 各就各位! 然而,文字和理论是一回事,但实践经验又是另一回事。要真正理解和领会网页游戏开发的全部潜力,最好的方法就是沉浸在开发过程中。因此,作为网页游戏开发成功的例子,我们将创建自己的游戏。这个过程将使我们学习开发的关键方面,面对实际问题并找到解决方案,并看到网页游戏开发平台可以多么强大和灵活。 在一系列文章中,我们将了解如何使用该库的功能创建第一人称射击游戏,并深入探索令人兴奋的网页游戏开发世界! 最终演示 https://codesandbox.io/p/github/JI0PATA/fps-game?embedable=true 上的存储库 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 然后从我们的项目中 所有不必要的内容。 删除 部分代码 自定义画布显示 在 文件中,添加将作为范围显示在页面上的 div 元素。插入 组件并设置相机的视野。在 组件内放置 组件。 main.jsx Canvas Canvas App 让我们向 添加样式,以将UI 元素拉伸到屏幕的整个高度,并将范围显示为屏幕中心的圆圈。 index.css 在 组件中我们添加了一个 组件,它将以天空的形式显示在我们的游戏场景中作为背景。 App Sky 部分代码 地板表面 让我们创建一个 组件并将其放置在 组件中。 Ground App 在 中,创建一个平坦的表面元素。在 Y 轴上向下移动,使该平面位于相机的视野内。并在 X 轴上翻转平面,使其水平。 Ground 即使我们指定灰色作为材质颜色,平面仍显示为全黑。 部分代码 基本照明 默认情况下,场景中没有照明,因此让我们添加一个光源 ,它从各个方向照亮对象,并且没有定向光束。作为参数设置发光强度。 ambientLight 部分代码 地板表面的纹理 为了使地板表面看起来不均匀,我们将添加纹理。以沿着表面重复的单元格的形式制作地板表面的图案。 在 文件夹中添加带有纹理的 PNG 图像。 资源 要在场景中加载纹理,让我们使用 包中的 钩子。作为钩子的参数,我们将传递导入到文件中的纹理图像。设置图像在水平轴上的重复次数。 @react- Three/drei useTexture 部分代码 相机移动 使用 包中的 组件,将光标固定在屏幕上,这样当你移动鼠标时它不会移动,但会改变相机在场景中的位置。 @react-two/drei PointerLockControls 让我们对 组件进行一些小的编辑。 Ground 部分代码 添加物理 为了清楚起见,让我们向场景添加一个简单的立方体。 <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 之后,我们会立即看到每次页面重新加载时,立方体都会在重力的影响下下落。 但现在还有另一个任务 - 有必要使地板成为立方体可以与之交互的物体,并且超过它就不会掉落。 部分代码 地板作为一个物理对象 让我们回到 组件并添加一个 组件作为地板表面的包装。 Ground RigidBody 现在,当掉落时,立方体像真实的物理物体一样留在地板上。 部分代码 让角色服从物理定律 让我们创建一个 组件来控制场景中的角色。 Player 该角色与添加的立方体是同一物理对象,因此它必须与地板表面以及场景中的立方体进行交互。这就是我们添加 组件的原因。让我们将角色制作成胶囊的形式。 RigidBody 将 组件放置在Physics 组件内。 Player 现在我们的角色已经出现在现场了。 部分代码 移动角色 - 创建钩子 角色将使用 键进行控制,并使用 进行跳跃。 WASD 空格键 通过我们自己的react-hook,我们实现了移动角色的逻辑。 让我们创建一个 文件并在其中添加一个新的 函数。 hooks.js usePersonControls 让我们以 {"keycode": "action to be Perform"} 的格式定义一个对象。接下来,添加用于按下和释放键盘按键的事件处理程序。当处理程序被触发时,我们将确定当前正在执行的操作并更新其活动状态。作为最终结果,该钩子将返回一个格式为 {"action in Progress": "status"} 的对象。 部分代码 移动角色 - 实现钩子 实现 钩子后,应该在控制角色时使用它。在 组件中,我们将添加运动状态跟踪并更新角色运动方向的向量。 usePersonControls Player 我们还将定义存储移动方向状态的变量。 要更新角色的位置,让我们使用 包提供的 。该钩子的工作原理与 类似,每秒执行函数主体约 60 次。 @react- Three/Fiber Frame requestAnimationFrame 代码说明: 为玩家对象创建链接。此链接将允许与场景中的玩家对象直接交互。 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); 通过减去运动向量、对结果进行归一化(使向量长度为 1)并乘以运动速度常数来计算玩家运动的最终向量。 8. Direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED); “唤醒”玩家对象以确保它对更改做出反应。如果不使用此方法,一段时间后对象将“休眠”并且不会对位置变化做出反应。 9.playerRef.current.wakeUp(); 根据计算出的运动方向设置玩家新的线速度,并保持当前的垂直速度(以免影响跳跃或跌倒)。 10.playerRef.current.setLinvel({ x: 方向.x, y: 速度.y, z: 方向.z }); 结果,当按下 键时,角色开始在场景中移动。他还可以与立方体进行交互,因为它们都是物理对象。 WASD 部分代码 移动角色 - 跳跃 为了实现跳跃,我们使用 和 包中的功能。在此示例中,我们检查角色是否在地面上并且已按下跳跃键。在本例中,我们在 Y 轴上设置角色的方向和加速力。 @dimforge/rapier3d-compat @react- Three/rapier 对于 我们将在所有轴上添加质量和块旋转,以便他在与场景中的其他物体碰撞时不会在不同方向上摔倒。 玩家, 代码说明: 访问 物理引擎场景。它包含所有物理对象并管理它们的交互。 const world = 剑杆.world; Rapier 这就是“光线投射”(raycasting)发生的地方。创建一条从玩家当前位置开始并指向 y 轴的射线。该光线被“投射”到场景中,以确定它是否与场景中的任何对象相交。 const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 })); 如果玩家在地面上,则检查条件: const 接地 = ray && ray.collider && Math.abs(ray.toi) <= 1.5; - 是否被创建; ray 射线 - 射线是否与场景中的任何物体发生碰撞; ray.collider - 光线的“曝光时间”。如果该值小于或等于给定值,则可能表明玩家距离表面足够近,可以被视为“在地面上”。 Math.abs(ray.toi) 您还需要修改 组件,以便通过添加将与场景中其他对象交互的物理对象来确定“着陆”状态的光线追踪算法正常工作。 Ground 让我们将相机抬高一点,以便更好地观察场景。 部分代码 第一次提交 第二次提交 将摄像机移动到角色后面 为了移动相机,我们将获取玩家的当前位置,并在每次刷新帧时更改相机的位置。为了使角色精确地沿着相机所指向的轨迹移动,我们需要添加 。 applyEuler 代码说明: 方法根据指定的欧拉角对向量应用旋转。在这种情况下,相机旋转应用于 矢量。这用于匹配相对于相机方向的运动,以便玩家沿着相机旋转的方向移动。 applyEuler 方向 让我们稍微调整 的大小,使其相对于立方体更高,增加 的大小并修复“跳跃”逻辑。 Player CapsuleCollider 部分代码 第一次提交 第二次提交 立方体的生成 为了使场景不会感觉完全空虚,让我们添加立方体生成。在 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> ); } 让我们通过删除之前的单个立方体来将创建的 组件添加到 组件中。 立方体 应用程序 部分代码 将模型导入到项目中 现在让我们向场景添加 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 部分代码 添加阴影 此时,在我们的场景中,没有任何对象正在投射阴影。 要在场景上启用 ,您需要将 属性添加到 组件。 阴影 阴影 Canvas 接下来,我们需要添加一个新的光源。尽管场景中已经有了 ,但它无法为对象创建阴影,因为它没有定向光束。因此,让我们添加一个名为 新光源并对其进行配置。启用“ ”阴影模式的属性是 。正是这个参数的添加,表明这个物体可以给其他物体投射阴影。 环境光 orientationLight 的 投射 castShadow 之后,我们给 组件添加另一个属性 ,这意味着场景中的组件可以接收并显示自身的阴影。 Ground receiveShadow 类似的属性应该添加到场景中的其他对象:立方体和玩家。对于立方体,我们将添加 和 ,因为它们都可以投射和接收阴影,而对于玩家,我们将仅添加 。 castShadow receiveShadow castShadow 让我们为 添加 。 Player castShadow 为 添加 和 。 Cube castShadow receiveShadow 部分代码 添加阴影 - 修正阴影剪切 现在如果你仔细观察,你会发现投射阴影的表面积相当小。而当超出这个区域时,影子就被简单地切断了。 原因是默认情况下相机仅捕获来自 的显示阴影的一小部分区域。我们可以通过为 组件添加额外的属性 来扩展这个区域的可见性。添加这些属性后,阴影会变得稍微模糊。为了提高质量,我们将添加 属性。 orientationLight orientationLight shadow-camera-(top,bottom,left,right) shadow-mapSize 部分代码 将武器绑定到角色 现在让我们添加第一人称武器显示。创建一个新的 组件,其中将包含武器行为逻辑和 3D 模型本身。 武器 import {WeaponModel} from "./WeaponModel.jsx"; export const Weapon = (props) => { return ( <group {...props}> <WeaponModel /> </group> ); } 让我们将此组件放置在与角色的 相同的水平上,并在 挂钩中,我们将根据相机值的位置设置位置和旋转角度。 RigidBody useFrame 部分代码 行走时武器摆动的动画 为了使角色的步态更加自然,我们将在移动时添加武器的轻微摆动。为了创建动画,我们将使用已安装的 库。 tween.js 组件将被包装在一个组标签中,以便您可以通过 挂钩添加对它的引用。 Weapon useRef 让我们添加一些 来保存动画。 useState 让我们创建一个函数来初始化动画。 代码说明: 创建对象从当前位置“摆动”到新位置的动画。 const twSwayingAnimation = new TWEEN.Tween(currentPosition) ... 创建第一个动画完成后对象返回到其起始位置的动画。 const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) ... 连接两个动画,以便当第一个动画完成时,第二个动画自动开始。 twSwayingAnimation.chain(twSwayingBackAnimation); 在 中我们调用动画初始化函数。 useEffect 现在有必要确定运动发生的时刻。这可以通过确定角色方向的当前向量来完成。 如果角色发生移动,我们将刷新动画并在完成后再次运行。 代码说明: 这里检查对象的运动状态。如果方向向量的长度大于0,则表示物体有运动方向。 const isMoving = Direction.length() > 0; 如果对象正在移动并且“摆动”动画已完成,则执行此状态。 if (isMoving && isSwayingAnimationFinished) { ... } 在 组件中,我们添加一个 来更新补间动画。 App useFrame 更新 库中的所有活动动画。在每个动画帧上调用此方法以确保所有动画顺利运行。 TWEEN.update() TWEEN.js 部分代码: 第一次提交 第二次提交 反冲动画 我们需要定义射击的时刻 - 即按下鼠标按钮的时刻。让我们添加 来存储此状态, 来存储对武器对象的引用,以及两个用于按下和释放鼠标按钮的事件处理程序。 useState useRef 让我们在单击鼠标按钮时实现反冲动画。为此,我们将使用 库。 tween.js 让我们定义反冲力和动画持续时间的常量。 与武器摆动动画一样,我们为反冲和返回起始位置动画添加了两个 useState 状态,以及一个具有动画结束状态的状态。 让我们创建函数来获取反冲动画的随机向量 和 。 -generateRecoilOffset generateNewPositionOfRecoil 创建一个函数来初始化反冲动画。我们还将添加 ,其中我们将指定“镜头”状态作为依赖项,以便在每次镜头时再次初始化动画并生成新的结束坐标。 useEffect 在 中,我们添加一个检查“按住”鼠标键以进行射击,以便在释放按键之前射击动画不会停止。 useFrame 部分代码 不活动期间的动画 实现角色“不动”的动画,让游戏没有“挂”的感觉。 为此,我们通过 添加一些新状态。 useState 让我们修复“摆动”动画的初始化以使用状态中的值。这个想法是,不同的状态:行走或停止,将使用不同的动画值,并且每次动画都会首先初始化。 结论 在这一部分中,我们实现了场景生成和角色移动。我们还添加了武器模型、射击时和闲置时的反冲动画。在下一部分中,我们将继续完善我们的游戏,添加新功能。 也发布 。 在这里