万字长文,用canvas实现经典游戏《坦克大战》

2,468 阅读19分钟

我正在参加「码上掘金挑战赛」详情请看:码上掘金挑战赛来了!

写在前面

前面我们实现了一个简单的canvas库 100行代码写个canvas库 ,也用它做了几个小Demo

看到前面活动里的奖励有小霸王游戏机,瞬间勾起了小时候的回忆,所以就实现了这个坦克大战,这里把实现过程整理出来。

在线体验传送门

回忆

经过短暂的回忆后,这个游戏的玩法和机制大概有下面这些

  • 墙(土砖,铁砖,水,草地)
  • 敌方坦克(种类很多,红坦克打死会出道具)
  • 道具(坦克加命,时钟定身,铁锹保护基地,炸弹清场,星星升级坦克,安全帽无敌)
  • 我方坦克
  • 城堡
  • 提示信息(敌方还有多少坦克,我方还剩多少条命)
  • 其他隐藏机制

初始化游戏场景

60x60的方块,搭建一个13x13的场景

let s2 = new Stage(document.getElementById("stage"), 780, 780);

游戏里的每个砖块都是由4个小砖块组成的,所以可以打掉一个角,这里为了节省时间,最小单位是60x60的方块,我们先来实现土砖

class Brick {
    constructor(option) {
        this.x = option.x
        this.y = option.y
        this.w = option.w
        this.h = option.h
        this.type = "Brick"
        this.image = new Image()
        this.image.src = "./tuzhuan.png"
    }

    draw(ctx) {
        ctx.drawImage(this.image, this.x, this.y, this.w, this.h);
    }
    destroy() {
        this.parent.remove(this)
    }
}

然后根据第一关的分布,完成初始化

// 关卡
let shut = {
// 第一关
    one: [[60, 60],[180, 60],[300,60],[420,60],[540,60],[660,60],
        [60, 120],[180, 120],[300,120],[420,120],[540,120],[660,120],
        [60, 180],[180, 180],[300,180],[420,180],[540,180],[660,180],
        [60, 240],[180, 240],[540,240],[660,240],
        [300, 300],[420,300],
        [0, 360],[120, 360],[180,360],[540,360],[600,360],[720,360],
        [0, 420],[720,420],
        [0, 480],[720,480],
        [60, 480],[180, 480],[540,480],[660,480],
        [60, 540],[180, 540],[540,540],[660,540],
        [60, 600],[180, 600],[540,600],[660,600],
        [60, 660],[180, 660],[540,660],[660,660],
        [300, 420],[420,420],
        [300, 480],[360,480],[420,480],
        [300, 540],[420,540],
        [300, 660],[360,660],[420,660],
        [300, 720],[420,720],]
},
let tuzhuans = shut.one
tuzhuans.forEach(item => {
    let t = new Brick({
        x: item[0],
        y: item[1],
        w: 60,
        h: 60
    })
    s2.add(t)
})

这样第一关的全部砖块就完成了,原本第一关是有铁砖的,这里还没有实现,所以全部用土砖

image.png

实现我方基地

接下来实现我方的老巢,这个东西不管是我方坦克还是敌方坦克,被击中就GG了,也很简单

class Heart{
    constructor(option) {
        this.x = option.x
        this.y = option.y
        this.w = option.w
        this.h = option.h
        this.type = "Heart"
        this.image = new Image()
        this.image.src = "./heart.png"
    }
    draw(ctx) {
        ctx.drawImage(this.image, this.x, this.y, this.w, this.h);
    }
    destroy() {
        this.parent.remove(this)
        // 心脏都被摧毁了,直接GG
        console.log("Game Over")
    }
}

let heart = new Heart({
    x: 360,
    y: 720, // 老巢初始位置
    w: 60,
    h: 60
})
s2.add(heart)

image.png

实现双方坦克

接下来实现敌我双方坦克,坦克我们用4张不同方向的图片,切换方向就换,敌我双方坦克的属性其实是一样的,不过双方交互逻辑不一样。我们再创建一个EnemyTank来表示敌方坦克,初始我们只有一种,后面会追加更厉害的种类,无非是跑的更快,子弹发射的更快,子弹的速度更快,抗打击的次数更多

class Tank {
    constructor(option) {
        this.x = option.x;
        this.y = option.y;
        this.w = option.w;
        this.h = option.h;
        this.type = "Tank"
        this.direction = "up";
        this.image = new Image();
        this.image.src = "./tankup.png";
        this.parent = null
        this.speed = 10
    }
    draw(ctx) {
        this.image.src = `./tank${this.direction}.png`;
        ctx.drawImage(this.image, this.x, this.y, this.w, this.h);
    }
    setDirection(d) {
        this.direction = d;
    }
    destroy() {
        this.parent.remove(this)
    }
}

// 初始化一个我方坦克(黄色)
let tank = new Tank({
    x: 480,
    y: 720,
    w: 60,
    h: 60
})
s2.add(tank)

// 初始化一个敌方坦克(绿色)
let etank = new EnemyTank({
    x: 0,
    y: 0,
    w: 60,
    h: 60
})
s2.add(tanke)

image.png

到目前为止,初始的场景我们已经搭建好了,但是这个时候还没有交互逻辑,接下来我们实现子弹

子弹

子弹其实是一个很单纯的类,简单来说初始化之后就从起点(坦克发射时候的位置)以一定的速度运动到终点(场景边缘),然后销毁。这里我们添加了4个方向的子弹

// 子弹,从起点位置到终点位置,中间做碰撞检测,碰撞了就销毁
class Bullet {
    constructor(option) {
        this.sx = option.sx
        this.sy = option.sy
        this.ex = option.ex
        this.ey = option.ey // 起点和终点
        this.x = this.sx
        this.y = this.sy
        this.w = option.w;
        this.h = option.h;
        this.direction = option.direction // 直接初始化传入即可,不再更新,子弹不拐弯
        this.type = "Bullet";
        this.image = new Image();
        this.image.src = `./bullet${this.direction}.png`;

        this.speed = 16; // 初始速度
        this.level = 20;

        // 初始化就发射出去
        this.fire()
    }
    draw(ctx) {
        ctx.drawImage(this.image, this.x, this.y, this.w, this.h);
    }
    destroy() {
        this.parent.remove(this)
    }
    fire() {
        this.timer = new Timer(() => {
            // 以符合子弹等级的速度运动完,这里其实一般只有x或者y变化,不会同时变的,那样就斜着飞了
            this.x += (this.ex - this.sx) / this.level
            this.y += (this.ey - this.sy) / this.level
        }, this.speed)
    }
}
// 初始化一颗子弹,后面由坦克触发
let b = new Bullet({
    sx: 360,
    sy: 720, // 坦克所在的位置
    ex: 360,
    ey: -20, // x不变,运动到-20,直到看不见就销毁
    w: 10,
    h: 20, // 竖着的子弹尺寸
    direction: "up" // 方向
})
s2.add(b)

zidan.gif

这样子弹就实现了,在实现坦克发射子弹之前,我们来实现坦克的移动,设置方向,根据等级和速度移动即可

坦克移动

class Tank {
    // 省略
    setDirection(d) {
        this.direction = d;
    }
    move() {
        switch (this.direction) {
            case "up":
                this.y -= this.speed;
                break;
            case "right":
                this.x += this.speed;
                break;
            case "down":
                this.y += this.speed;
                break;
            case "left":
                this.x -= this.speed;
                break;
        }
    }
}
// 给方向键添加上移动监听
document.addEventListener("keyup", (e) => {
    switch(e.code) {
        case "ArrowUp": 
            tank.setDirection("up")
            tank.move()
            break
        case "ArrowRight": 
            tank.setDirection("right")
            tank.move()
            break
        case "ArrowDown": 
            tank.setDirection("down")
            tank.move()
            break
        case "ArrowLeft": 
            tank.setDirection("left")
            tank.move()
            break
        default: 
            break
    }
})

move.gif

坦克发射子弹

坦克和子弹实现之后,给坦克加上fire事件,因为坦克和子弹都是有大小的,要让子弹看上去像是从枪管里发射出去的,结合坦克当前位置坦克方向坦克大小子弹大小场景边缘初始化一个子弹即可,子弹会执行自己的运动逻辑

document.addEventListener("keyup", (e) => {
    switch(e.code) {
        case "Space":
            tank.fire()
            break
        default: 
            break
    }
})
class Tank {
    fire() {
        let opt = {}
        switch(this.direction) {
            case "up":
                opt = {
                    sx: this.x + this.w / 2 - config.bullet.w / 2,
                    sy: this.y - config.bullet.h,
                    ex: this.x + this.w / 2 - config.bullet.w / 2,
                    ey: -config.bullet.h,
                    w: config.bullet.w,
                    h: config.bullet.h,
                    direction: "up"
                }
                break
            case "right":
                opt = {
                    sx: this.x + this.w,
                    sy: this.y + this.h / 2 - config.bullet.w / 2,
                    ex: config.stage.w + config.bullet.h,
                    ey: this.y + this.h / 2 - config.bullet.w / 2,
                    w: config.bullet.h,
                    h: config.bullet.w,
                    direction: "right"
                }
                break
            case "down":
                opt = {
                    sx: this.x + this.w / 2 - config.bullet.w / 2,
                    sy: this.y + this.h, 
                    ex: this.x + this.w / 2 - config.bullet.w / 2,
                    ey: config.stage.h + config.bullet.h,
                    w: config.bullet.w,
                    h: config.bullet.h,
                    direction: "down"
                }
                break
            case "left":
                opt = {
                    sx: this.x - config.bullet.h,
                    sy: this.y + this.h / 2 - config.bullet.w / 2,
                    ex: -config.bullet.h,
                    ey: this.y + this.h / 2 - config.bullet.w / 2,
                    w: config.bullet.h,
                    h: config.bullet.w,
                    direction: "left"
                }
                break
            default: 
                break
        }
        let b = new Bullet(opt)
        s2.add(b)
    }
}

fire2.gif

这样一个到处横行霸道还能发射子弹的坦克就完成了,接下来我们实现坦克移动的碰撞检测

碰撞检测

我们已经实现了墙壁(土砖),横行霸道的我方坦克,横行霸道的敌方坦克子弹,但是他们在场景上各爬各的,要实现它们之间的交互逻辑

  • 坦克碰到墙壁和场景边缘不能继续移动
  • 我方子弹打中敌方坦克
  • 敌方子弹打中我方坦克
  • 子弹打中墙壁

就要检测他们之间是否碰撞到了,关于碰撞检测有很多种方式,先说结论,我们使用分离轴检测,这里可以简述一下分离轴检测的原理。

分离轴检测的几个原理

  1. 一个凸多边形的所有的内部点一定都在任意一条边的一侧(非常重要,很好理解) image.png
  2. 两个没重叠的凸多边形,一定存在一条轴线(这条轴有很多,只要找到一条就可以了),使得两个多边形分别在轴的两边(非常重要,很好理解)

image.png

  1. 那么多的分离轴中,一定有一条是平行于某条边的(非常重要,看图理解),两个多边形中间有无数条分离轴,但是一定有一条mn是平行AB

image.png

  1. 计算两个多边形所有顶点在AB法向量上的投影,如果没有相交,就存在这样的分离轴,看图,多边形1的投影点abcde多边形2的投影点fghij是不相交的,那么很显然,ef中间很容易找到一根线就把两个多边形隔开了

image.png

  1. 对所有的边都做上述的检测,只要找到一条轴就说明2者没有发生一丁点的重叠,反之2者就重叠了

这里贴一下分离轴的代码实现,有兴趣的可以研究,没兴趣的可以直接拿去用

// 坐标系向量
class Vector {
    x: number;
    y: number;
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    // 获取向量的长度
    getLength() {
        return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2));
    }
    // 向量相加
    add(v: Vector) {
        return new Vector(this.x + v.x, this.y + v.y);
    }
    // 向量相减
    sub(v: Vector) {
        return new Vector(this.x - v.x, this.y - v.y);
    }
    // 向量点积
    dot(v: Vector) {
        return this.x * v.x + this.y * v.y;
    }
    // 返回法向量
    perp() {
        return new Vector(this.y, -this.x);
    }
    // 单位向量
    unit() {
        let d = this.getLength();
        return d ? new Vector(this.x / d, this.y / d) : new Vector(0, 0);
    }
}

// 投影
class Projection {
    min: number
    max: number
    constructor(min, max) {
        this.min = min
        this.max = max
    }
    // 2个投影是否重叠
    overlaps(p : Projection) {
        return this.max > p.min && this.min < p.max
    }
}

class Polygon {
    points: any;
    constructor(points) {
        this.points = points
    }
    draw(ctx) {
        ctx.beginPath();
        ctx.lineWidth = 1;
        ctx.moveTo(this.points[0].x, this.points[0].y);
        this.points.slice(1).forEach(item => {
            ctx.lineTo(item.x, item.y)
        })
        ctx.lineTo(this.points[0].x, this.points[0].y)
        ctx.strokeStyle = "red";
        ctx.stroke();
        ctx.closePath();
    }
}

// 获取多个点的所有投影轴
function getAxes(points) {
    let axes = [];
    for (let i = 0, j = points.length - 1; i < j; i++) {
        let v1 = new Vector(points[i].x, points[i].y);
        let v2 = new Vector(points[i + 1].x, points[i + 1].y);
        axes.push(v1.sub(v2).perp().unit());
    }
    let firstPoint = points[0];
    let lastPoint = points[points.length - 1];
    let v1 = new Vector(lastPoint.x, lastPoint.y);
    let v2 = new Vector(firstPoint.x, firstPoint.y);
    axes.push(v1.sub(v2).perp().unit());
    return axes;
}

// 获取投影轴上的投影,参数为投影轴向量
function getProjection(v: Vector, points) {
    let min = Number.MAX_SAFE_INTEGER;
    let max = Number.MIN_SAFE_INTEGER;
    points.forEach(point => {
        let p = new Vector(point.x, point.y);
        let dotProduct = p.dot(v);
        min = Math.min(min, dotProduct);
        max = Math.max(max, dotProduct);
    })
    return new Projection(min, max);
}

// 判断两个凸多边形是否碰撞
function isCollision(poly, poly2) {
    let axes1 = getAxes(poly.points);
    let axes2 = getAxes(poly2.points);
    let axes = [...axes1, ...axes2];

    for (let ax of axes) {
        let p1 = getProjection(ax, poly.points);
        let p2 = getProjection(ax, poly2.points);
        if (!p1.overlaps(p2)) {
            return false
        }
    }
    return true
}

这样我们会得到一个函数,传入2个多边形的顶点数组(因为我们的元素都是由xywh描述的),返回他们是否碰撞

function isCollision(poly, poly2) {
    let axes1 = getAxes(poly.points);
    let axes2 = getAxes(poly2.points);
    let axes = [...axes1, ...axes2];

    for (let ax of axes) {
        let p1 = getProjection(ax, poly.points);
        let p2 = getProjection(ax, poly2.points);
        if (!p1.overlaps(p2)) {
            return false
        }
    }
    return true
}

坦克,子弹,墙砖的碰撞

由于坦克,子弹,墙砖都是长方形,也属于凸多边形的一种,而且顶点还特别少,可以直接套用

  • 在子弹的移动过程中,进行子弹和土砖、坦克的检测,如果碰撞了,就销毁子弹,土砖,和坦克
  • 在坦克的移动过程中,进行坦克和土砖、坦克的检测,如果碰撞了,停止移动
class Bullet {
    fire() {
        if(isCollision(this, brick)) {
            this.destroy()
            brick.destroy()
        }
        if(isCollision(this, tank)) {
            this.destroy()
            tank.destroy()
        }
    }
}

考虑到后续的扩展(坦克种类的增加,墙砖种类的增加,还有道具等),封装一个按照种类批量碰撞检测的函数

// elms,所有的元素
// elm,当前检测碰撞元素
// type[],要与之检测的元素种类
// cb,碰撞回调
function checkCollision(elms, elm, type, cb) {
    let checkElms = elms.filter(item => type.includes(item.type) && item.id != elm.id)
    let p = false
    for(let i = 0;i < checkElms.length;i++) {
        if(isCollision(getElementPoints(checkElms[i]), getElementPoints(elm))) {
            p = true
            cb(checkElms[i])
            break
        }
    }
    return p
}

这样坦克的碰撞可以写成这样,以向上移动为例,可以把碰撞种类提取为配置,这样方便扩展

case "left":
    if (this.x >= this.speed) {
        this.x -= this.speed;
    }
    elms = flatArrayChildren(this.parent.children);
    // 我方坦克与墙和敌方普通坦克的碰撞检测,如果发生碰撞了,就把移动重置掉
    checkCollision(elms, this, ["Brick", "EnemyTank"], (elm) => {
        this.x += this.speed
    })
    break;

这个时候一个完整机制的游戏已经完成了,初始化一个我方坦克,控制移动和发射子弹,子弹会自动检测摧毁目标,按一定的机制初始化敌方坦克,随机发射子弹。

// 初始化舞台
let s2 = new Stage(document.getElementById("stage"));

// 初始化所有的墙砖
let tuzhuans = shut.one
tuzhuans.forEach(item => {
    let t = new Brick({
        x: item[0],
        y: item[1],
        w: 60,
        h: 60
    })
    s2.add(t)
})
// 初始化一个我方老巢
let heart = new Heart({
    x: 360,
    y: 720,
    w: 60,
    h: 60
})
s2.add(heart)

// 初始化一个我方坦克
let tank = new Tank({
    x: 480,
    y: 720,
    w: 60,
    h: 60
})
s2.add(tank)

// 初始化一个敌方坦克
let etank = new EnemyTank({
    x: 480,
    y: 0,
    w: 60,
    h: 60
})
s2.add(etank)

// 我方坦克控制交互
document.addEventListener("keyup", (e) => {
    switch(e.code) {
        case "ArrowUp": 
            tank.setDirection("up")
            tank.move()
            break
        case "ArrowRight": 
            tank.setDirection("right")
            tank.move()
            break
        case "ArrowDown": 
            tank.setDirection("down")
            tank.move()
            break
        case "ArrowLeft": 
            tank.setDirection("left")
            tank.move()
            break
        case "Space":
            tank.fire()
            break
        default: 
            break
    }
})

fire3.gif

相信我,我已经很用力的在按方向键了,碰到墙砖是真的前进不了的,先来一发自杀,可以看到,碰撞检测是非常精准的,精度可以达到1px以内,子弹在碰到目标元素的一瞬间就触发了。

击毁敌方坦克 fire4.gif

接下来我们可以开始着手实现游戏机制了

  1. 敌方势力强大,有很多坦克
  2. 敌方的驾驶员是猪,上一颗子弹销毁了才会发射下一颗,每0.5s才会进行一次移动
  3. 我方坦克过于老旧,上一颗子弹销毁了才会发射下一颗
  4. 敌方驾驶员喝大酒了,一直前进,直到碰到墙或者坦克,才会随机进行一次转向

敌方坦克AI

class EnemyTank {
    action() {
        this.actionTimer = new Timer(() => {
            this.fire()
        }, 500)
        this.moveTimer = new Timer(() => {
            this.move()
        }, 500)
    },
    randomDirection() {
        this.setDirection(['up', 'down', 'right', 'left'][Math.floor(Math.random() * 4)])
    }
    move() {
        switch (this.direction) {
            case "up":
                if (this.y >= this.speed) {
                    this.y -= this.speed;
                } else {
                    // 碰到边缘了也随机换方向
                    this.randomDirection()
                }
                elms = flatArrayChildren(this.parent.children);
                checkCollision(elms, this, ["Brick", "Tank", "EnemyTank"], (elm) => {
                    this.y += this.speed
                    // 碰到墙了就随机换方向
                    this.randomDirection()
                })
                break;
        // 省略
    }
}

做完这些,就可以初始化游戏了

  1. 初始化舞台
  2. 初始化墙砖
  3. 初始化我方坦克,方向键移动,空格键发射
  4. 初始化敌方坦克们
let s2 = new Stage(document.getElementById("stage"));
let tuzhuans = shut.one
tuzhuans.forEach(item => {
    let t = new Brick({
        x: item[0],
        y: item[1],
        w: 60,
        h: 60
    })
    s2.add(t)
})
let heart = new Heart({
    x: 360,
    y: 720,
    w: 60,
    h: 60
})
s2.add(heart)

let tank = new Tank({
    x: 480,
    y: 720,
    w: 60,
    h: 60
})
s2.add(tank)

let enemyTanks = [[60, 0], [180, 0], [300, 0], [420, 0], [540, 0], [660, 0],
                  [180, 420], [540, 420]]
enemyTanks.forEach(item => {
    let t = new EnemyTank({
        x: item[0],
        y: item[1],
        w: 60,
        h: 60
    })
    t.action()
    s2.add(t)
})

document.addEventListener("keyup", (e) => {
    switch(e.code) {
        case "ArrowUp": 
            tank.setDirection("up")
            tank.move()
            break
        case "ArrowRight": 
            tank.setDirection("right")
            tank.move()
            break
        case "ArrowDown": 
            tank.setDirection("down")
            tank.move()
            break
        case "ArrowLeft": 
            tank.setDirection("left")
            tank.move()
            break
        case "Space":
            tank.fire()
            break
        default: 
            break
    }
})

先来体验一把,看我神威,无坚不摧 game.gif

竟然没打过,可惜,再来一把 game2.gif

又没打过,可恶,再来,把坦克的初始方向改成向左,这样他们就不会开始就向下进攻了,总算打过了 game3.gif

其他类型的砖块

虽然游戏的基本功能已经完成了,但是我们只有一种土砖,实际还有其他功能的砖块的

  • 土砖,阻挡坦克移动,任意子弹都能击毁
  • 铁砖,阻挡坦克移动,普通子弹不能击毁,吃到三颗星星以上的坦克发射的子弹可以击毁
  • 水砖,阻挡坦克移动,子弹可以穿过,不能销毁
  • 草砖,坦克可以移动,但是会遮盖坦克,特殊子弹可以击毁

实现铁砖和水砖

由于我们已经实现了土砖,所以这里只需要继承一下,设置一下类型即可

class SteelBrick extends Brick {
    constructor(option) {
        super(option)
        this.type = "SteelBrick"
        this.image = new Image()
        this.image.src = "./tiezhuan.png"
    }
}
class WaterBrick extends Brick {
    constructor(option) {
        super(option)
        this.type = "WaterBrick"
        this.image = new Image()
        this.image.src = "./shuizhuan.png"
    }
}

新的碰撞

由于铁砖和水砖的碰撞逻辑是特殊的,那我们是不是又要重新写之前的碰撞逻辑呢?

答案是不需要,因为我们前面已经把碰撞的逻辑封装成按类型去检测了,所以砖块的检测逻辑就成了这样

  • 土砖,与所有的子弹进行碰撞检测,碰撞后销毁子弹和土砖
  • 铁砖,与所有的子弹进行碰撞检测,普通子弹销毁,特殊子弹同时销毁铁砖
  • 水砖,只与坦克进行碰撞检测,阻止移动
  • 草砖,只与特殊子弹进行碰撞检测,同时销毁子弹和草砖

我们稍微改一下子弹和坦克的碰撞逻辑

在子弹的逻辑中,把铁砖加进要检测的类型里,然后不销毁就可以了,水砖根本不用与子弹检测

class Bullet {
    // 省略
    fire() {
        // 省略
        // 把 SteelBrick 加入要检测的类型数组里
        checkCollision(elms, this, ["SteelBrick", "Brick", "EnemyTank", "Heart"], (elm) => {
            if(elm.type != "SteelBrick") {
                elm.destroy()
            }
        })
    }
}

在坦克的移动碰撞检测里,加上铁砖即可

class Tank {
    move() {
        // 省略
        checkCollision(elms, this, ["SteelBrick", "Brick", "EnemyTank"])
    }
}

这样我们特殊的砖块就加入到游戏中了,从上面的实现可以看到,砖块的扩展是很方便的,以后有什么金砖银砖要加进游戏也很简单

steel.gif

water.gif

高级坦克

高级砖块有了,那么高级坦克自然也要有,主要是敌方坦克,我方坦克是吃道具升级的(说到这里,后面肯定要实现道具了)

  • 道具坦克,因为游戏中该坦克总是闪烁着红光,我喜欢叫它红坦克,击毁后会在地图上掉落随机道具,可能与墙砖重叠
  • 高级坦克,主要是跑的更快,子弹速度更快,抗揍,打中几次才会击毁

抗揍能力

上面提到,高级坦克是能抗好几发子弹的,那这个逻辑怎么顺利的加到我们的游戏里呢?一开始我的想法是在碰撞里做检测,判断坦克的类型,达到次数后再销毁

checkCollision(elms, this, ["SeniorEnemyTank"], (elm) => {
    // 每中一次弹就减一次,直到销毁
    if(elm.lifeCount > 0) {
        elm.lifeCount --
    } else {
        elm.destroy()
    }
})

但是这样等坦克种类多的时候,里面的逻辑就很混乱,于是我把子弹碰撞的逻辑改成了中弹,不管是坦克还是墙砖,只要与子弹碰撞了就调用中弹,至于中弹之后是该销毁还是减少血量,各元素内部自行处理,包括上面提到的安全帽道具,我方坦克还是会中弹,但是中弹之后,由于自身有安全帽,那么不执行中弹的后果就行了,这样逻辑就清晰很多

checkCollision(elms, this, ["SeniorEnemyTank"], (elm) => {
    // 元素中弹
    elm.gotShot()
    // 子弹自己销毁
    this.destroy()
})

高级坦克的中弹处理

class SeniorEnemyTank {
    constructor() {
        this.lifeCount = 4
    }
    gotShot() {
        this.lifeCount --
        if(this.lifeCount <= 0) {
            this.destroy()
        }
    }
}

senior.gif

可以看到,普通坦克(绿色)一发子弹就挂了,高级坦克(白色)要打中4次才会挂掉,更多高级的坦克只是属性不同罢了,这里就不多搞了

铁砖的优化

还记得前面铁砖的逻辑吗,我们在子弹的逻辑里是这样写的

checkCollision(elms, this, ["SteelBrick"], (elm) => {
    this.destroy()
    if(elm.type != "SteelBrick") {
        elm.destroy()
    }
})

现在也可以把中弹的逻辑优化上去了,铁砖中弹无事即可

checkCollision(elms, this, ["SteelBrick"], (elm) => {
    elm.gotShot()
    this.destroy()
})
class SteelBrick {
    gotShot() {
        // do nothing
    }
}

道具来了

一个游戏没有道具那还玩个锤子,前面我们已经实现了抗揍的高级敌方坦克,现在要实现给我们送道具的红坦克,击毁后会在地图随机一个位置生成一个随机道具,道具的功能各种各样,有些道具比较简单,就不一一实现了,说一下实现原理就行了

  • 坦克,吃了加一条命,不做,要实现的话,搞个全局变量记个数就行了,我方坦克挂了,在初始位置再弄一个
  • 时钟,吃了敌方坦克定身一定时间,不做,要实现的话,在所有敌方坦克的控制逻辑里加上变量校验就行了
  • 铁锹,吃了我方城堡外面的砖一定时间内变成铁砖,不做,要实现的话,搞个定时器把对应位置的砖换一下就行了
  • 安全帽,吃了我方坦克无敌,不做,要做的话也是一样,搞个变量,在我方坦克中弹的时候判断一下就行了
  • 炸弹,吃了所有敌方坦克全部爆炸,不做,这个太简单了
  • 星星,吃了我方坦克可以加速,子弹速度变快,3个以上可以打铁砖,这个要做一下,坦克就靠这个升级了

星星道具效果

  • 加速,吃了坦克移速更快,子弹速度也更快
  • 加攻击,吃了坦克的子弹攻击力更大,原本打几下才能死的坦克可以一下打死
  • 质变,我记得好像3颗星星之后,子弹就可以打铁砖了,这里就不搞这么花里胡哨了
  • 抗揍,有的版本里好像吃多了星星还能抗子弹,这里就不搞这么花里胡哨了
class Star {
    // 省略
    beEaten(elm) {
        // 星星被吃之后,给目标加属性,其他道具一样的逻辑
        // 比如炸弹,在这里直接销毁所有敌方坦克就完事了
        elm.star ++
        this.destroy()
    }
}

坦克加速

坦克增加星星数速度攻击力的属性,吃到星星的时候改变属性,然后坦克的交互加上这些属性的计算

this.x += this.speed
// 或者
this.y += this.speed
// 子弹的话,初始化的时候传递一个更大的参数即可

坦克加攻击

前面我们实现了坦克或者墙砖的中弹逻辑,现在加上子弹攻击力的逻辑,默认子弹的杀伤力是1,吃了星星之后就加1,在中弹的时候传递给对应的中弹目标,进行扣血逻辑

// 由于子弹的参数是完全由坦克的状态初始化的,星星越多的坦克,初始化出来的子弹伤害越高,
checkCollision(elms, this, ["Brick", "EnemyTank"], (elm) => {
    this.destroy()
    elm.gotShot(this.hurt)
})

相应的,坦克中弹逻辑就要加上伤害的处理了

class Tank {
    // 反正减到0以下就挂了
    gotShot(hurt) {
        this.lifeCount -= hurt
        if(this.lifeCount <= 0) {
            this.destroy()
        }
    }
}

坦克里加上与道具的碰撞去吃掉,同时增加属性

class Tank {
    move() {
        // 单独加上吃道具逻辑,后续可以扩展
        checkCollision(elms, this, ["Star"], (elm) => {
            elm.beEaten(this)
        })
    }
}

可以看下图,原本一辆高级坦克我们要攻击4下才可以打死,因为我们默认攻击力为1,但是我们吃了一颗星星后,只需要攻击2次就打死了 star.gif

优化

其实游戏机制到这里已经差不多了(还剩一些简单的交互),剩下的无非是怎么合理的设计每局的墙砖类型和分布,怎么增加敌方的坦克,或者优化敌方坦克的移动和发射机制(这个需要专业的人去设计了),或者扩展我方坦克的数量,地方坦克的种类,道具的种类等等,这都不是我们要考虑的了,但是从代码层面来说,还是有要优化提升的地方。

  1. 碰撞检测的优化

现在是按照类型去做检测,例如每颗子弹都会与所有的砖块检测,每个坦克也会与所有的砖块检测,地图上每2个元素之间就要做一次碰撞检测,而每一次检测又要对所有的顶点做投影计算,这样的检测每隔16ms一直不停的进行,非常的消耗性能。

很显然,基于我们朴素的观察,一颗左下角的子弹,是不可能与右上角的坦克碰撞的,基于这个朴素的认知,加上游戏里最大单位是60px,我们每次做碰撞检测的时候,只需要筛选出待检元素附近60px范围类的元素来检测就行了

  1. 代码的优化

显然不管是敌方的坦克还是我方的坦克,大部分的逻辑都是一样的,所以有必要做一下封装。然后各个类自行去处理自己的逻辑

// 坦克继承
xxTank extends Tank

// 砖块继承 
xxBrick extends Brick

// 子弹继承
xxBullet extends Bullet

// 道具继承
xxProp extends Prop
  1. 配置的优化

可以把很多值提取为配置,例如关卡砖块的分布,坦克的种类,子弹的种类,道具的种类,每种元素要与之做碰撞检测的元素,这样不管是扩展还是修改游戏机制,都会方便很多

结语

到此为止,这款童年经典就算复刻完成了,有很多瑕疵,但是主要的游戏机制都有了,那些交互逻辑,敌方坦克的AI,关卡分布的逻辑,就不再往下还原了,有兴趣的可以把相关内容私信给我,有空加进去。