从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)

目录

第1章: 初始场景与UI界面制作

登录场景制作

制作登录界面UI

UI自适应原理

制作角色创建界面​

制作Tips动态提示界面

制作Loading进度界面

第2章: UI逻辑框架与配置文件

客户端开发环境配置

UI逻辑框架介绍

游戏启动逻辑

异步场景加载

更新场景加载进度

登录注册界面逻辑

UI窗口基类

音效播放服务

业务系统层基类

Tips弹窗显示

设置Tips显示队列

角色创建界面逻辑

生成随机名字配置文件

解析随机名字配置文件

完成随机名字生成

第3章: 网络通信与服务器环境配置

服务器开发环境配置

介绍PESocket开源网络库

服务器使用PESocket

Unity使用PESocket

设置日志接口

服务器框架介绍

服务器启动逻辑

服务器网络服务

客户端网络服务

封装通用工具

登录协议定制

登录消息分发处理

附加会话信息到消息包

驱动逻辑处理

增加数据缓存层

缓存上线玩家数据

客户端消息分发

第4章:数据库与服务器缓存层

数据库环境配置

数据库增删改查

增加数据库管理器

查询玩家数据

插入默认玩家数据

重命名功能

缓存更新逻辑

数据表备份与错误码处理


第1章: 初始场景与UI界面制作

登录场景制作

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第1张图片

制作登录界面UI

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第2张图片

UI自适应原理

首先设置Canvas根据窗口大小匹配匹配,然后以高度为匹配对象

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第3张图片

给UI上的各个物体添加锚点,进行自适应

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第4张图片从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第5张图片

制作角色创建界面

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第6张图片

制作Tips动态提示界面

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第7张图片

制作Loading进度界面

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第8张图片


第2章: UI逻辑框架与配置文件

客户端开发环境配置

使用VS2017加VA助手进行代码开发。

UI逻辑框架介绍

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第9张图片

游戏启动逻辑

根据设计的UI逻辑框架,首先创建三个脚本GameRoot,ResSvc和LoginSys分别负责游戏启动入口,资源加载服务和游戏登录服务。

public class GameRoot : MonoBehaviour 
{
    private void Start()
    {
        Debug.Log("Game Start...");
        Init();
    }

    private void Init()
    {
        //服务模块初始化
        ResSvc res = GetComponent();
        res.InitSvc();

        //业务系统初始化
        LoginSys login = GetComponent();
        login.InitSys();

        //进入登录场景并加载相应UI
        login.EnterLogin();

    }
}
public class ResSvc : MonoBehaviour 
{
    public void InitSvc()
    {
        Debug.Log("Init ResSvg...");
    }
}
public class LoginSys : MonoBehaviour 
{
    public void InitSys()
    {
        Debug.Log("Init LoginSys...");
    }

    /// 
    /// 进入登录场景
    /// 
    public void EnterLogin()
    {
        //TODO
        //异步加载登录场景
        //并显示加载的进度条
        //加载完成以后并显示注册登录界面
    }
}

异步场景加载

异步加载登录场景

    public void AsyncLoadScene(string sceneName)
    {
        SceneManager.LoadSceneAsync(sceneName);
    }

给加载界面添加一个LoadingWnd脚本,进行管理。这个脚本有GameRoot直接负责进行管理,因为游戏中很多其他地方也会多次加载页面。

public LoadingWnd loadingWnd;

在LoginSys脚本的EnterLogin中首先加载这个页面。

GameRoot.Instance.loadingWnd.gameObject.SetActive(true);

更新场景加载进度

在loadingWnd中添加初始化场景加载和修改读取条的方法

    public Text txtTips;
    public Image imgFG;
    public Image imgPoint;
    public Text txtPrg;

    private float fgWidth;

    //初始化窗口
    public void InitWnd()
    {
        fgWidth = imgFG.GetComponent().sizeDelta.x;
        txtTips.text = "这是一条游戏Tips";
        txtPrg.text = "0%";
        imgFG.fillAmount = 0;
        imgPoint.transform.localPosition = new Vector3(-400f, 0, 0);
    }

    //修改进度条
    public void SetProgress(float prg)
    {
        txtPrg.text = (int)(prg * 100) + "%";
        imgFG.fillAmount = prg;
        float posX = prg * fgWidth - 400;
        imgPoint.GetComponent().anchoredPosition = new Vector2(posX, 0);
    }

ResSvc中添加异步加载场景的动画并且实时更新

    private Action prgCB = null;
    public void AsyncLoadScene(string sceneName)
    {
        AsyncOperation sceneAsync = SceneManager.LoadSceneAsync(sceneName);
        prgCB = () => {
            float val = sceneAsync.progress;
            GameRoot.Instance.loadingWnd.SetProgress(val);
            if (val == 1)
            {
                prgCB = null;
                sceneAsync = null;
                GameRoot.Instance.loadingWnd.gameObject.SetActive(false);
            }
        };
    }

    private void Update()
    {
        if (prgCB != null)
            prgCB();
    }

LoginSys的的EnterLogin方法中进行调用

    /// 
    /// 进入登录场景
    /// 
    public void EnterLogin()
    {
        //异步加载登录场景
        //并显示加载的进度条
        GameRoot.Instance.loadingWnd.gameObject.SetActive(true);
        GameRoot.Instance.loadingWnd.InitWnd();
        //加载完成以后并显示注册登录界面
        ResSvc.Instance.AsyncLoadScene(Constants.SceneLogin);
    }

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第10张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第11张图片

登录注册界面逻辑

给LoginWnd添加一个LoginWnd脚本,里面进行初始化和更新

public class LoginWnd : MonoBehaviour 
{
    public InputField iptAcct;
    public InputField iptPass;
    public Button btnEnter;
    public Button btnNotice;
    public void InitWnd()
    {
        //获取本地存储的账号密码
        if(PlayerPrefs.HasKey("Acct")&&PlayerPrefs.HasKey("Pass"))
        {
            iptAcct.text = PlayerPrefs.GetString("Acct");
            iptPass.text = PlayerPrefs.GetString("Pass");
        }
        else
        {
            iptAcct.text = "";
            iptPass.text = "";
        }
    }

    //TODO 更新本地存储的账号密码

更新上节课异步加载的方法,让它能够公用

    private Action prgCB = null;
    public void AsyncLoadScene(string sceneName, Action loaded)
    {
        //异步加载登录场景
        //并显示加载的进度条
        GameRoot.Instance.loadingWnd.gameObject.SetActive(true);
        GameRoot.Instance.loadingWnd.InitWnd();

        AsyncOperation sceneAsync = SceneManager.LoadSceneAsync(sceneName);
        prgCB = () => {
            float val = sceneAsync.progress;
            GameRoot.Instance.loadingWnd.SetProgress(val);
            if (val == 1)
            {
                if (loaded != null)
                    loaded();
                //LoginSys.Instance.OpenLoginWnd();
                prgCB = null;
                sceneAsync = null;
                GameRoot.Instance.loadingWnd.gameObject.SetActive(false);
            }
        };
    }

    private void Update()
    {
        if (prgCB != null)
            prgCB();
    }

修改LoginSys中的进入登录界面的和打开登录界面的方法

    /// 
    /// 进入登录场景
    /// 
    public void EnterLogin()
    {
        //异步加载登录场景
        //并显示加载的进度条
        //加载完成以后并显示注册登录界面
        ResSvc.Instance.AsyncLoadScene(Constants.SceneLogin,OpenLoginWnd);
    }

    /// 
    /// 打开登录界面
    /// 
    public void OpenLoginWnd()
    {
        loginWnd.gameObject.SetActive(true);
        loginWnd.InitWnd();
    }

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第12张图片

UI窗口基类

建立一个WindowRoot基类,让LoadingWnd和LoginWnd都继承它,方便管理

public class WindowRoot : MonoBehaviour
{
    public ResSvc resSvc = null;

    public void SetWndState(bool isActive = true)
    {
        if (gameObject.activeSelf != isActive)
            SetActive(gameObject, isActive);
        if (isActive)
            InitWnd();
        else
            ClearWnd();
    }

    protected virtual void InitWnd()
    {
        resSvc = ResSvc.Instance;
    }

    protected virtual void ClearWnd()
    {
        resSvc = null;
    }

    #region Tool Functions
    protected void SetActive(GameObject go, bool isActive = true)
    {
        go.SetActive(isActive);
    }
    protected void SetActive(Transform trans, bool state = true)
    {
        trans.gameObject.SetActive(state);
    }
    protected void SetActive(RectTransform rectTrans, bool state = true)
    {
        rectTrans.gameObject.SetActive(state);
    }
    protected void SetActive(Image img, bool state = true)
    {
        img.transform.gameObject.SetActive(state);
    }
    protected void SetActive(Text txt, bool state = true)
    {
        txt.transform.gameObject.SetActive(state);
    }

    protected void SetText(Text txt, string context = "")
    {
        txt.text = context;
    }
    protected void SetText(Transform trans, int num = 0)
    {
        SetText(trans.GetComponent(), num);
    }
    protected void SetText(Transform trans, string context = "")
    {
        SetText(trans.GetComponent(), context);
    }
    protected void SetText(Text txt, int num = 0)
    {
        SetText(txt, num.ToString());
    }
    #endregion
}

音效播放服务

创建两个空物体BGAudio和UIAudio分别挂上AudioSource组件,新建一个AudioSvc负责声音资源

public class AudioSvc : MonoBehaviour 
{
    public static AudioSvc Instance = null;

    public AudioSource bgAudio;
    public AudioSource uiAudio;

    public void InitSvc()
    {
        Instance = this;
        Debug.Log("Init AudioSvc...");
    }

    public void PlayBGMusic(string name,bool isLoop = true)
    {
        AudioClip audio = ResSvc.Instance.LoadAudio("ResAudio/"+name,true);
        if(bgAudio.clip == null || bgAudio.clip.name !=audio.name)
        {
            bgAudio.clip = audio;
            bgAudio.loop = isLoop;
            bgAudio.Play();
        }
    }

    public void PlayUIMusic(string name)
    {
        AudioClip audio = ResSvc.Instance.LoadAudio("ResAudio/" + name, true);
        uiAudio.clip = audio;
        uiAudio.Play();
    }
}

ResSvc类中添加一个加载声音的方法

    private Dictionary adDic = new Dictionary(); 
    public AudioClip LoadAudio(string path,bool cache = false)
    {
        AudioClip au = null;
        if(!adDic.TryGetValue(path,out au))
        {
            au = Resources.Load(path);
            if (cache)
                adDic.Add(path, au);
        }       
        return au;
    }

添加常量到Constants中

    //音效
    public const string BGlogin = "bgLogin";

在打开登录界面的时候进行声音播放

AudioSvc.Instance.PlayBGMusic(Constants.BGlogin);

业务系统层基类

创建一个业务系统基类SystemRoot,让所有以Sys结尾的文件继承它

public class SystemRoot : MonoBehaviour 
{
    protected ResSvc resSvc;
    protected AudioSvc audioSvc;

    public virtual void InitSys()
    {
        resSvc = ResSvc.Instance;
        audioSvc = AudioSvc.Instance;
    }
}

让登录界面的飞龙可以一直在界面上,需要循环这个动画,在它身上添加一个脚本

public class LoopDragonAni : MonoBehaviour 
{
    private Animation ani;

    private void Awake()
    {
        ani = transform.GetComponent();
    }

    private void Start()
    {
        if (ani != null)
        {
            InvokeRepeating("PlayDragonAni", 0, 20);
        }
    }

    private void PlayDragonAni()
    {
        if (ani != null)
            ani.Play();
    }
}

Tips弹窗显示

给DynamicWnd创建一个同名脚本,用来负责弹窗的显示。另外还支持弹窗动画播放完毕后,关闭显示。

public class DynamicWnd : WindowRoot 
{
    public Animation tipsAni;
    public Text txtTips;

    protected override void InitWnd()
    {
        base.InitWnd();
        SetActive(txtTips, false);//初始关闭text的显示
    }

    public void SetTips(string tips)
    {
        SetActive(txtTips);
        SetText(txtTips, tips);

        AnimationClip clip = tipsAni.GetClip("TipsShowAni");
        tipsAni.Play();
        //延时关闭激活状态
        StartCoroutine(AniPlayDone(clip.length, () =>{
            SetActive(txtTips, false);
        }));

    }

    private IEnumerator AniPlayDone(float sec,Action cb)
    {
        yield return new WaitForSeconds(sec);
        if(cb!=null)
        {
            cb();
        }
    }
}

但存在Bug,如果在GameRoot里连续调用SetTips函数,旧的tips会被新的覆盖。

设置Tips显示队列

新增一个队列来进行Tips的显示,修改之前的DynamicWnd里面的方法

public class DynamicWnd : WindowRoot 
{
    public Animation tipsAni;
    public Text txtTips;
    //定义一个队列来进行tips的队列显示
    private Queue tipsQue = new Queue();

    private bool isTipsShow = false;

    protected override void InitWnd()
    {
        base.InitWnd();
        SetActive(txtTips, false);//初始关闭text的显示
    }

    public void AddTips(string tips)
    {
        lock (tipsQue)
        {
            tipsQue.Enqueue(tips);
        }
    }

    private void Update()
    {
        if(tipsQue.Count>0 && isTipsShow == false)
        {
            lock(tipsQue)
            {
                string tips = tipsQue.Dequeue();
                isTipsShow = true;
                SetTips(tips);
            }
        }
    }


    public void SetTips(string tips)
    {
        SetActive(txtTips);
        SetText(txtTips, tips);

        AnimationClip clip = tipsAni.GetClip("TipsShowAni");
        tipsAni.Play();
        //延时关闭激活状态
        StartCoroutine(AniPlayDone(clip.length, () =>{
            SetActive(txtTips, false);
            isTipsShow = false;
        }));

    }

    private IEnumerator AniPlayDone(float sec,Action cb)
    {
        yield return new WaitForSeconds(sec);
        if(cb!=null)
        {
            cb();
        }
    }
}

在GameRoot里面新增加两个方法,一个是AddTips用来实现tips的添加,一个是ClearUIRoot用来把所有的Wnd都隐藏

    private void ClearUIRoot()
    {
        Transform canvas = transform.Find("Canvas");
        for(int i=0;i

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第13张图片

角色创建界面逻辑

给CreateWnd创建一个同名脚本,用来角色创建。

在LoginSys中添加登录成功的方法

    public void RspLogin()
    {
        GameRoot.AddTips("登录成功");
        //打开角色创建页面
        createWnd.SetWndState();
        //关闭登录页面
        loginWnd.SetWndState(false);
    }

在LoginWnd中更新进入游戏的方法

    /// 
    /// 点击进入游戏
    /// 
    public void ClickEnterBtn()
    {
        audioSvc.PlayUIMusic(Constants.UILoginBtn);
        string acct = iptAcct.text;
        string pass = iptPass.text;
        if(acct!=""&&pass!="")
        {
            //更新本地存储的账号密码
            PlayerPrefs.SetString("Acct", acct);
            PlayerPrefs.SetString("Pass", pass);
            //TODO 发送网络消息,请求登录

            //TO Remove
            LoginSys.Instance.RspLogin();
        }
        else
        {
            GameRoot.AddTips("账号或密码为空");
        }
    }

    public void ClickNoticeBtn()
    {
        audioSvc.PlayUIMusic(Constants.UIClickBtn);
        GameRoot.AddTips("功能正在开发中...");
    }

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第14张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第15张图片

生成随机名字配置文件

首先需要一个xml的模板,如下所示



			
		
		
		
	
			
		
		
		
	

然后在excel里面讲xml导入进去,然后自己添加上合适的信息

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第16张图片

然后进行导出

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第17张图片

打开导出的文件,文件中的内容为



	
		a
		b
		c
	
	
		d
		e
		f
	

解析随机名字配置文件

首先建立一个PathDefine的脚本,用来存储xml文件的地址

public class PathDefine 
{
    #region Configs
    public const string RDNameCfg = "ResCfgs/rdname";
    #endregion
}

然后在ResSvc中写入一个新的方法来解析xml文件

    private List surnameLst = new List();
    private List manLst = new List();
    private List womanLst = new List();
    //解析随机名字的xml文件
    private void InitRDNameCfg()
    {
        TextAsset xml = Resources.Load(PathDefine.RDNameCfg);
        if (!xml)
            Debug.LogError("xml file:" + PathDefine.RDNameCfg + "not exist");
        else
        {
            XmlDocument doc = new XmlDocument();
            doc.LoadXml(xml.text);

            XmlNodeList nodLst = doc.SelectSingleNode("root").ChildNodes;
            for(int i =0;i

完成随机名字生成

首先创建一个PETools工具脚本,这里用来生成一个随机数,用来随机名字

public class PETools
{
    //产生一个随机数
    public static int RDInt(int min,int max,System.Random rd = null)
    {
        if(rd == null)
            rd = new System.Random();
        int val = rd.Next(min, max + 1);
        return val;
    }
}

在ResSvc中添加一个新方法,用来获取一个随机名字

    public string GetRDNameData(bool man = true)
    {
        System.Random rd = new System.Random();
        string rdName = surnameLst[PETools.RDInt(0, surnameLst.Count - 1)];
        if (man)
        {
            rdName += manLst[PETools.RDInt(0, manLst.Count - 1)];
        }
        else
            rdName += womanLst[PETools.RDInt(0, womanLst.Count - 1)];
        return rdName;
    }

在CreateWnd中调用产生随机名字的方法,并且添加按钮点击的方法

    public InputField iptName;
    protected override void InitWnd()
    {
        base.InitWnd();

        //TODO
        //显示一个随机名字
        iptName.text = resSvc.GetRDNameData(false);
    }

    public void ClickRandBtn()
    {
        audioSvc.PlayUIMusic(Constants.UIClickBtn);
        iptName.text = resSvc.GetRDNameData();
    }

    public void ClickEnterBtn()
    {
        audioSvc.PlayUIMusic(Constants.UIClickBtn);
        if (iptName.text != "")
        {
            //TODO
            //发送名字数据到服务器,登录主程
        }
        else
            GameRoot.AddTips("当前名字不符合规范");
    }

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第18张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第19张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第20张图片


第3章: 网络通信与服务器环境配置

服务器开发环境配置

VS2017中需要安装C#平台的支持,我们在windos上使用服务器。在这个项目中,我们使用PESocket开源网络库来实现网络通信。

介绍PESocket开源网络库

下载PESocket:
GitHub地址:https://github.com/PlaneZhong/PESocket
介绍网络库(防止学校访问外网波动)
博客园地址:
https://www.cnblogs.com/planezhong/p/10074676.html

服务器使用PESocket

需要创建和引用的文件如下

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第21张图片

NetMsg.cs一个类库,用来存储发送的信息以及服务器ip地址和接口号


using PENet;
using System;

namespace Protocal
{
    [Serializable]
    public class NetMsg:PEMsg
    {
        public string text;
    }

    public class IPCfg
    {
        public const string srvIP = "127.0.0.1";
        public const int srvPort = 17666;
    }
}

ServerSession.cs用来进行服务器会话控制

using System;
using Protocal;
using PENet;


public class ServerSession:PESession
{
    protected override void OnConnected()
    {
        PETool.LogMsg("Client Connect");
    }

    protected override void OnReciveMsg(NetMsg msg)
    {
        PETool.LogMsg("Client req" + msg.text);
    }

    protected override void OnDisConnected()
    {
        PETool.LogMsg("Client DisConnect");
    }
}

ServerStart.cs用来启动服务器

using Protocal;
using PENet;

namespace Server
{
    class ServerStart
    {
        static void Main(string[] args)
        {
            PESocket server = new PESocket();
            server.StartAsServer(IPCfg.srvIP, IPCfg.srvPort);

            while(true)
            {

            }
            
        }
    }
}

运行后结果如下:

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第22张图片

Unity使用PESocket

Unity创建一个新的项目,然后将PENet.dll和Protocal.dll添加到这个项目中

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第23张图片

ClientSession.cs用来进行客户端会话控制,与服务器端一致

using PENet;
using Protocal;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;


public class ClientSession:PENet.PESession
{
    protected override void OnConnected()
    {
        PETool.LogMsg("Server Connect");
    }

    protected override void OnReciveMsg(NetMsg msg)
    {
        PETool.LogMsg("Server req" + msg.text);
    }

    protected override void OnDisConnected()
    {
        PETool.LogMsg("Server DisConnect");
    }
}

GameStart.cs用来启动客户端,并进行测试

using Protocal;
using UnityEngine;

public class GameStart : MonoBehaviour 
{
    PENet.PESocket client = null;
    private void Start()
    {
        client = new PENet.PESocket();
        client.StartAsClient(IPCfg.srvIP, IPCfg.srvPort);


    }

    private void Update()
    {
        if(Input.GetKeyDown(KeyCode.Space))
        {
            client.session.SendMsg(new NetMsg
            {
                text = "hello unity"
            });
        }
    }
}

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第24张图片

设置日志接口

        //指定一个日志接口,让服务器的错误在Unity的控制台打印出来
        client.SetLog(true,(string msg,int lv) =>
        {
            switch(lv)
            {
                case 0:
                    msg = "Log:" + msg;
                    Debug.Log(msg);
                    break;
                case 1:
                    msg = "Warn:" + msg;
                    Debug.LogWarning(msg);
                    break;
                case 2:
                    msg = "Error:" + msg;
                    Debug.LogError(msg);
                    break;
                case 3:
                    msg = "Info:" + msg;
                    Debug.Log(msg);
                    break;
            }
        });

服务器框架介绍

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第25张图片

服务器启动逻辑

创建一个服务端的C#项目,暂时需要的文件如下

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第26张图片

ServeRoot.cs



/// 
/// 服务器初始化模板
/// 

public class ServerRoot
{
    private static ServerRoot instance = null;
    public static ServerRoot Instance
    {
        get
        {
            if (instance == null)
                instance = new ServerRoot();
            return instance;
        }
    }

    public void Init()
    {
        //数据库TODO

        //服务处
        NetSvc.Instance.Init();

        //业务系统层
        LoginSys.Instance.Init();
    }
}

ServeStart.cs

/// 
/// 服务器入口
/// 

public class ServerStart
{
    static void Main(string[] args)
    {
        ServerRoot.Instance.Init();

        while (true)
        {

        }
    }
}

服务器网络服务

与之前的案例相似,需要创建ServerSession和GameMsg文件。

下面修改NetSvc和LoginSys中的部分方法。

/// 
/// 网络服务
/// 
/// 
using PENet;
using PEProtocol;

public class NetSvc
{
    private static NetSvc instance = null;
    public static NetSvc Instance
    {
        get
        {
            if (instance == null)
                instance = new NetSvc();
            return instance;
        }
    }

    public void Init()
    {
        PESocket server = new PESocket();
        server.StartAsServer(SrvCfg.srvIP, SrvCfg.srvPort);

        PETool.LogMsg("NetSvc Init Done.");
    }
}

using PENet;
/// 
/// 登录业务系统
/// 
public class LoginSys
{
    private static LoginSys instance = null;
    public static LoginSys Instance
    {
        get
        {
            if (instance == null)
                instance = new LoginSys();
            return instance;
        }
    }

    public void Init()
    {
        PETool.LogMsg("LoginSys Init Done.");
    }
}

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第27张图片

客户端网络服务

按照之前的方法添加客户端连接服务,然后在NetSvc中进行初始化

using UnityEngine;
using PENet;
using PEProtocol;

public class NetSvc : MonoBehaviour 
{
    public static NetSvc Instance = null;
    PESocket client = null;
    
    public void InitSvc()
    {
        Instance = this;

        client = new PESocket();
        
        client.SetLog(true, (string msg, int lv) =>
        {
            switch (lv)
            {
                case 0:
                    msg = "Log:" + msg;
                    Debug.Log(msg);
                    break;
                case 1:
                    msg = "Warn:" + msg;
                    Debug.LogWarning(msg);
                    break;
                case 2:
                    msg = "Error:" + msg;
                    Debug.LogError(msg);
                    break;
                case 3:
                    msg = "Info:" + msg;
                    Debug.Log(msg);
                    break;
            }
        });

        client.StartAsClient(SrvCfg.srvIP, SrvCfg.srvPort);
        Debug.Log("Init NetSvc...");
    }
}

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第28张图片

封装通用工具

using PENet;

public enum LogType
{
    Log=0,
    Warn=1,
    Error=2,
    Info=3
}

public class PECommon
{
    public static void Log(string msg="",LogType tp = LogType.Log)
    {
        LogLevel lv = (LogLevel)tp;
        PETool.LogMsg(msg, lv);
    }
}

使用PECommon.Log可以让其他文件不需要另外调用命名空间。

登录协议定制

首先修改服务器的GmaeMsg.cs,让它知道发送过来的消息是什么

    [Serializable]
    public class GameMsg:PEMsg
    {
        public ReqLogin reqLogin;
    }

    [Serializable]
    public class ReqLogin
    {
        public string acct;
        public string pass;
    }


    public enum CMD
    {
        None=0,
        //登录相关 100
        ReqLogin = 101,
        RspLogin = 102,
    }

在客户端的LoginWnd.cs中给服务器发送登录的账号密码

            GameMsg msg = new GameMsg {
                cmd = (int)CMD.ReqLogin,
                reqLogin = new ReqLogin
                {
                    acct = _acct,
                    pass = _pass
                }
            };
            netSvc.SendMsg(msg);

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第29张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第30张图片

登录消息分发处理

在服务器端LoginSys.cs中添加回复登录请求的函数

    //对登消息进行回复
    public void ReqLogin(GameMsg msg)
    {

    }

在NetSvc.cs中增加消息队列,并且添加各种操作方法

    public static readonly string obj = "lock";
    private Queue msgPackQue = new Queue();

    //将收到的消息存到队列中
    public void AddMsgQue(GameMsg msg)
    {   
        lock(obj)
        {
            msgPackQue.Enqueue(msg);
        }      
    }

    //取出消息队列中的消息进行处理
    public void Update()
    {
        if (msgPackQue.Count > 0)
        {
            PECommon.Log("PackCount:" + msgPackQue.Count);
            lock(obj)
            {
                GameMsg msg = msgPackQue.Dequeue();
            }
        }
    }

    private void HandOutMsg(GameMsg msg)
    {
        switch ((CMD)msg.cmd)
        {
            case CMD.ReqLogin:
                LoginSys.Instance.ReqLogin(msg);
                break;
        }
    }

附加会话信息到消息包

为了方便回复消息,把之前的消息队列打包成这两个。

//建立一个消息包,包含msg和session
public class MsgPack
{
    public ServerSession session;
    public GameMsg msg;
    public MsgPack(ServerSession session,GameMsg msg)
    {
        this.session = session;
        this.msg = msg;
    }
}

在LoginSys里面,分析登录验证逻辑和回应客户端

    //对登消息进行回复
    public void ReqLogin(MsgPack pack)
    {
        //当前账号是否已经上线
        //已上线:返回错误信息
        //未上线:
        //账号是否存在
        //存在,检测密码
        //不存在,创建默认的账号密码

        //回应客户端
        GameMsg msg = new GameMsg
        {
            cmd = (int)CMD.ReqLogin,
            rspLogin = new RspLogin { }
        };

        pack.session.SendMsg(msg);
    }

驱动逻辑处理

将NetSvc中的消息处理方法添加到服务器入口ServerStart中的主函数中去,让它一直执行

public class ServerStart
{
    static void Main(string[] args)
    {
        ServerRoot.Instance.Init();

        while (true)
        {
            ServerRoot.Instance.Update();
        }
    }
}

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第31张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第32张图片

增加数据缓存层

新建一个缓存层,里面主要负责服务器端数据检查等功能

using System.Collections.Generic;
/// 
/// 缓存层
/// 
class CacheSvc
{
    private static CacheSvc instance = null;
    public static CacheSvc Instance
    {
        get
        {
            if (instance == null)
                instance = new CacheSvc();
            return instance;
        }
    }

    private Dictionary onLineAcctDic = new Dictionary();

    public void Init()
    {
        PECommon.Log("CacheSvc Init Done.");
    }

    //判断账号是否上线
    public bool IsAcctOnline(string acct)
    {
        return onLineAcctDic.ContainsKey(acct);
    }
}

在LoginSys中继续完成登录验证的方法

    private CacheSvc cacheSvc = null;

    public void Init()
    {
        cacheSvc = CacheSvc.Instance;
        PECommon.Log("LoginSys Init Done.");
    }

    //对登消息进行回复
    public void ReqLogin(MsgPack pack)
    {
        ReqLogin data = pack.msg.reqLogin;
        GameMsg msg = new GameMsg
        {
            cmd = (int)CMD.RspLogin,
            rspLogin = new RspLogin { }
        };

        //当前账号是否已经上线
        if(cacheSvc.IsAcctOnline(data.acct))
        {
            //已上线:返回错误信息
            msg.err = (int)ErrorCode.AcctIsOnline;
        }
        else//未上线:
        {
            //账号是否存在
                //存在,检测密码
                //不存在,创建默认的账号密码
        }

        //回应客户端
        pack.session.SendMsg(msg);
    }

在GameMsg中,设置好错误代码和登录信息

    [Serializable]
    public class RspLogin
    {
        public PlayerData playerData;
    }

    [Serializable]
    public class PlayerData
    {
        public int id;
        public string name;
        public int lv;
        public int exp;
        public int power;
        public int coin;
        public int diamond;
    }

    public enum ErrorCode
    {
        None=0,//没有错误
        AcctIsOnline,//账号已经上线
    }

缓存上线玩家数据

缓冲层CacheSvc中添加两个方法

    //根据账号密码返回对应账号数据,密码错误返回null,账号不存在则默认创建新账号
    public PlayerData GetPlayerData(string acct,string pass)
    {
        //TODO:从数据库中查找账号数据
        return null;
    }

    /// 
    /// 账号上线,缓存数据
    /// 
    public void AcctOnline(string acct,ServerSession serverSession,PlayerData playerData)
    {
        onLineAcctDic.Add(acct, serverSession);
        onLineSessionDic.Add(serverSession, playerData);

    }

完善LoginSys中的登录方法

    //对登消息进行回复
    public void ReqLogin(MsgPack pack)
    {
        ReqLogin data = pack.msg.reqLogin;
        GameMsg msg = new GameMsg
        {
            cmd = (int)CMD.RspLogin
        };

        //当前账号是否已经上线
        if(cacheSvc.IsAcctOnline(data.acct))
        {
            //已上线:返回错误信息
            msg.err = (int)ErrorCode.AcctIsOnline;
        }
        else//未上线:
        {
            //账号是否存在
            PlayerData playerData = cacheSvc.GetPlayerData(data.acct, data.pass);
            if (playerData == null)
            {
                //存在,密码错误
                msg.err = (int)ErrorCode.WrongPass;
            }
            else
            {
                //不存在,创建默认的账号密码
                msg.rspLogin = new RspLogin
                {
                    playerData = playerData
                };

                //将创建的账号密码缓存
                cacheSvc.AcctOnline(data.acct, pack.session, playerData);
            }
        }

        //回应客户端
        pack.session.SendMsg(msg);
    }

客户端消息分发

同服务器端一样,在客户端中处理消息,首先是在NetSvc中

    public static readonly string obj = "lock";
    private Queue msgQue = new Queue();   
     public void AddNetPkg(GameMsg msg)
    {
        lock(obj)
        {
            msgQue.Enqueue(msg);
        }
    }

    private void Update()
    {
        if(msgQue.Count>0)
        {
            lock (obj)
            {
                GameMsg msg = msgQue.Dequeue();
                ProcessMsg(msg);
            }
        }
    }

    private void ProcessMsg(GameMsg msg)
    {
        if (msg.err != (int)ErrorCode.None)
        {
            switch ((ErrorCode)msg.err)
            {
                case ErrorCode.AcctIsOnline:
                    GameRoot.AddTips("当前账号已经上线");
                    break;
                case ErrorCode.WrongPass:
                    GameRoot.AddTips("密码错误");
                    break;
            }
            return;
        }
        switch ((CMD)msg.cmd)
        {
            case CMD.RspLogin:
                LoginSys.Instance.RspLogin(msg);
                break;
        }
    }

修改LoginSys中登录成功的方法

    public void RspLogin(GameMsg msg)
    {
        GameRoot.AddTips("登录成功");

        if(msg.rspLogin.playerData.name == "")
        {
            //打开角色创建页面
            createWnd.SetWndState();
        }
        else
        {
            //进入主城
        }
        //关闭登录页面
        loginWnd.SetWndState(false);
    }

第4章:数据库与服务器缓存层

数据库环境配置

使用MySQL5.6和Navicat for MySQL进行数据库开发和设计。

数据库增删改查

使用C#创建一个控制台应用SqlTest,并且添加引用MySql.Data.dll

使用下面两条语句进行连接数据库

static MySqlConnection conn = null;

conn = new MySqlConnection("server=localhost;port=3306;database=studymysql;user=root;password=;charset = utf8;");
conn.Open();

增加数据的方法

    static void Add()
    {
        MySqlCommand cmd = new MySqlCommand("insert into userinfo set name ='Mai',age='25'", conn);
        cmd.ExecuteNonQuery();
        int id = (int)cmd.LastInsertedId;//获取主键的id号
        Console.WriteLine("Sql Insert Key:{0}",id);
    }

查找数据的方法

    static void Query()
    {
        //MySqlCommand cmd = new MySqlCommand("select * from userinfo", conn);
        MySqlCommand cmd = new MySqlCommand("select * from userinfo where name = 'sou'", conn);

        MySqlDataReader reader = cmd.ExecuteReader();
        while(reader.Read())
        {
            int id = reader.GetInt32("id");
            string name = reader.GetString("name");
            int age = reader.GetInt32("age");

            Console.WriteLine(string.Format("sql result:id:{0} name:{1} age:{2}", id, name, age));
        }
    }

更新和删除数据的方法

    static void Del()
    {
        MySqlCommand cmd = new MySqlCommand("delete from userinfo where id = 1", conn);
        cmd.ExecuteNonQuery();
        Console.WriteLine("delete done.");
    }

    static void Update()
    {
        //MySqlCommand cmd = new MySqlCommand("update userinfo set name = 'Akimoto',age=25 where id = 3", conn);
        MySqlCommand cmd = new MySqlCommand("update userinfo set name = @name,age=@age where id = @id", conn);
        cmd.Parameters.AddWithValue("name", "Hanabi");
        cmd.Parameters.AddWithValue("age", "123");
        cmd.Parameters.AddWithValue("id", "3");

        cmd.ExecuteNonQuery();
        Console.WriteLine("update done.");
    }

增加数据库管理器

首先在数据库中创建一张表account,该表有下列属性

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第33张图片

然后在Server端创建一个DBMgr.cs类,用来进行数据库管理。首先要初始化该管理类,让它链接数据库。添加新的方法,用来验证账号密码是否正确。

/// 
/// 数据库管理类
/// 

using MySql;
using MySql.Data.MySqlClient;
using PEProtocol;

public class DBMgr
{
    private static DBMgr instance = null;
    public static DBMgr Instance
    {
        get
        {
            if (instance == null)
                instance = new DBMgr();
            return instance;
        }
    }

    private MySqlConnection conn = null;

    public void Init()
    {
        conn = new MySqlConnection("server=localhost;port=3306;database=darkgod;user=root;password=;charset = utf8;");
        PECommon.Log("DBMgr Init Done.");
    }

    public PlayerData QueryPlayerData(string acct,string pass)
    {
        PlayerData playerData = null;

        //TODO
        return playerData;
    }
}

在CacheSvc中进行调用验证账号密码的方法。

    //根据账号密码返回对应账号数据,密码错误返回null,账号不存在则默认创建新账号
    public PlayerData GetPlayerData(string acct,string pass)
    {
        //从数据库中查找账号数据
        return dBMgr.QueryPlayerData(acct,pass);
    }

查询玩家数据

完善查询玩家的方法,如果没有该账号密码,系统会自动创建新的账号密码

    public PlayerData QueryPlayerData(string acct,string pass)
    {
        PlayerData playerData = null;
        MySqlDataReader reader = null;
        bool isNew = true;//默认是一个新的账号
        try
        {
            MySqlCommand cmd = new MySqlCommand("select * from account whre acct = @acct", conn);
            cmd.Parameters.AddWithValue("acct", acct);
            reader = cmd.ExecuteReader();
            if (reader.Read())
            {
                isNew = false;
                string _pass = reader.GetString("pass");
                if (_pass.Equals(pass))
                {
                    //密码正确,返回玩家数据
                    playerData = new PlayerData
                    {
                        id = reader.GetInt32("id"),
                        name = reader.GetString("name"),
                        lv = reader.GetInt32("level"),
                        exp = reader.GetInt32("exp"),
                        power = reader.GetInt32("power"),
                        coin = reader.GetInt32("coin"),
                        diamond = reader.GetInt32("diamond")
                    };
                }
            }
        }
        catch(Exception e)
        {
            PECommon.Log("Query PlayerData By Acct&Pass Error:" + e, LogType.Error);
        }
        finally
        {
            if(isNew)
            {
                //不存在账号数据,创建新的默认账号数据,并返回
                playerData = new PlayerData
                {
                    id = -1,
                    name = "",
                    lv = 1,
                    exp=0,
                    power=150,
                    coin = 5000,
                    diamond = 500
                };
                playerData.id = InsertNewAccData(acct,pass, playerData);
            }
        }
        return playerData;
    }

    //插入数据到数据库,并返回id
    private int InsertNewAccData(string acct,string pass,PlayerData pd)
    {

        return 0;
    }

插入默认玩家数据

完善插入数据到数据库并返回id的方法

    //插入数据到数据库,并返回id
    private int InsertNewAccData(string acct,string pass,PlayerData pd)
    {
        int id = -1;
        try
        {
            MySqlCommand cmd = new MySqlCommand("insert into account set acct=@acct,pass =@pass,name=@name,level=@level,exp=@exp,power=@power,coin=@coin,diamond=@diamond", conn);
            cmd.Parameters.AddWithValue("acct", acct);
            cmd.Parameters.AddWithValue("pass", pass);
            cmd.Parameters.AddWithValue("name", pd.name);
            cmd.Parameters.AddWithValue("level", pd.lv);
            cmd.Parameters.AddWithValue("exp", pd.exp);
            cmd.Parameters.AddWithValue("power", pd.power);
            cmd.Parameters.AddWithValue("coin", pd.coin);
            cmd.Parameters.AddWithValue("diamond", pd.diamond);

            cmd.ExecuteNonQuery();
            id = (int)cmd.LastInsertedId;
        }
        catch (Exception e)
        {
            PECommon.Log("Insert PlayerData Error:" + e, LogType.Error);
        }
        return id;
    }

使用一条语句进行测试

QueryPlayerData("123", "123");

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第34张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第35张图片

重命名功能

首先在GameMsg中添加重命名相关的请求和属性

    [Serializable]
    public class GameMsg:PEMsg
    {
        public ReqLogin reqLogin;
        public RspLogin rspLogin;

        public ReqRename reqRename;
        public ReqRename rspRename;
    }

    public class ReqRename
    {
        public string name;
    }
    public class RspRename
    {
        public string name;
    }

        public enum ErrorCode
    {
        None=0,//没有错误
        AcctIsOnline,//账号已经上线
        WrongPass,//密码错误
        NameIsExist,//名字已经存在
    }

    public enum CMD
    {
        None=0,
        //登录相关 100
        ReqLogin = 101,
        RspLogin = 102,

        ReqRename=103,
        RspRename=104,
    }

然后在客户端将名字发送给服务器端

        if (iptName.text != "")
        {
            //发送名字数据到服务器,登录主程
            GameMsg msg = new GameMsg
            {
                cmd = (int)CMD.ReqRename,
                reqRename = new ReqRename
                {
                    name = iptName.text
                }
            };
            netSvc.SendMsg(msg);
        }

服务器端首先接收到消息,并识别是什么类型

            case CMD.ReqRename:
                LoginSys.Instance.ReqName(pack);
                break;

然后在调用LoginSys中的办法进行处理

    //对姓名进行处理
    public void ReqName(MsgPack pack)
    {

    }

缓存更新逻辑

完善对姓名进行处理的办法

    //对姓名进行处理
    public void ReqName(MsgPack pack)
    {
        ReqRename data = pack.msg.reqRename;
        GameMsg msg = new GameMsg
        {
            cmd = (int)CMD.RspRename
        };
        
        //判断名字是否已经存在           
        if(cacheSvc.IsNameExist(data.name))
        {
            //存在:返回错误码
            msg.err = (int)ErrorCode.NameIsExist;
        }
        else
        {
            //不存在:更新缓存,以及数据库,再返回给客户端
            PlayerData playerData = cacheSvc.GetPlayerDataBySession(pack.session);
            playerData.name = data.name;

            if(!cacheSvc.UpdatePlayerData(playerData.id,playerData))
            {
                msg.err = (int)ErrorCode.UpdateDBError;
            }
            else
            {
                msg.rspRename = new RspRename
                {
                    name = data.name
                };
            }
        }
        pack.session.SendMsg(msg);
    }

其中IsNameExist(string name)的方法如下

    //判断名字是否在数据库中存在
    public bool IsNameExist(string name)
    {
        return dBMgr.QueryNameData(name);
    }
    public bool QueryNameData(string name)
    {
        bool exist = false;
        MySqlDataReader reader = null;
        try
        {
            MySqlCommand cmd = new MySqlCommand("select * from account where name = @name", conn);
            cmd.Parameters.AddWithValue("name", name);
            reader = cmd.ExecuteReader();
            if (reader.Read())
                exist = true;
        }
        catch(Exception e)
        {
            PECommon.Log("Query Name State Error:" + e, LogType.Error);
        }
        finally
        {
            if (reader != null)
                reader.Close();
        }
        return exist;
    }

还需要对数据库的名字进行更新,函数是UpdatePlayerData(int id, PlayerData playerData)

    public bool UpdatePlayerData(int id,PlayerData playerData)
    {     
        return dBMgr.UpdatePlayerData(id,playerData);
    }
    //更新数据库
    public bool UpdatePlayerData(int id,PlayerData playerData)
    {
        try
        {
            MySqlCommand cmd = new MySqlCommand(
            "update account set name=@name,level=@level,exp=@exp,power=@power,coin=@coin,diamond=@diamond where id =@id", conn);
            cmd.Parameters.AddWithValue("id", id);
            cmd.Parameters.AddWithValue("name", playerData.name);
            cmd.Parameters.AddWithValue("level", playerData.lv);
            cmd.Parameters.AddWithValue("exp", playerData.exp);
            cmd.Parameters.AddWithValue("power", playerData.power);
            cmd.Parameters.AddWithValue("coin", playerData.coin);
            cmd.Parameters.AddWithValue("diamond", playerData.diamond);

            //TOADD Others
            cmd.ExecuteNonQuery();

        }
        catch (Exception e)
        {
            PECommon.Log("Update PlayerData Error:" + e, LogType.Error);
            return false;
        }

        return true;
    }

然后在客户端进行操作,首先要接收来自服务器端的消息

            case CMD.RspRename:
                LoginSys.Instance.RspRename(msg);
                break;

GameRoot里面添加一个设置名字的方法

    public void SetPlayerName(string name)
    {
        playerData.name = name;
    }

LoginSys.cs中添加RspRename()处理更换名字消息的方法


    public void RspRename(GameMsg msg)
    {
        GameRoot.Instance.SetPlayerName(msg.rspRename.name);

        //TODO
        //跳转场景进入主城
        //打开主城的界面

        //关闭创建页面
        createWnd.SetWndState(false);
    }

运行服务器端和客户端

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第36张图片

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第37张图片

数据库中的显示如下

从0开始的网游ARPG实战案例:暗黑战神(第一章至第四章:设计登陆和创建角色功能实现)_第38张图片

数据表备份与错误码处理

                case ErrorCode.UpdateDBError:
                    PECommon.Log("数据库更新异常", LogType.Error);
                    //不能将数据库异常显示给用户
                    GameRoot.AddTips("网络不稳定");
                    break;

 

你可能感兴趣的:(Unity3D,ARPG实战)