Canvas 游戏——俄罗斯方块
我第三个动画游戏终于可以玩了,^_^。
试玩
Demo:http://ambar.github.com/Tetris/
截图
起源
所得
如何实现
由于 wikipedia 的素材很像砖块,我代码里就把 Tetromino 称为了砖块(brick),整个拼板称为墙面(wall)——分别对应了游戏中玩家控制目标和地图。
变形
首先观察 " J " 形状转置(T)和旋转(R)的图形,零表示空心部位,非零实心部位:
J =>
1,0,0
1,1,1
T =>
1,1
0,1
0,1
R =>
1,1
1,0
1,0
转置即每一行变成了目标的每一列。再比较转置和旋转的图形,可以发现,它们的区别在于每一行的元素顺序是相反的,这对应的数组方法很明晰了,即 pop 和 unshift ,实现:
// 用二维数组表示'形状'
var shape_array =
[
[1,0,0],
[1,1,1],
]
// 转置,行列交换
var transpose = function(ary){
var ret = [], row, rows = ary.length, cols = ary[0].length;
cols.times(function(y){
ret.push(row = [])
rows.times(function(x){
row.push(ary[x][y])
})
})
return ret;
}
// 向右旋转(顺时针),每行生成列元素时顺序相反
var rotate = function(ary){
var ret = [], row, rows = ary.length, cols = ary[0].length;
cols.times(function(y){
ret.push(row = [])
rows.times(function(x){
row.unshift(ary[x][y])
})
})
return ret;
}
这时,再看看向左旋转的图形,与转置图形比较可以显然看出来,把转置后 行的顺序逆转 就可以了,即可用现成的数组的 reverse 方法:
/* 逆时针旋转图示
0,1
0,1
1,1
*/
// 逆时针旋转算法
var rotate_anticlockwise = function(ary){
return transpose(ary).reverse();
}
我开始的游戏是用的逆时针旋转,我以为这更自然。试玩了一下网上其他的游戏实现,结果都是顺时针的 =_= 。
砖块定义
我定义了三个主要属性:
- position : 左下角参照坐标
- parts : 每个可见部分坐标
- shape : 形状
/*
* 以一个形状(二维数组)的左下角为参照位置,把它映射成一个位置列表(一维数组)。零为空位,全部舍弃
* @pos {vector}
* @shape {array}
*/
var mapShape = function(pos,shape) {
var ret = [], rows = shape.length, cols = shape[0].length;
rows.times(function(x){
cols.times(function(y){
shape[rows-x-1][y] && ret.push( pos.add( V([y,-x]) ) )
});
});
return ret;
}
若某砖块的参照坐标为Vector[3,4],shape 为上面演示的 J,它的 parts 则为:
// parts = mapShape( V([3,4]), shape_ary ) =>
[ Vector[3,4], Vector[4,4], Vector[5,4], Vector[3,3] ]
有了恰当的属性定义之后,移动和拼到墙面就是轻而易举的了。
地图
地图关系存储及碰撞部分,结构也和上面的形状一样,使用二维数组。
首先要注意的是屏幕上的坐标系统和数组不是直接对应的:
screen : {x,y}
┏━━━x
┃
y
array : [x][y]
┏━━━y
┃
x
屏幕上的点转置才能对应上数组的元素。
我游戏的前半程是这样存储的:
map[p.x][p.y] = type;
点 p 与地图数组元素一 一对应,这样用起来很爽,但是有两个缺点:
- 观察或打印结构不方便,需要转置地图数组。(如果拼板是 20*10,此时地图结构是的 10*20)
- 检测消行不方便,也要转置列为行。
因此,在后半程,改正了过来:
map[p.y][p.x] = type;
消行
我用的一种相当简易的做法,毫无特效——直接删除一行,再用充满零元素的模板行塞到地图头部:
function eraseRow(idx) {
this.map.splice(idx,1);
this.map.unshift(this.tmplRow);
}
碰撞
es5 的 some 方法最适合处理这个,直接接受上面的 parts 参数就好:
function collideWith(vectors) {
var map = this.map, height = this.height;
return vectors.some(function(v) {
var row = map[v.y];
return v.y >= height || (row && row[v.x]);
})
}
细节
- 明暗变化的颜色,用HSL比RGB方便太多了。
- 画多边形时,斜线会有黑色的锯齿,可以再在上面画一条线段解决。
- canvas 绘制文字消耗太大,尤其是firefox。一定要用的话,新建一个 canvas 层做背景,文字绘制到它上面,并用CSS定位把它到主画布下面。
- 未开户硬件加速的情况下,仅仅绘制普通图形,半屏之后也很卡——解决办法,用 getImageData 缓存绘制过的图形。
AMD
游戏代码后期改用 AMD 方式组织,加载器挑选了国人写的 seajs 。
AMD 是 CommonJS 倡议的模块定义方式,干掉了老套的用全局变量做命名空间的形式。 与老式的命名空间相比,AMD 的方式更加容易组织代码,结构也更干净、更一致。
这个游戏花了我几个小时来做转换,总结:
- 如果一个模块是一组功能的集合,就用 exports
- 如果一个模块是一个单一的类,就用 module.exports
- 模块定义(define函数)的第一个参数‘模块名’是完全无意义的。我开始为了能够简单的合并文件,就全部命名了一遍,后面发现太麻烦——因为你要引用它时,就可能不得不打开文件去找它取了什么名字,这很悲剧。后面就自然的全部改了。
文件的摆放层次 就是就是你的命名空间,这应该是 AMD 隐含的核心理念。
seajs 实际使用上,可能会碰到的问题:
- 循环引用。seajs 会报一个警告,仔细检查可以排除。但是,不排除也可以工作。有点纠结,现在的折衷做法是导出了一个全局变量 TetrisGame。
- define 第二个依赖参数也可能会出现模块引用的奇怪问题。同样是循环引用,但没有错误提示什么的,加载的模块也没有加载到——许久之后,提示超时了。
使用
// 网格单位长度, 默认 40
// TetrisGame.unit = 20;
// 显示网格线, 默认 true
// TetrisGame.showGrid = false;
// 主题类型 ["classic", "window", "bubble"], 默认 classic
// TetrisGame.theme = 'window';
// canvas,列数,行数,缩放
TetrisGame.init('#tetris-game',10,20,1);
// TetrisGame.init('#tetris-game',10,20,.5);