[TOC] 介紹 通過本項目能夠更直觀地理解應用層和運輸層網路協議, 以及繼承封裝多態的運用. 網路部分是本文敘述的重點, 你將看到如何使用Java建立TCP和UDP連接並交換報文, 你還將看到如何自己定義一個簡單的應用層協議來讓自己應用進行網路通信. "獲取源碼" 基礎版本 游戲的原理, 圖形界面 ...
目錄
介紹
- 通過本項目能夠更直觀地理解應用層和運輸層網路協議, 以及繼承封裝多態的運用. 網路部分是本文敘述的重點, 你將看到如何使用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地址, 該倉庫下有基礎版本和改進版本. 基礎版本完成了視頻教學中的所有內容, 改進版本也就是最新版本則是個人在基礎版本上作出的一些改進, 比如加入圖片等.
- 基礎版本地址
- 改進版本地址