超详细的纯 JS 实现童年经典飞机大战游戏,带解析和预览图

1,861 阅读12分钟

前言:为方便大家更快体验,整体代码已上传至gitee

一. 总体效果预览️️ ✈

1.gif

二. 案例分析 ✈

开发前我们需要思考清楚整体的架构和所需要的功能模块

  • 开始前 : 一个开始游戏面板 
  • 游戏中: 背景滚动  hero 的操作  敌机的创建与运动  子弹的创建与运动  碰撞检测
  • 游戏结束: 一个排行榜面板

三. 适配设备 ✈

新建 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

这个判断移动端的方法大家可以保存下来,以后很多的项目我们也用的到

因为移动端下背景图片要占满屏幕,所以需要一个if语句进行判断,如果isPhone返回的是true,说明当前在移动端,则需要修改背景图片的宽高:

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 就是当前设备的屏幕宽度

四. 背景滚动 ✈

游戏背景是最外层盒子 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 里面定义一个定时器

function start() {
    timer = setInterval(function() {
        // 2.1 背景滚动
        bgMove();
        }, 30)
}

在我们制作的这个游戏中,不论是背景移动还是待会要做的 hero的移动,敌机的移动,最后封装的函数都需要在这个定时器里调用,这样才会有我们看到的那种动画一样的效果

效果图

2.gif

五. hero操作 ✈

新建一个控制 hero 移动的 js 文件:hero.js ,分三步进行开发

  • 获取装 hero 飞机的盒子
  • 添加键盘事件,判断按下的状态
  • 封装移动函数

这里需要着重强调的就是第二步(这里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;
    }  
}

这里每当按下键盘或者键盘抬起的时候,都会判断相应的状态,如果没有这一步,实现不了飞机向左上飞或者向右上飞,只能要么竖着上下飞,要么横着左右飞

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

效果图

3.gif

六. 敌机的创建与运动 ✈

在实现敌机的创建之前,要让生成的敌机实现随机分布,所以在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)
    }

效果图

4.gif

七. 子弹的创建与运动 ✈

创建一个 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 相关的,所以子弹的 topleft 值需要用到 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';
    }
}

在子弹的移动函数中调用子弹的创建函数,通过 spacecount 两个变量来控制子弹的生成频率,要不然子弹每隔30毫秒就生成一个就太快了;然后让子弹在超出边界后就自动销毁

把这个方法和之前一样加到主页的定时器中

    function start() {
        timer = setInterval(function() {
            // 2.1 背景滚动
            bgMove();
            // 2.2 hero的操作  pc键盘
            heroMove();
            // 2.3 敌机的创建与运动
            enemyMove();
            // 2.4 子弹的创建与运动
            bulletMove();
        }, 30)
    }

效果图

5.png

八. 碰撞检测 ✈

在这一节要实现子弹与敌机相碰时,子弹和敌机都会销毁,如果 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)
}

效果图

6.gif

九. 统计得分 ✈

设置当子弹击毁敌机的时候得分就加一,得分会在游戏界面的左上角显示出来,这一节主要实现得分的这个功能,显示与样式这里先不关注

因为在子弹和敌机碰撞的时候得分才会加一,所以这个功能应该添加在上一节的 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;
            }
        }

现在当子弹命中敌机的时候,左上角的得分就会相应的加一

十. 设置开始与结束界面 ✈

在游戏开始的时候应该先设置一个开始界面,然后可以输入昵称,这样方便后续结束游戏的时候设置排行榜

下面是定义的开始界面,样式和 html 结构这里就不关注了,详情可留意前言中的代码包,这里主要关注功能的实现

效果图

7.png

单击开始按钮的时候就会隐藏开始界面,然后调用 start 函数,star函数封装了定时器 timer

    startBut.onclick = function() {
        if (iptNick.value === "") {
            alert("昵称不能为空");
            return ;
        }
        dStart.style.display = 'none';
        start();
    }

开始界面设置完后,就实现结束界面,先看一下结束界面的效果

8.png

在结束界面需要把最终得分还有排行榜输出出来,这里我们先不关系排行榜如何设置,先实现游戏结束的功能,当点击再来一次的时候,结束面板就会隐藏,弹出开始面板,因为我们知道结束面板的弹出和 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';
}

那现在重新开始游戏能正常实现了么?也没有,因为还得恢复所有数据

    againBut.onclick = function() {
        again();
        //数据还原
        dis = 0;
        count = 0;
        dBullet.innerHTML = "";
        score = 0;
        pScore.innerHTML = "得分:0";
        dEnemy.innerHTML = "";
        setHeroPosition();
    }

index.html 中定义这个点击事件,先调用前面定义过的 again 方法,然后把所有计数用的变量初始化,再把画面中的所有子弹和敌机删除,最后调用 setHeroPosition 方法实现 hero 归位