# 设计思路

总体的设计思路是将整个游戏分成三部分完成: UI部分, 角色属性及功能部分(内部逻辑), 地图部分.

# 进度

目前正在进行开始界面UI部分的设计, 思路是这样的, 整个游戏共分为四个窗体: 菜单窗体, 帮助信息窗体(显示游戏玩法), 游戏窗体, 游戏结束窗体. 通过为不同窗体容器中的组件添加监听事件来实现窗体间的切换. 比如, 菜单窗体的游戏帮助按钮可以切换到帮助信息窗体, 后者的返回按钮又能再切换回去, 这是目前的效果图:

img

img

目前共遇到三个问题

  1. 第二个窗体类没有main方法, 所有的初始化工作都是通过外界新建对象时调用构造函数来实现的, 这样存在一个问题, 为返回按钮添加监听事件时, this关键字调用的是ActionListener类, 从而没法实现关闭效果

  2. 返回按钮监听事件中拉起第一个窗口时, 窗口是空窗口, 不带任何组件, 因为第一个窗体有main方法, 而所有的配置工作都是通过main方法调用的, 这样只是添加

JFrame jf = new JFrame();

jf.setVisible(true);

是没法显示全部组件的, 因为main方法一直没调用

3.第三个问题是JPanel面板的布局问题, 默认布局是流式布局, 也就是说, 所有的组件都是按顺序添加的, 这样组件的位置就会相当难设置, 每当窗口尺寸一变化组件的位置就会变化. 目前只能先禁用窗口最大化功能来固定组件的位置, 后续再进行调试

# 解决方案:

# 问题1:

使窗体类实现ActionListener接口, 再在本类中重写actionPerform方法, 就可以在构造方法中或者其他成员方法中使用 组件.addActionListener(this)来实现组件监听事件的处理, 就不用通过传递对象来添加了

# 问题2:

将本窗体类中的所有组件先在成员变量区声明, 这样在本类的所有地方都能够调用了, 然后将所有的初始化及组件添加操作都放在构造函数中进行, 这样外界只要创建本类对象, 同时就能完成所有初始化操作. 当然也能在本类中新建初始化方法, 然后在构造方法中调用即可. 同样, 菜单窗体类也需要实现ActionListener接口.

# 问题3:

我发现目前暂时只需要一个容器, 所以就不添加JPanel组件了, 使用Container container = jframe.getContentPane()来进行组件的添加. 同时使用setResizable()方法来固定窗体

更新于2019-4-24

# 为窗体添加图片,创建防御塔类和敌人类

我最开始的思路是使用JPanel的子类重写paint方法来绘制图像, 但是后来发现, 绘制图片后, 面板中的组件就会被覆盖掉, 窗体中只显示图片.

此时我的解决方案是在一个窗体下添加两个面板, 分成上下两个部分, 上方用来显示图片, 下方用来展示功能按键. 经过反复调试后, 预期的功能是实现了, 但是达不到我想要的效果. 特别是窗体全屏后,组件还是原来的大小.

后来的解决方法是不用paint方法来绘图, 只要重写paintcomponent方法即可,绘图代码为:

class MenuPanel extends JPanel {
		//覆盖JPanel的paint方法
		//Graphics是绘图的重要类, 可以将其理解成一只画笔
	@Override
	protected void paintComponent(Graphics g) {
		super.paintComponent(g);
		Image im = Toolkit.getDefaultToolkit().getImage(Panel.class.getResource("/menu.png"));
		g.drawImage(im, 0, 0, this.getWidth(), this.getHeight(), this);
	}
}

paintComponent在底层绘制的,不会意外擦除在绘制过程中渲染的任何组件, 绘制的图片会随着窗体的变化而变化. 这是改进的效果图:

img

2019-05-10


我原来一直有一个思维误区, 那就是我原本以为每一个场景都需要一个独立的窗体, 通过窗体间的切换来实现场景切换, 但是原来一直存在一个问题: 窗体切换是会有大概0.2秒的延迟, 整个窗体会闪一下. 之前我认为java会提供一些特殊的机制来消除闪动, 但现在我发现之前的想法一直都是错误的. 整个游戏只需要一个窗体就够了, 先将顶层容器设置为卡片式布局, 再通过不同面板的切换来实现游戏场景的切换, 这样也完美解决了前面的问题.

具体实现是通过在每一个面板类中定义一个主窗体变量, 再通过构造方法传递对应的参数, 从而实现将主窗体变成一个全局变量, 这样在每一个面板中就能通过主窗体实现对其他面板的访问(切换). 另外, 主窗体类中必须定义对应的getXxx()setXxx()方法.

这是目前的三个面板类和主窗体类:

img

实现效果和之前相同, 但是更加完善了.

2019-05-16


# 地图绘制

在这里我使用地图素材的大小是64x64像素的图片,而窗体的分辨率是1366x768,也就是说要把整个窗体绘制完图片,每行大概能放22张,每列12张图片。这样就可以定义一个22行12列二维数组,绘制图片的时候将数组下标ix64作为图片的横坐标, 将jx64作为图片的纵坐标绘制在屏幕上,再通过for循环的嵌套,即可完成地图的绘制,完成的效果如下:

img

所对应的二维数组是这样的:

private int[][] map = { { 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1 },
                        { 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1 },
                        { 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1 },
                        { 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 }, 
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 },
                        { 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1 }
                        };

这是绘制地图的函数:

// drawMap方法
private void drawMap(Graphics g) {
    // 嵌套for循环进行地图绘制
    for (int x = 0; x < ROW; x++) {
        for (int y = 0; y < COL; y++) {
            switch (map[x][y]) {
                case 0:
                    // map标记为0时绘出道路
                    g.drawImage(roadImage, x * CS, y * CS, this);
                    break;
                case 1:
                    // map标记为1时绘出草地
                    g.drawImage(grassLandImage, x * CS, y * CS, this);
                    break;
                default:
                    break;
            }
        }
    }
}

2019-05-17

# 对map、enemy等功能进行封装

之前在GamePanel类中实现地图绘制功能后,会使整个代码看起来比较繁杂,而且后面需要的游戏地图不止一张,所以我将地图功能单独封装成一个GameMap类, 并且提供对应的drawMap()方法。同样的,在实现enemy部分功能后, 我也将其封装起来,并提供相应的drawEnemy()方法,这样的好处是更加体现面向对象的思想,整个代码也更加易于阅读,这是目前实现的效果:

img

这是目前的类图列表:

img

2019-05-19


# 添加炮塔放置功能

由于三个按钮的功能都是一致的:生成Tower对象,而要在屏幕上实现同时监听两个不同位置的鼠标点击事件是非常困难的(即先点击相应的按钮,再点击地图上的位置)。三个按钮唯一不共性的地方是:点击后鼠标的图标会产生相应的变化,所以我就在鼠标监听事件中先进行鼠标图标的判断,相应的图标就在屏幕上绘制相应的对象,这样就达到了目标效果,实现也很简单。这是目前实现的效果:

img

2019-05-21


# 当敌人进入攻击范围内时炮塔发射子弹

​ 将Tower类实现Runnable接口,重写run方法实时监测敌人和防御塔的相对位置,当二者相对位置小于128个像素时新建Bullet类,然后在线程内部不停更新Bullet的坐标。

# 遇到的问题

​ 最开始的思路是,通过游戏面板主线程监测TowerEnemy的相对位置,然后在本线程中开启Tower中的线程,再在Tower线程中调用fire方法(新建Bullet类,并开启线程)。这样就造成了3层线程嵌套,不同步时会出现单一线程抢占CPU时间过长的问题,同步后又会出现一个机制问题:同一时间段只能开启一个线程。这样就造成了目标坐标在变化但是屏幕不刷新的问题。解决方案是:将主线程和Tower线程相互独立开,更新子弹坐标不采用线程实现。

# 实现的效果

img

2019-05-24


# 实现子弹追踪和炮塔转向

# 子弹追踪

​ 实时计算敌人和子弹间距离deltaxdeltay,再利用Math.atan()方法计算出子弹和敌人间与x轴的夹角,最后再用三角函数计算出更新后的坐标。以下为源码:

deltax = (double)landforce.getX() - x;
deltay = (double)landforce.getY() - y;

/*
		 * 为了防止相除的时候分母为0,在 这里需要对delta做判断
		 */
if( deltax == 0 )
{
    if( (double)landforce.getY() >= y ) // 子弹需要下移
        deltax = 0.0000001;
    else                    // 子弹需要上移
        deltax = -0.0000001;
}
if( deltay == 0 )
{
    if( (double)landforce.getX() >= x )// 子弹需要右移
        deltay = 0.0000001;
    else                     // 子弹需要左移
        deltay = -0.0000001;
}

//判断敌人所处象限
if( deltax>0 && deltay>0 )
    angle = Math.atan(Math.abs(deltay/deltax));           // 第一项限

else if( deltax<0 && deltay>0 )
    angle = Math.PI - Math.atan(Math.abs(deltay/deltax));       // 第二项限

else if( deltax<0 && deltay<0 )                     
    angle = Math.PI + Math.atan(Math.abs(deltay/deltax));         // 第三项限

else 
    angle = 2*Math.PI - Math.atan(Math.abs(deltay/deltax));         // 第四项限

if(isLive) {//更新子弹坐标
    x += speed*Math.cos(angle);
    y += speed*Math.sin(angle);
}
# 炮塔转向

​ 利用同样的算法,也可以实现炮塔的转向功能,以下为代码实现:

subx = landforce.getX() - this.getX();
suby = landforce.getY() - this.getY();
System.out.println(subx+"---"+suby);
//判断敌人所处象限
if( subx>0 && suby>0 && Math.abs(subx)<Math.abs(suby) )
    this.setDirect(DOWN);// 第一项限
else if( subx>0 && suby>0 && Math.abs(subx)>Math.abs(suby) )
    this.setDirect(RIGHT);

else if( subx<0 && suby>0 && Math.abs(subx)<Math.abs(suby) )
    this.setDirect(DOWN);// 第二项限
else if( subx<0 && suby>0 && Math.abs(subx)>Math.abs(suby) )
    this.setDirect(LEFT);

else if( subx<0 && suby<0 && Math.abs(subx)<Math.abs(suby) )                     
    this.setDirect(UP);  // 第三项限
else if( subx<0 && suby<0 && Math.abs(subx)>Math.abs(suby) )                     
    this.setDirect(LEFT); 

else if(subx>0 && suby<0 && Math.abs(subx)<Math.abs(suby))
    this.setDirect(UP); 
else 
    this.setDirect(RIGHT); // 第四项限

实现的效果:

img

2019-05-26

# 利用集合存储Enemy类,绘制血条

# 单一线程原则

​ 每个敌人类都是一个线程,因此想要多个敌人类并发运行,就需要开启多个线程(由于Swing是非线程安全的,因此如果线程与Swing组件有接触的话必须满足单一线程原则,即使用专门的时间派发线程处理Swing组件事件。我在这里虽然没有利用线程对组件进行改动,但是如果利用组件开启一个非常耗时的事件的话,就会产生一些bug,比如导致与线程类相关的重绘方法在线程启动期间不执行),最佳解决方案时单开一个线程类,利用这个线程类来延时开启多个Enemy线程(即在线程中启动其他线程),再利用组件启动这个单独的线程类,就可以完美的达到预期的效果。

# 血条绘制

​ 利用Graphics类中的drawRect()fillRect()方法即可完成血条的绘制。

# 实现效果

img

2019-05-29


# 更改敌人寻路算法,利用集合存储防御塔和子弹类

​ 在Map类中设置几个关键点坐标(每个方向改变的点),并使用数组进行存储,然后再遍历这个坐标类,利用每个元素和敌人间的方向和距离来控制敌人移动,当敌人类和关键点接近重合时更新至下一个关键坐标,以此循环来达到敌人寻路的效果,并使用线程启动,再利用上面的线程类开线程的方法完成多个敌人同时的移动。寻路算法的源码如下:

public void move() {
		int dx = road_1[loc].x - x;
		int dy = road_1[loc].y - y;
		if (Math.abs(road_1[loc].x - x) < 3 & Math.abs(road_1[loc].y - y) < 3 && loc < road_1.length - 1) {
			loc++;
		}
		if (Math.abs(x - road_1[road_1.length - 1].x)<3 && Math.abs(y - road_1[road_1.length - 1].y)<3) {
			isLive = false;
		}

		if (dx > 0 && Math.abs(dy) >= 0 && Math.abs(dy) < 3) {
			direct = RIGHT;
			x += speed;
		}
		if (dx < 0 && Math.abs(dy) >= 0 && Math.abs(dy) < 3) {
			direct = LEFT;
			x -= speed;
		}
		if (Math.abs(dx) >= 0 && Math.abs(dx) < 3 && dy > 0) {
			direct = DOWN;
			y += speed;
		}
		if (Math.abs(dx) >= 0 && Math.abs(dx) < 3 && dy < 0) {
			direct = UP;
			y -= speed;
		}
	}

​ 优化子弹类,通过线程实现子弹追踪,并且通过集合进行存储。优化防御塔类线程,设计两种攻击模式,同时通过集合进行存储。在Map类中定义防御塔基座集合,在鼠标点击事件中进行判断,使用户只能在指定的坐标范围内创建防御塔,创建后移除该基座坐标。增加防御塔售卖功能,用户点击特殊区域中断防御塔线程,使防御塔isLive状态为false,并且将该坐标重新添加到基座集合中。防御塔线程如下:

public void run() {
		int delay = 10;
		while(true) {
			for(int i = 0; i < list_landforce.size(); i++) {
				Enemy_Landforce landforce = list_landforce.get(i);
				if(landforce.isLive) {
					subx = landforce.getX() - this.getX();
					suby = landforce.getY() - this.getY();
					
					if(Math.abs(subx)<128&&Math.abs(suby)<128) {
						//判断敌人所处象限
						if( subx>0 && suby>0 && Math.abs(subx)<Math.abs(suby) ) {
							this.setDirect(DOWN);// 第一项限
							try {
								Thread.sleep(delay);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
						else if( subx>0 && suby>0 && Math.abs(subx)>Math.abs(suby) ) {
							this.setDirect(RIGHT);
							try {
								Thread.sleep(delay);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
						else if( subx<0 && suby>0 && Math.abs(subx)<Math.abs(suby) ) {
							this.setDirect(DOWN);// 第二项限
							try {
								Thread.sleep(delay);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
						else if( subx<0 && suby>0 && Math.abs(subx)>Math.abs(suby) ) {
							this.setDirect(LEFT);
							try {
								Thread.sleep(delay);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
						else if( subx<0 && suby<0 && Math.abs(subx)<Math.abs(suby) ) {                  
							this.setDirect(UP);  // 第三项限
							try {
								Thread.sleep(delay);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
						else if( subx<0 && suby<0 && Math.abs(subx)>Math.abs(suby) ) {              
							this.setDirect(LEFT); 
							try {
								Thread.sleep(10);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
						else if(subx>0 && suby<0 && Math.abs(subx)<Math.abs(suby)) {
							this.setDirect(UP); 
							try {
								Thread.sleep(10);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
						else if(subx>0 && suby<0 && Math.abs(subx)>Math.abs(suby)){
							this.setDirect(RIGHT); // 第四项限
							try {
								Thread.sleep(10);
							} catch (InterruptedException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
					}
						
//						if(subx>-90&&subx<90&&suby>-90&&suby<90) {
//							//第二种防御塔,随机攻击在一定范围内的敌人,不会一直追着一个敌人打
//							fire(landforce);
//							for(int j = 0; j < list_Bullet.size(); j++) {
//								Bullet_F1 bullet1 = list_Bullet.get(j); 
//								if(!bullet1.isLive) {
//									list_Bullet.remove(bullet1);
//								}
//							}
//							try {
//								Thread.sleep(1000);
//							} catch (InterruptedException e) {
//								e.printStackTrace();
//							}
//						}
						
						//只要敌人活着并且没有走出防御塔范围,就一直攻击
						while(subx>-90&&subx<90&&suby>-90&&suby<90&&landforce.isLive) {
						//第一种防御塔,跟踪塔
                            //在fire方法中创建子弹类同时开启子弹线程
							fire(landforce);
							//进行子弹回填
							for(int j = 0; j < list_Bullet.size(); j++) {
								Bullet_F1 bullet1 = list_Bullet.get(j); 
								if(!bullet1.isLive) {
									list_Bullet.remove(bullet1);
								}
							}
							try {
								Thread.sleep(1000);
							} catch (InterruptedException e) {
								e.printStackTrace();
							}
							if(!isLive) {//进行三次判断,如果防御塔isLive状态为false,从内部开始中断线程
//								System.out.println("停止开火,跳出while循环");
								break;
							}
						}
						
						try {
							Thread.sleep(10);
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
				}
				if(!isLive) {
//					System.out.println("停止开火,停止检测敌人");
					break;
				}
			}
			
			if(!isLive) {
//				System.out.println("线程中断了");
				break;
			}
		}
	}

​ 实现的效果如下:

img

2019-06-01


# 创建Player类,根据Hp、money变量在屏幕绘制玩家血条,金币数量

​ 稍微麻烦一点的是根据Player类中的money变量在屏幕上绘制金币数量并且实时更新,可以利用对10相除取余的操作分别求出money变量的个十百千位,再根据这些位数上的int值在屏幕上特定的区域绘制出相应的数字。然后利用线程更新即可。

​ 另外,分别在创建防御塔时和售卖防御塔时加入对玩家金币数变量money判断的操作,当money值>=100时,可以创建一座基础防御塔,卖出防御塔时玩家金币+50,游戏开始时玩家有100金币可以用于创建一座防御塔,玩家每消灭一个敌人金币数+50,每当有一个敌人到达右端终点玩家Hp-20,当Hp为0时Player线程结束。实现效果如下:

img

2019-06-02

# 最终效果

​ 上面的步骤完成了整个设计90%左右的工作,剩下的就是资源整合,将所有的模块数据进行匹配优化了,以及设计一些附加的功能,最终完成的效果如图:

# 游戏菜单

img

# 帮助界面

img

# 选关界面

img

# 第一关

img

# 第二关

img

# 第三关

img

以上

参考资料:

java图形化Swing教程(一) (opens new window)

一些游戏素材的网站推荐 (opens new window)

子弹跟踪算法 (opens new window)

网络游戏-弹道子弹追击目标 (opens new window)

eclipse支持sun.包的配备 (opens new window)

Java Swing播放声音 (opens new window)

多线程卡顿解决方案 (opens new window)

基于JAVA实现的塔防游戏 (opens new window)

java笔记--使用事件分配线程更新Swing控件 (opens new window)