因为课程需要,我们要设计一款多线程的程序。老师推荐说可以写一个坦克大战,正好我一直有着写一个游戏的想法,就借这个机会写一个坦克大战的小游戏出来。
项目源码:https://github.com/SANEBEN/tankBattle
开发环境:
集成开发环境:idea
jdk版本:jdk1.8.0_241
图形界面使用Javafx实现
最开始在确定图形界面设计语言的时候考虑过C#,但是一直觉得visual studio开发不如idea舒服,后来思来想去还是决定用Java实现图形界面。在网络上搜了一下Java图形界面的相关博文,发现大部分都在介绍swing,但是我又觉得swing开发太麻烦不够直观,就用了快凉了的javafx,虽然网上关于javafx的开发资料少得可怜,但是使用的体验还是不错的。下面就具体介绍一下我开发的过程。因为在最开始开发的时候走了不少的弯路,所以开始记录的时候已经有了一个较完整的版本了。
一、项目结构
二、实际效果
目前实现的功能就是地图的绘制,子弹发射,简单的碰撞检测等一些基础功能,写到这里多线程已经在项目中有很多的使用 了,暂时不会再往下写了。
三、实现思路
首先是贴图资源的加载。素材可以在这个网站下载(http://www.aigei.com/s?q=%E5%9D%A6%E5%85%8B%E5%A4%A7%E6%88%98&type=2d)
图片资源可以使用一个类静态加载,之后如果遇到需要重绘的直接使用就行了
这是一些代码片段
1 public static final Image TANK_ENEMY_YELLOW_BOTTON = new Image("/img/tank/enemy3D.gif"); 2 public static final Image TANK_ENEMY_YELLOW_LEFT = new Image("/img/tank/enemy3L.gif"); 3 public static final Image TANK_ENEMY_YELLOW_RIGHT = new Image("/img/tank/enemy3R.gif"); 4 public static final Image TANK_ENEMY_YELLOW_TOP = new Image("/img/tank/enemy3U.gif");
在贴图加载之后就是地图的设置了,我将游戏中一些重要的信息都放到了一个类进行管理,地图在代码中使用二维数组进行存储
感受一下:

public static int[][] map = { {2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 1, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 1, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 1, 1, 1, 3, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}, {2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 1, 4, 1, 3, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2}, {2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2} };
下面就到了地图绘制的环节了,在这个地方我还踩了一个坑,因为涉及到贴图,所以我第一时间想到的是使用imageView组件,在只贴地图素材时确实没啥问题,问题出在后面绘制坦克和子弹上面,具体的之后再说了。反正最后我实现使用的canvas,搞笑的是我搜索canvas相关的api出来的都是html5的信息,不过好在接口的名称和参数都是一样的,磕磕绊绊也把功能实现了。在绘图的时候我使用了三层的canvas,具体原因也和之前提到的bug有关系,这个后面一起说。反正地图单独占用了一个canvas。具体的代码很简单。
javafx的布局使用了xml文件进行配置,不过在这个程序的实现里面不需要过多的关注,将canvas放置到布局中之后除了按键的监听所有的操作都在canvas上完成。这里代码就不全放了。
1 public class WallCanvas extends Canvas { 2 3 public WallCanvas(double width, double height) { 4 super(width, height); 5 Game.wall_canvas_gc = getGraphicsContext2D(); 6 7 Game.InitMap(); 8 } 9 }

public static void InitMap() { int height = map.length; int width = map[0].length; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { /* 参数解释:sx:源矩形的x坐标位置,sy:源矩形的y坐标位置,sw:源矩形的宽度,sh:源矩形的高度 dx:目标矩形的x坐标位置,dx:目标矩形的y坐标位置,dw:目标矩形的宽度,dh:目标矩形的高度 */ int mark = map[y][x]; if (mark == 2) { Wall wall = new Wall(x * UNIT_LENGTH, y * UNIT_LENGTH, Img.STEEL, false); wall.draw(); walls.add(wall); } else if (mark == 1) { Wall wall = new Wall(x * UNIT_LENGTH, y * UNIT_LENGTH, Img.WALL, true); wall.draw(); walls.add(wall); } else if (mark == 3) { //要实现草丛的效果就不把草的绘图加到障碍物的列表中了,这样就不会在碰撞检测里面被检测 Wall wall = new Wall(x * UNIT_LENGTH, y * UNIT_LENGTH, Img.GRASS, true); wall.draw(); } else if (mark == 4) { //玩家基地作为 Base base = new Base(x * UNIT_LENGTH, y * UNIT_LENGTH, Img.BASE, true); base.draw(); Game.base = base; } } } }
可以注意到我将所有绘制了的障碍物都通过链表存储了下来,应该说我将所有在地图上绘制的物体都存了下来,这个涉及到后面的碰撞检测部分。
这里看到的封装类设计就不详细介绍了,大概的就是将一些公有的属性和方法放到抽象类中,然后将一些方法的实现延迟到具体的子类中。
下面就到了坦克的绘制以及移动。
贴图部分和地图的绘制类似,但是为了实现移动的效果,我们需要不断的将原有的贴图擦除然后绘制新的坦克,所以坦克也单独占用了一个图层。前面说到的使用三个图层的其中一个原因就是,在重绘的时候为了节省资源我们会将整个canvas上的贴图全部进行擦除然后再进行下一次的绘制,但是障碍物不需要移动也就不需要重绘,这就消耗了额外的性能。
玩家坦克的移动通过监听键盘上wasd键来控制。

class KeyPressHandler implements EventHandler<KeyEvent> { @Override public void handle(KeyEvent event) { switch (event.getCode()) { case W: playerTank.setDirection(Direction.UP); BasicMap.isMove = true; playerTank.move(); break; case S: playerTank.setDirection(Direction.DOWN); BasicMap.isMove = true; playerTank.move(); break; case A: playerTank.setDirection(Direction.LEFT); BasicMap.isMove = true; playerTank.move(); break; case D: playerTank.setDirection(Direction.RIGHT); BasicMap.isMove = true; playerTank.move(); break; case SPACE: Game.addBullet(playerTank.fire()); } } }
可以看到在监听时间中我只是修改了坦克当前的位置以及更换于方向相符的贴图,并没有直接重绘。在我最开始的设计中为了方便我就直接针对玩家所在的区域而不是整张地图进行重绘,这样实现起来很直观易于理解。但是当我尝试在线程中绘制敌方坦克的时候就出现了问题,由于线程冲突,程序时常会崩掉。在排查了一遍之后我发现问题出现在canvas用于重绘的对象上面,当线程执行的频率过高的时候,这个对象就会报空指针的问题,当降低线程的执行频率后,bug出现的次数变少了,而如果我不去触发玩家坦克的重绘则基本不会出bug了。所以我就将坦克的重绘进行了重新的设置,给了它一个单独的图层,并集中进行绘制。
这里实现集中重绘就得提到javafx中的ScheduledService类,具体的介绍参考(https://www.jianshu.com/p/97e02eccc87c)
可以看到ScheduledService可以设置每次的执行时间以及两次之间的间隔时间,还可以暂停重启等。同时它还是javafx提供的官方线程实现类,不会出现线程冲突的问题。
public class TankScheduleService extends ScheduledService<String> { @Override protected Task<String> createTask() { return new Task<String>() { @Override protected String call() { Game.tankCanvas.cleanCanvas(); Game.tankCanvas.draw(); return null; } }; } }
这就是实现了一个画布清除和重绘的功能。
同理的,我还实现了 另外两个类似的线程来分别实现地方坦克下一个位置的计算、子弹飞行轨迹的计算(ps:其实就是单纯的往前飞);
最后比较重要的就是碰撞检测了
//物体碰撞检测的方法 public BaseObject collisionDetection() { double current_center_x = x + side_length / 2; double current_center_y = y + side_length / 2; for (BaseObject baseObject : baseObjectList) { double center_x = baseObject.x + Game.UNIT_LENGTH / 2; double center_y = baseObject.y + Game.UNIT_LENGTH / 2; if (Math.abs(current_center_x - center_x) < side_length / 2 + baseObject.side_length / 2 &&//横向判断 Math.abs(current_center_y - center_y) < side_length / 2 + baseObject.side_length / 2) { return baseObject; } } return null; }
这个方法我放到了所有物体的基类中实现,但是其中baseObjectList由各个子类中设置。这个判断其实就是在判断两个矩形是否有重叠,所以看起来还是有些奇怪,会出现子弹还没碰到问题就触发被击中事件。
被击中时间延迟到子类中实现。
//物体被击中的抽象方法,由子类实现。 public abstract void beHit();
感兴趣的可以加Q讨论:473811301