开源项目Swift-2048学习、分析
[TOC]
这篇博客写了什么?
刚开始使用swift编写ios程序,花了两三天的时间看了下《The Swift Programming Language》,看了就忘了o(╯□╰)o,于是干脆从一个项目入手,一边开发一边学习。github上面找了一个挺有名的开源项目[swift-2048][id],经过一天半的"刻苦"学习,终于略有小成。
[id]: https://github.com/austinzheng/swift-2048 "gitHub"
项目结构
除去xocde7自动生成的一些文件外,austinzheng(2048项目作者),一共就使用7个.swift文件完成了整个项目,让本人第一感觉这个项目挺简单的。接下来就简单的介绍一下每一个文件的大概用处。
-
GameModel
全工程最庞大的一个文件,在models文件夹下,这个文件主要是算法的实现(移动合并算法),虽然说是model文件后缀,但是本人实在想不到这个文件和MVC中的model有什么关系。
-
AuxiliaryModels
里面定义着本项目用到的所有的、用户自定义的结构体与枚举
-
AccessoryViews
本文件里面定义着代表分数的view
-
GameboardView
和文件名称一样,这个文件是游戏的主要面板也就是下面这个
-
TileView
文件名也出卖了它,这个让用户看起来就是2048游戏中的那些可以移动的数字。
-
NumberTileGame
游戏的主要控制器,几乎所有的逻辑均在这里处理
-
AppearanceProvider
项目辅助功能,它决定着游戏中数字以及TileView的颜色
代码分析
以文件为单位,对2048项目进行一个简单的分析。
TileView.swift
2048项目里面较为简单的一个文件。TileView也就是2048中可以移动的方块
-
value 属性
这个属性代表着一个tileView上面所显示的分数的数值,并设置了一个属性观察器didSet,这个属性观察器在每一次value属性被设置新值的时候调用。目的是为了在每一次值改变的时候给TileView的背景颜色、文字颜色、以及TileView上显示的Label数值,这给3个属性重新赋值(2048游戏中,TIleVIew数值的不同,颜色是不一样的,2,4,8的颜色都不一样)
-
delegate 属性
遵守AppearanceProviderProtocol协议的代理,主要用于更改颜色
-
numberLabel 属性
TileView上每个数字都是一个Label
-
构造方法
无疑是对TileView本身进行一些初始化,包括本身的大小以及UILabel的创建之类
AccessoryViews.swift
前面项目结构以及分析了,这个文件主要负责获得分数的显示.这个文件也十分的简单,里面主要的类就是 ScoreView它遵守了ScoreViewProtocol协议。因为简单,所以不过多解释.
-
score 属性
游戏总共的得分,同样也有一个didSet属性观察器,再每次score属性发生改变的时候,更新Label的显示
scoreChanged 方法
在每一次分数改变的时候调用,用于跟新分数显示(个人感觉这个方法和协议写的有点多余)
AppearanceProvider.swift
一个辅助用的,主要用于TileView颜色的控制,简单不多解释.(没有这个文件提供的功能,项目一样可以跑,只是丑点)
GameboardView.swift
一个稍稍复杂的文件,代表游戏面板的view,也就是下面这个黑框框(十分明显的九宫格布局)。当然还实现了一些对TileView的移动、插入等操作。接下来只解释一些主要属性。
tiles 属性
一个Dictionary<NSIndexPath, TileView>类型的数据,其实就是OC里面的一个字典,只是key换成了NSIndexPath类型,存储的value是TileView。所以这个字典里面存储的是整个游戏中,所有的TIleView,至于使用NSIndexPath做为key的目的很明显,能够使用类似于(1,3)的方式便捷取出任意一个TileView。provider 属性
控制颜色用的。。仅仅在创建TIleView的时候作为参赛传入即可xxxTime xxScale
一系列以这种方式结尾的属性,都是用来控制动画用的。。直接忽视它们吧。。。构造方法
构造方法除了对属性的一些初始化,最主要的任务就是创建上图的那个九宫格黑框框,调用的方法便是setupBackgroundsetupBackground 方法
十分经典的九宫格布局的创建方法。。也没啥可说的,(啥?你不知道什么是九宫格布局?)-
insertTile 方法
这个就是创建TileView并且显示的方法,方法接收(pos: (Int, Int), value: Int)2个参数,pos一个元组类型的数据,表示TileView应该插入的位置,Value便是值咯。创建完后利用下面这句话添加进入tiles字典(这下知道为什么tiles类型会是<NSIndexPath, TileView>了吧)tiles[NSIndexPath(forRow: row, inSection: col)] = tile
-
moveOneTile 方法
从名字就可以知道,这是移动一个tileView的方法,参数from、to、value的意义也十分明了。整个过程就是,首先利用from取出需要移动的TileView,然后根据to参数计算出目标位置的x,y。
func moveOneTile(from: (Int, Int), to: (Int, Int), value: Int) { ... //这里是计算目标位置的x,y finalFrame.origin.x = tilePadding + CGFloat(toCol)*(tileWidth + tilePadding) finalFrame.origin.y = tilePadding + CGFloat(toRow)*(tileWidth + tilePadding) // 将原来的TileView从tiles中剔除 tiles.removeValueForKey(fromKey) // 将拥有新位置的tileView重新放入tiles字典 tiles[toKey] = tile // 到这里,内部逻辑已经将tile平移,但是界面没有显示 // Animate ...//一些动画效果,设置需要移动tile的frame和数值 }
-
moveTwoTiles 方法
功能几乎和moveOneTile类似,只是它的from有两个来源。为什么会有两个来源?想一想tileView的合并。两个TileView合并过程应该是这样的。
|[][2][][2]| ==>向左滑动 |[2][2][][]| ==> |[4][][][]|
所以是先移动,再进行合并!
整个的逻辑是这样1 根据from取出2个需要移动的tile
2 根据to参数计算出需要移动的位置(2个同时都是移动到那个位置,动画给人一种合并的效果)
3 将2个tile从tiles字典中移除,并将其中一个(总是里to这个位置最近的那个)tiles添加进tiles字典
4 在动画中修改两个tiles的frame(营造动画效果)
5 将其中一个从GameboadView中移除,设置另外一个tileView新的value数值。
到达这里,完成平移与合并操作。
NumberTileGame.swift
这个文件就是本项目最主要的一个视图控制器的实现。处理着绝大部分的逻辑。
-
model 属性
这个属性稍后会在GameModel.swift中进行详细介绍,这里先有个初步了解,它处理游戏中平移与合并算法。2048游戏最重要也是最难的便是它的平移和移动算法的实现。
-
其他属性
其他的属性几乎都是前面介绍过的。
-
构造方法
构造方法中对几个属性进行了初始化,并调用setupSwipeControls方法添加了4个手势识别器。
-
setupGame 方法
由ViewDidLoad调用,用来对游戏界面进行初始化(创建显示分数的ScoreView和游戏面板GameboardView)。值得一提的便是其中的两个用于计算x,y的内嵌方法。xPositionToCenterView与yPositionForViewAtPosition。首先得明白,游戏中"大"的view就只有显示总分数的scoreView和游戏面板GameboardView。而这两个view的位置关系总是scoreView在上GameboardView在下,并且两个view都是居于屏幕最中央(水平且垂直居中)。自己可以换几个不同屏幕大小试一试。而让他们位置有自适应的能力的便就是xPositionToCenterView与yPositionForViewAtPosition两个内嵌方法
func xPositionToCenterView(v: UIView) -> CGFloat {
let viewWidth = v.bounds.size.width
let tentativeX = 0.5*(vcWidth - viewWidth)
return tentativeX >= 0 ? tentativeX : 0
}
这个方法还不算复杂,其目的是:计算出能使传入参数v这个view,在控制器中居中显示的x的值。
func yPositionForViewAtPosition(order: Int, views: [UIView]) -> CGFloat {
...
//所有控件高度之和(包括间距),views.map({ $0.bounds.size.height })将所有view的高度取出,然后通过.reduce对所有高度进行求和。
let totalHeight = CGFloat(views.count - 1)*viewPadding + views.map({ $0.bounds.size.height }).reduce(verticalViewOffset, combine: { $0 + $1 })
// 这便是计算出来的views整体的起点y
let viewsTop = 0.5*(vcHeight - totalHeight) >= 0 ? 0.5*(vcHeight - totalHeight) : 0
// 然后根据order,数值0,代表第一个view也就是最上面的;数值1就是第二个view(本项目一共只有2个view所以也就是最下面的view)计算出任意一个view的y值
var acc: CGFloat = 0
for i in 0..<order {
acc += viewPadding + views[i].bounds.size.height
}
return viewsTop + acc
}
yPositionForViewAtPosition就比较复杂了。前面已经说明这两个方法是为了让两个view居中显示。yPositionForViewAtPosition就是为了找到能让任意一个view垂直居中的y值。因为在垂直面上有多个view(这里是2个),所以单独凭借一个view是无法计算的,必须把所有的view都传进来,再根据所有view的高度和计算出scoreView应该距离顶部的位置或者GameboardView距离底部的位置。
在scoreVIew与GameboardView创建完后,调用insertTileAtRandomLocation插入2个tileVIew结束。
-
followUp 方法
在每一次成功移动tileView后调用,判定游戏是否结束,如果没有结束随机生成一个tileview
-
upCommand 方法
手势识别器的监听方法,当上划操作时候调用。这里调用GameModel的queueMove方法,进行移动操作(稍后会有解释)。其他Command方法几乎一样
-
一堆代理方法
基本都是调用其他文件内实现的方法.
GameModel
终于来到2048核心所在!先来简单的看一下所包含的属性。
-
gameboard 属性
它代表的是游戏的逻辑面板,为什么是逻辑面板?在前面已经有一个GameboardView这是一个实际的能人用户看到的游戏面板,空的就是黑黑的框,有数值的就能看到一个个2、4、8之类的数字。这些都是呈现给用户看的。而我们实际进行计算的是在gameboard这个逻辑面板中,定义如下.
struct SquareGameboard<T> {
let dimension : Int // 面板大小
var boardArray : [T] // 这里是存储TileObject类型的数组
init(dimension d: Int, initialValue: T) {
dimension = d
boardArray = [T](count:d*d, repeatedValue:initialValue)
}
//下标脚本,这样能够快速访问到boardArray任意一个元素(gameboard[0][1])
subscript(row: Int, col: Int) -> T {
get {
assert(row >= 0 && row < dimension)
assert(col >= 0 && col < dimension)
return boardArray[row*dimension + col]
}
set {
assert(row >= 0 && row < dimension)
assert(col >= 0 && col < dimension)
boardArray[row*dimension + col] = newValue
}
}
...
}
附上gameboard与GameboardView关系图一张
-
queue 属性
一个装MoveCommand枚举的数组,这个枚举的意思是:direction这次滑动是哪个方向,completion以及tileView移动完成后需要做些什么。这里定义成一个数组。然而实际这个数组长度是不可能超过1的。。
-
timer 属性
一个定时器,目的是为了不让因为手指滑动过快而导致tileView过快的移动(实际也没有什么用。。因为你的手速是一般达不到那么快的)
-
queueMove 方法
在NumberTileGame.swift已经提及到,一旦用进行滑动操作,便会调用这个方法。首先先将操作放入quenen数组,然后在看有没有定时器在运行,没有就调用timerFired方法.在属性解释的时候已经说过quenen以及定时器的作用(其实并没什么作用)。
-
timerFired 方法
进行一些无聊的判断后调用performMove准备进行移动!当然如果有发生移动,那么得重新启动定时器.
-
performMove 方法
重点终于来了!一进来就看到一个庞大的闭包
//闭包接收一个整数作为参数,并且返回一个[(int),(int)]装有元组的数组 let coordinateGenerator: (Int) -> [(Int, Int)] = { (iteration: Int) -> [(Int, Int)] in var buffer = Array<(Int, Int)>(count:self.dimension, repeatedValue: (0, 0)) for i in 0..<self.dimension { switch direction { case .Up: buffer[i] = (i, iteration) case .Down: buffer[i] = (self.dimension - i - 1, iteration) case .Left: buffer[i] = (iteration, i) case .Right: buffer[i] = (iteration, self.dimension - i - 1) } } return buffer }
这个方法目的是:根据滑动方向的不同,返回对应的滑动顺序。看不懂?没事,看下面的图解。
接着继续往下走,下面的代码就是把取出来准备进行移动计算的每一列(行),根据其(Int, Int)类型的数据,取出gameboard对应的每一项TIleObject。
// coords数组存放的顺序也就是移动的顺序
let tiles = coords.map() { (c: (Int, Int)) -> TileObject in
let (x, y) = c
return self.gameboard[x, y]
}
提供一个思考方式
每当获得tiles这个需要的移动的逻辑tiles,便会开始执行合并操作--调用merge方法.这个方法返回bool类型,只有发生移动/合并操作才会返回true.读到这里,笔者推荐先去看看下文介绍的merge方法的实现再继续看下面的解释。
读到这里,笔者默认你已经读完下面解释merge三个步骤
任然接着算法第三步返回的例子,返回值我还记得是【SingleMoveOrder(1, 0,4,ture),SingleMoveOrder(2,1,2,false)】
接着对返回的数据(操作)进行处理,项目使用的是forin的循环。请看代码注释(先感叹一下,好巧妙的映射方式)
for object in orders {
switch object {
case let MoveOrder.SingleMoveOrder(s, d, v, wasMerge):
// 是不是已经忘记coords里面是什么了?同样在map映射解释那有写哦
let (sx, sy) = coords[s]//针对我们的例子这里的值是[(1,3),1,2),1,1),1,0)]。 我们算出来都是2个SingleMoveOrder类型,这里分析合并情况即s=1,所以取出来的是(1,2)
let (dx, dy) = coords[d] //这里取出来的是(1,3)
if wasMerge {
score += v //这个是总得分,这里会触发属性观察器从而调用代理
}
gameboard[sx, sy] = TileObject.Empty // 设置(跟新)逻辑面板状态
gameboard[dx, dy] = TileObject.Tile(v)// 设置(跟新)逻辑面板状态
//到这里,逻辑面板已经移动完毕,接下来就是改变UI了,所以调用下面的方法。这个方法在GameboardView.swift中实现.
delegate.moveOneTile(coords[s], to: coords[d], value: v)
case let MoveOrder.DoubleMoveOrder(s1, s2, d, v):
// Perform a simultaneous two-tile move
let (s1x, s1y) = coords[s1]
let (s2x, s2y) = coords[s2]
let (dx, dy) = coords[d]
score += v
gameboard[s1x, s1y] = TileObject.Empty
gameboard[s2x, s2y] = TileObject.Empty
gameboard[dx, dy] = TileObject.Tile(v)
delegate.moveTwoTiles((coords[s1], coords[s2]), to: coords[d], value: v)
}
}
SingleMoveOrder与DoubleMoveOrder处理上几乎一致,所以继续分析。到这里,整个项目几乎已经分析完。项目中最难理解的一个是合并算法,另外一个笔者便认为是项目作者设计的一种巧妙的映射方式,最后附上全部映射关系图一张.
-
merge 方法
2048的作者已经将算法分为三步来实现,分别对应3个方法condense、collapse、convert,接下来对这些方法进行逐个解释
-
condense 方法
在介绍详细算法之前,必须先知道,这个项目的合并算法是先移动再合并!
而合并其实就是将2个需要合并的tile,一个从GameboardView中删除,另外一个则改变其数值大小,给人一种合并的假象。
例如
[2][ ][2][4]--->是先将下标为2的2移动到下标为0的2(重叠)
哪怕是这样
[2][2][4][8]--->也是先将标为1的2移动到下标为0的2(重叠)合并算法的第一步.
目的:“去除”数组中的空的项(也就是移动),列如[2][][4][] ---> [2][4]
var tokenBuffer = [ActionToken]()
ActionToken枚举里面定义了一共四种操作类型,condense只用到两种,后两种便先不仔细介绍.这个方法是为了移动。那怎么样判断是否需要移动呢?
需要移动的必须满足的条件
1:本身是非"空"的即tile不是.Empty类型
2:这个tile前面必须有"位置"能够移动,也就是说,排在这个tile移动顺序之前的tiles内最少有以一个是.Empty类型.
TileObject有两个类型的值.Empty代表空的。 .tile(value)代表这个是存在Tile的,满足条件1.
如果是[2][2][2][2]这种情况,tokenBuffer会老老实实的调用tokenBuffer.append(ActionToken.NoAction(source: idx, value: value))把这些全部添加进去。一旦出现了一个为.Empty类型的tile,这次switch会直接执行default,从而导致where tokenBuffer.count == idx这个条件永远为false!.这才会调用tokenBuffer.append(ActionToken.Move(source: idx, value: value))。
这个方法执行完,返回tokenBuffer,此时tokenBuffer中装有是所有tile需要进行的操作要么是Move要么是NoAction。任然用这个例子:
在我向右滑动上面那个面板,用第二行来举例。condense接收的group内容是
【Value(2),Value(2),Value(2),Empty】(不知道为什么?返回看上面map映射的解释那张图)在经过本condense计算后返回的tokenBuffer是【NoAtion(0,2),NoAtion(1,2),NoAtion(2,2)】这里必须得对ActionToken进行一些解释,这个source参数是代表这个tile的的原位置不是它在tokenBuffer中的位置,一定得记住是tile的的原位置!tile的的原位置!tile的的原位置!
-
collapse
合并算法第二步:合并相同数值的tile:[2][2][2][4] -> [4][2][4]
根据代码进行分析,笔者添加了很多注释
func collapse(group: [ActionToken]) -> [ActionToken] { var tokenBuffer = [ActionToken]() var skipNext = false //如果发生了合并的操作,那么下一个tile将不进行操作(已经被合并) for (idx, token) in group.enumerate() { if skipNext { //如果发生了合并的操作,那么下一个tile将不进行操作(已经被合并) skipNext = false continue } switch token { ... case let .NoAction(s, v) where (idx < group.count-1 && v == group[idx+1].getValue() && GameModel.quiescentTileStillQuiescent(idx, outputLength: tokenBuffer.count, originalPosition: s)): //这种情况应用在tile需要合并但不需要移动的情况,这个方法在一个数组队列中最多调用一次, //即tile需要合并但不需要移动的情况只有一次,因为一旦需要合并,后面的tile都需要进行移动操作 //哪怕是在第一步计算中是不需要移动的 let next = group[idx+1] let nv = v + group[idx+1].getValue() skipNext = true tokenBuffer.append(ActionToken.SingleCombine(source: next.getSource(), value: nv)) case let t where (idx < group.count-1 && t.getValue() == group[idx+1].getValue()):
//这种情况应用在tile需要移动且需要合并的情况,需要移动的状态包括第一步计算出来的与前面发生过合并导致的
let next = group[idx+1]
let nv = t.getValue() + group[idx+1].getValue()
skipNext = true
tokenBuffer.append(ActionToken.DoubleCombine(source: t.getSource(), second: next.getSource(), value: nv))
case let .NoAction(s, v) where !GameModel.quiescentTileStillQuiescent(idx, outputLength: tokenBuffer.count, originalPosition: s):
//这种情况应用于需要移动但不需要合并,而需要移动是因为前面进行过合并操作造成的
tokenBuffer.append(ActionToken.Move(source: s, value: v))
case let .NoAction(s, v):
//不需要移动且不合并
tokenBuffer.append(ActionToken.NoAction(source: s, value: v))
case let .Move(s, v):
// 仅仅需要移动
tokenBuffer.append(ActionToken.Move(source: s, value: v))
default:
break
}
}
return tokenBuffer
}
总结一下,合并/移动的分类
1 tile需要合并但不需要移动的情况,这个种情况在一列/行*tiles*中最多存在一次。 因为因为一旦需要合并,后面的tile都需要进行移动操作。哪怕是在第一步计算中是不需 要移动的。列如[2][2][8][2]---->[4][ ][8][2]--->[4][8][2][],这种情况经 过算法第一步后全是*NoAction*,但是在算法第二步因为前2个[2]会发生合并,所以导 致第二个位置会空从而导致原本NoAction的[8][2]也要移动,所以一旦有发生合并,后 面的都**必须进行移动**.
2 tile需要移动且需要合并的情况,需要移动的状态包括第一步计算出来的与前面发生过 合并导致的。比如[2][2][4][4]-->[4][ ][4][4]--->[4][8][][]
3 需要移动但不需要合并,而需要移动是因为前面进行过合并操作造成的(参考1)
4 不需要移动且不合并:列如[2][4][8][16]
5 仅仅需要移动,比如[2][ ][4][8] -->[2][4][8]
最后解释一下为什么在第一种分类下是*SingleCombine*而第二种是 *DoubleCombine*。两个类型的不同就在于*DoubleCombine*多了一个*second:*参 数。至于为什么这样?不妨回想一下上面1情况与2情况的分别。没错,区别在与我说的 *tile*是否需要移动,郑重强调:**只要发生合并操作,绝对是需要进行tile的移动 的!绝对需要移动**,是不是感觉奇怪,明明前面说不需要移动。对,我说tile不需要移 动是指*for*循环中**token**代表的当前的那个tile,但是合并是两个tile的事情, 所以*let next = group[idx+1]*取出了下一个tile,而这个利用*idx+1*取出来 的tile是**一定得需要移动的**,而*token*代表的那个tile不一定需要移动,所以: *SingleCombine*是指只要移动一个*tile*的情况,而*DoubleCombine*是值2个需 要合并的*tile*均需要移动!(其实在分析**condense**(算法第一步)的时候已经强 调,算法的步骤是**先移动再合并**)。任然回到算法第一步最后那个例子
经过第一步计算传入的group内容是【NoAtion(0,2),NoAtion(1,2),NoAtion(2,2)】
经过第二步计算出来的返回值tokenBuffer是【SingleCombine(1,4),Move(2,2)】
* convert
这是最后一步了,这里又多出了一个*MoveOrder*枚举,这个枚举把需要进行的操作再度 简化就分为*SingleMoveOrder*与*DoubleMoveOrder*两种操作类别,十分显然 *DoubleMoveOrder*对应的是前面需要进行两个tile移动的且这两个需要合并的操作( 对应算法第二步分类中的2)。*SingleMoveOrder*是单一一个tile的移动操作。啥?你说少了一种合并情况?tile需要合并当不需要移动的操作被吃了?我就问了:算法第二步分类中的1是不是也需要移动一块被合并的tile(用*let next = group[idx+1]*取出的那块)?,这就对了,*SingleMoveOrder*有个参数*wasMerge:*代表的就是需不需要合并。所以*SingleMoveOrder*对应算法第二步中分类的1、3、5。至于4,人家都说了不移动不合并,就让人家好好原地待着。接着例子来,我们看看最后返回的是什么
经过第二步计算传入的group是【SingleCombine(1,4),Move(2,2)】
经过第三步计算出返回值moveBuffer是【SingleMoveOrder(1, 0,4,ture),SingleMoveOrder(2,1,2,false)】
看到这里算法分析基本结束,可以返回去继续看**performMove**方法,看后续操作.
## 总结
到这里应该就告一段落了,虽然还有些代码没有分析,但那些是不太重要的东西了。整个项目可以说不难,比较适合初学者,关键是要理解作者设置的映射关系与合并算法,。想要彻底的了解这个2048,最好就是自己从头到尾从零开始写一个。先从搭建界面开始,一步一步慢慢的来。笔者2048项目花了1天办时间重写,而写这篇文章却花了将近三天。如果有时间。可能会继续写关于2048的博文,应该是一步一步的去记录实现2048的步骤。