【交互篇】用Swift完成贝塞尔曲线游戏
需求说明
由于prd实在是太复杂了,全是文字看得头晕还容易遗漏掉细节,所以咱们整一个流程图。
任务拆解
乍一看是一个巨复杂的事情,但是我们对其进行拆解就会好很多。
首先,要知道它需要的各种动画、及媒体播放等功能如何实现,然后分析出在不同的游戏状态下分别应该怎么展示。
所以我的开发步骤就是先梳理出上述两点,将基础功能满足后,根据游戏状态的不同需求,将这些功能进行拼装。大事化小之后,其实也不难。
其需要达到的功能主要分为以下几点:
- 媒体处理:背景图片切换、背景音频播放
- 提示语:渐显、渐隐动画
- 贝塞尔线条:延长动画、暂停、重置
- 指示圈:自动跟踪贝塞尔曲线移动
- 手势圈:放大、缩小动画
游戏状态大致分为下面几种:
- 默认(开始游戏前,没有任何手势交互下的纯展示态)
- 准备状态
- 开始游戏
- 游戏暂停(暂停这里也很复杂,下面会细说)
- 游戏结束
功能达成
媒体处理
背景图片选择较简单,此处主要说明音频文件预解析和加载。
YYCache+Global Disk+通过二进制的 data实例化AVAudioPlayer
- 音频预加载。
在FingerFlowVC
的初次viewDidAppear
(不在viewDidLoad
是因为处理逻辑中包含需要直接播放的情况)方法中,创建DispatchGroup
- 若缓存中已有数据,则跳过
- 若缓存中没有,则进入group解析队列并存入缓存
- 解析完成后,判断
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()
}
}
- 音频播放。没有太难的需求所以简单地使用
AVAudioPlayer
播放,AVAudioSession
设置playback
模式。注意前后台切换处理音频的情况,注册对UIApplication.didBecomeActiveNotification
和UIApplication.didEnterBackgroundNotification
两个通知的监听。
Core Animatio使用
根据下方代码可以看到,都是通过给view添加CABasicAnimation
实现,对提示语的渐隐/渐显和手势圈的放大/缩小处理分别用的keyPath是"opacity"和"transform.scale"。除了传入fromValue
、toValue
和duration
这种基本属性外,还需要注意的是对isRemovedOnCompletion
的设置。
以及在所有游戏状态切换的时候,都需要先对这些view的layer先执行removeAllAnimations
移除所有动画。
- 提示语的渐隐/渐显动画。
alpha
设置为保持结果,所以appear时传true
,disappear时传false
,isRemovedOnCompletion
设置为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)
}
}
- 手势圈的放大/缩小动画。
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)
}
}
贝塞尔线条动画与指示圈跟踪
- 关于曲线的计算与绘制请见《(算法篇)用Swift完成贝塞尔曲线游戏》
- 创建一个
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)
- 线条动画。依然用
CABasicAnimation
,keyPath
为strokeEnd
。(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")
- 指示圈跟踪动画。用
CAKeyframeAnimation
,keyPath
为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")
交互状态处理
在基础动画、媒体等需求都能满足后,剩下要做的就是拆解出这个游戏有哪些状态,以及分别应该对视图和动画做怎样的处理了。
所以再拆解一下,只需要解决这几个问题
- 有哪些游戏状态?
- 什么情况下会切换游戏状态?
- 不同的游戏状态下需要进行什么操作?
有哪些游戏状态?
我定了下面几个枚举,当时写的有点匆忙,现在看来感觉还是有点冗余,也许再梳理一遍的话处理上可能会更简洁。
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 // 手势脱离屏幕
}
-
在没有任何交互的时候那肯定是
before
(游戏前的准备态嘛!) -
当游戏状态为
before
,手势状态为inside
时,游戏进入到preparation
-
当游戏状态为
preparation
- 手势状态不为
inside
,回到步骤1,游戏before
状态
- 手势状态不为
-
preparation
倒计时结束开始游戏,游戏状态变为start
-
当游戏状态为
start
/resumeFromPauseCountdown
/resumeFromPauseRunning
这三种表明线条正在动画运行的状态时,若手势不为inside,则开始【暂停倒计时5s】的pauseCountdown
状态 -
若在
pauseCountdown
状态中- 手势为
inside
,游戏状态变为resumeFromPauseCountdown
,回到步骤5 - 若【暂停倒计时5s】,弹窗点击"结束游戏",状态变为
end
,游戏结束 - 若【暂停倒计时5s】,弹窗点击"继续游戏",状态变为
resumeFromPauseRunning
,回到步骤5
- 手势为
不同的游戏状态下需要进行什么操作?
-
before
状态下,就是一个重置操作。- 界面上所有元素均展示
- 指示圈
guideDot.layer.**resetMoveAnimation**()
移除动画 - 其他所有包含动画的元素**
removeAllAnimations()
**移除动画 - 停止所有Timer
- 移除本次游戏曲线的计算结果
- 移除已有的曲线
- 指示圈放大/缩小动画,提示语渐隐/渐显动画
-
preparation
状态下- 界面上所有元素均执行0.3s渐隐动画
- 移除指示圈、提示语动画
- 0.3s渐隐提示语后,开始【准备倒计时3s】,倒计时文本渐显
- GCD异步计算本次游戏的路径
-
开始游戏,
start
状态- 线条绘制动画
- audioPlayer开始播放音频
- 指示圈放大/缩小动画
- 每隔15s提示语渐显动画
-
游戏中手势异常进入
pauseCountdown
状态,【暂停倒计时5s】- 手机震动
- timer倒计时5s
- 线条暂停绘制
- 指示圈缩小动画
-
5s内若手势正常,回到
resumeFromPauseCountdown
状态继续游戏- 停止震动
- 停止【暂停倒计时】timer
- 游戏线条继续绘制
-
5s内未恢复游戏状态,
pause
暂停- 停止震动
- 弹窗
-
点击继续,来到
resumeFromPauseWaiting
,表明已经恢复游戏,等待手势放进指示圈即可继续- 提示语渐隐/渐显动画
- 其他动画同
before
状态下
-
游戏结束
- audioPlayer停止播放音频
- 线条、指示圈停止动画
- 自动截屏
一点小感想
其实刚拿到任务的时候还是很头大的,但是拆解下来之后,感觉好像也没那么难。很多事情都是这样,学会任务拆解真的很重要。放平心态稳步走!