主要是利用JAVA的swing和多线程做一个简易飞机大战的小游戏,功能比较简单。完整代码已经上传到 https://download.csdn.net/download/weixin_42368748/12137255 ,可免费下载。
通过鼠标控制己方飞机的左右移动,移动到不同地方按下空格键切换不同的状态(颜色),它发射出来的子弹要打到相同颜色的敌机才能使其击毁。击毁一架敌机加一分,敌机越界则己方扣一滴血,到0则游戏结束。
简单讲述一下设计思路,首先打开UI布局,然后启动飞行物管理线程;从而生成己方飞机,并且开启管理子弹的线程和管理敌机的线程;这两个线程生成子弹和敌机。
可以看看这个粗略的UML图,大致理解各个类的关系:
GameUI:游戏布局类。用JFrame和JPanel实现游戏布局;创建FlyCtrl对象,同时启动后者这一线程。
FlyCtrl:飞行物管理类,同时也是一个管理整个游戏的线程。包括创建Plane对象、ScoreBoard对象;启动BulletThread线程和ShipThread线程;存储上述两个线程产生的Bullet对象和Ship对象,并进行管理。
Plane:己方飞机类。存储己方飞机的位置、大小、状态等信息;受监听器MListener控制;提供位置信息给BulletThread;包含对自身的绘制。
ScoreBoard:得分和血量显示类。存储当前游戏得分、己方飞机血量;包含加分、扣血等方法;回馈信息给FlyCtrl;作为难度参考提供信息给ShipThread。
BulletThread:管理子弹生成的线程。根据Plane的位置和状态定时生成Bullet对象,加入到FlyCtrl的子弹列表中。
ShipThread:管理敌机生成的线程。根据ScoreBoard提供的当前分数,按不同难度随机生成Ship对象,加入到FlyCtrl的敌机列表中。
Bullet类和Ship类实现FlyObject接口,分别表示一个子弹和一个敌机,包含位置、大小、状态等自身信息,以及移动、绘制自身、得到自身信息的方法。
挑几个重点的讲一下吧!
重点就是启动管理线程:
FlyCtrl实现Runnable接口,所以创建了它的对象后,赋值给一个新的线程,并且start即可。另外,设置标志位是为了方便该线程的控制。
JPanel mainPanel= new JPanel(); //中间区域 游戏主要的区域
mainPanel.setPreferredSize(new Dimension(600,700));
jf.add(mainPanel, BorderLayout.CENTER);
jf.setVisible(true);
FlyCtrl ctrl= new FlyCtrl(mainPanel); //飞行控制线程,控制所有飞行物
Thread ctrlThread = new Thread(ctrl);
ctrlThread.start();
ctrl.setFlag(true);
飞行物管理类,同时也是一个管理整个游戏的线程。结构如下:
结合注释看看代码吧:
public class FlyCtrl implements Runnable{
Plane myPlane;
MListener listener;
ScoreBoard scoreBoard;
List<FlyObject> bulletList;
List<FlyObject> enemyList;
AudioClip boomSound;
AudioClip debloodSound;
Graphics2D g;
boolean flag; //线程运行的标志
//初始化
public void init(){
//创建己方飞机对象
Plane myPlane = new Plane();
listener.myplane= myPlane;
this.myPlane= myPlane;
//创建得分、血量显示牌
scoreBoard = new ScoreBoard();
//存储子弹和敌机的列表
bulletList = new ArrayList<FlyObject>();
enemyList = new ArrayList<FlyObject>();
//启动子弹管理线程
BulletThread bulletThread = new BulletThread(myPlane,bulletList);
bulletThread.start();
//启动敌机管理线程
ShipThread shipThread = new ShipThread(enemyList,scoreBoard);
shipThread.start();
}
//构造方法
public FlyCtrl(JPanel mainPanel){
//获取画板
g = (Graphics2D)mainPanel.getGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); //抗锯齿
//创建监听器
listener = new MListener();
mainPanel.addMouseMotionListener(listener); //添加鼠标监听器
mainPanel.addKeyListener(listener); //添加按键监听器
mainPanel.requestFocusInWindow(); //获得焦点
init(); //初始化
try { //生成声音
boomSound = JApplet.newAudioClip(new File(".../boom.wav").toURI().toURL());
debloodSound = JApplet.newAudioClip(new File(".../deblood.wav").toURI().toURL());
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
void setFlag(boolean t){ //设置线程运行标志
flag = t;
}
void crash(int bulletNo, int shipNo){ //碰撞处理方法
bulletList.remove(bulletNo);
enemyList.remove(shipNo);
}
//对子弹和敌机的移动、绘制、碰撞检测
void drawAll(Graphics g){
//创建带缓冲区图像
BufferedImage bi = new BufferedImage(600, 700, BufferedImage.TYPE_4BYTE_ABGR);
//可以理解为获取临时画布
Graphics tmp_g = bi.getGraphics();
//背景覆盖
tmp_g.setColor(Color.white);
tmp_g.fillRect(0, 0, 600, 700);
//绘制己方飞机
myPlane.draw(tmp_g);
//子弹的管理:移动、碰撞检测、绘制
for(int i=0;i<bulletList.size();i++){
FlyObject tmp_b = bulletList.get(i);
if(tmp_b.move()){ //未越界
boolean wh = true; //是否有碰撞
for(int j=0;j<enemyList.size()&&wh;j++){ //遍历敌机检测碰撞
FlyObject tmp_ship = enemyList.get(j);
if( Math.abs(tmp_b.getX()-tmp_ship.getX()) < (tmp_b.getWidth()+tmp_ship.getWidth())
&& Math.abs(tmp_b.getY()-tmp_ship.getY()) < (tmp_b.getHeight()+tmp_ship.getHeight()) ){
//子弹和敌机碰撞
wh=false;
//相同颜色
if(tmp_b.getState() == tmp_ship.getState()){
boomSound.play(); //播放音效
scoreBoard.addPoint();
crash(i,j);
}else{ //不同颜色
bulletList.remove(i);
}
}
}
if(wh) //若没有碰撞
tmp_b.draw(tmp_g);
}else{ //已越界
bulletList.remove(i);
}
}
//敌机的管理:移动、越界处理、绘制
for(int i=0;i<enemyList.size();i++){
FlyObject tmp = enemyList.get(i);
if(tmp.move()){
tmp.draw(tmp_g);
}else{
enemyList.remove(i);
debloodSound.play();
if(!scoreBoard.deBlood()){ //飞船越界则扣血
//血量为0
setFlag(false);
}
}
}
//绘制得分和血量板
scoreBoard.draw(tmp_g);
//把缓存画布上的所有东西真正画到JPanel的画板上
g.drawImage(bi,0,0, null);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//线程运行方法
public void run(){
while(flag){ //游戏运行时
drawAll(g);
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//游戏结束弹窗
String tip="游戏结束,得分为:"+scoreBoard.score+"。是否重新开始?";
int i =JOptionPane.showOptionDialog(null, tip, "游戏结束", JOptionPane.YES_OPTION, 0, null, null, null);
if(i==0){ //重新开始
init(); //初始化
setFlag(true); //设置标志位
this.run(); //重新运行
}
}
}
判断碰撞:| Xa - Xb | <= (Wa + Wb) 且 | Ya - Yb | <= (Ha + Hb) ,也就是横(纵)坐标之差的绝对值不大于两者宽度(高度)之和即为碰撞。另外,还要颜色相同才算有效击毁。
双缓存图像显示和声音播放在后面技术要点中讲。
BulletThread是管理子弹生成的线程:根据Plane的位置和状态定时生成Bullet对象,加入到FlyCtrl的子弹列表中。类似的,ShipThread是管理敌机生成的线程:根据ScoreBoard提供的当前分数,按不同难度随机生成Ship对象,加入到FlyCtrl的敌机列表中。
重点是把己方飞机、Ctrl中的子弹(敌机)列表传进来,然后定期(随机)产生新的子弹(敌机)对象,并放到列表中。
那么只展示BulletThread的代码吧(完整代码):
public class BulletThread extends Thread{
Plane myPlane;
List<FlyObject> bulletList;
Color colors[] = {new Color(176,153,23),new Color(34,177,76),
new Color(0,162,232),new Color(137,2,145)};
public BulletThread(Plane plane,List<FlyObject> bulletList){
this.myPlane=plane;
this.bulletList=bulletList;
}
//新建一个子弹
void newBullet(int initX,int initY,int state){
Bullet tempBullet = new Bullet(initX,initY,colors[state],state);
bulletList.add(tempBullet);
}
public void run(){
while(true){
newBullet(myPlane.planeX,myPlane.planeY-35,myPlane.state);
try {
sleep(150);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
自定义了一个飞行物的接口,让子弹和敌机实现它,主要是为了统一方法,和创建列表的时候可以把它们都放在一起。我原来的设计是己方飞机、子弹、敌机、分数牌都实现该接口,都放在一个列表里面的,但是后面觉得这样判断的时候还要先识别类型,更加麻烦,所以最后就只是让子弹和敌机实现该接口,己方飞机和分数牌单独处理。如果后续还想添加可捡的道具、大boss等等这个接口的作用就更明显了。
方法的作用看注释:
public interface FlyObject {
public boolean move(); //移动
public void draw(Graphics g); //绘制
public int getX(); //返回自身横坐标
public int getY(); //返回自身纵坐标
public int getHeight(); //返回自身高度
public int getWidth(); //返回自身宽度
public int getState(); //返回自身状态(颜色)
}
先说为什么用多线程。使用多线程可以让不同的事情看上去可以同时被执行,例如生成子弹和生成敌机可以。代码最终产生了:主线程、总控线程、子弹生成线程、敌机生成线程。线程的独立性使得总控制、子弹生成、敌机生成可以分开进行,而不会互相牵制(至少在代码层面是这样)。
Thread是Java中用来表示线程的类,要建立线程就要: ①创建Thread对象,②给它赋值一个Runnable(任务),③启动。
例如像这样:
FlyCtrl ctrl= new FlyCtrl(mainPanel); //本质是一个Runnable
Thread ctrlThread = new Thread(ctrl);
ctrlThread.start();
又或者像这样,三步并作两步走:
//启动子弹管理线程
BulletThread bulletThread = new BulletThread(myPlane,bulletList); //直接继承Thread类
bulletThread.start();
//启动敌机管理线程
ShipThread shipThread = new ShipThread(enemyList,scoreBoard);
shipThread.start();
监听鼠标拖拽就要用到 MouseMotionListener 这个监听器接口,主要有两个方法,分别是鼠标按下拖动和鼠标不按下拖动:
public void mouseDragged(MouseEvent e);
public void mouseMoved(MouseEvent e);
而监听键盘按键则用到 KeyListener 这个接口,主要有三个方法,具体可以看我前几天写的这篇博客:【JAVA入门】键盘监听器KeyListener
public void keyTyped(KeyEvent e); //敲击
public void keyPressed(KeyEvent e); //按下
public void keyReleased(KeyEvent e) //松开
如果每个物品一产生或变化就直接画在JPanel的Graphics上的话,就会有闪烁的现象,按我的理解是因为显示器从显示器缓冲区获取图形,而图形没有一次性完整地显示出来,而是每次显示一部分,从而造成闪烁。具体原理可以看看这篇博客:http://blog.csdn.net/xiaohui_hubei/article/details/16319249
解决的方法是具有先创建一个可访问图像数据缓冲区的图像BufferedImage,获取它的Graphics,先把图像画到这个Graphics中,最后再把整个图像画到JPanel的Graphics中。可以理解为,每次先把图像都画在一个临时的缓冲画板上,最后再把整个画板画在容器的画板上。
例如:
BufferedImage bi = new BufferedImage(600, 700, BufferedImage.TYPE_4BYTE_ABGR);
Graphics tmp_g = bi.getGraphics(); //获取缓冲图像上的画笔
tmp_g.setColor(Color.white); //背景覆盖
tmp_g.fillRect(0, 0, 600, 700);
myPlane.draw(tmp_g); //绘制己方飞机
// ... bullet.draw(tmp_g); //绘制所有子弹
// ... ship.draw(tmp_g); //绘制所有敌机
// ...
g.drawImage(bi,0,0, null); //把缓冲图像画到JPanel的画板上
先把wav音频文件赋值给File对象,然后用toURL方法把File转为urlAudio对象,然后用newAudioClip方法转为AudioClip对象,就可以在需要播放的时候直接对AudioClip对象用play方法播放了。不过运行时第一次播放的时候会有较大延迟。
//详细分步:
File f = new File(".../XXX.wav");
URL urlAudio = f.toURL();
AudioClip ac = Applet.newAudioClip(urlAudio);
//一步搞定:
//AudioClip ac = JApplet.newAudioClip(new File(".../XXX.wav").toURI().toURL());
//播放
ac.play(); //单次播放
//ac.loop(); //循环播放
//ac.stop(); //停止播放
弹窗就要用到java.swing中的 JOptionPane 了,它主要有4个方法:
方法名 | 描述 |
---|---|
showConfirmDialog | 询问一个确认问题 选择有 yes/no/cancel |
showInputDialog | 提示要求某些输入 |
showMessageDialog | 告知用户某事已发生 |
showOptionDialog | 上述的集合 |
调用这些方法,然后设置参数即可。详细可以参考一下这一篇博客:https://blog.csdn.net/qq_40791843/article/details/91047377
另外,调用这些方法会返回一个int,例如“是”就返回0,“否”就返回1,其他就返回-1,所以我这里当返回0时就重新开始:
String tip="游戏结束,得分为:"+scoreBoard.score+"。是否重新开始?";
int i =JOptionPane.showOptionDialog(null, tip, "游戏结束", JOptionPane.YES_OPTION, 0, null, null, null);
if(i==0){ //重新开始
//...
}
以后想到什么再随时更新吧。如果有进阶版也会分享出来。
点个赞吧!