天天看點

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

目錄

  • 介紹
    • 本項目的Github位址
  • 基礎版本
    • 用戶端連接配接上伺服器
    • 定義應用層協定
    • TankNewMsg
    • TankMoveMsg
    • MissileNewMsg
    • TankDeadMsg和MissileDeadMsg
    • 遊戲的原理, 圖形界面(非重點)
    • 遊戲邏輯
    • 網絡聯機
  • 改進版本.
    • 定義更精細的協定
    • 坦克戰亡後伺服器端的處理
    • 用戶端線程同步
    • 添加圖檔
  • 總結與緻謝
  • 通過本項目能夠更直覺地了解應用層和運輸層網絡協定, 以及繼承封裝多态的運用. 網絡部分是本文叙述的重點, 你将看到如何使用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端口号産生疑問, 别急, 後面一小節将描述它的作用.
Java實作簡易聯網坦克對戰小遊戲
  • 附上這部分的代碼片段:
//用戶端
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();
            }
        }
    }
}      

Java實作簡易聯網坦克對戰小遊戲
  • 客戶機連上伺服器後, 兩邊分别擷取了初始資訊,  且用戶端和伺服器均開啟了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坦克出生協定, 消息類型為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協定, 消息類型為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協定, 消息類型為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資訊. 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位址, 該倉庫下有基礎版本和改進版本. 基礎版本完成了視訊教學中的所有内容, 改進版本也就是最新版本則是個人在基礎版本上作出的一些改進, 比如加入圖檔等.
  • 基礎版本位址
  • 改進版本位址