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..
至此,我们完成了对游戏数据表达的抽象,即将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
}
判断用户是否已经失败
这个逻辑要相对复杂一点,用户失败时,即用户无论怎么操作矩阵都不会发生变化。按照规则,用户失败应当满足下面两个条件
- 所有的格子都已经填满
- 任意一个格子和其相邻格子都无法合并
这一过程可以形成下面的代码。代码的逻辑并不十分复杂,可以通过阅读源代码进行理解。
/// 用户是已经获胜
func userHasWon() -> Bool {
return matrix.max >= winningThreshold
}
// 用户已经失败
func userHasLost() -> Bool {
return !isPotentialMoveAvaialbe()
}
/// 用户是否还有可以移动的步骤
func isPotentialMoveAvaialbe() -> Bool {
var result: Bool = false
for row in 0.. 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.. 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.. 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..= 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),我们自然地将其分解为三个子动作,分别是两个单纯移动和一个新的格子出现。对于单纯移动而值不发生变化的格子,我们将其MoveAction
的val
设置成-1,对于新出现的格子,我们将其src
设置成-1。当然,如果被合并的两个格子其中有一个没有移动,那么就只会生成一个格子移动和一个新格子产生的MoveAction
。
View部分
View部分相对比较简单,毕竟只有一个页面。View部分只涉及到两个类,分别是Container
和TileView
(格子)。
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.. 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工程量可不小啊,里面肯定有很多瑕疵的地方,大家遇到什么问题在评论里指出,我会尽快回答。