最近断断续续地在学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-left
和 move-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