用 90 行 Haskell 代码实现 2048 游戏

jopen 7年前

上个星期赖斯大学的MOOC 计算的规则 公开课在 Coursera 上开讲啦. 从第一周的材料来看,看起来它有了他们之前的课程 Python中的交互式编程介绍 所有优良的东西: 演示文稿做的很不错,也有大量的支持可用, 而布置的作业也很有趣. 第一个作业就是编写 2048 游戏的逻辑.

鉴于其设计中的根本性缺陷,我并不认为2048特别的有趣. 首先,你并不能在某个地方取得游戏的胜利. 其次,最有希望的游戏策略使得其玩起来相当的繁琐,而且最大的乐趣并不是自己的游戏技能而是随机数生成器制造的幸运连胜. 就我个人而言,更愿意选择那种有时被称为“理论完美”的游戏, 比如,游戏的一个属性使得玩它的人能够取得一个确定的胜利. 而2048的游戏结果却没有吸引到我,不过我也明白为什么会有人喜欢让瓷砖四处滑动起来.

为游戏的逻辑编写代码是相当直接的。归因于使用Python作为教学语言的计算原则课程, 对于在我的最初版本中的一个错误是由于python发生了改变,我不会感到奇怪. 我想着用Haskell写这个东西可能会更有趣, 随后就着手开始用这个语言编写了2048的一个完整实现, 包括 I/O 处理. 整个代码可以在 我的git账号 上找到. 最终结果证明,更加完整的Haskell方案所需要的代码比使用Python的程序逻辑要少几行.

作为说明,如果你到这个页面来只是为了找寻计算规则这门课程的Python作业的解决方案,那你就是在浪费时间. Haskell的实现和Python的实现很不同,使用的编程语言构造也不能在Python上用. 换言之,如果你正纠结这个作业,Haskell的源代码将不会对你有所帮助.

在这篇文章中,我仅想着重强调游戏逻辑的核心部分,因为它很好地显示了函数式编程的力量。首先,我定义一个数据类型,用于展示网格中的数字移动的方向,还有一个用于存放整数列表的列表的类型同义词,用来提高类型特征的可读性。从函数‘move’的命名可以明显看出函数的作用;再下一步,将输入作为一个网格的数字和移动方向,并产生新的网格。

data Move = Up | Down | Left | Right  type Grid = [[Int]]

2048这个游戏是在一个4x4的棋盘上进行的。开始位置在我的实现中是固定的:

start :: Grid  start = [[0, 0, 0, 0],           [0, 0, 0, 0],           [0, 0, 0, 2],           [0, 0, 0, 2]]

棋盘上可以在4个方向上对数字进行移动,意味着所有的数字的移动都会向着一个指定的方向,如果是2个数字,移动相同的方向,以彼此相临而告终,则他们合并到一起。举例来说,在如下所示的起始位置,移动方向为‘Up’,结果棋盘变成了下面所示:

[[0, 0, 0, 4],   [0, 0, 0, 0],   [0, 0, 0, 0],   [0, 0, 0, 0]]

如果网格中的起始位置移动方向为向右,则不会有任何变化。如果网格变化了,则一个新的数字会在任何空的格子中产生,这个数字可能是2或者4.

我们看这种方法,问题在于其如何更有效的建模。在网格中的任何行列,都可被理解为一个列表。行和列表之间的关系是简单明了的。列将不得不提取、 修改,或虽然再,插入。或者他们不需要?

我写了一个函数来合并一行或一列,表示为一个列表。首先,所有的0要被移动,然后该列表将被处理,合并相邻元素,如果它们包含相同的数字,接着如果必要的话,为结果中填充0.

merge :: [Int] -> [Int]  merge xs = merged ++ padding      where padding          = replicate (length xs - length merged) 0            merged           = combine $ filter (/= 0) xs            combine (x:y:xs) | x == y    = x * 2 : combine xs                             | otherwise = x     : combine (y:xs)             combine x        = x

当棋盘中的移动方心为左时,这个合并函数可以立刻被应用。其他方向的移动,然而,需要进行一些考虑,如果希望代码保持简洁。向右移动网格是通过采取反转它之前将它提交给函数merge的每一行完成的,然后再次反转结果:

move :: Grid -> Move -> Grid  move grid Left  = map merge grid  move grid Right = map (reverse . merge . reverse) grid  move grid Up    = transpose $ move (transpose grid) Left  move grid Down  = transpose $ move (transpose grid) Right

对于网格向上或者向下移动,如果你想提取出一列,对其应用合并函数,然后产生新的网格进行列的插入,这是极其痛苦的。相反,虽然一点点的线性代数知识,却导致一个更优雅的解决方案。如果你不能立即明确如何移调导致所期望的结果,请看看下面的插图。

        input       transpose   move        transpose    Up:     0 0         0 2         2 0         2 2          2 2         0 2         2 0         0 0      Down:   2 2         2 0         0 2         0 0           0 0         2 0         0 2         2 2

我Haskell的实现使用终端作为输出。它不像Gabriele Cirulli版本的JavaScript前端一样令人印象深刻,但它是可维护的,如下两个屏幕截图展示:

用 90 行 Haskell 代码实现 2048 游戏

用 90 行 Haskell 代码实现 2048 游戏

总体来讲,我对于这个原型还是很满意的。当然有几个可能的改进。一个分数跟踪器的添加将是微不足道的,虽然一个 GUI 将是一个更加耗时的努力。如果有立即响应键盘输入的程序,我会觉得这个很有趣。当前,每个通过 WASD的输入 需要点击回车键进行确认。如果只按一个键将触发程序执行的下一步,那么游戏玩法会加快很多。在研究这一问题时,我没有找到任何快速的解决办法。尽管Haskell库NCurses包含键盘事件。我可能会深入探究一下,如果我用ASCII 图形进行编程使之成为一个“独立”游戏。

如果你觉得这篇文章有趣,请随意看看我的 2048的 Haskell 实现的源代码