java 编写游戏联网_手把手教你用Java实现一个简易联网坦克对战小游戏

作者:炭烧生蚝

cnblogs.com/tanshaoshenghao/p/10708586.html

介绍

通过本项目能够更直观地理解应用层和运输层网络协议, 以及继承封装多态的运用. 网络部分是本文叙述的重点, 你将看到如何使用Java建立TCP和UDP连接并交换报文, 你还将看到如何自己定义一个简单的应用层协议来让自己应用进行网络通信.

本项目的Github地址

https://github.com/liuyj24/TankOnline

72ffe4a169b65100c52f9ef630c5f772.gif

基础版本

游戏的原理, 图形界面(非重点)

多张图片快速连续地播放, 图片中的东西就能动起来形成视频, 对视频中动起来的东西进行操作就变成游戏了. 在一个坦克对战游戏中, 改变一辆坦克每一帧的位置, 当多帧连续播放的时候, 视觉上就有了控制坦克的感觉. 同理, 改变子弹每一帧的位置, 看起来就像是发射了一发炮弹. 当子弹和坦克的位置重合, 也就是两个图形的边界相碰时, 在碰撞的位置放上一个爆炸的图片, 就完成了子弹击中坦克发生爆炸的效果.

在本项目借助坦克游戏认识网络知识和面向对象思想, 游戏的显示与交互使用到了Java中的图形组件, 如今Java已较少用于图形交互程序开发, 本项目也只是使用了一些简单的图形组件.

在本项目中, 游戏的客户端由TankClient类控制, 游戏的运行和所有的图形操作都包含在这个类中, 下面会介绍一些主要的方法.

//类TankClient, 继承自Frame类//继承Frame类后所重写的两个方法paint()和update()//在paint()方法中设置在一张图片中需要画出什么东西. @Overridepublic void paint(Graphics g) {    //下面三行画出游戏窗口左上角的游戏参数    g.drawString("missiles count:" + missiles.size(), 10, 50);    g.drawString("explodes count:" + explodes.size(), 10, 70);    g.drawString("tanks    count:" + tanks.size(), 10, 90);    //检测我的坦克是否被子弹打到, 并画出子弹    for(int i = 0; i < missiles.size(); i++) {        Missile m = missiles.get(i);        if(m.hitTank(myTank)){            TankDeadMsg msg = new TankDeadMsg(myTank.id);            nc.send(msg);            MissileDeadMsg mmsg = new MissileDeadMsg(m.getTankId(), m.getId());            nc.send(mmsg);        }        m.draw(g);    }    //画出爆炸    for(int i = 0; i < explodes.size(); i++) {        Explode e = explodes.get(i);        e.draw(g);    }    //画出其他坦克    for(int i = 0; i < tanks.size(); i++) {        Tank t = tanks.get(i);        t.draw(g);    }    //画出我的坦克    myTank.draw(g);}/* * update()方法用于写每帧更新时的逻辑.  * 每一帧更新的时候, 我们会把该帧的图片画到屏幕中. * 但是这样做是有缺陷的, 因为把一副图片画到屏幕上会有延时, 游戏显示不够流畅 * 所以这里用到了一种缓冲技术. * 先把图像画到一块幕布上, 每帧更新的时候直接把画布推到窗口中显示 */@Overridepublic void update(Graphics g) {    if(offScreenImage == null) {        offScreenImage = this.createImage(800, 600);//创建一张画布    }    Graphics gOffScreen = offScreenImage.getGraphics();    Color c = gOffScreen.getColor();    gOffScreen.setColor(Color.GREEN);    gOffScreen.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);    gOffScreen.setColor(c);    paint(gOffScreen);//先在画布上画好    g.drawImage(offScreenImage, 0, 0, null);//直接把画布推到窗口}//这是加载游戏窗口的方法public void launchFrame() {    this.setLocation(400, 300);//设置游戏窗口相对于屏幕的位置    this.setSize(GAME_WIDTH, GAME_HEIGHT);//设置游戏窗口的大小    this.setTitle("TankWar");//设置标题    this.addWindowListener(new WindowAdapter() {//为窗口的关闭按钮添加监听        @Override        public void windowClosing(WindowEvent e) {            System.exit(0);        }    });    this.setResizable(false);//设置游戏窗口的大小不可改变    this.setBackground(Color.GREEN);//设置背景颜色    this.addKeyListener(new KeyMonitor());//添加键盘监听,     this.setVisible(true);//设置窗口可视化, 也就是显示出来    new Thread(new PaintThread()).start();//开启线程, 把图片画出到窗口中    dialog.setVisible(true);//显示设置服务器IP, 端口号, 自己UDP端口号的对话窗口}//在窗口中画出图像的线程, 定义为每50毫秒画一次. class PaintThread implements Runnable {    public void run() {        while(true) {            repaint();            try {                Thread.sleep(50);            } catch (InterruptedException e) {                e.printStackTrace();            }        }    }}

以上就是整个游戏图形交互的主要部分, 保证了游戏能正常显示后, 下面我们将关注于游戏的逻辑部分.

游戏逻辑

在游戏的逻辑中有两个重点, 一个是坦克, 另一个是子弹. 根据面向对象的思想, 分别把这两者封装成两个类, 它们所具有的行为都在类对应有相应的方法.

坦克的字段

22d83af79c1b98d875bae29896fc0b45.png

由于在TankClient类中的paint方法中需要画出图形, 根据面向对象的思想, 要画出一辆坦克, 应该由坦克调用自己的方法画出自己.

55eb7c8a80a7bdd68487f8f9438a463b.png

上面提到了改变坦克坐标的move()方法, 具体代码如下:

a9f3f31ff2397e08c7071d2273d050d6.png

上面提到了根据坦克的方向改变坦克的左边, 而坦克的方向通过键盘改变. 代码如下:

31cb14d02089c0a892d69b36b3a2b5a9.png

上面提到了坦克开火的方法, 这也是坦克最后一个重要的方法了, 代码如下, 后面将根据这个方法引出子弹类.

dd4a2c59efc7b5f185b0f96cb4e05e41.png

子弹类, 首先是子弹的字段

b37a1f6de0b6719488a782e02eb0e1d4.png

子弹类中同样有draw(), move()等方法, 在此不重复叙述了, 重点关注子弹打中坦克的方法. 子弹是否打中坦克, 是调用子弹自身的判断方法判断的.

13750d7aa904e4057c4eeb3f0162a7e1.png

补充, 坦克和子弹都以图形的方式显示, 在本游戏中通过Java的原生api获得图形的矩形框并判断是否重合(碰撞)

public Rectangle getRect() {    return new Rectangle(x, y, WIDTH, HEIGHT);}

在了解游戏中两个主要对象后, 下面介绍整个游戏的逻辑.

  • 加载游戏窗口后, 客户端会创建一个我的坦克对象, 初始化三个容器, 它们分别用于存放其他坦克, 子弹和爆炸.
  • 当按下开火键后, 会创建一个子弹对象, 并加入到子弹容器中(主战坦克发出一棵炮弹), 如果子弹没有击中坦克, 穿出游戏窗口边界后判定子弹死亡, 从容器中移除; 如果子弹击中了敌方坦克, 敌方坦克死亡从容器移出, 子弹也死亡从容器移出, 同时会创建一个爆炸对象放到容器中, 等爆炸的图片轮播完, 爆炸移出容器.

以上就是整个坦克游戏的逻辑. 下面将介绍重头戏, 网络联机.

网络联机

客户端连接上服务器

首先客户端通过TCP连接上服务器, 并把自己的UDP端口号发送给服务器, 这里省略描述TCP连接机制, 但是明白了连接机制后对为什么需要填写服务器端口号和IP会有更深的理解, 它们均为TCP报文段中必填的字段.

服务器通过TCP和客户端连上后收到客户端的UDP端口号信息, 并将客户端的IP地址和UDP端口号封装成一个Client对象, 保存在容器中.

这里补充一点, 为什么能获取客户端的IP地址? 因为服务器收到链路层帧后会提取出网络层数据报, 源地址的IP地址在IP数据报的首部字段中, Java对这一提取过程进行了封装, 所以我们能够直接在Java的api中获取源地址的IP.

服务器封装完Client对象后, 为客户端的主机坦克分配一个id号, 这个id号将用于往后游戏的网络传输中标识这台坦克.

同时服务器也会把自己的UDP端口号发送客户端, 因为服务器自身会开启一条UDP线程, 用于接收转发UDP包. 具体作用在后面会讲到.

客户端收到坦克id后设置到自己的主战坦克的id字段中. 并保存服务器的UDP端口号.

这里你可能会对UDP端口号产生疑问, 别急, 后面一小节将描述它的作用.

0d2e47c05679d83315ec4347746531de.png

附上这部分的代码片段:

1a8862c361a05b24f55eb1061f410a32.png

定义应用层协议

077e09f207edf7f187f2005f139973de.png

客户机连上服务器后, 两边分别获取了初始信息, 且客户端和服务器均开启了UDP线程. 客户端通过保存的服务器UDP端口号可以向服务器的UDP套接字发送UDP包, 服务器保存了所有连上它的Client客户端信息, 它可以向所有客户端的UDP端口发送UDP包.

此后, 整个坦克游戏的网络模型已经构建完毕, 游戏中的网络传输道路已经铺设好, 但想要在游戏中进行网络传输还差一样东西, 它就是这个网络游戏的应用层通信协议.

在本项目中, 应用层协议很简单, 只有两个字段, 一个是消息类型, 一个是消息数据(有效载荷).

这里先列出所有的具体协议, 后面将进行逐一讲解.

09c531301547d36396c261c63db315b4.png

在描述整个应用层协议体系及具体应用前需要补充一下, 文章前面提到TankClient类用于控制整个游戏客户端, 但为了解耦, 客户端将需要进行的网络操作使用另外一个NetClient类进行封装.

回到正题, 我们把应用层协议定义为一个接口, 具体到每个消息协议有具体的实现类, 这里我们将用到多态.

8861aacc3d8ccbc8d421be295158cbbe.png

下面将描述多态的实现给本程序带来的好处.

在NetClient这个网络接口类中, 需要定义发送消息和接收消息的方法. 想一下, 如果我们为每个类型的消息编写发送和解析的方法, 那么程序将变得复杂冗长. 使用多态后, 每个消息实现类自己拥有发送和解析的方法, 要调用NetClient中的发送接口发送某个消息就方便多了. 下面代码可能解释的更清楚.

3671da5bb8c3f0fdcd6dff3772939d09.png

接下来介绍每个具体的协议.

TankNewMsg

首先介绍的是TankNewMsg坦克出生协议, 消息类型为1. 它包含的字段有坦克id, 坦克坐标, 坦克方向, 坦克好坏.

当我们的客户端和服务器完成TCP连接后, 客户端的UDP会向服务器的UDP发送一个TankNewMsg消息, 告诉服务器自己加入到了游戏中, 服务器会将这个消息转发到所有在服务器中注册过的客户端. 这样每个客户端都知道了有一个新的坦克加入, 它们会根据TankNewMsg中新坦克的信息创建出一个新的坦克对象, 并加入到自己的坦克容器中.

但是这里涉及到一个问题: 已经连上服务器的客户端会收到新坦克的信息并把新坦克加入到自己的游戏中, 但是新坦克的游戏中并没有其他已经存在的坦克信息.

一个较为简单的方法是旧坦克在接收到新坦克的信息后也发送一条TankNewMsg信息, 这样新坦克就能把旧坦克加入到游戏中. 下面是具体的代码. (显然这个方法不太好, 每个协议应该精细地一种操作, 留到以后进行改进)

2190f2aa8215328b903d6c87f8da495d.png

TankMoveMsg

下面将介绍TankMoveMsg协议, 消息类型为2, 需要的数据有坦克id, 坦克坐标, 坦克方向, 炮筒方向. 每当自己坦克的方向发生改变时, 向服务器发送一个TankMoveMsg消息, 经服务器转发后, 其他客户端也能收该坦克的方向变化, 然后根据数据找到该坦克并设置方向等参数. 这样才能相互看到各自的坦克在移动.

下面是发送TankMoveMsg的地方, 也就是改变坦克方向的时候.

2db74ad443ac20c09eac7681ee91ac25.png

MissileNewMsg

下面将介绍MissileNewMsg协议, 消息类型为3, 需要的数据有发出子弹的坦克id, 子弹id, 子弹坐标, 子弹方向. 当坦克发出一发炮弹后, 需要将炮弹的信息告诉其他客户端, 其他客户端根据子弹的信息在游戏中创建子弹对象并加入到容器中, 这样才能看见相互发出的子弹.

MissileNewMsg在坦克发出一颗炮弹后生成.

bd752ceaaca4ae9237339bed6017723b.png

TankDeadMsg和MissileDeadMsg

下面介绍TankDeadMsg和MissileDeadMsg, 它们是一个组合, 当一台坦克被击中后, 发出TankDeadMsg信息, 同时子弹也死亡, 发出MissileDeadMsg信息. MissileDeadMsg需要数据发出子弹的坦克id, 子弹id, 而TankDeadMsg只需要坦克id一个数据.

08d71b373d1845fb51ebf767d9b84dea.png

到此为止, 基础版本就结束了, 基础版本已经是一个能正常游戏的版本了.

改进版本.

定义更精细的协议

当前如果有一辆坦克加入服务器后, 会向其他已存在的坦克发送TankNewMsg, 其他坦克接收到TankNewMsg会往自己的坦克容器中添加这辆新的坦克.

之前描述过存在的问题: 旧坦克能把新坦克加入到游戏中, 但是新坦克不能把旧坦克加入到游戏中, 当时使用的临时解决方案是: 旧坦克接收到TankNewMsg后判断该坦克是否已经存在自己的容器中, 如果不存在则添加进容器, 并且自己发送一个TankNewMsg, 这样新的坦克接收到旧坦克的TankNewMsg, 就能把旧坦克加入到游戏里.

但是, 我们定义的TankNewMsg是发出一个坦克出生的信息, 如果把TankNewMsg同时用于引入旧坦克, 如果以后要修改TankNewMsg就会牵涉到其他的代码, 我们应该用一个新的消息来让新坦克把旧坦克加入到游戏中.

当旧坦克接收TankNewMsg后证明有新坦克加入, 它先把新坦克加入到容器中, 再向服务器发送一个TankAlreadyExistMsg, 其他坦克检查自己的容器中是否有已经准备的坦克的信息, 如果有了就不添加, 没有则把它添加到容器中.

不得不说, 使用多态后扩展协议就变得很方便了.

f7ce5ba995e9be01d27e2b40e2764f3b.png

坦克战亡后服务器端的处理

当一辆坦克死后, 服务器应该从Client集合中删除掉该客户端的信息, 从而不用向该客户端发送信息, 减轻负载.而且服务器应该开启一个新的UDP端口号用于接收坦克死亡的消息, 不然这个死亡的消息会转发给其他客户端.

所以在客户端进行TCP连接的时候要把这个就收坦克死亡信息的UDP端口号也发送给客户端.

被击败后, 弹框通知游戏结束.

3c0b36dafbcdea0ba4a1741887bf0cec.png

完成这个版本后, 多人游戏时游戏性更强了, 当一个玩家死后他可以重新开启游戏再次加入战场. 但是有个小问题, 他可能会加入到击败他的坦克的阵营, 因为服务器为坦克分配的id好是递增的, 而判定坦克的阵营仅通过id的奇偶判断. 但就这个版本来说服务器端处理死亡坦克的任务算是完成了.

客户端线程同步

在完成基础版本后考虑过这个问题, 因为在游戏中, 由于延时的原因, 可能会造成各个客户端线程不同步. 处理手段可以是每隔一定时间, 各个客户端向服务器发送自己坦克的位置消息, 服务器再将该位置消息通知到其他客户端, 进行同步.

但是在本游戏中, 只要坦克的方向一发生移动就会发送一个TankMoveMsg包, TankMoveMsg消息中除了包含坦克的方向, 也包含坦克的坐标, 相当于做了客户端线程同步. 所以考虑暂时不需要再额外进行客户端同步了.

添加图片

在基础版本中, 坦克和子弹都是通过画一个圆表示, 现在添加坦克和子弹的图片为游戏注入灵魂.

总结与致谢

最后回顾整个项目, 整个项目并没有用到什么高新技术, 相反这是一个十多年前用纯Java实现的教学项目. 我觉得项目中的网络部分对我的帮助非常大. 我最近看完了《计算机网络:自顶向下方法》, 甚至把里面的课后复习题都做了一遍, 要我详细描述TCP三次握手, 如何通过DHCP协议获取IP地址, DNS的解析过程都不是问题, 但是总感觉理论与实践之间差了点东西.

现在我重新考虑协议这个名词, 在网络中, 每一种协议定义了一种端到端的数据传输规则, 从应用层到网络层, 只要有数据传输的地方就需要协议. 人类的智慧在协议中充分体现, 比如提供可靠数据传输和拥塞控制的TCP协议和轻便的UDP协议, 它们各有优点, 在各自的领域作出贡献.

但是协议最终是要执行的, 在本项目中运输层协议可以直接调用Java api实现, 但是应用层协议就要自己定义了. 尽管只是定义了几个超级简单的协议, 但是定义过的协议在发送端和接收端是如何处理的, 是落实到代码敲出来的.

当整个项目做完后, 再次考虑协议这个名词, 能看出它共通的地方, 如果让我设计一个通信协议, 我也不会因对设计协议完全没有概念而彷徨了, 当然设计得好不好就另说咯.

最后隆重致谢本项目的制作者马士兵老师, 除了简单的网络知识, 马老师在项目中不停强调程序设计的重要性, 这也是我今后要努力的方向.

对了,在这里说一下,我目前是在职Java开发,如果你现在正在学习Java,了解Java,渴望成为一名合格的Java开发工程师,在入门学习Java的过程当中缺乏基础入门的视频教程,可以关注并私信我:01。获取。我这里有最新的Java基础全套视频教程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值