最近在研究Unity中多人游戏的实现,要保证在同一游戏里不同玩家所用的客户端之间的状态保持同步,需要配置统一的服务器来分发玩家状态的列表,以在客户端完成多人状态的更新。

做一个小的联机Demo,传输玩家的位置、水平转角,动画状态等信息,通信的逻辑是当客户端将玩家的信息更新到服务端的同时,将状态信息储存在服务端,服务端再响应给客户端当前游戏所有玩家的状态信息列表。

为什么要用Java的SpringBoot框架来搭服务器呢,是由于最近刚好学了Java和SpringBoot的一些知识,想着自己动手熟悉一下,当然如果有大佬能指出一些不足的地方就更好啦。网上找了一些SpringBoot集成WebSocket的用例之后,直接开干

先来看看效果吧:

unity局域网游戏断线重连 unity局域网联机_java

分别介绍一下服务端和客户端的一些代码:

服务端:

SpringBoot项目的代码结构如下:

unity局域网游戏断线重连 unity局域网联机_java_02

首先,在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格式发送给客户端。


unity局域网游戏断线重连 unity局域网联机_unity局域网游戏断线重连_03

客户端与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;
}

将脚本挂载到场景中:


unity局域网游戏断线重连 unity局域网联机_游戏_04

挂载脚本

大功告成,打开服务端,然后开启多个客户端,就能看到多个玩家在场景中啦。

不过由于目前的玩家状态信息很有限,如跳跃、攻击、血量等还没做,如果后面条件允许也会补上的(画饼)