自从开始接触Swing以来,就喜欢写写各种管理系统,写多了就萌生了一种类似于实时在线对战的游戏,经过一番构思后就开始着手设计这个网络对战版本的五子棋了。
游戏代码包含两部分,常规的C/S模式(C代表客户端,S代表服务端)
下载代码后先启动服务器,服务器正常启动后,你会在控制台看到相关的日志(这里注意,服务器是没有做界面管理的),接着启动客户端(可以启动多个客户端),连接服务器后点击菜单栏联网、对战、匹配等操作
游戏效果图:
欢迎大家支持新自的研游戏:中国象棋
下载在线客户端版本试玩:
链接:https://pan.baidu.com/s/1-Bt8tcuZGkVj-jFxN4YDbg 密码:w2oy
要求:jdk环境 1.6或以上
使用方式:环境正常安装后,解压下载的文件,点击 startClient.bat 就可以打开了
备注:若你只开一个客户端,进行匹配的话可能没人跟你玩,建议不是为了技术纯测试的话与你和你的朋友一起对战
阅读本文前,您需要了解:
- java swing(好像是废话)
- socket
- json
- 多线程(不多,计时用了一下)
1:服务端与客户端数据交互如何约定?
在c/s程序的设计之初,如何按照约定的方式进行数据交互一直是一个需要解决的问题,在我这个程序中,有一个常量类定义如下,这是我与客户端进行的一个约定,任何请求都会含有一个基础数据(key),而所做的工作就是这个基础数据(key)所对应的基础数据(value)
package cn.xt.net;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
public class Const {
public static final int PORT = 7001; // 监听端口
// 基础数据(key)
public static final String ID = "id";
public static final String MSG = "msg";
// 基础数据(value)
public static final String ID_STATUS_ERROR = "idError"; // 错误信息
public static final String ID_STATUS_INIT = "初始化客户端"; // 向服务器请求初始化
public static final String ID_STATUS_PP = "匹配玩家";
public static final String PP_SUCCESS = "匹配成功";
public static final String ID_STATUS_PUT = "传送落子位置";
public static final String ID_STATUS_GET = "获取落子位置";
public static final String ID_STATUS_OVER = "对局结束";
public static final String ID_STATUS_MSG = "聊天消息";
public static final String ID_STATUS_BACK = "请求悔棋";
public static final String ID_STATUS_FAIL = "认输";
public static final String ID_STATUS_HANDSNAKE = "初次握手";
public static final String ID_STATUS_BACK_RESULT = "请求悔棋结果";
public static final String ID_STATUS_OVERTIME = "游戏超时";
public static final String SIZE = "棋盘长度";
public static final String EXISTS = "该用户名已存在系统中";
public static final String USER_NAME = "userName";
public static final String INIT_SUCCESS = "初始化成功";
public static final String X = "x";
public static final String Y = "y";
public static final String STATUS = "status"; // 当前棋子的状态
public static final String COLOR = "落子颜色";
public static final String SYSTEM_MSG = "系统消息";
// key - value
public static final String MY = "my"; // 玩家
public static final String YOU = "you"; // 对家
public static final String FIRST = "先手方"; // 1:先手; 0:后手
// 属于页面的专属数据
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
public static String getId() {
return getSysDate() + "-" + UUID.randomUUID().toString();
}
public static String getSysDate(){
return sdf.format(new Date());
}
// public static void main(String[] args) {
// System.out.println(Const.getId());
// }
}
2:服务端构建
客户端的连接信息将会在服务器定义一个Map保存,该实体类定义如下:
保存客户端所有信息的Map:
private static volatile Map<String, UserData> client = new HashMap<>();
实体类定义(类名:UserData):
private MessageFormat msgFormat; // 处理消息的对象
private Socket socket; // 客户端套接字对象
private Thread thread; // 处理消息的线程
private String userId; // 登陆的用户(唯一性)
private String userName; // 客户的名字(自己取的名称)
private String status; // 状态(0:登陆; 1:准备; 2:对局开始; 3:观战)
private String enemy; // 对家
private String isFirst; // 是否为先手
private List<Chess> chessBoard; // 棋盘
private boolean isOver; // 对局是否结束
定义一个普通的启动类,继承Thread,重写Thread类中的run方法以监听客户端socket请求,其中MessageFormat类是处理客户端的发送/传输类,每个客户端连接后都会生成一个inputstream与outputstream(I/O)流,MessageFormat对其进行了相关的封装,也是统一处理客户端发送的信息和发送信息给客户端,在下面我会重点介绍该类,该类也是处理消息的一个核心类。
private ServerSocket ss;
private Socket s;
private boolean start = false;
private static volatile Map<String, UserData> client = new HashMap<>();
public static void main(String[] args) {
SuperServlet servlet = new SuperServlet();
servlet.startService();
}
public SuperServlet() {
try {
ss = new ServerSocket(Const.PORT);
} catch (IOException e) {
e.printStackTrace();
}
}
// 线程用来接收客户端连接的请求
@Override
public void run() {
try {
while (start) {
s = ss.accept(); // 一直监听客户端的请求
synchronized (s) {
String userId = Const.getId(); // 返回一个唯一id
MessageFormat cs = new MessageFormat(s); // 建立传输入数据的线程(服务类)
// 为每个连接的客户端定义一个线程为其服务
Thread th = new Thread(cs);
// 定义用户信息类
UserData ud = new UserData();
ud.setUserId(userId);
ud.setMsgFormat(cs);
ud.setSocket(s);
ud.setThread(th);
client.put(userId, ud); // 将这个用户保存到服务器中
th.start(); // 启动这个线程
// 将识别ID发给客户端保存
JSONObject responseMsg = new JSONObject();
responseMsg.put(Const.ID, Const.ID_STATUS_HANDSNAKE);
responseMsg.put(Const.MSG, userId);
cs.send(responseMsg); // 发送消息给客户端
System.out.println("一个客户连接... 在线数量:" + client.size());
}
}
} catch (Exception er) {
System.out.println("服务已启动或端口被占用!");
er.printStackTrace();
System.exit(0);
}
}
2.1:服务器之MessageFormat类
前面我讲到,该类处理客户端的请求与服务端发送给该客户端的请求,为不给服务器造成阻塞,该类肯定是一个线程,简单的看一下该类定义的几个方法:
// 默认构造器,对新连接的客户端的I/O流进行封装
public MessageFormat(Socket s)
// 重写该线程的run方法,用于循环监听客户端的发送的信息
public void run()
// 发送信息给该客户端
public void send(final JSONObject msg)
// 该客户端主动发送信息给别的客户端
public void send(String userId, JSONObject msg)
// 客户端断开时关闭I/O流
public void close()
// 消息处理中心(msg为客户端发过来的json数据)
public void addInfo(String msg)
服务端重点处理的是客户端发过来的信息,所以先来讲解处理的方式:
对着下面的代码,我们先是循环监听客户端发过来的信息,dis.readUTF()是一个阻塞式的方法,若客户端发过来信息则返回一个字符串,接着在这里只是进行简单的打印就将信息传送给了addInfo() 这个方法;在我的catch中进行了相关处理,这里主要处理客户端若遇到不可描述的事情断开后,服务器应主动踢出这个客户端,这里我是先遍历了服务器中所有的客户端,找到一个线程ID与之匹配的,然后把这个线程踢出Map
@Override
public void run() {
while (clientStart) {
try {
// 客户端传来的信息
String requestMsg = dis.readUTF();
System.out.println("service do in:" + requestMsg);
// LogUtils.write("service do in:" + requestMsg);
// 消息处理
addInfo(requestMsg);
} catch (IOException e) {
// 如果无法接收客户端的信息
for(Entry<String, UserData> set : SuperServlet.getClient().entrySet()){
String userId = set.getKey();
UserData ud = set.getValue();
// 匹配退出的客户端进程
if(this == ud.getMsgFormat()){
// 给对手发送退出信息
JSONObject responseMsg = new JSONObject();
responseMsg.put(Const.ID, Const.ID_STATUS_FAIL);
UserData clientData = SuperServlet.getClient().get(ud.getEnemy());
if(clientData != null && !clientData.isOver()){
clientData.getMsgFormat().send(responseMsg);
}
MessageFormatHelper.ppList.remove(ud.getUserId());
System.out.println("退出的客户端为:"+ ud.getUserName() + " --> " + ud.getUserId());
SuperServlet.getClient().remove(userId); // 服务器移除这个用户
System.out.println("在线数量:" + SuperServlet.getClient().size());
// ud.getThread().interrupt(); // 等待线程关闭
close(); // 关闭相应的流
clientStart = false; // 预先停止 while循环
ud.getThread().stop(); // 等待线程关闭
}
}
}
}
}
由上面的代码可以看到,客户端的所有信息我是将由addInfo()方法处理的:
对着下面代码,我们先是将传过来的信息进行转义替换,然后转换成JSON,取到开始时常量类里面定义的key,进行switch匹配,匹配到对应的ID就做对应的事,这种做法在SpringMVC中跟RequestMapper有着异曲同工之想法。在这里,有一处地方我没有做讲解,MessageFormatHelper这个类是做什么的呢?在我们写Javaweb项目时,常会写一个dao 与 daoImpl,在这里MessageFormatHelper相当于 daoImpl类,也就是实现具体功能的类。
public void addInfo(String msg) {
JSONObject json = JSONObject.fromObject(msg.replaceAll("\"", "\\\""));
MessageFormatHelper.initialized(json); // 优先初始化服务器数据
String id = json.get(Const.ID).toString();
switch (id) {
// 初始化
case Const.ID_STATUS_INIT:
MessageFormatHelper.init();
break;
// 匹配玩家
case Const.ID_STATUS_PP:
MessageFormatHelper.matchUser();
break;
// 落子
case Const.ID_STATUS_PUT:
MessageFormatHelper.putChess();
break;
// 获取棋子
// case Const.ID_STATUS_GET:
// MessageFormatHelper.getChess();
// break;
// 对局结束
case Const.ID_STATUS_OVER:
MessageFormatHelper.gameOver();
break;
// 悔棋请求
case Const.ID_STATUS_BACK:
MessageFormatHelper.toBack();
break;
// 悔棋请求的结果
case Const.ID_STATUS_BACK_RESULT:
MessageFormatHelper.toBackResult();
break;
// 认输
case Const.ID_STATUS_FAIL:
MessageFormatHelper.fail();
break;
// 聊天消息
case Const.ID_STATUS_MSG:
MessageFormatHelper.chatMsg();
break;
// 游戏超时
case Const.ID_STATUS_OVERTIME:
MessageFormatHelper.overTime();
break;
default:
System.out.println("server: 未匹配到分支; id:" + id);
break;
}
// 未找到匹配分支
// JSONObject result = new JSONObject();
// result.put(Const.ID, "未找到匹配分支");
// return result;
}
MessageFormatHelper类实现的功能在MessageFormat中皆有体现并有注释,因代码太多,大家自行下载代码研究,我的代码注释一般都写的比较详细,且思路通俗易懂;这里贴几个少一点的功能代码
/**
* 双方棋子互传
*/
public static void putChess() {
// 设置我方棋盘
String userId = json.getString(Const.MY);
// 获取坐标
int x = json.getInt(Const.X);
int y = json.getInt(Const.Y);
String color = json.getString(Const.COLOR);
// 更新我方棋盘
UserData my = SuperServlet.getClient().get(userId);
my.getChessBoard().add(new Chess(x, y, color));
// 更新对方棋盘
UserData you = SuperServlet.getClient().get(my.getEnemy());
you.getChessBoard().add(new Chess(x, y, color));
// 更新服务器数据
SuperServlet.updateClient(userId, my);
SuperServlet.updateClient(you.getUserId(), you);
// 将棋子同步给对手
if(!you.isOver()){
json.put(Const.ID, Const.ID_STATUS_GET);
you.getMsgFormat().send(json);
} else {
System.out.println("无将棋子同步给对手, 对方已结束游戏");
}
}
/**
* 聊天消息处理
*/
public static void chatMsg() {
result.put(Const.ID, Const.ID_STATUS_MSG);
String userId = json.getString(Const.MY);
UserData my = SuperServlet.getClient().get(userId);
// 获取对手名称
UserData you = SuperServlet.getClient().get(my.getEnemy());
if(you != null){
result.put(Const.MSG, json.getString(Const.MSG));
result.put(Const.MY, my.getUserName());
you.getMsgFormat().send(result);
}
}
/**
* 悔棋请求
*/
public static void toBack(){
String userId = json.getString(Const.MY);
UserData my = SuperServlet.getClient().get(userId);
result.put(Const.ID, Const.ID_STATUS_BACK);
UserData you = SuperServlet.getClient().get(my.getEnemy());
you.getMsgFormat().send(result);
}
/**
* 悔棋结果
*/
public static void toBackResult(){
String userId = json.getString(Const.MY);
UserData my = SuperServlet.getClient().get(userId);
result.put(Const.ID, Const.ID_STATUS_BACK_RESULT);
result.put(Const.MSG, json.getString(Const.MSG));
UserData you = SuperServlet.getClient().get(my.getEnemy());
you.getMsgFormat().send(result);
// 更新服务器棋盘以备观战
if("同意".equals(json.getString(Const.MSG))){
my.getChessBoard().remove(my.getChessBoard().size() - 1);
you.getChessBoard().remove(you.getChessBoard().size() - 1);
SuperServlet.updateClient(my.getUserId(), my);
SuperServlet.updateClient(you.getUserId(), you);
}
}
3:客户端
客户端传送信息与服务器交互采用的方式跟服务器处理信息的方式是一样的,所以没有什么可讲的,主要讲一下棋盘的绘制与如何判断已经胜利。
3.1:客户端之JPanel
在这个之前还有一个JFrame,学过awt的应该知道 frame,而JFrame则是swing,swing是AWT的之类,相关资料可以查询API,JPanel是一个容器,而JFrame是一个窗口,这里直接跳过JFrame讲JPanel,因为绘制棋盘的甩有功能都是在这个容器里面实现的
/**
* 相关属性定义
*/
private int span = 35; // 棋盘格子宽度
private int margin = 22;
private final int DIAMETER = 35; // 直径
private final int row = 15; // 棋盘行、列
private final int col = 15;
private int i = 0;
private boolean isBlack = true;
private boolean isPicture = true;// 是否用图片作为背景(图片是正常游戏背景,false为测试游戏背景)
private ImageIcon img = new ImageIcon("src/images/board.jpg");
private List<Chess> list = new LinkedList<Chess>(); // 整个棋盘
public boolean gameOver = true; // 默认结束游戏
// 网络数据
public boolean isNetworkPK = false;
public boolean myChessColor = false; // 记录我方落子的颜色
public boolean failFlag = false; // 认输标记
public MessageQueuePanel MQPanel;
public String userName = null; // 我的名称
public String userId = null; // 我的ID
以上定义中,棋盘就是我们的List,重写 JPanel 的Paint方法绘制游戏棋盘
@Override
public void paint(Graphics g) {
super.paint(g);
// span = this.getHeight() / row; // 当窗口被拖动,动态刷新窗口
Graphics2D g2 = (Graphics2D) g;
// 正常游戏绘制的棋盘是一张背景图片
g.drawImage(img.getImage(), 1, 0, null);
RadialGradientPaint rgp = null;
// 画棋子
for (i = 0; i < list.size(); ++i) {
Chess chess = list.get(i);
int xPos = chess.getX() * span + margin; // 将真实坐标转换成网格坐标
int yPos = chess.getY() * span + margin;
g2.setColor(chess.getColors()); // 设置画笔颜色
if (chess.getColors() == Color.BLACK) {
rgp = new RadialGradientPaint(xPos - DIAMETER / 2 + 26, //
yPos - DIAMETER / 2 + 12, 20, new float[] { 0.0f, 1.0f }, //
new Color[] { Color.WHITE, Color.BLACK });
g2.setPaint(rgp);
} else {
// x, y, 直径, 渐变度, 渐变色
rgp = new RadialGradientPaint(xPos - DIAMETER / 2 + 25, //
yPos - DIAMETER / 2 - 30, 60, new float[] { 0f, 1f }, //
new Color[] { Color.BLACK, Color.WHITE });
g2.setPaint(rgp);
}
// 去锯齿
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_DEFAULT);
g2.fillOval(xPos - DIAMETER / 2, yPos - DIAMETER / 2, span - 1, span);
// 画红色矩形
g2.setColor(Color.RED);
if (i == list.size() - 1)
g2.draw3DRect(xPos - DIAMETER / 2 - 1, //
yPos - DIAMETER / 2 - 1, span, span + 1, true);
}
}
3.2:客户端之如何判断胜利
在isWin方法中,我们传入整个棋盘,以及当前的落子位置和当前落子的颜色,五子棋判断胜利相关于一个‘米’字,实则只需要扫描一条边后,然后判断一下同颜色的连子数是否大于等于五个就可判断它是否已经胜利了,在代码中前两个循环是判断第一条横线,分别判断从落子位置的左边和右边,也就是米字中的 一 ,其它判断通俗易懂,大家可自行领悟
// 判断胜利的方法
private boolean isWin(List<Chess> list, int xPos, int yPos, boolean isBlack) {
int chessCount = 1;
final int max = 5; // 连子数
int x = 0, y = 0;
Color color = (isBlack ? Color.BLACK : Color.WHITE);
// 当前位置向左
for (x = xPos - 1; x >= 0; --x) {
if (getChess(list, x, yPos, color) != null)
chessCount++;
else
break;
}
// 当前位置向右
for (x = xPos + 1; x <= row; ++x) {
if (getChess(list, x, yPos, color) != null)
chessCount++;
else
break;
}
if (chessCount >= max)
return true;
else
chessCount = 1;
// 当前位置向上
for (y = yPos - 1; y >= 0; --y) {
if (getChess(list, xPos, y, color) != null)
chessCount++;
else
break;
}
// 当前位置向下
for (y = yPos + 1; y <= col; ++y) {
if (getChess(list, xPos, y, color) != null)
chessCount++;
else
break;
}
if (chessCount >= max)
return true;
else
chessCount = 1;
// 左斜着向上
for (x = xPos - 1, y = yPos - 1; x >= 0 && y >= 0; --x, --y) {
if (getChess(list, x, y, color) != null)
chessCount++;
else
break;
}
// 右斜着向下
for (x = xPos + 1, y = yPos + 1; x <= row && y <= col; ++x, ++y) {
if (getChess(list, x, y, color) != null)
chessCount++;
else
break;
}
if (chessCount >= max)
return true;
else
chessCount = 1;
// 右斜着向上
for (x = xPos + 1, y = yPos - 1; x <= row && y >= 0; ++x, --y) {
if (getChess(list, x, y, color) != null)
chessCount++;
else
break;
}
// 左斜着向下
for (x = xPos - 1, y = yPos + 1; x >= 0 && y <= col; --x, ++y) {
if (getChess(list, x, y, color) != null)
chessCount++;
else
break;
}
if (chessCount >= max)
return true;
else
chessCount = 1;
return false;
}