我正在参加「码上掘金挑战赛」详情请看:码上掘金挑战赛来了!
写在前面
前面我们实现了一个简单的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)
})
这样第一关的全部砖块就完成了,原本第一关是有铁砖的,这里还没有实现,所以全部用土砖
实现我方基地
接下来实现我方的老巢,这个东西不管是我方坦克还是敌方坦克,被击中就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)
实现双方坦克
接下来实现敌我双方坦克,坦克我们用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)
到目前为止,初始的场景我们已经搭建好了,但是这个时候还没有交互逻辑,接下来我们实现子弹
子弹
子弹其实是一个很单纯的类,简单来说初始化之后就从起点
(坦克发射时候的位置)以一定的速度运动到终点
(场景边缘),然后销毁
。这里我们添加了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)
这样子弹就实现了,在实现坦克发射子弹之前,我们来实现坦克的移动,设置方向,根据等级和速度移动即可
坦克移动
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
}
})
坦克发射子弹
坦克和子弹实现之后,给坦克加上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)
}
}
这样一个到处横行霸道还能发射子弹的坦克就完成了,接下来我们实现坦克移动的碰撞检测
碰撞检测
我们已经实现了墙壁
(土砖),横行霸道的我方坦克
,横行霸道的敌方坦克
,子弹
,但是他们在场景上各爬各的,要实现它们之间的交互逻辑
- 坦克碰到墙壁和场景边缘不能继续移动
- 我方子弹打中敌方坦克
- 敌方子弹打中我方坦克
- 子弹打中墙壁
就要检测他们之间是否碰撞到了,关于碰撞检测有很多种方式,先说结论,我们使用分离轴检测
,这里可以简述一下分离轴检测的原理。
分离轴检测的几个原理
- 一个
凸多边形
的所有的内部点一定都在任意一条边的一侧(非常重要,很好理解) - 两个没重叠的凸多边形,一定存在一条轴线(这条轴有很多,只要找到一条就可以了),使得两个多边形分别在轴的两边(非常重要,很好理解)
- 那么多的分离轴中,一定有一条是平行于某条边的(非常重要,看图理解),两个多边形中间有无数条分离轴,但是一定有一条
mn
是平行AB
的
- 计算两个多边形所有顶点在
AB法向量
上的投影,如果没有相交,就存在这样的分离轴,看图,多边形1
的投影点abcde
和多边形2
的投影点fghij
是不相交的,那么很显然,ef
中间很容易找到一根线就把两个多边形隔开了
- 对所有的边都做上述的检测,只要找到一条轴就说明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
}
})
相信我,我已经很用力的在按方向键了,碰到墙砖是真的前进不了的,先来一发自杀,可以看到,碰撞检测是非常精准的,精度可以达到1px
以内,子弹在碰到目标元素的一瞬间就触发了。
击毁敌方坦克
接下来我们可以开始着手实现游戏机制了
- 敌方势力强大,有很多坦克
- 敌方的驾驶员是猪,上一颗子弹销毁了才会发射下一颗,每0.5s才会进行一次移动
- 我方坦克过于老旧,上一颗子弹销毁了才会发射下一颗
- 敌方驾驶员喝大酒了,一直前进,直到碰到墙或者坦克,才会随机进行一次转向
敌方坦克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;
// 省略
}
}
做完这些,就可以初始化游戏了
- 初始化舞台
- 初始化墙砖
- 初始化我方坦克,方向键移动,空格键发射
- 初始化敌方坦克们
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
}
})
先来体验一把,看我神威,无坚不摧
竟然没打过,可惜,再来一把
又没打过,可恶,再来,把坦克的初始方向改成向左,这样他们就不会开始就向下进攻了,总算打过了
其他类型的砖块
虽然游戏的基本功能已经完成了,但是我们只有一种土砖,实际还有其他功能的砖块的
- 土砖,阻挡坦克移动,任意子弹都能击毁
- 铁砖,阻挡坦克移动,普通子弹不能击毁,吃到三颗星星以上的坦克发射的子弹可以击毁
- 水砖,阻挡坦克移动,子弹可以穿过,不能销毁
- 草砖,坦克可以移动,但是会遮盖坦克,特殊子弹可以击毁
实现铁砖和水砖
由于我们已经实现了土砖,所以这里只需要继承一下,设置一下类型即可
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"])
}
}
这样我们特殊的砖块就加入到游戏中了,从上面的实现可以看到,砖块的扩展是很方便的,以后有什么金砖银砖要加进游戏也很简单
高级坦克
高级砖块有了,那么高级坦克自然也要有,主要是敌方坦克,我方坦克是吃道具升级的(说到这里,后面肯定要实现道具了)
- 道具坦克,因为游戏中该坦克总是闪烁着红光,我喜欢叫它红坦克,击毁后会在地图上掉落随机道具,可能与墙砖重叠
- 高级坦克,主要是跑的更快,子弹速度更快,抗揍,打中几次才会击毁
抗揍能力
上面提到,高级坦克是能抗好几发子弹的,那这个逻辑怎么顺利的加到我们的游戏里呢?一开始我的想法是在碰撞里做检测,判断坦克的类型,达到次数后再销毁
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()
}
}
}
可以看到,普通坦克(绿色)一发子弹就挂了,高级坦克(白色)要打中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次就打死了
优化
其实游戏机制到这里已经差不多了(还剩一些简单的交互),剩下的无非是怎么合理的设计每局的墙砖类型和分布,怎么增加敌方的坦克,或者优化敌方坦克的移动和发射机制(这个需要专业的人去设计了),或者扩展我方坦克的数量,地方坦克的种类,道具的种类等等,这都不是我们要考虑的了,但是从代码层面来说,还是有要优化提升的地方。
- 碰撞检测的优化
现在是按照类型去做检测,例如每颗子弹都会与所有的砖块检测,每个坦克也会与所有的砖块检测,地图上每2个元素
之间就要做一次碰撞检测,而每一次检测又要对所有的顶点做投影计算,这样的检测每隔16ms
一直不停的进行,非常的消耗性能。
很显然,基于我们朴素的观察,一颗左下角的子弹,是不可能与右上角的坦克碰撞的,基于这个朴素的认知,加上游戏里最大单位是60px
,我们每次做碰撞检测的时候,只需要筛选出待检元素附近60px
范围类的元素来检测就行了
- 代码的优化
显然不管是敌方的坦克还是我方的坦克,大部分的逻辑都是一样的,所以有必要做一下封装。然后各个类自行去处理自己的逻辑
// 坦克继承
xxTank extends Tank
// 砖块继承
xxBrick extends Brick
// 子弹继承
xxBullet extends Bullet
// 道具继承
xxProp extends Prop
- 配置的优化
可以把很多值提取为配置,例如关卡砖块的分布,坦克的种类,子弹的种类,道具的种类,每种元素要与之做碰撞检测的元素,这样不管是扩展还是修改游戏机制,都会方便很多
结语
到此为止,这款童年经典就算复刻完成了,有很多瑕疵,但是主要的游戏机制都有了,那些交互逻辑,敌方坦克的AI,关卡分布的逻辑,就不再往下还原了,有兴趣的可以把相关内容私信给我,有空加进去。