一个小游戏,也花了点功夫,稍微记录一下过程,也可以理解为是对我代码的一个解释。主要用HTML5的canvas的一些API,早就想模仿东方project系列弹幕游戏写一个了,但以前学C/C++时嫌windowAPI太麻烦就没写,现在正好浏览器自带界面,canvas又提供了很好的绘制环境,于是就仿东方风神录写了一个。
项目地址:https://github.com/dreamhuan/stg-game 欢迎加star⊙▽⊙
在线体验:https://dreamhuan.github.io/stg-game/
(音频是mp3格式,没考虑兼容性,建议chorme或者ie/edge体验,服务器音频加载会出现问题,所以建议clone或者download后线下体验)
思路参考:http://www.cnblogs.com/axes/p/3582843.html
素材来源:上述链接的素材,以及网上游戏原作的素材。
canvasAPI:https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
参考书籍:Core HTML5 Canvas (项目有一个文件代码来源于此)
代码组织:
效率优化:对象池。
先new相关对象数组吧每个对象visible属性设置为false,要用了遍历一遍,找到一个visible为false的改为true并跳出循环,用完了之后再把那个对象visible改回false。
代码解释:
index.html主要进行了游戏的相关说明包括游戏时长,按键,设定之类的。
js相关文件都进行了代码分离,在html的引用中都注释了他们的用途(其中第一个文件来自书《Core HTML5 Canvas》附带代码,是关于浏览器兼容的一个函数requestNextAnimationFrame,如果用chorme则不需要,直接可用原生API requestAnimationFrame)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>众神眷恋的幻想乡</title>
<script src="js/requestNextAnimationFrame.js"></script><!--浏览器兼容-->
<script src="js/data.js"></script><!--资源路径数组,精灵表-->
<script src="js/variable.js"></script><!--全局变量名-->
<script src="js/function.js"></script><!--主要函数-->
<script src="js/event.js"></script><!--所有事件(键盘)-->
<script src="js/loading.js"></script><!--资源预加载函数-->
<script src="js/frame.js"></script><!--不同游戏进度帧-->
<script src="js/sprites.js"></script><!--精灵类和精灵初始化函数-->
<link href="css/style.css" type="text/css" rel="stylesheet">
</head>
<body>
<h1>众神眷恋的幻想乡</h1>
<canvas id="canvas" width="640" height="480"></canvas>
<p id="fps" style="position: absolute; left: 0; top: 0;"></p>
<p id="showGameNotes" style="position: absolute; left: 0; top: 60px; font-size: 15px;">
游戏说明:<br/>
全部完成大概300秒<br/>
每隔60秒敌机换弹幕<br/>
自机判定点被击中才算死亡<br/>
(判定点按住shift时会显示)<br/>
<br/>
</p>
<p style="position: absolute; left: 0; top: 150px; font-size: 20px; font-weight: 900; border: solid red">
操作:<br/>
↑↓←→:移动<br/>
shift:低速飞行<br/>
z :射击<br/>
x :符卡<br/>
esc :暂停<br/>
</p>
<p style="position: absolute; left: 0; top: 300px; font-size: 12px; ">
<br/>以下内容看不看并不影响游戏:)<br/>
数据:<br/>
分数:每次击破敌机后 += 自机point * 1231<br/>
生命:6条,显示0后再死亡就gameover<br/>
能量:1~5,真正的射击火力只有1~4,使用符卡后-1<br/>
(因为最低是1,所以能量大于等于2才可以用符卡)<br/>
<br/>
其他:<br/>
自机死亡后重生有5s无敌时间(自机显示半透明)<br/>
击破敌机后会掉落power(红色)和point(蓝色)<br/>
吃power 自机power + 0.5<br/>
吃point 自机point + 1<br/>
自机重生后power,point重置1<br/>
</p>
<script src="js/index.js"></script>
</body>
</html>
css部分有一个canvas绝对居中的样式参考:
margin: auto;
position: absolute;
top: 0; left: 0; bottom: 0; right: 0;
canvas阴影的样式参考:
box-shadow: 10px 10px 12px rgba(86, 102, 255, 0.5);
关键代码:
另外,最后做代码分离的时候吧全局变量单独写到一个文件方便修改和管理,但是注释部分可能没有更新。
index.js是程序的入口,背景的滚动是两个图片叠加移动《Core HTML5 Canvas》一书有详细介绍
//loading页面显示的三张图片优先加载
var img1 = new Image();
var img2 = new Image();
var img3 = new Image();
img1.src = "image/loading/loading.png";
img2.src = "image/loading/sig.png";
img3.src = "image/loading/sig_r.png";
//************** 程序入口 ****************
window.onload = function (e) { //三个图片加载完执行
loading(); //后台载入资源
animationFrame = requestNextAnimationFrame(loadingFrame); //前台显示加载页面,进入加载帧
};
后台加载资源的函数在loading.js
思路是遍历datas数组(所有资源的路径数组),第一个if判断是音频还是图片。音频绑定canplaythrough事件,加载到能播放后执行loadMp3函数,函数里面移除这个事件,并把音频加到html中,然后已加载资源数+1。图片也同理,new Image,onload绑定一个函数,里面让已加载资源数+1。
屏幕显示加载界面时同时判断是否已加载完成,加载完成则进入游戏界面。
function loading() {
for (var i = 0; i < datas.length; i++) {
if (datas[i].indexOf("mp3") >= 0) {
var audio = document.createElement("audio");
audio.preload = "auto";
audio.src = datas[i];
audio.addEventListener("canplaythrough", loadMp3);
if (datas[i].indexOf("bgm") >= 0) {
audio.id = "bgm";
audio.loop = true;
audio.volume = 0.8;
}
/*这里有一大串else if不贴出来了*/
loadMp3(audio);
} else {
loadImg(datas[i]);
}
}
}
function loadMp3(audio) {
audio.removeEventListener("canplaythrough", loadMp3);
alreadyLoadCounts++;
document.body.appendChild(audio);
}
function loadImg(src) {
if (src.indexOf("enemy") >= 0) {
imageenemy = new Image();
imageenemy.src = src;
imageenemy.onload = function () {
alreadyLoadCounts++;
}
}
/*这里也有一大串else if不贴出来了*/
}
界面显示的所有代码都在frame.js
一边显示图片,一边判断已加载项目是不是等于总数,如果是则设定flag
flag为true时关闭当前界面,进入下一界面。
通过以下两行代码实现界面的切换:
cancelAnimationFrame(animationFrame);
animationFrame = requestNextAnimationFrame(ganmeFrame);
以下是主要逻辑,绘制代码和其他变量赋值已省去。
function loadingFrame() {
//删除了部分代码,完整代码见github
animationFrame = requestNextAnimationFrame(loadingFrame);
//alreadyLoadCounts是loading.js定义的变量,用于资源计数
if (alreadyLoadCounts === datas.length) { //判断加载是否完成
setTimeout(function () { //延迟一下 不然秒加载完多尴尬。。。
loadingComplete = true; //加载完成
}, 5000);
}
//这里的代码只执行一次 进入游戏前的资源预处理and初始化
if (loadingComplete) {
initSprite(); //初始化精灵
document.getElementById("bgm").play(); //播放背景音
cancelAnimationFrame(animationFrame); //关闭加载界面帧
animationFrame = requestNextAnimationFrame(gameFrame); //进入游戏帧
}
}
initSprite这个函数在sprites.js这个文件
加载完图片资源后当然是初始化需要的精灵啦,这里需要的精灵比较多,有自机(player)敌机(enemy)boss(boss)子弹(bullet)爆炸(boom)食物(food)符卡(spellcard)。
其中自机、boss、符卡同时只需要一个,所以就初始化一个够了,别的都初始化一个数组,用对象池提高性能。
这个文件里面除了initSprite这个函数以外的别的函数是书上代码,用于创建精灵表以及精灵用的。我进行了部分修改以适应我的需求。精灵能动起来的原理就是所谓的视觉暂留,这个书上也有详细介绍,代码实现就是每秒显示几十张图片按顺序变化(每一张图片称为一帧),就有动画效果了。所谓的精灵表就是画了每一帧图片的图片(就是把好几帧画在一个图片中,通过不同参数可以画出不同的图片然后可以用循环实现一直绘制)。
以下是创建精灵的相关构造函数代码(书上代码)
SpriteSheetPainter = function (spritesheet, cells) {
this.spritesheet = spritesheet;
this.cells = cells || [];
this.cellIndex = 0
};
SpriteSheetPainter.prototype = {
advance: function () {
if (this.cellIndex == this.cells.length - 1) {
this.cellIndex = 0;
}
else {
this.cellIndex++;
}
},
paint: function (sprite, context) {
var cell = this.cells[this.cellIndex];
context.drawImage(this.spritesheet,
cell.x, cell.y, cell.w, cell.h,
sprite.x, sprite.y, cell.w, cell.h);
}
};
var Sprite = function (name, painter, behaviors) {
if (name !== undefined) this.name = name;
if (painter !== undefined) this.painter = painter;
if (behaviors !== undefined) this.behaviors = behaviors;
return this;
};
Sprite.prototype = {
x: 0,
y: 0,
w: 100,
h: 100,
velocityX: 0, //pps
velocityY: 0, //pps
fire: false,
visible: true,
painter: undefined, // object with paint(sprite, context)
behaviors: [], // objects with execute(sprite, context, time)
paint: function (context) {
if (this.painter !== undefined && this.visible) {
this.painter.paint(this, context);
}
},
update: function (context, time) {
for (var i = this.behaviors.length; i > 0; --i) {
this.behaviors[i - 1].execute(this, context, time);
}
}
};
解读一下这个文件:
SpriteSheetPainter 是一个精灵表绘制器,有一个图片,一个参数数组,一个下标作为成员变量,paint和advance两个成员函数paint用当前下标对应参数数组的参数画出图片(精灵表)的一部分,advance循环修改下标。paint和advance结合可以画出一系列图片实现动画效果。
Sprite 是一个精灵构造函数,有一个painter绘制器用于绘制,behavior数组则可以定义行为,behavior必须实现execute函数,然后调用精灵的update会依次执行behavior数组每一个成员的execute。
比如player有一个runInPlace行为,用于实现精灵的原地动作(这里的原地动作就是基于精灵表的循环绘制)
var runInPlace =
{
lastAdvance: 0,
PAGEFLIP_INTERVAL: 150,//每隔150ms绘制绘制一次
execute: function (sprite, context, now) {
var time = now - this.lastAdvance;
if (time > this.PAGEFLIP_INTERVAL) {
player.painter.advance();
this.lastAdvance = now;
}
}
};
所以,所谓的初始化精灵就是确定每个精灵的精灵表、绘制器和行为。当然这里还有xy坐标以及x方向y方向的速度精灵的大小,visible等一系列属性。。。
另外,对于自机的移动,因为事件不能同时触发(比如不能向上向左同时移动)所以设置了标志来解决,按下按键相应的标志为true,松开则恢复false,依此来实现多个事件的组合。
event.js:
window.onkeydown = function (e) {
var evt = e || event;
var currKey = evt.keyCode || evt.which || evt.charCode;
switch (currKey) {
case 16://shift
player.lowerSpeed = true;
break;
case 90://z
player.fire = true;
break;
case 88://x
if (player.power >= 2 && !playerspellcard.visible) { //加上符卡不可见的判断是保证不会同时发动符卡
player.power -= 1;
player.fireLevel -= 1;
showPower -= 1;
playerspellcard.visible = true;
}
break;
case 37://←
player.toLeft = true;
break;
//...
}
};
window.onkeyup = function (event) {
switch (event.keyCode) {
case 16: //shift
player.lowerSpeed = false;
break;
case 90: //z
player.fire = false;
break;
case 88: //x
break;
case 37: //←
player.toLeft = false;
break;
//...
}
};
自机的移动和射击行为,每帧绘制都会调用一下自机的update函数实现相关行为。敌机的射击移动,子弹、食物的移动等都是各自的behavior里面实现的。
var moveAndShoot =
{
lastMove: 0,
execute: function (sprite, context, now) {
if (this.lastMove !== 0) {
var pps = sprite.velocityX;//pps 每秒300个像素
if (sprite.lowerSpeed)
pps = 150;
var ppf = calculatePpf(pps, fps);
if (sprite.toLeft && sprite.x > 35)
sprite.x -= ppf;
if (sprite.toRight && sprite.x < 385)
sprite.x += ppf;
if (sprite.toTop && sprite.y > 20)
sprite.y -= ppf;
if (sprite.toBottom && sprite.y < 420)
sprite.y += ppf;
if (sprite.fire && now - sprite.lastTimeShoot > 100) {
shoot(sprite);
sprite.lastTimeShoot = now;
}
}
this.lastMove = now;
}
};
所以在游戏循环中,只需要调用相关函数更新行为即可。
接下进入整理,主游戏循环,就是gameFrame
function gameFrame() {
context.clearRect(0, 0, canvas.width, canvas.height);
fps = calculateFps();
if (!(fps && fps > 0 && fps < 100)) //用于处理暂停开始那种一瞬间的fps不稳定情况
fps = 60;
p.innerHTML = "fps:" + fps + "<br/>" + "Time:" + parseFloat(gameTime / 1000).toFixed(2) + "(s)";
//背景移动
var ppfBG = calculatePpf(ppsBG, fps);
locBG1.y += ppfBG;
locBG1.y = Math.round(locBG1.y);
locBG1.y = locBG1.y > 449 ? -450 : locBG1.y;//背景移出屏幕了补到后面
locBG2.y += ppfBG;
locBG2.y = Math.round(locBG2.y);
locBG2.y = locBG2.y > 449 ? -450 : locBG2.y;//背景移出屏幕了补到后面
drawBackground();
gameLoop();
gameShow();
animationFrame = requestNextAnimationFrame(ganmeFrame);
if (gameStop) {
cancelAnimationFrame(animationFrame);
animationFrame = requestNextAnimationFrame(gameStopFrame);
} else if (gameOver) {
cancelAnimationFrame(animationFrame);
animationFrame = requestNextAnimationFrame(gameOverFrame);
} else if (gameWin) {
cancelAnimationFrame(animationFrame);
animationFrame = requestNextAnimationFrame(gameWinFrame);
}
}
gameFrame主要是游戏界面的逻辑循环,这个函数主要处理了背景的计算和界面的切换判断,顺便贴出其他几个界面的函数:
//****************** 游戏暂停帧 *********************
function gameStopFrame() {
document.getElementById("bgm").pause();
document.getElementById("boss").pause();
context.save();
context.fillStyle = "red";
context.font = "italic bold 30px Arial";
context.fillText("Game Stop", 220, 200);
context.fillText("Press Enter to start", 160, 260);
context.restore();
}
//****************** 游戏结束帧 **********************
function gameOverFrame() {
document.getElementById("bgm").pause();
document.getElementById("bgm").currentTime = 0; //播放进度设为0(下次play就是重播,不然会接下去)
document.getElementById("boss").pause();
document.getElementById("boss").currentTime = 0;
document.getElementById("gameover").play();
try {
var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
var numPiexel = imageData.data.length / 4;
for (var i = 0; i < numPiexel; i += 1) {
var avg = Math.round((imageData.data[4 * i] + imageData.data[4 * i + 1] + imageData.data[4 * i + 2]) / 3);
imageData.data[4 * i] = avg; // R
imageData.data[4 * i + 1] = avg; // G
imageData.data[4 * i + 2] = avg; // B
imageData.data[4 * i + 3] = imageData.data[4 * i + 3]; // A
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.putImageData(imageData, 0, 0);
} catch (e) {
console.log(e);
} finally {
context.save();
context.fillStyle = "red";
context.font = "italic bold 30px Arial";
context.fillText("Game Over !!!", 220, 200);
context.fillText("Press Enter to restart", 160, 260);
context.restore();
}
}
//****************** 游戏获胜帧 ***********************
function gameWinFrame() {
document.getElementById("boss").pause();
document.getElementById("boss").currentTime = 0;
document.getElementById("end").play();
context.save();
context.fillStyle = "red";
context.font = "italic bold 30px Arial";
context.fillText("You Win!", 220, 200);
context.fillText("Press Enter to restart", 160, 260);
context.restore();
}
可以看到,其实别的几个界面都只是单纯的加些字而已,然后音乐停止。因为没有清除当前canvas的内容,所以就有一种停留在那一帧的感觉,gameOver比别的多了一个try-catch-finally逻辑
try {
var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
var numPiexel = imageData.data.length / 4;
for (var i = 0; i < numPiexel; i += 1) {
var avg = Math.round((imageData.data[4 * i] + imageData.data[4 * i + 1] + imageData.data[4 * i + 2]) / 3);
imageData.data[4 * i] = avg; // R
imageData.data[4 * i + 1] = avg; // G
imageData.data[4 * i + 2] = avg; // B
imageData.data[4 * i + 3] = imageData.data[4 * i + 3]; // A
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.putImageData(imageData, 0, 0);
} catch (e) {
console.log(e);
} finally {
context.save();
context.fillStyle = "red";
context.font = "italic bold 30px Arial";
context.fillText("Game Over !!!", 220, 200);
context.fillText("Press Enter to restart", 160, 260);
context.restore();
}
因为context.getImageData这个在本地运行会产生跨域问题(服务器不会),所以套了层逻辑,finally部分还是显示字而已。然后try部分用getImageData后做了个图片的灰度效果再贴上去的,给人一种gameOver后全部都暗下来的感觉。
然后游戏继续/重新开始则是由事件响应控制,游戏win或者over重新开始前会初始化相关数据,其实player和boss的初始化因为参数比较多所以直接封装成了两个函数调用,另外player的初始化在被击中后也会调用。event.js里window.onkeydown部分代码:
case 13: //Enter
if (gameOver || gameWin) {
gameOver = false;
gameWin = false;
//重置各种参数
//...
bullets.foreach(function () {
if (this.visible) {
this.visible = false;
}
});
//...
playerReborn();
bossInit();
//...
gameFrame();
} else if (gameStop) {
gameStop = false;
//...
gameFrame();
}
break;
case 27: //esc
gameStop = true;
break;
那个foreach是另外给Array对象配置的一个函数
Array.prototype.foreach = function (callback) {
for (var i = 0; i < this.length; i++) {
callback.apply(this[i], [i]);
}
};
这个函数的意思是对于数组中的每一个对象都用那个对象作为回调函数(callback)的this执行一遍callback。具体的就是如果win或者over,把每个对象的visible属性变成false,完成初始化任务,最后调用gameFrame函数。如果stop就是else if里面的内容只是简单调用gameFrame函数。gameFrame最后有requestNextAnimationFrame反复调用自身(用词有点问题,然而找不到更好的词语了,它不是递归,requestAnimationFrame的机制是把函数扔到执行列表而已,下一步按顺序执行那个列表,具体自行Google或者百度)而其他几个Frame并没有requestNextAnimationFrame(requestAnimationFrame的简单封装)只是调用一次就停在那里了,需要别的事件来“启动”游戏界面。
最后来到整个游戏的重点部分,游戏循环(gameLoop),我们一段段拆分这个函数。
首先是游戏进行时间的计算,整个程序控制完全是按照时间进行的,至于为什么不用当前时间减去游戏开始的时间,因为那样暂停就会有问题,所以才有了每帧之间时间加上去的计算方法。。。
绘制之前都先判断是否可见,可见才进行进一步逻辑,比如这里自机的绘制就是这样。画前context.save()
画后context.restore()
是个好习惯,因为画这个过程中可能会改变context的状态,所以要先保存,画完还原。游戏设定player刚出生有5s无敌时间,用半透明显示,然后如果按了shift就要画出中间的判定点,这也是游戏原作的特点,我沿用一下。(所谓的判定点就是地方子弹碰到自机别的位置都没事,只有打中判定点才会死)
function gameLoop() {
var now = new Date;
gameTime += now - lastGameTime;
lastGameTime = now;
//绘制自机
if (player.visible) {
context.save();
if (player.isGod) { //无敌模式半透明
context.globalAlpha = 0.5;
}
player.update(context, now);
player.paint(context);
if (player.lowerSpeed) { //低速模式显示判定点
context.beginPath();
context.fillStyle = "red";
context.arc(player.x + 16, player.y + 24, 3, 0, 2 * Math.PI);
context.fill();
context.beginPath();
context.fillStyle = "white";
context.arc(player.x + 16, player.y + 24, 2, 0, 2 * Math.PI);
context.fill();
}
context.restore();
}
画完自机接下来画子弹。子弹是整个游戏的精髓,因为画之前要做碰撞判定,检测是否中弹。中弹又分为敌方中弹和我方中弹。遍历数组,对每个可见子弹都执行操作(至于子弹什么时候可见就是player、enemy还有boss的behaviors决定的了,behaviors里面会有moveAndShoot这个behavior调用shoot函数进行射击,shoot调用了addbullet函数添加子弹,并设定子弹的坐标和速度,封装addbullet函数后可用于制作弹幕,这些函数除了behavior在sprites.js别的都在function.js里面,所以最后讲的function.js是游戏主体)
每个player、enemy、boss有个isgood属性,用来判断是好的还是坏的(在initSprite函数里面有player.isgood = true; enemy.isgood = false; boss.isgood = false; )然后shoot里面和addbullet里面开头就可以判断sprite.isgood进行区分,然后通过sprite.isgood设置bullet.isgood,另外initbullet函数里面的bulletPainter 和bulletBehavior 也会通过isgood属性判断是敌机的子弹还是自机的子弹。从而做出不同的绘制和不同的行为。(自机子弹往上飞,敌机往别的方向等…)
然后自机的子弹遍历敌机或者boss判定是否击中,用勾股定理计算距离,击中就把敌机的visible设成false,并且调用boom函数,boom函数会遍历boom数组把一个visible设成true并设置坐标值。要是敌机的子弹就计算和自机的距离,小于判定点半径就boom自机,并且boom全部敌机消除全部子弹。
//遍历子弹数组绘制可见子弹
bullets.foreach(function () {
var bullet = this;
if (bullet.visible) {
if (bullet.isgood) { //自机的子弹
enemys.foreach(function () { //遍历敌机
var enm = this;
if (enm.visible) {
var distance = Math.sqrt(
Math.pow((bullet.x) - (enm.x + 16), 2)
+ Math.pow((bullet.y) - (enm.y + 16), 2)
);
if (distance < 20) {
bullet.visible = false;
enm.blood -= 50;
if (enm.blood <= 0) {
enm.visible = false;
boom(enm);
addfood(enm);
showScore += player.point * 1231;
}
}
}
});
if (boss.visible) { //boss判定
var distance = Math.sqrt(
Math.pow((bullet.x) - (boss.x + 32), 2)
+ Math.pow((bullet.y) - (boss.y + 32), 2)
);
if (distance < 20) {
bullet.visible = false;
boss.blood -= 50;
if (boss.blood <= 0) {
boss.visible = false;
boom(boss);
bossboom = true; //让后面显示boss那里变成不可见
document.getElementById("bossdie").play();
showScore += player.point * 12310504;
setTimeout(function () {
gameWin = true;
}, 5000);
}
}
}
} else if (player.visible && !player.isGod) { //敌机的子弹
var distance = Math.sqrt(
Math.pow((bullet.x) - (player.x + 16), 2)
+ Math.pow((bullet.y) - (player.y + 24), 2)
);
if (distance < 5) {
player.visible = false;
showPlayer--;
if (showPlayer < 0) {
showPlayer = "gameover!";
gameOver = true;
}
bullet.visible = false;
boom(player); //自己挂了同时画面清空 并且吐出食物。。。
bullets.foreach(function () {
if (this.visible) {
this.visible = false;
}
});
enemys.foreach(function () {
if (this.visible) {
this.visible = false;
boom(this);
addfood(this);
}
});
for (var i = 0; i < player.power; i++) {
addfood(player, "power", player.x + 20 * i, player.y - 100 - i * 20);
addfood(player, "power", player.x + 100 - 20 * i, player.y - 100 - i * 20);
}
setTimeout(function () { //不知道啥时候重生,直接硬编码...
playerReborn();
player.visible = true;
}, 1000);
}
}
this.update(context, now);
this.paint(context);
//console.log(bullet.y);
}
});
之后产生敌机并绘制。200秒前是普通敌机,200秒出现boss,敌机出现的间隔时间函数是y(ms) = (1000 * 100) / (T(s) + 100),在(0,+∞)单调递减,几个关键点:(0,1000)(60,625)(120,454),敌机发射子弹的间隔时间也是这个函数,敌机子弹速度并没有改变,有需要可以自行修改。
if (gameTime / 1000 < 200) { //200s后出现boss
//产生enemy的逻辑
if (now - lastenemyTime > 1000 * 100 / (gameTime / 1000 + 100)) { //和shoot间隔算法一样
for (var i in enemys) {
if (!enemys[i].visible) {
enemys[i].lastTimeShoot = new Date();
enemys[i].x = Math.random() * 385 + 34;
enemys[i].y = 0;
enemys[i].lastMoveTime = new Date();
enemys[i].moveDirFlag = 0;
enemys[i].visible = true;
//console.log("visible");
break;
}
}
lastenemyTime = now;
}
} else {
document.getElementById("bgm").pause();
document.getElementById("bgm").currentTime = 0; //播放进度设为0(下次play就是重播,不然会接下去)
document.getElementById("boss").play();
//产生boss
if (!bossboom) //时间到了并且boss没有死就可见
boss.visible = true;
context.save();
if (boss.visible) {
boss.update(context, now);
boss.paint(context);
//画boss的血槽
context.fillStyle = "red";
context.fillRect(40, 20, boss.blood / boss.fullBlood * 370, 5);
}
context.restore();
}
enemys.foreach(function () {
if (this.visible) {
this.update(context, now);
this.paint(context);
}
});
然后画炸弹和食物,炸弹没啥好讲的,就是遍历一遍,visible为true的画一下,画完就改为false(这是initboom的时候就写好的,源代码有注释)画食物本质和画子弹一样,只是比它简单一点,判定食物和自机的距离,少于自机的大小就被自机“吃了”,然后把visible设为false,并且根据食物种类加相应的值。最后画出来。
booms.foreach(function () {
if (this.visible) {
this.update(context, now);
this.paint(context);
}
});
foods.foreach(function () {
var food = this;
if (food.visible) {
if (player.visible) {
var distance = Math.sqrt(
Math.pow((food.x) - (player.x + 16), 2)
+ Math.pow((food.y) - (player.y + 24), 2)
);
if (distance < 20) {
if (food.type === "power" && player.power < 5) {
player.power += 0.5;
player.power = parseFloat(player.power.toFixed(2));
showPower = player.power;
player.fireLevel = parseInt(player.power);
}
else {
player.point++;
}
food.visible = false;
}
}
food.update(context, now);
food.paint(context);
}
});
最后是绘制符卡,因为要保证一个符卡还没弄完不能放第二个,所以在按键x的事件中if加了个visible的判断,因为符卡的visible为false的话则没有发动符卡,为true则一发动,不能同时发动第二次。符卡发动完毕后会吧visible改会false(符卡初始化时候的behavior里面写好的)发动符卡后炸所有敌机(对boss无效)并且消弹
if (playerspellcard.visible) {
playerspellcard.update(context, now);
playerspellcard.paint(context);
bullets.foreach(function () {
if (this.visible) {
this.visible = false;
}
});
enemys.foreach(function () {
if (this.visible) {
this.visible = false;
boom(this);
addfood(this);
}
});
}
context.drawImage(offcanvasBGClip, 0, 0); //覆盖掉子弹飞出框框的部分
}
好了,游戏主循环的介绍告一段落了,最后是弹幕系统的介绍。出于判定方便考虑,弹幕的移动是用x方向y方向速度叠加的(就是两个方向速度单独考虑,简单的矢量合成)
自机检测到按下z会把player.fire设置为true,并在player的behavior里面调用shoot函数,敌机经过一定时间间隔也会在behavior里面调用shoot函数。shoot函数根据调用者(这里作为参数sprite传入)的isgood属性进行区分处理添加子弹。敌机的子弹另外封装成了弹幕,自机的子弹就是简单的调用addbullet函数
function shoot(sprite) {
if (!sprite.isgood) {
if (gameTime / 1000 < 60)
addLineBullet(sprite, 200);
else if (gameTime / 1000 < 120)
addThreeBullet(sprite, 200);
else if (gameTime / 1000 < 200)
addCircleBullet(sprite, 50);
else if (sprite.name === "boss")
addFinalBullet(sprite, 100);
return; //执行完直接返回
}
if (player.fireLevel === 1) {//不同火力添加不同子弹
addbullet(sprite, sprite.x + 16, sprite.y - 20);
}
else if (player.fireLevel === 2) {
addbullet(sprite, sprite.x + 6, sprite.y - 20);
addbullet(sprite, sprite.x + 27, sprite.y - 20);
}
else if (player.fireLevel === 3) {
addbullet(sprite, sprite.x - 5, sprite.y);
addbullet(sprite, sprite.x + 16, sprite.y - 20);
addbullet(sprite, sprite.x + 37, sprite.y);
}
else {
addbullet(sprite, sprite.x - 6, sprite.y);
addbullet(sprite, sprite.x + 9, sprite.y - 20);
addbullet(sprite, sprite.x + 24, sprite.y - 20);
addbullet(sprite, sprite.x + 39, sprite.y);
}
//播放自机的射击音乐
var audio = document.getElementsByTagName("audio");
for (var i = 0; i < audio.length; i++) {
if (audio[i].src.indexOf("shoot") >= 0 && (audio[i].paused || audio[i].ended)) {
audio[i].play();
break;
}
}
}
然后是addbullet函数,有一大堆参数,看名字就能理解干嘛的。另外解释下为什么要设置isLeft和isUp参数而不是直接弄成负数值,因为原本设置的是子弹速度会随时间变换(后来取消了。。。源代码文件中以注释的形式存在),然而这样的话负值变大应该是减,就比较烦,所以直接速度都是正了(后面设计弹幕的时候速度也可以是负,只不过需要转换一下,把速度转为正,把相应参数设为true)这样的感觉是麻烦了点,这部分的设计可以重构下。。。
function addbullet(sprite, x, y, vx, vy, isLeft, isUp, rotateAngle) {
for (var j = 0; j < bullets.length; j++) {
if (!bullets[j].visible) {
//console.log("addbulletGood");
if (sprite.isgood) {
bullets[j].isgood = true;
bullets[j].x = x || sprite.x;
bullets[j].y = y || sprite.y;
bullets[j].velocityY = 1200;
} else {
bullets[j].isgood = false;
bullets[j].x = x || sprite.x + 16;
bullets[j].y = y || sprite.y;
bullets[j].velocityX = vx;
bullets[j].velocityY = vy;
bullets[j].isleft = !!isLeft; //子弹是否左偏,左偏后面的逻辑要-ppfx
bullets[j].isup = !!isUp; //子弹是否上偏,上偏后面的逻辑要-ppfy
bullets[j].rotateAngle = rotateAngle; //子弹旋转角度,度为单位
}
bullets[j].visible = true;
break;
}
}
}
最后是敌机弹幕设计的代码,旋转角度是因为素材文件的子弹是直向下的,所以需要转一下再画出来。boss的那个addFinalBullet算是个抛砖引玉,欢迎用各种数学运算组合出华丽的弹幕。
function addLineBullet(sprite, velocity) {
//精灵 x坐标 y坐标 x速度 y速度 是否往左 是否往上 旋转角度(向下是0°)
addbullet(sprite, sprite.x + 16, sprite.y + 25, 0, velocity, false, false, 0);//下
}
function addThreeBullet(sprite, velocity) {
//精灵 x坐标 y坐标 x速度 y速度 是否往左 是否往上 旋转角度(向下是0°)
addbullet(sprite, sprite.x + 16, sprite.y + 25, 0, velocity, false, false, 0);//下
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity * Math.tan(10 / 180 * Math.PI), velocity, false, false, -10);//右下
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity * Math.tan(10 / 180 * Math.PI), velocity, true, false, 10);//左下
}
function addCircleBullet(sprite, velocity) {
//精灵 x坐标 y坐标 x速度 y速度 是否往左 是否往上 旋转角度(向下是0°)
addbullet(sprite, sprite.x + 16, sprite.y + 25, 0, velocity, false, false, 0);//下
addbullet(sprite, sprite.x + 16, sprite.y + 25, 0, velocity, false, true, 0);//上
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity, 0, false, false, 90);//右
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity, 0, true, false, 90);//左
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity / Math.SQRT2, velocity / Math.SQRT2, false, false, -45);//右下
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity / Math.SQRT2, velocity / Math.SQRT2, true, false, 45);//左下
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity / Math.SQRT2, velocity / Math.SQRT2, true, true, -45);//左上
addbullet(sprite, sprite.x + 16, sprite.y + 25, velocity / Math.SQRT2, velocity / Math.SQRT2, false, true, 45);//右上
}
function addFinalBullet(sprite, velocity) {
//精灵 x坐标 y坐标 x速度 y速度 是否往左 是否往上 旋转角度(向下是0°)
for (var i = 0; i < 360; i += 10) {
var rot = i + Math.random() * 360;
var vx = velocity * Math.sin(rot / 180 * Math.PI);
var vy = velocity * Math.cos(rot / 180 * Math.PI);
var isL = false;
var isU = false;
if (vx < 0) {
vx *= -1;
isL = true;
} else {
isL = false;
}
if (vy < 0) {
vy *= -1;
isU = true;
} else {
isU = false;
}
addbullet(sprite, sprite.x + 32, sprite.y + 32, vx, vy, isL, isU, -rot);
}
}
写到这里差不多是结束了,勉强算个教程吧。虽然更多觉得是代码解释。。。水平有限,个人感觉很多地方可以重构下,用面向对象思路包装下。至于扩展,可以重构下addbullet函数的逻辑(另外还涉及到sprite.js的initbullet里面的bulletBehavior),可以设计华丽的弹幕函数,甚至可以写第二面第三面。。。我的素材文件夹里面还有别的enemy精灵没有使用,variable.js文件下也定义了一些没有用到过的变量,但是我没精力写下去了,希望有人可以继续push代码或者自己fork自己玩,有什么有趣的更新欢迎留言:)