【交互篇】用Swift完成贝塞尔曲线游戏

【交互篇】用Swift完成贝塞尔曲线游戏

需求说明

由于prd实在是太复杂了,全是文字看得头晕还容易遗漏掉细节,所以咱们整一个流程图。

任务拆解

乍一看是一个巨复杂的事情,但是我们对其进行拆解就会好很多。

首先,要知道它需要的各种动画、及媒体播放等功能如何实现,然后分析出在不同的游戏状态下分别应该怎么展示。

所以我的开发步骤就是先梳理出上述两点,将基础功能满足后,根据游戏状态的不同需求,将这些功能进行拼装。大事化小之后,其实也不难。

其需要达到的功能主要分为以下几点:

  1. 媒体处理:背景图片切换、背景音频播放
  2. 提示语:渐显、渐隐动画
  3. 贝塞尔线条:延长动画、暂停、重置
  4. 指示圈:自动跟踪贝塞尔曲线移动
  5. 手势圈:放大、缩小动画

游戏状态大致分为下面几种:

  1. 默认(开始游戏前,没有任何手势交互下的纯展示态)
  2. 准备状态
  3. 开始游戏
  4. 游戏暂停(暂停这里也很复杂,下面会细说)
  5. 游戏结束

功能达成

媒体处理

背景图片选择较简单,此处主要说明音频文件预解析和加载。

YYCache+Global Disk+通过二进制的 data实例化AVAudioPlayer

  1. 音频预加载。

FingerFlowVC的初次viewDidAppear(不在viewDidLoad是因为处理逻辑中包含需要直接播放的情况)方法中,创建DispatchGroup

  1. 若缓存中已有数据,则跳过
  2. 若缓存中没有,则进入group解析队列并存入缓存
  3. 解析完成后,判断needToPlay,若为true则直接播放
swift 复制代码
func prepareAudioDataGroup() {
    let workingGroup = DispatchGroup()
    let workingQueue = DispatchQueue(label: "fingerflow.audio")

    for urlString in musilUrlList {
			// 若缓存中已有数据,则跳过
      guard AppManager.shared.globalDiskCache.getData(for: urlString) == nil,
            let audioURL = URL(string: urlString) else {
        continue
      }
      workingGroup.enter()
      workingQueue.async {
        do { // 若缓存中没有,则进入group解析队列并存入缓存
          let newData = try Data(contentsOf: audioURL)
          AppManager.shared.globalDiskCache.setData(newData,
                                                    for: urlString)
					workingGroup.leave()
        } catch (let error) {
					workingGroup.leave()
        }
      }
    }

    workingGroup.notify(queue: workingQueue) { [weak self] in
      guard let self = self, self.needToPlay else {
        return
      } // 解析完成后,判断needToPlay,若为true则直接播放
      self.prepareAudio(self.currentMusic)
      self.audioPlayer?.play()
    }
  }
  1. 音频播放。没有太难的需求所以简单地使用AVAudioPlayer播放,AVAudioSession设置playback模式。注意前后台切换处理音频的情况,注册对UIApplication.didBecomeActiveNotificationUIApplication.didEnterBackgroundNotification两个通知的监听。

Core Animatio使用

根据下方代码可以看到,都是通过给view添加CABasicAnimation实现,对提示语的渐隐/渐显和手势圈的放大/缩小处理分别用的keyPath是"opacity"和"transform.scale"。除了传入fromValuetoValueduration这种基本属性外,还需要注意的是对isRemovedOnCompletion的设置。

以及在所有游戏状态切换的时候,都需要先对这些view的layer先执行removeAllAnimations移除所有动画。

  1. 提示语的渐隐/渐显动画。alpha设置为保持结果,所以appear时传true,disappear时传falseisRemovedOnCompletion设置为true。因为需要提示语是一个渐隐-渐显-渐隐-渐显的循环效果。
ini 复制代码
extension UIView {
	// 渐显示例代码。
  func animateAppear(fromValue: Float = 0,
                     toValue: Float = 1,
                     duration: CFTimeInterval = 0.3,
                     key: String? = nil,
                     keepResult: Bool = true) {
    alpha = keepResult ? 1 : 0
    let animation = CABasicAnimation(keyPath: "opacity")
    animation.fromValue = fromValue
    animation.toValue = toValue
    animation.duration = duration
    animation.isRemovedOnCompletion = true
    animation.fillMode = .forwards
    layer.add(animation, forKey: key)
  }

  func animateDisappear(fromValue: Float = 1,
                        toValue: Float = 0,
                        duration: CFTimeInterval = 0.3,
                        key: String? = nil,
                        keepResult: Bool = true) {
    alpha = keepResult ? 0 : 1
    let animation = CABasicAnimation(keyPath: "opacity")
    animation.fromValue = fromValue
    animation.toValue = toValue
    animation.duration = duration
    animation.isRemovedOnCompletion = true
    animation.fillMode = .forwards
    layer.add(animation, forKey: key)
  }   
}
  1. 手势圈的放大/缩小动画。isRemovedOnCompletion需要传false,避免退后台时动画会被停止!
ini 复制代码
extension UIView {
  func animateScaleOut(fromValue: CGFloat = 0,
                        toValue: CGFloat = 1,
                        duration: CFTimeInterval = 0.3,
                        key: String? = nil) {
    let animation = CABasicAnimation(keyPath: "transform.scale")
    animation.fromValue = fromValue
    animation.toValue = toValue
    animation.duration = duration
    animation.isRemovedOnCompletion = false
    animation.fillMode = .forwards
    layer.add(animation, forKey: key)
  }

  func animateScaleIn(fromValue: CGFloat = 1,
                      toValue: CGFloat = 0,
                      duration: CFTimeInterval = 0.3,
                      key: String? = nil) {
    let animation = CABasicAnimation(keyPath: "transform.scale")
    animation.fromValue = fromValue
    animation.toValue = toValue
    animation.duration = duration
    animation.isRemovedOnCompletion = false
    animation.fillMode = .forwards
    layer.add(animation, forKey: key)
  }
}

贝塞尔线条动画与指示圈跟踪

  1. 创建一个CALayer,将已计算好的UIBezierPath赋值给它的路径,将该layer添加到视图上
ini 复制代码
let gameLayer = CAShapeLayer()
gameLayer.strokeColor = ColorGuide.main.cgColor
gameLayer.fillColor = UIColor.clear.cgColor
gameLayer.lineWidth = 6.0
gameLayer.lineCap = .round
gameLayer.lineJoin = .round
gameLayer.path = progressPath.cgPath
layer.addSublayer(gameLayer)
  1. 线条动画。依然用CABasicAnimationkeyPathstrokeEnd。(strokeEnd:按照指定的动画轨迹执行动画)
ini 复制代码
// line path animate
let fromValue = startPath.cgPath.length / lengthNeededToRun
let animateStrokeEnd = CABasicAnimation(keyPath: "strokeEnd")
animateStrokeEnd.duration = duration
animateStrokeEnd.fromValue = fromValue
animateStrokeEnd.toValue = 1
animateStrokeEnd.fillMode = .forwards
animateStrokeEnd.isRemovedOnCompletion = false
gameLayer.add(animateStrokeEnd,
              forKey: "Move")
  1. 指示圈跟踪动画。用CAKeyframeAnimationkeyPath为position
ini 复制代码
// circle dot animate
let dotDuration = duration + startPath.cgPath.length / 15
let circleAnimation = CAKeyframeAnimation(keyPath:"position")
circleAnimation.duration = dotDuration
circleAnimation.path = gamePath
circleAnimation.calculationMode = .paced
circleAnimation.isRemovedOnCompletion = false
circleAnimation.fillMode = .forwards

self.guideDot.layer.add(circleAnimation, forKey:"Move")

交互状态处理

在基础动画、媒体等需求都能满足后,剩下要做的就是拆解出这个游戏有哪些状态,以及分别应该对视图和动画做怎样的处理了。

所以再拆解一下,只需要解决这几个问题

  1. 有哪些游戏状态?
  2. 什么情况下会切换游戏状态?
  3. 不同的游戏状态下需要进行什么操作?

有哪些游戏状态?

我定了下面几个枚举,当时写的有点匆忙,现在看来感觉还是有点冗余,也许再梳理一遍的话处理上可能会更简洁。

arduino 复制代码
enum FingerFlowState: String {
  case before // 指示动画循环播放
  case preparation // 【游戏倒计时3s】,动画
  case start // 开始动画
  case pauseCountdown // 手势异常,进入【暂停倒计时5s】
  case pause // 【暂停倒计时】结束,依然是手势异常状态,暂停游戏并弹窗
  case resumeFromPauseCountdown // 在【暂停倒计时】期间,手势结束异常,继续游戏
  case resumeFromPauseWaiting // 在暂停状态下,点击弹窗上的继续游戏,回到游戏(暂无线条动画)
  case resumeFromPauseRunning // 从上面一个状态点击继续游戏回到游戏后,手势放回到指示圈内,继续游戏
  case end // 游戏结束

  var isRunning: Bool {
    return [FingerFlowState.resumeFromPauseRunning,
            FingerFlowState.start,
            FingerFlowState.resumeFromPauseCountdown].contains(self)
  }
}

什么情况下会切换游戏状态?

手势有如下三种情况

arduino 复制代码
enum FingerFlowPressState {
  case inside // 手势在指示圈中
	case outside // 手势在指示圈外
	case none  // 手势脱离屏幕
}
  1. 在没有任何交互的时候那肯定是before(游戏前的准备态嘛!)

  2. 当游戏状态为before,手势状态为inside时,游戏进入到preparation

  3. 当游戏状态为preparation

    1. 手势状态不为inside,回到步骤1,游戏before状态
  4. preparation倒计时结束开始游戏,游戏状态变为start

  5. 当游戏状态为start/resumeFromPauseCountdown/resumeFromPauseRunning这三种表明线条正在动画运行的状态时,若手势不为inside,则开始【暂停倒计时5s】的pauseCountdown状态

  6. 若在pauseCountdown状态中

    1. 手势为inside,游戏状态变为resumeFromPauseCountdown,回到步骤5
    2. 若【暂停倒计时5s】,弹窗点击"结束游戏",状态变为end,游戏结束
    3. 若【暂停倒计时5s】,弹窗点击"继续游戏",状态变为resumeFromPauseRunning,回到步骤5

不同的游戏状态下需要进行什么操作?

  1. before状态下,就是一个重置操作。

    1. 界面上所有元素均展示
    2. 指示圈guideDot.layer.**resetMoveAnimation**()移除动画
    3. 其他所有包含动画的元素**removeAllAnimations()**移除动画
    4. 停止所有Timer
    5. 移除本次游戏曲线的计算结果
    6. 移除已有的曲线
    7. 指示圈放大/缩小动画,提示语渐隐/渐显动画
  2. preparation状态下

    1. 界面上所有元素均执行0.3s渐隐动画
    2. 移除指示圈、提示语动画
    3. 0.3s渐隐提示语后,开始【准备倒计时3s】,倒计时文本渐显
    4. GCD异步计算本次游戏的路径
  3. 开始游戏,start状态

    1. 线条绘制动画
    2. audioPlayer开始播放音频
    3. 指示圈放大/缩小动画
    4. 每隔15s提示语渐显动画
  4. 游戏中手势异常进入pauseCountdown状态,【暂停倒计时5s】

    1. 手机震动
    2. timer倒计时5s
    3. 线条暂停绘制
    4. 指示圈缩小动画
  5. 5s内若手势正常,回到resumeFromPauseCountdown状态继续游戏

    1. 停止震动
    2. 停止【暂停倒计时】timer
    3. 游戏线条继续绘制
  6. 5s内未恢复游戏状态,pause暂停

    1. 停止震动
    2. 弹窗
  7. 点击继续,来到resumeFromPauseWaiting,表明已经恢复游戏,等待手势放进指示圈即可继续

    1. 提示语渐隐/渐显动画
    2. 其他动画同before状态下
  8. 游戏结束

    1. audioPlayer停止播放音频
    2. 线条、指示圈停止动画
    3. 自动截屏

一点小感想

其实刚拿到任务的时候还是很头大的,但是拆解下来之后,感觉好像也没那么难。很多事情都是这样,学会任务拆解真的很重要。放平心态稳步走!

相关推荐
iOS民工1 小时前
iOS SSZipArchive 解压后 中文文件名乱码问题
ios
皮蛋很白5 小时前
IOS safari 播放 mp4 遇到的坎儿
前端·ios·safari·video.js
江上清风山间明月20 小时前
Flutter DragTarget拖拽控件详解
android·flutter·ios·拖拽·dragtarget
Kaelinda1 天前
iOS开发代码块-OC版
ios·xcode·oc
ii_best2 天前
ios按键精灵自动化的脚本教程:自动点赞功能的实现
运维·ios·自动化
app开发工程师V帅2 天前
iOS 苹果开发者账号: 查看和添加设备UUID 及设备数量
ios
CodeCreator18182 天前
iOS AccentColor 和 Color Set
ios
iOS民工2 天前
iOS keychain
ios
m0_748238922 天前
webgis入门实战案例——智慧校园
开发语言·ios·swift
Legendary_0083 天前
LDR6020在iPad一体式键盘的创新应用
ios·计算机外设·ipad