用canvas写一个“火箭车”小游戏

写在前面

先玩一把再看(额声明:并没有通关的说)
(注:本文写在游戏写完之后,所以代码并不是从 0 开始的)
小时候在小霸王上玩过一款游戏,忘记名字了,应该是叫火箭车吧,看起来蛮简单的(这 flag 立的太明显了(T^T)),正好这几天比较闲,就想拿 canvas 实现一下,因为之前一直不能理解 canvas 动画,正好也熟悉熟悉锻炼一下(其实还想练练写文章)。

只会写干货

怎么写游戏呢,没经验,首先想到的是我的游戏里要有什么,回忆火箭车简单的分析一下,

  • 地图上有公路,公路两旁是草地
  • 有一辆自己的车,可以左右上下移动
  • 有 n 个对手,对手的速度不一致
  • 有障碍物
  • 地图的路会拐弯
  • 有碰撞,碰撞后车会旋转速度减为 0
  • 有进度条

嗯其实功能点还是不少的,挨个儿来。

Before the first, 要明白 canvas 的动画原理,就像电影一样,是按桢来的,所以要有一个函数(就叫她 draw吧) 来绘制每一帧的画面,然后 requestAnimationFrame 来将每一帧连起来, requestAnimationFrame 是大约每 16 毫秒执行一次,所以画面非常连贯,看起来就是动画了。

所以 canvas 动画是这么运作之后,先找素材吧,images.google.com 直接搜 car top 就是满屏的车辆俯视图,挑一些好看的合适的,拿 PS 切成 png 图,裁剪掉透明像素就好了。

接下来我们先定义一些变量,比如我们的小车车、canvas 对象等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var canvas = document.getElementById('canvas') // canvas 元素
var context = canvas.getContext('2d') // 画布
var contextWidth = canvas.width // 画布宽
var contextHeight = canvas.height // 画布高
var car = {
img: 'car.png', // 小车车的背景图片
x: 0, // x 轴坐标
y: 0, // y 轴坐标
width: 0, // 小车车的宽度
height: 0 // 小车车的高度
}

// 移动小车车时的坐标和速度
var move = {
x: 0, // 按一下方向键移动的像素距离,x 轴
y: 0,
speed: 2, // 移动速度
friction: 0.9 // 摩擦力
}

好的然后我们需要定个流程骨架,先什么再什么,嗯我们需要个 init 函数来初始化,初始化里来启动第一桢,由于 canvas 动画的原理,即意味着每一帧都要把所有内容都要绘制一遍,所以 draw 里边我们先

  1. clearContext清掉上一桢
  2. drawBackground画背景草地
  3. drawRoad 画公路
  4. drawEnemys 画对手的小车车
  5. drawCar 画自己的小车车
  6. requestAnimationFrame(draw) 画下一桢

这么定义。另外还需要个 listener 函数来监听方向键按键。

1. clearContext清掉上一桢

这个简单,

1
2
3
function clearContext() {
context.clearRect(0, 0, contextWidth, contextHeight)
}

即可。

2. drawBackground画背景草地

我觉得还是先找素材吧,Google 了一通也没找到合适的就随便选了张拿 PS 剪了剪,然后定义变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var road = {
img: new Image(),
direction: 1, // 本想用作公路的方向,并未实现
path: [], // 本想用作公路的数据,并未实现
meadowI: 0, // 草地移动距离
meadows: [], // 草地数据
zebraCrossing: [], // 公路分割线数据
zebraCrossingI: 0, // 公路移动距离
meters: 0, // 分?
width: 0 // 公路的宽度
}
road.img.src = 'roads.png' // 草地的图片
road.img.onload = function() {
var x = Math.ceil(contextWidth / this.width)
var y = Math.ceil(contextHeight / this.height)
for (var i = -1; i < y; i++) {
for (var j = 0; j < x; j++) {
road.meadows.push([j*this.width, i*this.height, this.width, this.height])
}
}
init() // 草地的图片加载完毕后初始化
listener()
}

road.meadows 为二维数组,我们是从上到下绘制,草地还需要向下移动,所以 x 轴第一列的 y 坐标应该是负数,所以从 i = -1 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function drawBackground() {
var x = Math.ceil(contextWidth / road.img.width)
var y = Math.ceil(contextHeight / road.img.height)
road.meadowI++
// 这个 10 我也不知道怎么来的,是试出来的,反正从大于 10 时重置 road.meadowI 从 0 开始绘制草地是无缝连贯的
if (road.meadowI > 10) {
road.meadowI = 0
}
if (road.meadows.length) {
road.meadows.forEach(function(i) {
// drawImage( img, x, y, width, height) 这里 y + meadowI * 6, 是每一帧草地都向下移动 meadowI * 6 个像素
context.drawImage(road.img, i[0], i[1] + road.meadowI * 6, i[2], i[3])
})
}
}

3. drawRoad 画公路

公路也很简单,画个矩形

1
2
context.fillStyle = '#888'
context.fillRect(road.x, 0, road.width, contextHeight)

嗯还没说 road.x 和 road.width 怎么来的呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var carImg = new Image()
carImg.onload = function() {
car.width = this.width
car.height = this.height
car.x = contextWidth / 2 - car.width / 2
car.y = contextHeight - car.height - move.y
road.width = car.width * 4 + 10
road.x = contextWidth / 2 - road.width / 2
var f41 = road.width / 4
for (var j = -10; j < contextHeight / 50; j++) { // 嗯这个 50 我也是拍脑袋想出来的不要问为什么
for (var i = 1; i <= 3; i++) { // 这个 3 是因为定义公路同向 4 车道
road.zebraCrossing.push([road.x + f41 * i, j * 60, 2, 40]) // 这个 60 也是
}
}
}
carImg.src = car.img

加载上自己的小车车后,把小车车放到画布的正中下,公路的宽度定为车的宽度的 4 倍外加 10 个像素(没啥原因,就是想加 10 个像素),分割线怎么画,定义每个分割线的矩形为 宽 2 像素,高 40 像素,j = -10 的原因也是因为分割线向下移动,给人一种画面在向上前进的感觉。

OK,再在空空的矩形公路上画上分割线,移动规律理同草地

1
2
3
4
5
6
7
8
9
road.zebraCrossingI++
if (road.zebraCrossingI > 30) { // 嗯不要问了,30 也是拍脑袋出来的
road.zebraCrossingI = 0
}
if (road.zebraCrossing.length) {
road.zebraCrossing.forEach(function(i) {
context.fillRect(i[0], i[1] + road.zebraCrossingI * 6, i[2], i[3])
})
}

再画两条路两边的白色不可跨域实线

1
2
3
context.fillStyle = '#fff'
context.fillRect(road.x - 3, 0, 3, contextHeight)
context.fillRect(road.x + road.width, 0, 3, contextHeight)

嗯公路就这样了,我还没想出来怎么让公路能拐弯…

4. drawEnemys 画对手的小车车

想了想对手貌似不太好实现,还是先将对手作为障碍物的存在吧,嗯还是先定义些变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var enemySrc = (function() {
var r = []
for (var i = 1; i <= 12; i++) { // 我找了 12 张不同小车车的俯视图......
r.push({
width: 0,
height: 0,
img: 'enemy'+i+'.png'
})
}
return r
}())
var enemys = [] // 障碍物数据
for (var i = enemySrc.length - 1; i >= 0; i--) {
enemySrc[i].imgObj = new Image()
enemySrc[i].imgObj.onload = (function(obj) {
return function() {
obj.width = this.width
obj.height = this.height
}
})(enemySrc[i])
enemySrc[i].imgObj.src = enemySrc[i].img
}

障碍物需要放到公路上,所以生成的时候根据 road.x road.width 公路的 x 坐标和宽度来,在初始化的时候生成第一个障碍物

1
2
3
4
5
6
7
8
9
10
11
12
// 拿到一个介于 0 到障碍物数量的随机数,因为是数组的下标所以是从 -1 开始
var _random = Math.ceil(randomWithRange(-1, enemySrc.length + 1))
var _img = enemySrc[((_random+0) >= 0 ? _random : 1)]
// 这个 _index 是表示这个障碍物放到从左数第几条车道的
var _index = Math.floor(randomWithRange(0, enemys.length + 1))
enemys = [{
img: _img,
index: _index,
x: Math.floor(road.width / 4 * _index + road.x),
y: 0,
inc: Math.ceil(randomWithRange(2, 5)) // 这是这个障碍物的移动速度
}]

我们需要生成障碍物的数据,所以需要有个 geneEnemy 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function geneEnemy() {
var _img = enemySrc[Math.ceil(randomWithRange(-1, enemySrc.length + 1))]
// Math.random() * 10 > 5 是表示 2 分之 1 的概率,enemys.length < 3 是表示总得出现的障碍物不超过 3 个
// 为啥又要 2 分之 1 的概率又要不超过 3 个的限定条件呢,嗯不然满屏都是障碍物了......
if (Math.random() * 10 > 5 && _img && enemys.length < 3) {
var _index = Math.floor(randomWithRange(0, enemys.length + 1))
// 如果没有上一个障碍物,或者将要生成的障碍物的所属车道 不等于 上一个障碍物的所属车道
if (!enemys[enemys.length - 1] || _index != enemys[enemys.length - 1].index) {
enemys.push({
img: _img,
index: _index,
x: Math.floor(road.width / 4 * _index + road.x),
y: 0,
inc: Math.ceil(randomWithRange(2, 5))
})
}
}
}

好了,接下来我们可以画障碍物了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function drawEnemy() {
if (enemys.length) {
for (var i = 0; i < enemys.length; i++) {
if (enemys[i + 1]) {
enemys[i + 1].y + enemys[i + 1].height // 这段干嘛的,我也忘了,看样子增加了障碍之间的距离......
}
}
for (var i = 0; i <= enemys.length - 1; i++) {
if (enemys[i].y > contextHeight) {
enemys = enemys.slice(i) // 当障碍物出了画布时就清掉
} else {
enemys[i].y += enemys[i].inc
context.drawImage(enemys[i].img.imgObj, 0, 0, enemys[i].img.width, enemys[i].img.height, enemys[i].x, enemys[i].y, enemys[i].img.width, enemys[i].img.height)
}
}
if (enemys[enemys.length - 1].y > (contextHeight / 3)) { // 当最后一个障碍物的 y 坐标大于画布的 3 分之 1 的时候就生成新的障碍物
geneEnemy()
}
}
}

5. drawCar 画自己的小车车

好的接下来改画我们自己的小车车了,初版的实现里我并未实现自己的车移动时有动画效果,后来搜了下才实现缓动效果

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
var keys = [];
function drawCar(e) {
if (keys[38]) {
if (move.y > -move.speed) {
move.y--;
}
}
if (keys[40]) {
if (move.y < move.speed) {
move.y++;
}
}
if (keys[39]) {
if (move.x < move.speed) {
move.x++;
}
}
if (keys[37]) {
if (move.x > -move.speed) {
move.x--;
}
}
move.y *= move.friction;
car.y += move.y;
move.x *= move.friction;
car.x += move.x;
context.drawImage(carImg, 0, 0, car.width, car.height, car.x, car.y, car.width, car.height)
}
function listener() {
document.onkeydown = function(e) {
keys[e.keyCode] = true
};
document.onkeyup = function(e) {
keys[e.keyCode] = false
}
}

这个不解释了…

6. 然后呢

嗯接下来其实可以玩了,但是还缺了点什么,对碰撞检测呢,写了一大串

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
function detectAccident() {
var carCoordinate = {
topLeft: { // 左上角
x: car.x + carCorner,
y: car.y + carCorner
},
topRight: { // 右上角
x: car.x + car.width - carCorner,
y: car.y + carCorner
},
bottomLeft: { // 左下角
x: car.x + carCorner,
y: car.y + car.height - carCorner
},
bottomRight: { // 右下角
x: car.x + car.width - carCorner,
y: car.y + car.height - carCorner
}
}
// console.log(enemys.length)
if (car.x < (road.x - carCorner)
||
(car.x + car.width) > (road.x + road.width + carCorner)
) {
throw 'AccidentException';
}
for (var i = 0; i < enemys.length; i++) {
var enemysCarCoordinate = {
topLeft: { // 左上角
x: enemys[i].x + carCorner,
y: enemys[i].y + carCorner
},
topRight: { // 右上角
x: enemys[i].x + enemys[i].img.width - carCorner,
y: enemys[i].y + carCorner
},
bottomLeft: { // 左下角
x: enemys[i].x + carCorner,
y: enemys[i].y + enemys[i].img.height - carCorner
},
bottomRight: { // 右下角
x: enemys[i].x + enemys[i].img.width - carCorner,
y: enemys[i].y + enemys[i].img.height - carCorner
}
}
if (
isAccident(carCoordinate, enemysCarCoordinate)
) {
throw 'AccidentException';
}
}
}
function isAccident(car, enemy) {
return (car.topLeft.x >= enemy.topLeft.x &&
car.topLeft.x <= enemy.topRight.x &&
car.topLeft.y >= enemy.topLeft.y &&
car.topLeft.y <= enemy.bottomLeft.y) ||
(car.topRight.x >= enemy.topLeft.x &&
car.topRight.x <= enemy.topRight.x &&
car.topRight.y >= enemy.topRight.y &&
car.topRight.y <= enemy.bottomRight.y) ||
(car.bottomLeft.x >= enemy.topLeft.x &&
car.bottomLeft.x <= enemy.topRight.x &&
car.bottomLeft.y >= enemy.topLeft.y &&
car.bottomLeft.y <= enemy.bottomLeft.y) ||
(car.bottomRight.x >= enemy.topLeft.x &&
car.bottomRight.x <= enemy.topRight.x &&
car.bottomRight.y >= enemy.topRight.y &&
car.bottomRight.y <= enemy.bottomRight.y)
}

总结

嗯,大概就是酱紫啦,再加上开始和结束,就算暂时完工了,有时间再继续研究怎么让路拐弯…,还是很遗憾并没有做的跟火箭车一样…( p_q)

代码见 resource 吧…( ̄∀ ̄)。

第一次写小游戏,欢迎大神指教(o^^o)♪。

avatar

Thomas Chan

年轻就是暴躁,年轻就是不安分,年轻就是发脾气