Java實現簡易聯網坦克對戰小遊戲

介紹

  • 經過本項目可以更直觀地理解應用層和運輸層網絡協議, 以及繼承封裝多態的運用. 網絡部分是本文敘述的重點, 你將看到如何使用Java創建TCP和UDP鏈接並交換報文, 你還將看到如何本身定義一個簡單的應用層協議來讓本身應用進行網絡通訊.

獲取源碼java

基礎版本

遊戲的原理, 圖形界面(非重點)

  • 多張圖片快速連續地播放, 圖片中的東西就能動起來造成視頻, 對視頻中動起來的東西進行操做就變成遊戲了. 在一個坦克對戰遊戲中, 改變一輛坦克每一幀的位置, 當多幀連續播放的時候, 視覺上就有了控制坦克的感受. 同理, 改變子彈每一幀的位置, 看起來就像是發射了一發炮彈. 當子彈和坦克的位置重合, 也就是兩個圖形的邊界相碰時, 在碰撞的位置放上一個爆炸的圖片, 就完成了子彈擊中坦克發生爆炸的效果.
  • 在本項目藉助坦克遊戲認識網絡知識和麪向對象思想, 遊戲的顯示與交互使用到了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地址, 該倉庫下有基礎版本和改進版本. 基礎版本完成了視頻教學中的全部內容, 改進版本也就是最新版本則是我的在基礎版本上做出的一些改進, 好比加入圖片等.
  • 基礎版本地址
  • 改進版本地址
相關文章
相關標籤/搜索