读源码系列(swift2048)-model篇

前言

笔者是swift自学新手,希望借助阅读别人开源项目提升自己swift水平。文中将尽量使用文字描述来代替代码的堆砌,建议读者多参考源码,以便更好理解项目。文中难免有错误之处,欢迎各路大牛留言指正。

项目信息

swift-2048 github地址

项目主界面

该项目可以说一个带有实验学习性质的项目,其中部分功能没有实现或不完整。但2048游戏的基本功能均完整实现。笔者将分3篇文章,分别按controller、model、view的进行介绍。

本篇是第2篇,将重点展开介绍model部分。
以往文章:
第1篇-controller篇

正文

本文将从以下三点展开说明:

  1. 文件结构概括
  2. 数据结构定义
  3. 处理用户滑动手势

1.文件结构概括

该项目model部分,从文件结构上看,包含以下2个文件:

  • GameModel.swfit 负责定义委托协议,以及包含所有游戏逻辑的GameModel处理类

protocol GameModelProtocol : class {...} //协议(里面4个方法上一篇已经介绍过,这里不重复了)
class GameModel : NSObject {...} //游戏逻辑

  • AuxiliaryModels.swift 负责定义model处理过程中所需的数据结构(后文将一一介绍)

enum TileObject {...}
struct SquareGameboard<T> {...}
enum MoveDirection {...}
struct MoveCommand {...}
enum ActionToken {...}
enum MoveOrder {...}

2.数据结构定义

游戏盘和棋子

开发者在GameModel类中定义了属性game board 表示游戏盘(Gameboard)和盘上的棋子(Tile)

class GameModel : NSObject {
 ...
 var gameboard: SquareGameboard<TileObject>
 ...
}

SquareGameboard和TileObject就是定义在AuxiliaryModels.swift中的数据结构:

enum TileObject {//表示棋子
 case Empty
 case Tile(Int)
}

代码可见,TileObject不仅表示有数字的棋子,还表示空格子(格子上没有棋子)

struct SquareGameboard<T> {//表示游戏盘
 let dimension : Int //维度
 var boardArray : [T] //泛型数组,表示游戏盘上的每个棋子(包括空棋子)

init(dimension d: Int, initialValue: T) {//初始化
  dimension = d
  boardArray = [T](count:d*d, repeatedValue:initialValue)
 }

subscript(row: Int, col: Int) -> T { //下标函数
  get {
   assert(row >= 0 && row < dimension)
   assert(col >= 0 && col < dimension)
   return boardArray[rowdimension + col] //根据行列坐标位置,找到数组中的棋子
  }
  set {
   assert(row >= 0 && row < dimension)
   assert(col >= 0 && col < dimension)
   boardArray[row
dimension + col] = newValue //根据行列坐标位置,找到数组中的棋子
  }
 }
 ... ...(省略部分代码)
}

代码可见
1.使用了泛型,使得游戏盘这个结构更通用,不受棋子类型的限制。
2.使用了下标函数(subscript),从而达到:内部使用一位数组存储;外部按行列坐标这种2维形式访问,例如:

for i in 0..<dimension {
 for j in 0..<dimension {
  if case .Empty = gameboard[i, j] {//按2维数组访问
   ... ...
  }
 }
}

2.处理用户滑动手势

(这部分的讲解,笔者将带领各位读者从滑动手势处理函数开始,一步一步深入。所以代码较多。)

从向下滑动手势的处理函数开始:

func downCommand(r: UIGestureRecognizer!) {
 assert(model != nil)
 let m = model!
 m.queueMove(MoveDirection.Down,//手势发生后,controller调用了model的queueMove方法
  completion: { (changed: Bool) -> () in
   if changed {
    self.followUp()
   }
  })
}

其中MoveDirection是一个枚举,表示滑动方向

enum MoveDirection {
 case Up, Down, Left, Right
}


进入queueMove方法:

class GameModel : NSObject {
 ...
 var queue: [MoveCommand] //MoveCommand队列
 var timer: NSTimer //计时器

func queueMove(direction: MoveDirection, completion: > (Bool) -> ()) {
  ...(省略验证)
  queue.append(MoveCommand(direction: direction, completion: completion))//参数整合成MoveCommand对象,放入队列
  if !timer.valid {//计时器未启动,则启动
   timerFired(timer)
  }
 }
}

queueMove方法够简单,就做了2件事情:
1》参数生成MoveCommand,放到队列,等待处理
2》调用timerFired(timer)。计时器执行

结构MoveCommand比较好理解,表示的是:手势处理函数中,传给queueMove方法的参数(滑动方向+完成闭包):

struct MoveCommand {
 let direction : MoveDirection
 let completion : (Bool) -> ()
}


移步timerFired方法

func timerFired(_: NSTimer) {
 ...(省略验证)
 var changed = false
 while queue.count > 0 {//循环队列
  let command = queue[0]
  queue.removeAtIndex(0) //得到队列的第一个
  changed = performMove( command.direction ) //调用performMove方法
  command.completion(changed)//各位看到,完成无名闭包是在这里调用。
  if changed {//如果没有变化,进行到下一个(没有变化,不break)
   break
  }
 }
 if changed {//如果发生了变化,将timer在一定间隔之后,再次执行在执行本方法
  timer = NSTimer.scheduledTimerWithTimeInterval( queueDelay, target: self, selector: Selector("timerFired:"), userInfo: nil, repeats: false)
 }
}

可见timerFired并不复杂:
循环queue,取出最先的MoveCommand:

  • 执行performMove,并执行完成函数
  • 如果结果游戏盘有变化,则跳出循环,一定时间间隔后再执行timerFired
  • 如果没有变化,则循环继续,处理下一个MoveCommand

虽然timerFired不复杂,但是理解上还是要拐个弯:timerFired中的while循环,仅仅为了处理相同的动作(过滤掉相同的动作),而真正对queue的循环处理,其实Timer来完成的


目光在转向performMove方法,游戏真正的逻辑终于要登场了

func performMove(direction: MoveDirection) -> Bool {
 let coordinateGenerator: (Int) -> [(Int, Int)] = {
  ...(代码不贴了,不复杂就是有点抽象)
 }
 //一个局部闭包。用来返回在移动方向上 一行或一列 棋子的坐标顺序。顺序是移动方向上格子移动的顺序
 //(看文字还是抽象?等会我再详细解释)

var atLeastOneMove = false
 for i in 0..<dimension {
   let coords = coordinateGenerator(i)
   let tiles = coords.map() { (c: (Int, Int)) -> TileObject in
   let (x, y) = c
   return self.gameboard[x, y]
  }//用前面的坐标找到对应的tileObject,放到数组tiles
let orders = merge(tiles)//处理棋子的合并,返回了MoveOrder数组
  atLeastOneMove = orders.count > 0 ? true : atLeastOneMove
  for object in orders {
   switch object {
    case let MoveOrder.SingleMoveOrder(s, d, v, wasMerge)://单棋子移动
     let (sx, sy) = coords[s]
     let (dx, dy) = coords[d]
     if wasMerge {//计算分数
      score += v
     }
     gameboard[sx, sy] = TileObject.Empty//原来的设为空
     gameboard[dx, dy] = TileObject.Tile(v)//新的设为值
     delegate.moveOneTile(coords[s], to: coords[d], value: v)//调用委托
    case let MoveOrder.DoubleMoveOrder(s1, s2, d, v)://2个棋子移动
     ...(省略,与单棋子移动类似。有兴趣,请参加项目源码)
   }
  }
 }
 return atLeastOneMove//返回表示有移动
}

上面代码有点多,主要流程就是:
1》得到一行或一列 移动的顺序坐标
2》用坐标找到棋子数组 tiles
3》merge(tiles) 方法 得到MoveOrder数组
4》根据MoveOrder数组循环处理,更新棋盘数据、调用委托,通知controller

还是很抽象?让来用图片来说明。主要是2点:
1.向下滑动手势。我们可以很容易的发现,其实每一列都是独立的。列与列之间没有关系。而且每一列的移动方式一样,所以可对几个列使用相同算法进行循环处理。

分解成列,每一列的移动方式一样

2.另外3个方向滑动,其实只要旋转角度,都可以转化成向下滑动手势。(你直接旋转你的手机,所有的滑动都可以变成向下滑动)

其他方向的滑动,都可转成向下滑动

这个旋转动作,主要就是交给内部闭包coordinateGenerator来干的。这就是coordinateGenerator的意义。当然内部实现类似坐标转化,有兴趣的读者自己查看源码吧;)


理解了上面拆分过程,后面就可以看merge方法,如何将一列数据(棋子)进行合并处理,合并处理的结果用MoveOrder数组表示:

func merge(group: [TileObject]) -> [MoveOrder] {
 return convert(collapse(condense(group)))
}

merge方法虽然只有1句代码,但是3个函数嵌套调用,说明了合并过程被拆分为3个步骤:
1》condense 移动(按字面意思是“集中”)
2》collapse 合并
3》convert 转化成MoveOrder


先看步骤一:condense函数

func condense(group: [TileObject]) -> [ActionToken] {
 var tokenBuffer = ActionToken//数组
 for (idx, tile) in group.enumerate() {
  switch tile {
   case let .Tile(value) where tokenBuffer.count == idx://表示不用动
    tokenBuffer.append(ActionToken.NoAction(source: idx, value: value))
   case let .Tile(value):
    tokenBuffer.append(ActionToken.Move(source: idx, value: value))//表示移动。
   default:
    break
  }
 }//显然,tokenBuffer中的index就是现在新的位置
 return tokenBuffer;
}

NoAction和Move是什么,看图就一目了然了

NoAction和Move

上面2种情况,在结构ActionToken做了定义

enum ActionToken {
case NoAction(source: Int, value: Int) //不动,即位置不变。
case Move(source: Int, value: Int)//移动,即位置发生变化。
...
}

小结一下本方法,就是在对一列的棋子,进行移动集中。每个非空棋子移动的结果以ActionToken信息表示,存放在一个新数组中。
新数组的索引index,就是棋子在这列中的当前(新)位置


接下来阶段二:collapse函数

func collapse(group: [ActionToken]) -> [ActionToken] {
 var tokenBuffer = ActionToken//数组
 var skipNext = false
 for (idx, token) in group.enumerate() {
  if skipNext {
   skipNext = false
   continue
  }//因为只能2个格子合并,所以处理上一个棋子发生合并,则当前棋子已经处理过了,跳过
  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)):
     let next = group[idx+1]
     let nv = v + group[idx+1].getValue()
     skipNext = true
     tokenBuffer.append( ActionToken.SingleCombine( source: next.getSource(), value: nv))
     //如果当前的NoAction的格子,这里合并,就是SingleCombine。只记录后面的source
   case let t where (idx < group.count-1 && t.getValue() == group[idx+1].getValue()):
    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))
    //如果第一个不是noaction,则是DoubleCombine,是要记录开始和后面
   case let .NoAction(s, v) where !GameModel.quiescentTileStillQuiescent(idx, outputLength: tokenBuffer.count, originalPosition: s):
    tokenBuffer.append( ActionToken.Move(source: s, value: v))
    //如果当前是NoAction,但是前面已经有合并,中间空出来,则将变成Move
   case let .NoAction(s, v):
    tokenBuffer.append( ActionToken.NoAction(source: s, value: v))
    //除了上一种情况,NoAction还是NoAction
   case let .Move(s, v):
    tokenBuffer.append(ActionToken.Move(source: s, value: v))
    //move还是move
   default:
    break
  }
 }
 return tokenBuffer
}

collapse函数,输入一个ActionToken数组 输出一个新的ActionToken数组。新数组的产生依次(switch语句)执行如下规则:
1》当前ActionToken为noaction,后面一个与当前数字一样,且前面没有空棋子,则生成一个新的SingleCombine,放到新数组。

生成SingleCombine

2》(如果1规则不满足)当前棋子,与后面棋子一样,则生成一个新的DoubleCombine,放到新数组

生成DoubleCombine

3》(如果1、2规则都不满足)如果ActionToken为noaction(不会和后面数字一样,不然就是满足规则2了),且前面有空格(即前面发生了合并),则生成Move

NoAtion变成Move的情况

4》(规则1,2,3都不满足)如果还是noaction,则生成noAction
5》原来的move,生成move


最后再看阶段三:convert函数就简单了,主要将ActionToken变成MoveOrder

func convert(group: [ActionToken]) -> [MoveOrder] {
 var moveBuffer = MoveOrder
 for (idx, t) in group.enumerate() {
  switch t {
   case let .Move(s, v)://移动单个格子
    moveBuffer.append( MoveOrder.SingleMoveOrder( source: s, destination: idx, value: v, wasMerge: false))
   case let .SingleCombine(s, v)://合并,但是只是移动了后面的那个棋子,与前面棋子没有移动(算只移动了一个棋子)
    moveBuffer.append( MoveOrder.SingleMoveOrder( source: s, destination: idx, value: v, wasMerge: true))
   case let .DoubleCombine(s1, s2, v)://合并,是2个格子移动并合并成了一个新的棋子
    moveBuffer.append( MoveOrder.DoubleMoveOrder( firstSource: s1, secondSource: s2, destination: idx, value: v))
   default:
    break
  }
 }
 return moveBuffer
}

经过全面多个函数的分析,ActionToken和MoveOrder的含义也明确了:

enum ActionToken {
 case NoAction(source: Int, value: Int)
 case Move(source: Int, value: Int)
 case SingleCombine(source: Int, value: Int)
 case DoubleCombine(source: Int, second: Int, value: Int)
  ...
}

ActionToken表示了逻辑计算中一个棋子,是从何处移动或合并过来的。

enum MoveOrder {
 case SingleMoveOrder( source: Int, destination: Int, value: Int, wasMerge: Bool)
 case DoubleMoveOrder( firstSource: Int, secondSource: Int, destination: Int, value: Int)
}

表示棋子最终将如何移动。这个是修改游戏数据并调用委托通知view进行显示的依据。

ActionToken中NoAction本来就是不移动,所以不出现在MoveOrder中
ActionToken中的Move和SingleCombine,在显示上都是一个棋子的移动,所以合并成一个SingleMoveOrder

回看performMove函数中的相关代码,看model是如何修改数据、调用委托的:

switch object {
 case let MoveOrder.SingleMoveOrder(s, d, v, wasMerge)://单棋子移动
  let (sx, sy) = coords[s]
  let (dx, dy) = coords[d]
  if wasMerge {//计算分数
   score += v
  }
  gameboard[sx, sy] = TileObject.Empty//原来的设为空
  gameboard[dx, dy] = TileObject.Tile(v)//新的设为值
 //上面2行就是修改游戏盘中的数据

delegate.moveOneTile(coords[s], to: coords[d], value: v)//调用委托
 case let MoveOrder.DoubleMoveOrder(s1, s2, d, v)://2个棋子移动
  ...(省略,与单棋子移动类似。有兴趣,请参加项目源码)
}


回顾整个处理流程:
queueMove对外接口
|===>timerFired 利用timer,循环处理外界传入的用户手势动作
|======>performMove 将棋盘按方向,拆分为与方向无关的棋子数组
|=========>merge 处理方向无关格子数组的移动(分成3步)
|============>condense第一步:移动
|============>collapse第二步:合并
|============>convert第三步:中间结果转成最后结果

总结

笔者认为,整个model中,给笔者映像最深的就是“将游戏盘按方向,拆分为与方向无关的棋子一维数组”。虽然该项目中的model算法有其局限性,但是这种将不确定的因素从算法中剥离出来的思想,真值得笔者好好学习。

(该项目的model部分还有一些处理逻辑文中没有提到(例如赢和输的判断、插入新棋子等),有兴趣的读者可自行查看源码了解。

非常感谢您的阅读!您的留言、打赏、点赞、关注、分享,对笔者最大的鼓励:P

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

推荐阅读更多精彩内容