20-仿水果忍者小游戏Geometry-Fighter

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

01-Scenes场景

在Xcode主菜单中选择File > New > Project.

选择iOS/Application/Game模板,点击Next

输入项目名GeometryFighter,选择Swift语言, SceneKit游戏技术,Universal设备类型, 去掉单元测试的勾,点击Next:

下一步,清理不需要的文件. 删除art.scnassets文件夹. 清理GameViewController.swift文件中的内容:

import UIKit
import SceneKit
class GameViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
  }
  override var shouldAutorotate: Bool {
return true
}
  override var prefersStatusBarHidden: Bool {
return true
} }

然后在viewDidLoad()前面添加:

var scnView: SCNView!

再在prefersStatusBarHidden()下方添加:

func setupView() {
   scnView = self.view as! SCNView
}

并在Main.storyboard中将view类型设置为SCNView.

继续添加属性:

var scnScene: SCNScene!

setupView()下方接着写:

func setupScene() {
  scnScene = SCNScene()
  scnView.scene = scnScene
}

viewDidLoad()中调用这些方法:

 setupView()
 setupScene()

Resources中找到游戏图标,拖放到Assets.xcassets

此时运行游戏,看到的是黑屏.

02-Nodes节点

resources文件夹中拖放GeometryFighter.scnassets到我们的项目中,选中Copy items if needed, Create Groups还有我的项目GeometryFighter,点击Finish.

在项目中选中素材文件,可以查看详情


下面添加启动屏幕.

先点击Assets.xcassets,拖放GeometryFighter.scnassets/Textures/Logo_Diffuse.pngAppIcon下面.

再点击LaunchScreen.storyboard,选中view,设置背景为深蓝色:

从右下的媒体库中,拖放Logo_Diffuse到view中,设置Content ModeAspect Fit:

添加约束:


运行一下:


添加游戏中的背景图片

GameViewController.swiftsetupScene()方法的底部添加:

 scnScene.background.contents = "GeometryFighter.scnassets/Textures/
Background_Diffuse.png"

运行一下


添加摄像机

打开GameViewController.swift,在scnScene下方添加新属性:

var cameraNode: SCNNode!

并在setupScene()方法下方添加:

func setupCamera() {
  // 1
  cameraNode = SCNNode()
  // 2
  cameraNode.camera = SCNCamera()
  // 3
  cameraNode.position = SCNVector3(x: 0, y: 0, z: 10)
  // 4
  scnScene.rootNode.addChildNode(cameraNode)
}

其中:

  1. 创建一个空节点并赋值到cameraNode.
  2. 创建一个新的SCNCamera对象,并赋值给cameraNodecamera属性.
  3. 设置摄像机位置(x:0, y:0, z:10).
  4. 添加cameraNode到场景中,作为场景根节点的一个子节点.

完成后,在viewDidLoad()方法中,setupScene()方法后面调用:

setupCamera()
添加几何体

添加一个新文件,命名为setupCamera()

打开并更改内容如下:

import Foundation
// 1
enum ShapeType:Int {
  case box = 0
  case sphere
  case pyramid
  case torus
  case capsule
  case cylinder
  case cone
  case tube
// 2
  static func random() -> ShapeType {
    let maxValue = tube.rawValue
    let rand = arc4random_uniform(UInt32(maxValue+1))
    return ShapeType(rawValue: Int(rand))!
} }

代码含义:

  1. 创建一个新的枚举名为ShapeType,用来表示各种不同形状.
  2. 定义一个static方法名为random(),用来产生随机的ShapeType.

GameViewController.swift中,setupCamera()方法下面,添加:

func spawnShape() {
  // 1
  var geometry:SCNGeometry
  // 2
  switch ShapeType.random() {
  default:
// 3
    geometry = SCNBox(width: 1.0, height: 1.0, length: 1.0,
      chamferRadius: 0.0)
}
// 4
  let geometryNode = SCNNode(geometry: geometry)
  // 5
  scnScene.rootNode.addChildNode(geometryNode)
}

代码含义:

  1. 创建一个占位几何体,稍后会用到.
  2. 定义一个switch语句来处理ShapeType.random()中返回的形状.暂时我们只添加一个立方体形状,其他的稍后添加.
  3. 创建一个SCNBox对象并储存在geometry中.
  4. 创建一个SCNNode实例,命名为geometryNode.构造器使用geometry参数来自动创建一个节点并将几何体附加在上面.
  5. 将节点添加到场景的根节点上.

还需要在viewDidLoad()中调用一下,放在setupCamera()后面:

spawnShape()

运行一下,看到一个白方块:


因为立方体节点是从spwnSpape()创建的,会位于场景的(x:0, y:0, z:0).我们又是从cameraNode节点来观察场景的,摄像机节点位置是在(x:0, y:0: z:10),所以正好立方体正好出现在屏幕中间.

为了更方便观察,我们可以打开视图的内置属性,给GameViewController.swift中的setupView()方法再添加几行:

// 1
scnView.showsStatistics = true
// 2
scnView.allowsCameraControl = true
// 3
scnView.autoenablesDefaultLighting = true

代码含义:

  1. showStatistics会在屏幕底部启动一个实时的统计面板.
  2. allowsCameraControl能让你用手势(单指轻扫,双指轻扫,双指捏合,双击)控制摄像机的位置.
  3. autoenablesDefaultLighting则创建一个泛光灯来照亮你的场景.

运行一下,看起来好多了!


03-Physics物理效果

导入游戏工具类

拖放GameUtils文件夹到我们的项目中,点击Finish:

物理效果

打开GameViewController.swift,在spawnShape()中的创建geometryNode代码之后添加一行:

 geometryNode.physicsBody =
  SCNPhysicsBody(type: .dynamic, shape: nil)

shape传nil,会自动根据显示的形状创建一个物理形体.

运行一下,会看到随机产生的几何体,自动掉落下去了,这是因为SceneKit的场景会自动打开重力:


添加力

spawnShape()中的创建geometryNode代码之后添加一行:

// 1
let randomX = Float.random(min: -2, max: 2)
let randomY = Float.random(min: 10, max: 18)
// 2
let force = SCNVector3(x: randomX, y: randomY , z: 0)
// 3
let position = SCNVector3(x: 0.05, y: 0.05, z: 0.05)
// 4
geometryNode.physicsBody?.applyForce(force,
  at: position, asImpulse: true)

代码含义:

  1. 创建两个随机的浮点数代表力的x分量和y分量.用到的正是我们添加进项目中的工具类.
  2. 用这些随机数来创建一个向量代表这个力.
  3. 创建另一个向量来表示力施加的位置.这个位置是故意稍微偏离中心一些的,这样就能让物体旋转起来.
  4. 通过调用applyForce(direction: at: asImpulse:)方法将力应用到geometryNode的物理形体上.

运行一下,物体凭空出现后,受到力的作用被抛向空中,飞翔之后,最终受到重力影响下落.


添加更多效果

现在物体是在屏幕中间凭空出现,效果很不好,我们只需要修改摄像机的位置就可以改善.在setupCamera()中更改位置:

cameraNode.position = SCNVector3(x: 0, y: 5, z: 10)

下面,还可以给几何体添加一些随机颜色.在spawnShape()方法中添加一行,在创建geometry之后中, 创建geometryNode之前:

geometry.materials.first?.diffuse.contents = UIColor.random()

运行一下,物体就有了漂亮的颜色:


04-Render Loop渲染循环

创建

GameViewController.swift中,添加SCNSceneRendererDelegate协议,并实现协议方法:

// 1
extension GameViewController: SCNSceneRendererDelegate {
  // 2
  func renderer(_ renderer: SCNSceneRenderer,
    updateAtTime time: TimeInterval) {
    // 3
    spawnShape()
} }

在此之前,还要先成为视图的代理.在setupView()方法的末尾添加一行:

scnView.delegate = self

此时,已经可以删除viewDidLoad()中对spawnShape()的调用了.运行一下:

可以发现,创建的太多了,场面几乎失控了.我们需要控制一下创建几何体的时间间隔.

cameraNode下方添加一个新属性:

var spawnTime: TimeInterval = 0

然后替换renderer(_:updateAtTime:)方法中的内容:

// 1
if time > spawnTime {
  spawnShape()
// 2
  spawnTime = time + TimeInterval(Float.random(min: 0.2, max: 1.5))
}

代码含义:

  1. 检查time(当前系统的时间),如果大于spawnTime就产生一个新的形状,否则,什么也不做.
  2. 创建一个物体后,更新spawnTime来决定下一次创建的时机.下一次创建时间应该是在当前时间上增加一个随机量.

运行一下.


移除子节点

spawnShape()方法一直不停地创建新的节点并添加到场景中,但是却没有移除,仅仅是掉落出视线而已.虽然SceneKit有些优化能让场景继续运行下去不卡顿,但我们仍然需要将不要的节点移除掉.

spawnShape()下方,添加几行:

func cleanScene() {
  // 1
  for node in scnScene.rootNode.childNodes {
    // 2
    if node.presentation.position.y < -2 {
      // 3
      node.removeFromParentNode()
    }
} }

代码含义:

  1. 循环遍历场景的根节点.
  2. 这里需要注意,因为物理效果模拟此时正在进行中,所以我们不能简单取物体的position来表示它的真实位置,此时的position反应的是动画开始前的位置.SceneKit在动画期间保存了对象的副本,并用副本来执行动画.要想得到动画进行过程中的实际位置,需要使用presentationNode属性.
  3. 让一个物体消失.

renderer(_: updatedAtTime:)方法中调用cleanScene()方法:

cleanScene()

还有一个问题需要处理.默认情况下,SceneKit在没有动画时会进入"暂停"状态.我们可以启用SCNView实例的playing属性来阻止它.

setupView()的最后,添加下面的代码:

scnView.isPlaying = true

运行一下,旋转看看物体下落到哪里消失的.


05-Particle Systems粒子系统

运动尾迹

创建一个新分组


命名为Particles,右击分组选择New File,选择iOS/Resource/SceneKit Particle System模板,点击Next继续:

接下来,在Particle system template中选择Fire类型,点击Next.保存为Tail.scnp并点击Create.然后你会看到这样的场景:

注:Xcode 11 中,粒子系统创建方式有变化,在.scn 场景右上角的“+”号中。


在右侧配置粒子系统的属性如下:









配置完成后的最终效果如下,如果你看到的不一样,试着旋转一下摄像机:


GameViewController.swift类中添加下面的代码:

// 1
func createTrail(color: UIColor, geometry: SCNGeometry) ->
  SCNParticleSystem {
  // 2
  let trail = SCNParticleSystem(named: "Trail.scnp", inDirectory: nil)!
  // 3
  trail.particleColor = color
// 4
  trail.emitterShape = geometry
// 5
  return trail
}

代码含义:

  1. 定义一个方法createTrail(_: geometry:)接收colorgeometry参数来创建粒子系统.
  2. 从先前创建的文件里加载粒子系统.
  3. 根据传入的颜色修改粒子的颜色.
  4. 用传入的几何体参数来指定发射器的形状.
  5. 返回新创建的粒子系统.

进入spawnShape()中,找到设置材质颜色的代码,用常量保存起来:

 let color = UIColor.random()
geometry.materials.first?.diffuse.contents = color

下一步,在spawnShape()中,在添加力到geometryNode的物理形体上之后,添加下面的代码:

 let trailEmitter = createTrail(color: color, geometry: geometry)
geometryNode.addParticleSystem(trailEmitter)

运行一下:


抬头显示面板

GameViewController.swift中添加一个新属性,放在spawnTime后面:

var game = GameHelper.sharedInstance

GameViewController最底部,createTail()方法后面,添加下面的方法:

func setupHUD() {
  game.hudNode.position = SCNVector3(x: 0.0, y: 10.0, z: 0.0)
  scnScene.rootNode.addChildNode(game.hudNode)
}

其中我们是从帮助文件库中调用的game.hudNode.

下一步,我们需要调用setupHUD().在viewDidLoad()方法的底部添加一行:

setupHUD()

我们还需要不断更新显示的内容.在renderer(_: updateAtTime:)方法底部,调用game.updateHUD():

game.updateHUD()

运行一下,屏幕上方就出现了抬头显示面板:


触摸处理

在我们处理触摸事件之前,我们需要标识出每个物体.最简单的方法就是给他们起个名字.

spawnShape()中添加下面的代码,放在添加粒子系统之后:

if color == UIColor.black {
  geometryNode.name = "BAD"
} else {
  geometryNode.name = "GOOD"
}

下一步,在GameViewController中, setupHUD()之后,添加下列方法:

func handleTouchFor(node: SCNNode) {
  if node.name == "GOOD" {
    game.score += 1
    node.removeFromParentNode()
  } else if node.name == "BAD" {
    game.lives -= 1
    node.removeFromParentNode()
  }
}

下一步,在GameViewController中, handleTouchFor(_:)之后,添加下列方法:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
{
// 1
  let touch = touches.first!
  // 2
  let location = touch.location(in: scnView)
  // 3
  let hitResults = scnView.hitTest(location, options: nil)
  // 4
  if let result = hitResults.first {
// 5
    handleTouchFor(node: result.node)
  }
}

代码含义:

  1. 拿到可用的touch.此处如果玩家用了多根手指就会有多个touch.
  2. 从屏幕坐标转换到scnView的坐标.
  3. hitTest(_: options:)返回一个SCNHitTestResult对象数组,代表着从用户触摸点发出的射线碰到的所有物体.
  4. 检查第一个结果是否可用.
  5. 将第一个碰到的节点传递给触摸处理方法,它可以计算增加分数或减少生命值.

最后一步,需要禁用摄像机控制:

scnView.allowsCameraControl = false

运行一下,用手指触摸就会毁灭!


爆炸粒子效果

再创建一个粒子效果,命名为Explode.scnp.尝试着自己配置一下,让它看起来像这样:


可以用下面的图片作为参考:

可以在projects/challenge/ GeometryFighter文件夹中找到已经完成的Explode.scnp文件.

接着还需要将这个效果用起来.在GameViewController中, touchesBegan(_: withEvent)方法后面,添加下面的代码:

// 1
func createExplosion(geometry: SCNGeometry, position: SCNVector3,
  rotation: SCNVector4) {
  // 2
  let explosion =
    SCNParticleSystem(named: "Explode.scnp", inDirectory:
  nil)!
  explosion.emitterShape = geometry
  explosion.birthLocation = .surface
  // 3
  let rotationMatrix =
    SCNMatrix4MakeRotation(rotation.w, rotation.x,
      rotation.y, rotation.z)
  let translationMatrix =
    SCNMatrix4MakeTranslation(position.x, position.y,
      position.z)
  let transformMatrix =
    SCNMatrix4Mult(rotationMatrix, translationMatrix)
  // 4
  scnScene.addParticleSystem(explosion, transform: transformMatrix)
}

代码含义:

  1. createExplosion(_: position: rotation:)接收三个参数:geometry定义了粒子效果的形状,positionrotation帮助放置爆炸效果到场景中.
  2. 加载Explode.scnp,将其用作发射器.发射器使用geometry作为emitterShape,这样粒子就可以从形状的表面发射出来.
  3. 创建旋转矩阵和平移矩阵,相乘得到复合变换矩阵.
  4. 调用addParticleSystem(_: wtihTransform)将爆炸效果添加到场景中.

handleTouchFor(_:)中添加两次下面的代码-"good"分支一次,"bad"分支一次.添加在移除节点之前:

createExplosion(geometry: node.geometry!,  position: node.presentation.position,rotation: node.presentation.rotation)

这里,我们又使用了presentation,因为物理效果模拟正在移动节点.

运行一下,点击爆炸!


这个效果可以在projects/ challenge/GeometryFighter文件夹中找到.

彩蛋

为了让游戏更好玩,还可以添加很多彩蛋效果,比如:

  • 游戏状态管理:比如点击开始游戏,暂停/开始,游戏结束等.
  • 启动闪屏:根据游戏状态提供不同的效果.
  • 声音效果:根据玩家的操作,提供声音反馈
  • 摄像机抖动:剧烈爆炸会产生剧烈冲击波,添加摄像机抖动来模拟冲击波效果.

这些效果都可以在projects/juiced/GeometryFighter文件夹中找到最终完成品.打开尝试一下吧.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345