1. Three.js實(shí)現(xiàn)3D推箱子小游戲

        共 14076字,需瀏覽 29分鐘

         ·

        2024-04-11 22:05

        最近一直在學(xué) Three.js ,看到別人做出那么多炫酷的效果,覺得太厲害了,于是決定從一些簡(jiǎn)單的效果開始做。所以打算借這個(gè) 小游戲[1] 來認(rèn)真學(xué)習(xí)一下 Three.js 。

        在線預(yù)覽

        https://liamwu50.github.io/three-sokoban-live/

        游戲介紹

        "推箱子" 游戲最早是由日本游戲開發(fā)者Hiroyuki Imabayashi 于1982年開發(fā)和發(fā)布的。這款游戲的日本名為 "Sokoban"(倉庫番),意為 "倉庫管理員"。"推箱子" 游戲的目標(biāo)是在游戲區(qū)域內(nèi)將箱子移動(dòng)到指定的位置,通常通過推箱子來完成。游戲邏輯并不復(fù)雜,正好可以用來練練手。

        代碼實(shí)現(xiàn)

        基礎(chǔ)場(chǎng)景

        初始化場(chǎng)景

        游戲場(chǎng)景主要分為四個(gè)部分:場(chǎng)景底部面板、倉庫邊界、箱子、推箱子的人。首先肯定是初始化場(chǎng)景,需要完成場(chǎng)景、相機(jī)、燈光、控制器的創(chuàng)建。場(chǎng)景、渲染器都是常規(guī)創(chuàng)建就行,相機(jī)的話因?yàn)槲覀冇螒驁?chǎng)景的范圍是 10*10 ,所以相機(jī)需要稍微調(diào)整一下。

                  const fov = 60
            const aspect = this.sizes.width / this.sizes.height
            this.camera = new PerspectiveCamera(fov, aspect, 0.1)
            this.camera.position.copy(
              new Vector3(
                this.gridSize.x / 2 - 2,
                this.gridSize.x / 2 + 4.5,
                this.gridSize.y + 1.7
              )
            )

        gridSize 表示游戲場(chǎng)景的范圍,暫時(shí)設(shè)置為 10*10 的網(wǎng)格,后面隨著游戲關(guān)數(shù)不同,復(fù)雜度的變化,整體游戲范圍肯定會(huì)越來越大。燈光我們需要?jiǎng)?chuàng)建兩個(gè)燈光,一個(gè)平行光,一個(gè)環(huán)境光,光的顏色都設(shè)置為白色就行,平行光需要添加一些陰影的參數(shù)。

              const ambLight = new AmbientLight(0xffffff0.6)
        const dirLight = new DirectionalLight(0xffffff0.7)

        dirLight.position.set(202020)
        dirLight.target.position.set(this.gridSize.x / 20this.gridSize.y / 2)
        dirLight.shadow.mapSize.set(10241024)
        dirLight.shadow.radius = 7
        dirLight.shadow.blurSamples = 20
        dirLight.shadow.camera.top = 30
        dirLight.shadow.camera.bottom = -30
        dirLight.shadow.camera.left = -30
        dirLight.shadow.camera.right = 30

        dirLight.castShadow = true

        this.scene.add(ambLight, dirLight)

        底部平面

        Three.js 的場(chǎng)景初始完之后,接著需要?jiǎng)?chuàng)建游戲場(chǎng)景的底部平面。

        游戲場(chǎng)景平面我們用 PlaneGeometry 來創(chuàng)建,接著將平面沿著x軸旋轉(zhuǎn)90度,調(diào)整為水平方向,并且給平面添加網(wǎng)格輔助 AxesHelper ,方便我們?cè)谟螒蛞苿?dòng)的過程中找準(zhǔn)位置。

              private createScenePlane() {
            const { x, y } = this.gridSize
            const planeGeometry = new PlaneGeometry(x * 50, y * 50)
            planeGeometry.rotateX(-Math.PI * 0.5)
            const planMaterial = new MeshStandardMaterial({ color: theme.groundColor })
            const plane = new Mesh(planeGeometry, planMaterial)
            plane.position.x = x / 2 - 0.5
            plane.position.z = y / 2 - 0.5
            plane.position.y = -0.5
            plane.receiveShadow = true
            this.scene.add(plane)
          }
          
          private createGridHelper() {
            const gridHelper = new GridHelper(
              this.gridSize.x,
              this.gridSize.y,
              0xffffff,
              0xffffff
            )
            gridHelper.position.set(
              this.gridSize.x / 2 - 0.5,
              -0.49,
              this.gridSize.y / 2 - 0.5
            )
            gridHelper.material.transparent = true
            gridHelper.material.opacity = 0.3
            this.scene.add(gridHelper)
          }

        人物

        接著我們創(chuàng)建一個(gè)可以推動(dòng)箱子的人物,我們用 RoundedBoxGeometry 來創(chuàng)建身體,再創(chuàng)建兩個(gè) SphereGeometry 當(dāng)做眼睛,最后再用 RoundedBoxGeometry 創(chuàng)建一個(gè)嘴巴,就簡(jiǎn)單的完成了一個(gè)人物。

              export default class PlayerGraphic extends Graphic {
          constructor() {
            const NODE_GEOMETRY = new RoundedBoxGeometry(0.80.80.850.1)
            const NODE_MATERIAL = new MeshStandardMaterial({
              color: theme.player
            })
            const headMesh = new Mesh(NODE_GEOMETRY, NODE_MATERIAL)
            headMesh.name = PLAYER

            const leftEye = new Mesh(
              new SphereGeometry(0.161010),
              new MeshStandardMaterial({
                color: 0xffffff
              })
            )
            leftEye.scale.z = 0.1
            leftEye.position.x = 0.2
            leftEye.position.y = 0.16
            leftEye.position.z = 0.46

            const leftEyeHole = new Mesh(
              new SphereGeometry(0.1100100),
              new MeshStandardMaterial({ color: 0x333333 })
            )

            leftEyeHole.position.z += 0.08
            leftEye.add(leftEyeHole)

            const rightEye = leftEye.clone()
            rightEye.position.x = -0.2

            const mouthMesh = new Mesh(
              new RoundedBoxGeometry(0.40.150.250.05),
              new MeshStandardMaterial({
                color: '#5f27cd'
              })
            )
            mouthMesh.position.x = 0.0
            mouthMesh.position.z = 0.4
            mouthMesh.position.y = -0.2

            headMesh.add(leftEye, rightEye, mouthMesh)
            headMesh.lookAt(headMesh.position.clone().add(new Vector3(001)))

            super(headMesh)
          }
        }

        創(chuàng)建出來的人物長這樣:

        b097f6da1826a6a99987b2b6807761fa.webp

        游戲場(chǎng)景

        搭建場(chǎng)景

        游戲的所有內(nèi)容都是通過 Three.js 的立體幾何來創(chuàng)建的,整個(gè)場(chǎng)景分為了游戲區(qū)域以及環(huán)境區(qū)域,游戲區(qū)域一共有五種類型:人物、圍墻、箱子、目標(biāo)點(diǎn)、空白區(qū)域。首先定義五種類型:

              export const EMPTY = 'empty'
        export const WALL = 'wall'
        export const TARGET = 'TARGET'
        export const BOX = 'box'
        export const PLAYER = 'player'

        類型定義好之后,我們需要定義整個(gè)游戲關(guān)卡的布局,推箱子的游戲掘金上也有很多,我看了設(shè)置布局的方式多種多樣,我選擇一種比較容易理解也比較簡(jiǎn)單的數(shù)據(jù)結(jié)構(gòu),就是用雙層數(shù)組結(jié)構(gòu)來表示每一種元素對(duì)應(yīng)所在的位置。并且我把目標(biāo)點(diǎn)的位置沒有放在整個(gè)游戲的布局?jǐn)?shù)據(jù)里面,而是單獨(dú)存起來,這樣做是因?yàn)閜layer移動(dòng)之后我們需要實(shí)時(shí)的去維護(hù)這個(gè)布局?jǐn)?shù)據(jù),所以少一種類型的話我們會(huì)簡(jiǎn)化很多判斷邏輯。

              export const firstLevelDataSource: LevelDataSource = {
          layout: [
            [WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL],
            [WALL, PLAYER, EMPTY, EMPTY, WALL, WALL, WALL, WALL, WALL],
            [WALL, EMPTY, BOX, BOX, WALL, WALL, WALL, WALL, WALL],
            [WALL, EMPTY, BOX, EMPTY, WALL, WALL, WALL, EMPTY, WALL],
            [WALL, WALL, WALL, EMPTY, WALL, WALL, WALL, EMPTY, WALL],
            [WALL, WALL, WALL, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, WALL],
            [WALL, WALL, EMPTY, EMPTY, EMPTY, WALL, EMPTY, EMPTY, WALL],
            [WALL, WALL, EMPTY, EMPTY, EMPTY, WALL, WALL, WALL, WALL],
            [WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL]
          ],
          targets: [
            [37],
            [47],
            [57]
          ]
        }

        layout就表示游戲的布局?jǐn)?shù)據(jù),后面我們循環(huán)加載的時(shí)候按照類型來對(duì)應(yīng)加載就行了。接著我們開始加載游戲的基本數(shù)據(jù)。

              /**
         * 創(chuàng)建類型網(wǎng)格
         */

        private createTypeMesh(cell: CellType, x: number, y: number) {
          if (cell === WALL) {
            this.createWallMesh(x, y)
          } else if (cell === BOX) {
            this.createBoxMesh(x, y)
          } else if (cell === PLAYER) {
            this.createPlayerMesh(x, y)
          }
        }

        這里的x,y實(shí)際就對(duì)應(yīng)當(dāng)前幾何體所在的位置,需要注意的就是在加載箱子的時(shí)候,需要判斷一下,當(dāng)前箱子的位置是不是在目標(biāo)點(diǎn)上,如果在目標(biāo)點(diǎn)上的話就需要把箱子的顏色設(shè)置為激活的顏色。

              private createBoxMesh(x: number, y: number) {
          const isTarget = this.elementManager.isTargetPosition(x, y)
          const color = isTarget ? theme.coincide : theme.box
          const boxGraphic = new BoxGraphic(color)
          boxGraphic.mesh.position.x = x
          boxGraphic.mesh.position.z = y
          this.entities.push(boxGraphic)
          this.scene.add(boxGraphic.mesh)
        }

        這里我還創(chuàng)建了一個(gè) elementManager 管理工具,專門用來存當(dāng)前關(guān)卡的布局?jǐn)?shù)據(jù)以及用來移動(dòng)幾何體的位置。創(chuàng)建出來的基礎(chǔ)游戲場(chǎng)景就是這樣。

        c41785c05c9410dc403ae177e652625c.webp

        基礎(chǔ)布局創(chuàng)建完之后,添加上鍵盤事件,主要用來控制人物和箱子的移動(dòng)。

              private bindKeyboardEvent() {
          window.addEventListener('keyup'(e: KeyboardEvent) => {
            if (!this.isPlaying) return

            const keyCode = e.code
            const playerPos = this.elementManager.playerPos

            const nextPos = this.getNextPositon(playerPos, keyCode) as Vector3
            const nextTwoPos = this.getNextPositon(nextPos, keyCode) as Vector3
            const nextElement = this.elementManager.layout[nextPos.z][nextPos.x]

            const nextTwoElement =
              this.elementManager.layout[nextTwoPos.z][nextTwoPos.x]

            if (nextElement === EMPTY) {
              this.elementManager.movePlayer(nextPos)
            } else if (nextElement === BOX) {
              if (nextTwoElement === WALL || nextTwoElement === BOX) return
              this.elementManager.moveBox(nextPos, nextTwoPos)
              this.elementManager.movePlayer(nextPos)
            }
          })
        }

        這里主要做了兩件事,首先把下個(gè)和下下個(gè)的位置和位置所在的 mesh 類型查找出來,計(jì)算位置很簡(jiǎn)單,用當(dāng)前 player 所在的位置加上鍵盤按下的方向計(jì)算出來就行。

              if (newDirection) {
           const mesh = this.sceneRenderManager.playerMesh
           mesh.lookAt(mesh.position.clone().add(newDirection))
           return position.clone().add(newDirection)
         }

        查找坐標(biāo)所在的 mesh 直接用當(dāng)前位置所在的坐標(biāo)x,y,就能在 elementManager 上獲取到。

              const nextElement = this.elementManager.layout[nextPos.z][nextPos.x]

        然后我們接著判斷下個(gè)坐標(biāo)以及下下個(gè)坐標(biāo)的類型,來決定 player 和箱子是否可以移動(dòng)。

              if (nextElement === EMPTY) {
          this.elementManager.movePlayer(nextPos)
        else if (nextElement === BOX) {
          if (nextTwoElement === WALL || nextTwoElement === BOX) return
          this.elementManager.moveBox(nextPos, nextTwoPos)
          this.elementManager.movePlayer(nextPos)
        }

        elementManager 里面更新 mesh 的位置,首先是根據(jù)坐標(biāo)把對(duì)應(yīng)的mesh查找出來,然后把 mesh 坐標(biāo)設(shè)置為下一個(gè)坐標(biāo),并且還需要維護(hù) this.levelDataSource.layout 布局?jǐn)?shù)據(jù),因?yàn)檫@個(gè)數(shù)據(jù)是隨著玩家的操作實(shí)時(shí)更新的。

              /**
         * 更新實(shí)體位置
         */

        private updateEntityPosotion(curPos: Vector3, nextPos: Vector3) {
          const entity = this.scene.children.find(
            (mesh) =>
              mesh.position.x === curPos.x &&
              mesh.position.y === curPos.y &&
              mesh.position.z === curPos.z &&
              mesh.name !== TARGET
          ) as Mesh

          if (entity) {
            const position = new Vector3(nextPos.x, entity.position.y, nextPos.z)
            entity.position.copy(position)
          }
          // 如果實(shí)體是箱子,需要判斷是否是目標(biāo)位置
          if (entity?.name === BOX) this.updateBoxMaterial(nextPos, entity)
        }

        最后在每一步鍵盤操作之后都需要判斷當(dāng)前游戲是否結(jié)束,只需要判斷所有的box所在的位置是否全部都在目標(biāo)點(diǎn)的位置上就行。

              /**
        * 判斷游戲是否結(jié)束
        */

        public isGameOver() {
         // 第一步找出所有箱子的位置,然后判斷箱子的位置是否全部在目標(biāo)點(diǎn)上
         const boxPositions: Vector3[] = []
         this.layout.forEach((row, y) => {
           row.forEach((cell, x) => {
             if (cell === BOX) boxPositions.push(new Vector3(x, 0, y))
           })
         })
         return boxPositions.every((position) =>
           this.isTargetPosition(position.x, position.z)
         )
        }


        參考資料 [1]

        源碼: https://github.com/LiamWu50/three-sokoban-live



        作者:Liam_wu

        鏈接:https://juejin.cn/post/7296658371214016553



        感謝您的閱讀      

        在看點(diǎn)贊 好文不斷    7f79bbc52fe5123bb53e6ef0736741e8.webp

        瀏覽 44
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 被c哭视频 | 五月天黄色视频91 | 幺公吃我奶水边摸边做 | 亚洲五月天激情 | 操逼视频免费试看 |