坦克大战 - 防止敌人坦克重叠 踩坑记录
坦克大战 - 防止敌人坦克重叠 踩坑记录
目前,我们在 chapter_18
中完成的坦克大战 0.4 版还存在一个问题:敌人坦克在运动时会发生重叠现象!现在在 chapter_20
中我们就要解决这个问题。
启动点(目前我的坦克大战代码)
我的启动点和老韩的代码存在一定差异,这里先给出目前我的坦克大战代码,方便后续基于我的代码实现时进行说明
Tank
类:
1 | package com.hspedu.tankgame5; |
EnemyTank
类
1 | package com.hspedu.tankgame5; |
Hero
类
1 | package com.hspedu.tankgame5; |
Shot
类
1 | package com.hspedu.tankgame5; |
Bomb
类
1 | package com.hspedu.tankgame5; |
MyPanel
类
1 | package com.hspedu.tankgame5; |
HspTankGame05
类
1 | package com.hspedu.tankgame5; |
老韩的思路解析
老韩的实现仅限于防止敌人坦克重叠,其思路乍一看不咋地,但在我尝试自己的思路屡屡碰壁后,才发现老韩的实现确实是内藏设计的,我认为有两个比较关键的点:
- 防止敌人坦克重叠的最终目标,是实现当坦克撞到其他坦克时能够停下的效果。
因此在发生碰撞后,一定要搞清楚,到底是限制谁的移动。
举个例子,假设坦克相撞时的情况如下:
当发生碰撞后,显然应该向右走的坦克停止移动,而向上走的坦克可以继续移动! - 坦克相撞后,如何防止坦克之间粘连在一起?
坦克相撞后,如果没有设计好,很可能出现两辆坦克撞在一起后,就互相黏住无法移动的情况。
老韩思路最核心的地方是:仅检查坦克朝向的两个顶点是否落在其他坦克的矩形内部!对应上面两点:
明确了限制移动的主体,并给出了恰如其分的限制移动的条件,不多不少,如下图:
- 若将向右移动的坦克作为主体时,我们仅检查其朝向的两个顶点(右上和右下)是否侵入了其他坦克的矩形内部,这里显然是的,所以向右移动的坦克不再向右移动!
- 向上移动的坦克将继续移动,不受影响,因为将其作为主体时,其朝向的两个顶点(左上和右上)并没有侵入其他坦克的矩形内部!
在相撞后,向上移动的坦克,其背部的左下顶点其实已经侵入了向右移动的坦克的矩形内部。
因此如果将限制移动的条件扩大,将坦克背后的两个顶点也加入,那么这里向上移动的坦克被撞到后,就也会停止移动,这是不太合理的,因为其正前方是空阔的,完全可以移动!没有检查坦克背部的两个顶点是否侵入了其他坦克的矩形内部,同时还为坦克相撞后分离提供了条件!
由 1. 已知,如果将坦克背后的两个顶点也加入限制移动的条件,那么向上移动的坦克就无法移动了。此时根据限制条件,向右移动的坦克也无法移动,如果向右移动的坦克尝试调转方向往左再移动,但因为调转方向后其背后的右上顶点仍然侵入了向上移动的坦克内部,所以形成了明明两辆坦克正前方都是空阔的,但却依然互相粘连在原地无法移动的奇怪现象。
综上可以看到,老韩只检查坦克朝向的两个顶点,这种克制的思路背后巧妙之处!
老韩的思路在我的启动点上如何实现
我的启动点和老韩的启动点存在差异,另外老韩的思路虽然设计巧妙,但在实现上却非常丑陋,存在大量冗余代码,这里进行优化,提高复用性,确保简洁!
某坦克是否可继续移动,可分解为 3 步:
- 实现函数
f1(x, y, tank)
,判断某个点(x, y)
是否落在某坦克tank
的矩形区域内部 - 实现函数
f2(tankA, tankB)
,获取坦克tankA
朝向的两个顶点(x1, y1), (x2, y2)
,分别调用f1(x1, y1, tankB)
和f1(x2, y2, tankB)
,检查顶点是否落在坦克tankB
的矩形区域内部 - 实现函数
f3(tankA, otherTanks)
,对于坦克tankA
和 其他所有坦克otherTanks
,分别调用f2(tankA, otherTanks[i])
,检查坦克看tankA
朝向的两个顶点,是否落在其他所有坦克中某一个坦克的矩形内部
就上面的 1.
而言,Tank
类中存在一个具有相同功能的成员函数 isPointInTank(int x, int y)
,我们可以直接利用,但在利用时一定要分清楚主体!
2.
没有现成的函数,这里我们选择将其实现也放到 Tank
类中,实现如下(注意把握好,限制移动的主体到底是谁):
1 | /** |
3.
也没有现成的函数,在实现函数之前,这里需要先好好考虑应该将函数实现放到哪。
首先,明确该函数会在哪里调用:应该是在坦克的移动方法执行前调用。在进行坦克实际的移动之前,先检查目前坦克朝向的两个顶点,是否侵入了其他所有坦克中的某一个的矩形内部。
如果是在坦克的所有移动方法内部调用,因为坦克的所有移动方法,都是无形参的,这就给调用f3(tankA, otherTanks)
带来了难题,tankA
尚能以this
方式提供,但otherTanks
去哪里获取?选择并不多:- 给坦克的移动方法加上形参
otherTanks
- 将
otherTanks
设置为一个类内成员
两种方式都不怎么优雅,这里选择和老韩保持一致(第二种:设置为类内成员)
当然也可以在坦克的所有移动方法的外部使用,这样虽然不需要考虑移动方法的形参的问题,但在外部,需要在所有用到移动方法的前面都加上该函数的调用,也不怎么优雅。
- 给坦克的移动方法加上形参
我的实现会扩展到将玩家坦克也实现防止重叠,对于玩家坦克
otherTanks
就是所有电脑坦克,对于电脑坦克otherTanks
就是不包括自己的电脑坦克再加上玩家坦克
既然玩家坦克和电脑坦克的比较对象存在差异,该函数就应该实现两个版本放到Hero
和EnemyTank
中。
Hero
类版本的实现:
首先给
Hero
添加一个成员otherTanks
及其 setter 方法1
2
3
4
5private Vector<EnemyTank> otherTanks = null;
public void setOtherTanks(Vector<EnemyTank> otherTanks) {
this.otherTanks = otherTanks;
}实现防止重叠的检查
1
2
3
4
5
6
7
8public boolean isTouchOtherTanks() {
for (int i = 0; i < otherTanks.size(); i++) {
if (isTouchAnotherTank(otherTanks.get(i))) {
return true;
}
}
return false;
}重写
Tank
父类的移动方法,加上防止重叠的检查1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void moveUp() {
if (isTouchOtherTanks()) return;
super.moveUp();
}
public void moveRight() {
if (isTouchOtherTanks()) return;
super.moveRight();
}
public void moveDown() {
if (isTouchOtherTanks()) return;
super.moveDown();
}
public void moveLeft() {
if (isTouchOtherTanks()) return;
super.moveLeft();
}
EnemyTank
类版本的实现:
首先给
EnemyTank
添加成员otherTanks
和hero
及其 setter 方法1
2
3
4
5
6
7
8
9
10private Vector<EnemyTank> otherTanks = null;
private Hero hero = null;
public void setOtherTanks(Vector<EnemyTank> otherTanks) {
this.otherTanks = otherTanks;
}
public void setHero(Hero hero) {
this.hero = hero;
}实现防止重叠的检查
1
2
3
4
5
6
7
8
9
10public boolean isTouchOtherTanks() {
if (isTouchAnotherTank(hero)) return true;
for (int i = 0; i < otherTanks.size(); i++) {
if (this == otherTanks.get(i)) continue;
if (isTouchAnotherTank(otherTanks.get(i))) {
return true;
}
}
return false;
}重写
Tank
父类的移动方法,加上防止重叠的检查1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void moveUp() {
if (isTouchOtherTanks()) return;
super.moveUp();
}
public void moveRight() {
if (isTouchOtherTanks()) return;
super.moveRight();
}
public void moveDown() {
if (isTouchOtherTanks()) return;
super.moveDown();
}
public void moveLeft() {
if (isTouchOtherTanks()) return;
super.moveLeft();
}
目前,防止敌人坦克重叠的功能已经完成,但还有可以改进的地方。
在 Hero
和 EnemyTank
类中,都需要重写移动方法 moveXXX
,而且两个类中重写的移动方法内容是一样的,显然我们应该把重写后的移动方法逻辑提到父类中去。
为此我们就必须在父类中也定义 isTouchOtherTanks
方法,当然按照继承的思想,该方法是子类的共有方法,应该提到父类中,该方法在父类中可以没有具体实现,甚至可以做成抽象方法,让运行时动态绑定到 Hero
和 EnemyTank
重写的 isTouchOtherTanks
方法即可。
所以,现在按照继承的思路来进行代码结构的调整。在 Hero
和 EnemyTank
类中,共有的属性是 otherTanks
,共有的方法是 isTouchOtherTanks
,我们将它们都提到父类中去。
otherTanks
的类型是 Vector<EnemyTank>
,在父类定义含有子类类型的变量似乎不太好,换成 Vector<Tank>
似乎更好一点,但这又会遇到另外一个问题,就是泛型不具备继承的特性:无法将 Vector<EnemyTank>
赋值给 Vector<Tank>
,为此,我们需要将所有 Vector<EnemyTank>
的地方都改成 Vector<Tank>
。
先从 Tank
类开始:
- 增加
otherTanks
属性和相应的 setter 和 getter 方法
1 | private Vector<Tank> otherTanks = null; |
- 在
Tank
类中定义抽象方法isTouchOtherTanks
,将类改为抽象类
1 | public abstract class Tank { |
- 修改
Tank
类中的移动方法逻辑,加入防重叠检查
1 | // 坦克上右下左移动方法 |
回过头来修改 Hero
和 EnemyTank
子类,先是 Hero
:
- 移除
otherTanks
属性和及其 setter 方法,且移除所有重写的移动方法 - 重写
isTouchOtherTanks
方法1
2
3
4
5
6
7
8
9
public boolean isTouchOtherTanks() {
for (int i = 0; i < getOtherTanks().size(); i++) {
if (isTouchAnotherTank(getOtherTanks().get(i))) {
return true;
}
}
return false;
}
再是 EnemyTank
:
- 移除
otherTanks
属性和及其 setter 方法,且移除所有重写的移动方法 - 重写
isTouchOtherTanks
方法1
2
3
4
5
6
7
8
9
10
11
public boolean isTouchOtherTanks() {
if (isTouchAnotherTank(hero)) return true;
for (int i = 0; i < getOtherTanks().size(); i++) {
if (this == getOtherTanks().get(i)) continue;
if (isTouchAnotherTank(getOtherTanks().get(i))) {
return true;
}
}
return false;
}
由于泛型不具有继承性,无法将 Vector<EnemyTank>
赋值给 Vector<Tank>
,所以 MyPanel
中的代码也需要修改:
将
enemyTanks
属性的类型从Vector<EnemyTank>
改为Vector<Tank>
1
private Vector<Tank> enemyTanks = new Vector<>();
enemyTanks
的属性改变后,从中取出元素的编译类型就变为了Tank
,因此后续一些代码也需要略作修改:
1 | // paint 方法中 |
其实 Hero
和 EnemyTank
中的 shots
属性及其 getter 方法,也可以再提到父类 Tank
中去,这里就不做改动
一个隐藏的坑(坦克撞到空气墙)
老韩的实现中还隐藏了一个 Bug,即坦克撞到空气墙,复现该 Bug 步骤是:先让玩家坦克被击毁(死亡),此时游戏区域上玩家坦克已经爆炸消失,但此时电脑坦克移动到玩家坦克爆炸的地点,会像撞到空气墙一样停下。
该 Bug 的原因不难确定:在进行防重叠检查时,即使 Hero
对象已经死亡,但还是会将其纳入检查范围内,也即电脑坦克会检查自己是否侵入已死亡的 Hero
对象的矩形内部。
另外,在我的实现中,坦克死亡了,其子弹仍然会被绘制(而不是直接不去绘制)。
为了实现这一点,已经死亡的电脑坦克并不会立刻从 MyPanel
类的 enemyTanks
中移除,所以坦克撞到空气墙的现象,在我的版本里面会更加的频繁。
1 | // 绘制电脑坦克和每个坦克的相应子弹 |
要解决空气墙 Bug,只需要对 Tank
中的 isTouchAnotherTank
略作修改即可:
1 | /** |
我的思路有哪些问题(记录我踩的一些坑)
认为如果两个坦克重叠,则任取其中一个坦克,其对应矩形的四个顶点,必然至少有一个落在另外一个坦克的对应矩形内部。
对于正方形来说是正确的,但对于长方形来说,这是不一定的。
可以想象一个两坦克摆出的十字架,显然这两个坦克是重叠的,但它们对应矩形的四个顶点都没有落在另外一个坦克的矩形内部在认为重叠的判断中,将坦克的矩形的四个顶点都纳入判断条件,会造成坦克碰撞后的原地粘连的现象
没有弄清楚坦克重叠/碰撞后,到底应该限制谁的移动,也即在实现过程中只盯着防止重叠,却忽略了限制移动。
我的做法是,将重叠的两个坦克全部停止移动,这是不太合理的。在写好了防止坦克重叠的方法后,没有弄清楚应该在哪里使用该方法,从而限制坦克的移动
我一开始的做法是,在Tank
中设置canMove
布尔属性,该属性会在坦克的移动方法中用到,然后在MyPanel
的run
方法中不断进行重叠检查,发生重叠后就将相应坦克的canMove
布尔属性置为false
。
这样的做法是有一些问题的,MyPanel
的run
方法是 100ms 执行一次,而EnemyTank
的移动是 50ms 一次,Hero
中的移动更是和键盘输入有关(一般小于 50ms)。
假设EnemyTank
移动后已经碰撞到其他坦克了,且因为canMove
为true
所以还可以继续移动,那么必须要等到MyPanel
的run
方法执行检查完将canMove
设为false
后才能真正限制其移动,这样EnemyTank
会重叠得更深一点。
将MyPanel
的run
方法的执行周期降低到 50ms 以下可以解决这个问题,但不可否认的是,这种通过“中间商”canMove
间接控制的方式,不仅逻辑上不够直接,还会造成其他麻烦!
因此,还是像老韩那样,将重叠检查放到每个坦克类中,从而直接在调用移动方法时,进行防重叠检查来限制移动,更直接更明确。