<kbd id="5sdj3"></kbd>
<th id="5sdj3"></th>

  • <dd id="5sdj3"><form id="5sdj3"></form></dd>
    <td id="5sdj3"><form id="5sdj3"><big id="5sdj3"></big></form></td><del id="5sdj3"></del>

  • <dd id="5sdj3"></dd>
    <dfn id="5sdj3"></dfn>
  • <th id="5sdj3"></th>
    <tfoot id="5sdj3"><menuitem id="5sdj3"></menuitem></tfoot>

  • <td id="5sdj3"><form id="5sdj3"><menu id="5sdj3"></menu></form></td>
  • <kbd id="5sdj3"><form id="5sdj3"></form></kbd>

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

    共 14076字,需瀏覽 29分鐘

     ·

    2024-04-11 22:05

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

    在線預(yù)覽

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

    游戲介紹

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

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

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

    初始化場(chǎng)景

    游戲場(chǎng)景主要分為四個(gè)部分:場(chǎng)景底部面板、倉(cāng)庫(kù)邊界、箱子、推箱子的人。首先肯定是初始化場(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ì)越來(lái)越大。燈光我們需要?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 來(lái)創(chuàng)建,接著將平面沿著x軸旋轉(zhuǎn)90度,調(diào)整為水平方向,并且給平面添加網(wǎng)格輔助 AxesHelper ,方便我們?cè)谟螒蛞苿?dòng)的過(guò)程中找準(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 來(lái)創(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)建出來(lái)的人物長(zhǎng)這樣:

    b097f6da1826a6a99987b2b6807761fa.webp

    游戲場(chǎng)景

    搭建場(chǎng)景

    游戲的所有內(nèi)容都是通過(guò) Three.js 的立體幾何來(lái)創(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)來(lái)表示每一種元素對(duì)應(yīng)所在的位置。并且我把目標(biāo)點(diǎn)的位置沒(méi)有放在整個(gè)游戲的布局?jǐn)?shù)據(jù)里面,而是單獨(dú)存起來(lái),這樣做是因?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í)候按照類型來(lái)對(duì)應(yīng)加載就行了。接著我們開(kāi)始加載游戲的基本數(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 管理工具,專門用來(lái)存當(dāng)前關(guān)卡的布局?jǐn)?shù)據(jù)以及用來(lái)移動(dòng)幾何體的位置。創(chuàng)建出來(lái)的基礎(chǔ)游戲場(chǎng)景就是這樣。

    c41785c05c9410dc403ae177e652625c.webp

    基礎(chǔ)布局創(chuàng)建完之后,添加上鍵盤事件,主要用來(lái)控制人物和箱子的移動(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 類型查找出來(lái),計(jì)算位置很簡(jiǎn)單,用當(dāng)前 player 所在的位置加上鍵盤按下的方向計(jì)算出來(lái)就行。

          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)的類型,來(lái)決定 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查找出來(lái),然后把 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)

    <kbd id="5sdj3"></kbd>
    <th id="5sdj3"></th>

  • <dd id="5sdj3"><form id="5sdj3"></form></dd>
    <td id="5sdj3"><form id="5sdj3"><big id="5sdj3"></big></form></td><del id="5sdj3"></del>

  • <dd id="5sdj3"></dd>
    <dfn id="5sdj3"></dfn>
  • <th id="5sdj3"></th>
    <tfoot id="5sdj3"><menuitem id="5sdj3"></menuitem></tfoot>

  • <td id="5sdj3"><form id="5sdj3"><menu id="5sdj3"></menu></form></td>
  • <kbd id="5sdj3"><form id="5sdj3"></form></kbd>
    依人在线大香蕉 | 亚洲欧洲高清无码在线视频 | 亚洲成人网站观看 | 三级论久久久 | 污网站亚洲第一 |