前一小节,咱们实现了一个包含能够绘制坦克图片的绘图板的游戏窗体小程序。这一小节,咱们要实现的目标如下:
- 设计坦克的基类
- 实现各种类型带血条坦克的绘制
- 实现坦克移动
- 实现通过方向键控制玩家坦克移动
先调整上一小节的类设计,将MyPanel
作为MyFrame
的成员变量,在MyFrame
无参构造中对其进行实例化和赋值;而MyPanel
中也持有对MyFrame
的依赖,调整如下:
package com.pf.java.tankbattle;
import ...
public class MyFrame extends JFrame {
private MyPanel panel;
public MyFrame() {
...
// 将面板组件添加到窗口对象的内容面板中
this.panel = new MyPanel(this);
getContentPane().add(panel);
...
}
}
package com.pf.java.tankbattle;
import ...
public class MyPanel extends JPanel {
private MyFrame frame;
public MyPanel(MyFrame frame) {
this.frame = frame;
...
}
}
这样主类就简化为:
package com.pf.java.tankbattle;
import ...
public class GameMain {
public static void main(String[] args) {
...
// 创建窗体对象
new MyFrame();
}
}
以上的操作还是遵循面向对象封装的思想,客户端(游戏主类)不需要关心游戏窗体组件内部的部件,这也不应该对客户端暴露出来。复习了下Java中面向对象的封装思想,我们再来看看面向对象中的继承。
坦克类设计
这里咱们首先考虑一个坦克有哪些属性和行为,为其设计一个基类。然后再扩展两个具体的坦克类:玩家的英雄坦克和电脑的敌军坦克来继承这个基类。一起来看下基类中的属性(这里省略了getter和setter方法)和方法(省略了特定的构造器)。
基本的属性:
-
x、y坐标
代表坦克在绘图板中被绘制时的左上角的坐标位置,坦克在前进时会导致某个方向的坐标值变化,转向时也可能导致坐标点的变化。
-
speed
坦克前进的速度,也就是每1000毫秒坦克移动的像素数,如果坦克的速度是40,则移动一个像素需要25毫秒。如果通过多线程来控制坦克移动,则只要每休眠25毫秒让坦克往前移动一个像素即可。
-
direction
枚举类型,坦克前进的方向。
-
blood
坦克的血点,体现坦克血条的长度,被敌方坦克炮弹击中后会掉血,掉到0则坦克会被摧毁(调用其
die()
方法)。 -
picIndex
在绘制坦克时要确定的索引位置,取值范围0至13。
-
gearToggle
记录履带交替改变的布尔变量,坦克每向前移动一个像素,就会在两个只有履带纹样不同的坦克图片之间进行切换:
-
frame
游戏窗体对象
关于坦克基本的行为,这里我们暂时提供几个方法:
/**
* 坦克移动的方法,每次移动一个像素的距离
* @return 是否被阻挡的布尔值,如果被阻挡则不会往前移动一个像素
*/
public boolean move() {
// todo 待实现
return false;
}
/**
* 坦克转向的方法
* @param direction 调转的方向
*/
public void turnRound(Direction direction) {
// 调用direction属性的setter方法设置新的方向
setDirection(direction);
}
/**
* 坦克被绘制的方法
* @param g 绘图板的画笔对象
*/
public void paint(Graphics g) {
// todo 待实现
}
/**
* 坦克被摧毁的方法
*/
public void die() {
// todo 待实现
}
接下来我们着重实现paint(Graphics g)
和move()
方法。
实现paint方法
首先我们封装一个绘制各种类型坦克的方法,实现代码如下:
public void paint(Graphics g) {
// 根据坦克的方向获取其索引 ↓1处
int index = direction.ordinal();
// 计算截取坦克图片的起始位置 ↓2处
int subX = (picIndex + 28 * index + (gearToggle ? 14 : 0)) * SIZE;
// 抠图并绘制 ↓3处
g.drawImage(ResourceMgr.tank.getSubimage(subX, 0, SIZE, SIZE), x, y, null);
}
代码详解:
-
1处获取枚举项所在的索引值,我们在定义方向枚举时是按照上、右、下、左的顺序定义的:
package com.pf.java.tankbattle.enums; /** * 方向枚举类 */ public enum Direction { UP, RIGHT, DOWN, LEFT; }
方向和索引的关系如下:
因此,如果坦克的方向为
DOWN
,则通过direction.ordinal()
我们将得到索引值2
。 -
2处计算要绘制的坦克的起始位置
从下图中不难发现,当
picIndex
确定后,即要绘制的坦克类型确定后,假设picIndex
为0
,我们发现每经过28个SIZE的像素单位后坦克的方向发生了变化,自然按照第一步确定的index
计算出的偏移量为28 * index
,再加上控制履带变化的变量,索引的偏移量为28 * index + (gearToggle ? 14 : 0)
,再算上picIndex
和坦克的SIZE
,最终得到计算坦克抠图的起始位置的表达式为:(picIndex + 28 * index + (gearToggle ? 14 : 0)) * SIZE
-
3处按照第二步计算出来的坦克图片的扣取区域的起点位置,扣取坦克
SIZE
宽高的区域,并以坦克当前的(x, y)
坐标点进行绘制。
下面测试下坦克的绘制:
package com.pf.java.tankbattle;
import ...
public class MyPanel extends JPanel {
// 临时在绘图板中定义一个英雄
private HeroTank heroTank;
...
public MyPanel(MyFrame frame) {
...
// 实例化我们的英雄
heroTank = new HeroTank(Direction.DOWN, 32, 32, 80, 0, frame);
}
@Override
protected void paintComponent(Graphics g) {
...
// 测试坦克的绘制,因为该方法会被调用多次,为防止被覆盖,x坐标每次都设置下
heroTank.setX(32);
heroTank.paint(g);
// 改变履带和x坐标位置再绘制一次
heroTank.setGearToggle(!heroTank.isGearToggle());
heroTank.setX(64);
heroTank.paint(g);
}
}
程序运行截图:
现在咱再给坦克安上血条,首先我们编写一个工具类,以不同的颜色代表不同的血量范围,工具类代码如下:
package com.pf.java.tankbattle.util;
import ...
public class LifeColorUtil {
/**
* 根据血量计算出要显示的血条颜色
* @param blood
* @return
*/
public static Color parseColor(int blood) {
Color c;
if (blood >= 90) {
c = new Color(127, 255, 0);
} else if (blood >= 80) {
c = new Color(118, 238, 0);
} else if (blood >= 60) {
c = new Color(179, 238, 58);
} else if (blood >= 50) {
c = new Color(238, 238, 0);
} else if (blood >= 40) {
c = new Color(238, 220, 130);
} else if (blood >= 30) {
c = new Color(255, 193, 37);
} else if (blood >= 15) {
c = new Color(255, 127, 36);
} else {
c = new Color(255, 48, 48);
}
return c;
}
}
好在idea支持色值展示,我们可以看到随着血量的减少,相应的色值的变化:
在Tank
类的paint
方法的最后再绘制上血条:
public void paint(Graphics g) {
...
// 根据血量计算出血条颜色
g.setColor(LifeColorUtil.parseColor(blood));
// 绘制血条
g.fillRect(x, y == 0 ? y : y - 2, 32 * blood / 100, 2);
}
在MyPanel
类中完善测试代码:
package com.pf.java.tankbattle;
import ...
public class MyPanel extends JPanel {
...
@Override
protected void paintComponent(Graphics g) {
...
...
// 设置血量
heroTank.setBlood(80);
heroTank.paint(g);
...
// 设置血量
heroTank.setBlood(40);
heroTank.paint(g);
...
heroTank.setBlood(12);
heroTank.paint(g);
}
}
效果:
实现坦克移动
要实现坦克的移动很简单,暂时不考虑与边界和障碍物的碰撞检测,在Tank
基类中实现如下:
public boolean move() {
// 让坦克履带转动起来
gearToggle = !gearToggle;
// 实现在前进方向移动一个像素的距离
switch (direction) {
case LEFT:
x--;
break;
case UP:
y--;
break;
case RIGHT:
x++;
break;
case DOWN:
y++;
}
return true;
}
为了让坦克在绘图板中“活”起来,我们需要不断的刷新绘图板的画面,也就是先清除画布,再重新在新的位置绘制坦克,这样坦克就动起来了。为此我们在游戏窗体中创建一个线程,来对整个窗体进行不断的重绘:
package com.pf.java.tankbattle;
import ...
public class MyFrame extends JFrame {
private Thread paintThread;
...
public MyFrame() {
...
// 创建一个线程,不停执行对游戏窗体进行重绘
paintThread = new Thread(() -> {
while (true) {
try {
// 刷新的频率越快,动画越流畅,但也要考虑CPU的开销
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
repaint();
}
});
paintThread.start();
}
}
对当前的窗体对象进行repaint
时,MyPanel
中的paintComponent
方法会自动被调用,因此该方法只要简化为如下即可:
protected void paintComponent(Graphics g) {
super.paintComponent(g);
heroTank.paint(g);
}
剩下的事则是,在游戏启动后,控制坦克移动(调用其move()
方法)即可。
package com.pf.java.tankbattle.entity.tank;
import ...
public class HeroTank extends Tank {
/** 坦克发动机线程 */
private Thread moveThread;
public HeroTank(Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
super(direction, x, y, speed, picIndex, frame);
// 构造和启动坦克引擎
moveThread = new Thread(() -> {
// todo 这里先不考虑坦克被摧毁的情况,引擎发动后就一直持续下去
while (true) {
// 只管向前冲
move();
try {
// 计算每走一个像素花费的毫秒数,并以此作为休眠时间
Thread.sleep(1000 / speed);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
moveThread.start();
}
}
效果如下:
通过方向键操控玩家坦克
是时候将我们的意志注入给英雄的坦克了。接下来我们要实现通过上下左右方向键控制玩家坦克移动。玩家可以同时按下多个方向键,最后按下的起作用,当松开一个方向键后,最近一次按下的起作用,而当全部方向键都松开后,坦克停下来,可以参考下面的示意图:
我们将通过Java AWT组件提供的键盘事件监听器,再结合多线程来实现上述需求。具体代码如下:
package com.pf.java.tankbattle.entity.tank;
import ...
public class HeroTank extends Tank {
/** 坦克发动机线程 */
private Thread moveThread;
public HeroTank(Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
super(direction, x, y, speed, picIndex, frame);
// 注册键盘事件
frame.addKeyListener(new MyKeyListener());
}
/**
* 内部类,实现了键盘事件(键按下、键松开)的处理方法
*/
class MyKeyListener extends KeyAdapter {
/** 记录已按下的方向键的数值 */
private LinkedList<Integer> oprs;
/** 坦克是否处于静止状态,注意必须要保证多线程的可见性,用volatile修饰 */
private volatile boolean stop = true;
public MyKeyListener() {
oprs = new LinkedList<>();
moveThread = new Thread(() -> {
// todo 这里先不考虑坦克被摧毁的情况
while (true) {
// 如果坦克处于停止状态则将线程park住
if (stop) {
LockSupport.park();
}
// 在前进方向移动坦克
move();
try {
// 计算每走一个像素花费的毫秒数,并以此作为休眠时间
Thread.sleep(1000 / getSpeed());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
moveThread.start();
}
@Override
public void keyPressed(KeyEvent e) {
int key = e.getKeyCode();
switch (key) {
case KeyEvent.VK_LEFT:
case KeyEvent.VK_UP:
case KeyEvent.VK_RIGHT:
case KeyEvent.VK_DOWN:
break;
default:
return;
}
// 如果不包含在控制列表中则添加进来
if (!oprs.contains(key)) {
oprs.add(key);
}
// 准备启动坦克
if (stop) {
stop = false;
LockSupport.unpark(moveThread);
}
// 设置坦克转向为最新按下的方向键
setDirection(getDirectionByKey(key));
}
@Override
public void keyReleased(KeyEvent e) {
// 注意下面调用oprs.remove方法传入的参数必须是包装类型
Integer key = e.getKeyCode();
switch (key) {
case KeyEvent.VK_LEFT:
case KeyEvent.VK_UP:
case KeyEvent.VK_RIGHT:
case KeyEvent.VK_DOWN:
break;
default:
return;
}
// 移除松开的方向键
oprs.remove(key);
if (oprs.isEmpty()) {
// 所有方向键都松开,则控制线程的状态变量设为停止
stop = true;
} else {
// 否则取方向控制列表中最近一次添加的
setDirection(getDirectionByKey(oprs.getLast()));
}
}
private Direction getDirectionByKey(int key) {
switch (key) {
case KeyEvent.VK_LEFT:
return Direction.LEFT;
case KeyEvent.VK_UP:
return Direction.UP;
case KeyEvent.VK_RIGHT:
return Direction.RIGHT;
case KeyEvent.VK_DOWN:
return Direction.DOWN;
default:
return null;
}
}
}
}
说明
- 这里控制
moveThread
线程的运行和停止采用的是juc包中的LockSupport
类,调用其pack()
挂起当前线程,但是持有的锁不会被释放,和Thread.sleep(millis)
类似,只是前者唤醒可以由其他线程控制,调用LockSupport.unpark(thread)
即可唤醒先前被park
住的线程。- 这里定义的
stop
变量会有多个线程访问,监听键盘事件的后台线程会对该变量进行读写,而我们创建的moveThread
也会读取它,因此必须用volatile
关键字来修饰它,确保其可见性。- 对方向键的存取这里采用的是
LinkedList
,而不是ArrayList
,因为有频繁的插入和删除操作,自然链表结构实现的效率会更高。
运行程序,玩家可以顺畅的操作方向键来灵活的控制玩家坦克,手感杠杠滴,效果如下:
但存在一个很明显的瑕疵,当短暂的切换方向键时,无法控制坦克只转向而不移动,实际坦克还是会移动一段距离,效果如下:
修复办法:当坦克由静止状态时,按下一个方向键,moveThread
线程会继续执行LockSupport.park()
后续的代码,此时可以适当休眠下,在这个时间间隙里,坦克不会移动,而超过这个时间间隔后才继续调用move()
方法。增加的控制逻辑:
moveThread = new Thread(() -> {
while (true) {
if (stop) {
LockSupport.park();
// 控制坦克只转向而不移动
try {
// 这里短暂休眠下再进行下一轮判断,以便实现短暂按键下只转向不移动
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
...
}
});
效果如下:
通过这一小节的学习,相信大伙儿在敲代码中慢慢找到了学习Java的乐趣,把多线程和集合的知识也运用进来了,对于面向对象也理解的更深刻些了吧。不过这才是开始,后续我们将逐步的过渡到设计模式的实操上来,大家加油!