坦克大战 - 防止敌人坦克重叠 踩坑记录

目前,我们在 chapter_18 中完成的坦克大战 0.4 版还存在一个问题:敌人坦克在运动时会发生重叠现象!现在在 chapter_20 中我们就要解决这个问题。

启动点(目前我的坦克大战代码)

我的启动点和老韩的代码存在一定差异,这里先给出目前我的坦克大战代码,方便后续基于我的代码实现时进行说明

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package com.hspedu.tankgame5;

import java.awt.*;

/**
* @author RQTN
* @version 1.0
* 父类坦克
*/
public class Tank {
private int x; // 坦克的横坐标
private int y; // 坦克的纵坐标
private int direct = 0; // 坦克的朝向(上/右/下/左 -> 0/1/2/3)
private int speed = 2; // 坦克的速度(每次移动的像素个数)
private Color color; // 坦克的颜色(玩家坦克和电脑坦克颜色不同)
private boolean isAlive = true;

public Tank(int x, int y) {
this.x = x;
this.y = y;
}

public int getX() {
return x;
}

public void setX(int x) {
this.x = x;
}

public int getY() {
return y;
}

public void setY(int y) {
this.y = y;
}

public int getDirect() {
return direct;
}

public void setDirect(int direct) {
this.direct = direct;
}

public int getSpeed() {
return speed;
}

public void setSpeed(int speed) {
this.speed = speed;
}

public Color getColor() {
return color;
}

public void setColor(Color color) {
this.color = color;
}

public boolean isAlive() {
return isAlive;
}

public void setAlive(boolean alive) {
isAlive = alive;
}

// 坦克上右下左移动方法
// 在坦克上右下左移动方法中,加入对边界情况的限制!
public void moveUp() {
if (y - speed > 0) {
y -= speed;
} else {
y = 0;
}
}

public void moveRight() {
if (x + 60 + speed < MyPanel.WIDTH) {
x += speed;
} else {
x = MyPanel.WIDTH - 60;
}
}

public void moveDown() {
if (y + 60 + speed < MyPanel.HEIGHT) {
y += speed;
} else {
y = MyPanel.HEIGHT - 60;
}
}

public void moveLeft() {
if (x - speed > 0) {
x -= speed;
} else {
x = 0;
}
}

/**
* 获取本坦克炮口的位置
*
* @return int[] 即炮口的 xy 坐标
*/
public int[] getMuzzleXY() {
switch (direct) {
case 0: // 上
return new int[]{x + 20, y};
case 1: // 右
return new int[]{x + 60, y + 20};
case 2: // 下
return new int[]{x + 20, y + 60};
case 3: // 左
return new int[]{x, y + 20};
default:
return null;
}
}

/**
* 判断某个点的坐标是否落在本坦克对应的矩形内部
*
* @param x 该点的 x 坐标
* @param y 该点的 y 坐标
* @return 如果该点落在本坦克对应的矩形内部,返回 true,否则返回 false
*/
public boolean isPointInTank(int x, int y) {
switch (direct) {
case 0:
case 2:
if (x >= this.x && x <= this.x + 40
&& y >= this.y && y <= this.y + 60) {
return true;
}
break;
case 1:
case 3:
if (x >= this.x && x <= this.x + 60
&& y >= this.y && y <= this.y + 40) {
return true;
}
break;
}
return false;
}
}

EnemyTank

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.hspedu.tankgame5;

import java.awt.*;
import java.util.Vector;

/**
* @author RQTN
* @version 1.0
*/
public class EnemyTank extends Tank implements Runnable {
private static final Color ENEMY_COLOR = Color.CYAN;
private Vector<Shot> shots = new Vector<>();

public EnemyTank(int x, int y) {
super(x, y);
setColor(ENEMY_COLOR);
}

public Vector<Shot> getShots() {
return shots;
}

public void shotHero() {
// 规定在游戏区域上,电脑坦克最多同时出现 1 颗子弹
if (!isAlive() || shots.size() == 1) {
return;
}

int[] muzzleXY = getMuzzleXY();
// 根据炮口位置和坦克方向创建子弹
Shot shot = new Shot(muzzleXY[0], muzzleXY[1], getDirect());
shot.setColor(ENEMY_COLOR);
shots.add(shot);
new Thread(shot).start(); // 启动子弹线程
}

// 为了让电脑坦克可以随机移动,应将其实现为一个线程类
@Override
public void run() {
long start1 = System.currentTimeMillis();
long start2 = System.currentTimeMillis();


while (isAlive()) {
// 每 50 ms 电脑坦克移动一次
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}

switch (getDirect()) {
case 0: // 上
moveUp();
break;
case 1: // 右
moveRight();
break;
case 2: // 下
moveDown();
break;
case 3: // 左
moveLeft();
break;
}

// 每 2~4 s 敌人坦克尝试改变一下方向
if (System.currentTimeMillis() - start1 > 1000 * ((int) (Math.random() * 3) + 2)) {
start1 = System.currentTimeMillis();
setDirect((int) (Math.random() * 4));
}

// 每 4~6 s 敌人坦克尝试发射一下子弹
if (System.currentTimeMillis() - start2 > 1000 * ((int) (Math.random() * 3) + 4)) {
start2 = System.currentTimeMillis();
shotHero();
}
}

System.out.println("敌人坦克线程结束~");
}
}

Hero

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.hspedu.tankgame5;

import java.awt.*;
import java.util.Vector;

/**
* @author RQTN
* @version 1.0
* 玩家坦克
*/
public class Hero extends Tank {
private static final Color HERO_COLOR = Color.YELLOW;
private Vector<Shot> shots = new Vector<>();

public Hero(int x, int y) {
super(x, y);
setColor(HERO_COLOR);
}

public Vector<Shot> getShots() {
return shots;
}

public void shotEnemyTank() {
// 规定在游戏区域上,玩家坦克最多同时出现 5 颗子弹
if (!isAlive() || shots.size() == 5) {
return;
}

int[] muzzleXY = getMuzzleXY();
// 根据炮口位置和坦克方向创建子弹
Shot shot = new Shot(muzzleXY[0], muzzleXY[1], getDirect());
shot.setColor(HERO_COLOR);
shots.add(shot);
new Thread(shot).start(); // 启动子弹线程
}

}

Shot

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
package com.hspedu.tankgame5;

import java.awt.*;

/**
* 表示一个子弹(的射击行为)
* 子弹射出后会沿射击方向不断运动,因此将 Shot 实现为一个线程类
* 该线程类启动后,run 方法中不断去更新子弹的 xy 坐标!
*/
public class Shot implements Runnable {
private int x; // 子弹 x 坐标
private int y; // 子弹 y 坐标
private final int direct; // 子弹的运动朝向(子弹打出后不可能拐弯,可设为 final)
private int speed = 3; // 子弹的运动速度
private Color color;
private boolean isAlive = true; // 子弹是否还存活
// isAlive 用于控制两个行为:
// 1. 是否继续更新子弹位置(即子弹线程是否退出)
// 2. 是否继续重绘子弹

public Shot(int x, int y, int direct) {
this.x = x;
this.y = y;
this.direct = direct;
}

public int getX() {
return x;
}

public void setX(int x) {
this.x = x;
}

public int getY() {
return y;
}

public void setY(int y) {
this.y = y;
}

public int getDirect() {
return direct;
}

public boolean isAlive() {
return isAlive;
}

public void setAlive(boolean alive) {
isAlive = alive;
}

public int getSpeed() {
return speed;
}

public void setSpeed(int speed) {
this.speed = speed;
}

public Color getColor() {
return color;
}

public void setColor(Color color) {
this.color = color;
}

@Override
public void run() {
while (isAlive) {
// 每隔 50ms 移动一次子弹,更新其 xy 坐标
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 根据方向更新 xy 坐标
switch (direct) {
case 0: // 上
y -= speed;
break;
case 1: // 右
x += speed;
break;
case 2: // 下
y += speed;
break;
case 3: // 左
x -= speed;
break;
}
// System.out.println(Thread.currentThread().getName() + " (" + x + ", " + y + ")");
// 检查更新后子弹是否超过游戏区域边界
// 注意:外界也可能设置 isAlive 为 false,从而以通知的方式让子弹线程结束!
if (x < 0 || x > MyPanel.WIDTH || y < 0 || y > MyPanel.HEIGHT) {
isAlive = false;
}
}
System.out.println("子弹线程结束~");
}
}

Bomb

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.hspedu.tankgame5;

import java.awt.*;

public class Bomb {

// 爆炸效果本质上是通过绘制爆炸图片来完成
// 这里有三张爆炸图片,分别用于整个动态爆炸效果展示的三个阶段
// life 为 7~9 时绘制 bombImage1,为 4~6 时绘制 bombImage2,为 1~3 时绘制 bombImage3
public final static Image bombImage1;
public final static Image bombImage2;
public final static Image bombImage3;

static {
Toolkit toolkit = Toolkit.getDefaultToolkit();
bombImage1 = toolkit.getImage(Panel.class.getResource("/bomb_1.gif"));
bombImage2 = toolkit.getImage(Panel.class.getResource("/bomb_2.gif"));
bombImage3 = toolkit.getImage(Panel.class.getResource("/bomb_3.gif"));
}

private int x; // 爆炸效果的横坐标
private int y; // 爆炸效果的纵坐标
private int life = 9; // 爆炸持续时间(持续在连续 9 次画板绘制中出现)
private boolean isAlive = true;

public Bomb(int x, int y) {
this.x = x;
this.y = y;
}

public int getX() {
return x;
}

public void setX(int x) {
this.x = x;
}

public int getY() {
return y;
}

public void setY(int y) {
this.y = y;
}

public int getLife() {
return life;
}

public boolean isAlive() {
return isAlive;
}

public void setAlive(boolean alive) {
isAlive = alive;
}

/**
* 画板每绘制一次,递减一次爆炸的持续时间
* 该方法主要用于更好更逼真地绘制爆炸效果
*/
public void lifeDown() {
if (life > 0) {
life--;
} else {
isAlive = false;
}
}
}

MyPanel

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
package com.hspedu.tankgame5;

import javax.swing.*;
import java.awt.*;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.util.Vector;

/**
* @author RQTN
* @version 1.0
* 坦克大战的绘图画板(游戏区域)
*/
public class MyPanel extends JPanel implements KeyListener, Runnable {
public static final int WIDTH = 1000;
public static final int HEIGHT = 750;
private Hero hero = null;
// 后续涉及到多线程,故使用 Vector 作为容器
private Vector<EnemyTank> enemyTanks = new Vector<>();
private int enemyTankSize = 9;
private Vector<Bomb> bombs = new Vector<>();

public MyPanel() {
hero = new Hero(200, 200); // 初始化一台玩家坦克,初始位置为 (200, 200)
// 初始化三台电脑坦克
for (int i = 0; i < enemyTankSize; i++) {
EnemyTank enemyTank = new EnemyTank((100) * (i + 1), 0);
enemyTank.setDirect(2);
// 启动电脑坦克线程,使其开始随机移动
new Thread(enemyTank).start();
enemyTanks.add(enemyTank);
}
}

@Override
public void paint(Graphics g) {
super.paint(g);
// 坦克大战的区域/地图,为填充矩形,默认黑色
g.fillRect(0, 0, WIDTH, HEIGHT);

// 画坦克的代码较多,且坦克的朝向和类型均会影响坦克的绘制,因此最佳实践是封装为方法
// 绘制玩家坦克
if (hero.isAlive()) {
drawTank(hero, g);
}

// 绘制玩家坦克子弹
Vector<Shot> heroShots = hero.getShots();
for (int i = 0; i < heroShots.size(); ) {
Shot heroShot = heroShots.get(i);
if (heroShot.isAlive()) {
drawShot(heroShot, g);
i++;
} else {
heroShots.remove(heroShot);
}
}

// 绘制电脑坦克和每个坦克的相应子弹
for (int i = 0; i < enemyTanks.size(); ) {
EnemyTank enemyTank = enemyTanks.get(i);
if (enemyTank.isAlive()) {
drawTank(enemyTank, g);
i++;
} else {
// 仅当电脑坦克的子弹也都死亡后,才将已死亡的电脑坦克移除
if (enemyTank.getShots().size() == 0) {
enemyTanks.remove(enemyTank);
} else {
i++;
}
}
// 电脑坦克死亡,其已经发射的子弹依然存活,需要进行绘制
Vector<Shot> shots = enemyTank.getShots();
for (int j = 0; j < shots.size(); ) {
Shot enemyShot = shots.get(j);
if (enemyShot.isAlive()) {
drawShot(enemyShot, g);
j++;
} else {
shots.remove(enemyShot);
}
}
}

// 绘制炸弹(爆炸效果)
for (int i = 0; i < bombs.size(); ) {
Bomb bomb = bombs.get(i);
drawBomb(bomb, g);

if (bomb.isAlive()) {
i++;
} else {
bombs.remove(bomb);
}
}

}

/**
* 画坦克(由基础图形组装而成)
*
* @param tank 坦克对象
* @param g 可绘制各种基本图形的画笔
*/
public void drawTank(Tank tank, Graphics g) {

g.setColor(tank.getColor());

// 坦克的朝向会影响基础图形的组装绘制逻辑
int x = tank.getX();
int y = tank.getY();
switch (tank.getDirect()) {
case 0: // 上
g.fill3DRect(x, y, 10, 60, false);
g.fill3DRect(x + 30, y, 10, 60, false);
g.fill3DRect(x + 10, y + 10, 20, 40, false);
g.fillOval(x + 10, y + 20, 20, 20);
g.drawLine(x + 20, y + 30, x + 20, y);
break;
case 1: // 右
g.fill3DRect(x, y, 60, 10, false);
g.fill3DRect(x, y + 30, 60, 10, false);
g.fill3DRect(x + 10, y + 10, 40, 20, false);
g.fillOval(x + 20, y + 10, 20, 20);
g.drawLine(x + 30, y + 20, x + 60, y + 20);
break;
case 2: // 下
g.fill3DRect(x, y, 10, 60, false);
g.fill3DRect(x + 30, y, 10, 60, false);
g.fill3DRect(x + 10, y + 10, 20, 40, false);
g.fillOval(x + 10, y + 20, 20, 20);
g.drawLine(x + 20, y + 30, x + 20, y + 60);
break;
case 3: // 左
g.fill3DRect(x, y, 60, 10, false);
g.fill3DRect(x, y + 30, 60, 10, false);
g.fill3DRect(x + 10, y + 10, 40, 20, false);
g.fillOval(x + 20, y + 10, 20, 20);
g.drawLine(x + 30, y + 20, x, y + 20);
break;
default:
System.out.println("暂未处理~");
}
}

/**
* 绘制子弹
*
* @param shot 子弹对象
* @param g 可绘制各种基本图形的画笔
*/
public void drawShot(Shot shot, Graphics g) {
g.setColor(shot.getColor());
g.draw3DRect(shot.getX(), shot.getY(), 1, 1, false);
}

/**
* 绘制炸弹(爆炸效果)
*
* @param bomb 炸弹(爆炸效果)对象
* @param g 可绘制图片的画笔
*/
public void drawBomb(Bomb bomb, Graphics g) {
Image bombImage = null;
int bombLife = bomb.getLife();
if (bombLife > 6) {
bombImage = bomb.bombImage1;
} else if (bombLife > 3) {
bombImage = bomb.bombImage2;
} else {
bombImage = bomb.bombImage3;
}
g.drawImage(bombImage, bomb.getX(), bomb.getY(), 60, 60, this);
bomb.lifeDown();
}


/**
* 检查某一方子弹是否击中另一方的坦克,如果是则将子弹和坦克的 isAlive 都置 false,
* 表示生命周期结束,并返回 true,否则返回 false
* 具体实现:将坦克视为一个矩形,判断子弹的坐标是否落在坦克对应的矩形内部
*
* @param shot 子弹对象
* @param tank 坦克对象
* @return 如果子弹集中坦克,返回 true,否则返回 false
*/
public boolean isShotHitTank(Shot shot, Tank tank) {
// 要求传入的 shot 和 tank 属于不同阵营
if (shot.getColor().equals(tank.getColor())) {
return false;
}
// 要求传入的 shot 和 tank 目前还存活
if (!shot.isAlive() || !tank.isAlive()) {
return false;
}

if (tank.isPointInTank(shot.getX(), shot.getY())) {
shot.setAlive(false);
tank.setAlive(false);
bombs.add(new Bomb(tank.getX(), tank.getY()));
return true;
}
return false;
}

/**
* 玩家坦克的子弹是否击中电脑坦克,如果是结束两者的生命周期
*/
public void checkHeroShotEnemy() {
Vector<Shot> heroShots = hero.getShots();
for (int i = 0; i < heroShots.size(); i++) {
Shot heroShot = heroShots.get(i);
for (int j = 0; j < enemyTanks.size(); j++) {
EnemyTank enemyTank = enemyTanks.get(j);
isShotHitTank(heroShot, enemyTank);
}
}
}

/**
* 电脑坦克的子弹是否击中玩家坦克,如果是结束两者的生命周期
*/
public void checkEnemyShotHero() {
for (int i = 0; i < enemyTanks.size(); i++) {
EnemyTank enemyTank = enemyTanks.get(i);
Vector<Shot> shots = enemyTank.getShots();
for (int j = 0; j < shots.size(); j++) {
isShotHitTank(shots.get(j), hero);
}
}
}

@Override
public void keyTyped(KeyEvent e) {

}

@Override
public void keyPressed(KeyEvent e) {
switch (e.getKeyCode()) {
case KeyEvent.VK_W:
hero.setDirect(0);
hero.moveUp();
break;
case KeyEvent.VK_D:
hero.setDirect(1);
hero.moveRight();
break;
case KeyEvent.VK_S:
hero.setDirect(2);
hero.moveDown();
break;
case KeyEvent.VK_A:
hero.setDirect(3);
hero.moveLeft();
break;
case KeyEvent.VK_J:
hero.shotEnemyTank();
break;
}

repaint();
}

@Override
public void keyReleased(KeyEvent e) {

}

@Override
public void run() {
while (true) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}

checkHeroShotEnemy();
checkEnemyShotHero();

repaint();
}
}

}

HspTankGame05

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
26
27
28
29
package com.hspedu.tankgame5;

import javax.swing.*;

/**
* @author RQTN
* @version 1.0
*/
public class HspTankGame05 extends JFrame {

private MyPanel mp;

public HspTankGame05() {
mp = new MyPanel();
// 启动画板线程,让画板定时重绘,刷新游戏显示
new Thread(mp).start();
this.add(mp);
this.setSize(1050, 800);
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
// HspTankGame05 注册键盘监听器,当监听到键盘事件后,要求转发给 MyPanel 处理
this.addKeyListener(mp);
this.setVisible(true);
}

public static void main(String[] args) {
HspTankGame05 hspTankGame05 = new HspTankGame05();
}
}

老韩的思路解析

老韩的实现仅限于防止敌人坦克重叠,其思路乍一看不咋地,但在我尝试自己的思路屡屡碰壁后,才发现老韩的实现确实是内藏设计的,我认为有两个比较关键的点:

  1. 防止敌人坦克重叠的最终目标,是实现当坦克撞到其他坦克时能够停下的效果。
    因此在发生碰撞后,一定要搞清楚,到底是限制谁的移动
    举个例子,假设坦克相撞时的情况如下:

    当发生碰撞后,显然应该向右走的坦克停止移动,而向上走的坦克可以继续移动
  2. 坦克相撞后,如何防止坦克之间粘连在一起?
    坦克相撞后,如果没有设计好,很可能出现两辆坦克撞在一起后,就互相黏住无法移动的情况

老韩思路最核心的地方是:仅检查坦克朝向的两个顶点是否落在其他坦克的矩形内部!对应上面两点:

  1. 明确了限制移动的主体,并给出了恰如其分的限制移动的条件,不多不少,如下图:

    • 若将向右移动的坦克作为主体时,我们仅检查其朝向的两个顶点(右上和右下)是否侵入了其他坦克的矩形内部,这里显然是的,所以向右移动的坦克不再向右移动!
    • 向上移动的坦克将继续移动,不受影响,因为将其作为主体时,其朝向的两个顶点(左上和右上)并没有侵入其他坦克的矩形内部!

    在相撞后,向上移动的坦克,其背部的左下顶点其实已经侵入了向右移动的坦克的矩形内部。
    因此如果将限制移动的条件扩大,将坦克背后的两个顶点也加入,那么这里向上移动的坦克被撞到后,就也会停止移动,这是不太合理的,因为其正前方是空阔的,完全可以移动!

  2. 没有检查坦克背部的两个顶点是否侵入了其他坦克的矩形内部,同时还为坦克相撞后分离提供了条件

    由 1. 已知,如果将坦克背后的两个顶点也加入限制移动的条件,那么向上移动的坦克就无法移动了。此时根据限制条件,向右移动的坦克也无法移动,如果向右移动的坦克尝试调转方向往左再移动,但因为调转方向后其背后的右上顶点仍然侵入了向上移动的坦克内部,所以形成了明明两辆坦克正前方都是空阔的,但却依然互相粘连在原地无法移动的奇怪现象。

综上可以看到,老韩只检查坦克朝向的两个顶点,这种克制的思路背后巧妙之处!


老韩的思路在我的启动点上如何实现

我的启动点和老韩的启动点存在差异,另外老韩的思路虽然设计巧妙,但在实现上却非常丑陋,存在大量冗余代码,这里进行优化,提高复用性,确保简洁!

某坦克是否可继续移动,可分解为 3 步:

  1. 实现函数 f1(x, y, tank),判断某个点 (x, y) 是否落在某坦克 tank 的矩形区域内部
  2. 实现函数 f2(tankA, tankB),获取坦克 tankA 朝向的两个顶点 (x1, y1), (x2, y2),分别调用 f1(x1, y1, tankB)f1(x2, y2, tankB),检查顶点是否落在坦克 tankB 的矩形区域内部
  3. 实现函数 f3(tankA, otherTanks),对于坦克 tankA 和 其他所有坦克 otherTanks,分别调用 f2(tankA, otherTanks[i]),检查坦克看 tankA 朝向的两个顶点,是否落在其他所有坦克中某一个坦克的矩形内部

就上面的 1. 而言,Tank 类中存在一个具有相同功能的成员函数 isPointInTank(int x, int y),我们可以直接利用,但在利用时一定要分清楚主体

2. 没有现成的函数,这里我们选择将其实现也放到 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
26
27
28
29
/**
* 判断本坦克朝向的两个顶点,是否侵入其他坦克对应的矩形内部
*
* @param anotherTank 其他坦克
* @return 如果本坦克朝向的两个顶点都没有侵入其他坦克对应的矩形内部,返回 false,否则返回 true
*/
public boolean isTouchAnotherTank(Tank anotherTank) {
// 把握好主体:本坦克(this)朝向的两个顶点是否侵入了其他坦克 anotherTank 的矩形内部
// 本坦克朝向的两个顶点与本坦克的方向有关,因此这里 switch 中放入的是 this.direct
switch (direct) {
case 0:
// 本坦克向上移动,判断本坦克的左上和右上,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x, y)
|| anotherTank.isPointInTank(x + 40, y);
case 2:
// 本坦克向下移动,判断本坦克的左下和右下,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x, y + 60)
|| anotherTank.isPointInTank(x + 40, y + 60);
case 1:
// 本坦克向右移动,判断本坦克的右上和右下,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x + 60, y)
|| anotherTank.isPointInTank(x + 60, y + 40);
case 3:
// 本坦克向左移动,判断本坦克的左上和左下,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x, y)
|| anotherTank.isPointInTank(x, y + 40);
}
return false; // 该 return 理论上不执行(除非异常)
}

3. 也没有现成的函数,在实现函数之前,这里需要先好好考虑应该将函数实现放到哪。

  • 首先,明确该函数会在哪里调用:应该是在坦克的移动方法执行前调用。在进行坦克实际的移动之前,先检查目前坦克朝向的两个顶点,是否侵入了其他所有坦克中的某一个的矩形内部。
    如果是在坦克的所有移动方法内部调用,因为坦克的所有移动方法,都是无形参的,这就给调用 f3(tankA, otherTanks) 带来了难题,tankA 尚能以 this 方式提供,但 otherTanks 去哪里获取?选择并不多:

    • 给坦克的移动方法加上形参 otherTanks
    • otherTanks 设置为一个类内成员

    两种方式都不怎么优雅,这里选择和老韩保持一致(第二种:设置为类内成员

    当然也可以在坦克的所有移动方法的外部使用,这样虽然不需要考虑移动方法的形参的问题,但在外部,需要在所有用到移动方法的前面都加上该函数的调用,也不怎么优雅。

  • 我的实现会扩展到将玩家坦克也实现防止重叠,对于玩家坦克 otherTanks 就是所有电脑坦克,对于电脑坦克 otherTanks 就是不包括自己的电脑坦克再加上玩家坦克
    既然玩家坦克和电脑坦克的比较对象存在差异,该函数就应该实现两个版本放到 HeroEnemyTank 中。

Hero 类版本的实现:

  • 首先给 Hero 添加一个成员 otherTanks 及其 setter 方法

    1
    2
    3
    4
    5
    private Vector<EnemyTank> otherTanks = null;

    public void setOtherTanks(Vector<EnemyTank> otherTanks) {
    this.otherTanks = otherTanks;
    }
  • 实现防止重叠的检查

    1
    2
    3
    4
    5
    6
    7
    8
    public 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

    @Override
    public void moveUp() {
    if (isTouchOtherTanks()) return;
    super.moveUp();
    }

    @Override
    public void moveRight() {
    if (isTouchOtherTanks()) return;
    super.moveRight();
    }

    @Override
    public void moveDown() {
    if (isTouchOtherTanks()) return;
    super.moveDown();
    }

    @Override
    public void moveLeft() {
    if (isTouchOtherTanks()) return;
    super.moveLeft();
    }

EnemyTank 类版本的实现:

  • 首先给 EnemyTank 添加成员 otherTankshero 及其 setter 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    private 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
    10
    public 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

    @Override
    public void moveUp() {
    if (isTouchOtherTanks()) return;
    super.moveUp();
    }

    @Override
    public void moveRight() {
    if (isTouchOtherTanks()) return;
    super.moveRight();
    }

    @Override
    public void moveDown() {
    if (isTouchOtherTanks()) return;
    super.moveDown();
    }

    @Override
    public void moveLeft() {
    if (isTouchOtherTanks()) return;
    super.moveLeft();
    }

目前,防止敌人坦克重叠的功能已经完成,但还有可以改进的地方。

HeroEnemyTank 类中,都需要重写移动方法 moveXXX,而且两个类中重写的移动方法内容是一样的,显然我们应该把重写后的移动方法逻辑提到父类中去
为此我们就必须在父类中也定义 isTouchOtherTanks 方法,当然按照继承的思想,该方法是子类的共有方法,应该提到父类中,该方法在父类中可以没有具体实现,甚至可以做成抽象方法,让运行时动态绑定到 HeroEnemyTank 重写的 isTouchOtherTanks 方法即可。

所以,现在按照继承的思路来进行代码结构的调整。在 HeroEnemyTank 类中,共有的属性是 otherTanks,共有的方法是 isTouchOtherTanks,我们将它们都提到父类中去。

otherTanks 的类型是 Vector<EnemyTank>,在父类定义含有子类类型的变量似乎不太好,换成 Vector<Tank> 似乎更好一点,但这又会遇到另外一个问题,就是泛型不具备继承的特性:无法将 Vector<EnemyTank> 赋值给 Vector<Tank>,为此,我们需要将所有 Vector<EnemyTank> 的地方都改成 Vector<Tank>

先从 Tank 类开始:

  • 增加 otherTanks 属性和相应的 setter 和 getter 方法
1
2
3
4
5
6
7
8
private Vector<Tank> otherTanks = null;
public Vector<Tank> getOtherTanks() {
return otherTanks;
}

public void setOtherTanks(Vector<Tank> otherTanks) {
this.otherTanks = otherTanks;
}
  • Tank 类中定义抽象方法 isTouchOtherTanks,将类改为抽象类
1
2
3
4
5
6
7
8
9
10
public abstract class Tank {
// 省略其他代码

/**
* 判断本坦克朝向的两个顶点,是否侵入了其他所有坦克中某个坦克对应的矩形内部
* 留给子类重写
* @return
*/
public abstract boolean isTouchOtherTanks();
}
  • 修改 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
26
27
28
29
30
31
32
33
34
35
36
37
// 坦克上右下左移动方法
// 在坦克上右下左移动方法中,加入对边界情况的限制!加入防重叠检查!
public void moveUp() {
if (isTouchOtherTanks()) return;
if (y - speed > 0) {
y -= speed;
} else {
y = 0;
}
}

public void moveRight() {
if (isTouchOtherTanks()) return;
if (x + 60 + speed < MyPanel.WIDTH) {
x += speed;
} else {
x = MyPanel.WIDTH - 60;
}
}

public void moveDown() {
if (isTouchOtherTanks()) return;
if (y + 60 + speed < MyPanel.HEIGHT) {
y += speed;
} else {
y = MyPanel.HEIGHT - 60;
}
}

public void moveLeft() {
if (isTouchOtherTanks()) return;
if (x - speed > 0) {
x -= speed;
} else {
x = 0;
}
}

回过头来修改 HeroEnemyTank 子类,先是 Hero

  • 移除 otherTanks 属性和及其 setter 方法,且移除所有重写的移动方法
  • 重写 isTouchOtherTanks 方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    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
    @Override
    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
2
3
4
5
6
7
8
9
10
11
12
// paint 方法中
// EnemyTank enemyTank = enemyTanks.get(i); // old
EnemyTank enemyTank = (EnemyTank) enemyTanks.get(i); // new

// checkHeroShotEnemy 方法中
// EnemyTank enemyTank = enemyTanks.get(j); // old
EnemyTank enemyTank = (EnemyTank) enemyTanks.get(j); // new

// checkEnemyShotHero 方法中
// EnemyTank enemyTank = enemyTanks.get(i); // old
EnemyTank enemyTank = (EnemyTank) enemyTanks.get(i); // new

其实 HeroEnemyTank 中的 shots 属性及其 getter 方法,也可以再提到父类 Tank 中去,这里就不做改动

一个隐藏的坑(坦克撞到空气墙)

老韩的实现中还隐藏了一个 Bug,即坦克撞到空气墙,复现该 Bug 步骤是:先让玩家坦克被击毁(死亡),此时游戏区域上玩家坦克已经爆炸消失,但此时电脑坦克移动到玩家坦克爆炸的地点,会像撞到空气墙一样停下。

该 Bug 的原因不难确定:在进行防重叠检查时,即使 Hero 对象已经死亡,但还是会将其纳入检查范围内,也即电脑坦克会检查自己是否侵入已死亡的 Hero 对象的矩形内部。

另外,在我的实现中,坦克死亡了,其子弹仍然会被绘制(而不是直接不去绘制)。
为了实现这一点,已经死亡的电脑坦克并不会立刻从 MyPanel 类的 enemyTanks 中移除,所以坦克撞到空气墙的现象,在我的版本里面会更加的频繁

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
26
// 绘制电脑坦克和每个坦克的相应子弹
for (int i = 0; i < enemyTanks.size(); ) {
EnemyTank enemyTank = (EnemyTank) enemyTanks.get(i);
if (enemyTank.isAlive()) {
drawTank(enemyTank, g);
i++;
} else {
// 仅当电脑坦克的子弹也都死亡后,才将已死亡的电脑坦克移除(!!!)
if (enemyTank.getShots().size() == 0) {
enemyTanks.remove(enemyTank);
} else {
i++;
}
}
// 电脑坦克死亡,其已经发射的子弹依然存活,需要进行绘制
Vector<Shot> shots = enemyTank.getShots();
for (int j = 0; j < shots.size(); ) {
Shot enemyShot = shots.get(j);
if (enemyShot.isAlive()) {
drawShot(enemyShot, g);
j++;
} else {
shots.remove(enemyShot);
}
}
}

要解决空气墙 Bug,只需要对 Tank 中的 isTouchAnotherTank 略作修改即可:

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
26
27
28
29
30
31
32
33
/**
* 判断本坦克朝向的两个顶点,是否侵入其他坦克对应的矩形内部
*
* @param anotherTank 其他坦克
* @return 如果本坦克朝向的两个顶点都没有侵入其他坦克对应的矩形内部,返回 false, 否则返回 true
*/
public boolean isTouchAnotherTank(Tank anotherTank) {
// 对于已经死亡的其他坦克,默认没有侵入
if (!anotherTank.isAlive) {
return false;
}
// 把握好主体:本坦克(this)朝向的两个顶点是否侵入了其他坦克 anotherTank 的矩形内部
// 本坦克朝向的两个顶点与本坦克的方向有关,因此这里 switch 中放入的是 this.direct
switch (direct) {
case 0:
// 本坦克向上移动,判断本坦克的左上和右上,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x, y)
|| anotherTank.isPointInTank(x + 40, y);
case 2:
// 本坦克向下移动,判断本坦克的左下和右下,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x, y + 60)
|| anotherTank.isPointInTank(x + 40, y + 60);
case 1:
// 本坦克向右移动,判断本坦克的右上和右下,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x + 60, y)
|| anotherTank.isPointInTank(x + 60, y + 40);
case 3:
// 本坦克向左移动,判断本坦克的左上和左下,是否侵入了其他坦克 anotherTank
return anotherTank.isPointInTank(x, y)
|| anotherTank.isPointInTank(x, y + 40);
}
return false; // 该 return 理论上不执行(除非异常)
}

我的思路有哪些问题(记录我踩的一些坑)

  1. 认为如果两个坦克重叠,则任取其中一个坦克,其对应矩形的四个顶点,必然至少有一个落在另外一个坦克的对应矩形内部。
    对于正方形来说是正确的,但对于长方形来说,这是不一定的。
    可以想象一个两坦克摆出的十字架,显然这两个坦克是重叠的,但它们对应矩形的四个顶点都没有落在另外一个坦克的矩形内部

  2. 在认为重叠的判断中,将坦克的矩形的四个顶点都纳入判断条件,会造成坦克碰撞后的原地粘连的现象

  3. 没有弄清楚坦克重叠/碰撞后,到底应该限制谁的移动,也即在实现过程中只盯着防止重叠,却忽略了限制移动。
    我的做法是,将重叠的两个坦克全部停止移动,这是不太合理的。

  4. 在写好了防止坦克重叠的方法后,没有弄清楚应该在哪里使用该方法,从而限制坦克的移动
    我一开始的做法是,在 Tank 中设置 canMove 布尔属性,该属性会在坦克的移动方法中用到,然后在 MyPanelrun 方法中不断进行重叠检查,发生重叠后就将相应坦克的 canMove 布尔属性置为 false
    这样的做法是有一些问题的,MyPanelrun 方法是 100ms 执行一次,而 EnemyTank 的移动是 50ms 一次,Hero 中的移动更是和键盘输入有关(一般小于 50ms)。
    假设 EnemyTank 移动后已经碰撞到其他坦克了,且因为 canMovetrue 所以还可以继续移动,那么必须要等到 MyPanelrun 方法执行检查完将 canMove 设为 false 后才能真正限制其移动,这样 EnemyTank 会重叠得更深一点。
    MyPanelrun 方法的执行周期降低到 50ms 以下可以解决这个问题,但不可否认的是,这种通过“中间商” canMove 间接控制的方式,不仅逻辑上不够直接,还会造成其他麻烦!
    因此,还是像老韩那样,将重叠检查放到每个坦克类中,从而直接在调用移动方法时,进行防重叠检查来限制移动,更直接更明确。