美文网首页SceneKit学习
[SceneKit专题]3D打砖块游戏Breaker

[SceneKit专题]3D打砖块游戏Breaker

作者: 苹果API搬运工 | 来源:发表于2017-09-25 23:34 被阅读210次

    说明

    本系列文章是对<3D Apple Games by Tutorials>一书的学习记录和体会此书对应的代码地址

    SceneKit系列文章目录

    更多iOS相关知识查看github上WeekWeekUpProject

    06-SceneKit Editor场景编辑器

    创建游戏

    打开Xcode,创建一个新项目,选择iOS/Application/Game模板.
    游戏名Breaker,语言选Swift,游戏技术SceneKit,设备支持Universal,取消勾选两个测试选项.

    打开项目,删除art.scnassets文件夹.并将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
      }
      func setupNodes() {
    }
      func setupSounds() {
      }
      override var shouldAutorotate: Bool { return true }
      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. GameViewController遵守SCNSceneRendererDelegate协议,并实现renderer(_: updateAtTime:)方法.

    找到resources/AppIcon文件夹,里面有各种尺寸的应用图标.打开项目的Assets.xcassets并选择AppIcon.将图标拖放到里面去.

    WX20171106-215541@2x.png

    选中Assets.xcassets,拖放resources/Logo_Diffuse.png到里面.然后打开LaunchScreen.storyboard,将背景颜色改为深蓝色.在右下角的Media Library中找到Logo_Diffuse,拖放到启动屏幕里.设置图片的Content ModeAspect Fit,并添加约束,让它处在屏幕中间:

    WX20171106-221817@2x.png

    完成后:


    WX20171106-221905@2x.png

    下面还需要添加音效.找到resources/Breaker.scnassets文件夹,拖放到时项目中.注意选中Copy items if needed, Create groups及目标项目Breaker.这里面有子文件夹,SoundsTextures分别是音频和纹理图片.

    还需要一些游戏工具类.拖放resources/GameUtil到项目中.
    打开GameViewController.swift,在scnView下面添加属性:

    var game = GameHelper.sharedInstance
    
    加载场景

    右击Breaker.scnassets,创建一个新文件夹命名为Scenes,用来盛放所有场景.

    WX20171106-222712@2x.png

    选中Breaker项目,创建新文件,选择iOS/Resource/ SceneKit Scene模板,命名为Game.scn.注意位置选择在Breaker.scnassets下面的Scenes文件夹下面.

    WX20171106-222942@2x.png

    从右下角的物体对象库中拖拽一个Box出来,随便放在场景中:

    WX20171106-225141@2x.png

    GameViewController中添加一个新属性:

    var scnScene: SCNScene!
    

    接下来,在setupScene()方法的底部,添加下面代码:

     scnScene = SCNScene(named: "Breaker.scnassets/Scenes/Game.scn")
    scnView.scene = scnScene
    

    运行一下:


    WX20171106-225545@2x.png

    测试完成后,就可以删除立方体了.在左侧的场景树中,按Command-A选择所有节点,按Delete键全部删除.

    WX20171106-225812@2x.png

    07-Cameras摄像机

    添加摄像机

    打开GameViewController.swift,在setupNodes()中添加下面一行:

    scnScene.rootNode.addChildNode(game.hudNode)
    

    然后,在renderer(_,updateAtTime)中添加一行:

    game.updateHUD()
    

    选中Game.scn,以显示编辑器.
    在左下角点击+按钮,创建一个空的节点默认命名为untitled.将其改名为Cameras.

    WX20171108-215639@2x.png

    从右下角的对象库中拖放两个Camera节点到场景中.

    WX20171108-215828@2x.png

    分别命名为VerticalCameraHorizontalCamera.稍后会讲为什么需要两个摄像机.

    TL/DR:双摄像机能让你更好地处理横屏与竖屏状态下的视角.

    让两个摄像机都成为Cameras的子节点:

    WX20171108-221039@2x.png

    选中VerticalCamera,在节点检查器中设置Position(x:0, y:22, z:9),Euler(x:-70, y:0, z:0)

    WX20171108-221410@2x.png

    选中HorizontalCamera,在节点检查器中设置Position(x:0, y:8.5, z:15),Euler(x:-40, y:0, z:0).

    WX20171108-221819@2x.png

    对比来看,水平摄像机比竖直摄像机离得更近,角度也更小.


    WX20171108-221912@2x.png

    GameViewController.swift中添加两个属性:

     var horizontalCameraNode: SCNNode!
      var verticalCameraNode: SCNNode!
    

    setupNodes()方法的开头添加下面代码:

    horizontalCameraNode = scnScene.rootNode.childNode(withName:
    "HorizontalCamera", recursively: true)!
    verticalCameraNode = scnScene.rootNode.childNode(withName:
    "VerticalCamera", recursively: true)!
    

    因为场景已经加载进来了,所以我们只需要用childNode(withName:recursively:)方法来找到摄像机节点就可以了.recursively设置为true会递归遍历其中的子文件夹.

    处理旋转

    设置在旋转时,屏幕的显示范围也在跟着变.与其在两个方向中找到"sweet-spot",倒不如使用两个摄像机,每一个都可以最大化利用显示范围.


    WX20171108-223028@2x.png

    为了追踪设备方向,需要重写viewWillTransition(to size:, with coordinator:)方法:

    // 1
    override func viewWillTransition(to size: CGSize, with coordinator:
    UIViewControllerTransitionCoordinator) {
    // 2
      let deviceOrientation = UIDevice.current.orientation
      switch(deviceOrientation) {
      case .portrait:
        scnView.pointOfView = verticalCameraNode
      default:
        scnView.pointOfView = horizontalCameraNode
      }
    }
    

    代码含义:

    1. 重写viewWillTransition(to:with:)来运行切换方向的代码.
    2. 根据从UIDevice.current().orientation中获取到的deviceOrientation来切换方向.如果将要切换到.portrait,则设置视点为verticalCameraNode.否则,切换视点到horizontalCameraNode.

    运行一下:


    WX20171108-223615@2x.png

    08-Lights灯光

    添加小球

    选中Game.scn.在对象库中,拖放一个Sphere到场景中.

    WX20171108-223931@2x.png

    确保球体节点仍处于选中状态,然后选择节点检查器.将Name命名为Ball,将position设置为0,这样球就在正中间了.

    WX20171108-230307@2x.png

    接着打开属性检查器.将Radius改为0.25, Segment count17.

    WX20171108-230522@2x.png

    两种球体sphere和geosphere本质上是同样的.不同的是下面的geodesic复选框,决定了渲染引擎如何构建球体.一种是四边形,一种是三角形.

    下一步,选中材料检查器.将Diffuse改为7F7F7F.将Specular改为White.

    WX20171108-230913@2x.png

    继续向下,找到Setting区域,将Shininess改为0.3.

    WX20171108-231032@2x.png

    完成后,选中HorizontalCamera,场景看起来是这样:

    WX20171108-231153@2x.png

    下面,打开GameViewController.swift,添加一个属性:

    var ballNode: SCNNode!
    

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

     ballNode = scnScene.rootNode.childNode(withName: "Ball", recursively:true)!
    
    三点光照

    首先,打开Game.scn,点击+创建一个空节点,命名为Lights.它将用来盛放场景中的所有灯光.

    WX20171109-212629@2x.png

    从对象库中,拖放一个Omni light到场景中,放到灯光节点下面.

    WX20171109-213018@2x.png

    选中灯光节点,打开节点检查器,重命名节点为Back.设置Position(x:-15, y:-2, z:15).

    WX20171109-213223@2x.png

    选择Attributes Inspector,设置泛光灯属性.

    WX20171109-213336@2x.png

    再从对象库中拖放一个Omni light光源到场景中.还是移动到Lights组节点下.

    命名新节点为Front,设置Position(x:6, y:10, z:15).

    WX20171109-213612@2x.png

    再从对象库中拖放一个Ambient light光源到场景中.还是移动到Lights组节点下.

    WX20171109-220913@2x.png

    命名新节点为Ambient,设置Position(x:0, y:0, z:0).

    WX20171109-221045@2x.png

    打开属性检查器:


    WX20171109-221205@2x.png

    完成后的场景效果:


    WX20171109-221251@2x.png

    运行一下,效果如下:


    WX20171109-221341@2x.png

    09-Geometric Shapes几何形状

    创建边框

    选择Game.scn,点击+按钮添加一个空白节点,命名为Barriers.
    这将是用来盛放所有的边框节点的:

    WX20171109-224809@2x.png

    从对象库中,拖放一个Box,在场景树中,将新的立方体节点拖放到Barriers组节点下面.

    WX20171109-224937@2x.png

    打开节点检查器,命名为Top,设置位置为(x:0,y:0,z:-10.5).开属性检查器,设置Sizewidth:13, height:2, length:1,设置Chamfer radius0.3.
    打开

    WX20171111-110146@2x.png
    材料检查器,将Diffuse改为暗灰色Hex Color333333,并将Specular改为White:
    WX20171109-231133@2x.png WX20171111-105642@2x.png

    下面我们通过复制的方式来创建底部的边框.
    复制方法是:按住Option键,点击要复制的节点并沿着蓝色坐标轴拖动:

    WX20171111-110434@2x.png

    复制成功后,重命名为Bottom,将设置为Barriers组的子节点.

    WX20171111-110514@2x.png

    更改一下位置,Position(x:0, y:0, z:10.5).

    WX20171111-113425@2x.png

    最终效果,如图:


    WX20171111-113510@2x.png

    还有一个重要的事:注意场景树的结构,组节点是如何包含顶边框/底边框的.
    选中新复制出的节点的Attributes Inspector属性检查器,在Geometry Sharing区下面,点击Unshare按钮.

    因为创建复本时,复制出的节点仍然会共享原始节点的几何体(Geometry).这个默认设置是为了减少总的绘制调用(draw call)数.

    左侧边框的建立

    左右两侧的边框分别由两根圆柱组成.先在Barriers组下面建立一个Left节点,并放置到合适的位置.里面的子节点也会跟着发生位置变动.

    WX20171111-115817@2x.png WX20171111-115849@2x.png

    建立左边框的上半部分
    拖放一个Cylinder,重命名为Top,放置到Barriers/Left下面:

    WX20171111-120053@2x.png
    WX20171111-120123@2x.png

    在节点检查器中,设置Position(x:0, y:0.5, z:0),Euler(x:90, y:0, z:0).
    属性检查器中,设置Radius0.3,Height22.5.
    材料检查器中,设置DiffuseHex Color #B3B3B3 ,SpecularWhite:

    WX20171111-120335@2x.png
    WX20171111-120655@2x.png
    WX20171111-120713@2x.png

    建立左边框的下半部分
    选中Barrier/Left/Top节点,按住Option键,沿蓝色坐标轴,点击拖动.重命名为Bottom,放在Barriers/Left组下面.在节点检查器中,设置Position(x:0,y:-0.5,z:0):

    WX20171111-125653@2x.png
    WX20171111-125915@2x.png
    WX20171111-125939@2x.png

    最终效果如图:


    WX20171111-125954@2x.png

    建立右侧边框

    选中Barriers/Left组,按住Command+Option并沿红色坐标轴点击拖动,这样就复制了一组节点.重命名为Right,并设置位置为(x:6, y:0, z:0)

    WX20171111-130404@2x.png
    WX20171111-130443@2x.png
    WX20171111-130454@2x.png

    最终效果如图:


    WX20171111-130609@2x.png
    创建球拍挡板

    点击+按钮创建新的节点,命名为Paddle.打开节点检查器,设置Position(x:0, y:0, z:8).

    WX20171111-132831@2x.png
    WX20171111-132841@2x.png

    球拍挡板共有三个部分:左,中,右.
    我们先创建中间部分,拖放一个圆柱体,命名为Center,放在Paddle组节点下面.

    WX20171111-133129@2x.png
    WX20171111-133141@2x.png

    打开节点检查器,设置Position0,设置Euler(x:0, y:0, z:90).
    打开属性检查器,设置Radius0.25, Height1.5.
    打开材料检查器,设置DiffuseHex Color #333333, SpecularWhite.

    WX20171111-133213@2x.png
    WX20171111-133225@2x.png
    WX20171111-133239@2x.png

    创建左侧部分

    拖放一个圆柱体,命名为Left,放在Paddle组节点下面.

    WX20171111-133904@2x.png

    设置Position(x:-1, y:0, z:0), Euler(x:0, y:0, z:90).
    打开属性检查器,设置Radius0.25, Height0.5.
    打开材料检查器,设置DiffuseHex Color #666666, SpecularWhite.

    WX20171111-134208@2x.png
    WX20171111-134218@2x.png
    WX20171111-134235@2x.png

    复制右侧部分
    选中Paddle/Left节点,按住Command+Option并沿绿色坐标轴点击拖动,这样就复制了一组节点.重命名为Right,并设置位置为(x:1, y:0, z:0).还是要注意取消几何体共享.

    WX20171111-141015@2x.png
    WX20171111-141028@2x.png

    绑定球拍挡板,以便操作

    打开GameViewController.swift,添加属性:

    var paddleNode: SCNNode!
    

    setupNodes()方法的末尾,添加绑定球拍的代码:

     paddleNode =
      scnScene.rootNode.childNode(withName: "Paddle", recursively: true)!
    

    你可以在本章对应代码的projects/final/Breaker文件夹下,找到最终的完成版项目.

    添加砖块,挑战项目
    • 首先,创建一个组节点命名为Bricks,用来放置所有的砖块.

    • 设置Bricks节点的位置为(x:0, y:0, z:-3.0).

    • 每个砖块都是使用一个Box,尺寸为width:1, height:0.5, length: 0.5,Chamfer Radius:0.05.

    • 先创建一列各种颜色的砖块,颜色分别使用white (#FFFFFF), red (#FF0000), yellow (#FFFF00), blue (#0000FF), purple (#8000FF), green (#00FF80):


      WX20171111-142007@2x.png
    • 为了方便定位,白色砖块可以放置在(x: 0, y:0, z:-2.5),绿色砖块应该在(x:0, y:0, z:0).

    • 将砖块用自己的颜色命名.

    • 复制更多列出来.(按住OptionCommand)

    • 复制时,记得使用材料检查器下面的Unshare按钮,以免改变了原始节点的颜色.

    • 复制填满整个区域.

    最终效果如图:


    WX20171111-142642@2x.png

    运行程序


    WX20171111-142655@2x.png

    你可以在本章对应代码的projects/challenge/Breaker文件夹下,找到最终的完成版项目.

    10-Basic Collision Detection碰撞检测基础

    物理效果

    先给小球添加物理效果.
    打开Game.scn并选中Ball.打开Physics Inspector物理效果检查器.将Physics BodyType改为Dynamic.
    并按下图设置各个项目:

    WX20171111-143239@2x.png

    给边框添加物理效果
    一次性选中左右边框的四个部分,可以有两种方法:

    1. 按住Command在场景树中点击每个节点.
    2. 类似于文件夹多选操作,先选中Top节点,按住Shift,点击Right,两者之间的节点会被全部选中.
      WX20171111-143739@2x.png

    保持选中状态,打开物理效果检查器,在Physics Body区域,将Type改为Static,在新展开的设置项里按下图设置:

    WX20171111-143930@2x.png

    点击工具条上的播放按钮,就可以预览物理效果:


    WX20171111-144621@2x.png

    接着给砖块添加物理效果
    全选砖块节点:


    WX20171111-144805@2x.png

    设置为Static形体,其余如下图:

    WX20171111-144821@2x.png

    给球拍挡板添加物理效果
    选中球拍三个节点,打开物理效果检查器,设置TypeKinematic,其余项目设置如下:

    WX20171111-150415@2x.png
    WX20171111-150430@2x.png

    运行一下,小球会疯狂地到处碰撞,包括与球拍的碰撞:


    WX20171111-151240@2x.png
    碰撞检测

    碰撞检测用到的是SCNPhysicsContactDelegate协议.
    打开GameViewController.swift,添加一个新属性:

    var lastContactNode: SCNNode!
    

    它的作用有两个:

    1. 当两个节点发生互相滑动时,就相当于和同一个节点不停发生碰撞,而我们只关心第一次碰撞.
    2. 在这个游戏中,尽管碰撞可能会持续,但小球不能和同一个节点两次发生接触事件,直到小球碰到了其它节点.所以我们需要确保只处理一次碰撞.

    GameViewController.swift底部添加类扩展:

    // 1
    extension GameViewController: SCNPhysicsContactDelegate {
      // 2
      func physicsWorld(_ world: SCNPhysicsWorld,
        didBegin contact: SCNPhysicsContact) {
        // 3
        var contactNode: SCNNode!
        if contact.nodeA.name == "Ball" {
          contactNode = contact.nodeB
    } else {
          contactNode = contact.nodeA
        }
    // 4
        if lastContactNode != nil &&
            lastContactNode == contactNode {
    return
    }
        lastContactNode = contactNode
      }
    }
    

    代码含义:

    1. 扩展GameViewController类以实现SCNPhysicsContactDelegate协议,方便组织代码.
    2. 实现physicsWorld(_:didBegin:).默认不触发,需要设置接触掩码.
    3. 传入一个SCNPhysicsContact参数,可以判断并找到哪个是小球.
    4. 防止和同一个节点多次碰撞.

    使用位掩码来检测接触事件.
    我们已经给游戏中的不同元素设置了Category bitmask分类掩码,这个值是二进制的,各分类如下:

    Ball:     1 (Decimal) = 00000001 (Binary)
    Barrier:  2 (Decimal) = 00000010 (Binary)
    Brick:    4 (Decimal) = 00000100 (Binary)
    Paddle:   8 (Decimal) = 00001000 (Binary)
    

    GameViewController顶部定义一个枚举:

    enum ColliderType: Int {
      case ball     = 0b0001
      case barrier  = 0b0010
      case brick    = 0b0100
      case paddle   = 0b1000
    }
    

    setupNodes()方法的末尾添加下面代码来处理碰撞:

    ballNode.physicsBody?.contactTestBitMask =
      ColliderType.barrier.rawValue |
        ColliderType.brick.rawValue |
          ColliderType.paddle.rawValue
    

    这样,你就告诉了物理引擎,当小球和分类掩码为2, 4, 8的节点碰撞时,调用physicsWorld(_:didBegin:)方法通知我. 2,4,8也就是指barrier边框, brick砖块和paddle球拍.

    physicsWorld(_:didBegin:)方法的末尾继续写:

    // 1
    if contactNode.physicsBody?.categoryBitMask ==
    ColliderType.barrier.rawValue {
      if contactNode.name == "Bottom" {
        game.lives -= 1
        if game.lives == 0 {
          game.saveState()
          game.reset()
        }
    } }
    // 2
    if contactNode.physicsBody?.categoryBitMask ==
    ColliderType.brick.rawValue {
      game.score += 1
      contactNode.isHidden = true
      contactNode.runAction(
        SCNAction.waitForDurationThenRunBlock(duration: 120) {
        (node:SCNNode!) -> Void in
           node.isHidden = false
      })
    }
    // 3
    if contactNode.physicsBody?.categoryBitMask ==
    ColliderType.paddle.rawValue {
      if contactNode.name == "Left" {
        ballNode.physicsBody!.velocity.xzAngle -=
          (convertToRadians(angle: 20))
      }
      if contactNode.name == "Right" {
        ballNode.physicsBody!.velocity.xzAngle +=
          (convertToRadians(angle: 20))
      }
    }
    // 4
    ballNode.physicsBody?.velocity.length = 5.0
    

    代码含义:

    1. 检查categoryBitMask来判断小球是不是和边框节点碰撞了.再根据名字判断,如果是和底部边框碰撞,则需要扣掉一个生命值.
    2. 检查并判断小球是不是和砖块碰撞了.让对应砖块消失120秒,再皇亲出现,这样游戏就能一直玩下去.
    3. 判断小球是不是和球拍碰撞了.如果遇到了中间部分,不改变物理效果,由引擎自动控制反弹.如果是碰到了左边或右边,则给小球增加一个20度的水平偏转.
    4. 将小球速度强制限制在5,以防物理引擎出现偏差而失控.

    还要记得成为接触代理.在setupScene()底部添加一行:

    scnScene.physicsWorld.contactDelegate = self
    

    运行一下,可以打掉砖块了!


    WX20171111-160202@2x.png
    触摸控制球拍

    GameViewController添加两个属性:

     var touchX: CGFloat = 0
     var paddleX: Float = 0
    

    下一步,给GameViewController添加下面的方法:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
    {
      for touch in touches {
        let location = touch.location(in: scnView)
        touchX = location.x
        paddleX = paddleNode.position.x
      } 
    }
    

    记录下触摸的初始位置,球拍的初始位置

     override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
    {
      for touch in touches {
        // 1
        let location = touch.location(in: scnView)
        paddleNode.position.x = paddleX +
          (Float(location.x - touchX) * 0.1)
        // 2
        if paddleNode.position.x > 4.5 {
          paddleNode.position.x = 4.5
        } else if paddleNode.position.x < -4.5 {
          paddleNode.position.x = -4.5
        }
      }
    }
    

    代码含义:

    1. 当触摸位置移动时,根据相对初始触摸位置的偏移touchX来更新球拍的位置.
    2. 限制球拍的移动,确保在边框之间.

    运行一下,可以来回移动球拍了:


    WX20171111-163506@2x.png
    摄像机追踪

    touchesMoved(_:with:)方法的底部,添加下面代码,让摄像机水平位置和球拍一致:

     verticalCameraNode.position.x = paddleNode.position.x
     horizontalCameraNode.position.x = paddleNode.position.x
    

    GameViewController中添加一个新属性来依旧在地板节点:

    var floorNode: SCNNode!
    

    setupNodes()底部添加代码:

    floorNode =
      scnScene.rootNode.childNode(withName: "Floor",
        recursively: true)!
    verticalCameraNode.constraints =
      [SCNLookAtConstraint(target: floorNode)]
    horizontalCameraNode.constraints =
      [SCNLookAtConstraint(target: floorNode)]
    

    这段代码含义:找到名为Floor的节点,绑定到floorNode.给场景中的两个摄像机添加SCNLookAtConstraint约束,能让摄像机始终对准目标节点,也就是游戏区域的中央.

    可以运行试玩一下了:


    WX20171111-164815@2x.png
    粒子效果

    选中场景Game.scn.从对象库中拖放一个Particle System粒子系统到场景中,命名为Trail,并放在Ball节点中

    WX20171111-165921@2x.png
    :
    WX20171111-165743@2x.png

    打开节点检查器,设置position(x:0, y:0, z:0).

    WX20171111-170628@2x.png

    打开属性检查器,配置粒子系统的属性:


    WX20171111-171334@2x.png

    完成后,点击播放按钮预览一下:


    WX20171111-171439@2x.png

    正式运行一下,可以玩起来了!


    WX20171111-171501@2x.png

    该部分最终完成的项目,放在代码中对应章节的projects/final/Breaker文件夹里.

    添加声音效果

    添加setupSounds()方法,并添加代码:

    game.loadSound(name: "Paddle",
      fileNamed: "Breaker.scnassets/Sounds/Paddle.wav")
    game.loadSound(name: "Block0",
      fileNamed: "Breaker.scnassets/Sounds/Block0.wav")
    game.loadSound(name: "Block1",
      fileNamed: "Breaker.scnassets/Sounds/Block1.wav")
    game.loadSound(name: "Block2",
      fileNamed: "Breaker.scnassets/Sounds/Block2.wav")
    game.loadSound(name: "Barrier",
      fileNamed: "Breaker.scnassets/Sounds/Barrier.wav")
    

    可以在碰撞的时候,播放对应的音效:

    1. 使用game.playSound(node: scnScene.rootNode, name: "SoundToPlay")来播放已加载好的音效.
    2. Block添加音效时使用随机值,用random() % 3来产生0~2的随机数.

    最终完成的项目,放在代码中对应章节的projects/challenge/Breaker文件夹里.

    相关文章

      网友评论

        本文标题:[SceneKit专题]3D打砖块游戏Breaker

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