Three.js+React實現(xiàn)3D開放世界小游戲
背景
2545光年之外的開普勒1028星系,有一顆色彩斑斕的宜居星球 ,星際移民必須穿戴基地發(fā)放的防輻射服才能生存。阿貍駕駛星際飛行器降臨此地,快幫它在限定時間內(nèi)使用輪盤移動找到基地獲取防輻射服吧!
本文使用 Three.js + React + CANNON
技術(shù)棧,實現(xiàn)通過滑動屏幕控制模型在 3D
世界里運(yùn)動的 Low Poly
低多邊形風(fēng)格小游戲。本文主要涉及到的知識點包括:Three.js
陰影類型、創(chuàng)建粒子系統(tǒng)、cannon.js
基本用法、使用 cannon.js
高度場 Heightfield
創(chuàng)建地形、通過輪盤移動控制模型動畫等。
效果
- 游戲玩法:點擊開始游戲按鈕,通過操作屏幕底部輪盤來移動阿貍,在倒計時限定時間內(nèi)找到基地。
- 主線任務(wù):限定時間內(nèi)找到庇護(hù)所。
- 支線任務(wù):自由探索開放世界。
在線預(yù)覽:
已適配:
- PC端
- 移動端
小提示:站得越高看得越遠(yuǎn),隱隱約約聽說基地位于初始位置的西面,開始時應(yīng)該向左前方前進(jìn)哦。
設(shè)計
游戲流程如下圖所示:頁面加載完成后玩家點擊開始按鈕,然后在限定時間內(nèi)通過控制頁面底部輪盤 ?? 移動模型,找到目標(biāo)基地所在的位置。尋找成功或失敗都會顯示結(jié)果頁,結(jié)果上面有兩個按鈕再試一次和自由探索,點擊再試一次時間會重置,然后重新回到起點開始倒計時。點擊自由探索則不在計時,玩家可以在 3D 開放世界里操作模型自由探索。同時,游戲內(nèi)頁面也提供一個時光倒流按鈕,它的作用是玩家可以在失敗前自己手動重置倒計時,重新回到起點開始游戲。
實現(xiàn)
加載資源
加載開發(fā)所需的必備資源:GLTFLoader
用于加載狐貍和基地模型、CANNON
是用于創(chuàng)建 3D
世界的物理引擎;CannonHelper
是對 CANNON
一些使用方法的封裝;JoyStick
用于創(chuàng)建通過監(jiān)聽鼠標(biāo)移動位置或觸碰屏幕產(chǎn)生的位移來控制模型移動的輪盤。
import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; import CANNON from 'cannon'; import CannonHelper from './scripts/CannonHelper'; import JoyStick from './scripts/JoyStick';
頁面結(jié)構(gòu)
頁面結(jié)構(gòu)比較簡單,.webgl
用于渲染 WEBGL
;.tool
是游戲內(nèi)的工具欄,用于重置游戲和顯示一些提示語;.loading
是游戲加載頁面,用來顯示游戲加載進(jìn)度、介紹游戲規(guī)則、顯示游戲開始按鈕;.result
是游戲結(jié)果頁面,用于顯示游戲成功或失敗結(jié)果,并提供再試一次和自由探索兩個按鈕。
(<div id="metaverse"> <canvas className='webgl'></canvas> <div className='tool'> <div className='countdown'>{ this.state.countdown }</div> <button className='reset_button' onClick={this.resetGame}>時光倒流</button> <p className='hint'>站得越高看得越遠(yuǎn)</p> </div> { this.state.showLoading ? (<div className='loading'> <div className='box'> <p className='progress'>{this.state.loadingProcess} %</p> <p className='description'>游戲描述</p> <button className='start_button' style={{'visibility': this.state.loadingProcess === 100 ? 'visible' : 'hidden'}} onClick={this.startGame}>開始游戲</button> </div> </div>) : '' } { this.state.showResult ? (<div className='result'> <div className='box'> <p className='text'>{ this.state.resultText }</p> <button className='button' onClick={this.resetGame}>再試一次</button> <button className='button' onClick={this.discover}>自由探索</button> </div> </div>) : '' } </div>)
數(shù)據(jù)初始化
數(shù)據(jù)變量包括加載進(jìn)度、是否顯示加載頁面、是否顯示結(jié)果頁、結(jié)果頁文案、倒計時、是否開啟自由探索等。
state = { loadingProcess: 0, showLoading: true, showResult: false, resultText: '失敗', countdown: 60, freeDiscover: false }
場景初始化
初始化場景、相機(jī)、光源。
const renderer = new THREE.WebGLRenderer({ canvas: document.querySelector('canvas.webgl'), antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; const scene = new THREE.Scene(); // 添加主相機(jī) const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, .01, 100000); camera.position.set(1, 1, -1); camera.lookAt(scene.position); // 添加環(huán)境光 const ambientLight = new THREE.AmbientLight(0xffffff, .4); scene.add(ambientLight) // 添加平行光 var light = new THREE.DirectionalLight(0xffffff, 1); light.position.set(1, 1, 1).normalize(); scene.add(light);
Three.js 陰影類型
本文使用了 THREE.PCFSoftShadowMap
以開啟效果更加柔和的陰影,Three.js
提供以下幾種陰影類型:
THREE.BasicShadowMap
:提供未經(jīng)過濾的陰影貼圖,性能最快,但質(zhì)量最低。THREE.PCFShadowMap
:使用Percentage-Closer Filtering (PCF)
算法過濾陰影貼圖,是默認(rèn)類型。THREE.PCFSoftShadowMap
:使用PCF
算法過濾的更加柔和的陰影貼圖,尤其是在使用低分辨率陰影貼圖時。THREE.VSMShadowMap
:使用方差陰影貼圖VSM
算法過濾的陰影貼圖。 使用VSMShadowMap
時,所有陰影接收者也會投射陰影。
創(chuàng)建世界
使用 Cannon.js
初始化物理世界。
// 初始化物理世界 const world = new CANNON.World(); // 在多個步驟的任意軸上測試剛體的碰撞 world.broadphase = new CANNON.SAPBroadphase(world); // 設(shè)置物理世界的重力為沿y軸向上-10米每二次方秒 world.gravity.set(0, -10, 0); // 創(chuàng)建默認(rèn)聯(lián)系材質(zhì) world.defaultContactMaterial.friction = 0; const groundMaterial = new CANNON.Material("groundMaterial"); const wheelMaterial = new CANNON.Material("wheelMaterial"); const wheelGroundContactMaterial = new CANNON.ContactMaterial(wheelMaterial, groundMaterial, { // 摩擦系數(shù) friction: 0, // 恢復(fù)系數(shù) restitution: 0, // 接觸剛度 contactEquationStiffness: 1000 }); world.addContactMaterial(wheelGroundContactMaterial);
Cannon.js
Cannon.js
是用 JavaScript
實現(xiàn)的物理引擎庫,可以與任何支持瀏覽器的渲染或游戲引擎,可以用于模擬剛體,實現(xiàn) 3D
世界中更加真實的物理形式的移動和交互。更多 Cannon.js
相關(guān) API
文檔和示例可以參考文章末尾鏈接。
創(chuàng)建星空
創(chuàng)建 1000
個粒子用于模型星空 ?
,并將它們添加到場景中。本示例中通過著色器形式創(chuàng)建粒子,這樣更有利于 GPU
渲染效率。
const textureLoader = new THREE.TextureLoader(); const shaderPoint = THREE.ShaderLib.points; const uniforms = THREE.UniformsUtils.clone(shaderPoint.uniforms); uniforms.map.value = textureLoader.load(snowflakeTexture); for (let i = 0; i < 1000; i++) { sparkGeometry.vertices.push(new THREE.Vector3()); } const sparks = new THREE.Points(new THREE.Geometry(), new THREE.PointsMaterial({ size: 2, color: new THREE.Color(0xffffff), map: uniforms.map.value, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.75 })); sparks.scale.set(1, 1, 1); sparks.geometry.vertices.map(spark => { spark.y = randnum(30, 40); spark.x = randnum(-500, 500); spark.z = randnum(-500, 500); return true; }); scene.add(sparks);
創(chuàng)建地形
通過 CANNON.Heightfield
高度場創(chuàng)建 128 x 128 x 60
可視化漸變色地形。地形的凹凸起伏狀態(tài)是通過以下高度圖 HeightMap
實現(xiàn),它是一張黑白圖片,通過像素點的顏色深淺來記錄高度信息,根據(jù)高度圖數(shù)據(jù)信息創(chuàng)建地形網(wǎng)格??赏ㄟ^文章末尾提供的鏈接在線生成隨機(jī)高度圖。地形生成完成并將它添加到世界中,然后在 animate
方法中頁面重繪時調(diào)用 check
方法,用于檢測和更新模型在地形上的位置。
const cannonHelper = new CannonHelper(scene); var sizeX = 128, sizeY = 128, minHeight = 0, maxHeight = 60, check = null; Promise.all([ // 加載高度圖 img2matrix.fromUrl(heightMapImage, sizeX, sizeY, minHeight, maxHeight)(), ]).then(function (data) { var matrix = data[0]; // 地形體 const terrainBody = new CANNON.Body({ mass: 0 }); // 地形形狀 const terrainShape = new CANNON.Heightfield(matrix, { elementSize: 10 }); terrainBody.addShape(terrainShape); // 地形位置 terrainBody.position.set(-sizeX * terrainShape.elementSize / 2, -10, sizeY * terrainShape.elementSize / 2); // 設(shè)置從軸角度 terrainBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2); world.add(terrainBody); // 將生成的地形剛體可視化 cannonHelper.addVisual(terrainBody, 'landscape'); var raycastHelperGeometry = new THREE.CylinderGeometry(0, 1, 5, 1.5); raycastHelperGeometry.translate(0, 0, 0); raycastHelperGeometry.rotateX(Math.PI / 2); var raycastHelperMesh = new THREE.Mesh(raycastHelperGeometry, new THREE.MeshNormalMaterial()); scene.add(raycastHelperMesh); // 使用 Raycaster檢測并更新模型在地形上的位置 check = () => { var raycaster = new THREE.Raycaster(target.position, new THREE.Vector3(0, -1, 0)); var intersects = raycaster.intersectObject(terrainBody.threemesh.children[0]); if (intersects.length > 0) { raycastHelperMesh.position.set(0, 0, 0); raycastHelperMesh.lookAt(intersects[0].face.normal); raycastHelperMesh.position.copy(intersects[0].point); } target.position.y = intersects && intersects[0] ? intersects[0].point.y + 0.1 : 30; var raycaster2 = new THREE.Raycaster(shelterLocation.position, new THREE.Vector3(0, -1, 0)); var intersects2 = raycaster2.intersectObject(terrainBody.threemesh.children[0]); shelterLocation.position.y = intersects2 && intersects2[0] ? intersects2[0].point.y + .5 : 30; shelterLight.position.y = shelterLocation.position.y + 50; shelterLight.position.x = shelterLocation.position.x + 5 shelterLight.position.z = shelterLocation.position.z; } });
CANNON.Heightfield
本示例中凹凸不平的地形是通過 CANNON.Heightfield
實現(xiàn)的,它是 Cannon.js
物理引擎的高度場。在物理學(xué)中把某個物理量在空間中一個區(qū)域內(nèi)的分布稱為場,高度場就是與高度相關(guān)的場。Heightfield
的高度就是關(guān)于兩個變量的函數(shù),可以表達(dá)為 HEIGHT(i,j)
。
Heightfield(data, options)
data
是一個 y值
數(shù)組,將用于構(gòu)建地形。
options
是一個配置項,有三個可配置參數(shù):
minValue
是數(shù)據(jù)數(shù)組中數(shù)據(jù)點的最小值。如果未給出,將自動計算。maxValue
最大值。elementSize
是x軸
方向上數(shù)據(jù)點之間的世界間距。
加載進(jìn)度管理
使用 LoadingManager
管理加載進(jìn)度,當(dāng)頁面模型加載完成之后,加載進(jìn)度頁面顯示開始游戲菜單。
const loadingManager = new THREE.LoadingManager(); loadingManager.onProgress = async (url, loaded, total) => { this.setState({ loadingProcess: Math.floor(loaded / total * 100) }); };
創(chuàng)建基地模型
加載基地模型前先創(chuàng)建一個 shelterLocation
網(wǎng)格用來放置基地模型,該網(wǎng)格對象還用于后續(xù)地形檢測。然后使用 GLTFLoader
加載基地模型,然后把它添加到 shelterLocation
網(wǎng)格上。最后添加一個 PointLight
給基地模型添加彩色點光源,添加一個 DirectionalLight
用于生成陰影。
const shelterGeometry = new THREE.BoxBufferGeometry(0.15, 2, 0.15); const shelterLocation = new THREE.Mesh(shelterGeometry, new THREE.MeshNormalMaterial({ transparent: true, opacity: 0 })); shelterLocation.position.set(this.shelterPosition.x, this.shelterPosition.y, this.shelterPosition.z); shelterLocation.rotateY(Math.PI); scene.add(shelterLocation); // 加載模型 gltfLoader.load(Shelter, mesh => { mesh.scene.traverse(child => { child.castShadow = true; }); mesh.scene.scale.set(5, 5, 5); mesh.scene.position.y = -.5; shelterLocation.add(mesh.scene) }); // 添加光源 const shelterPointLight = new THREE.PointLight(0x1089ff, 2); shelterPointLight.position.set(0, 0, 0); shelterLocation.add(shelterPointLight); const shelterLight = new THREE.DirectionalLight(0xffffff, 0); shelterLight.position.set(0, 0, 0); shelterLight.castShadow = true; shelterLight.target = shelterLocation; scene.add(shelterLight);
創(chuàng)建阿貍模型
狐貍模型的加載也是類似的,需要先創(chuàng)建一個目標(biāo)網(wǎng)格,后續(xù)用于地形檢測,然后把狐貍模型添加到目標(biāo)網(wǎng)格上。狐貍模型完成加載后,需要保存它的 clip1
、 clip1
兩種動畫效果,后續(xù)需要通過判斷輪盤的移動狀態(tài)來判斷播放哪種動畫。最后添加一個 DirectionalLight
光源來產(chǎn)生陰影。
var geometry = new THREE.BoxBufferGeometry(.5, 1, .5); geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, .5, 0)); const target = new THREE.Mesh(geometry, new THREE.MeshNormalMaterial({ transparent: true, opacity: 0 })); scene.add(target); var mixers = [], clip1, clip2; const gltfLoader = new GLTFLoader(loadingManager); gltfLoader.load(foxModel, mesh => { mesh.scene.traverse(child => { if (child.isMesh) { child.castShadow = true; child.material.side = THREE.DoubleSide; } }); var player = mesh.scene; player.position.set(this.playPosition.x, this.playPosition.y, this.playPosition.z); player.scale.set(.008, .008, .008); target.add(player); var mixer = new THREE.AnimationMixer(player); clip1 = mixer.clipAction(mesh.animations[0]); clip2 = mixer.clipAction(mesh.animations[1]); clip2.timeScale = 1.6; mixers.push(mixer); }); const directionalLight = new THREE.DirectionalLight(new THREE.Color(0xffffff), .5); directionalLight.position.set(0, 1, 0); directionalLight.castShadow = true; directionalLight.target = target; target.add(directionalLight);
控制阿貍運(yùn)動
使用輪盤控制器移動阿貍模型時,實時更新模型的方向,若輪盤產(chǎn)生位移,更新模型位移并播放奔跑動畫,否則播放禁止動畫。同時根據(jù)模型目標(biāo)的位置,實時更新相機(jī)的位置,生成第三人稱視角。輪盤移動控制模型移動功能是通過引入 JoyStick
類來實現(xiàn),它的主要實現(xiàn)原理是監(jiān)聽鼠標(biāo)或點觸位置,然后通過計算映射到模型位置變化上。
var setup = { forward: 0, turn: 0 }; new JoyStick({ onMove: (forward, turn) => { setup.forward = forward; setup.turn = -turn; }}); const updateDrive = (forward = setup.forward, turn = setup.turn) => { let maxSteerVal = 0.05; let maxForce = .15; let force = maxForce * forward; let steer = maxSteerVal * turn; if (forward !== 0) { target.translateZ(force); clip2 && clip2.play(); clip1 && clip1.stop(); } else { clip2 && clip2.stop(); clip1 && clip1.play(); } target.rotateY(steer); } // 生成第三人稱視角 const followCamera = new THREE.Object3D(); followCamera.position.copy(camera.position); scene.add(followCamera); followCamera.parent = target; const updateCamera = () => { if (followCamera) { camera.position.lerp(followCamera.getWorldPosition(new THREE.Vector3()), 0.1); camera.lookAt(target.position.x, target.position.y + .5, target.position.z); } }
動畫更新
在頁面重繪動畫中,更新相機(jī)、模型狀態(tài)、Cannon
世界、場景渲染等。
var clock = new THREE.Clock(); var lastTime; var fixedTimeStep = 1.0 / 60.0; const animate = () => { updateCamera(); updateDrive(); let delta = clock.getDelta(); mixers.map(x => x.update(delta)); let now = Date.now(); lastTime === undefined && (lastTime = now); let dt = (Date.now() - lastTime) / 1000.0; lastTime = now; world.step(fixedTimeStep, dt); cannonHelper.updateBodies(world); check && check(); renderer.render(scene, camera); requestAnimationFrame(animate); };
頁面縮放適配
頁面產(chǎn)生縮放時,更新渲染場景和相機(jī)。
window.addEventListener('resize', () => { var width = window.innerWidth, height = window.innerHeight; renderer.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); }, false);
到此,游戲三維世界已經(jīng)全部實現(xiàn)完畢了。
添加游戲邏輯
根據(jù)前面的游戲流程設(shè)計,現(xiàn)在添加游戲邏輯,開始游戲時重置數(shù)據(jù)并開始 60s 倒計時;重置游戲時將阿貍位置、方向及相機(jī)位置設(shè)置為初始狀態(tài);自由探索時開啟自由探索狀態(tài),并清除倒計時。
startGame = () => { this.setState({ showLoading : false, showResult: false, countdown: 60, resultText: '失敗', freeDiscover: false },() => { this.interval = setInterval(() => { if (this.state.countdown > 0) { this.setState({ countdown: --this.state.countdown }); } else { clearInterval(this.interval) this.setState({ showResult: true }); } }, 1000); }); } resetGame = () => { this.player.position.set(this.playPosition.x, this.playPosition.y, this.playPosition.z); this.target.rotation.set(0, 0, 0); this.target.position.set(0, 0, 0); this.camera.position.set(1, 1, -1); this.startGame(); } discover = () => { this.setState({ freeDiscover: true, showResult: false, countdown: 60 }, () => { clearInterval(this.interval); }); }
毛玻璃效果
Loading
頁面、結(jié)果頁面以及回到過去按鈕都采用了毛玻璃效果樣式,通過以下幾行樣式代碼,即可實現(xiàn)驚艷的毛玻璃。
background rgba(0, 67, 170, .5) backdrop-filter blur(10px) filter drop-shadow(0px 1px 1px rgba(0, 0, 0, .25))
總結(jié)
本文涉及到的新知識點主要包括:
Three.js
陰影類型- 創(chuàng)建粒子系統(tǒng)
cannon.js
基本用法- 使用
cannon.js
高度場Heightfield
創(chuàng)建地形 - 通過輪盤移動控制模型動畫
以上就是Three.js+React實現(xiàn)3D開放世界小游戲的詳細(xì)內(nèi)容,更多關(guān)于Three.js React 3D游戲的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JavaScript中判斷函數(shù)是new還是()調(diào)用的區(qū)別說明
具名函數(shù)的各種調(diào)用方式 在之前篇幅中已經(jīng)介紹過了。這篇看看如何判斷一個函數(shù)是被new調(diào)用的,還是被其它方式調(diào)用的。2011-04-045分鐘快速掌握J(rèn)S中var、let和const的異同
在javascript中有三種聲明變量的方式:var、let、const,這個是對新手們來說應(yīng)該掌握的知識,所以這篇文章主要給大家介紹了關(guān)于如何通過5分鐘快速掌握J(rèn)S中var,let和const的異同,需要的朋友可以參考下2018-09-09Bootstrap實現(xiàn)可折疊分組側(cè)邊導(dǎo)航菜單
這篇文章主要介紹了Bootstrap實現(xiàn)可折疊分組側(cè)邊導(dǎo)航菜單的相關(guān)資料,需要的朋友可以參考下2018-03-03復(fù)制小說文本時出現(xiàn)的隨機(jī)亂碼的去除方法
想把小說復(fù)制下來慢慢看,卻發(fā)現(xiàn)復(fù)制到記事本里出現(xiàn)一大堆亂七八糟的東西,很是不爽。于是就想了個簡單的辦法把它干掉了。2010-09-09JavaScript空數(shù)組的every()方法實踐
every()方法用于檢測數(shù)組中的所有元素是否都滿足指定條件, 本文主要介紹了JavaScript空數(shù)組的every()方法實踐,具有一定的參考價值,感興趣的可以了解一下2024-03-03js實現(xiàn)網(wǎng)頁的兩個input標(biāo)簽內(nèi)的數(shù)值加減(示例代碼)
下面小編就為大家?guī)硪黄猨s實現(xiàn)網(wǎng)頁的兩個input標(biāo)簽內(nèi)的數(shù)值加減(示例代碼)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-08-08js動態(tài)生成按鈕并動態(tài)生成8位隨機(jī)數(shù)
用js生成按鈕,動態(tài)生成8位隨機(jī)數(shù)的腳本2008-09-09