2048是IOS学习的Demo中经久不衰的话题了。之前为了给后辈们讲一个关于iOS+Swift的讲座,便自己开发了一个。Github上倒是已经有了一个工程 ,但是最后的一次commit也已经是2015年的时候了,有些地方应该已经落后了吧。
这篇教程假设你已经对于Swift的基本语法只是和Xcode的使用方法有了一个比较清楚的认知。如果你还不了解这方面的知识的话,建议先去阅读以下相关文章进行入门。
这个教程的源代码已经放在了我的github主页上面: ,目前没有放License,不过你可以自由使用本文以及Github工程中的所有源代码。
话题回到项目本身。这个项目上,我也是采用了经典了MVC架构,即Model-View-Controller。在下面讲解中,我也将基本以这个顺序来介绍代码的结构与逻辑。
在这个部分,我们创建工程文件,并简要梳理一下工程的结构以及各个文件的作用。
上面已经声明,我假设你已经熟悉了Xcode的操作,故这里说的简略一点。使用Xcode创建一个Single View Application,然后删除Storyboard相关的内容,我们将会使用代码来构建页面。然后创建一下文件:
-
Model部分 - 构建起描述游戏的概念模型
2048游戏中,我们主要需要处理的是一个矩形的数据结构,为了方便的存储和处理数据,我们创建一个名为
/// 初始化函数,创建一个Matrix结构体 /// - d: 游戏中矩阵的维数,一般是4Matrix
的结构体:其思想并不复杂,
Matrix
内部包裹的仍然是一个一维数组。为了让这个Matrix
能够如同Matlab等程序中的矩阵一样可以用二元数的方式访问,我们给它添加如下的代码:同时为了传递参数的方便,我们将二元数定义成一个特定的类型,方便参数传递。
// 定了一个特殊的二元数作为空坐标Matrix
外部加上然后在
Matrix
中加上:最后我们还给Matrix加上一些有用的工具函数,用于查询和插入
/// 将矩阵的所有元素置零 /// 将元素的值插入到矩阵的指定位置,注意这个函数只能给原来为空的位置赋值 /// 矩阵指定位置是否为空(为空即是指此处为0) /// 获取矩阵中所有为空的位置 /// 矩阵中元素的最大值 /// 矩阵中所有元素的和
至此,我们完成了对游戏数据表达的抽象,即将2048中的4*4矩阵用
Matrix
来表示,并在这个结构体上定义了方便的访问方式和工具函数。Model层对外封装结构
然后我们定义Model需要对Controller露出的操作接口。定义一个新的
GameModel
类。考虑2048的游戏规则,Model层应该对上层提供如下这些接口:
- 在一个随机空位置插入一个新的格子
- 在指定位置插入一个指定值
- 对用户的上下左右滑动操作做出响应
// 在一个指定位置插入一个指定的值
// 在一个随机空位置插入一个随机的值,按照一般的规则,随机的插入2或者4,其中2的概率要远大于4
// 用户是否已经胜利
// 用户是否已经失败
// 响应用户操作,注意这里我们引入了新的MoveCommand和MoveAction的概念,这个我们会在后面详细解释
下面我们来逐个解释各个结构的功能和实现。
这个接口实现非常简单,因为我们已经在Matrix
类中实现了类似的接口。故在这里我们只需要调用对应的函数即可。
在一个随机空位插入一个随机的值
处于程序设计中函数应当保持短小精悍的原则,为了实现这个功能,我们增加几个工具函数:
// 这个函数会返回插入的位置,返回的格式为matrix内部一维数组定义下的index
// 工具函数,按照预定的概率生成2或者4
这里只需要判断matrix
中的最大值是否达到了给定的阈值即可:
这个逻辑要相对复杂一点,用户失败时,即用户无论怎么操作矩阵都不会发生变化。按照规则,用户失败应当满足下面两个条件
- 任意一个格子和其相邻格子都无法合并
这一过程可以形成下面的代码。代码的逻辑并不十分复杂,可以通过阅读源代码进行理解。
/// 用户是已经获胜
/// 用户是否还有可以移动的步骤
/// 指定的格子是否还可以移动
/// 获取一个格子的相邻格子
重置游戏只需要把matrix
中的数值清空即可
对用户的上下左右滑动操作做出响应
这个部分涉及到的就是游戏逻辑的核心了。通过对游戏规则的发现2048问题有如下的特点:
- 向某一个方向滑动时,沿该方向的各个列之间互相独立,故可以将一次滑动产生的二维格子移动合并问题,转化成为若干个独立求解的一维格子队列的移动和合并问题。
- 不同的滑动方向,其实逻辑规则是在旋转操作下是等价的。
综上所述,我们可以将游戏中针对用户操作方向做出响应计算matrix
矩阵的新值这样一个二维问题,分解为若干个线性问题的组合。例如,若某一个操作以后matrix
所代表的游戏中格子分布为:
此时用户向左滑动,则求解新的矩阵数值分布可以分解为四个子问题:即[2, 2, 0 4], [4, 0, 2, 0], [0, 0, 0, 0], [0, 0, 0, 0]。而且,由于旋转等价性,我们可以将各个方向的滑动全部都分解为一维的,向左合并的子问题。为了更形象的说明这一点,还是参照上面给出的例子。若用户向上滑动,则可以分解为[2, 4, 0, 0], [2, 0, 0,
完成了上述问题的抽象和简化以后,我们来着重分析一维的,向左合并的简化问题。这个问题的求解,可以分解成两种操作:一是从左到右,移除非零数字之间的零,我们称之为condense;二是将相邻的相等数字进行合并,我们称之为collapse。一般只需要condense — collapse两步即可,少数情况下需要最后额外进行一次condense,例如[2, 2, 2,
在编程的时候,condense是一个非常方便实现的操作。我们只需要将待处理的数组中的非零元素按照原来的顺序放到新数组里面就可以了。
在前一部分的分析中我们发现,不同方向的滑动,都可以分解为若干个一维问题,只是不同的滑动方向下,一维问题的分解方式,以及将各个解出的结果还原为二维矩阵的方式不同。而一维问题的求解方法是一致的。这种特点适合于采用多态的设计方法。即我们定义一个基类MoveCommand
,在其中实现一维问题求解的算法,而把一维问题的提取和还原的算法放在各个滑动方向对应的子类中实现:
/// 移动指令,代表用户在屏幕上的一次滑动
* 我们使用了多态来处理不同的滑动指令。
* 为了解决2048这个发生在二维空间的问题,我们需要将问题进行降维。下面以四维情况为例来说明。
* 无论用户想那个方向滑动,格子的变化,总是沿着用户滑动的方向进行,即格子其他处于同一用户滑动方向直线上格子发生交互(合并),而与其他
* 平行的直线上的格子无关。那么我们可以在用户滑动发生时,将矩阵按照用户滑动方向划分成多个组,然后在每组中独立的解决一维的合并问题。例如
* 当用户向左侧滑动是,可以将上面的矩阵拆解成|0 |0 |2 |2 |的一维问题进行求解。
* 而且容易发现,对于用户的不同滑动方向,只是一维问题分解的方式不同,求解一维问题的方法是一致的。我们用多态来实现这种复用。
在上面的代码中,我们引入了MovableTile
这个类。其作用是描述格子在一次滑动操作中的变化过程。
/// 矩阵变化过程中描述每一个格子的数据结构,可以记录格子的移动,合并,消失,以及值的改变
/// 如果此值非负,则意味着这个结构体描述了一个合并过程,并且这个src2代表参与合并的另一个格子,为默认值-1时,则意味着只是单纯的格子移动,没有发生合并
/// 这个格子是否实际发生了移动。
接下来,我们需要实现不同滑动方向对应的子类,其实现逻辑非常直观,读者可以自己理解一下:
有了上述准备,我们可以着手实现GameModel
中的接口了。把下面的函数添加到GameModel
下
/// 执行一个移动命令
// 最后生成的可供UI解析的移动命令
// 逐行或者逐列进行遍历(具体取决于滑动方向)
// 提取出一维问题,注意这里提取的是列或者行中所有格子的坐标
// 取出各个格子中的值
// 应用计算完之后的结果
// 将需要UI执行的变化返回
这里我们又引入了一个新的类MoveAction
,这个类其实是对MovableTile
的一个整理。在前面我们提到了,当MovableTile
可以描述在滑动过程中具体格子的变化。诚然,单个格子的移动我们可以直接利用MovableTile
里面的数据操纵UI,但是在发生合并是就要麻烦很多了。出于这个原因我们引入了新的MoveAction
,并且保证每个MoveAction
只对应UI中的一个格子的一个运动。其定义如下:
注意这里面和MovableTile
的一个主要不同时取消了src2
这个属性。
对于由一个MovableTile
表示的两个格子的合并过程(即src2
不为-1),我们自然地将其分解为三个子动作,分别是两个单纯移动和一个新的格子出现。对于单纯移动而值不发生变化的格子,我们将其MoveAction
的val
设置成-1,对于新出现的格子,我们将其src
设置成-1。当然,如果被合并的两个格子其中有一个没有移动,那么就只会生成一个格子移动和一个新格子产生的MoveAction
。
View部分相对比较简单,毕竟只有一个页面。View部分只涉及到两个类,分别是Container
和TileView
(格子)。
格子比较简单,除了背景以外只需要显示一个数字。我在这里使用了SnapKit这个AutoLayout库,大家可以在github上阅读以下说明。也非常推荐大家在自己的Project中使用这个库。
这个比较简单,就不多说了。
Container采用了相对比较特别的设计方法,使得我们在移动格子的时候的代码操作会比较简单。总的来说,以UIStackView
为核心,在方形的UIStackView
容器内,放入四个横条状的UIStackView
,再在第二级UIStackView
内放置方格。注意,这里放入的方格并非之后用户操作移动的带数值的方格,而是空白的,没有数字显示的”placeholder
tile”,其作用是标记方格位置。当我们需要把一个带数字的格子移动到某个位置时,就把其与该位置的placeholder使用Autolayout对齐起来。
本着上面的描述,诸位可以参考下面的代码来理解一下。
在上面的代码中我们还引入了一些控制按钮,比如重新开始,这部分并不困难,相信你能理解。不过,里面关于逻辑控制的代码,可能需要特别说明一下。其中最为核心的函数为move(withAction:)
函数,我们把这个函数以及其调用的函数单独拎出来说明一下。
这就比较简单了,直接贴代码吧,大家都能看懂的吧。
剩下的工作是把在AppDelegate.swift文件里面加上合适的代码来启动我们的APP了:
这篇blog工程量可不小啊,里面肯定有很多瑕疵的地方,大家遇到什么问题在评论里指出,我会尽快回答。