IOS+Swift:怎样开发一个2048游戏

2048是IOS学习的Demo中经久不衰的话题了。之前为了给后辈们讲一个关于iOS+Swift的讲座,便自己开发了一个。Github上倒是已经有了一个工程 austinzheng/swift-2048 ,但是最后的一次commit也已经是2015年的时候了,有些地方应该已经落后了吧。

这篇教程假设你已经对于Swift的基本语法只是和Xcode的使用方法有了一个比较清楚的认知。如果你还不了解这方面的知识的话,建议先去阅读以下相关文章进行入门。

前言

这个教程的源代码已经放在了我的github主页上面: Game2048,目前没有放License,不过你可以自由使用本文以及Github工程中的所有源代码。

话题回到项目本身。这个项目上,我也是采用了经典了MVC架构,即Model-View-Controller。在下面讲解中,我也将基本以这个顺序来介绍代码的结构与逻辑。

准备工作

在这个部分,我们创建工程文件,并简要梳理一下工程的结构以及各个文件的作用。

创建项目

上面已经声明,我假设你已经熟悉了Xcode的操作,故这里说的简略一点。使用Xcode创建一个Single View Application,然后删除Storyboard相关的内容,我们将会使用代码来构建页面。然后创建一下文件:

  • Matrix.swift: Model部分的代码,在这里我们构建了描述游戏中各个实体的概念模型以及处理游戏操作逻辑的算法
  • Container.swift: View部分的代码,定义了2048游戏操作的面板
  • Tile.swift: 我们称2048游戏中的一个格子为一个Tile,这个文件即为Tile的View
  • ColorProvider.swift: 我们将游戏中的颜色控制部分独立了出来,使得样式的替换更加方便
  • Other.swift:其他的支援代码
  • Constant.swift: 某些常量定义在这里

除此之外,我们还使用了一些第三方代码库,这些库我们通过CocoaPod来安装,Podfile的内容为:

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '10.0'
use_frameworks!

target 'Game2048' do
    pod 'SnapKit', '~> 3.0'
    pod 'Dollar'
end

运行pod install来安装这些依赖。

Model部分 - 构建起描述游戏的概念模型

基本数据表示 - Matrix

2048游戏中,我们主要需要处理的是一个矩形的数据结构,为了方便的存储和处理数据,我们创建一个名为Matrix的结构体:

struct Matrix {
      private let dimension: Int
      private var elements: [Int]

      /// 初始化函数,创建一个Matrix结构体
    ///
    /// - Parameters:
    ///   - d: 游戏中矩阵的维数,一般是4
    ///   - initialValue: 被创建的矩阵中每个元素的初始值
    init(dimension d: Int, initialValue: Int = 0) {
        dimension = d
        elements = [Int](repeating: initialValue, count: d * d)
    }
    
    func getDimension() -> Int {
        return dimension
    }

    func asArray() -> [Int] {
        return elements
    }
}

其思想并不复杂,Matrix内部包裹的仍然是一个一维数组。为了让这个Matrix能够如同Matlab等程序中的矩阵一样可以用二元数的方式访问,我们给它添加如下的代码:

subscript(row: Int, col: Int) -> Int {
        get {
            assert(row >= 0 && row < dimension)
            assert(col >= 0 && col < dimension)
            return elements[row * dimension + col]
        }
        
        set {
            assert(row >= 0 && row < dimension)
            assert(col >= 0 && col < dimension)
            elements[row * dimension + col] = newValue
        }
    }

同时为了传递参数的方便,我们将二元数定义成一个特定的类型,方便参数传递。Matrix外部加上

typealias MatrixCoordinate = (row: Int, col: Int)
// 定了一个特殊的二元数作为空坐标
let kNullMatrixCoordinate = MatrixCoordinate(row: -1, col: -1)

然后在Matrix中加上:

subscript(index: MatrixCoordinate) -> Int {
    get {
        let (row, col) = index
        return self[row, col]
    }
        
    set {
        let (row, col) = index
        self[row, col] = newValue
    }
}

最后我们还给Matrix加上一些有用的工具函数,用于查询和插入

    /// 将矩阵的所有元素置零
    mutating func clearAll() {
        for index in 0 ..< (dimension * dimension) {
            elements[index] = kZeroTileValue
        }
    }
    
    
    /// 将元素的值插入到矩阵的指定位置,注意这个函数只能给原来为空的位置赋值
    ///
    /// - Parameters:
    ///   - position: 坐标
    ///   - value: 插入的值
    mutating func insert(at position: MatrixCoordinate, with value: Int) {
        if isEmpty(at: position) {
            self[position] = value
        } else {
            assertionFailure()
        }
    }
    
    
    /// 矩阵指定位置是否为空(为空即是指此处为0)
    ///
    /// - Parameter position: 指定位置
    /// - Returns: 是否为空
    func isEmpty(at position: MatrixCoordinate) -> Bool {
          // kZeroTileValue定义在Constant.swift里面,为0
        return self[position] == kZeroTileValue
    }
    
    
    /// 获取矩阵中所有为空的位置
    ///
    /// - Returns: 列表形式的坐标集合
    func getEmptyTiles() -> [MatrixCoordinate] {
        var buffer: [MatrixCoordinate] = []
        for row in 0..<dimension {
            for col in 0..<dimension {
                let pos = MatrixCoordinate(row: row, col: col)
                if isEmpty(at: pos) {
                    buffer.append(pos)
                }
            }
        }
        return buffer
    }
    
    
    /// 矩阵中元素的最大值
    var max: Int {
        get {
            return elements.max()!
        }
    }
    
    
    /// 矩阵中所有元素的和
    var total: Int {
        return $.reduce(elements, initial: 0, combine: { $0 + $1 })
    }

至此,我们完成了对游戏数据表达的抽象,即将2048中的4*4矩阵用Matrix来表示,并在这个结构体上定义了方便的访问方式和工具函数。

Model层对外封装结构

然后我们定义Model需要对Controller露出的操作接口。定义一个新的GameModel类。

class GameModel {
    private var matrix: Matrix
    var dimension: Int {
        get {
            return matrix.getDimension()
        }
    }
    let winningThreshold: Int
    
    /// 分数
    var score: Int {
        return matrix.total
    }

    init (dimension: Int = 4, winningThreshold threshold: Int = 2048) {
        matrix = Matrix(dimension: dimension)
        winningThreshold = threshold
    }
}

考虑2048的游戏规则,Model层应该对上层提供如下这些接口:

  • 在一个随机空位置插入一个新的格子
  • 在指定位置插入一个指定值
  • 判断用户是否胜利
  • 判断用户是否已经失败
  • 对用户的上下左右滑动操作做出响应
  • 重置游戏
// 在一个指定位置插入一个指定的值
func insertTile(at position: MatrixCoordinate, with value: Int) {
}
// 在一个随机空位置插入一个随机的值,按照一般的规则,随机的插入2或者4,其中2的概率要远大于4
func insertTilesAtRandonPosition(with value: Int) -> Int {
}
// 用户是否已经胜利
func userHasWon() -> Bool {
}
// 用户是否已经失败
func userHasLost() -> Bool {
}
// 响应用户操作,注意这里我们引入了新的MoveCommand和MoveAction的概念,这个我们会在后面详细解释
func perform(move command: MoveCommand) -> [MoveAction] {
}

下面我们来逐个解释各个结构的功能和实现。

在指定位置插入指定值

这个接口实现非常简单,因为我们已经在Matrix类中实现了类似的接口。故在这里我们只需要调用对应的函数即可。

func insertTile(at position: MatrixCoordinate, with value: Int) {
    matrix.insert(at: position, with: value)
}
在一个随机空位插入一个随机的值

处于程序设计中函数应当保持短小精悍的原则,为了实现这个功能,我们增加几个工具函数:

// 这个函数会返回插入的位置,返回的格式为matrix内部一维数组定义下的index
func insertTilesAtRandonPosition(with value: Int) -> Int {
        let emptyTiles = matrix.getEmptyTiles()
        if emptyTiles.isEmpty {
            return -1
        }
        let randomIdx = Int(arc4random_uniform(UInt32(emptyTiles.count - 1)))
        let result = emptyTiles[randomIdx]
        insertTile(at: emptyTiles[randomIdx], with: value)
        return coordinateToIndex(result)
}

func coordinateToIndex(_ coordincate: MatrixCoordinate) -> Int {
        let (row, col) = coordincate
        return row * dimension + col
}

// 工具函数,按照预定的概率生成2或者4
func getValueForInsert() -> Int {
        if uniformFromZeroToOne() < chanceToDisplayFour {
            return 4
        } else {
            return 2
        }
    }
    
    func uniformFromZeroToOne() -> Double {
        return Double(arc4random()) / Double(UINT32_MAX)
    }
判断用户是否胜利

这里只需要判断matrix中的最大值是否达到了给定的阈值即可:

func userHasWon() -> Bool {
    return matrix.max >= winningThreshold
}
判断用户是否已经失败

这个逻辑要相对复杂一点,用户失败时,即用户无论怎么操作矩阵都不会发生变化。按照规则,用户失败应当满足下面两个条件

  1. 所有的格子都已经填满
  2. 任意一个格子和其相邻格子都无法合并
    这一过程可以形成下面的代码。代码的逻辑并不十分复杂,可以通过阅读源代码进行理解。
    /// 用户是已经获胜
    func userHasWon() -> Bool {
        return matrix.max >= winningThreshold
    }
    
    
    // 用户已经失败
    func userHasLost() -> Bool {
        return !isPotentialMoveAvaialbe()
    }
    
    
    /// 用户是否还有可以移动的步骤
    func isPotentialMoveAvaialbe() -> Bool {
        var result: Bool = false
        for row in 0..<dimension {
            for col in 0..<dimension {
                result = result || isTileMovable(at: MatrixCoordinate(row: row, col: col))
                if result {
                    break
                }
            }
        }
        return result
    }
    
    
    /// 指定的格子是否还可以移动
    func isTileMovable(at tileCoordincate: MatrixCoordinate) -> Bool {
        let val = matrix[tileCoordincate]
        if val == kZeroTileValue {
            return true
        }
        let neighbors = getNeightbors(around: tileCoordincate)
        var result: Bool = false
        for index: MatrixCoordinate in neighbors {
            let fetchedVal = matrix[index]
            result = result || (fetchedVal == val) || fetchedVal == kZeroTileValue
            if result {
                break
            }
        }
        return result
    }
    
    /// 获取一个格子的相邻格子
    func getNeightbors(around tileCoordincate: MatrixCoordinate) -> [MatrixCoordinate] {
        let (row, col) = tileCoordincate
        var result: [MatrixCoordinate] = []
        if row - 1 > 0 {
            result.append(MatrixCoordinate(row: row - 1, col: col))
        }
        if row + 1 < dimension {
            result.append(MatrixCoordinate(row: row + 1, col: col))
        }
        if col - 1 > 0 {
            result.append(MatrixCoordinate(row: row, col: col - 1))
        }
        if col + 1 < dimension {
            result.append(MatrixCoordinate(row: row, col: col + 1))
        }
        return result
    }
重置游戏

重置游戏只需要把matrix中的数值清空即可

    func clearAll() {
        matrix.clearAll()
    }
对用户的上下左右滑动操作做出响应
游戏逻辑分析

这个部分涉及到的就是游戏逻辑的核心了。通过对游戏规则的发现2048问题有如下的特点:

  • 向某一个方向滑动时,沿该方向的各个列之间互相独立,故可以将一次滑动产生的二维格子移动合并问题,转化成为若干个独立求解的一维格子队列的移动和合并问题。
  • 不同的滑动方向,其实逻辑规则是在旋转操作下是等价的。
    综上所述,我们可以将游戏中针对用户操作方向做出响应计算matrix矩阵的新值这样一个二维问题,分解为若干个线性问题的组合。例如,若某一个操作以后matrix所代表的游戏中格子分布为:
    2 | 2 | 0 |4
    4 | 0 | 2 | 0
    0 | 0 | 0 | 0
    0 | 0 | 0 | 0
    此时用户向左滑动,则求解新的矩阵数值分布可以分解为四个子问题:即[2, 2, 0 4], [4, 0, 2, 0], [0, 0, 0, 0], [0, 0, 0, 0]。而且,由于旋转等价性,我们可以将各个方向的滑动全部都分解为一维的,向左合并的子问题。为了更形象的说明这一点,还是参照上面给出的例子。若用户向上滑动,则可以分解为[2, 4, 0, 0], [2, 0, 0, 0], [0, 2, 0, 0], [4, 0, 0, 0]四个问题的。

完成了上述问题的抽象和简化以后,我们来着重分析一维的,向左合并的简化问题。这个问题的求解,可以分解成两种操作:一是从左到右,移除非零数字之间的零,我们称之为condense;二是将相邻的相等数字进行合并,我们称之为collapse。一般只需要condense — collapse两步即可,少数情况下需要最后额外进行一次condense,例如[2, 2, 2, 2],collapse完成以后得到[4, 0, 4, 0],需要再进行一次condense才能变成[4, 4, 0, 0]。

在编程的时候,condense是一个非常方便实现的操作。我们只需要将待处理的数组中的非零元素按照原来的顺序放到新数组里面就可以了。

用户操作的表示和实现

在前一部分的分析中我们发现,不同方向的滑动,都可以分解为若干个一维问题,只是不同的滑动方向下,一维问题的分解方式,以及将各个解出的结果还原为二维矩阵的方式不同。而一维问题的求解方法是一致的。这种特点适合于采用多态的设计方法。即我们定义一个基类MoveCommand,在其中实现一维问题求解的算法,而把一维问题的提取和还原的算法放在各个滑动方向对应的子类中实现:


/// 移动指令,代表用户在屏幕上的一次滑动
class MoveCommand {
    /**
     * 我们使用了多态来处理不同的滑动指令。
     * 为了解决2048这个发生在二维空间的问题,我们需要将问题进行降维。下面以四维情况为例来说明。
     * 
     * 无论用户想那个方向滑动,格子的变化,总是沿着用户滑动的方向进行,即格子其他处于同一用户滑动方向直线上格子发生交互(合并),而与其他
     * 平行的直线上的格子无关。那么我们可以在用户滑动发生时,将矩阵按照用户滑动方向划分成多个组,然后在每组中独立的解决一维的合并问题。例如
     * 下面的矩阵情形
     *  |0  |0  |2  |2  |
     *  |0  |0  |2  |2  |
     *  |0  |0  |2  |2  |
     *  |0  |0  |2  |2  |
     
     * 当用户向左侧滑动是,可以将上面的矩阵拆解成|0  |0  |2  |2  |的一维问题进行求解。
     * 而且容易发现,对于用户的不同滑动方向,只是一维问题分解的方式不同,求解一维问题的方法是一致的。我们用多态来实现这种复用。
     */
    
    // 还原
    func getCoordinate(forIndex index: Int, withOffset offset: Int, dimension: Int) -> MatrixCoordinate {
        fatalError("Not implemented")
    }
    
    // 提取一维问题
    func getOneLine(forDimension dimension: Int, at index: Int) -> [MatrixCoordinate] {
        fatalError("Not implemented")
    }
    
    // condense
    func getMovableTiles(from line: [Int]) -> [MovableTile] {
        var buffer: [MovableTile] = []
        for (idx, val) in line.enumerated() {
            if val > 0 {
                buffer.append(MovableTile(src: idx, val: val, trg: buffer.count))
            }
        }
        return buffer
    }
    
    // collapse
    func collapse(_ tiles: [MovableTile]) -> [MovableTile] {
        var result: [MovableTile] = []
        var skipNext: Bool = false
        for (idx, tile) in tiles.enumerated() {
            if skipNext {
                skipNext = false
                continue
            }
            if idx == tiles.count - 1 {
                var collapsed = tile
                collapsed.trg = result.count
                result.append(collapsed)
                break
            }
            
            let nextTile = tiles[idx + 1]
            if nextTile.val == tile.val {
                result.append(MovableTile(src: tile.src, val: tile.val + nextTile.val, trg: result.count, src2: nextTile.src))
                skipNext = true
            } else {
                var collapsed = tile
                collapsed.trg = result.count
                result.append(collapsed)
            }
        }
        return result
    }
}

在上面的代码中,我们引入了MovableTile这个类。其作用是描述格子在一次滑动操作中的变化过程。


/// 矩阵变化过程中描述每一个格子的数据结构,可以记录格子的移动,合并,消失,以及值的改变
struct MovableTile {
    
    /// 源位置
    var src: Int
    
    /// 取值
    var val: Int
    
    /// 目标位置
    var trg: Int = -1
    
    
    /// 如果此值非负,则意味着这个结构体描述了一个合并过程,并且这个src2代表参与合并的另一个格子,为默认值-1时,则意味着只是单纯的格子移动,没有发生合并
    var src2: Int = -1
    
    init (src: Int, val: Int, trg: Int = -1, src2: Int = -1) {
        self.src = src
        self.val = val
        self.trg = trg
        self.src2 = src2
    }
    
    
    /// 这个格子是否实际发生了移动。
    ///
    /// - Returns: 是否需要移动
    func needMove() -> Bool {
        return src != trg || src2 >= 0
    }
}

接下来,我们需要实现不同滑动方向对应的子类,其实现逻辑非常直观,读者可以自己理解一下:


class UpMoveCommand: MoveCommand {
    override func getOneLine(forDimension dimension: Int, at index: Int) -> [MatrixCoordinate] {
        return (0..<dimension).map({ MatrixCoordinate(row: $0, col: index) })
    }
    
    override func getCoordinate(forIndex index: Int, withOffset offset: Int, dimension: Int) -> MatrixCoordinate {
        return MatrixCoordinate(row: offset, col: index)
    }
}

class DownMoveCommand: UpMoveCommand {
    override func getOneLine(forDimension dimension: Int, at index: Int) -> [MatrixCoordinate] {
        return super.getOneLine(forDimension: dimension, at: index).reversed()
    }
    
    override func getCoordinate(forIndex index: Int, withOffset offset: Int, dimension: Int) -> MatrixCoordinate {
        return MatrixCoordinate(row: dimension - 1 - offset, col: index)
    }
}

class LeftMoveCommand: MoveCommand {
    override func getOneLine(forDimension dimension: Int, at index: Int) -> [MatrixCoordinate] {
        return (0..<dimension).map({ MatrixCoordinate(row: index, col: $0) })
    }
    
    override func getCoordinate(forIndex index: Int, withOffset offset: Int, dimension: Int) -> MatrixCoordinate {
        return MatrixCoordinate(row: index, col: offset)

    }
}

class RightMoveCommand: LeftMoveCommand {
    override func getOneLine(forDimension dimension: Int, at index: Int) -> [MatrixCoordinate] {
        return super.getOneLine(forDimension: dimension, at: index).reversed()
    }
    
    override func getCoordinate(forIndex index: Int, withOffset offset: Int, dimension: Int) -> MatrixCoordinate {
        return MatrixCoordinate(row: index , col: dimension - 1 - offset)
    }
}
实现GameModel中的接口

有了上述准备,我们可以着手实现GameModel中的接口了。把下面的函数添加到GameModel

    /// 执行一个移动命令
    func perform(move command: MoveCommand) -> [MoveAction] {
        // 最后生成的可供UI解析的移动命令
        var actions: [MoveAction] = []
        var newMatrix = matrix
        newMatrix.clearAll()
        // 逐行或者逐列进行遍历(具体取决于滑动方向)
        (0..<matrix.getDimension()).forEach { (index) in
            // 提取出一维问题,注意这里提取的是列或者行中所有格子的坐标
            let tiles = command.getOneLine(forDimension: matrix.getDimension(), at: index)
            // 取出各个格子中的值
            let tilesVals = tiles.map({ matrix[$0] })
            // 进行condense-collapse-condense操作
            let movables = command.collapse(command.getMovableTiles(from: tilesVals))
            // 将movable tiles转化成move action
            for move in movables {
                let trg = command.getCoordinate(forIndex: index, withOffset: move.trg, dimension: matrix.getDimension())
                newMatrix[trg] = move.val
                if !move.needMove() {
                    continue
                }
                let src = command.getCoordinate(forIndex: index, withOffset: move.src, dimension: matrix.getDimension())
                if move.src != move.trg {
                    let action = MoveAction(src: src, trg: trg, val: -1)
                    actions.append(action)
                }
                if move.src2 >= 0 {
                    let src2 = command.getCoordinate(forIndex: index, withOffset: move.src2, dimension: matrix.getDimension())
                    actions.append(MoveAction(src: src2, trg: trg, val: -1))
                    actions.append(MoveAction(src: kNullMatrixCoordinate, trg: trg, val: move.val))
                }
            }
        }
        // 应用计算完之后的结果
        self.matrix = newMatrix
        newMatrix.printSelf()
        // 将需要UI执行的变化返回
        return actions
    }

这里我们又引入了一个新的类MoveAction,这个类其实是对MovableTile的一个整理。在前面我们提到了,当MovableTile可以描述在滑动过程中具体格子的变化。诚然,单个格子的移动我们可以直接利用MovableTile里面的数据操纵UI,但是在发生合并是就要麻烦很多了。出于这个原因我们引入了新的MoveAction,并且保证每个MoveAction只对应UI中的一个格子的一个运动。其定义如下:

struct MoveAction {
    var src: MatrixCoordinate
    var trg: MatrixCoordinate
    var val: Int
    
    init(src: MatrixCoordinate, trg: MatrixCoordinate, val: Int) {
        self.src = src
        self.trg = trg
        self.val = val
    }
}

注意这里面和MovableTile的一个主要不同时取消了src2这个属性。
对于由一个MovableTile表示的两个格子的合并过程(即src2不为-1),我们自然地将其分解为三个子动作,分别是两个单纯移动和一个新的格子出现。对于单纯移动而值不发生变化的格子,我们将其MoveActionval设置成-1,对于新出现的格子,我们将其src设置成-1。当然,如果被合并的两个格子其中有一个没有移动,那么就只会生成一个格子移动和一个新格子产生的MoveAction

View部分

View部分相对比较简单,毕竟只有一个页面。View部分只涉及到两个类,分别是ContainerTileView(格子)。

TileView

格子比较简单,除了背景以外只需要显示一个数字。我在这里使用了SnapKit这个AutoLayout库,大家可以在github上阅读以下说明。也非常推荐大家在自己的Project中使用这个库。

class TileView: UIView {
    // 显示数字
    var valLbl: UILabel!
    
    // 在矩阵中的位置,row * dimension + col
    var loc: Int = -1
    
    // 颜色配置
    var color: ColorProvider!
    
    // 数值
    var val: Int = 0 {
        didSet {
            valLbl.text = "\(val)"
            backgroundColor = color.colorForValue(val)
            valLbl.textColor = color.textColorForVal(val)
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        configureValLbl()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }
    
    func configureBackground() {
        layer.cornerRadius = 2
    }
    
    func configureValLbl() {
        valLbl = UILabel()
        valLbl.font = UIFont.systemFont(ofSize: 25, weight: UIFontWeightBold)
        valLbl.textColor = .black
        valLbl.textAlignment = .center
        
        addSubview(valLbl)
        
        valLbl.snp.makeConstraints { (make) in
            make.edges.equalTo(self)
        }
    }
}

这个比较简单,就不多说了。

Container

Container采用了相对比较特别的设计方法,使得我们在移动格子的时候的代码操作会比较简单。总的来说,以UIStackView为核心,在方形的UIStackView容器内,放入四个横条状的UIStackView,再在第二级UIStackView内放置方格。注意,这里放入的方格并非之后用户操作移动的带数值的方格,而是空白的,没有数字显示的”placeholder tile”,其作用是标记方格位置。当我们需要把一个带数字的格子移动到某个位置时,就把其与该位置的placeholder使用Autolayout对齐起来。
本着上面的描述,诸位可以参考下面的代码来理解一下。

class Container: UIViewController {
    
    var data: GameModel
    var color: ColorProvider
    
    let tileInterval: CGFloat = 5
    let horizontalMargin: CGFloat = 20
    let tileCornerRadius: CGFloat = 4
    let boardCornerRadius: CGFloat = 8
    
    let panDistanceUpperThreshold: CGFloat = 20
    let panDistanceLowerThreshold: CGFloat = 10
    
    var board: UIStackView!
    var tileMatrx: [UIView] = []
    var foreGroundTiles: [Int: TileView] = [:]
    var scoreLbl: UILabel!
    var restartBtn: UIButton!
    
    var needsToBeRemoved: [UIView] = []
    
    init(dimension: Int, winningThreshold: Int) {
        data = GameModel(dimension: dimension, winningThreshold: winningThreshold)
        color = DefaultColorProvider()
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureBoard()
        configureTileMatrix()
        configureScoreLbl()
        configureGestureRecognizers()
        configureRestartBtn()
        
        restart()
    }
    
    func configureRestartBtn() {
        restartBtn = UIButton()
        restartBtn.addTarget(self, action: #selector(restart), for: .touchUpInside)
        view.addSubview(restartBtn)
        restartBtn.setTitle("Restart", for: .normal)
        restartBtn.setTitleColor(.white, for: .normal)
        restartBtn.backgroundColor = color.tileBackgroundColor()
        restartBtn.layer.cornerRadius = 6
        restartBtn.snp.makeConstraints { (make) in
            make.right.equalTo(board)
            make.top.equalTo(view).offset(20)
            make.width.equalTo(70)
            make.height.equalTo(30)
        }
    }
    
    func configureScoreLbl() {
        scoreLbl = UILabel()
        scoreLbl.textColor = .black
        scoreLbl.font = UIFont.systemFont(ofSize: 24, weight: UIFontWeightBold)
        scoreLbl.text = "0"
        view.addSubview(scoreLbl)
        scoreLbl.snp.makeConstraints { (make) in
            make.centerX.equalTo(view)
            make.bottom.equalTo(board.snp.top).offset(-20)
        }
    }
    
    func configureBoard() {
        board = UIStackView()
        view.addSubview(board)
//        board.backgroundColor = color.boardBackgroundColor()
        board.alignment = .center
        board.distribution = .fillEqually
        board.axis = .vertical
        board.spacing = tileInterval
        
        board.snp.makeConstraints { (make) in
            make.left.equalTo(view).offset(horizontalMargin)
            make.right.equalTo(view).offset(-horizontalMargin)
            make.height.equalTo(board.snp.width)
            make.centerY.equalTo(view)
        }
        
        let boardBackground = UIView()
        boardBackground.backgroundColor = color.boardBackgroundColor()
        board.addSubview(boardBackground)
        boardBackground.layer.cornerRadius = boardCornerRadius
        boardBackground.snp.makeConstraints { (make) in
            make.edges.equalTo(board).inset(-tileInterval)
        }
    }
    
    func configureTileMatrix() {
        for _ in 0..<getDimension() {
            let stack = UIStackView()
            board.addArrangedSubview(stack)
            configureHorizontalStackViews(stack)
            for _ in 0..<getDimension() {
                let tile = createTilePlaceholder()
                stack.addArrangedSubview(tile)
                tile.snp.makeConstraints({ (make) in
                    make.height.equalTo(tile.snp.width)
                })
                tileMatrx.append(tile)
            }
        }
    }
    
    func configureHorizontalStackViews(_ stackView: UIStackView) {
        stackView.backgroundColor = .clear
        stackView.spacing = tileInterval
        stackView.axis = .horizontal
        stackView.alignment = .center
        stackView.snp.makeConstraints { (make) in
            make.left.equalTo(board)
            make.right.equalTo(board)
        }
    }
    
    func createTilePlaceholder() -> UIView {
        let tile = UIView()
        tile.backgroundColor = color.tileBackgroundColor()
        tile.layer.cornerRadius = tileCornerRadius
        return tile
    }
    
    func getDimension() -> Int {
        return data.dimension
    }
    
    func updateScore() {
        scoreLbl.text = "Score: \(data.score)"
    }
    
    // 创建手势识别器,用来识别用户的滑动操作
    func configureGestureRecognizers() {
        createGestureRecognizer(withDirections: [.up, .down, .right, .left]).forEach({ view.addGestureRecognizer($0) })
    }
    
    func createGestureRecognizer(withDirections directions: [UISwipeGestureRecognizerDirection]) -> [UIGestureRecognizer]{
        return directions.map({ (dir) -> UIGestureRecognizer in
            let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
            swipe.direction = dir
            return swipe
        })
    }
    
    func swiped(_ swipe: UISwipeGestureRecognizer) {
        let move: MoveCommand
        switch swipe.direction {
        case UISwipeGestureRecognizerDirection.up:
            move = UpMoveCommand()
        case UISwipeGestureRecognizerDirection.down:
            move = DownMoveCommand()
        case UISwipeGestureRecognizerDirection.left:
            move = LeftMoveCommand()
        case UISwipeGestureRecognizerDirection.right:
            move = RightMoveCommand()
        default:
            fatalError()
        }
        let result = data.perform(move: move)
        print(result)
        self.move(withActions: result)
    }
    
    func move(withActions actions: [MoveAction]) {
        if actions.count == 0 {
            if data.userHasLost() {
                restart()
            }
            return
        }
        
        actions.filter({ $0.val < 0 }).forEach({ moveTile(from: data.coordinateToIndex($0.src), to: data.coordinateToIndex($0.trg)) })
        UIView.animate(withDuration: 0.1, animations: {
            self.view.layoutIfNeeded()
        })
        
        actions.filter({ $0.val >= 0 }).forEach({ showNewTile(at: data.coordinateToIndex($0.trg), withVal: $0.val) })
        
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.21) {
            self.removeViewsNeededToBeRemoved()
            self.addNewRandomTile(animated: true)
            self.updateScore()
        }
    }
    
    func removeViewsNeededToBeRemoved() {
        for view in needsToBeRemoved {
            view.removeFromSuperview()
        }
        needsToBeRemoved.removeAll()
    }
    
    func moveTile(from idx1: Int, to idx2: Int) {
        guard let tileFrom = foreGroundTiles[idx1] else {
            assertionFailure()
            return
        }
        
        let trgTilePh = tileMatrx[idx2]
        tileFrom.snp.remakeConstraints { (make) in
            make.edges.equalTo(trgTilePh)
        }
        
        foreGroundTiles[idx1] = nil
        if let oldView = foreGroundTiles[idx2] {
            needsToBeRemoved.append(oldView)
        }
        foreGroundTiles[idx2] = tileFrom
    }
    
    func showNewTile(at idx: Int, withVal val: Int) {
        let tile = createNewTile()
        tile.val = val
        if let oldView = foreGroundTiles[idx] {
            needsToBeRemoved.append(oldView)
        }
        foreGroundTiles[idx] = tile
        
        let trgTilePh = tileMatrx[idx]
        
        view.addSubview(tile)
        tile.snp.makeConstraints { (make) in
            make.edges.equalTo(trgTilePh)
        }
        UIView.animate(withDuration: 0.1, delay: 0.05, animations: {
            tile.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
            }) { (_) in
                UIView.animate(withDuration: 0.05, animations: {
                    tile.transform = .identity
                })
        }
    }
    
    // MARK: - Game logic
    
    func restart() {
        data.clearAll()
        for (_, tile) in foreGroundTiles {
            tile.removeFromSuperview()
        }
        foreGroundTiles.removeAll()
        
        addNewRandomTile()
        addNewRandomTile()
        
        updateScore()
    }
    
    func addNewRandomTile(animated: Bool = false) {
        let val = data.getValueForInsert()
        let idx = data.insertTilesAtRandonPosition(with: val)
        if idx < 0 {
            return
        }
        let tile = createNewTile()
        tile.val = val
        assert(foreGroundTiles[idx] == nil)
        foreGroundTiles[idx] = tile
        
        let placeHolder = tileMatrx[idx]
        tile.snp.makeConstraints { (make) in
            make.edges.equalTo(placeHolder)
        }
        
        if animated {
            tile.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
            UIView.animate(withDuration: 0.2, animations: { 
                tile.transform = .identity
            })
        }
    }
    
    func createNewTile() -> TileView{
        let tile = TileView()
        tile.color = color
        view.addSubview(tile)
        tile.layer.cornerRadius = tileCornerRadius
        
        return tile
    }
}

在上面的代码中我们还引入了一些控制按钮,比如重新开始,这部分并不困难,相信你能理解。不过,里面关于逻辑控制的代码,可能需要特别说明一下。其中最为核心的函数为move(withAction:)函数,我们把这个函数以及其调用的函数单独拎出来说明一下。

    // 解析一次滑动产生的`MoveAction`操作列表
    func move(withActions actions: [MoveAction]) {
        // 列表为空,那么有可能是用户已经无路可以走了
        if actions.count == 0 {
            if data.userHasLost() {
                // 这里我们是直接自动重新开始游戏了,你也可以选择弹出提示框告诉用户已经失败
                restart()
            }
            return
        }
        
        // `val`字段小于0的MoveAction是指纯粹的移动。将这些指令筛选出来,进行移动操作
        actions.filter({ $0.val < 0 }).forEach({ moveTile(from: data.coordinateToIndex($0.src), to: data.coordinateToIndex($0.trg)) })
        // 驱动移动动画
        UIView.animate(withDuration: 0.1, animations: {
            self.view.layoutIfNeeded()
        })
        
        // `val`字段非负的MoveAction是指合并后新的格子的生成。将这些指令筛选出来,并构造新的Tile
        actions.filter({ $0.val >= 0 }).forEach({ showNewTile(at: data.coordinateToIndex($0.trg), withVal: $0.val) })
        
        
        // 稍微等待一段很短的时间以后,在空格处插入一个新的格子,并且更新分数
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.21) {
            // 注意在上面的操作之后,有一些格子需要移除,主要是合并的格子,在新的格子产生以后需要将原来的两个格子去掉
            self.removeViewsNeededToBeRemoved()
            self.addNewRandomTile(animated: true)
            self.updateScore()
        }
    }
    
    func removeViewsNeededToBeRemoved() {
        // 需要被移除的格子被暂存在了`needsToBeRemoved`队列中
        for view in needsToBeRemoved {
            view.removeFromSuperview()
        }
        needsToBeRemoved.removeAll()
    }
    
    // 处理格子的移动
    func moveTile(from idx1: Int, to idx2: Int) {
        // `foreGroundTiles`是我们建立的一个由位置到格子的索引表
        guard let tileFrom = foreGroundTiles[idx1] else {
            assertionFailure()
            return
        }
        
        // `tileMatrix`是placeholder的索引表
        let trgTilePh = tileMatrx[idx2]
        
        // 移动格子
        tileFrom.snp.remakeConstraints { (make) in
            make.edges.equalTo(trgTilePh)
        }
        
        // 更新`foreGroundTiles`索引表
        foreGroundTiles[idx1] = nil
        // 注意,这里是为了保证在目标位置在一次操作完成后总是最多只有一个格子。
        // 设想在一次合并过程中,两个格子会一起移动到同一个目标位置,那么第二次
        // 移动执行时,会把前一个移动到这里的格子标记为需要移除
        if let oldView = foreGroundTiles[idx2] {
            needsToBeRemoved.append(oldView)
        }
        foreGroundTiles[idx2] = tileFrom
    }
    
    // 生成新的格子
    func showNewTile(at idx: Int, withVal val: Int) {
        let tile = createNewTile()
        tile.val = val
        // 和上面moveTile(from:to:)末尾的注释接起来。新的格子生成后,会把之前第二个移动到这里的格子标记为
        // 需要移除
        if let oldView = foreGroundTiles[idx] {
            needsToBeRemoved.append(oldView)
        }
        foreGroundTiles[idx] = tile
        
        let trgTilePh = tileMatrx[idx]
        
        view.addSubview(tile)
        // 移动格子
        tile.snp.makeConstraints { (make) in
            make.edges.equalTo(trgTilePh)
        }
        // 动画
        UIView.animate(withDuration: 0.1, delay: 0.05, animations: {
            tile.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
            }) { (_) in
                UIView.animate(withDuration: 0.05, animations: {
                    tile.transform = .identity
                })
        }
    }
ColorProvider

这就比较简单了,直接贴代码吧,大家都能看懂的吧。

extension UIColor {
    static func RGB(r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) -> UIColor {
        return UIColor(red: r / 255, green: g / 255, blue: b / 255, alpha: a / 100)
    }
    
    static func RGB(r: CGFloat, g: CGFloat, b: CGFloat) -> UIColor {
        return UIColor.RGB(r: r, g: g, b: b, a: 100)
    }
}


protocol ColorProvider {
    func colorForValue(_ val: Int) -> UIColor
    func boardBackgroundColor() -> UIColor
    func tileBackgroundColor() -> UIColor
    func textColorForVal(_ val: Int) -> UIColor
}

class DefaultColorProvider: ColorProvider {
    private var colorMap: [Int: UIColor] = [
        2: UIColor.RGB(r: 240, g: 240, b: 240),
        4: UIColor.RGB(r: 237, g: 224, b: 200),
        8: UIColor.RGB(r: 242, g: 177, b: 121),
        16: UIColor.RGB(r: 245, g: 149, b: 99),
        32: UIColor.RGB(r: 246, g: 124, b: 95),
        64: UIColor.RGB(r: 246, g: 94, b: 59)
    ]
    
    func colorForValue(_ val: Int) -> UIColor {
        if let result = colorMap[val] {
            return result
        } else {
//            fatalError()
            return UIColor.red
        }
    }
    
    func textColorForVal(_ val: Int) -> UIColor {
        if val >= 256 {
            return UIColor.white
        } else {
            return UIColor.black
        }
    }
    
    func tileBackgroundColor() -> UIColor {
        return UIColor.RGB(r: 204, g: 192, b: 180)
    }
    
    func boardBackgroundColor() -> UIColor {
        return UIColor.RGB(r: 185, g: 171, b: 160)
    }
}

启动APP

剩下的工作是把在AppDelegate.swift文件里面加上合适的代码来启动我们的APP了:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        self.window = UIWindow(frame: UIScreen.main.bounds)
        // Override point for customization after application launch.
        self.window!.backgroundColor = UIColor.white
        let container = Container(dimension: 4, winningThreshold: 2048)
        self.window?.rootViewController = container
        self.window!.makeKeyAndVisible()
        return true
    }

总结一下

这篇blog工程量可不小啊,里面肯定有很多瑕疵的地方,大家遇到什么问题在评论里指出,我会尽快回答。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,569评论 25 707
  • Objective-C 编译器与运行时系统支撑着OC程序的运行。 Objective-C程序在三个层面上与runt...
    4d1487047cf6阅读 491评论 0 1
  • 又是一年一度端午节,大多数人在意的还是三天的假期,可以家人团聚,朋友相约,南方地区节日氛围更浓厚的还有龙舟比赛,但...
    悲观的理想主义者阅读 346评论 0 0
  • 关于自己 北上广深,最近看《欢乐颂》,自己一个人很有感触,遇到朋友不容易,遇到合得来的朋友更不容易。一个...
    姑娘很文静的阅读 263评论 0 0
  • 姐姐就是不一样 总是不一样 每当我遇到生命中的流离失所 总会想到她 那副不屑一顾的决绝 对于生活 她的化妆包永远鼓...
    我不是社会人阅读 106评论 0 0