多年后终于(开始)完成的夙愿——2048

嗯,我想写一个2048已经好久好久了……

人蠢是一种怎样的体验?
连个2048都不会写。

懒得上色的2048

当然啦,我说的2048一定是指带动画的版本,静态的我早就写过了。“写个动画有什么难的?”我在动手写之前也是这么想的,后来发现这背后的问题大了……不过在此之前,我们先明确一下2048的规则。

一群2们究竟是怎么合成一个2048的

一个简单的问题:一行四个2,为什么合成的是两个4,不是一个8或者一个4+两个2?

在2048中,一次滑动之后,要经过以下的步骤:

  1. 按照滑动的方向把数字分为四列,每列之间互不干涉。
  2. 按照滑动的方向把各数字标号,手指轨迹指向的方向为“大端”,从大端开始分别标号为1,2,3,4。
  3. 从2号数字开始,称为当前数字,执行以下的步骤
    1. 查看当前数字前面的一个位置;
    2. 若该位置被标记为“已合并”,则当前数字停在该位置之后的一个位置上;
    3. 若该位置上有数字,则若该数字与当前数字一样,就将当前数字合并到该位置上,并将该位置标记为“已合并”;否则,将当前数字移动到该位置之后的一个位置上;
    4. 若该位置没有数字,则继续看该位置之前的一个位置,并重复以上步骤;若一直看到1号位置都没有数字,则将当前数字移动到1号位置上。

以上的步骤对每列的数字顺次执行,即前一个数字操作完了后一个数字再开始,当所有数字都操作完时这次滑动的效果全部完成。如果在整个过程中没有任何一个数字的位置或大小发生变化,则认为这是一个无效操作;否则在空格区域随机添加一个数字,位置完全随机,数字有十分之一的概率是4,其余是2。开局的两个数字与此一致。


经过上面的一番长篇大论,大致可以看出,操作的基本单位是数字,而基本操作有三种:移动,合并和新增。其中合并是指一个数字不动,另一个数字移动到其位置与之合并,该操作又可以分为两步:先移动,然后目的地的数字替换为加倍的数字。为什么要把合并拆开看?仔细观察2048游戏界面就会发现,每次滑动过后的动画,是按照移动、合并和新增的顺序进行的,只有所有的移动动画全部结束以后(无论是单纯的移动还是合并的前半部分),才会开始合并的动画。到这里,为什么动画难实现的回答也有了一半:所有的操作不能按照算法的顺序直接作用在局面上,要先存起来,整理好顺序才能执行。

而另一半原因则是这三种操作本身的区别:移动和替换都是针对一个数字,而新增则是从无到有;移动时,两个数字可能会移动到同一个位置上。按照一般的思路……算了我已经把一般的思路忘得差不多了。接下来就是Try2048登场的时间了。


相比起2048的“官方”实现,这应该算是一个超级短小的程序,目前还没有500行,因此没有分散在多个文件里,预期就是让读者可以一口气看到低——记得当初实在写不出去看官方版本的时候,还没有看懂那些现在看来连设计模式都算不上的封装就已经被吓跑了,真是怀念啊。写代码的思路基本是分层,然后由下至上的实现,因此本文也采取这样的顺序。

最底层并不是画界面,我认为这个层面可以慢慢写,所以首先抽象了三个基本操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Advent(x, y, number, finished)
{
console.log("Number " + number + " appears at " + StringP(x, y));
DisplayAdvent(x, y, number, finished);
}
function Move(x, y, newX, newY, finished)
{
console.log("The number at " + StringP(x, y) + " is now at " +
StringP(newX, newY));
DisplayMove(x, y, newX, newY, finished);
}
function Substitute(x, y, number, finished)
{
console.log("The number at " + StringP(x, y) + " is now changed to " +
number);
DisplaySubstitute(x, y, number, finished);
}

这样做之后给自己带来了一些小小的麻烦,下文中会详细讲到。可以看到,函数体原本只有一行log,后来写好了底层才对接了上来。接受的参数中finished是一个不带参数的回调函数,调用它即意味着当前基本操作完成了。是的,这些接口都会在动画执行之前就返回,因此需要这个参数确定确切的完成时间。原本只打印log的时候finished就直接就地调用了,现在则需要传下去。

有了基本操作的表示以后,进入到最复杂也是最重要的一个抽象层次:Turn。它代表着一次滑动所触发的所有基本操作的集合,对上层提供的接口除了向集合中添加基本操作外,还有一个Trigger函数,在执行时会按照上文所述的顺序执行所有的基本操作,并且等待一类基本操作都执行完了再执行下一类,它还可以携带一些钩子函数,在合适的时机触发它们。首先是内部数据结构:

1
2
3
4
5
6
7
function Turn()
{
this._actions = {advent: [], move: [], substitute: []};
this._remain = {advent: 0, move: 0, substitute: 0};
var self = this;
// ...
}

其中_actions存了每种基本操作的每一个操作的调用参数,比如advent中的元素形式为{x, y, number},与上面的接口相对应,而_remain中则是每种基本操作的剩余个数。

接下来是向Turn中添加操作的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
this.Advent = function(x, y, number)
{
self._remain.advent++;
self._actions.advent.push({x: x, y: y, number: number});
};
this.Move = function(x, y, newX, newY)
{
self._remain.move++;
self._actions.move.push({x1: x, y1: y, x2: newX, y2: newY});
};
this.Merge = function(x, y, toX, toY, number)
{
self.Move(x, y, toX, toY);
self._remain.substitute++;
self._actions.substitute.push({x: toX, y: toY, number: number * 2});
};

其中,Merge在该层被抽象出来,上层将可以使用真正需要的三个接口进行调用。接下来的Trigger函数……做好心理准备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
this.Trigger = function(afterMove=null, afterAdvent=null)
{
function CommonPatternHere(dataSet, call, decrease, beforeNext,
after)
{
return function()
{
if (dataSet.length === 0)
{
after();
return;
}
while (dataSet.length > 0)
{
var data = dataSet.pop();
call(data, function()
{
// It is heard that in Javascript there's no data race.
// The following code need fix if I was wrong.
// `decrease` will change count value and return its new
// value. It must be wrapped to change the origin var.
if (decrease() === 0)
{
if (beforeNext !== null)
beforeNext();
after();
}
});
}
};
};
// ...
}

以上是该函数的第一部分。由于对于三种基本操作的处理方法基本一致,因此抽象出一个pattern。整个思路还算清晰,有点别扭的大概就是上面的decrease了,这是因为原本调用是用的self._remain.advent之类,后来发现不行,因此基本类型是传值调用的,于是改成{value: self._remain.advent},结果还是不行,因为保留更改的只是这个匿名对象里的值,而从self._remain.advent到这个匿名对象还是复制了……所以接下来如果看到这种东西:

1
function() { return --self._remain.advent; }

希望你能体会我的无奈。

哦你说为啥参数里没有afterMerge?这不是赤裸裸的歧视吗?一方面我还没想好怎么把它对接到替换上去,另一方面……其实这俩也是因为用上了才慢慢加上的,afterMerge?没有这种需求,当然就没有啦。

看完第一部分我想你已经猜到了接下来怎么调用它,不过也许你发现了一些不得了的东西:为什么这个函数要返回一个匿名函数?这不是多此一举吗?如果我告诉你这是为了保持和after参数形式的一致性,我想你大概能预感到一些可怕的事情了。没错,我遵循那个著名的信条:

嵌套回调函数是丑陋的。

原话大概还包含了对其的一个形象的比喻,不过被我忘记了。总之,作为嵌套回调转化为链式的准备步骤,就有了接下来的第二部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function CurryList()
{
this._list = [];
var self = this;
this.Call = function(dataSet, call, decrease,
beforeNext=null)
{
self._list.push({dataSet: dataSet, call: call,
decrease: decrease, beforeNext: beforeNext});
return self;
};
this.List = function()
{
return self._list.map(function(argument)
{
return function(after)
{
return CommonPatternHere(argument.dataSet,
argument.call, argument.decrease,
argument.beforeNext, after);
};
});
};
}

可惜我当时脑子不太好使,不然应该可以写出一个不需要缓存参数的版本。现在……脑子更不好使了,就当没看见吧。从嵌套到平铺的一个关键问题就是没有after,因此首先对各个函数调用进行科里化,延缓其对after的需求。最后,第三部分包含了套参数和组装的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
new CurryList()
.Call(self._actions.move,
function(move, finished) {
Move(move.x1, move.y1, move.x2, move.y2, finished);
}, function() { // I want pointer!
return --self._remain.move;
}, afterMove)
.Call(self._actions.substitute,
function(sub, finished) {
Substitute(sub.x, sub.y, sub.number, finished);
}, function() {
return --self._remain.substitute;
})
.Call(self._actions.advent,
function(advent, finished) {
Advent(advent.x, advent.y, advent.number, finished);
}, function() {
return --self._remain.advent;
}, afterAdvent)
.List().reduceRight(
function(after, previous) { return previous(after); },
function() { console.log("All done for this turn."); })();

使用reduceRight就可以按照自然顺序进行链式调用了(“在CurryList里不能处理吗?”“听不见听不见……”),顺便你会发现在接口层当中预留的finished函数其实就是上面第一部分中定义的,基本逻辑是给计数器减一,到0就调用after开始下一阶段。afterXXX也都被安排到了合理的位置上。

顺便一说写到reduceRight的时候我突然想起对IE的兼容性问题,于是万念俱灰的用IE11打开这个页面,没想到在去掉了默认参数和Array.fill以后居然正确的运行了!这一刻我的喜悦和程序刚刚跑通时相比有增无减。


下一个抽象层次是Grid,它代表一张棋盘。在这里,我们终于要迎来喜闻乐见的游戏逻辑,而在此之前,问自己一个问题:如何避免把同样的代码写四遍?官方实现用的方法非常精巧,而我就笨拙得多了。进入主题前先看看内部数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
function Grid(size)
{
this._grid = new Array(size);
for (var i = 0; i < this._grid.length; i++)
{
this._grid[i] = new Array(size);
for (var j = 0; j < this._grid[i].length; j++)
this._grid[i][j] = 0; // 0 means no number here.
}
var self = this;
// ...
}

一个简单的二维数组,因为去掉了Array.fill所以显得有些啰嗦,不过倒是有点像我上一篇文章里说的,有点C程序的感觉了,只是现在……我谢天谢地它不是用C写的。跳过生成随机数字的函数,首先来看Slide的第一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// `direction`: 0 => up, 1 => right, 2 => down, 3 => left.
this.Slide = function(direction, turn)
{
// Slide `numbers` toward numbers[0].
function SlideVector(numbers, move, merge)
{
var merged = new Array(size);
for (var i = 0; i < merged.length; i++)
merged[i] = false;
var changed = false;
for (var i = 1; i < size; i++) // First number stays.
{
if (numbers[i] === 0)
continue;
for (var j = i - 1; j >= 0; j--)
{
if (merged[j]) // Merged number will not be modified again.
{
if (j + 1 !== i)
{
changed = true;
move(i, j + 1);
numbers[j + 1] = numbers[i];
numbers[i] = 0;
}
break;
}
if (numbers[j] !== 0)
{
if (numbers[j] === numbers[i]) // Merge same numbers.
{
changed = true;
merge(i, j, numbers[i]);
merged[j] = true;
numbers[j] += numbers[i];
numbers[i] = 0;
}
else // Move to neighbour.
{
if (j + 1 !== i)
{
changed = true;
move(i, j + 1);
numbers[j + 1] = numbers[i];
numbers[i] = 0;
}
}
break;
}
if (j === 0) // Stop moving on the edge.
{
changed = true;
move(i, 0);
numbers[0] = numbers[i];
numbers[i] = 0;
break;
}
}
}
return changed;
}
// ...
};

这个子函数的功能是把一列数向着它的首元素方向“滑动”,逻辑基本就是上文的复述。这里主要注意的是移动数字以后对于“是否改变”的判定,要把“原地移动”的情况排除掉。想来这个函数功能的简单出乎各位看官的预料,那么更多的功能显然交给调用方了。大家等不及看四遍重复的代码了吧?然而,还真没有……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var UP = 0, RIGHT = 1, DOWN = 2, LEFT = 3;
var changed = false;
for (var first = 0; first < size; first++)
{
var numbers = new Array(size), iX = new Array(size),
iY = new Array(size);
for (var second = 0; second < size; second++)
{
var sec = direction === UP || direction === LEFT ? second :
size - second - 1;
// I hate 80 ruler.
iX[second] =
direction === UP || direction === DOWN ? first : sec;
iY[second] =
direction === UP || direction === DOWN ? sec : first;
// console.log(second + " " + StringP(iX[second], iY[second]));
numbers[second] = self._grid[iX[second]][iY[second]];
}
// It's a little breaking my rule to put left brace on the same line
// of previous code, but it seems too ugly otherwise.
var c = SlideVector(numbers, function(from, to) {
turn.Move(iX[from], iY[from], iX[to], iY[to]);
self._grid[iX[to]][iY[to]] = self._grid[iX[from]][iY[from]];
self._grid[iX[from]][iY[from]] = 0;
}, function(from, to, number) {
turn.Merge(iX[from], iY[from], iX[to], iY[to], number);
self._grid[iX[to]][iY[to]] += self._grid[iX[from]][iY[from]];
self._grid[iX[from]][iY[from]] = 0;
});
changed = c ? true : changed;
}
if (changed)
self.AddRandom(turn);
return changed;

在对不同的情况把numbers当中的数排好顺序的同时,填好iXiY两个数组,作为两个平行数组分别储存numbers中各元素的x和y坐标。在SlideVector中使用的movemerge也在此处被包装好,提供足够的信息去调用下层的接口。在此之后,Grid当中还有一个判断游戏是否结束的函数,主要检查两件事:还有没有空位,以及有没有相邻的相同元素,符合任何一个条件都还是活局,否则就死了。

以上两个层次作为这篇代码的精华,也是最华而不实的部分……如果把其中为了形式美观做的抽象(科里化、SlideVector子函数等)都去掉,再把所有的左大括号拿到上一行去(做梦吧),这篇代码也许就只剩300多行了。


接下来是组装前的最后一个步骤:画界面层。在这里我就遇到了之前给自己挖的坑:接口层留的三个基本接口实在是太“纯粹”了,它们都是无状态的接口,不能在两次调用之间留存任何信息。而偏偏图形层就需要一些信息:比如说前一个调用创建了一个数字,后一个调用去移动它的时候,得先在DOM树上把它找回来。之前采用id来标识,就会遇到两个数字移动到同一个位置的尴尬局面。最后先妥协了一下,用了全局变量,之后尽量把它改过来,先凑合着看吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// Display Part.
// This is the base of a more complicated implementation of interfaces.
function DisplayInitialize()
{
var container = document.createElement("div");
container.id = "board-tiles-container";
container.style.width = container.style.height = "450px";
document.getElementById("board-container").appendChild(container);
}
var searchTable = new Object, registryTable = new Object, deadQueue = new Array;
function DisplayAdvent(x, y, number, finished)
{
var tile = document.createElement("div");
tile.innerText = number;
tile.classList.add("board-tile");
tile.style.transition = "all 0.05s";
tile.style.width = tile.style.height = 0;
tile.style.lineHeight = "100px";
tile.style.fontSize = 0;
tile.style.margin = "50px";
tile.style.top = (y * 110 + 10) + "px";
tile.style.left = (x * 110 + 10) + "px";
document.getElementById("board-tiles-container").appendChild(tile);
searchTable[StringP(x, y)] = tile;
setTimeout(function() {
tile.style.width = tile.style.height = "100px";
tile.style.fontSize = "20px";
tile.style.margin = "";
}, 0);
OnceListener(tile, finished);
}
function DisplayMove(x, y, newX, newY, finished)
{
var tile = searchTable[StringP(x, y)];
tile.style.transition = "all 0.2s";
tile.style.top = (newY * 110 + 10) + "px";
tile.style.left = (newX * 110 + 10) + "px";
// There's no need to worry about overriding.
// Any override tile will be override again by substitution.
// So any of them survives will be okay.
if (registryTable[StringP(newX, newY)] !== undefined)
{
var dead = registryTable[StringP(newX, newY)];
deadQueue.push(dead);
}
delete searchTable[StringP(x, y)];
registryTable[StringP(newX, newY)] = tile;
OnceListener(tile, finished);
}
function DisplayAfterMove()
{
while (deadQueue.length > 0)
{
var dead = deadQueue.pop();
dead.parentElement.removeChild(dead);
}
Object.keys(registryTable).forEach(function(pos)
{
if (searchTable[pos] !== undefined)
{
var dead = searchTable[pos];
dead.parentElement.removeChild(dead);
}
searchTable[pos] = registryTable[pos];
});
registryTable = new Object;
// console.log(searchTable);
}
function DisplaySubstitute(x, y, num, finished)
{
var tile = searchTable[StringP(x, y)];
tile.innerText = num;
tile.style.transition = "all 0.1s";
tile.style.width = tile.style.height = tile.style.lineHeight = "110px";
tile.style.fontSize = "22px";
tile.style.margin = "-5px";
OnceListener(tile, function() {
tile.style.width = tile.style.height = tile.style.lineHeight = "100px";
tile.style.fontSize = "20px";
tile.style.margin = "";
OnceListener(tile, finished);
});
}

其中还有大量的常数,之后会抽到配置当中去。这里的三个全局(列)表都是为DisplayMove服务的,在一个Turn的周期内,所有的数字以如下的方式在这三个表中流窜:

  • 最初,所有数字都在searchTable里;
  • 移动阶段,每个数字移动的同时,把自己注册到registryTable的对应位置上,如果遇到两个数字都移动且终点相同的情况则后来者会覆盖先到者,并把它转移到deadQueue里面去。(这里谁去谁留没有区别,因为这种情况之后一定会对应一个替换步骤,所以……谁都活不了)
  • 移动结束阶段,首先deadQueue里的数字被清除掉,然后registryTable里的数字转移回searchTable里,如果转移回来的时候发现对应的格子里已经有数字了,说明这是一个数字移动到另一个静止数字位置上的情况,则静止数字被清除,由新来的数字填补。
  • 接下来的时间里,大家都一直呆在searchTable里,直到下一个移动阶段到来。

这里的一个要点就是一定要到移动结束阶段(对应于DisplayAfterMove函数)才可以开始清理重叠的数字,对于静止的数字,这是为了防止格子上的数字突然消失吓到小朋友;对于两个数字都移动的情况,别忘了它们身上还有一个finished呢,这个函数的调用时机是transitionend事件(比手动setTimeout高端多了),如果提前清掉了移动阶段就再也结束不了了。这几个方法的结尾都有一个OnceListener函数的调用,这个函数会给元素添加一个绑定在transitionend事件上的一次性回调函数,触发了就被移除,避免与下一个finished弄混。

此外,DisplayAdvent要实现的动画效果是从中心一个点开始扩大,要把设置其样式为正常值的部分放在setTimeout(..., 0)的里面,这样可以避免与前面的DOM操作合并,动画没了是小事,触发不了transitionend可就坏了。

最后,DisplaySubstitute(感谢这个程序终于让我记熟了这个词的拼写)的效果是先变大一圈再变小。你问我为什么不写成链式的了?懒啊!


好了,最后终于来到了主函数部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Main Part.
// The main game loop and entry point.
window.onload = function()
{
DisplayInitialize();
var turn = new Turn();
var grid = new Grid(4);
grid.AddRandom(turn);
grid.AddRandom(turn);
turn.Trigger();
// console.log(grid);
var animating = false, over = false;
window.addEventListener("keypress", function(e)
{
// Disable key press during animation.
if (animating || over)
return;
var map = {w: 0, d: 1, s: 2, a: 3};
if (e.key in map)
{
if (grid.Slide(map[e.key], turn))
{
animating = true;
turn.Trigger(DisplayAfterMove, function() {
animating = false;
console.log(StringG(grid));
if (grid.Over())
{
console.log("Game Over");
over = true;
}
});
}
// console.log(searchTable);
}
});
}

大部分逻辑都一目了然了。其中animating是为了在动画过程中屏蔽按键(虽然除非你是故意瞎滑也不可能那么快做出判断),而“Game Over”的界面不仅仅是没有加入到真正的页面上,甚至没有完成其正确性测试……每一局都因为各种奇奇怪怪的原因流产了……最后一次是在IE11上,我把它的console调出来看看输出,然后键盘操作就不好使了……什么龟。


感觉就像讲了一生的故事一样,这份代码经过几次推倒重来,虽然最终版只用了小半天时间,但算是近很长一段时间以来唯一一个算是完整的作品。当然啦,只能叫“算是”,毕竟这份代码的最终目的——帮我玩出4096,还一点都没开始呢。

项目地址:try-2048,欢迎丢星星。