Java实现简易联网坦克对战小游戏

2,942 阅读21分钟

介绍

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

获取源码

基础版本

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

  • 多张图片快速连续地播放, 图片中的东西就能动起来形成视频, 对视频中动起来的东西进行操作就变成游戏了. 在一个坦克对战游戏中, 改变一辆坦克每一帧的位置, 当多帧连续播放的时候, 视觉上就有了控制坦克的感觉. 同理, 改变子弹每一帧的位置, 看起来就像是发射了一发炮弹. 当子弹和坦克的位置重合, 也就是两个图形的边界相碰时, 在碰撞的位置放上一个爆炸的图片, 就完成了子弹击中坦克发生爆炸的效果.
  • 在本项目借助坦克游戏认识网络知识和面向对象思想, 游戏的显示与交互使用到了Java中的图形组件, 如今Java已较少用于图形交互程序开发, 本项目也只是使用了一些简单的图形组件.
  • 在本项目中, 游戏的客户端由TankClient类控制, 游戏的运行和所有的图形操作都包含在这个类中, 下面会介绍一些主要的方法.
//类TankClient, 继承自Frame类

//继承Frame类后所重写的两个方法paint()和update()
//在paint()方法中设置在一张图片中需要画出什么东西. 
@Override
public 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()方法用于写每帧更新时的逻辑. 
 * 每一帧更新的时候, 我们会把该帧的图片画到屏幕中.
 * 但是这样做是有缺陷的, 因为把一副图片画到屏幕上会有延时, 游戏显示不够流畅
 * 所以这里用到了一种缓冲技术.
 * 先把图像画到一块幕布上, 每帧更新的时候直接把画布推到窗口中显示
 */
@Override
public 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();
            }
        }
    }
}
  • 以上就是整个游戏图形交互的主要部分, 保证了游戏能正常显示后, 下面我们将关注于游戏的逻辑部分.

 

游戏逻辑

  • 在游戏的逻辑中有两个重点, 一个是坦克, 另一个是子弹. 根据面向对象的思想, 分别把这两者封装成两个类, 它们所具有的行为都在类对应有相应的方法.
  • 坦克的字段
public int id;//作为网络中的标识

public static final int XSPEED = 5;//左右方向上每帧移动的距离
public static final int YSPEED = 5;//上下方向每帧移动的距离
public static final int WIDTH = 30;//坦克图形的宽
public static final int HEIGHT = 30;//坦克图形的高

private boolean good;//根据true和false把坦克分成两类, 游戏中两派对战
private int x, y;//坦克的坐标
private boolean live = true;//坦克是否活着, 死了将不再画出
private TankClient tc;//客户端类的引用
private boolean bL, bU, bR, bD;//用于判断键盘按下的方向
private Dir dir = Dir.STOP;//坦克的方向
private Dir ptDir = Dir.D;//炮筒的方向

 

  • 由于在TankClient类中的paint方法中需要画出图形, 根据面向对象的思想, 要画出一辆坦克, 应该由坦克调用自己的方法画出自己.
    public void draw(Graphics g) {
        if(!live) {
            if(!good) {
                tc.getTanks().remove(this);//如果坦克死了就把它从容器中去除, 并直接结束
            }
            return;
        }
        //画出坦克
        Color c = g.getColor();
        if(good) g.setColor(Color.RED);
        else g.setColor(Color.BLUE);
        g.fillOval(x, y, WIDTH, HEIGHT);
        g.setColor(c);
        //画出炮筒
        switch(ptDir) {
            case L:
                g.drawLine(x + WIDTH/2, y + HEIGHT/2, x, y + HEIGHT/2);
                break;
            case LU:
                g.drawLine(x + WIDTH/2, y + HEIGHT/2, x, y);
                break;
            case U:
                g.drawLine(x + WIDTH/2, y + HEIGHT/2, x + WIDTH/2, y);
                break;
            //...省略部分方向
        }
        move();//每次画完改变坦克的坐标, 连续画的时候坦克就动起来了
    }

 

  • 上面提到了改变坦克坐标的move()方法, 具体代码如下:
private void move() {
    switch(dir) {//根据坦克的方向改变坐标
        case L://左
            x -= XSPEED;
            break;
        case LU://左上
            x -= XSPEED;
            y -= YSPEED;
            break;
        //...省略
    }

    if(dir != Dir.STOP) {
        ptDir = dir;
    }
    //防止坦克走出游戏窗口, 越界时要停住
    if(x < 0) x = 0;
    if(y < 30) y = 30;
    if(x + WIDTH > TankClient.GAME_WIDTH) x = TankClient.GAME_WIDTH - WIDTH;
    if(y + HEIGHT > TankClient.GAME_HEIGHT) y = TankClient.GAME_HEIGHT - HEIGHT;
}

 

  • 上面提到了根据坦克的方向改变坦克的左边, 而坦克的方向通过键盘改变. 代码如下:
    public void keyPressed(KeyEvent e) {//接收接盘事件
        int key = e.getKeyCode();
        //根据键盘按下的按键修改bL, bU, bR, bD四个布尔值, 回后会根据四个布尔值判断上, 左上, 左等八个方向
        switch (key) {
            case KeyEvent.VK_A://按下键盘A键, 意味着往左
                bL = true;
                break;
            case KeyEvent.VK_W://按下键盘W键, 意味着往上
                bU = true;
                break;
            case KeyEvent.VK_D:
                bR = true;
                break;
            case KeyEvent.VK_S:
                bD = true;
                break;
        }
        locateDirection();//根据四个布尔值判断八个方向的方法
    }

    private void locateDirection() {
        Dir oldDir = this.dir;//记录下原来的方法, 用于联网
        //根据四个方向的布尔值判断八个更细分的方向
        //比如左和下都是true, 证明玩家按的是左下, 方向就该为左下
        if(bL && !bU && !bR && !bD) dir = Dir.L;
        else if(bL && bU && !bR && !bD) dir = Dir.LU;
        else if(!bL && bU && !bR && !bD) dir = Dir.U;
        else if(!bL && bU && bR && !bD) dir = Dir.RU;
        else if(!bL && !bU && bR && !bD) dir = Dir.R;
        else if(!bL && !bU && bR && bD) dir = Dir.RD;
        else if(!bL && !bU && !bR && bD) dir = Dir.D;
        else if(bL && !bU && !bR && bD) dir = Dir.LD;
        else if(!bL && !bU && !bR && !bD) dir = Dir.STOP;
        //可以先跳过这段代码, 用于网络中其他客户端的坦克移动
        if(dir != oldDir){
            TankMoveMsg msg = new TankMoveMsg(id, x, y, dir, ptDir);
            tc.getNc().send(msg);
        }
    }
    //对键盘释放的监听
    public void keyReleased(KeyEvent e) {
        int key = e.getKeyCode();
        switch (key) {
            case KeyEvent.VK_J://设定J键开火, 当释放J键时发出一发子弹
                fire();
                break;
            case KeyEvent.VK_A:
                bL = false;
                break;
            case KeyEvent.VK_W:
                bU = false;
                break;
            case KeyEvent.VK_D:
                bR = false;
                break;
            case KeyEvent.VK_S:
                bD = false;
                break;
        }
        locateDirection();
    }

 

  • 上面提到了坦克开火的方法, 这也是坦克最后一个重要的方法了, 代码如下, 后面将根据这个方法引出子弹类.
private Missile fire() {
    if(!live) return null;//如果坦克死了就不能开火
    int x = this.x + WIDTH/2 - Missile.WIDTH/2;//设定子弹的x坐标
    int y = this.y + HEIGHT/2 - Missile.HEIGHT/2;//设定子弹的y坐标
    Missile m = new Missile(id, x, y, this.good, this.ptDir, this.tc);//创建一颗子弹
    tc.getMissiles().add(m);//把子弹添加到容器中. 
    //网络部分可暂时跳过, 发出一发子弹后要发送给服务器并转发给其他客户端.
    MissileNewMsg msg = new MissileNewMsg(m);
    tc.getNc().send(msg);
    return m;
}
  • 子弹类, 首先是子弹的字段
public static final int XSPEED = 10;//子弹每帧中坐标改变的大小, 比坦克大些, 子弹当然要飞快点嘛
public static final int YSPEED = 10;
public static final int WIDTH = 10;
public static final int HEIGHT = 10;
private static int ID = 10;

private int id;//用于在网络中标识的id
private TankClient tc;//客户端的引用
private int tankId;//表明是哪个坦克发出的
private int x, y;//子弹的坐标
private Dir dir = Dir.R;//子弹的方向
private boolean live = true;//子弹是否存活
private boolean good;//子弹所属阵营, 我方坦克自能被地方坦克击毙

 

  • 子弹类中同样有draw(), move()等方法, 在此不重复叙述了, 重点关注子弹打中坦克的方法. 子弹是否打中坦克, 是调用子弹自身的判断方法判断的.
public boolean hitTank(Tank t) {
    //如果子弹是活的, 被打中的坦克也是活的
    //子弹和坦克不属于同一方
    //子弹的图形碰撞到了坦克的图形
    //认为子弹打中了坦克
    if(this.live && t.isLive() && this.good != t.isGood() && this.getRect().intersects(t.getRect())) {
        this.live = false;//子弹生命设置为false
        t.setLive(false);//坦克生命设置为false
        tc.getExplodes().add(new Explode(x, y, tc));//产生一个爆炸, 坐标为子弹的坐标
        return true;
    }
    return false;
}

 

  • 补充, 坦克和子弹都以图形的方式显示, 在本游戏中通过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端口号产生疑问, 别急, 后面一小节将描述它的作用.

 

  • 附上这部分的代码片段:
//客户端
public void connect(String ip, int port){
    serverIP = ip;
    Socket s = null;
    try {
        ds = new DatagramSocket(UDP_PORT);//创建UDP套接字
        s = new Socket(ip, port);//创建TCP套接字
        DataOutputStream dos = new DataOutputStream(s.getOutputStream());
        dos.writeInt(UDP_PORT);//向服务器发送自己的UDP端口号
        DataInputStream dis = new DataInputStream(s.getInputStream());
        int id = dis.readInt();//获得服务器分配给自己坦克的id号
        this.serverUDPPort = dis.readInt();//获得服务器的UDP端口号
        tc.getMyTank().id = id;
        tc.getMyTank().setGood((id & 1) == 0 ? true : false);//根据坦克的id号的奇偶性设置坦克的阵营
    } catch (IOException e) {
        e.printStackTrace();
    }finally {
        try{
            if(s != null) s.close();//信息交换完毕后客户端的TCP套接字关闭
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    TankNewMsg msg = new TankNewMsg(tc.getMyTank());
    send(msg);//发送坦克出生的消息(后面介绍)

    new Thread(new UDPThread()).start();//开启UDP线程
}

//服务器
public void start(){
    new Thread(new UDPThread()).start();//开启UDP线程
    ServerSocket ss = null;
    try {
        ss = new ServerSocket(TCP_PORT);//创建TCP欢迎套接字
    } catch (IOException e) {
        e.printStackTrace();
    }

    while(true){//监听每个客户端的连接
        Socket s = null;
        try {
            s = ss.accept();//为客户端分配一个专属TCP套接字
            DataInputStream dis = new DataInputStream(s.getInputStream());
            int UDP_PORT = dis.readInt();//获得客户端的UDP端口号
            Client client = new Client(s.getInetAddress().getHostAddress(), UDP_PORT);//把客户端的IP地址和UDP端口号封装成Client对象, 以备后面使用
            clients.add(client);//装入容器中

            DataOutputStream dos = new DataOutputStream(s.getOutputStream());
            dos.writeInt(ID++);//给客户端的主战坦克分配一个id号
            dos.writeInt(UDP_PORT);
        }catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                if(s != null) s.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

 

定义应用层协议

  • 客户机连上服务器后, 两边分别获取了初始信息, 且客户端和服务器均开启了UDP线程. 客户端通过保存的服务器UDP端口号可以向服务器的UDP套接字发送UDP包, 服务器保存了所有连上它的Client客户端信息, 它可以向所有客户端的UDP端口发送UDP包.
  • 此后, 整个坦克游戏的网络模型已经构建完毕, 游戏中的网络传输道路已经铺设好, 但想要在游戏中进行网络传输还差一样东西, 它就是这个网络游戏的应用层通信协议.
  • 在本项目中, 应用层协议很简单, 只有两个字段, 一个是消息类型, 一个是消息数据(有效载荷).
  • 这里先列出所有的具体协议, 后面将进行逐一讲解.
消息类型消息数据
1.TANK_NEW_MSG(坦克出生信息)坦克id, 坦克坐标, 坦克方向, 坦克好坏
2.TANK_MOVE_MSG(坦克移动信息)坦克id, 坦克坐标, 坦克方向, 炮筒方向
3.MISSILE_NEW_MESSAGE(子弹产生信息)发出子弹的坦克id, 子弹id, 子弹坐标, 子弹方向
4.TANK_DEAD_MESSAGE(子弹死亡的信息)发出子弹的坦克id, 子弹id
5.MISSILE_DEAD_MESSAGE(坦克死亡的信息)坦克id
  • 在描述整个应用层协议体系及具体应用前需要补充一下, 文章前面提到TankClient类用于控制整个游戏客户端, 但为了解耦, 客户端将需要进行的网络操作使用另外一个NetClient类进行封装.
  • 回到正题, 我们把应用层协议定义为一个接口, 具体到每个消息协议有具体的实现类, 这里我们将用到多态.
public interface Msg {
    public static final int TANK_NEW_MSG = 1;
    public static final int TANK_MOVE_MSG= 2;
    public static final int MISSILE_NEW_MESSAGE = 3;
    public static final int TANK_DEAD_MESSAGE = 4;
    public static final int MISSILE_DEAD_MESSAGE = 5;

    //每个消息报文, 自己将拥有发送和解析的方法, 为多态的实现奠定基础. 
    public void send(DatagramSocket ds, String IP, int UDP_Port);
    public void parse(DataInputStream dis);
}
  • 下面将描述多态的实现给本程序带来的好处.
  • NetClient这个网络接口类中, 需要定义发送消息和接收消息的方法. 想一下, 如果我们为每个类型的消息编写发送和解析的方法, 那么程序将变得复杂冗长. 使用多态后, 每个消息实现类自己拥有发送和解析的方法, 要调用NetClient中的发送接口发送某个消息就方便多了. 下面代码可能解释的更清楚.
//如果没有多态的话, NetClient中将要定义每个消息的发送方法
public void sendTankNewMsg(TankNewMsg msg){
    //很长...
}
public void sendMissileNewMsg(MissileNewMsg msg){
    //很长...
}
//只要有新的消息类型, 后面就要接着定义...

//假如使用了多态, NetClient中只需要定义一个发送方法
public void send(Msg msg){
    msg.send(ds, serverIP, serverUDPPort);
}
//当我们要发送某个类型的消息时, 只需要
TankNewMsg msg = new TankNewMsg();
NetClient nc = new NetClient();//实践中不需要, 能拿到唯一的NetClient的引用
nc.send(msg)

//在NetClient类中, 解析的方法如下
private void parse(DatagramPacket dp) {
    ByteArrayInputStream bais = new ByteArrayInputStream(buf, 0, dp.getLength());
    DataInputStream dis = new DataInputStream(bais);
    int msgType = 0;
    try {
        msgType = dis.readInt();//先拿到消息的类型
    } catch (IOException e) {
        e.printStackTrace();
    }
    Msg msg = null;
    switch (msgType){//根据消息的类型, 调用具体消息的解析方法
        case Msg.TANK_NEW_MSG :
            msg = new TankNewMsg(tc);
            msg.parse(dis);
            break;
        case  Msg.TANK_MOVE_MSG :
            msg = new TankMoveMsg(tc);
            msg.parse(dis);
            break;
        case Msg.MISSILE_NEW_MESSAGE :
            msg = new MissileNewMsg(tc);
            msg.parse(dis);
            break;
        case Msg.TANK_DEAD_MESSAGE :
            msg = new TankDeadMsg(tc);
            msg.parse(dis);
            break;
        case Msg.MISSILE_DEAD_MESSAGE :
            msg = new MissileDeadMsg(tc);
            msg.parse(dis);
            break;
    }
}
  • 接下来介绍每个具体的协议.

 

TankNewMsg

  • 首先介绍的是TankNewMsg坦克出生协议, 消息类型为1. 它包含的字段有坦克id, 坦克坐标, 坦克方向, 坦克好坏.
  • 当我们的客户端和服务器完成TCP连接后, 客户端的UDP会向服务器的UDP发送一个TankNewMsg消息, 告诉服务器自己加入到了游戏中, 服务器会将这个消息转发到所有在服务器中注册过的客户端. 这样每个客户端都知道了有一个新的坦克加入, 它们会根据TankNewMsg中新坦克的信息创建出一个新的坦克对象, 并加入到自己的坦克容器中.
  • 但是这里涉及到一个问题: 已经连上服务器的客户端会收到新坦克的信息并把新坦克加入到自己的游戏中, 但是新坦克的游戏中并没有其他已经存在的坦克信息.
  • 一个较为简单的方法是旧坦克在接收到新坦克的信息后也发送一条TankNewMsg信息, 这样新坦克就能把旧坦克加入到游戏中. 下面是具体的代码. (显然这个方法不太好, 每个协议应该精细地一种操作, 留到以后进行改进)
//下面是TankNewMsg中解析本消息的方法
public void parse(DataInputStream dis){
    try{
        int id = dis.readInt();
        if(id == this.tc.getMyTank().id){
            return;
        }

        int x = dis.readInt();
        int y = dis.readInt();
        Dir dir = Dir.values()[dis.readInt()];
        boolean good = dis.readBoolean();

        //接收到别人的新信息, 判断别人的坦克是否已将加入到tanks集合中
        boolean exist = false;
        for (Tank t : tc.getTanks()){
            if(id == t.id){
                exist = true;
                break;
            }
        }
        if(!exist) {//当判断到接收的新坦克不存在已有集合才加入到集合.
            TankNewMsg msg = new TankNewMsg(tc);
            tc.getNc().send(msg);//加入一辆新坦克后要把自己的信息也发送出去.
            Tank t = new Tank(x, y, good, dir, tc);
            t.id = id;
            tc.getTanks().add(t);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

 

TankMoveMsg

  • 下面将介绍TankMoveMsg协议, 消息类型为2, 需要的数据有坦克id, 坦克坐标, 坦克方向, 炮筒方向. 每当自己坦克的方向发生改变时, 向服务器发送一个TankMoveMsg消息, 经服务器转发后, 其他客户端也能收该坦克的方向变化, 然后根据数据找到该坦克并设置方向等参数. 这样才能相互看到各自的坦克在移动.
  • 下面是发送TankMoveMsg的地方, 也就是改变坦克方向的时候.
private void locateDirection() {
    Dir oldDir = this.dir;//记录旧的方向
    if(bL && !bU && !bR && !bD) dir = Dir.L;
    else if(bL && bU && !bR && !bD) dir = Dir.LU;
    else if(!bL && bU && !bR && !bD) dir = Dir.U;
    else if(!bL && bU && bR && !bD) dir = Dir.RU;
    else if(!bL && !bU && bR && !bD) dir = Dir.R;
    else if(!bL && !bU && bR && bD) dir = Dir.RD;
    else if(!bL && !bU && !bR && bD) dir = Dir.D;
    else if(bL && !bU && !bR && bD) dir = Dir.LD;
    else if(!bL && !bU && !bR && !bD) dir = Dir.STOP;

    if(dir != oldDir){//如果改变后的方向不同于旧方向也就是说方向发生了改变
        TankMoveMsg msg = new TankMoveMsg(id, x, y, dir, ptDir);//创建TankMoveMsg消息
        tc.getNc().send(msg);//发送
    }
}

 

MissileNewMsg

  • 下面将介绍MissileNewMsg协议, 消息类型为3, 需要的数据有发出子弹的坦克id, 子弹id, 子弹坐标, 子弹方向. 当坦克发出一发炮弹后, 需要将炮弹的信息告诉其他客户端, 其他客户端根据子弹的信息在游戏中创建子弹对象并加入到容器中, 这样才能看见相互发出的子弹.
  • MissileNewMsg在坦克发出一颗炮弹后生成.
private Missile fire() {
    if(!live) return null;
    int x = this.x + WIDTH/2 - Missile.WIDTH/2;
    int y = this.y + HEIGHT/2 - Missile.HEIGHT/2;
    Missile m = new Missile(id, x, y, this.good, this.ptDir, this.tc);
    tc.getMissiles().add(m);

    MissileNewMsg msg = new MissileNewMsg(m);//生成MissileNewMsg
    tc.getNc().send(msg);//发送给其他客户端
    return m;
}

//MissileNewMsg的解析
public void parse(DataInputStream dis) {
    try{
        int tankId = dis.readInt();
        if(tankId == tc.getMyTank().id){//如果是自己发出的子弹就跳过(已经加入到容器了)
            return;
        }
        int id = dis.readInt();
        int x = dis.readInt();
        int y = dis.readInt();
        Dir dir = Dir.values()[dis.readInt()];
        boolean good = dis.readBoolean();
        //把收到的这颗子弹添加到子弹容器中
        Missile m = new Missile(tankId, x, y, good, dir, tc);
        m.setId(id);
        tc.getMissiles().add(m);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

 

TankDeadMsg和MissileDeadMsg

  • 下面介绍TankDeadMsg和MissileDeadMsg, 它们是一个组合, 当一台坦克被击中后, 发出TankDeadMsg信息, 同时子弹也死亡, 发出MissileDeadMsg信息. MissileDeadMsg需要数据发出子弹的坦克id, 子弹id, 而TankDeadMsg只需要坦克id一个数据.
//TankClient类, paint()中的代码片段, 遍历子弹容器中的每颗子弹看自己的坦克有没有被打中. 
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);
}

//MissileDeadMsg的解析
public void parse(DataInputStream dis) {
    try{
        int tankId = dis.readInt();
        int id = dis.readInt();
        //在容器找到对应的那颗子弹, 设置死亡不再画出, 并产生一个爆炸. 
        for(Missile m : tc.getMissiles()){
            if(tankId == tc.getMyTank().id && id == m.getId()){
                m.setLive(false);
                tc.getExplodes().add(new Explode(m.getX(), m.getY(), tc));
                break;
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

//TankDeadMsg的解析
public void parse(DataInputStream dis) {
    try{
        int tankId = dis.readInt();
        if(tankId == this.tc.getMyTank().id){//如果是自己坦克发出的死亡消息旧跳过
            return;
        }
        for(Tank t : tc.getTanks()){//否则遍历坦克容器, 把死去的坦克移出容器, 不再画出. 
            if(t.id == tankId){
                t.setLive(false);
                break;
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

 

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

 

改进版本.

定义更精细的协议

  • 当前如果有一辆坦克加入服务器后, 会向其他已存在的坦克发送TankNewMsg, 其他坦克接收到TankNewMsg会往自己的坦克容器中添加这辆新的坦克.
  • 之前描述过存在的问题: 旧坦克能把新坦克加入到游戏中, 但是新坦克不能把旧坦克加入到游戏中, 当时使用的临时解决方案是: 旧坦克接收到TankNewMsg后判断该坦克是否已经存在自己的容器中, 如果不存在则添加进容器, 并且自己发送一个TankNewMsg, 这样新的坦克接收到旧坦克的TankNewMsg, 就能把旧坦克加入到游戏里.
  • 但是, 我们定义的TankNewMsg是发出一个坦克出生的信息, 如果把TankNewMsg同时用于引入旧坦克, 如果以后要修改TankNewMsg就会牵涉到其他的代码, 我们应该用一个新的消息来让新坦克把旧坦克加入到游戏中.
  • 当旧坦克接收TankNewMsg后证明有新坦克加入, 它先把新坦克加入到容器中, 再向服务器发送一个TankAlreadyExistMsg, 其他坦克检查自己的容器中是否有已经准备的坦克的信息, 如果有了就不添加, 没有则把它添加到容器中.
  • 不得不说, 使用多态后扩展协议就变得很方便了.
//修改后, TankNewMsg的解析部分如下
    public void parse(DataInputStream dis){
        try{
            int id = dis.readInt();
            if(id == this.tc.getMyTank().getId()){
                return;
            }
            int x = dis.readInt();
            int y = dis.readInt();
            Dir dir = Dir.values()[dis.readInt()];
            boolean good = dis.readBoolean();
            Tank newTank = new Tank(x, y, good, dir, tc);
            newTank.setId(id);
            tc.getTanks().add(newTank);//把新的坦克添加到容器中
            //发出自己的信息            
            TankAlreadyExistMsg msg = new TankAlreadyExistMsg(tc.getMyTank());
            tc.getNc().send(msg);

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
//TankAlreadyExist的解析部分如下
public void parse(DataInputStream dis) {
    try{
        int id = dis.readInt();
        if(id == tc.getMyTank().getId()){
            return;
        }
        boolean exist = false;//判定发送TankAlreadyExist的坦克是否已经存在于游戏中
        for(Tank t : tc.getTanks()){
            if(id == t.getId()){
                exist = true;
                break;
            }
        }
        if(!exist){//不存在则添加到游戏中
            int x = dis.readInt();
            int y = dis.readInt();
            Dir dir = Dir.values()[dis.readInt()];
            boolean good = dis.readBoolean();
            Tank existTank = new Tank(x, y, good, dir, tc);
            existTank.setId(id);
            tc.getTanks().add(existTank);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

 

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

  • 当一辆坦克死后, 服务器应该从Client集合中删除掉该客户端的信息, 从而不用向该客户端发送信息, 减轻负载.而且服务器应该开启一个新的UDP端口号用于接收坦克死亡的消息, 不然这个死亡的消息会转发给其他客户端.
  • 所以在客户端进行TCP连接的时候要把这个就收坦克死亡信息的UDP端口号也发送给客户端.
  • 被击败后, 弹框通知游戏结束.
//服务端添加的代码片段
int deadTankUDPPort = dis.readInt();//获得死亡坦克客户端的UDP端口号
for(int i = 0; i < clients.size(); i++){//从Client集合中删除该客户端. 
    Client c = clients.get(i);
    if(c.UDP_PORT == deadTankUDPPort){
        clients.remove(c);
    }
}
//而客户端则在向其他客户端发送死亡消息后通知服务器把自己从客户端容器移除
    for(int i = 0; i < missiles.size(); i++) {
        Missile m = missiles.get(i);
        if(m.hitTank(myTank)){
            TankDeadMsg msg = new TankDeadMsg(myTank.getId());//发送坦克死亡的消息
            nc.send(msg);
            MissileDeadMsg mmsg = new MissileDeadMsg(m.getTankId(), m.getId());//发送子弹死亡的消息, 通知产生爆炸
            nc.send(mmsg);
            nc.sendTankDeadMsg();//告诉服务器把自己从Client集合中移除
            gameOverDialog.setVisible(true);//弹窗结束游戏
        }
        m.draw(g);
    }
  • 完成这个版本后, 多人游戏时游戏性更强了, 当一个玩家死后他可以重新开启游戏再次加入战场. 但是有个小问题, 他可能会加入到击败他的坦克的阵营, 因为服务器为坦克分配的id好是递增的, 而判定坦克的阵营仅通过id的奇偶判断. 但就这个版本来说服务器端处理死亡坦克的任务算是完成了.

 

客户端线程同步

  • 在完成基础版本后考虑过这个问题, 因为在游戏中, 由于延时的原因, 可能会造成各个客户端线程不同步. 处理手段可以是每隔一定时间, 各个客户端向服务器发送自己坦克的位置消息, 服务器再将该位置消息通知到其他客户端, 进行同步. 但是在本游戏中, 只要坦克的方向一发生移动就会发送一个TankMoveMsg包, TankMoveMsg消息中除了包含坦克的方向, 也包含坦克的坐标, 相当于做了客户端线程同步. 所以考虑暂时不需要再额外进行客户端同步了.

 

添加图片

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

 

总结与致谢

  • 最后回顾整个项目, 整个项目并没有用到什么高新技术, 相反这是一个十多年前用纯Java实现的教学项目. 我觉得项目中的网络部分对我的帮助非常大. 我最近看完了《计算机网络:自顶向下方法》, 甚至把里面的课后复习题都做了一遍, 要我详细描述TCP三次握手, 如何通过DHCP协议获取IP地址, DNS的解析过程都不是问题, 但是总感觉理论与实践之间差了点东西.
  • 现在我重新考虑协议这个名词, 在网络中, 每一种协议定义了一种端到端的数据传输规则, 从应用层到网络层, 只要有数据传输的地方就需要协议. 人类的智慧在协议中充分体现, 比如提供可靠数据传输和拥塞控制的TCP协议和轻便的UDP协议, 它们各有优点, 在各自的领域作出贡献.
  • 但是协议最终是要执行的, 在本项目中运输层协议可以直接调用Java api实现, 但是应用层协议就要自己定义了. 尽管只是定义了几个超级简单的协议, 但是定义过的协议在发送端和接收端是如何处理的, 是落实到代码敲出来的.
  • 当整个项目做完后, 再次考虑协议这个名词, 能看出它共通的地方, 如果让我设计一个通信协议, 我也不会因对设计协议完全没有概念而彷徨了, 当然设计得好不好就另说咯.
  • 最后隆重致谢本项目的制作者马士兵老师, 除了简单的网络知识, 马老师在项目中不停强调程序设计的重要性, 这也是我今后要努力的方向.
  • 下面是马老师坦克大战的视频集合
  • 百度网盘链接 提取码:302w
  • 以下是我的GitHub地址, 该仓库下有基础版本和改进版本. 基础版本完成了视频教学中的所有内容, 改进版本也就是最新版本则是个人在基础版本上作出的一些改进, 比如加入图片等.
  • 基础版本地址
  • 改进版本地址

往期推荐:

  1. 写代码解释什么是api,什么是sdk
  2. 飞机大战小游戏全制作过程分享
  3. 仿flappy bird小游戏制作分享
  4. 如何开始编写技术博客?markdown语法入门,分享使人进步