最近在研究Unity中多人游戏的实现,要保证在同一游戏里不同玩家所用的客户端之间的状态保持同步,需要配置统一的服务器来分发玩家状态的列表,以在客户端完成多人状态的更新。
做一个小的联机Demo,传输玩家的位置、水平转角,动画状态等信息,通信的逻辑是当客户端将玩家的信息更新到服务端的同时,将状态信息储存在服务端,服务端再响应给客户端当前游戏所有玩家的状态信息列表。
为什么要用Java的SpringBoot框架来搭服务器呢,是由于最近刚好学了Java和SpringBoot的一些知识,想着自己动手熟悉一下,当然如果有大佬能指出一些不足的地方就更好啦。网上找了一些SpringBoot集成WebSocket的用例之后,直接开干
先来看看效果吧:
分别介绍一下服务端和客户端的一些代码:
服务端:
SpringBoot项目的代码结构如下:
首先,在application.yml配置服务器端口:
server:
port: 8888
Pom.xml中引入相应的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
然后创建消息类Message和玩家信息类PlayerInfo两个实体类:
@Data
@Component
@NoArgsConstructor
@AllArgsConstructor
public class Message {
public String type;
public String info;
}
@Data
@Component
@NoArgsConstructor
@AllArgsConstructor
public class PlayerInfo implements Serializable {
public String name;
public float x, y, z;
public float ry; //旋转,只考虑角色的水平旋转
public float speed; //玩家移动的速度
public float motionSpeed; //玩家移动动画速度
public boolean roll; //玩家是否翻滚
}
接下来编写三层架构。主要的逻辑为,当Controller层收到客户端的数据包后,通过注解@OnMessage调用onMessage方法,将数据包交给Service层处理,然后通过Service请求返回当前所有玩家的状态列表,打包成Message再转换Json格式发送给客户端。
客户端与SpringBoot服务端的三层架构通信
Controller层是负责和前端(客户端)打交道的,为了实现简单,这里采用了WebSocket来进行信息的接发,代码如下:
@ServerEndpoint("/")
@Component
public class WebSocketServer {
private Session session;
private static PlayerInfoServiceImpl playerInfoServiceImpl=new PlayerInfoServiceImpl();
//收到消息时执行
@OnMessage
public void onMessage(String message, Session session) throws IOException {
System.out.println("从客户端收到的消息:" + message);
playerInfoServiceImpl.saveInfoService(message);
sendMessage(new Message(MsgTypeConstant.ALL_PLAYER_INFO,
JSONArray.toJSONString(playerInfoServiceImpl.getPlayInfoList())));
}
public void sendMessage(Message message) throws IOException{
this.session.getAsyncRemote().sendText(JSONArray.toJSONString(message));
}
}
顺着这个思路,Service层就负责两件事,一个是将收到的数据保存到数据库中的玩家状态列表,一个是将数据库中的玩家状态列表返回出来。代码如下:
public class PlayerInfoServiceImpl implements PlayerInfoService {
//将数据保存至玩家状态列表
@Override
public void saveInfoService(String message) {
String[] msgList = message.split("&");//以&作为数据包的分割字符
for(String _msg : msgList){
if(_msg.length() > 0){
Message msg = JSON.parseObject(_msg, Message.class);
if(msg.type.equals(MsgTypeConstant.UPDATE_PLAYER_INFO)){
PlayerInfo thisPlayer = JSON.parseObject(msg.info, PlayerInfo.class);
userInfoMapper.userInfo.put(thisPlayer.name, thisPlayer);
}
}
}
}
//从玩家状态列表获取数据
@Override
public HashMap<String, PlayerInfo> getPlayInfoList() {
return userInfoMapper.userInfo;
}
}
Mapper层负责和数据库打交道,为了简单起见,这里就不涉及数据库的存储,先将玩家状态列表保存至Mapper层
public class userInfoMapper {
//用于储存所有玩家的状态信息
public static HashMap<String, PlayerInfo> userInfo = new HashMap<>();
}
ok,简单的服务端就搭建好了,当多个玩家通过这个端口访问服务器时,就可以实时同步他们的信息。如果后面时间允许,应该还得补上数据库的访问以及各种异常处理。下面来看看客户端的一些主要代码。
Unity 客户端
创建PlayerInfo.cs脚本构造玩家状态类
public class PlayerInfo
{
public string name;
public float x, y, z;
public float ry; //旋转,只用考虑角色的水平旋转,节省带宽
public float speed; //玩家移动的速度
public float motionSpeed; //玩家移动动画速度
public bool roll; //玩家是否翻滚
public PlayerInfo(string n, Vector3 pos, Vector3 rot, float speed, float motionSpeed, bool roll)
{
name = n;
x = pos.x; y = pos.y; z = pos.z;
ry = rot.y;
this.speed = speed;
this.motionSpeed = motionSpeed;
this.roll = roll;
}
public PlayerInfo() { }
public void setPos(Vector3 pos)
{
x = pos.x; y = pos.y;z = pos.z;
}
public void setRot(Vector3 rot)
{
ry = rot.y;
}
}
创建UserClient.cs脚本负责管理与服务端的连接和数据收发,在Start函数中,进行服务端的连接。并且在Update函数中将当前玩家的状态定时传至服务端,并等待服务端响应玩家状态信息列表。注意利用WebSocket定时收发操作均为异步进行。
//客户端
//定义消息类
public class Message
{
public string type;
public string info;
public Message(string _type, string _info)
{
type = _type;
info = _info;
}
}
public class UserClient : MonoBehaviour
{
public static float sendMsgCD = 0.1f; //发送数据的时隔
public static PlayerInfo playerInfo;
public static Queue<Message> msgQueue = new Queue<Message>();
static ClientWebSocket webSocket;
static float ntime = 0;
private void Awake()
{
playerInfo = new PlayerInfo();
playerInfo.name ="Starry";
}
private void Start()
{
string ip = "127.0.0.1" ;
int port = 8888;
Connect(ip, port);
}
private void Update()
{
UpdatePlayerInfo();
HandlePlayerInfo();
}
public static void Connect(string ip, int port)
{
webSocket = new ClientWebSocket();
webSocket.ConnectAsync(new Uri("ws://" + ip + ":" + port), CancellationToken.None);
print("连接成功");
}
public static async Task SendMessage(Message msg)
{
string str = JsonConvert.SerializeObject(msg);
byte[] bytes = Encoding.UTF8.GetBytes(str + '&');
await webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
// 接收服务器的响应
byte[] buffer = new byte[1024];
WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
string response = Encoding.UTF8.GetString(buffer, 0, result.Count);
print("服务器响应: " + response);
msgQueue.Enqueue(JsonConvert.DeserializeObject<Message>(response));
}
public static void UpdatePlayerInfo()
{
//设置计时实现周期更新数据
ntime += Time.deltaTime;
if(ntime > sendMsgCD)
{
Task task = SendMessage(new Message("UpdatePlayerInfo", JsonConvert.SerializeObject(playerInfo)));
ntime = 0;
}
}
public static void HandlePlayerInfo()
{
//从玩家状态队列中处理数据
if (msgQueue.Count > 0)
{
Message msg = msgQueue.Dequeue();
print(msg.type);
switch (msg.type)
{
case "AllPlayerInfo":
//获取所有玩家信息的列表
Dictionary<string, PlayerInfo> listInfo = JsonConvert.DeserializeObject<Dictionary<string, PlayerInfo>>(msg.info);
PlayerPool.ins.updatePlayer(listInfo);//交付PlayerPool处理玩家的状态更新
break;
}
}
}
}
创建PlayerPool.cs脚本用于处理玩家状态的更新(如位置、角度的变换、动画参数等)这里因为要在静态函数里调用该类的方法,注意要创建单例模式。
public class PlayerPool : MonoBehaviour
{
//联机模块代码
//为场景内的所有玩家创建模型并同步移动
public GameObject playerPrefab;
public bool instantSelf; //联机模式是否生成自己的模型
Dictionary<string, GameObject> models = new Dictionary<string, GameObject>(); //根据玩家的名字找到对应的模型
Dictionary<string, PlayerInfo> playerState = new Dictionary<string, PlayerInfo>(); //玩家名字与玩家的状态的字典
private Vector3 _posVel;
public void updatePlayer(Dictionary<string, PlayerInfo> list)
{
//尝试寻找玩家
foreach(var p in list)
{
if(PlayerPrefs.GetString("PlayerName") != p.Key || instantSelf)
{
if (models.ContainsKey(p.Key))
{
playerState[p.Key] = p.Value;
}
else
{
print("生成模型");
models[p.Key] = Instantiate(playerPrefab, new Vector3(p.Value.x, p.Value.y, p.Value.z),
Quaternion.Euler(new Vector3(0, p.Value.ry, 0)));
playerState[p.Key] = p.Value;
}
}
}
}
private void Update()
{
TransmitState();
}
private void TransmitState()
{
//更新玩家模型的状态
foreach (var p in playerState)
{
Rigidbody _rb = models[p.Key].GetComponent<Rigidbody>();
Animator _animator = models[p.Key].GetComponent<Animator>();
_posVel = _rb.velocity;
models[p.Key].transform.position = Vector3.SmoothDamp(models[p.Key].transform.position,
new Vector3(p.Value.x, p.Value.y, p.Value.z), ref _posVel, 0.05f);
models[p.Key].transform.rotation = Quaternion.Euler
(0, p.Value.ry, 0);
//更新角色预制体动画状态
_animator.SetFloat("MotionSpeed", p.Value.motionSpeed);
_animator.SetFloat("Speed", p.Value.speed);
_animator.SetBool("Roll", p.Value.roll);
}
}
//采用单例模式
public static PlayerPool ins;
private void Awake()
{
ins = this;
}
}
在玩家控制的函数中,可以更新当前玩家的状态信息。这里可以我放在的控制玩家移动的函数Move()中。
private void Move()
{
//...
UserClient.playerInfo.setPos(transform.position);
UserClient.playerInfo.setRot(transform.rotation.eulerAngles);
UserClient.playerInfo.speed = _animationBlend;
UserClient.playerInfo.motionSpeed = inputMagnitude;
}
将脚本挂载到场景中:
挂载脚本
大功告成,打开服务端,然后开启多个客户端,就能看到多个玩家在场景中啦。
不过由于目前的玩家状态信息很有限,如跳跃、攻击、血量等还没做,如果后面条件允许也会补上的(画饼)