用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 对象等:

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清掉上一桢

这个简单,

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

即可。

2. drawBackground画背景草地

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

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 开始。

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 画公路

公路也很简单,画个矩形

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

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

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,再在空空的矩形公路上画上分割线,移动规律理同草地

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])
	})
}

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

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

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

4. drawEnemys 画对手的小车车

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

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 坐标和宽度来,在初始化的时候生成第一个障碍物

// 拿到一个介于 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 函数

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))
			})
		}
	}
}

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

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 画自己的小车车

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

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. 然后呢

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

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)♪。