目录
1. 案例分析💨
2. 适配设备 💨
3. 背景滚动💨
4. hero操作💨
5. 敌机的创建与运动💨
6. 子弹的创建与运动💨
7. 碰撞检测💨
8. 统计得分💨
9. 设置开始与结束界面💨
10. 设置带本地存储功能的排行榜💨
我们先来看看接下来我们要做的效果:🙋🙋🙋
有需要源码和素材的同学,在文章末尾有链接。
下面是另外两个原生JS的游戏和项目,大家可以选择阅读,都有详细解析:
原生JS实现FlappyBird游戏:原生JS实现FlappyBird游戏 超详细解析 快来做一个自己玩吧
原生JS实现本地存储记账本:我用JS做了一个记账本 [数据可本地存储] 附万字详解
1. 案例分析💨
我们先思考一下这个游戏都由哪几个部分或者说哪些功能组成呢?
- 开始前 :一个开始游戏面板
- 游戏中:背景滚动 hero的操作 敌机的创建与运动 子弹的创建与运动 碰撞检测
- 游戏结束:一个排行榜面板
2. 适配设备 💨
新建一个public.js文件,这个文件放一些我们公共的方法,下面我们先定义一个isPhone方法来判断是否是移动端设备
function isPhone() {
var arr = ["iPhone","iPad","Android"];
var is = false;
for (var i=0; i<arr.length; i++) {
if (navigator.userAgent.includes(arr[i])) {
is = true;
break;
}
}
return is;
}
在isPhone方法里我们定义了一个数组arr用来存储移动端的设备名,UserAgent是HTTP请求中的用户标识,一般发送一个能够代表客户端类型的字符串,includes 方法判断数组是否包含指定的值,包含返回 true,不包含返回 false。
我们默认是PC端,如果includes返回true则代表与数组中的元素匹配,代表是移动端设备,那么我们的isPhone方法就返回true。
这个判断移动端的方法大家可以保存下来,以后很多的项目我们也用的到。
因为我们规定移动端下背景图片要占满屏幕,所以需要一个if语句进行判断,如果isPhone返回的是true,说明当前在移动端,我们需要修改背景图片的宽高:
sw和sh是在在外面定义的全局变量,默认情况下sw=320,sh=568,因为在后面我们还会用到sw,sh,所以如果设备是移动端的话,需要对它们进行重新赋值:
if (isPhone()) {
var bg = document.querySelector('.contain');
sw = document.documentElement.clientWidth + 'px';
sh = document.documentElement.clientHeight + 'px';
bg.style.width = sw;
bg.style.height = sh;
}
document.documentElement.clientWidth 就是当前设备的屏幕宽度,注意加符号
我们可以在chrome浏览器下模拟移动端不同设备下是否占满全屏,每次换完设备时要刷新页面:
这样的话我们适配设备的效果就完成了,成功做到了可以在移动端下占满全屏,下面就开始制作我们的飞机大战游戏吧!
3. 背景滚动💨
游戏背景是最外层盒子 container 的背景图片,背景图片是在y轴上平铺的,所以我们通过定时器改变背景图片的y轴位置就能达到持续滚动的效果。
我们在实现各个子模块功能的时候都写到单独的文件里,下面我们创建一个背景滚动文件 bg.js ,把相关功能的实现写在这里:
// 背景滚动
var dContainer = document.getElementById("container");
var dis = 0; //bg滚动的量
var speed = 5; //滚动的速度
function bgMove() {
dis += 5;
dis = dis>sh ? 0 : dis;
dContainer.style.backgroundPosition = `0 ${dis}px`;
}
我们在 index.html 中定义一个定时器,然后每隔30毫秒调用 bgMove 这个方法:
function start() {
timer = setInterval(function() {
// 2.1 背景滚动
bgMove();
}, 30)
}
在我们制作的这个游戏中,不论是背景移动还是待会要做的 hero的移动,敌机的移动,最后封装的函数都需要在这个定时器里调用,这样才会有我们看到的那种动画一样的效果。
我们看一下背景移动的效果:
这样背景的滚动就完成了,下面我们开始进行 hero 的操作。
4. hero操作💨
我们新建一个控制 hero 移动的 js 文件:hero.js
然后分为三步
1 .获取装hero飞机的盒子
2. 添加键盘事件,判断按下的状态
3. 封装移动函数
这里需要着重强调的就是第二步,我们先看一下代码:(这里37.38.39.40是阿斯克码分别代表左上右下键)
var isLeft = false;
var isTop = false;
var isRight = false;
var isBottom = false;
//键盘按下事件
window.onkeydown = function(e) {
if (e.keyCode === 37) {
isLeft = true;
} else if (e.keyCode === 38) {
isTop = true;
} else if (e.keyCode === 39) {
isRight = true;
} else if (e.keyCode === 40) {
isBottom = true;
}
}
//键盘抬起事件
window.onkeyup = function(e) {
if (e.keyCode === 37) {
isLeft = false;
} else if (e.keyCode === 38) {
isTop = false;
} else if (e.keyCode === 39) {
isRight = false;
} else if (e.keyCode === 40) {
isBottom = false;
}
}
这里每当按下键盘或者键盘抬起的时候,我们都会判断相应的状态,如果没有这一步,我们实现不了飞机向左上飞或者向右上飞,只能要么竖着上下飞,要么横着左右飞。
这里我们再看看hero移动的函数就能更好的理解了:
var dHero = document.getElementById("hero");
function heroMove() {
var left = dHero.offsetLeft;
var top = dHero.offsetTop;
if (isLeft) {
left -= 8;
left = left<-33 ? -33 : left;
}
if (isTop) {
top -= 8;
top = top<0? 0 : top;
}
if (isRight) {
left += 8;
left = left>sw-33 ? sw-33 : left;
}
if (isBottom) {
top += 8;
top = top>sh-82 ? sh-82 : top
}
dHero.style.left = left + 'px';
dHero.style.top = top + 'px';
}
当我们按下左移键时,isLeft等于true,当我们按下上移键时,isTop等于true,所以在移动函数heroMove里,前两个if都会被执行,这样就实现了向左上方飞的效果。
把 hero 的操作函数添加到定时器中:
function start() {
timer = setInterval(function() {
// 2.1 背景滚动
bgMove();
// 2.2 hero的操作 pc键盘
heroMove();
}, 30)
}
下面我们来看一下效果:
这样我们就实现了通过上下左右键对 hero 的操作
5. 敌机的创建与运动💨
在实现敌机的创建之前,因为我们要让生成的敌机实现随机分布,所以需要先写一个随机数函数,我们就在public.js里完成:
function rand(min, max) {
return Math.round(Math.random() * (max-min) + min)
}
创建一个 enemy.js文件编写敌机的创建与运动,首先我们写一个创建敌机的函数:
var dEnemy = document.getElementById("enemy");
function createEnemy() {
var d = document.createElement("div");
d.className = "enemy";
d.style.left = rand(0,sw-38) + 'px';
d.speed = rand(3,8);
dEnemy.appendChild(d);
}
这里我们首先获取 enemy 元素,enemy盒子是作为装载生成敌机的父盒子,类 enemy 就是给创建的 div 盒子增加了敌机的背景,因为最外层的背景盒子我们给了他一个相对定位,然后把装载敌机的盒子一个绝对定位。这样才能让敌机在背景上移动,在类 enemy 里我们定义所有生成的敌机的 top 值都是负的,让敌机从背景外向内移动。然后把创建的div盒子作为 dEnemy 的孩子添加进去。rand函数是创建的一个返回随机数的函数。第三行语句是为了让敌机生成在背景的水平方向的任意位置上,然后让生成的敌机速度也是随机的,减去38是因为我们创建的敌机的宽度是38。
接下来我们看一下敌机的运动函数:
// 敌机的创建于运动
var dEnemy = document.getElementById("enemy");//通过概率来限制敌机的创建与游戏难度
var diff = 200; //难度系数
//敌机运动
function enemyMove() {
// 1. 敌机的创建
if (rand(0,diff) <= 10) {
createEnemy()
}
// 2. 敌机的运动
var es = dEnemy.children;
for (var i=0; i<es.length; i++) {
var e = es[i];
if (e.offsetTop > sh) {
// 飞出了屏幕,需要删掉
dEnemy.removeChild(e);
i --; //防止漏掉元素
continue;
}
e.style.top = e.offsetTop + e.speed + 'px';
}
}
在敌机创建部分我们用了一个if语句,因为我们在通过定时器调用这个函数时,大概每秒钟会调用三十次,那样的话每次调用都创建一个敌机,敌机的数量就太多了。rand(0,200) <= 10意思就是是原来二十分之一的概率,这样生成的敌机数量正好。
还有一个值得注意的点是,当敌机飞出屏幕时,我们需要把敌机这个元素删点,那为什么要i--呢?
比如我们的敌机数组有四个元素,现在判断的是第二个元素,也就是i等于1,当我们移除掉这个元素后,原来的第三个元素就到了我们移除的第二个元素的位置上来。但是因为for循环还会进行一个i++的操作,这样i就等于2了,就是数组的第三个元素。但这其实是第四个元素,因为我们已经把第二个元素删掉了,所以就漏掉了第三个元素,就需要进行一个i--操作来防止漏掉元素。
把 enemyMove 方法添加到主页定时器中:
function start() {
timer = setInterval(function() {
// 2.1 背景滚动
bgMove();
// 2.2 hero的操作 pc键盘
heroMove();
// 2.3 敌机的创建与运动
enemyMove();
}, 30)
}
我们看一下效果:
6. 子弹的创建与运动💨
我们创建一个 bullet.js 文件,子弹的创建和上一节中敌机的创建是很相似的:
function createBullet() {
var dHero = document.getElementById("hero");
var d = document.createElement("div");
d.className = "bullet";
d.style.left = dHero.offsetLeft + 33 - 3 + 'px';
d.style.top = dHero.offsetTop + 'px';
dBullet.appendChild(d);
}
只不过子弹的定位是跟 hero 相关的,所以子弹的 top,left值需要用到 hero 的位置,' 33 -3 '那里前面介绍过33是指 hero 飞机宽度的一半,而3就是子弹宽度的一半,这样就能保证子弹是从飞机头的那个位置发射出来的。
接下来我们再完成子弹的运动函数:
//子弹运动及创建
var dBullet = document.getElementById("bullet");
// 使用间隔
var space = 7;
var count = 0; //计数
//子弹运动
function bulletMove() {
count ++;
// 1. 子弹的创建
if (count === space) {
createBullet();
count = 0;
}
// 2. 子弹的运动
var bs = dBullet.children;
for (var i=0; i<bs.length; i++) {
var top = bs[i].offsetTop;
if (top <= -14) {
dBullet.removeChild(bs[i]);
i-- ;
continue;
}
bs[i].style.top = top - 9 + 'px';
}
}
在子弹的移动函数中我们调用子弹的创建函数,通过 space 和 count 两个变量来控制子弹的生成频率,要不然子弹每隔30毫秒就生成一个就太快了。然后我们让子弹在超出边界后就自动销毁。
我们把这个方法和之前一样加到主页的定时器中:
function start() {
timer = setInterval(function() {
// 2.1 背景滚动
bgMove();
// 2.2 hero的操作 pc键盘
heroMove();
// 2.3 敌机的创建与运动
enemyMove();
// 2.4 子弹的创建与运动
bulletMove();
}, 30)
}
启动项目,看一下子弹的效果:
这样我们子弹的创建与运动就完成了,下一步就该判断子弹命中敌机后,销毁敌机的操作了。
7. 碰撞检测💨
我们在这一节要实现子弹与敌机相碰时,子弹和敌机都会销毁,如果 hero 和敌机相撞那就游戏结束了。首先我们创建一个 check.js 文件,在这里定义上述功能。
下面先理解一下判断是否碰撞的函数:
function isCrash(a,b) {
var l1 = a.offsetLeft;
var t1 = a.offsetTop;
var r1 = l1 + a.offsetWidth;
var b1 = t1 + a.offsetHeight;
var l2 = b.offsetLeft;
var t2 = b.offsetTop;
var r2 = l2 + b.offsetWidth;
var b2 = t2 + b.offsetHeight;
if (r2<l1 || b2<t1 || r1<l2 || b1<t2) {
// 不碰撞
return false;
} else {
// 碰撞
return true;
}
}
在 if 语句里只要有一个条件不满足就说明不会碰撞,这个很好理解,这里我们就分析一下为什么 r2 < l1 就说明不会碰撞呢? l1 代表飞机到左侧背景的距离, l2 代表敌机到背景左侧的距离,那么 r2 < l1 的意思就是敌机本身的宽度再加上敌机到背景左侧的距离比飞机到背景左侧的距离还小,这样二者肯定不会碰上,所以其他方向同理。
定义 check 函数判断敌机与hero,敌机与子弹是否碰撞:
function check() {
// 1. hero与敌机
// 2. 子弹与敌机
var es = dEnemy.children;
var bs = dBullet.children;
for(var i=0; i<es.length; i++) {
var e = es[i];
// 英雄与敌机
if (isCrash(dHero, e)) {
// gameover
alert('ganmeover');
clearInterval(timer);
}
// 子弹与敌机
for (var j=0; j<bs.length; j++) {
var b = bs[j];
if (isCrash(e,b)) {
// 1. 子弹消失
dBullet.removeChild(b);
// 2. 敌机消失
dEnemy.removeChild(e);
i --;
break;
}
}
}
}
在 check 方法中我们调用 isCrash 方法校验英雄与敌机,子弹与敌机是否碰撞,如果英雄与敌机碰撞,我们就清除主页定时器,并执行 gameover 的弹窗。然后通过两个 for 循环,先遍历所有敌机,再对每一个子弹遍历,判断是否子弹和敌机碰撞,如果二者碰撞那就通过 removeChild 把移除元素。
将 check 方法加入定时器中:
function start() {
timer = setInterval(function() {
// 2.1 背景滚动
bgMove();
// 2.2 hero的操作 pc键盘
heroMove();
// 2.3 敌机的创建与运动
enemyMove();
// 2.4 子弹的创建与运动
bulletMove();
// 2.5 碰撞检测
check();
}, 30)
}
运行项目,看一下效果能否都实现:
在子弹在和敌机碰撞时,就达到了消灭敌机的效果,并且 hero 在与敌机相撞时也会弹窗提示游戏结束,这样我们游戏的主体部分就完成了,剩下的就是一个让人头疼的带本地存储功能的计分和排行榜功能了。
8. 统计得分💨
我们设置当子弹击毁敌机的时候得分就加一,得分会在游戏界面的左上角显示出来,这一节我们主要实现得分的这个功能,显示与样式这里先不关注。
因为在子弹和敌机碰撞的时候得分才会加一,所以这个功能应该添加在上一节的 check 方法之中
先在 check.js 中获取元素,定义得分变量 score:
var score = 0; //得分
var pScore = document.getElementById("score");
这里 pScore 获取的就是游戏界面左上角装载得分的盒子
然后是得分的逻辑实现:
for (var j=0; j<bs.length; j++) {
var b = bs[j];
if (isCrash(e,b)) {
// 1. 子弹消失
dBullet.removeChild(b);
// 2. 敌机消失
dEnemy.removeChild(e);
// 3. 加分
score ++;
pScore.innerHTML = "得分:" + score;
// 4. 处理数据
i --;
break;
}
}
现在当子弹命中敌机的时候,左上角的得分就会相应的加一。
9. 设置开始与结束界面💨
在游戏开始的时候应该先设置一个开始界面,然后可以输入昵称,这样方便后续结束游戏的时候设置排行榜。
下面是我们定义的开始界面,样式和 html 结构这里就不展示了,我们主要关注功能的实现:
单击开始按钮的时候就会隐藏开始界面,然后调用 start 函数,star函数封装了定时器 timer :
startBut.onclick = function() {
if (iptNick.value === "") {
alert("昵称不能为空");
return ;
}
dStart.style.display = 'none';
start();
}
开始界面设置完后,我们就实现结束界面,先看一下结束界面的效果:
在结束界面需要我们把最终得分还有排行榜输出出来,这里我们先不关系排行榜如何设置,先实现游戏结束的功能,当点击再来一次的时候,结束面板就会隐藏,弹出开始面板,因为我们知道结束面板的弹出和 hero 与敌机相撞这个事件是绑定的,所以我们可以把这些功能放在一个 gameover 函数中,当触发事件就调用这个函数。
在 index.html 中我们定义一个 gameover 函数:
//游戏结束
function gameover() {
//停止计时
clearInterval(timer);
//修改本次得分
pShowScore.innerHTML = score;
// 设置排行榜
setPHB();
// 显示结束面板
dEnd.style.display = "block";
}
如果游戏结束的话一定要先清除定时器 timer ,否则游戏还会继续进行,然后把最终得分展示在结束面板,然后设置排行榜,这里先定义一个 setPHB 方法,下一节我们再完善里面的功能,最后再显示结束面板,这样 gameover 函数就完成了。
当敌机与hero相撞时,调用gameover函数:
// 英雄与敌机
if (isCrash(dHero, e)) {
// gameover
gameover();
}
下面我们实现单击再来一次重新开始游戏的效果
首先肯定是点击它的时候让结束面板隐藏,显示开始面板,我们定义一个 again 方法:
function again() {
dEnd.style.display = "none";
dStart.style.display = "block";
}
但是这就完事了么?很明显没有,因为当你每次重新开始游戏的时候都应该让 hero 在起始的中间位置,我们再定义一个 setHeroPosition 方法:
var dHero = document.getElementById("hero");
//重新定位hero的位置
function setHeroPosition() {
dHero.style.left = (sw-66)/2 + 'px';
dHero.style.top = sh - 82 + 'px';
}
这个方法我们把它定义在 hero.js 文件中。
那现在重新开始游戏能正常实现了么?也没有,因为我们还得恢复所有数据:
againBut.onclick = function() {
again();
//数据还原
dis = 0;
count = 0;
dBullet.innerHTML = "";
score = 0;
pScore.innerHTML = "得分:0";
dEnemy.innerHTML = "";
setHeroPosition();
}
在 index.html 中定义这个点击事件,先调用前面定义过的 again 方法,然后把所有我们计数用的变量初始化,再把画面中的所有子弹和敌机删除,最后调用 setHeroPosition 方法实现 hero 归位。
至此我们开始界面与结束界面的全部功能就都实现了。
10. 设置带本地存储功能的排行榜💨
我们先想一下这个排行榜应该怎么做,正常就是数据以对象存储在数组里,然后遍历显示在结束面板上。但是这样的话,如果我们刷新页面,所有的数据就被销毁了,那我们这个排行榜也就没有意义了,所以这里要通过 localStorage 本地存储实现。
在 gameover 函数中我们声明了一个 setPHB 方法,现在我们用 localStorage 来实现这个方法:
function setPHB() {
if (!localStorage.phb) {
localStorage.phb = "[]";
}
var arr = JSON.parse(localStorage.phb);
var isExit = -1; //昵称是否存在 -1表示不存在
for (var i=0; i<arr.length; i++) {
if (arr[i].nick === iptNick.value) {
// 存在
isExit = i;
break;
}
}
if (isExit != -1) {
// 更新数据
arr[isExit].score = score;
} else {
//将新数据放入数组
arr.push({
nick: iptNick.value,
score: score
});
}
// 排序
arr = arr.sort(function(a, b) {
return b.score - a.score;
})
//设置ul的内容
setUl(arr);
//将新数据存入到本地
localStorage.phb = JSON.stringify(arr);
}
如果有小伙伴看不太懂,那听我先分析分析:
最开始那个 if 语句是什么意思?
它的意思就是因为localstorage本身就是存在的,所以我们只需要判断localStorage.phb存不存在,不存在就把他赋值为一个空数组。因为我们第一次游戏的话浏览器中肯定没有localStorage.phb,所以我们把它设置为空数组就行。
为什么这里的空数组还有带上引号呢?
因为本地存储只能存储字符串,可以将对象JSON.stringify()编码后存储,或者通过JSON.parse()解析后获取数据
JSON.parse和JSON.stringify都是啥意思?
形象点说,就是JSON.parse方法可以把带字符串的玩意去掉字符串符号,比如原来是 " abc ",经过JSON.parse方法就能变成 abc。JSON.stringify()就是把这个过程反了过来。
通过 JSON.parse(localStorage.phb) 我们把本地存储的数据解码后拿出来,赋给变量 arr 。然后我们定义了一个变量 isExit 。如果排行榜上小张得了十分,下一次小张继续游戏得了十五分的话,那就得在排行榜上更新小张的得分记录,所以这里 isExit 就是干这个的。通过一个 for 循环判段 arr 数组里是否有开始界面输入的昵称相同的,有的话就通过索引更新数据,没有的话就把新的数据放进数组中。然后通过 sort 方法把数组排序,排行榜我们只取前三名。定义一个 setUI 方法把前三名记录在结束面板的排行榜上显示出来,最后再把数组 arr 通过 JSON.stringify() 给编码再存储回去。
下面我们完善 setUI 的代码,实现排行榜的显示效果:
function setUl(arr) {
ul.innerHTML = "";
for (var i=0; i<arr.length; i++) {
if (i > 2) {
break;
}
var li = document.createElement("li");
li.innerHTML = `
<span>${i+1}.</span>
<span>${arr[i].nick}</span>
<span>${arr[i].score}</span>
`;
ul.appendChild(li);
}
}
执行 setUI 的时候,我们先把排行榜清空重新排列,当 i>2 的时候说明是三名往后,就不用执行了直接 break 。排行榜的每条记录通过 innerHTML 插入排名,昵称和得分。
这样我们的飞机大战就全部完工啦!
源码地址:
https://gitee.com/jie_shao1112/aircraft-warhttps://gitee.com/jie_shao1112/aircraft-war