SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)

版本记录

版本号 时间
V1.0 2017.10.19 星期五

前言

SpriteKit框架使用优化的动画系统,物理模拟和事件处理支持创建基于2D精灵的游戏。接下来这几篇我们就详细的解析一下这个框架。相关代码已经传至GitHub - 刀客传奇,感兴趣的可以阅读另外几篇文章。
1. SpriteKit框架详细解析(一) —— 基本概览(一)
2. SpriteKit框架详细解析(二) —— 一个简单的动画实例(一)
3. SpriteKit框架详细解析(三) —— 创建一个简单的2D游戏(一)
4. SpriteKit框架详细解析(四) —— 创建一个简单的2D游戏(二)
5. SpriteKit框架详细解析(五) —— 基于SpriteKit的游戏编程的三角函数(一)

开始

在本系列的第一部分 SpriteKit框架详细解析(五) —— 基于SpriteKit的游戏编程的三角函数(一)中,您学习了三角学的基础知识,并亲眼看到它对于制作游戏有多大用处。

在本系列的第二部分中,您将通过添加导弹,轨道小行星盾牌和动画“game over”屏幕来扩展您的简单太空游戏。 在此过程中,您还将了解有关正弦和余弦函数的更多信息,并了解一些其他有用的方法,以便在游戏中使用三角函数的强大功能。

目前,你的游戏中有一个宇宙飞船和一个旋转的大炮,每个都带有生命条。 虽然它们可能是死敌,但是除非宇宙飞船直接飞向大炮(对于大炮来说效果更好),否则它们都无法破坏对方。

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第1张图片

Firing a Missile by Swiping - 通过滑动射击导弹

现在,您将通过滑动屏幕让玩家能够从太空船发射导弹。 宇宙飞船将向滑动方向发射导弹。

打开GameScene.swift。 将以下属性添加到GameScene

let playerMissileSprite = SKSpriteNode(imageNamed:"PlayerMissile")

var touchLocation = CGPoint.zero
var touchTime: CFTimeInterval = 0

你将导弹精灵从玩家的船上向其朝向的方向移动。 您将使用触摸位置和时间来跟踪用户在屏幕上点击以触发导弹的位置和时间。

然后,将以下代码添加到didMove(to :)的末尾:

playerMissileSprite.isHidden = true
addChild(playerMissileSprite)

请注意,导弹精灵最初是隐藏的;只有当玩家开火时你才能看到它。 为了增加挑战,玩家一次只能有一枚导弹在飞行中。

要检测放置在触摸屏上的第一根手指,请将以下方法添加到GameScene

override func touchesBegan(_ touches: Set, with event: UIEvent?) {
  guard let touch = touches.first else { return }
  let location = touch.location(in: self)
  touchLocation = location
  touchTime = CACurrentMediaTime()
}

这非常简单 - 只要检测到触摸,您就可以存储触摸位置和时间。 实际工作发生在touchesEnded(_:with :)中,您将在下一步添加:

override func touchesEnded(_ touches: Set, with event: UIEvent?) {
  let touchTimeThreshold: CFTimeInterval = 0.3
  let touchDistanceThreshold: CGFloat = 4
  
  guard CACurrentMediaTime() - touchTime < touchTimeThreshold,
    playerMissileSprite.isHidden,
    let touch = touches.first else { return }
  
  let location = touch.location(in: self)
  let swipe = CGVector(dx: location.x - touchLocation.x, dy: location.y - touchLocation.y)
  let swipeLength = sqrt(swipe.dx * swipe.dx + swipe.dy * swipe.dy)
  
  guard swipeLength > touchDistanceThreshold else { return }
  // TODO
}

guard语句检查开始和结束滑动之间经过的时间是否小于touchTimeThreshold值0.3秒。然后,检查导弹是否隐藏。如果没有,则玩家的一个允许导弹飞行并且忽略触摸。

下一部分将确定用户所做的那种姿势;是真的轻扫,还是只是点击?你应该只在滑动而不是点击上发射导弹。你已经做过几次这样的计算 - 减去两个坐标,然后用毕达哥拉斯定理找到它们之间的距离。如果距离大于4点的touchDistanceThreshold值,则将其视为有意滑动。

注意:您可以使用UIKit内置的手势识别器,但这里的目的是了解如何使用三角法来实现这种逻辑。

有两种方法可以使导弹飞行。第一个选项是根据您瞄准导弹的角度创建一个playerMissileVelocity矢量。在update(_:)内部,然后你将这个速度乘以增量时间到导弹精灵每帧的位置,并检查导弹是否已经飞出可见屏幕区域以便重置。这类似于您在本系列教程的第1部分中制作飞船的方法。

与宇宙飞船不同,导弹永远不会改变航向;它总是直线飞行。因此,您可以采取更简单的方法,并在发射时提前计算导弹的最终目的地。掌握了这些信息后,您可以让SpriteKit为导弹精灵设置动画到最终位置。

这使您无需检查导弹是否已离开可见屏幕。而且,这也是一个做更多有趣数学的机会!

首先,使用以下代码替换touchesEnded(_:with :)中的TODO注释:

let angle = atan2(swipe.dy, swipe.dx)
playerMissileSprite.zRotation = angle - 90 * degreesToRadians
playerMissileSprite.position = playerSprite.position
playerMissileSprite.isHidden = false

在这里,您使用atan2(_:_ :)将滑动矢量转换为角度,设置精灵的旋转和位置,并使导弹精灵可见。

现在是有趣的部分。 你知道导弹的起始位置(宇宙飞船的当前位置),你知道角度(来自玩家的滑动动作)。 因此,您可以根据这些事实计算导弹的目的地点。


Calculating Missile Destination - 计算导弹目的地

您已经拥有了方向向量,并且您在第1部分中学习了如何使用规范化normalization将向量的长度设置为您需要的任何值。 但你想要多大的长度? 嗯,这是具有挑战性的一点。 因为你希望导弹在移出屏幕边界时停止,所以它的行进长度取决于起始位置和方向。

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第2张图片

目标点始终位于屏幕边框外,而不是屏幕边框上。 因此,导弹在完全飞出视线时会消失。 这是为了让游戏更具视觉吸引力。 要实现这一点,请在GameScene.swift的顶部添加另一个常量:

let playerMissileRadius: CGFloat = 20

找到目标点有点复杂。例如,如果你知道玩家向下射击,你可以计算出导弹需要飞行的垂直距离。首先,通过简单地找到导弹开始Y位置和playerMissileRadius的总和来计算Y分量。其次,通过确定导弹与该边界线相交的位置来计算X分量。

对于从屏幕的底部或顶部边缘飞出的导弹,可以使用以下公式计算目的地的X分量:

destination.x = playerPosition.x +((destination.y - playerPosition.y)/ swipe.dy * swipe.dx)

这类似于第1部分中的归一化技术,其中您通过首先将x和y分量除以当前长度然后乘以所需长度来放大矢量。在这里,您可以计算滑动矢量的Y分量与最终距离的比率。然后将X分量乘以相同的值并将其添加到船舶的当前X位置以获得目标X坐标。

对于离开左边或右边的导弹,你基本上使用相同的功能,但交换所有的X和Y值。

这种将矢量延伸到边缘的技术称为投影projection,它对各种游戏应用非常有用,例如检测敌人是否可以通过沿着他们的视线投射矢量并看其是否能够看到玩家墙壁或玩家。

有一个障碍。如果交叉点靠近一个角落,导弹首先与哪个边缘交叉并不明显。

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第3张图片

没关系。你只需计算两个交叉点,然后看看与玩家距离较短的距离!

touchesEnded(_:with :)的末尾添加以下代码:

//calculate vertical intersection point
var destination1 = CGPoint.zero
if swipe.dy > 0 {
  destination1.y = size.height + playerMissileRadius // top of screen
} else {
  destination1.y = -playerMissileRadius // bottom of screen
}
destination1.x = playerSprite.position.x +
  ((destination1.y - playerSprite.position.y) / swipe.dy * swipe.dx)

//calculate horizontal intersection point
var destination2 = CGPoint.zero
if swipe.dx > 0 {
  destination2.x = size.width + playerMissileRadius // right of screen
} else {
  destination2.x = -playerMissileRadius // left of screen
}
destination2.y = playerSprite.position.y +
  ((destination2.x - playerSprite.position.x) / swipe.dx * swipe.dy)

在这里,你要计算导弹的两个候选目标点;现在你需要找出哪个更接近玩家。 接下来,在上面的代码正下方添加以下代码:

// find out which is nearer
var destination = destination2
if abs(destination1.x) < abs(destination2.x) || abs(destination1.y) < abs(destination2.y) {
  destination = destination1
}

你可以在这里使用毕达哥拉斯定理来计算从玩家到每个交叉点的对角线距离并选择最短的距离,但是有一个更快的方法。 由于两个可能的交叉点位于相同的矢量上,如果X或Y分量较短,则整个距离必须较短。 因此,无需计算对角线长度。

在刚刚添加的代码下方,将最后一段代码添加到touchesEnded(_:with :)

// run the sequence of actions for the firing
let missileMoveAction = SKAction.move(to: destination, duration: 2)
playerMissileSprite.run(missileMoveAction) {
  self.playerMissileSprite.isHidden = true
}

构建并运行应用程序。 您现在可以滑动以在炮塔上射击等离子体的螺栓。 请注意,您一次只能发射一枚导弹。 你必须等到前一枚导弹从屏幕上消失后再次发射。

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第4张图片

Making a Missile Travel at a Constant Speed - 使导弹以恒定速度飞行

还有一个问题。 根据飞行距离,导弹似乎行进得更快或更慢。

那是因为动画的持续时间被硬编码为持续2秒。 如果导弹需要进一步行进,它将以更快的速度行进,以便在相同的时间内覆盖更多的距离。 如果导弹始终以一致的速度行进将更加现实。

你的好朋友艾萨克·牛顿爵士可以在这里帮忙! 牛顿发现,time = distance / speed。 您可以使用毕达哥拉斯来计算距离,因此只需指定速度即可。

GameScene.swift的顶部添加另一个常量:

let playerMissileSpeed: CGFloat = 300

这是你希望导弹每秒传播的距离。 现在,替换你在touchesEnded(_:with :)中添加的最后一个代码块:

// calculate distance
let distance = sqrt(pow(destination.x - playerSprite.position.x, 2) +
  pow(destination.y - playerSprite.position.y, 2))
          
// run the sequence of actions for the firing
let duration = TimeInterval(distance / playerMissileSpeed)
let missileMoveAction = SKAction.move(to: destination, duration: duration)
playerMissileSprite.run(missileMoveAction) {
   self.playerMissileSprite.isHidden = true
}

您可以使用牛顿公式从距离和速度中得出它,而不是对持续时间进行硬编码。 再次运行应用程序,您将看到导弹现在总是以相同的速度飞行,无论目标点有多远或多近。

这就是你如何使用三角函数来发射移动的导弹。 这有点牵扯。 与此同时,SpriteKit会让所有的精灵运动动画为你工作。

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第5张图片

Detecting Collision Between Cannon and Missile - 探测炮与导弹之间的碰撞

现在,导弹完全忽略了大炮。 那即将改变。

您将像以前一样使用基于半径的简单方法进行碰撞检测。 你已经添加了playerMissileRadius,所以你已经准备好使用你用于炮/船碰撞的相同技术来检测大炮/导弹碰撞。

添加新方法:

func checkMissileCannonCollision() {
  guard !playerMissileSprite.isHidden else { return }
  let deltaX = playerMissileSprite.position.x - turretSprite.position.x
  let deltaY = playerMissileSprite.position.y - turretSprite.position.y
  
  let distance = sqrt(deltaX * deltaX + deltaY * deltaY)
  if distance <= cannonCollisionRadius + playerMissileRadius {
    
    playerMissileSprite.isHidden = true
    playerMissileSprite.removeAllActions()
    
    cannonHP = max(0, cannonHP - 10)
    updateHealthBar(cannonHealthBar, withHealthPoints: cannonHP)
  }
}

这与checkShipCannonCollision()非常相似。 您可以计算精灵之间的距离,如果该距离小于半径之和,则将其视为碰撞。

如果检测到碰撞,首先隐藏导弹精灵并取消其动画。 然后降低大炮的生命值,并重新绘制它的生命条。

在其他更新之后立即在update(_ :)方法中添加对checkMissileCannonCollision()的调用:

checkMissileCannonCollision()

构建并运行,然后尝试一下。 最后你可以对敌人造成一些伤害!

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第6张图片

在继续之前,如果导弹有一些声音效果会很好。 与以前的炮塔碰撞一样,您可以使用SpriteKit动作播放声音。 将以下两个属性添加到GameScene

let missileShootSound = SKAction.playSoundFileNamed("Shoot.wav", waitForCompletion: false)
let missileHitSound = SKAction.playSoundFileNamed("Hit.wav", waitForCompletion: false)

现在,用touchesEnded(_:with :)替换playerMissileSprite.run(missileMoveAction)

playerMissileSprite.run(SKAction.sequence([missileShootSound, missileMoveAction]))

你可以设置一个序列来播放声音然后移动导弹,而不是单个动作来移动导弹。

还要在checkMissileCannonCollision()中的updateHealthBar(cannonHealthBar,withHealthPoints:cannonHP)之后添加以下行:

run(missileHitSound)

导弹现在用ZZAPP声音射出,如果你的目标是真的,用一个令人满意的BOINK撞击炮塔!


Adding an Orbiting Asteroid Shield for the Cannon - 为大炮添加轨道小行星盾

为了让游戏更具挑战性,你将给敌人一个盾牌。 盾牌将是一个神奇的小行星,它绕着大炮运行并摧毁靠近它的任何导弹。

GameScene.swift的顶部添加一些常量:

let orbiterSpeed: CGFloat = 120
let orbiterRadius: CGFloat = 60
let orbiterCollisionRadius: CGFloat = 20

初始化精灵节点常量并在GameScene中添加一个新属性:

let orbiterSprite = SKSpriteNode(imageNamed:"Asteroid")
var orbiterAngle: CGFloat = 0

将以下代码添加到didMove(to :)的末尾:

addChild(orbiterSprite)

这会将orbiterSprite添加到GameScene中。

现在,将以下方法添加到GameScene

func updateOrbiter(_ dt: CFTimeInterval) {
  // 1
  orbiterAngle = (orbiterAngle + orbiterSpeed * CGFloat(dt)).truncatingRemainder(dividingBy: 360)
  
  // 2
  let x = cos(orbiterAngle * degreesToRadians) * orbiterRadius
  let y = sin(orbiterAngle * degreesToRadians) * orbiterRadius
  
  // 3
  orbiterSprite.position = CGPoint(x: cannonSprite.position.x + x, y: cannonSprite.position.y + y)
}

小行星将围绕大炮在圆形路径上运行。要做到这一点,你需要两个部分:确定小行星距离大炮中心的距离的半径,以及描述它围绕该中心点旋转多远的角度。

这就是updateOrbiter(_ :)所做的:

  • 1) 它通过orbiterSpeed增加角度,并根据增量时间进行调整。然后使用truncatingRemainder(divideBy :)将角度包装到0-360范围。这并不是绝对必要的,因为sin()cos()在该范围之外的角度下正确工作,但是如果角度变得太大,则浮点精度可能成为问题。此外,如果角度在此范围内以进行调试,则更容易将角度可视化。

  • 2) 它使用sin()cos()计算轨道器的新X和Y位置。它们取半径(形成三角形的斜边)和当前角度,然后分别返回相邻和相对的边。

  • 3) 它通过将X和Y位置添加到大炮的中心位置来设置轨道器精灵的新位置。

你曾经简单地看过sin()cos(),但它们可能并不完全清楚它们是如何工作的。您知道,一旦您有角度和斜边,这两个函数都可用于计算直角三角形的其他边长。

画一个圆圈:

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第7张图片

上面的插图准确地描绘了围绕大炮旋转的小行星的情况。圆圈描述了小行星的路径,圆圈的原点是大炮的中心。

角度从零度开始,但始终一直增加,直到它在开始时结束。如您所见,圆的半径决定了小行星放置中心的距离。

因此,给定角度和半径,您可以分别使用余弦和正弦导出X和Y位置:

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第8张图片

现在,看一下正弦波和余弦波的图:

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第9张图片

水平轴包含圆的度数,从0到360或0到2π弧度。垂直轴通常从-1到+1。但是如果你的圆的半径大于1并且它倾向于,那么垂直轴实际上从-radius变为+ radius

当角度从0度增加到360度时,在余弦和正弦波的图中找到水平轴上的角度。然后纵轴告诉你x和y值:

  • 1) 如果角度为0度,则cos(0)为1 * radius,但sin(0)为0 * radius。这完全对应于圆中的(x,y)坐标:x等于半径,但y为0。
  • 2) 如果角度是45度,则cos(45)是0.707 * radius,sin(45)也是一样的。这意味着x和y在圆上的这一点上都是相同的。注意:如果您在计算器上尝试此操作,请先将其切换为DEG模式。如果它处于RAD模式,你会得到截然不同的答案。
  • 3) 如果角度是90度,则cos(90)是0 * radius而sin(90)是1 * radius。您现在位于(x,y)坐标为(0, radius)的圆的顶部。
  • 4) 等等。为了更直观地了解圆中的坐标如何与正弦,余弦甚至正切函数的值相关,请尝试这个很酷的 interactive circle。

您是否也注意到正弦和余弦的曲线非常相似?事实上,余弦波只是正弦波移动了90度。

update(_:)结束时调用updateOrbiter(_ :)

updateOrbiter(deltaTime)

构建并运行应用程序。 你现在应该拥有一颗永远围绕敌人大炮的小行星。

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第10张图片

Spinning the Asteroid Around Its Axis - 围绕其轴旋转小行星

您还可以使小行星围绕其轴旋转。 将以下行添加到updateOrbiter(_ :)的末尾:

orbiterSprite.zRotation = orbiterAngle * degreesToRadians

通过将旋转设置为orbiterAngle,小行星始终保持与相对于大炮相同的位置,就像月亮总是显示地球的同一侧。


Detecting Collision Between Missile and Orbiter - 探测导弹与轨道器的碰撞

让我们给轨道器一个目的。 如果导弹太靠近,小行星会在它有机会对大炮造成任何伤害之前将其摧毁。 添加以下方法:

func checkMissileOrbiterCollision() {
  guard !playerMissileSprite.isHidden else { return }
  
  let deltaX = playerMissileSprite.position.x - orbiterSprite.position.x
  let deltaY = playerMissileSprite.position.y - orbiterSprite.position.y
  
  let distance = sqrt(deltaX * deltaX + deltaY * deltaY)
  guard distance < orbiterCollisionRadius + playerMissileRadius else { return }
  
  playerMissileSprite.isHidden = true
  playerMissileSprite.removeAllActions()
  
  orbiterSprite.setScale(2)
  orbiterSprite.run(SKAction.scale(to: 1, duration: 0.5))
}

并且不要忘记在update(_ :)结束时调用checkMissileOrbiterCollision()

checkMissileOrbiterCollision()

这看起来应该很熟悉。 它与checkMissileCannonCollision()基本相同。 当检测到碰撞时,导弹精灵被移除。 这次,你不播放声音。 但是作为一种额外的视觉效果,你将小行星精灵的大小增加了两倍。 然后,您立即动画小行星缩放再次缩小。 这使它看起来像轨道小行星“吃掉”导弹!

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第11张图片

建立并运行以查看新的轨道护盾。

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第12张图片

Game Over, With Trig! - 游戏结束

还有更多可以用正弦和余弦做的事情。 它们也可以派上用场做动画。

演示这样的动画的好地方是屏幕上的game over。 将以下常量添加到GameScene.swift的顶部:

let darkenOpacity: CGFloat = 0.8

并向GameScene添加一些属性:

lazy var darkenLayer: SKSpriteNode = {
  let color = UIColor(red: 0, green: 0, blue: 0, alpha: 1)
  let node = SKSpriteNode(color: color, size: size)
  node.alpha = 0
  node.position = CGPoint(x: size.width/2, y: size.height/2)
  return node
}()

lazy var gameOverLabel: SKLabelNode = {
  let node = SKLabelNode(fontNamed: "Helvetica")
  node.fontSize = 24
  node.position = CGPoint(x: size.width/2 + 0.5, y: size.height/2 + 50)
  return node
}()

var gameOver = false
var gameOverElapsed: CFTimeInterval = 0

您将使用这些属性来跟踪游戏状态和节点以显示“Game Over”信息。

接下来,将此方法添加到GameScene

func checkGameOver(_ dt: CFTimeInterval) {
  // 1
  guard playerHP <= 0 || cannonHP <= 0 else { return }
  
  if !gameOver {
    // 2
    gameOver = true
    gameOverElapsed = 0
    stopMonitoringAcceleration()
    
    // 3
    addChild(darkenLayer)
    
    // 4
    let text = (playerHP == 0) ? "GAME OVER" : "Victory!"
    gameOverLabel.text = text
    addChild(gameOverLabel)
    return
  }
  
  // 5
  darkenLayer.alpha = min(darkenOpacity, darkenLayer.alpha + CGFloat(dt))
}

此方法检查游戏是否完成,如果是,则通过动画处理游戏:

  • 1) 游戏继续进行,直到玩家或大炮用完生命值。
  • 2) 当游戏结束时,您将gameOver设置为true,并禁用加速度计。
  • 3) 在其他所有内容上添加新的黑色图层。 稍后在该方法中,您将为该图层的alpha值设置动画,使其显示为淡入。
  • 4) 添加新文本标签并将其放在屏幕上。 如果玩家获胜,则文本为“Victory!”或者如果玩家输了,则为“Game Over”,根据玩家的生命值确定。
  • 5) 上述步骤只发生一次,以便在屏幕上设置游戏 - 每次在此之后,您将darkenOpacity的alpha动画从0设置为0.8 - 几乎完全不透明,但不完全。

update(_ :)的底部添加对checkGameOver(_ :)的调用:

checkGameOver(deltaTime)

并在touchesEnded(_:with :)的顶部添加一小段逻辑:

guard !gameOver else {
  let scene = GameScene(size: size)
  let reveal = SKTransition.flipHorizontal(withDuration: 1)
  view?.presentScene(scene, transition: reveal)
  return
}

当用户在屏幕上点击游戏时,这将重新开始游戏。

构建并运行以试用它。 在大炮上射击或与你的船相撞,直到你们中的一个人没有生命。 屏幕将淡入黑色,将显示game over文本。 游戏不再响应加速度计,但动画仍在继续:

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第13张图片

这一切都很好,花花公子,但正弦和余弦在哪里? 您可能已经注意到,黑色图层的淡入淡出非常线性。 它以一致的速度从透明变为不透明。

你可以比这更好 - 你可以使用sin()来改变淡入淡出的时间。 这被称为easing,你在这里应用的效果被称为ease out

注意:您可以使用run()来执行alpha淡入淡出,因为它支持各种easing模式。 同样,本教程的目的不是学习SpriteKit;它是学习它背后的数学,包括easing

GameScene.swift顶部添加一个新常量:

let darkenDuration: CFTimeInterval = 2

接下来,使用以下内容替换checkGameOver(_ :)中的最后一行代码:

gameOverElapsed += dt
if gameOverElapsed < darkenDuration {
  var multiplier = CGFloat(gameOverElapsed / darkenDuration)
  multiplier = sin(multiplier * CGFloat.pi / 2) // ease out
  darkenLayer.alpha = darkenOpacity * multiplier
}

gameOverElapsed记录了自游戏结束以来已经过了多少时间。 淡入黑色层需要两秒钟(darkenDuration)multiplier确定经过的持续时间。 无论darkenDuration到底有多长,它的值始终介于0.0和1.0之间。

然后你执行:

multiplier = sin(multiplier * CGFloat.pi / 2) // ease out

这将multiplier从线性插值转换为为生命注入更多生命的插值:

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第14张图片

构建并运行以查看新的“ease out”效果。 如果您发现很难看到差异,请在注释掉的“ease out”行中尝试,或更改动画的持续时间。 效果很微妙,但它就在那里。

注意:如果您想要使用值并快速测试效果,请尝试将cannonHP设置为10,这样您就可以一次性结束游戏。

Easing是一种微妙的效果,所以让我们用更明显的弹跳效果结束 - 因为反弹的东西总是更有趣!

将以下代码添加到checkGameOver(_ :)的末尾:

// label position
let y = abs(cos(CGFloat(gameOverElapsed) * 3)) * 50
gameOverLabel.position = CGPoint(x: gameOverLabel.position.x, y: size.height/2 + y)

好的,这里发生了什么? 回想一下余弦的样子:

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第15张图片

如果你取cos()的绝对值 - 使用abs() - 那么先前低于零的部分将被翻转。 曲线看起来像是反弹的东西,你不觉得吗?

因为这些函数的输出介于0.0和1.0之间,所以将它乘以50以将其拉伸到0-50cos()的参数通常是一个角度,但是你给它gameOverElapsed时间让余弦向前移动它的曲线。

因子3只是为了让它更快一点。 您可以修改这些值,直到您认为某些东西看起来很酷。

构建并运行以查看弹跳文本:

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第16张图片

您已经使用余弦的形状来描述文本标签的弹簧运动。 这些余弦对各种事物都很有用!

你可以做的最后一件事是让弹跳运动随着时间的推移而失去振幅。 您可以通过添加阻尼系数来完成此操作。 在GameScene中创建一个新属性:

var gameOverDampen: CGFloat = 0

这里的想法是当游戏结束时,您需要将此值重置为1.0,以便阻尼生效。 随着文本反弹,随着时间的推移,阻尼将再次慢慢淡化为0。

checkGameOver(_ :)中,将gameOver设置为true后添加以下内容:

gameOverDampen = 1

用以下内容替换// label position下面的代码:

let y = abs(cos(CGFloat(gameOverElapsed) * 3)) * 50 * gameOverDampen
gameOverDampen = max(0, gameOverDampen - 0.3 * CGFloat(dt))
gameOverLabel.position = CGPoint(x: gameOverLabel.position.x, y: size.height/2 + y)

它与以前大致相同。 您将y值乘以阻尼系数。 然后,将阻尼系数从1.0缓慢降低到0.0,但不要小于0。这就是max()所实现的。 构建并运行,然后尝试一下!


源码

1. Swift

看一下代码文档结构

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第17张图片

看一下sb中的内容

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第18张图片

下面看一下源码

1. GameScene.swift
import SpriteKit
import CoreMotion

let darkenOpacity: CGFloat = 0.8
let darkenDuration: CFTimeInterval = 2
let playerMissileSpeed: CGFloat = 300

let degreesToRadians = CGFloat.pi / 180
let radiansToDegrees = 180 / CGFloat.pi

let maxPlayerAcceleration: CGFloat = 400
let maxPlayerSpeed: CGFloat = 200
let borderCollisionDamping: CGFloat = 0.4
let maxHealth = 100
let healthBarWidth: CGFloat = 40
let healthBarHeight: CGFloat = 4
let cannonCollisionRadius: CGFloat = 20
let playerCollisionRadius: CGFloat = 10
let collisionDamping: CGFloat = 0.8
let playerCollisionSpin: CGFloat = 180
let playerMissileRadius: CGFloat = 20

let orbiterSpeed: CGFloat = 120
let orbiterRadius: CGFloat = 60
let orbiterCollisionRadius: CGFloat = 20

class GameScene: SKScene {
  
  lazy var darkenLayer: SKSpriteNode = {
    let color = UIColor(red: 0, green: 0, blue: 0, alpha: 1)
    let node = SKSpriteNode(color: color, size: size)
    node.alpha = 0
    node.position = CGPoint(x: size.width/2, y: size.height/2)
    return node
  }()
  
  lazy var gameOverLabel: SKLabelNode = {
    let node = SKLabelNode(fontNamed: "Helvetica")
    node.fontSize = 24
    node.position = CGPoint(x: size.width/2 + 0.5, y: size.height/2 + 50)
    return node
  }()
  
  var gameOver = false
  var gameOverElapsed: CFTimeInterval = 0
  var gameOverDampen: CGFloat = 0
  
  var accelerometerX: UIAccelerationValue = 0
  var accelerometerY: UIAccelerationValue = 0
  var playerAcceleration = CGVector(dx: 0, dy: 0)
  var playerVelocity = CGVector(dx: 0, dy: 0)
  var lastUpdateTime: CFTimeInterval = 0
  var playerAngle: CGFloat = 0
  var previousAngle: CGFloat = 0
  let playerHealthBar = SKSpriteNode()
  let cannonHealthBar = SKSpriteNode()
  var playerHP = maxHealth
  var cannonHP = maxHealth
  var playerSpin: CGFloat = 0
  
  let playerSprite = SKSpriteNode(imageNamed: "Player")
  let cannonSprite = SKSpriteNode(imageNamed: "Cannon")
  let turretSprite = SKSpriteNode(imageNamed: "Turret")
  let playerMissileSprite = SKSpriteNode(imageNamed:"PlayerMissile")
  let orbiterSprite = SKSpriteNode(imageNamed:"Asteroid")
  
  let collisionSound = SKAction.playSoundFileNamed("Collision.wav", waitForCompletion: false)
  let missileShootSound = SKAction.playSoundFileNamed("Shoot.wav", waitForCompletion: false)
  let missileHitSound = SKAction.playSoundFileNamed("Hit.wav", waitForCompletion: false)
  
  let motionManager = CMMotionManager()
  var orbiterAngle: CGFloat = 0
  
  var touchLocation = CGPoint.zero
  var touchTime: CFTimeInterval = 0

  override func didMove(to view: SKView) {
    // set scene size to match view
    size = view.bounds.size
    
    backgroundColor = SKColor(red: 94.0/255, green: 63.0/255, blue: 107.0/255, alpha: 1)
    
    cannonSprite.position = CGPoint(x: size.width/2, y: size.height/2)
    addChild(cannonSprite)
    
    turretSprite.position = CGPoint(x: size.width/2, y: size.height/2)
    addChild(turretSprite)
    
    playerSprite.position = CGPoint(x: size.width - 50, y: 60)
    addChild(playerSprite)
    
    addChild(playerHealthBar)
    
    addChild(cannonHealthBar)
    
    cannonHealthBar.position = CGPoint(
      x: cannonSprite.position.x,
      y: cannonSprite.position.y - cannonSprite.size.height/2 - 10
    )
    
    updateHealthBar(playerHealthBar, withHealthPoints: playerHP)
    updateHealthBar(cannonHealthBar, withHealthPoints: cannonHP)
    
    addChild(orbiterSprite)
    
    startMonitoringAcceleration()
    
    playerMissileSprite.isHidden = true
    addChild(playerMissileSprite)
  }
  
  override func update(_ currentTime: TimeInterval) {
    // to compute velocities we need delta time to multiply by points per second
    // SpriteKit returns the currentTime, delta is computed as last called time - currentTime
    let deltaTime = max(1.0/30, currentTime - lastUpdateTime)
    lastUpdateTime = currentTime
    
    updatePlayerAccelerationFromMotionManager()
    updatePlayer(deltaTime)
    updateTurret(deltaTime)
    checkShipCannonCollision()
    checkMissileCannonCollision()
    updateOrbiter(deltaTime)
    checkMissileOrbiterCollision()
    checkGameOver(deltaTime)
  }
  
  func startMonitoringAcceleration() {
    guard motionManager.isAccelerometerAvailable else { return }
    motionManager.startAccelerometerUpdates()
    print("accelerometer updates on...")
  }
  
  func stopMonitoringAcceleration() {
    guard motionManager.isAccelerometerAvailable else { return }
    motionManager.stopAccelerometerUpdates()
    print("accelerometer updates off...")
  }
  
  func updatePlayerAccelerationFromMotionManager() {
    guard let acceleration = motionManager.accelerometerData?.acceleration else { return }
    let filterFactor = 0.75
    
    accelerometerX = acceleration.x * filterFactor + accelerometerX * (1 - filterFactor)
    accelerometerY = acceleration.y * filterFactor + accelerometerY * (1 - filterFactor)
    
    playerAcceleration.dx = CGFloat(accelerometerY) * -maxPlayerAcceleration
    playerAcceleration.dy = CGFloat(accelerometerX) * maxPlayerAcceleration
  }

  func updatePlayer(_ dt: CFTimeInterval) {
    playerVelocity.dx = playerVelocity.dx + playerAcceleration.dx * CGFloat(dt)
    playerVelocity.dy = playerVelocity.dy + playerAcceleration.dy * CGFloat(dt)
    
    playerVelocity.dx = max(-maxPlayerSpeed, min(maxPlayerSpeed, playerVelocity.dx))
    playerVelocity.dy = max(-maxPlayerSpeed, min(maxPlayerSpeed, playerVelocity.dy))
    
    var newX = playerSprite.position.x + playerVelocity.dx * CGFloat(dt)
    var newY = playerSprite.position.y + playerVelocity.dy * CGFloat(dt)
    
    var collidedWithVerticalBorder = false
    var collidedWithHorizontalBorder = false
    
    if newX < 0 {
      newX = 0
      collidedWithVerticalBorder = true
    } else if newX > size.width {
      newX = size.width
      collidedWithVerticalBorder = true
    }
    
    if newY < 0 {
      newY = 0
      collidedWithHorizontalBorder = true
    } else if newY > size.height {
      newY = size.height
      collidedWithHorizontalBorder = true
    }
    
    if collidedWithVerticalBorder {
      playerAcceleration.dx = -playerAcceleration.dx * borderCollisionDamping
      playerVelocity.dx = -playerVelocity.dx * borderCollisionDamping
      playerAcceleration.dy = playerAcceleration.dy * borderCollisionDamping
      playerVelocity.dy = playerVelocity.dy * borderCollisionDamping
    }
    
    if collidedWithHorizontalBorder {
      playerAcceleration.dx = playerAcceleration.dx * borderCollisionDamping
      playerVelocity.dx = playerVelocity.dx * borderCollisionDamping
      playerAcceleration.dy = -playerAcceleration.dy * borderCollisionDamping
      playerVelocity.dy = -playerVelocity.dy * borderCollisionDamping
    }
    
    playerSprite.position = CGPoint(x: newX, y: newY)
    
    let rotationThreshold: CGFloat = 40
    let rotationBlendFactor: CGFloat = 0.2

    let speed = sqrt(playerVelocity.dx * playerVelocity.dx + playerVelocity.dy * playerVelocity.dy)
    if speed > rotationThreshold {
      let angle = atan2(playerVelocity.dy, playerVelocity.dx)
      
      // did angle flip from +π to -π, or -π to +π?
      if angle - previousAngle > CGFloat.pi {
        playerAngle += 2 * CGFloat.pi
      } else if previousAngle - angle > CGFloat.pi {
        playerAngle -= 2 * CGFloat.pi
      }
      
      previousAngle = angle
      playerAngle = angle * rotationBlendFactor + playerAngle * (1 - rotationBlendFactor)
      
      if playerSpin > 0 {
        playerAngle += playerSpin * degreesToRadians
        previousAngle = playerAngle
        playerSpin -= playerCollisionSpin * CGFloat(dt)
        if playerSpin < 0 {
          playerSpin = 0
        }
      }
      
      playerSprite.zRotation = playerAngle - 90 * degreesToRadians
    }
    
    playerHealthBar.position = CGPoint(
      x: playerSprite.position.x,
      y: playerSprite.position.y - playerSprite.size.height/2 - 15
    )
  }
  
  func updateTurret(_ dt: CFTimeInterval) {
    let deltaX = playerSprite.position.x - turretSprite.position.x
    let deltaY = playerSprite.position.y - turretSprite.position.y
    let angle = atan2(deltaY, deltaX)
    
    turretSprite.zRotation = angle - 90 * degreesToRadians
  }
  
  func updateHealthBar(_ node: SKSpriteNode, withHealthPoints hp: Int) {
    let barSize = CGSize(width: healthBarWidth, height: healthBarHeight);
    
    let fillColor = UIColor(red: 113.0/255, green: 202.0/255, blue: 53.0/255, alpha:1)
    let borderColor = UIColor(red: 35.0/255, green: 28.0/255, blue: 40.0/255, alpha:1)
    
    // create drawing context
    UIGraphicsBeginImageContextWithOptions(barSize, false, 0)
    guard let context = UIGraphicsGetCurrentContext() else { return }
    
    // draw the outline for the health bar
    borderColor.setStroke()
    let borderRect = CGRect(origin: CGPoint.zero, size: barSize)
    context.stroke(borderRect, width: 1)
    
    // draw the health bar with a colored rectangle
    fillColor.setFill()
    let barWidth = (barSize.width - 1) * CGFloat(hp) / CGFloat(maxHealth)
    let barRect = CGRect(x: 0.5, y: 0.5, width: barWidth, height: barSize.height - 1)
    context.fill(barRect)
    
    // extract image
    guard let spriteImage = UIGraphicsGetImageFromCurrentImageContext() else { return }
    UIGraphicsEndImageContext()
    
    // set sprite texture and size
    node.texture = SKTexture(image: spriteImage)
    node.size = barSize
  }
  
  func checkShipCannonCollision() {
    let deltaX = playerSprite.position.x - turretSprite.position.x
    let deltaY = playerSprite.position.y - turretSprite.position.y
    
    let distance = sqrt(deltaX * deltaX + deltaY * deltaY)
    guard distance <= cannonCollisionRadius + playerCollisionRadius else { return }
    playerAcceleration.dx = -playerAcceleration.dx * collisionDamping
    playerAcceleration.dy = -playerAcceleration.dy * collisionDamping
    playerVelocity.dx = -playerVelocity.dx * collisionDamping
    playerVelocity.dy = -playerVelocity.dy * collisionDamping
    
    let offsetDistance = cannonCollisionRadius + playerCollisionRadius - distance
    let offsetX = deltaX / distance * offsetDistance
    let offsetY = deltaY / distance * offsetDistance
    playerSprite.position = CGPoint(
      x: playerSprite.position.x + offsetX,
      y: playerSprite.position.y + offsetY
    )
    
    playerSpin = playerCollisionSpin
    
    playerHP = max(0, playerHP - 20)
    cannonHP = max(0, cannonHP - 5)
    
    updateHealthBar(playerHealthBar, withHealthPoints: playerHP)
    updateHealthBar(cannonHealthBar, withHealthPoints: cannonHP)
    
    run(collisionSound)
  }
  
  override func touchesBegan(_ touches: Set, with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let location = touch.location(in: self)
    touchLocation = location
    touchTime = CACurrentMediaTime()
  }
  
  override func touchesEnded(_ touches: Set, with event: UIEvent?) {
    guard !gameOver else {
      let scene = GameScene(size: size)
      let reveal = SKTransition.flipHorizontal(withDuration: 1)
      view?.presentScene(scene, transition: reveal)
      return
    }
    
    let touchTimeThreshold: CFTimeInterval = 0.3
    let touchDistanceThreshold: CGFloat = 4
    
    guard CACurrentMediaTime() - touchTime < touchTimeThreshold,
      playerMissileSprite.isHidden,
      let touch = touches.first else { return }
    
    let location = touch.location(in: self)
    let swipe = CGVector(dx: location.x - touchLocation.x, dy: location.y - touchLocation.y)
    let swipeLength = sqrt(swipe.dx * swipe.dx + swipe.dy * swipe.dy)
    
    guard swipeLength > touchDistanceThreshold else { return }
    let angle = atan2(swipe.dy, swipe.dx)
    playerMissileSprite.zRotation = angle - 90 * degreesToRadians
    playerMissileSprite.position = playerSprite.position
    playerMissileSprite.isHidden = false
    
    //calculate vertical intersection point
    var destination1 = CGPoint.zero
    if swipe.dy > 0 {
      destination1.y = size.height + playerMissileRadius // top of screen
    } else {
      destination1.y = -playerMissileRadius // bottom of screen
    }
    destination1.x = playerSprite.position.x +
      ((destination1.y - playerSprite.position.y) / swipe.dy * swipe.dx)
    
    //calculate horizontal intersection point
    var destination2 = CGPoint.zero
    if swipe.dx > 0 {
      destination2.x = size.width + playerMissileRadius // right of screen
    } else {
      destination2.x = -playerMissileRadius // left of screen
    }
    destination2.y = playerSprite.position.y +
      ((destination2.x - playerSprite.position.x) / swipe.dx * swipe.dy)
    
    // find out which is nearer
    var destination = destination2
    if abs(destination1.x) < abs(destination2.x) || abs(destination1.y) < abs(destination2.y) {
      destination = destination1
    }
    
    // calculate distance
    let distance = sqrt(pow(destination.x - playerSprite.position.x, 2) +
      pow(destination.y - playerSprite.position.y, 2))
    
    // run the sequence of actions for the firing
    let duration = TimeInterval(distance / playerMissileSpeed)
    let missileMoveAction = SKAction.move(to: destination, duration: duration)
    playerMissileSprite.run(SKAction.sequence([missileShootSound, missileMoveAction])) {
      self.playerMissileSprite.isHidden = true
    }
  }
  
  func checkMissileCannonCollision() {
    guard !playerMissileSprite.isHidden else { return }
    let deltaX = playerMissileSprite.position.x - turretSprite.position.x
    let deltaY = playerMissileSprite.position.y - turretSprite.position.y
    
    let distance = sqrt(deltaX * deltaX + deltaY * deltaY)
    guard distance <= cannonCollisionRadius + playerMissileRadius else { return }
    
    playerMissileSprite.isHidden = true
    playerMissileSprite.removeAllActions()
    
    cannonHP = max(0, cannonHP - 10)
    updateHealthBar(cannonHealthBar, withHealthPoints: cannonHP)
    run(missileHitSound)
  }
  
  func updateOrbiter(_ dt: CFTimeInterval) {
    // 1
    orbiterAngle = (orbiterAngle + orbiterSpeed * CGFloat(dt)).truncatingRemainder(dividingBy: 360)
    
    // 2
    let x = cos(orbiterAngle * degreesToRadians) * orbiterRadius
    let y = sin(orbiterAngle * degreesToRadians) * orbiterRadius
    
    // 3
    orbiterSprite.position = CGPoint(x: cannonSprite.position.x + x, y: cannonSprite.position.y + y)
    
    orbiterSprite.zRotation = orbiterAngle * degreesToRadians
  }
  
  func checkMissileOrbiterCollision() {
    guard !playerMissileSprite.isHidden else { return }
    
    let deltaX = playerMissileSprite.position.x - orbiterSprite.position.x
    let deltaY = playerMissileSprite.position.y - orbiterSprite.position.y
    
    let distance = sqrt(deltaX * deltaX + deltaY * deltaY)
    guard distance < orbiterCollisionRadius + playerMissileRadius else { return }
    
    playerMissileSprite.isHidden = true
    playerMissileSprite.removeAllActions()
    
    orbiterSprite.setScale(2)
    orbiterSprite.run(SKAction.scale(to: 1, duration: 0.5))
  }
  
  func checkGameOver(_ dt: CFTimeInterval) {
    // 1
    guard playerHP <= 0 || cannonHP <= 0 else { return }
    
    guard gameOver else {
      gameOver = true
      gameOverDampen = 1
      gameOverElapsed = 0
      stopMonitoringAcceleration()
      
      // 3
      addChild(darkenLayer)
      
      // 4
      let text = playerHP == 0 ? "GAME OVER" : "Victory!"
      gameOverLabel.text = text
      addChild(gameOverLabel)
      return
    }
    
    // 5
    gameOverElapsed += dt
    if gameOverElapsed < darkenDuration {
      var multiplier = CGFloat(gameOverElapsed / darkenDuration)
      multiplier = sin(multiplier * CGFloat.pi / 2) // ease out
      darkenLayer.alpha = darkenOpacity * multiplier
    }
    
    // label position
    let y = abs(cos(CGFloat(gameOverElapsed) * 3)) * 50 * gameOverDampen
    gameOverDampen = max(0, gameOverDampen - 0.3 * CGFloat(dt))
    gameOverLabel.position = CGPoint(x: gameOverLabel.position.x, y: size.height/2 + y)
  }
  
  deinit {
    stopMonitoringAcceleration()
  }
}
2. GameViewController.swift
import UIKit
import SpriteKit
import GameplayKit

class GameViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        if let view = self.view as! SKView? {
            // Load the SKScene from 'GameScene.sks'
            if let scene = SKScene(fileNamed: "GameScene") {
                // Set the scale mode to scale to fit the window
                scene.scaleMode = .aspectFill
                
                // Present the scene
                view.presentScene(scene)
            }
            
            view.ignoresSiblingOrder = false
            
            view.showsFPS = true
            view.showsNodeCount = true
        }
    }

    override var shouldAutorotate: Bool {
        return true
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        if UIDevice.current.userInterfaceIdiom == .phone {
            return .allButUpsideDown
        } else {
            return .all
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Release any cached data, images, etc that aren't in use.
    }

    override var prefersStatusBarHidden: Bool {
        return true
    }
}

下面看一下实现效果

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第19张图片

后记

本篇主要讲述了基于SpriteKit的游戏编程的三角函数,感兴趣的给个赞或者关注~~~

SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二)_第20张图片

你可能感兴趣的:(SpriteKit框架详细解析(六) —— 基于SpriteKit的游戏编程的三角函数(二))