简介
经过前面的学习,我们基本掌握了常用的api和绘画技巧。现在通过实现一个小游戏,来深入了解画布在项目中如何使用。
游戏基本介绍
用户有一艘宇宙飞船,可以用箭头键左右移动,用空格键开火。屏幕顶部的敌人飞船来回移动,同时随机发射导弹。然后根据导弹和飞船的碰撞,来判断用户的飞船或的人的飞船,在什么时候被击杀。
绘制游戏背景
为了游戏,体验效果上升。我们首先绘制游戏背景,随着时间的变化,把背景上的图片对象由下向上移动。
先添加兼容性动画函数,使动画效率提升。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<style type="text/css">
.canvasDiv {
margin: 0 auto;
width: 500px;
}
</style>
<body>
<div class="canvasDiv">
<canvas width="650" height="500" id="canvas"></canvas>
</div>
<script type="text/javascript">
// 兼容性处理
window.requestAnimFrame = (function () {
return (
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function (callback, element) {
window.setTimeout(callback, 1000 / 60)
}
)
})()
</script>
</body>
</html>
初始化画布和游戏资源
/** @type {HTMLCanvasElement} */
var canvas = document.getElementById('canvas')
var c = canvas.getContext('2d')
// =========== 资源加载 ============
// 雪碧图 -- 自己的飞船 、 游戏背景物体
var sprite_image = new Image()
sprite_image.src = './sprite.png'
// =========== 游戏状态 ============
var game = {
state: 'start' // start:开始 playing:启动中 won:胜利 over:死亡
}
这里需要注意图片资源加载是异步,单独操作图片时可以在onload
事件中加载。
根据游戏状态,初始化背景图。
// =========== 背景动画 ============
// 背景图 对象
var backImgs = []
// 加载背景图数
function updatebackground() {
if (game.state === 'start') {
// 开始状态 重置 背景图片
backImgs = []
for (var i = 0; i < 4; i++) {
// 图片 精灵 位置
var imagePos =
i % 2
? { x: 270, y: 0, imgX: 150, imgY: 150, canX: 150, canY: 150 }
: {
x: 0,
y: 0,
imgX: 200,
imgY: 200,
canX: 200,
canY: 200
}
backImgs.push({
x: Math.round(Math.random() * 500), // x轴位置
y: 200 * i, // y轴位置
speed: 1, // 移动速度
// 图片中精灵的位置
imagePos: imagePos
})
}
game.state = 'playing'
}
// 位置移动
for (var key in backImgs) {
var backImg = backImgs[key]
if (backImg.y + 200 === 0) {
// 到顶部 返回底部
backImg.y = 650
backImg.x = Math.round(Math.random() * 500) // x 轴位置
}
backImg.y = backImg.y - backImg.speed
}
}
function drawBackground(c) {
c.fillStyle = 'black'
c.fillRect(0, 0, canvas.width, canvas.height)
for (var key in backImgs) {
var backImg = backImgs[key]
var imPos = backImg.imagePos
// 绘制图形
c.drawImage(sprite_image, imPos.x, imPos.y, imPos.imgX, imPos.imgY, backImg.x, backImg.y, imPos.canX, imPos.canY)
}
}
- 初始化数据和绘制图像是分开的,这样能更好的维护图片对象的状态数据。
- 通过判断游戏的状态,来初始化图片对象。因为画布高
650px
手动设置加载4个图片对象。数据初始化后,修改游戏状态,保持图片对象不会被再次初始化。 - 使用
drawImage()
根据图片对象中设置的绘制数据,获取雪片图中对应的精灵。 - 每一帧都会进入
updatebackground()
函数,修改对象对应的y轴数据,图片对象在画布中就会向上移动。 - 在函数中判断对象在画布中的位置,当超出画布后,重置对象数据到底部重新移动。
绘制敌人
在游戏中敌人在画布顶部,重复左右移动,在随机时间内,发出子弹。
加载敌人资源
// =========== 资源加载 ============
...
// 雪碧图 敌人的飞船
var hunter1_image = new Image()
hunter1_image.src = './Hunter.png'
// 炸弹 敌人的子弹
var bomb_image = new Image()
bomb_image.src = './bomb.png'
创建修改敌人对象的函数
// =========== 敌人 ============
// 敌人
var enemies = []
// 敌方 子弹
var enemyBullets = []
var num = 10 // 敌人数量
// 修改 敌人对象
function updateEnemies() {
if (game.state === 'start') {
// 每次重新开始 初始化敌人
enemies = [] // 清空敌人
enemyBullets = [] // 清空子弹
for (var i = 0; i < num; i++) {
enemies.push({
x: 50 + i * 50, // x轴 位置
y: 10,
width: 40,
height: 40,
state: 'alive', // alive: 存活 hit: 击中 dead: 死亡
counter: 0, // 计量
phase: Math.floor(Math.random() * 100) // 添加子弹的时间
})
}
game.state = 'playing'
}
// 添加子弹
for (var i = 0; i < num; i++) {
var enemy = enemies[i]
if (!enemy) continue
if (enemy && enemy.state == 'alive') {
// 存活 状态才继续
enemy.counter++
// 敌人 左右位移
enemy.x += Math.sin((enemy.counter * Math.PI * 2) / 100) * 2
// 添加 子弹时间
if ((enemy.counter + enemy.phase) % 200 == 0) {
enemyBullets.push({
x: enemy.x,
y: enemy.y + enemy.height,
width: 20,
height: 20,
counter: 0
})
}
}
// 被击中状态
if (enemy && enemy.state == 'hit') {
enemy.counter++
// 一段时间后 修改为 死亡状态
if (enemy.counter >= 20) {
enemy.state = 'dead'
enemy.counter = 0
}
}
}
// 清除死亡的敌人
enemies = enemies.filter(function (e) {
if (e && e.state != 'dead') {
return true
}
return false
})
// 修改 子弹位置
if (enemyBullets.length) {
for (var i in enemyBullets) {
var bullet = enemyBullets[i]
bullet.y += 1.2
bullet.counter++
}
// 超出屏幕 删除子弹
enemyBullets = enemyBullets.filter(function (bullet) {
return bullet.y < 600
})
}
}
- 根据画布宽度和敌人左右移动距离,计算生成10个敌人对象。
- 和之前一样根据游戏状态
game.state
判断是否初始化敌人对象,创建后修改游戏状态。在这就要特别注意,之前背景对象中初始后,修改游戏状态的代码需要注释// game.state = 'playing'
,因为游戏状态修改后会影响初始化数据,函数调用位置不可修改。 - 敌人对象初始化后,根据每一帧都会执行
updateEnemies()
函数,通过Math.sin()
来计算y轴位移,使其可以左右移动。 - 在修改位置的同时,根据函数执行次数
enemy.counter
和随机数来判断,敌人子弹何时生成。 - 判断敌人状态(击中后会修改状态)。当被击中后进入击中状态,不在产生子弹,并进入倒计时结束后,进入死亡状态。在对进入死亡状态的对象清除。
- 循环子弹对象修改位置,当超出画布时,清除子弹对象。
绘制敌人和子弹
// 绘制 敌人
function drawEnemies(cxt) {
// 敌人绘制
for (var key in enemies) {
var enemy = enemies[key]
if (enemy.state === 'alive') {
// 存活
// c.fillStyle = 'green'
cxt.drawImage(hunter1_image, 25, 50, 22, 22, enemy.x, enemy.y, enemy.width, enemy.height)
}
if (enemy.state === 'hit') {
// 击中 -- 修改为黑色
cxt.fillStyle = 'black'
cxt.fillRect(enemy.x, enemy.y, enemy.width, enemy.height)
}
if (enemy.state === 'dead') {
// 死亡 -- 不在绘制
}
}
// 子弹 --绘制
for (var i in enemyBullets) {
var bullet = enemyBullets[i]
// 切换 雪碧图 位置实现动画
var xoff = (bullet.counter % 9) * 12 + 2
var yoff = 1
cxt.drawImage(bomb_image, xoff, yoff, 9, 10, bullet.x, bullet.y, bullet.width, bullet.height)
}
}
- 根据敌人状态进入不同的绘制状态
- 子弹动画是通过雪碧图加载的,通过
bullet.counter
函数执行次数来计算,间隔多少帧后进入雪碧图下一个精灵,实现补间动画。
加入循环中
// =========== 初始化 ============
function mainLoop() {
// 清空画布
cxt.clearRect(0, 0, 650, 500)
// 修改 图像精灵
updatebackground()
// 修改 敌人对象 敌人子弹对象
updateEnemies()
// 绘制 背景图像
drawBackground(cxt)
// 绘制 --敌人 -- 敌人子弹
drawEnemies(cxt)
window.requestAnimFrame(mainLoop)
}
window.requestAnimFrame(mainLoop)
绘制用户
游戏中用户飞机可以,通过左右方向键来控制飞机移动,通过空格键发射子弹。
加载资源
// 用户 子弹
var bullets_image = new Image()
bullets_image.src = './bullets.png'
初始化用户对象和键盘事件
// 用户对象
var II = {
x: 300,
y: 400,
width: 50,
height: 50,
state: 'alive' // alive: 存活 hit: 击中 dead: 死亡
}
// 用户的子弹
var IIBullets = []
// 键盘监听
var keyboard = []
// 初始用户
function updatePlayer() {
// 死亡 不在绘制
if (II.state == 'dead') return
// 左键
if (keyboard[37]) {
II.x -= 10
if (II.x < 0) II.x = 0
}
// 右键
if (keyboard[39]) {
II.x += 10
var right = canvas.width - II.width
if (II.x > right) II.x = right
}
// 空格
if (keyboard[32]) {
if (!keyboard.fired) {
// 添加子弹
IIBullets.push({
x: II.x + 17.5,
y: II.y - 7,
width: 15,
height: 15,
counter: 0
})
keyboard.fired = true
}
} else {
keyboard.fired = false
}
// 击中状态 等待40帧 修改为 死亡状态
if (II.state == 'hit') {
II.counter++
if (II.counter >= 40) {
II.counter = 0
II.state = 'dead'
game.state = 'over'
}
}
// 修改子弹位置
if (IIBullets) {
for (i in IIBullets) {
var bullet = IIBullets[i]
bullet.y -= 8
bullet.counter++
}
// 超出屏幕 删除子弹
IIBullets = IIBullets.filter(function (bullet) {
return bullet.y > 0
})
}
- 定义好用户对象,在每次执行
updatePlayer()
时,先判断键盘监听对象keyboard
中,键盘对应的值是否是按住状态(keyboard
中的值在键盘监听事件中修改),来对用户对象修改和添加子弹。 - 击中状态,增加等待时间用于绘制时加入死亡动画。
- 修改子弹位置,超出画布清除
加入键盘事件
// =========== 键盘事件 ============
function doSetup() {
// 按下
attachEvent(document, 'keydown', function (e) {
keyboard[e.keyCode] = true
console.log(keyboard)
})
// 松开
attachEvent(document, 'keyup', function (e) {
keyboard[e.keyCode] = false
console.log(keyboard)
})
}
function attachEvent(node, name, func) {
if (node.addEventListener) {
node.addEventListener(name, func, false)
} else if (node.attachEvent) {
node.attachEvent(name, func)
}
}
// 进入就执行
doSetup()
- 监听键盘是否按下,如按下修改键盘值对象中对应的值为
true
,松开修改值为false。用于updatePlayer()
函数控制飞机对象的修改。
绘制用户飞机
// 绘制自己
function drawII(cxt) {
if (II.state === 'alive') {
cxt.drawImage(sprite_image, 201, 0, 70, 80, II.x, II.y, II.width, II.height)
}
if (II.state === 'hit') {
c.fillStyle = 'black'
c.fillRect(II.x, II.y, II.width, II.height)
// drawExplosion(cxt)
}
if (II.state === 'dead') {
return
}
// 绘制子弹
for (i in IIBullets) {
var bullet = IIBullets[i]
var count = Math.floor(bullet.counter / 4)
var xoff = (count % 4) * 24
cxt.drawImage(
bullets_image,
xoff + 10,
0 + 7.5,
8,
13,
bullet.x,
bullet.y,
bullet.width,
bullet.height //dst
)
}
}
- 根据飞机状态,绘制不同的图像。
增加碰撞检测和死亡动画
// =========== 碰撞检测 ============
function checkCollisions() {
// 自己被击中
for (var key in IIBullets) {
var bullet = IIBullets[key];
for (var j in enemies) {
var enemy = enemies[j];
if (collided(bullet, enemy)) {
bullet.state = "hit";
enemy.state = "hit";
enemy.counter = 0;
}
}
}
if (II.state == "hit" || II.state == "dead") return;
// 敌人被击中
for (var i in enemyBullets) {
var bullet = enemyBullets[i];
if (collided(bullet, II)) {
bullet.state = "hit";
II.state = "hit";
II.counter = 0;
}
}
}
/**
* 两物体 在画布中的判断
* a: 要判断的图行
* b: 被判断的图行
*/
function collided(a, b) {
// 检查水平碰撞
// b 物体 最右边 大于 a 物体 最左边
// b 物体 最左边 小于 a 物体 最右边
if (b.x + b.width > a.x && b.x < a.x + a.width) {
// 检查垂直碰撞
if (b.y + b.height >= a.y && b.y < a.y + a.height) {
return true;
}
}
// a 在 b 内
if (b.x <= a.x && b.x + b.width >= a.x + a.width) {
if (b.y <= a.y && b.y + b.height >= a.y + a.height) {
return true;
}
}
// b 在 a 内
if (a.x <= b.x && a.x + a.width >= b.x + b.width) {
if (a.y <= b.y && a.y + a.height >= b.y + b.height) {
return true;
}
}
return false;
}
- 判断飞机和敌人,是不被对方子弹击中,击中后修改状态为
hit
,并修改counter
为0。
增加死亡动画
// 修改
if (II.state === 'hit') {
// c.fillStyle = 'black'
// c.fillRect(II.x, II.y, II.width, II.height)
drawExplosion(cxt)
}
// =========== 死亡动画 ===========
var particles = []
function drawExplosion(cxt) {
// 被击中后 计数从0开始
if (II.counter == 0) {
// 生成粒子
particles = []
for (var i = 0; i < 50; i++) {
particles.push({
x: II.x + II.width / 2,
y: II.y + II.height / 2,
xv: (Math.random() - 0.5) * 2.0 * 5.0, // x velocity
yv: (Math.random() - 0.5) * 2.0 * 5.0, // y velocity
age: 0 // v\\\存在时间
})
}
}
if (II.counter > 0) {
for (var i = 0; i < particles.length; i++) {
var p = particles[i]
p.x += p.xv
p.y += p.yv
var v = 255 - p.age * 3
cxt.fillStyle = 'rgb(' + v + ',' + v + ',' + v + ')'
cxt.fillRect(p.x, p.y, 3, 3)
p.age++
}
}
}
- 飞机进入击中状态后,开始绘制死亡动画。粒子爆炸就是,我们在每一帧上更新粒子对象的列表。先计算出飞机爆炸的中间位置,然后以随机速度向随机方向扩展。
加入游戏状态
// =========== 游戏状态 ============
var overlay = {}
function updateGame() {
if (game.state == 'playing' && enemies.length == 0) {
// 游戏中
game.state = 'won' // 胜利
overlay.title = '敌人全部消灭'
overlay.subtitle = '按空格键重新开始'
overlay.counter = 0
}
if (game.state == 'over' && keyboard[32]) {
// 游戏结束
game.state = 'start' // 开始
II.state = 'alive'
overlay.counter = -1
} else if (game.state == 'over') {
overlay.title = '游戏结束'
overlay.subtitle = '按空格键重新开始'
}
if (game.state == 'won' && keyboard[32]) {
// 游戏胜利
game.state = 'start' // 开始
II.state = 'alive'
overlay.counter = -1
} else if (game.state == 'won') {
overlay.title = '游戏胜利'
overlay.subtitle = '按空格键重新开始'
}
if (overlay.counter >= 0) {
overlay.counter++
}
}
// 绘制 游戏 不同状态的提示
function drawOverlay(cxt) {
if (game.state == 'over' || game.state == 'won') {
cxt.fillStyle = 'white'
cxt.font = 'Bold 40pt Arial'
cxt.fillText(overlay.title, 225, 200)
cxt.font = '14pt Arial'
cxt.fillText(overlay.subtitle, 250, 250)
}
}
...
// =========== 初始化 ============
function mainLoop() {
// 清空画布
cxt.clearRect(0, 0, 650, 500)
// 修改 图像精灵
updatebackground()
// 修改 敌人对象 敌人子弹对象
updateEnemies()
// 修改 用户对象 子弹对象
updatePlayer()
// 检测碰撞
checkCollisions()
// 游戏状态
updateGame()
// 绘制 背景图像
drawBackground(cxt)
// 绘制 --敌人 -- 敌人子弹
drawEnemies(cxt)
// 绘制 --用户 -- 用户子弹
drawII(cxt)
// 游戏提示
drawOverlay(cxt)
window.requestAnimFrame(mainLoop)
}
- 根据不同的状态添加提示语。