美文网首页ARKit开发
22-3D平衡球游戏Marble-Maze

22-3D平衡球游戏Marble-Maze

作者: 095b62ead3cd | 来源:发表于2020-06-25 22:45 被阅读0次

    文章选自掘金苹果API搬运工的文章[SceneKit专题]22-3D平衡球游戏Marble-Maze
    主要记录自己在学习ARKit的过程中看到的好的文章,避免到时候链接失效无法找到原文的情况,非常感谢原博主的辛勤付出,也在此分享出来跟大家一起学习。

    创建项目
    1. 打开Xcode,创建一个新的iOS版SceneKit游戏项目,命名为MarbleMaze.
    2. 删除art.scnassets文件夹.
    3. resources文件夹中拖拽一个新的art.scnassets到项目中.
    4. 我们只使用竖屏模式,所以取消Landscape LeftLandscape Right来禁用旋转:

    替换GameViewController.swift中的内容:

    import UIKit
    import SceneKit
    class GameViewController: UIViewController {
      var scnView:SCNView!
      override func viewDidLoad() {
        super.viewDidLoad()
    // 1
        setupScene()
        setupNodes()
        setupSounds()
    }
    // 2
      func setupScene() {
          scnView = self.view as! SCNView
          scnView.delegate = self
          scnView.allowsCameraControl = true
          scnView.showsStatistics = true
    }
      func setupNodes() {
      }
      func setupSounds() {
      }
      override var shouldAutorotate : Bool { return false }
      override var prefersStatusBarHidden : Bool { return true }
    }
    // 3
    extension GameViewController: SCNSceneRendererDelegate {
      func renderer(_ renderer: SCNSceneRenderer,
        updateAtTime time: TimeInterval) {
      }
    }
    

    代码含义:

    1. viewDidLoad()中调用这些空的方法;稍后会向其中添加代码.
    2. self.view转换为SCNView并保存下来.并设置self为渲染循环的代理.
    3. 实现SCNSceneRendererDelegate协议中的方法.
    天空盒子,加载场景

    art.scnassets中找到空的game.scn场景文件.打开并选中默认的camera node,然后选中右上方的Scene Inspector.从右下方的媒体库中找到img_skybox.jpg拖拽到场景的背景属性上.:


    GameViewController类中添加下面属性:
    var scnScene:SCNScene!
    

    setupScene()中添加下面代码:

    // 1
    scnScene = SCNScene(named: "art.scnassets/game.scn")
    // 2
    scnView.scene = scnScene
    

    运行一下,看看神圣的天空景象:


    主角--小球

    拖拽一个空的SceneKit场景文件到你的项目,放到art.scnassets中,命名为obj_ball.scn:

    选中art.scnassets/obj_ball.scn,展开场景树,选中默认的摄像机节点.所有的新建场景都包含一个默认的摄像机节点,但作为引用节点被使用时就很不爽,所以我们删除它:

    下面开始创建木质小球.从对象库中拖拽一个球体到场景中:


    打开节点检查器.将小球命名为ball,放置位置为(x:0, y:0, z:0):

    现在的小球太大了.打开属性检查器,更改半径为0.45,提升分段数为36来让它显得更圆一些:

    材质设置

    漫反射设置


    法线设置


    高光设置


    反射设置


    发光设置


    随着各个贴图的添加,效果渐变如下:


    然后需要做的是将小球作为引用节点添加到场景中去. 选中art.scnassets/game.scn,然后拖拽art.scnassets/obj_ball.scn到场景中.设置位置为(x:0, y:0, z:0) 并命名为ball:

    这样,小球就作为一个引用节点被添加到场景中了.

    运行一下:


    挑战--创建木箱,小石块,大石块,柱子的引用节点

    这是一个小小的挑战:

    1. 为每个对象创建一个空的场景.
    2. 删除默认的摄像机. 试着创建下面的对象:


    • obj_crate1x1:命名为crate并设置尺寸为 (x:1, y:1, z:1).使用img_crate_diffuse纹理作为漫反射贴图,img_crate_normal作为法线贴图.高光颜色设为中灰色;如果设为纯白色,木箱会看起来像塑料的.
    • obj_stone1x1:命名为stone并设置尺寸为 (x:1, y:1, z:1).使用img_stone_diffuseimg_stone_normal纹理作为贴图,将法线intensity改为0.5. 设置高光色为White.
    • obj_stone3x3:命名为stone并设置尺寸为 (x:3, y:3, z:3).纹理设置同上,高光仍为White.但是需要使用纹理缩放设置,及WrapT和WrapS来使其生效.
    • obj_pillar1x3:命名为pillar并设置尺寸为 (x:1, y:3, z:1).使用img_pillar_ 纹理;还有高光纹理也要用上.还有应用缩放及wrap设置.

    当设置3x3方块时,可参照下面步骤:


    设置过程中,会看到如下的依次变化:


    最终完成版在12-Reference Nodes中的projects/ challenge/MarbleMaze/ 文件夹.

    组织场景

    选中art.scnassets/game.scn.组织一下场景树如下:

    创建一个空节点命名为follow_camera:

    camera节点放到follow_camera下,成为它的子节点,并设置位置为 (x:0, y:0, z:5),旋转为 (x:0, y:0, z:0):

    创建另一个空节点命名为follow_light:

    添加几个空节点作为占位节点,设置位置为零;

    • pearls:待收集的珍珠分组.
    • section1, section2, section3, section4:这些分组用来盛放本关卡的不同章节.

    创建最后一个空节点,命名为static_light:

    灯光

    首先是固定灯光 拖拽一个泛光灯和一个环境光到场景中,并按顺序放置在static_lights组节点中:

    选中omni light,打开节点检查器,命名为omni,位置,角度设为零:

    打开属性检查器,设置颜色为深灰色:


    选中ambient light,打开节点检查器:

    打开属性检查器,设置颜色为深灰:


    查看一下场景中的小球:


    接着添加跟随灯光 拖拽一个聚光灯到场景中,放置在follow_light组节点下面:

    选中聚光灯,打开它的节点检查器,设置位置如下:


    这个灯光是follow_light的子节点, follow_light的位置是 (x:0, y:0, z:0),旋转角度 (x:-25, y:-45, z:0);

    然后选中聚光灯,打开属性检查器,设置金黄色模拟环境中的阳光:


    完成后的效果:


    重用集合体

    将游戏中重复出现的结构做成重用集合体,方便在需要的时候直接调用. 此处我们制作的是休息点,它由一块3x3的石块和上面的4根柱子组成.

    拖拽一个空的SceneKit场景文件到项目的根目录中,然后在弹出框中选择art.scnassets,点击Create按钮.

    拖拽一个obj_stone3x3.scn的引用节点到空场景的,放置在(x: 0, y: 0, z:0).

    拖拽一个obj_pillar1x3.scn引用节点到时大石块的顶部.设置位置在(x: -1, y: 3, z: 1),即右上角位置.

    使用⌥⌘ (Option +Command) +点击拖拽,复制三个柱子,位置如下:

    • Top-Left. Positioned at (x: -1, y: 3, z: -1).
    • Top-Right. Positioned at (x: 1, y: 3, z: -1).
    • Bottom-Right. Positioned at (x: 1, y: 3, z: 1).


    记得删除场景中默认的摄像机.

    选中game.scn,然后拖放新创建的set_restpoint.scn到场景下方.位置设为 (x: 0, y: -2, z: 0)

    运行一下,会看到漂亮的阴影:


    创建其它部件

    现在还需要创建几个其他的集合体,以便在主场景中直接引用. 比如straight_bridge,用了7个stone1x1组成:

    zigzag_bridge,用了stone1x1crate1x1方块.共9格宽7格长.

    然后就可以用这些来组成大场景:


    从左下角开始,放置一个restpoint休息点在地平面下,(x:0, y:0, z:0) 处.然后将其他引用集合体拖拽到场景中. 注意将这些都放在section1下面,这是个游戏切换场景的小技巧:通过更改visible标记就能控制整个场景的显示与隐藏.

    运行一下,移动摄像机看看,还可以旋转视角,查看更漂亮的美景:


    拖拽一个空的SceneKit文件到项目中,命名为obj_pearl.scn,保存到art.scnassets文件夹:

    接着从对象库中拖放一个球体节点到新场景中:


    节点检查器中命名改为pearl,位置,角度为零. 属性检查器中,设置半径为0.2,分段数为16:

    接下来打开材料检查器,设置漫反射颜色为黑色,高光为白色.反射贴图使用img_skybox.jpg,但将强度降为0.75:

    完成后的效果图:


    还需要添加游戏工具类

    resources/ GameUtils/ 中拖拽GameUtils文件夹到项目中,如下图,点击Finish:

    位掩码(包括分类掩码,碰撞掩码,接触掩码)

    我们将采用如下的分类位掩码设置:


    打开GameViewController.swift,在开头添加分类码:

    let CollisionCategoryBall = 1
    let CollisionCategoryStone = 2
    let CollisionCategoryPillar = 4
    let CollisionCategoryCrate = 8
    let CollisionCategoryPearl = 16
    

    游戏中,我们想让小球与除了能量珍珠外的所有物体碰撞,所以需要定义碰撞掩码,来决定和哪些物体碰撞:


    Stone石头, Pillar柱子, Crate木箱和Pearl能量珍珠和碰撞掩码都是1,就是说它们能和分类掩码为1的物体碰撞,也就是都能和小球碰撞.而小球的碰撞掩码是14: CollisionMask = Stone + Pillar + Crate = 2 + 4 + 8 = 14

    接触掩码决定了哪些物体碰撞时,代理方法会被调用.


    我们只关心小球和能量珍珠,柱子及木箱的碰撞,所以: ContactMask = Pearl + Pillar + Crate = 16 + 8 + 4 = 28

    GameViewController.swift中,添加一个属性:

    var ballNode:SCNNode!
    

    添加下列代码到setupNodes()中:

    ballNode = scnScene.rootNode.childNode(withName: "ball", recursively:
    true)!
    ballNode.physicsBody?.contactTestBitMask = CollisionCategoryPillar |
    CollisionCategoryCrate | CollisionCategoryPearl
    

    启用物理效果

    选中obj_ball.scn,然后选中ball节点,打开物理效果检查器来将Physics Body类型设置为Dynamic:

    确保重力影响是打开的,不然小球可能会漂在空中:


    设置Category mask1,Collision mask14:

    ShapeDefault shape,TypeConvex:

    除了小球,其它物体都是不动的,是静态物理形体.设置如下:


    • obj_stone1x1.scnCategory mask2, Collision mask1;
    • obj_stone3x3.scn: Category mask2, Collision mask1**.
    • obj_pillar1x3.scn: Category mask4,Collision mask1.
    • obj_crate1x1.scn: Category mask8, Collision mask1.
    • obj_pearl.scn: Category mask16, Collision mask-1.

    对能量珍珠Physics shape设为Default shape, TypeConvex:

    其余的Physics shape设为Default shape, TypeBounding Box:

    添加碰撞检测处理

    现在终于设置好了各个物体,要处理相互的碰撞了.在GameViewController.swift底部:

    extension GameViewController : SCNPhysicsContactDelegate {
      func physicsWorld(_ world: SCNPhysicsWorld,
        didBegin contact: SCNPhysicsContact) {
        // 1
        var contactNode:SCNNode!
        if contact.nodeA.name == "ball" {
          contactNode = contact.nodeB
        } else {
          contactNode = contact.nodeA
        }
    // 2
        if contactNode.physicsBody?.categoryBitMask ==
          CollisionCategoryPearl {
        contactNode.isHidden = true
          contactNode.runAction(
            SCNAction.waitForDurationThenRunBlock(
              duration: 30) { (node:SCNNode!) -> Void in
            node.isHidden = false
          })
    }
    // 3
        if contactNode.physicsBody?.categoryBitMask ==
          CollisionCategoryPillar ||
            contactNode.physicsBody?.categoryBitMask ==
              CollisionCategoryCrate {
    } }
    }
    

    代码含义:

    1. 和前面一样,用来判断碰撞双方哪一个是小球.
    2. 如果碰撞到的是参量珍珠,则消失30秒,然后重新出现.
    3. 判断小球是碰撞到了柱子还是木箱,可以添加音效.

    并在setupScene()底部添加成为代理:

    scnScene.physicsWorld.contactDelegate = self
    

    还需要再添加一些小效果让游戏更生动. 打开obj_ball.scn,选中ball,设置y轴位置为10让小球出现时有个掉落效果:

    运行一下,可以看到掉落下来:


    选中游戏场景,然后拖拽obj_pearl.scn到场景中.放置在(x: 0, y: 0, z: 0)处.放到pearls组下面:

    运行一下,小球掉落并吸收了能量珍珠:


    还可以给场景中添加更多的能量珍珠,如下:


    辅助类和音效

    在前面我们已经添加了GameUtils类,现在还需要再添加一些东西以便使用它.

    GameViewController中添加下面的属性:

    var game = GameHelper.sharedInstance
    var motion = CoreMotionHelper()
    var motionForce = SCNVector3(x:0 , y:0, z:0)
    

    再从resources拖放Sounds文件夹到项目中:

    setupSounds()中添加下面代码:

    game.loadSound(name: "GameOver", fileNamed: "GameOver.wav")
    game.loadSound(name: "Powerup", fileNamed: "Powerup.wav")
    game.loadSound(name: "Reset", fileNamed: "Reset.wav")
    game.loadSound(name: "Bump", fileNamed: "Bump.wav")
    

    节点绑定和状态管理

    GameViewController类中添加下面的属性:

    var cameraNode:SCNNode!
    

    setupNodes()的末尾,添加下列代码:

    // 1
    cameraNode = scnScene.rootNode.childNode(withName: "camera",
      recursively: true)!
    // 2
    let constraint = SCNLookAtConstraint(target: ballNode)
    cameraNode.constraints = [constraint]
    

    代码含义:

    1. 将游戏场景中的camera绑定到cameraNode.
    2. 给摄像机添加一个SCNLookAtConstraint约束,使其朝向ballNode.

    当摄像机有SCNLookAtConstraint约束时,小球到处滚动,可能会导致摄像机向左或向右倾斜,所以我们需要在setupNodes()末尾打开万向节锁:

    constraint.isGimbalLockEnabled = true
    

    其它节点也需要同样处理.在GameViewController类中添加下列属性:

     var cameraFollowNode:SCNNode!
    var lightFollowNode:SCNNode!
    

    setupNodes()末尾添加下列代码:

    // 1
    cameraFollowNode = scnScene.rootNode.childNode(
      withName: "follow_camera", recursively: true)!
    // 2
    cameraNode.addChildNode(game.hudNode)
    // 3
    lightFollowNode = scnScene.rootNode.childNode(
      withName: "follow_light", recursively: true)!
    

    游戏节点绑定完成,还需要处理游戏的状态.游戏需要三种基本状态:

    • waitForTap:游戏开始前的状态
    • playing:点击屏幕开始游戏的状态
    • gameOver:能量用光或者掉落下平台的状态.

    GameViewController类中添加下列代码:

    // 1
    func playGame() {
      game.state = GameStateType.playing
      cameraFollowNode.eulerAngles.y = 0
      cameraFollowNode.position = SCNVector3Zero
    }
    // 2
    func resetGame() {
      game.state = GameStateType.tapToPlay
      game.playSound(node: ballNode, name: "Reset")
      ballNode.physicsBody!.velocity = SCNVector3Zero
      ballNode.position = SCNVector3(x:0, y:10, z:0)
      cameraFollowNode.position = ballNode.position
      lightFollowNode.position = ballNode.position
      scnView.isPlaying = true
      game.reset()
    }
    // 3
    func testForGameOver() {
      if ballNode.presentation.position.y < -5 {
        game.state = GameStateType.gameOver
        game.playSound(node: ballNode, name: "GameOver")
        ballNode.run(SCNAction.waitForDurationThenRunBlock(
          duration: 5) { (node:SCNNode!) -> Void in
            self.resetGame()
          })
    } }
    

    代码含义:

    1. 切换到.playing状态,开始游戏.以及基本的清理和重置.
    2. 切换到.waitForTap状态,播放音效,以及各种清理和重置工作.
    3. 检查小球的位置,y值小于-5,则切换到.gameOver状态,播放音效.5秒后自动调用resetGame(),并切换到.waitForTap状态.

    还要在viewDidLoad()末尾添加调用:

    resetGame()
    

    游戏开始时,玩家需要点击屏幕.因此在GameViewController类中,添加下面的触摸代码:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
    {
      if game.state == GameStateType.tapToPlay {
    playGame() }
    }
    

    GameViewController类中,添加下面的代码:

    func updateMotionControl() {
      // 1
      if game.state == GameStateType.playing {
        motion.getAccelerometerData(interval: 0.1) { (x,y,z) in
         self.motionForce = SCNVector3(x: Float(x) * 0.05, y:0,
           z: Float(y+0.8) * -0.05)
        }
    // 2
        ballNode.physicsBody!.velocity += motionForce
      }
    }
    

    代码含义:

    1. 根据当前的运动数据更新motionForce向量.
    2. motionForce向量赋值给小球的velocity.

    还需要在renderer(_, updateAtTime)方法中调用updateMotionControl()方法:

    updateMotionControl()
    

    运行游戏,看到小球从空中落下,点击屏幕开始游戏:


    小球身上的发光效果实际就是生命值,小球的发光强度将随着时间不断减弱直到降为0.0.如果收集到一个能量珍珠,则生命值恢复到1.0.我们需要一个方法来补充生命值.在GameViewController类中,添加下面的代码:

    func replenishLife() {
      // 1
      let material = ballNode.geometry!.firstMaterial!
      // 2
      SCNTransaction.begin()
      SCNTransaction.animationDuration = 1.0
    // 3
      material.emission.intensity = 1.0
    // 4
      SCNTransaction.commit()
      // 5
      game.score += 1
      game.playSound(node: ballNode, name: "Powerup")
    }
    
    1. 要获取发光贴图,就需要先获取ballNode的firstMaterial.
    2. 通过SCNTransaction.begin() 来开始动画.此处我们设置时长为1秒animationDuration = 1.0.
    3. 设置发光强度为1.0.
    4. 提交动画事务.提交后SceneKit将开始执行动画,将发光强度从当前值改为1.0
    5. 增加分数,播放音效.

    该方法需要在刚变成.playing状态时调用.在playGame()方法的末尾调用:

      replenishLife()
    

    有了恢复生命值的方法,还需要逐渐减少的方法.在GameViewController类中,添加下面的代码:

    func diminishLife() {
      // 1
      let material = ballNode.geometry!.firstMaterial!
      // 2
      if material.emission.intensity > 0 {
        material.emission.intensity -= 0.001
      } else {
        resetGame()
      }
    }
    

    我们需要在每次检查.gameOver状态时调用这个方法.

    摄像机和灯光

    GameViewController类中,添加下面的代码:

    func updateCameraAndLights() {
      // 1
      let lerpX = (ballNode.presentation.position.x -
        cameraFollowNode.position.x) * 0.01
      let lerpY = (ballNode.presentation.position.y -
        cameraFollowNode.position.y) * 0.01
      let lerpZ = (ballNode.presentation.position.z -
        cameraFollowNode.position.z) * 0.01
      cameraFollowNode.position.x += lerpX
      cameraFollowNode.position.y += lerpY
      cameraFollowNode.position.z += lerpZ
      // 2
      lightFollowNode.position = cameraFollowNode.position
    // 3
      if game.state == GameStateType.tapToPlay {
          cameraFollowNode.eulerAngles.y += 0.005
      }
    }
    

    代码含义:

    1. 用线性插值法计算要移动的位置.创造出一种特殊的减速移动效果.
    2. lightFollowNode节点跟随摄像机节点.
    3. 当进入.tapToPlay状态时,将摄像机抬起一些.

    这个函数需要在renderer(_, updateAtTime)的末尾调用,这样才能在每帧都能实时更新摄像机和灯光:

    updateCameraAndLights()
    

    运行一下,如下:


    点击屏幕,开始游戏:


    游戏已经基本完成,还需要处理一下HUD的显示问题,以及生命值耗尽的问题.

    GameViewController类中,添加下面的代码:

    func updateHUD() {
      switch game.state {
      case .playing:
        game.updateHUD()
      case .gameOver:
        game.updateHUD(s: "-GAME OVER-")
      case .tapToPlay:
        game.updateHUD(s: "-TAP TO PLAY-")
      }
    }
    

    renderer(_, updateAtTime)方法的末尾,添加调用:

    updateHUD()
    

    现在生命值耗尽,游戏也不会结束,只有掉落下去才会死.我们需要处理耗尽问题.在renderer(_, updateAtTime)方法的末尾,添加代码:

    if game.state == GameStateType.playing {
      testForGameOver()
      diminishLife()
    }
    

    还需要处理小球与能量珍珠碰撞时,珍珠消失但小球的生命值没有增加的问题.只需要在physicsWorld(_, didBeginContact)里处理与珍珠的碰撞代码块中,调用replenishLife()就行了:

    replenishLife()
    

    添加碰撞音效,在physicsWorld(_, didBeginContact)里处理与柱子/木箱的碰撞代码块中,调用播放音效就行了:

    game.playSound(node: ballNode, name: "Bump")
    

    最后一步,移除setupScene()中的调试代码:

     //scnView.allowsCameraControl = true
     //scnView.showsStatistics = true
    

    最终的完成版代码,在15-Motion Control中的projects/final/ MarbleMaze/ 文件夹下.

    相关文章

      网友评论

        本文标题:22-3D平衡球游戏Marble-Maze

        本文链接:https://www.haomeiwen.com/subject/dmesfktx.html