200行,写个2048游戏

时间:2022-05-06
本文章向大家介绍200行,写个2048游戏,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

最近断断续续地在学racket [1],同时也在把学习过程中的心得汇总成一本使用 scribble [2] 撰写的电子书 [3]。有几个读者看了之后,在公众号里不约而同地留言:

学这么一门小众的语言,除了了解下Lisp的能力(魔力)外,有什么实际的用途?

如果「实际的用途」是指用其找工作,那么的确没有,在可预见的未来(3-5年)也不太会有,您可以点左上角的返回按钮退出本文;但如果「实际的用途」指写点有意义的代码,而不是翻来覆去地写求阶乘的算法,快排的算法,那么可以继续。本文讲讲如何用racket写曾经风靡的2048游戏。

2048游戏的规则是这样:

1) 开始时棋盘上随机有两个棋子,2或4都有可能,其它为空

2) 玩家可以用方向键移动棋子。移动时所有棋子一起整体移动到用户按下的方向,直到不能移动为止

3) 在移动方向上,相邻的两个数字如果相同,则合并为一个,合并后的结果为两个数字之和(即乘以2)

4) 每移动一次,棋盘上空闲的位置会随机出现2或者4,出现2的几率(90%)要远大于4(10%)

5) 当棋子布满棋盘,四个方向移动时又无法进行合并,则游戏结束

我们知道,做这样一个小游戏,最核心的就是找到其内部状态的表示方式,然后将这种状态投射到漂亮的UI上。而2048的内部状态,最好的表述方式就是一个矩阵(0表示空闲的位置)。

'((0 2 4 8)
  (2 4 8 16)
  (4 4 0 16)
  (0 0 0 4))

矩阵是一个二维数组,也可以看作是数组的数组(或者说列表的列表)。因此,对整个矩阵的移动,可以看作是每行(每个数组)的单独移动后的集合。比如向右移动(注意:以下文字小屏幕手机请自行横屏观看 ^_^):

'((0 2 4 8)          '((0 2 4 8)
  (4 4 0 4)    --     (0 0 8 4)
  (4 4 0 16)   --/     (0 0 8 16)
  (0 0 0 4))           (0 *2* 0 4))

注意按照游戏的规范,每次移动完成后,会在空闲的位置上随机出现2或4。所以这里在最后一行,第二个位置,出现了一个2。我们再看如何向上移动。

'((0 2 4 8)          '((0 4 4  8)
  (0 0 8 4)    --     (0 0 16 4)
  (0 0 8 16)   --/     (*2* 0 0  16)
  (0 2 0 4))           (0 0 0  4))

问题来了,在大部分编程语言中,由于矩阵是数组的数组,所以横向的运算的算法无法直接应用到纵向的运算上,尽管算法上完全一致。作为一个讨厌重复的程序员,能不能找到两全其美的方法?

答案是矩阵的转置:

'((0 2 4 8)          '((0 0 0  0)
  (0 0 8 4)    --     (2 0 0  2)
  (0 0 8 16)   --/     (4 8 8  0)
  (0 2 0 4))           (8 4 16 4))

向左移动:

'((0 0 0  0)          '((0 0  0 0)
  (2 0 0  2)   --      (4 0  0 0)
  (4 8 8  0)   --/      (4 16 0 0)
  (8 4 16 4))           (8 4 16 4))

再转置回去(在相同的位置添加2):

'((0 0  0 0)          '((0 4 4  8)
  (4 0  0 0)   --      (0 0 16 4)
  (4 16 0 0)   --/      (*2* 0 0 16)
  (8 4 16 4))           (0 0 0  4))

假定一张棋盘的所有状态用 board 记录,合并一行上面的元素的函数是 merge,转置的函数是 transpose,在棋盘上的任意空闲位置添加一个2/4的函数是 put-random-piece,那么对于一行的移动的代码是:

(define (move-row row v left?)
    (let* ([n (length row)]  ; 获取长度 '(4 4 0 2) -> 4
           [l (merge (filter (λ (x) (not (zero? x))) row))] ; '(4 4 0 2) -> '(4 4 2) -> '(8 2)
           [padding (make-list (- n (length l)) v)]) ; '(0 0)
      (if left?
          (append l padding) ; '(8 2 0 0)
          (append padding l)))) '(0 0 8 2)

移动整张棋盘的函数 move,在 move-row 基础上 map 一下就好。然后就可以定义四个方向上的移动了,比如说 move-leftmove-up

(define (move-left lst)
    (put-random-piece (move lst 0 #t)))

(define (move-up lst)
    ((compose1 transpose move-left transpose) lst))

这样把大的思路理顺后我们再回过头来看 merge 算法,其实就是一个递归:

(define (merge row)
    (cond [(<= (length row) 1) row]  ; row长度小于等于1直接返回
          [(= (first row) (second row)) ; 判断头两个值是否相等,然后生成新的列表
           (cons (* 2 (first row)) (merge (drop row 2)))]
          [else (cons (first row) (merge (rest row)))]))

基本的思路有了之后,我们看如何把状态投射到UI上。racket里提供 2htdp/image 可以很方便地绘出一个个cell,进而绘出一张棋盘:

然后便可以使用 big-bang 来开始游戏。big-bang 接受事件,然后进行处理。我们可以在一开始画一张空棋盘,然后每次键盘方向键按下,就进行状态调整,根据最新的状态重绘棋盘。

整个游戏制作下来,不到200行代码。就这么简单。如果你有兴趣,点击「阅读原文」看看详细的解说和代码吧。


1. 一门从scheme基础上发展起来的Lisp方言,见:http://racket-lang.org

2. racket下一个用来撰写文档的工具

3. 见:http://racket.tchen.me