【JAVA实践】多线程游戏之多彩飞机大战v1(完整代码、设计思路、技术要点)

主要是利用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接口,分别表示一个子弹和一个敌机,包含位置、大小、状态等自身信息,以及移动、绘制自身、得到自身信息的方法。

重要代码展示

挑几个重点的讲一下吧!

(1) UI布局

  • 创建JFrame。
  • 创建中间面板,加进frame中。
  • 创建底部面板,添加标签插入图片,加进frame中。
  • 设置frame可见。
  • 创建FlyCtrl对象,传入中间面板。
  • 创建新线程,传入上述对象(任务)。
  • 线程start,设置标志位。

重点就是启动管理线程:
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);    

(2) 总控线程 FlyCtrl

飞行物管理类,同时也是一个管理整个游戏的线程。结构如下:

  • 获取画板。
  • 创建Plane对象(设置监听器)、ScoreBoard对象。
  • 创建对象并启动BulletThread线程和ShipThread线程。
  • 创建两个列表分别存储待生成的Bullet对象和Ship对象。
  • 绘制全部物品:
    • 创建缓冲图像。
    • 绘制己方飞机。
    • 对子弹进行处理:
      • 移动;
      • 检测是否出界,若出界则删除该子弹;
      • 检测碰撞,若与不同颜色敌机碰撞则删除子弹,同颜色则摧毁处理(删除、音效)。
      • 绘制现有所有子弹。
    • 对敌机进行处理:
      • 移动;
      • 检测是否出界,若出界说明越界,己方飞机扣血;
      • 绘制现有所有敌机。
    • 绘制得分血量牌。
    • 以上均是绘制到缓冲图像上,现在再把缓冲图像绘制到容器的画板上。
  • 线程循环运行。

结合注释看看代码吧:

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) ,也就是横(纵)坐标之差的绝对值不大于两者宽度(高度)之和即为碰撞。另外,还要颜色相同才算有效击毁。

双缓存图像显示声音播放在后面技术要点中讲。

(3) 管理生成的线程

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();
			}
		}
	}
}

(4) 飞行物接口

自定义了一个飞行物的接口,让子弹和敌机实现它,主要是为了统一方法,和创建列表的时候可以把它们都放在一起。我原来的设计是己方飞机、子弹、敌机、分数牌都实现该接口,都放在一个列表里面的,但是后面觉得这样判断的时候还要先识别类型,更加麻烦,所以最后就只是让子弹和敌机实现该接口,己方飞机和分数牌单独处理。如果后续还想添加可捡的道具、大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();   //返回自身状态(颜色)
}

技术要点

(1)线程创建

先说为什么用多线程。使用多线程可以让不同的事情看上去可以同时被执行,例如生成子弹和生成敌机可以。代码最终产生了:主线程、总控线程、子弹生成线程、敌机生成线程。线程的独立性使得总控制、子弹生成、敌机生成可以分开进行,而不会互相牵制(至少在代码层面是这样)。

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();

(2)鼠标监听器、按键监听器

监听鼠标拖拽就要用到 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) //松开

(3)双缓存图像显示

如果每个物品一产生或变化就直接画在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的画板上

(4)声音播放

先把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();  //停止播放

(5)弹窗

弹窗就要用到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){  //重新开始
	//...
}

后续可拓展方向

  • 增加道具,例如加快子弹发射,多子弹齐发,防护罩,清屏大招等等。只要增加一些实现飞行物FlyObject接口的类,并在敌机生成的线程中随机生成它们的方法就行了。
  • 增加单机双人玩法。增加己方飞机控制线程,并且更好地利用键盘监听器即可。
  • 通信结合,实现在线双人PK等。

以后想到什么再随时更新吧。如果有进阶版也会分享出来。

点个赞吧!

发布了10 篇原创文章 · 获赞 14 · 访问量 605

猜你喜欢

转载自blog.csdn.net/weixin_42368748/article/details/104184991