介紹
- 通過本專案能夠更直觀地理解應用層和運輸層網路協議, 以及繼承封裝多型的運用. 網路部分是本文敘述的重點, 你將看到如何使用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地址, 該倉庫下有基礎版本和改進版本. 基礎版本完成了視訊教學中的所有內容, 改進版本也就是最新版本則是個人在基礎版本上作出的一些改進, 比如加入圖片等.
- 基礎版本地址
- 改進版本地址