看完示例,接着开始写代码。本节完成的功能是从零开始搭建一个简单的聊天室,包括服务端和Unity客户端两部分。界面如图,客户端点击链接登录,输入聊天内容,所有连接的客户端都能够在调试窗口中看到消息。
系列文章
罗培羽:游戏服务端开源引擎GoWorld教程—— (1)安装和运行
罗培羽:游戏服务端开源引擎GoWorld教程——(2)Unity示例双端联调
罗培羽:游戏服务端开源引擎GoWorld教程——(3)手把手写一个聊天室
罗培羽:游戏服务端开源引擎GoWorld教程——(4)制作多频道聊天室
罗培羽:游戏服务端开源引擎GoWorld教程——(5)登录注册和存储
罗培羽:游戏服务端开源引擎GoWorld教程——(6)移动同步和AOI
罗培羽:游戏服务端开源引擎GoWorld教程——(7)源码解析之启动流程和热更新
罗培羽:游戏服务端开源引擎GoWorld教程——(8)源码解析之gate
罗培羽:游戏服务端开源引擎GoWorld教程——(9)源码解析之dispatcher
罗培羽:游戏服务端开源引擎GoWorld教程——(10)源码解析之entity
echo服务端
这个教程分两步进行,第一步是先把服务端给搭建起来,然后编写一个回应程序,以验证最基础的消息收发,第二步是添加聊天室功能。
服务端结构
有必要再回顾下goworld的结构图,客户端连接game,经由dispatcher与逻辑服game相连。其中的gate和dispatcher都是固定的程度,我们只需要编写game的逻辑即可。
game里面有两种基本的对象,一种是Entity(实体),另一种是Space(场景)。goworld的服务端至少要定义一种Space以及名为Account的Entity。当game启动时,引擎会给每个game场景一个名为NilSpace的场景,这是个特殊场景,不能交互。当客户端连接进服务器时,引擎会随机的在某个game的NilSpace创建一个代表该连接的Account实体,由它处理玩家的逻辑。
我们会编写简单场景MySpace和Account类,用Account处理玩家的逻辑。当有玩家连接时,game的结构如下。
echo.go
现在,开始编写服务端吧。在goworld目录里新建examples/echo文件夹,然后新建echo.go的文件,编写如下代码。
package main
import (
"github.com/xiaonanln/goworld"
)
func main() {
// 注册自定义的Space类型(必须提供)
goworld.RegisterSpace(&MySpace{})
// 注册Account类型
goworld.RegisterEntity("Account", &Account{})
// 运行游戏服务器
goworld.Run()
}
在main中注册Space和Account实体,这两个类稍后编写。最后调用goworld.Run运行游戏服务器。
MySpace.go
新建文件MySpace.go,编写如下的代码。这是个基本的结构,MySpace继承自entity.Space,它还有个空函数OnSpaceCreated,将会在空间创建时被调用。
package main
import (
"github.com/xiaonanln/goworld/engine/entity"
)
type MySpace struct {
entity.Space // Space type should always inherit from entity.Space
}
// OnSpaceCreated is called when the space is created
func (space *MySpace) OnSpaceCreated() {
}
Account.go
编写代表玩家的Account.go,它继承自entity.Entity。必须带有OnCreated和DescribeEntityType方法,引擎会在恰当的时候调用它们,目前留空即可。在Account中编写Echo_Client方法,客户端可以通过RPC调用“XXX_Client”方法,该方法被调用后会通过CallClient调用客户端的ShowInfo方法,并传回msg。
package main
import (
"github.com/xiaonanln/goworld"
"github.com/xiaonanln/goworld/engine/entity"
)
// 玩家类型
type Account struct {
// 自定义对象类型必须继承entity.Entity
entity.Entity
}
// OnCreated 在Player对象创建后被调用
func (a *Account) OnCreated() {
}
func (a *Account) Echo_Client(msg string) {
a.CallClient("ShowInfo", msg)
}
func (a *Account) DescribeEntityType(desc *entity.EntityTypeDesc) {
}
编写完成,即可编译并运行服务端
goworld build examples/echo
goworld start examples/echo
echo客户端
接着从零开始用Unity编写聊天室的客户端程序,新建个Unity工程,把goworld-unity-demo中Assets\Scripts\GoWorldUnity3D这整个文件夹复制过去,这里面包含了与goworld匹配的网络库。
为使网络库正常运行,还需将demo工程中的Plugin/MsgPack复制到新工程中。
复制完成后,确保没有报错就可以进入下一步。
制作界面
开始制作聊天室的简单的界面,界面如下图,拥有连接和发送两个按钮,还有一个文本输入框。
实现Entity
对goworld的entity/space结构中,服务端有的实体结构客户端也需要一份实现,需要实现Account。新建Account.cs,编写如下代码。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using GoWorldUnity3D;
public class Account : ClientEntity
{
protected override void OnCreated() {
Debug.Log ("OnCreated");
}
protected override void OnDestroy() {
Debug.Log ("OnDestroy");
}
protected override void OnEnterSpace() {
Debug.Log ("OnEnterSpace");
}
protected override void OnLeaveSpace() {
Debug.Log ("OnLeaveSpace");
}
protected override void OnBecomeClientOwner() {
Debug.Log ("OnBecomeClientOwner");
}
protected override void Tick() {
//Debug.Log ("Tick");
}
public static new GameObject CreateGameObject(MapAttr attrs)
{
Debug.Log ("CreateGameObject");
GameObject a = new GameObject ();
a.name = "account";
a.AddComponent<Account> ();
return a;
}
public void ShowInfo(string msg) {
Debug.Log (msg);
}
}
Account继承自ClientEntity,其中的OnCreated、OnDestroy等方法都是固定结构,引擎会在何时的时间调用它。比如当客户端连接后,服务端会创建一个Account,它还会通过网络消息让客户端也创建一个Account对象,然后调用它的OnCreated方法。
引擎会在Account创建后调用CreateGameObject,需要自行实现,此处会在场景里创建名为account的物体,再给它添加Account组件。
ShowInfo是个自定义的方法,供给服务端RPC调用的,它会打印出Log。
echo.cs
编写完Account,编写如下的启动代码。程序启动时调用RegisterEntity注册Account,所有使用的实体都必须要注册,在Update中调用GoWorldUnity3D.GoWorld.Tick驱动框架。再编写OnConnectClick和OnSendClick方法,分别对应界面上的按钮功能。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using GoWorldUnity3D;
public class echo : MonoBehaviour
{
bool isConnected;
public InputField inputField;
// Start is called before the first frame update
void Start()
{
Debug.Log("Register Entity Type Account ...");
GoWorld.RegisterEntity(typeof(Account));
}
// Update is called once per frame
void Update()
{
if (isConnected) {
GoWorldUnity3D.GoWorld.Tick ();
}
}
public void OnConnectClick(){
GoWorldUnity3D.GoWorld.Connect("134.175.xxx.xxx", 14001);
isConnected = true;
}
public void OnSendClick(){
GoWorld.ClientOwner.CallServer ("Echo", inputField.text);
}
}
OnConnectClick会连接服务端,OnSendClick会调用CallServer ,参数Echo意味着它会调用服务端Account实体的Echo_Client方法。
测试
编写完成后,绑定按钮事件,运行游戏。当成功连接服务端,会看到程序创建了个account对象。
当发送文本给服务端,会收到服务端的回应,并打印出来。
实现聊天室功能
接下来实现聊天室功能,当某个客户端发送一条消息,所有连接的客户端都应该收到这条消息。前面提及, 每个game在启动之后都会在本地创建一个唯一的NilSpace,但这个Space很特殊,无法交互。我们要另创建一个聊天Space,然后让Account转移到创建的Space中,新的Space即可以交互,如下图。
echo.go
为了记录新创建的Space,注册一个SpaceService服务。服务是一种特殊的实体,但它是全局唯一的。通过RegisterService注册即可,SpaceService稍后实现。
package main
import (
"github.com/xiaonanln/goworld"
)
func main() {
// 注册自定义的Space类型(必须提供)
goworld.RegisterSpace(&MySpace{})
// 注册Account类型
goworld.RegisterEntity("Account", &Account{})
// 注册自定义的SpaceService类型
goworld.RegisterService("SpaceService", &SpaceService{})
// 运行游戏服务器
goworld.Run()
}
SpaceService.go
添加空间服务SpaceService,代码如下。当服务创建时,引擎会调用它的OnCreated方法,这里我们调用CreateSpaceAnywhere让程序在任意一个game中创建一个MySpace,并且把id记录到s.mySpaceID。编写另一个供实体RPC调用的方法GetSpaceID,参数callerID是实体的ID,它会调用实体的OnGetSpaceID方法,并把空的的id传给它。
package main
import (
"github.com/xiaonanln/goworld"
"github.com/xiaonanln/goworld/engine/gwlog"
"github.com/xiaonanln/goworld/engine/common"
"github.com/xiaonanln/goworld/engine/entity"
)
// SpaceService is the service entity for space management
type SpaceService struct {
entity.Entity
mySpaceID common.EntityID
}
func (s *SpaceService) DescribeEntityType(desc *entity.EntityTypeDesc) {
}
// OnCreated is called when entity is created
func (s *SpaceService) OnCreated() {
gwlog.Infof("Registering SpaceService ...")
s.mySpaceID = goworld.CreateSpaceAnywhere(1)
gwlog.Infof("s.mySpaceID = ", s.mySpaceID)
}
// 获取场景ID
func (s *SpaceService) GetSpaceID(callerID common.EntityID) {
s.Call(callerID, "OnGetSpaceID", s.mySpaceID)
}
Account.go
修改代表玩家的Account.go,在它被创建时调用goworld.CallService("SpaceService", "GetSpaceID", a.ID)去SpaceService请求MySpace的ID,SpaceService会调用Account的OnGetSpaceID方法,它会调用EnterSpace进入到空间中。EnterSpace第一个参数代表空间的ID,第二个参数代表坐标,这里设置为默认值(0, 0, 0)。另外修改Echo_Client方法,现在不只是回应一个客户端,而是遍历Space中的所有实体,都做出回应。
package main
import (
"github.com/xiaonanln/goworld"
"github.com/xiaonanln/goworld/engine/entity"
"github.com/xiaonanln/goworld/engine/common"
)
// 玩家类型
type Account struct {
// 自定义对象类型必须继承entity.Entity
entity.Entity
}
// OnCreated 在Player对象创建后被调用
func (a *Account) OnCreated() {
goworld.CallService("SpaceService", "GetSpaceID", a.ID)
}
// OnGetSpaceID is called by SpaceService
func (a *Account) OnGetSpaceID(spaceID common.EntityID) {
// let account enter space with spaceID
a.EnterSpace(spaceID, entity.Vector3{})
}
func (a *Account) Echo_Client(msg string) {
msg = msg + " server echo"
a.Space.ForEachEntity( func(e *entity.Entity) {
e.CallClient("ShowInfo", msg)
})
}
func (a *Account) DescribeEntityType(desc *entity.EntityTypeDesc) {
}
编写完成后重新编译运行服务端即可测试,如下图开启多个客户端,连接后,在左侧客户端中输入消息,右侧客户端也能够收到消息。
通过本节,读者应该对goworld程序的编写方法有个大致的了解。
推荐些资料
笔者所著《Unity3D网络游戏实战(第2版)》是一本专门介绍如何开发多人网络游戏的实战书籍,手把手教你搭建网络框架,制作大型项目。
「同步」也是网络游戏开发的核心课题。玩家的位置和旋转需要同步给其他玩家,然而网络条件差,会不同步和卡顿。笔者主讲的live《网络游戏同步算法》揭示做好同步的方法,欢迎收听。