Java實(shí)現(xiàn)簡(jiǎn)易版聯(lián)網(wǎng)坦克對(duì)戰(zhàn)小游戲(附源碼)
介紹
通過(guò)本項(xiàng)目能夠更直觀地理解應(yīng)用層和運(yùn)輸層網(wǎng)絡(luò)協(xié)議, 以及繼承封裝多態(tài)的運(yùn)用. 網(wǎng)絡(luò)部分是本文敘述的重點(diǎn), 你將看到如何使用Java建立TCP和UDP連接并交換報(bào)文, 你還將看到如何自己定義一個(gè)簡(jiǎn)單的應(yīng)用層協(xié)議來(lái)讓自己應(yīng)用進(jìn)行網(wǎng)絡(luò)通信.

基礎(chǔ)版本
游戲的原理, 圖形界面(非重點(diǎn))
- 多張圖片快速連續(xù)地播放, 圖片中的東西就能動(dòng)起來(lái)形成視頻, 對(duì)視頻中動(dòng)起來(lái)的東西進(jìn)行操作就變成游戲了. 在一個(gè)坦克對(duì)戰(zhàn)游戲中, 改變一輛坦克每一幀的位置, 當(dāng)多幀連續(xù)播放的時(shí)候, 視覺(jué)上就有了控制坦克的感覺(jué). 同理, 改變子彈每一幀的位置, 看起來(lái)就像是發(fā)射了一發(fā)炮彈. 當(dāng)子彈和坦克的位置重合, 也就是兩個(gè)圖形的邊界相碰時(shí), 在碰撞的位置放上一個(gè)爆炸的圖片, 就完成了子彈擊中坦克發(fā)生爆炸的效果.
- 在本項(xiàng)目借助坦克游戲認(rèn)識(shí)網(wǎng)絡(luò)知識(shí)和面向?qū)ο笏枷? 游戲的顯示與交互使用到了Java中的圖形組件, 如今Java已較少用于圖形交互程序開(kāi)發(fā), 本項(xiàng)目也只是使用了一些簡(jiǎn)單的圖形組件.
- 在本項(xiàng)目中, 游戲的客戶端由TankClient類(lèi)控制, 游戲的運(yùn)行和所有的圖形操作都包含在這個(gè)類(lèi)中, 下面會(huì)介紹一些主要的方法.
//類(lèi)TankClient, 繼承自Frame類(lèi)
//繼承Frame類(lèi)后所重寫(xiě)的兩個(gè)方法paint()和update()
//在paint()方法中設(shè)置在一張圖片中需要畫(huà)出什么東西.
@Override
public void paint(Graphics g) {
//下面三行畫(huà)出游戲窗口左上角的游戲參數(shù)
g.drawString("missiles count:" + missiles.size(), 10, 50);
g.drawString("explodes count:" + explodes.size(), 10, 70);
g.drawString("tanks count:" + tanks.size(), 10, 90);
//檢測(cè)我的坦克是否被子彈打到, 并畫(huà)出子彈
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);
}
//畫(huà)出爆炸
for(int i = 0; i < explodes.size(); i++) {
Explode e = explodes.get(i);
e.draw(g);
}
//畫(huà)出其他坦克
for(int i = 0; i < tanks.size(); i++) {
Tank t = tanks.get(i);
t.draw(g);
}
//畫(huà)出我的坦克
myTank.draw(g);
}
/*
* update()方法用于寫(xiě)每幀更新時(shí)的邏輯.
* 每一幀更新的時(shí)候, 我們會(huì)把該幀的圖片畫(huà)到屏幕中.
* 但是這樣做是有缺陷的, 因?yàn)榘岩桓眻D片畫(huà)到屏幕上會(huì)有延時(shí), 游戲顯示不夠流暢
* 所以這里用到了一種緩沖技術(shù).
* 先把圖像畫(huà)到一塊幕布上, 每幀更新的時(shí)候直接把畫(huà)布推到窗口中顯示
*/
@Override
public void update(Graphics g) {
if(offScreenImage == null) {
offScreenImage = this.createImage(800, 600);//創(chuàng)建一張畫(huà)布
}
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);//先在畫(huà)布上畫(huà)好
g.drawImage(offScreenImage, 0, 0, null);//直接把畫(huà)布推到窗口
}
//這是加載游戲窗口的方法
public void launchFrame() {
this.setLocation(400, 300);//設(shè)置游戲窗口相對(duì)于屏幕的位置
this.setSize(GAME_WIDTH, GAME_HEIGHT);//設(shè)置游戲窗口的大小
this.setTitle("TankWar");//設(shè)置標(biāo)題
this.addWindowListener(new WindowAdapter() {//為窗口的關(guān)閉按鈕添加監(jiān)聽(tīng)
@Override
public void windowClosing(WindowEvent e) {
System.exit(0);
}
});
this.setResizable(false);//設(shè)置游戲窗口的大小不可改變
this.setBackground(Color.GREEN);//設(shè)置背景顏色
this.addKeyListener(new KeyMonitor());//添加鍵盤(pán)監(jiān)聽(tīng),
this.setVisible(true);//設(shè)置窗口可視化, 也就是顯示出來(lái)
new Thread(new PaintThread()).start();//開(kāi)啟線程, 把圖片畫(huà)出到窗口中
dialog.setVisible(true);//顯示設(shè)置服務(wù)器IP, 端口號(hào), 自己UDP端口號(hào)的對(duì)話窗口
}
//在窗口中畫(huà)出圖像的線程, 定義為每50毫秒畫(huà)一次.
class PaintThread implements Runnable {
public void run() {
while(true) {
repaint();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
以上就是整個(gè)游戲圖形交互的主要部分, 保證了游戲能正常顯示后, 下面我們將關(guān)注于游戲的邏輯部分.
游戲邏輯
在游戲的邏輯中有兩個(gè)重點(diǎn), 一個(gè)是坦克, 另一個(gè)是子彈. 根據(jù)面向?qū)ο蟮乃枷? 分別把這兩者封裝成兩個(gè)類(lèi), 它們所具有的行為都在類(lèi)對(duì)應(yīng)有相應(yīng)的方法.
坦克的字段
public int id;//作為網(wǎng)絡(luò)中的標(biāo)識(shí) public static final int XSPEED = 5;//左右方向上每幀移動(dòng)的距離 public static final int YSPEED = 5;//上下方向每幀移動(dòng)的距離 public static final int WIDTH = 30;//坦克圖形的寬 public static final int HEIGHT = 30;//坦克圖形的高 private boolean good;//根據(jù)true和false把坦克分成兩類(lèi), 游戲中兩派對(duì)戰(zhàn) private int x, y;//坦克的坐標(biāo) private boolean live = true;//坦克是否活著, 死了將不再畫(huà)出 private TankClient tc;//客戶端類(lèi)的引用 private boolean bL, bU, bR, bD;//用于判斷鍵盤(pán)按下的方向 private Dir dir = Dir.STOP;//坦克的方向 private Dir ptDir = Dir.D;//炮筒的方向
由于在TankClient類(lèi)中的paint方法中需要畫(huà)出圖形, 根據(jù)面向?qū)ο蟮乃枷? 要畫(huà)出一輛坦克, 應(yīng)該由坦克調(diào)用自己的方法畫(huà)出自己.
public void draw(Graphics g) {
if(!live) {
if(!good) {
tc.getTanks().remove(this);//如果坦克死了就把它從容器中去除, 并直接結(jié)束
}
return;
}
//畫(huà)出坦克
Color c = g.getColor();
if(good) g.setColor(Color.RED);
else g.setColor(Color.BLUE);
g.fillOval(x, y, WIDTH, HEIGHT);
g.setColor(c);
//畫(huà)出炮筒
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();//每次畫(huà)完改變坦克的坐標(biāo), 連續(xù)畫(huà)的時(shí)候坦克就動(dòng)起來(lái)了
}
上面提到了改變坦克坐標(biāo)的move()方法, 具體代碼如下:
private void move() {
switch(dir) {//根據(jù)坦克的方向改變坐標(biāo)
case L://左
x -= XSPEED;
break;
case LU://左上
x -= XSPEED;
y -= YSPEED;
break;
//...省略
}
if(dir != Dir.STOP) {
ptDir = dir;
}
//防止坦克走出游戲窗口, 越界時(shí)要停住
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;
}
上面提到了根據(jù)坦克的方向改變坦克的左邊, 而坦克的方向通過(guò)鍵盤(pán)改變. 代碼如下:
public void keyPressed(KeyEvent e) {//接收接盤(pán)事件
int key = e.getKeyCode();
//根據(jù)鍵盤(pán)按下的按鍵修改bL, bU, bR, bD四個(gè)布爾值, 回后會(huì)根據(jù)四個(gè)布爾值判斷上, 左上, 左等八個(gè)方向
switch (key) {
case KeyEvent.VK_A://按下鍵盤(pán)A鍵, 意味著往左
bL = true;
break;
case KeyEvent.VK_W://按下鍵盤(pán)W鍵, 意味著往上
bU = true;
break;
case KeyEvent.VK_D:
bR = true;
break;
case KeyEvent.VK_S:
bD = true;
break;
}
locateDirection();//根據(jù)四個(gè)布爾值判斷八個(gè)方向的方法
}
private void locateDirection() {
Dir oldDir = this.dir;//記錄下原來(lái)的方法, 用于聯(lián)網(wǎng)
//根據(jù)四個(gè)方向的布爾值判斷八個(gè)更細(xì)分的方向
//比如左和下都是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;
//可以先跳過(guò)這段代碼, 用于網(wǎng)絡(luò)中其他客戶端的坦克移動(dòng)
if(dir != oldDir){
TankMoveMsg msg = new TankMoveMsg(id, x, y, dir, ptDir);
tc.getNc().send(msg);
}
}
//對(duì)鍵盤(pán)釋放的監(jiān)聽(tīng)
public void keyReleased(KeyEvent e) {
int key = e.getKeyCode();
switch (key) {
case KeyEvent.VK_J://設(shè)定J鍵開(kāi)火, 當(dāng)釋放J鍵時(shí)發(fā)出一發(fā)子彈
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();
}
上面提到了坦克開(kāi)火的方法, 這也是坦克最后一個(gè)重要的方法了, 代碼如下, 后面將根據(jù)這個(gè)方法引出子彈類(lèi).
private Missile fire() {
if(!live) return null;//如果坦克死了就不能開(kāi)火
int x = this.x + WIDTH/2 - Missile.WIDTH/2;//設(shè)定子彈的x坐標(biāo)
int y = this.y + HEIGHT/2 - Missile.HEIGHT/2;//設(shè)定子彈的y坐標(biāo)
Missile m = new Missile(id, x, y, this.good, this.ptDir, this.tc);//創(chuàng)建一顆子彈
tc.getMissiles().add(m);//把子彈添加到容器中.
//網(wǎng)絡(luò)部分可暫時(shí)跳過(guò), 發(fā)出一發(fā)子彈后要發(fā)送給服務(wù)器并轉(zhuǎn)發(fā)給其他客戶端.
MissileNewMsg msg = new MissileNewMsg(m);
tc.getNc().send(msg);
return m;
}
子彈類(lèi), 首先是子彈的字段
public static final int XSPEED = 10;//子彈每幀中坐標(biāo)改變的大小, 比坦克大些, 子彈當(dāng)然要飛快點(diǎn)嘛 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;//用于在網(wǎng)絡(luò)中標(biāo)識(shí)的id private TankClient tc;//客戶端的引用 private int tankId;//表明是哪個(gè)坦克發(fā)出的 private int x, y;//子彈的坐標(biāo) private Dir dir = Dir.R;//子彈的方向 private boolean live = true;//子彈是否存活 private boolean good;//子彈所屬陣營(yíng), 我方坦克自能被地方坦克擊斃
子彈類(lèi)中同樣有draw(), move()等方法, 在此不重復(fù)敘述了, 重點(diǎn)關(guān)注子彈打中坦克的方法. 子彈是否打中坦克, 是調(diào)用子彈自身的判斷方法判斷的.
public boolean hitTank(Tank t) {
//如果子彈是活的, 被打中的坦克也是活的
//子彈和坦克不屬于同一方
//子彈的圖形碰撞到了坦克的圖形
//認(rèn)為子彈打中了坦克
if(this.live && t.isLive() && this.good != t.isGood() && this.getRect().intersects(t.getRect())) {
this.live = false;//子彈生命設(shè)置為false
t.setLive(false);//坦克生命設(shè)置為false
tc.getExplodes().add(new Explode(x, y, tc));//產(chǎn)生一個(gè)爆炸, 坐標(biāo)為子彈的坐標(biāo)
return true;
}
return false;
}
補(bǔ)充, 坦克和子彈都以圖形的方式顯示, 在本游戲中通過(guò)Java的原生api獲得圖形的矩形框并判斷是否重合(碰撞)
public Rectangle getRect() {
return new Rectangle(x, y, WIDTH, HEIGHT);
}
- 在了解游戲中兩個(gè)主要對(duì)象后, 下面介紹整個(gè)游戲的邏輯.
- 加載游戲窗口后, 客戶端會(huì)創(chuàng)建一個(gè)我的坦克對(duì)象, 初始化三個(gè)容器, 它們分別用于存放其他坦克, 子彈和爆炸.
- 當(dāng)按下開(kāi)火鍵后, 會(huì)創(chuàng)建一個(gè)子彈對(duì)象, 并加入到子彈容器中(主戰(zhàn)坦克發(fā)出一棵炮彈), 如果子彈沒(méi)有擊中坦克, 穿出游戲窗口邊界后判定子彈死亡, 從容器中移除; 如果子彈擊中了敵方坦克, 敵方坦克死亡從容器移出, 子彈也死亡從容器移出, 同時(shí)會(huì)創(chuàng)建一個(gè)爆炸對(duì)象放到容器中, 等爆炸的圖片輪播完, 爆炸移出容器.
- 以上就是整個(gè)坦克游戲的邏輯. 下面將介紹重頭戲, 網(wǎng)絡(luò)聯(lián)機(jī).
網(wǎng)絡(luò)聯(lián)機(jī)
客戶端連接上服務(wù)器
- 首先客戶端通過(guò)TCP連接上服務(wù)器, 并把自己的UDP端口號(hào)發(fā)送給服務(wù)器, 這里省略描述TCP連接機(jī)制, 但是明白了連接機(jī)制后對(duì)為什么需要填寫(xiě)服務(wù)器端口號(hào)和IP會(huì)有更深的理解, 它們均為T(mén)CP報(bào)文段中必填的字段.
- 服務(wù)器通過(guò)TCP和客戶端連上后收到客戶端的UDP端口號(hào)信息, 并將客戶端的IP地址和UDP端口號(hào)封裝成一個(gè)Client對(duì)象, 保存在容器中.
- 這里補(bǔ)充一點(diǎn), 為什么能獲取客戶端的IP地址? 因?yàn)榉?wù)器收到鏈路層幀后會(huì)提取出網(wǎng)絡(luò)層數(shù)據(jù)報(bào), 源地址的IP地址在IP數(shù)據(jù)報(bào)的首部字段中, Java對(duì)這一提取過(guò)程進(jìn)行了封裝, 所以我們能夠直接在Java的api中獲取源地址的IP.
- 服務(wù)器封裝完Client對(duì)象后, 為客戶端的主機(jī)坦克分配一個(gè)id號(hào), 這個(gè)id號(hào)將用于往后游戲的網(wǎng)絡(luò)傳輸中標(biāo)識(shí)這臺(tái)坦克.
- 同時(shí)服務(wù)器也會(huì)把自己的UDP端口號(hào)發(fā)送客戶端, 因?yàn)榉?wù)器自身會(huì)開(kāi)啟一條UDP線程, 用于接收轉(zhuǎn)發(fā)UDP包. 具體作用在后面會(huì)講到.
- 客戶端收到坦克id后設(shè)置到自己的主戰(zhàn)坦克的id字段中. 并保存服務(wù)器的UDP端口號(hào).
- 這里你可能會(huì)對(duì)UDP端口號(hào)產(chǎn)生疑問(wèn), 別急, 后面一小節(jié)將描述它的作用.

附上這部分的代碼片段:
//客戶端
public void connect(String ip, int port){
serverIP = ip;
Socket s = null;
try {
ds = new DatagramSocket(UDP_PORT);//創(chuàng)建UDP套接字
s = new Socket(ip, port);//創(chuàng)建TCP套接字
DataOutputStream dos = new DataOutputStream(s.getOutputStream());
dos.writeInt(UDP_PORT);//向服務(wù)器發(fā)送自己的UDP端口號(hào)
DataInputStream dis = new DataInputStream(s.getInputStream());
int id = dis.readInt();//獲得服務(wù)器分配給自己坦克的id號(hào)
this.serverUDPPort = dis.readInt();//獲得服務(wù)器的UDP端口號(hào)
tc.getMyTank().id = id;
tc.getMyTank().setGood((id & 1) == 0 ? true : false);//根據(jù)坦克的id號(hào)的奇偶性設(shè)置坦克的陣營(yíng)
} catch (IOException e) {
e.printStackTrace();
}finally {
try{
if(s != null) s.close();//信息交換完畢后客戶端的TCP套接字關(guān)閉
} catch (IOException e) {
e.printStackTrace();
}
}
TankNewMsg msg = new TankNewMsg(tc.getMyTank());
send(msg);//發(fā)送坦克出生的消息(后面介紹)
new Thread(new UDPThread()).start();//開(kāi)啟UDP線程
}
//服務(wù)器
public void start(){
new Thread(new UDPThread()).start();//開(kāi)啟UDP線程
ServerSocket ss = null;
try {
ss = new ServerSocket(TCP_PORT);//創(chuàng)建TCP歡迎套接字
} catch (IOException e) {
e.printStackTrace();
}
while(true){//監(jiān)聽(tīng)每個(gè)客戶端的連接
Socket s = null;
try {
s = ss.accept();//為客戶端分配一個(gè)專(zhuān)屬TCP套接字
DataInputStream dis = new DataInputStream(s.getInputStream());
int UDP_PORT = dis.readInt();//獲得客戶端的UDP端口號(hào)
Client client = new Client(s.getInetAddress().getHostAddress(), UDP_PORT);//把客戶端的IP地址和UDP端口號(hào)封裝成Client對(duì)象, 以備后面使用
clients.add(client);//裝入容器中
DataOutputStream dos = new DataOutputStream(s.getOutputStream());
dos.writeInt(ID++);//給客戶端的主戰(zhàn)坦克分配一個(gè)id號(hào)
dos.writeInt(UDP_PORT);
}catch (IOException e) {
e.printStackTrace();
}finally {
try {
if(s != null) s.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
定義應(yīng)用層協(xié)議

- 客戶機(jī)連上服務(wù)器后, 兩邊分別獲取了初始信息, 且客戶端和服務(wù)器均開(kāi)啟了UDP線程. 客戶端通過(guò)保存的服務(wù)器UDP端口號(hào)可以向服務(wù)器的UDP套接字發(fā)送UDP包, 服務(wù)器保存了所有連上它的Client客戶端信息, 它可以向所有客戶端的UDP端口發(fā)送UDP包.
- 此后, 整個(gè)坦克游戲的網(wǎng)絡(luò)模型已經(jīng)構(gòu)建完畢, 游戲中的網(wǎng)絡(luò)傳輸?shù)缆芬呀?jīng)鋪設(shè)好, 但想要在游戲中進(jìn)行網(wǎng)絡(luò)傳輸還差一樣?xùn)|西, 它就是這個(gè)網(wǎng)絡(luò)游戲的應(yīng)用層通信協(xié)議.
- 在本項(xiàng)目中, 應(yīng)用層協(xié)議很簡(jiǎn)單, 只有兩個(gè)字段, 一個(gè)是消息類(lèi)型, 一個(gè)是消息數(shù)據(jù)(有效載荷).
- 這里先列出所有的具體協(xié)議, 后面將進(jìn)行逐一講解.
| 消息類(lèi)型 | 消息數(shù)據(jù) |
|---|---|
| 1.TANK_NEW_MSG(坦克出生信息) | 坦克id, 坦克坐標(biāo), 坦克方向, 坦克好壞 |
| 2.TANK_MOVE_MSG(坦克移動(dòng)信息) | 坦克id, 坦克坐標(biāo), 坦克方向, 炮筒方向 |
| 3.MISSILE_NEW_MESSAGE(子彈產(chǎn)生信息) | 發(fā)出子彈的坦克id, 子彈id, 子彈坐標(biāo), 子彈方向 |
| 4.TANK_DEAD_MESSAGE(子彈死亡的信息) | 發(fā)出子彈的坦克id, 子彈id |
| 5.MISSILE_DEAD_MESSAGE(坦克死亡的信息) | 坦克id |
- 在描述整個(gè)應(yīng)用層協(xié)議體系及具體應(yīng)用前需要補(bǔ)充一下, 文章前面提到TankClient類(lèi)用于控制整個(gè)游戲客戶端, 但為了解耦, 客戶端將需要進(jìn)行的網(wǎng)絡(luò)操作使用另外一個(gè)NetClient類(lèi)進(jìn)行封裝.
- 回到正題, 我們把應(yīng)用層協(xié)議定義為一個(gè)接口, 具體到每個(gè)消息協(xié)議有具體的實(shí)現(xiàn)類(lèi), 這里我們將用到多態(tài).
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;
//每個(gè)消息報(bào)文, 自己將擁有發(fā)送和解析的方法, 為多態(tài)的實(shí)現(xiàn)奠定基礎(chǔ).
public void send(DatagramSocket ds, String IP, int UDP_Port);
public void parse(DataInputStream dis);
}
下面將描述多態(tài)的實(shí)現(xiàn)給本程序帶來(lái)的好處.
在NetClient這個(gè)網(wǎng)絡(luò)接口類(lèi)中, 需要定義發(fā)送消息和接收消息的方法. 想一下, 如果我們?yōu)槊總€(gè)類(lèi)型的消息編寫(xiě)發(fā)送和解析的方法, 那么程序?qū)⒆兊脧?fù)雜冗長(zhǎng). 使用多態(tài)后, 每個(gè)消息實(shí)現(xiàn)類(lèi)自己擁有發(fā)送和解析的方法, 要調(diào)用NetClient中的發(fā)送接口發(fā)送某個(gè)消息就方便多了. 下面代碼可能解釋的更清楚.
//如果沒(méi)有多態(tài)的話, NetClient中將要定義每個(gè)消息的發(fā)送方法
public void sendTankNewMsg(TankNewMsg msg){
//很長(zhǎng)...
}
public void sendMissileNewMsg(MissileNewMsg msg){
//很長(zhǎng)...
}
//只要有新的消息類(lèi)型, 后面就要接著定義...
//假如使用了多態(tài), NetClient中只需要定義一個(gè)發(fā)送方法
public void send(Msg msg){
msg.send(ds, serverIP, serverUDPPort);
}
//當(dāng)我們要發(fā)送某個(gè)類(lèi)型的消息時(shí), 只需要
TankNewMsg msg = new TankNewMsg();
NetClient nc = new NetClient();//實(shí)踐中不需要, 能拿到唯一的NetClient的引用
nc.send(msg)
//在NetClient類(lèi)中, 解析的方法如下
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();//先拿到消息的類(lèi)型
} catch (IOException e) {
e.printStackTrace();
}
Msg msg = null;
switch (msgType){//根據(jù)消息的類(lèi)型, 調(diào)用具體消息的解析方法
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;
}
}
接下來(lái)介紹每個(gè)具體的協(xié)議.
TankNewMsg
- 首先介紹的是TankNewMsg坦克出生協(xié)議, 消息類(lèi)型為1. 它包含的字段有坦克id, 坦克坐標(biāo), 坦克方向, 坦克好壞.
- 當(dāng)我們的客戶端和服務(wù)器完成TCP連接后, 客戶端的UDP會(huì)向服務(wù)器的UDP發(fā)送一個(gè)TankNewMsg消息, 告訴服務(wù)器自己加入到了游戲中, 服務(wù)器會(huì)將這個(gè)消息轉(zhuǎn)發(fā)到所有在服務(wù)器中注冊(cè)過(guò)的客戶端. 這樣每個(gè)客戶端都知道了有一個(gè)新的坦克加入, 它們會(huì)根據(jù)TankNewMsg中新坦克的信息創(chuàng)建出一個(gè)新的坦克對(duì)象, 并加入到自己的坦克容器中.
- 但是這里涉及到一個(gè)問(wèn)題: 已經(jīng)連上服務(wù)器的客戶端會(huì)收到新坦克的信息并把新坦克加入到自己的游戲中, 但是新坦克的游戲中并沒(méi)有其他已經(jīng)存在的坦克信息.
- 一個(gè)較為簡(jiǎn)單的方法是舊坦克在接收到新坦克的信息后也發(fā)送一條TankNewMsg信息, 這樣新坦克就能把舊坦克加入到游戲中. 下面是具體的代碼. (顯然這個(gè)方法不太好, 每個(gè)協(xié)議應(yīng)該精細(xì)地一種操作, 留到以后進(jìn)行改進(jìn))
//下面是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) {//當(dāng)判斷到接收的新坦克不存在已有集合才加入到集合.
TankNewMsg msg = new TankNewMsg(tc);
tc.getNc().send(msg);//加入一輛新坦克后要把自己的信息也發(fā)送出去.
Tank t = new Tank(x, y, good, dir, tc);
t.id = id;
tc.getTanks().add(t);
}
} catch (IOException e) {
e.printStackTrace();
}
}
TankMoveMsg
下面將介紹TankMoveMsg協(xié)議, 消息類(lèi)型為2, 需要的數(shù)據(jù)有坦克id, 坦克坐標(biāo), 坦克方向, 炮筒方向. 每當(dāng)自己坦克的方向發(fā)生改變時(shí), 向服務(wù)器發(fā)送一個(gè)TankMoveMsg消息, 經(jīng)服務(wù)器轉(zhuǎn)發(fā)后, 其他客戶端也能收該坦克的方向變化, 然后根據(jù)數(shù)據(jù)找到該坦克并設(shè)置方向等參數(shù). 這樣才能相互看到各自的坦克在移動(dòng).
下面是發(fā)送TankMoveMsg的地方, 也就是改變坦克方向的時(shí)候.
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){//如果改變后的方向不同于舊方向也就是說(shuō)方向發(fā)生了改變
TankMoveMsg msg = new TankMoveMsg(id, x, y, dir, ptDir);//創(chuàng)建TankMoveMsg消息
tc.getNc().send(msg);//發(fā)送
}
}
MissileNewMsg
下面將介紹MissileNewMsg協(xié)議, 消息類(lèi)型為3, 需要的數(shù)據(jù)有發(fā)出子彈的坦克id, 子彈id, 子彈坐標(biāo), 子彈方向. 當(dāng)坦克發(fā)出一發(fā)炮彈后, 需要將炮彈的信息告訴其他客戶端, 其他客戶端根據(jù)子彈的信息在游戲中創(chuàng)建子彈對(duì)象并加入到容器中, 這樣才能看見(jiàn)相互發(fā)出的子彈.
MissileNewMsg在坦克發(fā)出一顆炮彈后生成.
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);//發(fā)送給其他客戶端
return m;
}
//MissileNewMsg的解析
public void parse(DataInputStream dis) {
try{
int tankId = dis.readInt();
if(tankId == tc.getMyTank().id){//如果是自己發(fā)出的子彈就跳過(guò)(已經(jīng)加入到容器了)
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, 它們是一個(gè)組合, 當(dāng)一臺(tái)坦克被擊中后, 發(fā)出TankDeadMsg信息, 同時(shí)子彈也死亡, 發(fā)出MissileDeadMsg信息. MissileDeadMsg需要數(shù)據(jù)發(fā)出子彈的坦克id, 子彈id, 而TankDeadMsg只需要坦克id一個(gè)數(shù)據(jù).
//TankClient類(lèi), paint()中的代碼片段, 遍歷子彈容器中的每顆子彈看自己的坦克有沒(méi)有被打中.
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();
//在容器找到對(duì)應(yīng)的那顆子彈, 設(shè)置死亡不再畫(huà)出, 并產(chǎn)生一個(gè)爆炸.
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){//如果是自己坦克發(fā)出的死亡消息舊跳過(guò)
return;
}
for(Tank t : tc.getTanks()){//否則遍歷坦克容器, 把死去的坦克移出容器, 不再畫(huà)出.
if(t.id == tankId){
t.setLive(false);
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
到此為止, 基礎(chǔ)版本就結(jié)束了, 基礎(chǔ)版本已經(jīng)是一個(gè)能正常游戲的版本了.
改進(jìn)版本.
定義更精細(xì)的協(xié)議
- 當(dāng)前如果有一輛坦克加入服務(wù)器后, 會(huì)向其他已存在的坦克發(fā)送TankNewMsg, 其他坦克接收到TankNewMsg會(huì)往自己的坦克容器中添加這輛新的坦克.
- 之前描述過(guò)存在的問(wèn)題: 舊坦克能把新坦克加入到游戲中, 但是新坦克不能把舊坦克加入到游戲中, 當(dāng)時(shí)使用的臨時(shí)解決方案是: 舊坦克接收到TankNewMsg后判斷該坦克是否已經(jīng)存在自己的容器中, 如果不存在則添加進(jìn)容器, 并且自己發(fā)送一個(gè)TankNewMsg, 這樣新的坦克接收到舊坦克的TankNewMsg, 就能把舊坦克加入到游戲里.
- 但是, 我們定義的TankNewMsg是發(fā)出一個(gè)坦克出生的信息, 如果把TankNewMsg同時(shí)用于引入舊坦克, 如果以后要修改TankNewMsg就會(huì)牽涉到其他的代碼, 我們應(yīng)該用一個(gè)新的消息來(lái)讓新坦克把舊坦克加入到游戲中.
- 當(dāng)舊坦克接收TankNewMsg后證明有新坦克加入, 它先把新坦克加入到容器中, 再向服務(wù)器發(fā)送一個(gè)TankAlreadyExistMsg, 其他坦克檢查自己的容器中是否有已經(jīng)準(zhǔn)備的坦克的信息, 如果有了就不添加, 沒(méi)有則把它添加到容器中.
- 不得不說(shuō), 使用多態(tài)后擴(kuò)展協(xié)議就變得很方便了.
//修改后, 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);//把新的坦克添加到容器中
//發(fā)出自己的信息
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;//判定發(fā)送TankAlreadyExist的坦克是否已經(jīng)存在于游戲中
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();
}
}
坦克戰(zhàn)亡后服務(wù)器端的處理
- 當(dāng)一輛坦克死后, 服務(wù)器應(yīng)該從Client集合中刪除掉該客戶端的信息, 從而不用向該客戶端發(fā)送信息, 減輕負(fù)載.而且服務(wù)器應(yīng)該開(kāi)啟一個(gè)新的UDP端口號(hào)用于接收坦克死亡的消息, 不然這個(gè)死亡的消息會(huì)轉(zhuǎn)發(fā)給其他客戶端.
- 所以在客戶端進(jìn)行TCP連接的時(shí)候要把這個(gè)就收坦克死亡信息的UDP端口號(hào)也發(fā)送給客戶端.
- 被擊敗后, 彈框通知游戲結(jié)束.
//服務(wù)端添加的代碼片段
int deadTankUDPPort = dis.readInt();//獲得死亡坦克客戶端的UDP端口號(hào)
for(int i = 0; i < clients.size(); i++){//從Client集合中刪除該客戶端.
Client c = clients.get(i);
if(c.UDP_PORT == deadTankUDPPort){
clients.remove(c);
}
}
//而客戶端則在向其他客戶端發(fā)送死亡消息后通知服務(wù)器把自己從客戶端容器移除
for(int i = 0; i < missiles.size(); i++) {
Missile m = missiles.get(i);
if(m.hitTank(myTank)){
TankDeadMsg msg = new TankDeadMsg(myTank.getId());//發(fā)送坦克死亡的消息
nc.send(msg);
MissileDeadMsg mmsg = new MissileDeadMsg(m.getTankId(), m.getId());//發(fā)送子彈死亡的消息, 通知產(chǎn)生爆炸
nc.send(mmsg);
nc.sendTankDeadMsg();//告訴服務(wù)器把自己從Client集合中移除
gameOverDialog.setVisible(true);//彈窗結(jié)束游戲
}
m.draw(g);
}
完成這個(gè)版本后, 多人游戲時(shí)游戲性更強(qiáng)了, 當(dāng)一個(gè)玩家死后他可以重新開(kāi)啟游戲再次加入戰(zhàn)場(chǎng). 但是有個(gè)小問(wèn)題, 他可能會(huì)加入到擊敗他的坦克的陣營(yíng), 因?yàn)榉?wù)器為坦克分配的id好是遞增的, 而判定坦克的陣營(yíng)僅通過(guò)id的奇偶判斷. 但就這個(gè)版本來(lái)說(shuō)服務(wù)器端處理死亡坦克的任務(wù)算是完成了.
客戶端線程同步
在完成基礎(chǔ)版本后考慮過(guò)這個(gè)問(wèn)題, 因?yàn)樵谟螒蛑? 由于延時(shí)的原因, 可能會(huì)造成各個(gè)客戶端線程不同步. 處理手段可以是每隔一定時(shí)間, 各個(gè)客戶端向服務(wù)器發(fā)送自己坦克的位置消息, 服務(wù)器再將該位置消息通知到其他客戶端, 進(jìn)行同步. 但是在本游戲中, 只要坦克的方向一發(fā)生移動(dòng)就會(huì)發(fā)送一個(gè)TankMoveMsg包, TankMoveMsg消息中除了包含坦克的方向, 也包含坦克的坐標(biāo), 相當(dāng)于做了客戶端線程同步. 所以考慮暫時(shí)不需要再額外進(jìn)行客戶端同步了.
添加圖片
在基礎(chǔ)版本中, 坦克和子彈都是通過(guò)畫(huà)一個(gè)圓表示, 現(xiàn)在添加坦克和子彈的圖片為游戲注入靈魂.
總結(jié)與致謝
- 最后回顧整個(gè)項(xiàng)目, 整個(gè)項(xiàng)目并沒(méi)有用到什么高新技術(shù), 相反這是一個(gè)十多年前用純Java實(shí)現(xiàn)的教學(xué)項(xiàng)目. 我覺(jué)得項(xiàng)目中的網(wǎng)絡(luò)部分對(duì)我的幫助非常大. 我最近看完了《計(jì)算機(jī)網(wǎng)絡(luò):自頂向下方法》, 甚至把里面的課后復(fù)習(xí)題都做了一遍, 要我詳細(xì)描述TCP三次握手, 如何通過(guò)DHCP協(xié)議獲取IP地址, DNS的解析過(guò)程都不是問(wèn)題, 但是總感覺(jué)理論與實(shí)踐之間差了點(diǎn)東西.
- 現(xiàn)在我重新考慮協(xié)議這個(gè)名詞, 在網(wǎng)絡(luò)中, 每一種協(xié)議定義了一種端到端的數(shù)據(jù)傳輸規(guī)則, 從應(yīng)用層到網(wǎng)絡(luò)層, 只要有數(shù)據(jù)傳輸?shù)牡胤骄托枰獏f(xié)議. 人類(lèi)的智慧在協(xié)議中充分體現(xiàn), 比如提供可靠數(shù)據(jù)傳輸和擁塞控制的TCP協(xié)議和輕便的UDP協(xié)議, 它們各有優(yōu)點(diǎn), 在各自的領(lǐng)域作出貢獻(xiàn).
- 但是協(xié)議最終是要執(zhí)行的, 在本項(xiàng)目中運(yùn)輸層協(xié)議可以直接調(diào)用Java api實(shí)現(xiàn), 但是應(yīng)用層協(xié)議就要自己定義了. 盡管只是定義了幾個(gè)超級(jí)簡(jiǎn)單的協(xié)議, 但是定義過(guò)的協(xié)議在發(fā)送端和接收端是如何處理的, 是落實(shí)到代碼敲出來(lái)的.
- 當(dāng)整個(gè)項(xiàng)目做完后, 再次考慮協(xié)議這個(gè)名詞, 能看出它共通的地方, 如果讓我設(shè)計(jì)一個(gè)通信協(xié)議, 我也不會(huì)因?qū)υO(shè)計(jì)協(xié)議完全沒(méi)有概念而彷徨了, 當(dāng)然設(shè)計(jì)得好不好就另說(shuō)咯.
- 最后隆重致謝本項(xiàng)目的制作者馬士兵老師, 除了簡(jiǎn)單的網(wǎng)絡(luò)知識(shí), 馬老師在項(xiàng)目中不停強(qiáng)調(diào)程序設(shè)計(jì)的重要性, 這也是我今后要努力的方向.
- 下面是馬老師坦克大戰(zhàn)的視頻集合
- 百度網(wǎng)盤(pán)鏈接 提取碼:uftx
- 以下是我的GitHub地址, 該倉(cāng)庫(kù)下有基礎(chǔ)版本和改進(jìn)版本. 基礎(chǔ)版本完成了視頻教學(xué)中的所有內(nèi)容, 改進(jìn)版本也就是最新版本則是個(gè)人在基礎(chǔ)版本上作出的一些改進(jìn), 比如加入圖片等.
- 基礎(chǔ)版本地址 (本地下載)
- 改進(jìn)版本地址 (本地下載)
總結(jié)
以上就是這篇文章的全部?jī)?nèi)容了,希望本文的內(nèi)容對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,謝謝大家對(duì)腳本之家的支持。
相關(guān)文章
詳解Java編程中static關(guān)鍵字和final關(guān)鍵字的使用
這篇文章主要介紹了詳解Java編程中static關(guān)鍵字和final關(guān)鍵字的使用,是Java入門(mén)學(xué)習(xí)中的基礎(chǔ)知識(shí),需要的朋友可以參考下2015-09-09
JDK動(dòng)態(tài)代理,代理接口沒(méi)有實(shí)現(xiàn)類(lèi),實(shí)現(xiàn)動(dòng)態(tài)代理方式
這篇文章主要介紹了JDK動(dòng)態(tài)代理,代理接口沒(méi)有實(shí)現(xiàn)類(lèi),實(shí)現(xiàn)動(dòng)態(tài)代理方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-08-08
spring boot 2整合swagger-ui過(guò)程解析
這篇文章主要介紹了spring boot 2整合swagger-ui過(guò)程解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-12-12
Springboot初始化啟動(dòng)報(bào)錯(cuò)Error?creating?bean?with?name?'da
這篇文章主要為大家介紹了Springboot初始化啟動(dòng)報(bào)Error?creating?bean?with?name?'dataSource'?defined?in?class?path?resource解決,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-08-08
SpringSecurity?用戶帳號(hào)已被鎖定的問(wèn)題及解決方法
這篇文章主要介紹了SpringSecurity?用戶帳號(hào)已被鎖定,本文給大家分享問(wèn)題原因及解決方式,需要的朋友可以參考下2023-12-12
dom4j創(chuàng)建和解析xml文檔的實(shí)現(xiàn)方法
下面小編就為大家?guī)?lái)一篇dom4j創(chuàng)建和解析xml文檔的實(shí)現(xiàn)方法。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-06-06
Gradle進(jìn)階使用結(jié)合Sonarqube進(jìn)行代碼審查的方法
今天小編就為大家分享一篇關(guān)于Gradle進(jìn)階使用結(jié)合Sonarqube進(jìn)行代碼審查的方法,小編覺(jué)得內(nèi)容挺不錯(cuò)的,現(xiàn)在分享給大家,具有很好的參考價(jià)值,需要的朋友一起跟隨小編來(lái)看看吧2018-12-12
Java利用DelayQueue實(shí)現(xiàn)延遲任務(wù)代碼實(shí)例
這篇文章主要介紹了Java利用DelayQueue實(shí)現(xiàn)延遲任務(wù)代碼實(shí)例,DelayQueue?是一個(gè)支持延時(shí)獲取元素的阻塞隊(duì)列,?內(nèi)部采用優(yōu)先隊(duì)列?PriorityQueue?存儲(chǔ)元素,同時(shí)元素必須實(shí)現(xiàn)?Delayed?接口,需要的朋友可以參考下2023-12-12

