《极世界》MMORPG游戏制作

《极世界》MMORPG

目录

[跳转到5.5移动同步三](#5.5 移动同步三(完善同步))

[跳转到5.6 地图传送](#5.6 地图传送)

[跳转到6 基础系统](#6 基础系统)

[6.1 UI系统框架设计](#6.1 UI系统框架设计)

[6.1.1 小BUG解决](#6.1.1 小BUG解决)

[6.1.2 主UI](#6.1.2 主UI)

[6.2 NPC系统](#6.2 NPC系统)

[6.2.1 了解系统](#6.2.1 了解系统)

[6.2.2 要点:](#6.2.2 要点:)

6.2.3配置表

6.2.4NPC的制作

6.2.5NPC状态机

[6.3 道具系统](#6.3 道具系统)

[6.3.1 本节要点](#6.3.1 本节要点)

[6.3.2 配置表](#6.3.2 配置表)

[6.3.3 协议](#6.3.3 协议)

[6.3.4 DB(数据库)](#6.3.4 DB(数据库))

[6.3.5 管理器](#6.3.5 管理器)

[6.4 背包系统](#6.4 背包系统)

[6.4.1 本节要点](#6.4.1 本节要点)

[6.4.2 背包UI制作](#6.4.2 背包UI制作)

[6.4.3 配置表](#6.4.3 配置表)

[6.4.4 背包服务](#6.4.4 背包服务)

[6.4.5 背包逻辑(客户端)](#6.4.5 背包逻辑(客户端))

[6.5 商店系统](#6.5 商店系统)

6.5.1配置表

6.5.2配置表读取逻辑

[6.5.3 UI制作](#6.5.3 UI制作)

[6.5.4 商店制作客户端](6.5.4 商店制作客户端)

[6.5.5 商店制作服务端](#6.5.5 商店制作服务端)

[6.5.5.1 状态同步](#6.5.5.1 状态同步)

[6.5.5.2 底层修改](#6.5.5.2 底层修改)

[6.5.6 状态管理器](#6.5.6 状态管理器)

[6.6 装备系统](#6.6 装备系统)


前面的忘记记笔记了,就直接从5.3移动同步开始,

同步Gitee代码链接: MMORPG游戏的学习

博客园地址:《极世界》MMORPG游戏制作 - 叱咤月海鱼鱼猫 - 博客园

5. 底层基础支撑(二)

5.5 移动同步三(完善同步)

  1. Bug的定位与修复
  2. MonoBehaviour单例的使用
  3. Entity的位置同步逻辑

BUG1:重复进入主城会创建多个角色(一个端或者多端都会出的BUG)

​ 在进入游戏后退出游戏,重新选择角色进入,会创建多个角色。(如图)

image-20250704194140673

但是此时的客户端和服务端并没有报错。

image-20250704194349174

image-20250704194330255

那么此时可以确定是逻辑上的bug,依照现状来看并不是客户端的问题暂时大于服务端,那从客户端开始寻找bug。(每次寻找bug时应尽可能保证自己的环境干净,即没有过多的冗余数据,这里指服务端。)现在确定是要找客户端的bug,那么从这个bug的现象先找起,既然是角色创建多了,那么就从创建角色找起,至少从客户端的表现来看,创建角色的代码执行了多次。

而创建角色只有CreateCharacterObject一个位置进行了实例化,在这里打上断点。

image-20250704200249779

那么还需要查看有哪些地方调用了实例化函数。

image-20250704200350423

如上图有两个地方调用了该实例化函数,并全部打上断点。查看初始化函数在哪里进行了调用,查看后只有一个地方进行了调用,并同样打上断点。

image-20250704201335940

在查看另一个OnCharacterEnter有几个引用

image-20250704202620477

在这里我们看到这里不再是调用,而是事件,继续调查,发现只有一个位置进行了调用,同样打上断点。

image-20250704202908438

下好断点之后我们先正常进入,先看看正常执行逻辑是怎么样的。

image-20250704204702155

在我们选择好角色点击进入游戏的瞬间,GameObjectManager的OnStart开始初始化(进行角色创建),而这个是绑定在我们主城的,主城在加载之后才会进行初始化,至此,逻辑已经清晰:角色登录,选择完角色之后,就已经加入到了chatmanager里面了,但是这个时候我们的GameObjectManager还没有进行创建,所以我们在它的start里面做了一个初始化来补上已经存在的角色创建出来,这是第一次角色创建。

image-20250704204742803

退出游戏,再次进入,再次走逻辑,发现AddCharacter事件走了两次,通知了两次事件!

image-20250704210953227

image-20250704211456582

而这里我们也发现了服务器发挥了不止一个角色信息,并且这些信息都一样!

image-20250704211529503

image-20250704211538723

那么此时,BUG就转移到了服务器上。


这里我并没有遇到老师所遇到的会创建多个角色管理器的情况,这个与NetClient有关,NetClient为单例,所有的初始化都放在了这个单例当中,我们的游戏进行任意的初始化是没有任何问题的,但问题也出在这儿,我们将这个案例也绑定到了主城,我们的主城是随时可能会进入的,任何游戏对象做到场景当中场景只要被加载,就一定会创建一个新实例,这时单例就有了问题。(这也就是老师之前所说不能用单例,或者单例只初始化一次)回到我们的单例,查看代码,这时问题就很显然,这是一份单例,在销毁的时候不能销毁所以我们有DontDestoryimage-20250705141656297

在这里设置了这个对象并不销毁,但是我们没有办法阻止它进行创建,每次场景加载都会创建一份新的。那只需要将后创建多余的单例删除即可。这里的global是一个可选属性,设置为global,它才是一个全局单例,否则的话它只是一个场景单例(场景单例:场景消失便消失),如果只是在一个场景中,我们这个单例没有问题,但是这里用的是全局,就需要做出改变了。这里在进行之前进行判断,判断单例是否为空,是否为当前对象,如果是则进行销毁。而在单例没有创建之前,则为空,直接将自己设为单例。

image-20250705142904108


那么服务端为什么会多发回来那么多角色信息?首先猜测是退出的时候服务器并没有没有删除退出的角色信息,去UserService下断点

image-20250705154816997

现在CharacterManager.Instance.RemoveCharacter(character.Id);下断点,这里是客户端点击离开后便第一时间触发的。(毕竟能留下多个角色信息只能是用户离开时没有删除信息,所以首先要想到在用户离开时下断点)进行第一次调试:

image-20250705155326627

image-20250705155437701

决策树所对应的ID是正确的,继续向下调试:

image-20250705155549199

image-20250705155918631

MapEntity已经删除掉了角色信息,而离开的时候角色信息也正常全部删除了,那么猜测错误,此时需要考虑进入时发的响应部分是否有问题,并将角色进入地图的时候也下断点。

image-20250705161043411

image-20250705161200539

进行调试:

image-20250705161557635

两种ID都没有问题,但是在下面的MapCharacters中已经有了一个角色信息,此时应当是没有任何信息才对,果断于此处打上断点

image-20250705161641159

这里是登陆时返回给客户端的地方,这里遍历的是整个集合,会将当前地图中的所有玩家反馈给客户端,所以说如果出现多个信息,这里最有可能出BUG,并同时将移除的地方也打上断点。(漏删,多加都是可能的原因,同样的,客户端也会发生漏删、多加的原因,而这种BUG一般出现在角色进入和离开的时候,)

image-20250705162118546

再次进行调试:角色ID与NTTID都是1

image-20250705172029997

继续向下,退出角色后查看数据,服务端NTT数据已经全部删掉(角色管理器中的数据已经全部删掉)

image-20250705172550784

继续向下,发现Remove中的Cha的ID成了4!这里的ID应当是1这里一定要警觉,这里应当是问题的根源!

image-20250705173051788

在查看详细数据,确定无疑,这里的ID应当为1才对!这个4从哪里来呢?这个BUG便在这里找到了,服务器在进行移除的时候没有移除掉,原因是角色ID变成了4

image-20250705173254463

那么查看角色ID的所有引用:

image-20250705173527851

首先查看第一个ID,第一个位置是在构造的地方,cha的ID,cha是DBID,也就是DB中的ID给到了第一个ID。

image-20250705173600810

再查看下一个ID,是NCharacter的id,

image-20250705173728663

再查看下一个,这个ID是登录时候的ID,用的是c的ID,c也是Character,即DBID进行的赋值。

image-20250705173806561

在看最后一个,不出所料,也是DBID,这么多引用都是用的DBID。

image-20250705173916963

那此时问题很明显,为什么服务端ID和客户端ID不统一呢?原因是客户端ID我们用的是NChatacterID,因为DBID不能用在所有的游戏对象上,同步移动的ID要使用NCTID,不能使用DBID,怪物没有DBID,只有玩家才有DBID。那么这个BUG的原因也很清晰了,是因为我们客户端和服务端用了不同的ID导致的,那么问题找到了,如何修这个BUG?很简单,将服务端的所有ID引用位置进行调整,调整为NCTID。

要改的地方有几个呢?从角色创建(第一次反馈角色ID)、角色进入离开的时候,这些个位置修改。

首先先找到角色创建的时候,这里的ID应当改为0,因为我们还没有创建角色。(这里的ID一定不能搞混,NCharacterIDInfo、TCharacterInfo、CharacterInfo三个类型。)并将原CID移动到Tid,并添加用户字段(只有角色会存在DB当中。)

image-20250705174653668

image-20250705175410481

这个时候只是从服务端将角色列表拉了回来,还没有正式进入游戏,所以还没有创建NEntity,这个阶段ID显示为0,但是这个ID会填充到Characters里面(内存中),这是第一个关键的位置。


然后来看角色登录时候的ID,实际的userID应当等于user表里面的,在这里赋值一下,并强制转换一下(int),下面的与刚才修改一致,除第一个ID是c.ID外。

image-20250705180636853


然后去Map看需要修改的ID,这里我们再之前调试的时候就知道了是角色移除的时候ID不统一,我们先看看移除时的字典所引用的值是什么。

image-20250705180938448

填进去的时候,用的是CharacterID,但移除的时候用的是DBID

image-20250705181057495

而这里还有另外一个BUG,再我们离开游戏的时候,我们自己的服务器不会收到信息,所以我们需要对顺序进行调整,将Remove移动到后面。

image-20250705181244659

随后我们为了保证再移除的时候用的是NCID,这里我们将类型直接改为Character

image-20250705181654719

并改一下相应的接口,我们不用NCInfoID了,我们直接用实体ID(Character)

image-20250705181726500

那么这里就解决了一个ID是4,一个ID是1,4是因为NCID里面是赋DBID的值,但是EntityID是唯一创建的,再EntityManager的时候进行创建的。

image-20250705182130440

运行一下查看是否有报错,确实是有报错:

image-20250705182241614

直接使用character就好,在Session当中,放的是entity,直接用entity对象就好,不要用原来的NCharacter就好了。

image-20250705182458148

至此,服务端的bug应该是修改完成,服务端是否有bug,还需要再检查,先启动一下查看退出的时候是否能成功删除。

key是1,entityID也是1,info里面是4,没问题。

image-20250705183006160

image-20250705183023182

image-20250705183124801

客户端第一次初始化:

image-20250705183332204

第一次创建角色,里面应当有一个角色,base里面entityID是1,info里面是4,没有问题。

image-20250705183349451

image-20250705183436841

image-20250705183529799

至此第一次执行没有问题,接下来退出角色,先查看服务端收到的信息,角色dataID是4,entityID是1,id也是1,没有问题。

image-20250705183643933

image-20250705183740973

image-20250705183802362

这里cha的ID也是1,没问题,这里直接给下面Remove补断点,并继续向下调试。

image-20250705184006105

接下来看SendCharacterLeaveMap,角色ID也是1,Send也没有问题。

image-20250705185211716

接下来转到服务端,服务端ID也为1,没有问题,继续向下调试

image-20250705185942798

这里发现服务端里面Characters里面的Key是4,完了,客户端有一个位置漏掉了,现继续向下,服务端MapCharacter已经全部删除,服务端BUG解决。

image-20250705185912152

image-20250705190910483

现在得知是服务端错了,那么一定是Add的时候错了,所以我们需要在客户端Add的位置找一下是否有漏洞,检查后得知,客户端这边Add的时候是CharacterId,Remove时的ID和Add时的ID不一致

image-20250705191214852

image-20250705191340777

先看一下Add的引用位置,这里也有一点小问题,这里我们为了标记是不是本人,使用了这么一个ID,但是如果这里的ID不一样了,这里可以直接将这个赋空掉。

image-20250705191507649

上次地图出了问题,就是再角色离开的时候直接将地图ID清零,我们这里也直接将角色替换也直接赋空。在选择角色时无法获得EntityID,但是在同步的时候,又必须需要EntityID。在角色选择列表无法获得EntityID,所以这里直接将它清掉。

image-20250705192122158

回到Add,单纯的判断ID相同已经不行了,还需要判断角色选择列表是否为空,即第一次登录是空,从列表返回也是空,这种情况下,进行一个角色的切换。

image-20250705192850944

再次进行第二次调试,这里ID是1,

image-20250705193149052

而SendLeave这儿ID也是1,而我们要往客户端发送的消息,ID也是1,没有问题。

image-20250705193653684

image-20250705193933732

此时,我们还需要解决最后一个问题,是服务端发错了信息,还是客户端自己错了,服务端已经成功删除了,是否是客户端自己没有删除,重新启动一次,这里看到Info的ID是4,问题来了,这里传的数据character.Info是假,所以还是服务器的问题,服务器的数据发错了,那么这里EntityID的值与InfoID应当统一起来,直接去EntityManager,因为它负责ID生成。

image-20250705194429622

这里的Info是在哪儿产生的?是在构造函数产生的,在赋值的时候已经默认赋值成了默认的角色ID,当它在AddCharacter时才变成了一个EntityID,所以这里将entityid赋值给Infoid

image-20250705194824775

image-20250705195023277

image-20250705195250179

那么这里ID会成为4的原因是EntityID有了值之后,并没有告诉网络层EntityID已经变了,所以在EntityID变了第一时间,就应当通知Info,它变了,再次重启进行调试:这里可以看到InfoID已经变成1了。

image-20250705195920907

继续下调至客户端,角色是小娜娜,key值是1

image-20250705200104186

image-20250705200137436

继续向下运行,遇到了新的报错,MinMap报错了,因为单例是空的,这里涉及到脚本执行前后的一个问题,场景在加载的时候Min Map就加载了,但是这时单例还没有创建,所以它一定会报错,解决方法是不要在这里进行初始化。

image-20250705200646567

将这个方法放到下面

image-20250705201128987

将判断放到Manager中,并加以判断

image-20250705201941272

而再次启动时,会出选摄像机跟随不到角色的bug,这是因为在单例创建的时候角色还没有创建,摄像机的初始化晚于角色初始化,所以没能跟随到角色。

image-20250705201913834

打上断点,并查看一下相关的所有引用,此时我们发现了一个老朋友ID不统一,当然只是猜测,毫不犹豫打上断点!当前角色并不是所谓的那个当前角色,那么转到当前角色判断的脚本。

image-20250705202415456

image-20250705202356992

这里发现选择角色的时候赋的值是不对的,一开始选择角色的时候因为没有服务端,是纯客户端用的,使得我们能够在场景中进行角色选择,这个值的ID一定是4,因为这里赋过值,别的地方又没有赋过值,所以一直是4,这里直接删掉。

image-20250705202709167


移动完善,每次移动的时候都会发现角色移动有问题,这里是因为Entity脚本少一次同步,那么这里加一个方法(其实就是将原有的脚本放到了一个方法中)。

image-20250705205155846

image-20250705205222451

全部修改完之后进行打包,进行最后的移动同步测试。

image-20250705210423561

没有问题!


5.6 地图传送

本节要点:1.创建传送点2.传送点的配置3.编辑器扩展:传送点数据生成4.传送协议与实现


1. 增加传送门

先进入主城场景中

image-20250706193740935

既然是要增加传送门,首先要考虑的肯定是要知道用户是进入传送门,那么肯定是用碰撞器来实现,先增加一个MapRoot的空节点。

image-20250706194030901

在节点下方创建一个柱体,并适当调整其位置使其能够处于传送门前,并记得选上Is Trriger(不会碰撞)。

image-20250706194427242

调整碰撞器的大小,使得角色能够很好的触发触碰器。

image-20250706194632544

那么此时就有一个新的问题,玩家要去新的地图是从这个碰撞要去别的地图,而当玩家回来的时候是否也是这个碰撞,但如果还是这个碰撞,另一张地图又应该怎么制作碰撞,制作逻辑会变得复杂,这里我们可以再新加一个区域,用来当作传入位置。(很多游戏每次进入地图都会有一个固定的位置,而这个就可以当作是类似的道理。)

image-20250706195301629

将这两个传送点复制一份到另一个门,做另一个门的传送点。

image-20250706195423784

那么现在游戏对象有了,就可以增加游戏脚本了,给传送对象增加一个脚本,既然是游戏对象,那我们的脚本(传送点TeleporterObject)也加在对应的游戏对象文件夹下(Game Object)下。

image-20250706195907897

并给我们四个传送点都拖一份脚本上去。

image-20250706200001671

可是这就有一个问题,我们有四个传送点,怎样才能知道那个是哪个呢,用ID。而在场景中的四个传送点是白色的,在进入游戏后也是会显示出来的,但如果取消勾选Mesh Renderer,则会使的场景中的传送点消失,这时我们想要选取又需要再次勾选,就显得极为繁琐,此时便可以再脚本上做些事情,即通过脚本让它能够再编辑器模式下还能绘制出来,这就是编辑器的扩展式(自己写的代码,能够让它显示出来)并加一个宏。

image-20250706200336115

这个宏的意思是旨在编译器下面有效,如果写这种不加宏在编译器下生效的逻辑,在编译打包的时候在运行平台上会编译,不过它会报错。这边选择绿色作为绘制的颜色(用Gizmos.color来绘制颜色),这样可以知道它绘制出来了。

#if UNITY_EDITOR
    private void OnDrawGizmos()
    {
        // 在编辑器中绘制传送门的标识
        Gizmos.color = Color.green;
	}
#endif

image-20250709170130040

此外这里希望能够绘制成网格(线框)的样子,所以需要将Mesh加上(Mesh mesh = null;),这些工具可以方便我们在开发时调试一些细节(比如方便找到它在哪儿,而不是隐藏起来),所以不是要Mesh隐藏起来,而是用自己的方式显示出来。

if(this.mesh!=null)
{
    Gizmos.DrawWireMesh(this.mesh, this.transform.position + Vector3.up * this.transform.localScale.y * .5f, this.transform.rotation, this.transform.localScale);
}

接下来为传送门添加小箭头,因为传送门都是有方向的,当我们传送过来的时候,我们是希望角色能有一个默认的朝向的。

UnityEditor.Handles.color = Color.red;
UnityEditor.Handles.ArrowHandleCap(0, this.transform.position, this.transform.rotation, 1f, EventType.Repaint);

然后去给mesh赋值。

this.mesh = this.GetComponent<MeshFilter>().sharedMesh;

去看一下绘制的效果,可以看到多出了两个小箭头。

image-20250709173725970

调整两个传入的方向,使得它们正向面对城内,而不是面对大门。

image-20250709174013737

并且此时便可以去掉Mesh,将所有的传送门法阵全部都删掉。

image-20250709174123321

image-20250709174231863

为传送门进行手动编号,进行编号的原因是为了在后续配置表中配置传送点时方便使用。找到传送点配置表,查看已经预先配置的好的传送点。

image-20250709174608197

如上表,传送点暂时定义了八个,主城用了四个传送点,1、2号传出,3、4号是传入,5号是野外到主城的传入,6号是野外的传入点,7号是副本到主城的传送点,8号是副本里面的传送点。1号点可以传送到6号,2号可以传送到8号,即1号是野外传送点,2号是副本传送点,这样就可以通过这个表格来任意链接两个点(从那个点进入,从那个点出来),这样就完成了一个基础传送点的定义。将传送门用到的特效添加到到两个传出传送门上,并调整到合适的大小,然后将做好的传送节点保存成Prefab(这里传送门的特效在没有选中的情况下是不会显示出来的,只有在选中的时候才会显示,而传入的传送门不需要加特效,只需要让角色进入的时候知道在这里就行了)。

image-20250709181200214


那么这张地图就已经ok了,去另一张地图Map01(野外),并像之前主城一样为野外制作传送门,并保存为Prefab,脚本和ID也都记得加上。

image-20250709182525626

image-20250709182533201

image-20250709182539530

image-20250709182545203

下面为传送门添加触发器,忘记方法的话可以去[Unity官方文档](Unity - Scripting API: MonoBehaviour)查找自己想要的信息。(记得勾选IsTigger)

image-20250709191723585

先拉个表,从表中获取传送点的信息,那么读取到数据之后进行判断,并将信息传递到服务端。

public void OnTriggerEnter(Collider other)
{
    // 检测是否是玩家进入传送门
    PlayerInputController playerController = other.GetComponent<PlayerInputController>();
    if(playerController != null && playerController.isActiveAndEnabled)
    {
        // 拉个表,从表里获取传送点的定义
        // 这里要知道这个ID对应的是那个传送点,从数据管理器中获取
        TeleporterDefine td = DataManager.Instance.Teleporters[this.ID];
        if (td == null)
        {
            Debug.LogErrorFormat("TeleporterObject: Character [{0}] Enter Telepoter [{1}] ,But TelepoterDefine not existed", playerController.character.Info.Name, this.ID);
            return;
        }
        // 传送点的ID和名称
        Debug.LogFormat("TeleporterObject: Character [{0}] Enter Telepoter [{1}:{2}] ", playerController.character.Info.Name, td.ID, td.Name);
        if(td.LinkTo > 0)
        {
            // 判断LinkId是否存在
            if (DataManager.Instance.Teleporters.ContainsKey(td.LinkTo))
            {
                // 告诉地图要进行传送,这里只需要传送点的ID,因为在地图这个方法中会做数据的填充和校验
                MapService.Instance.SendMapTelepoter(this.ID);
            }
            else
            {
                Debug.LogErrorFormat("Telepoter ID ;{0} LinkID {1} error!", td.ID, td.LinkTo);
            }
        }
    }
}

传递过来要传递地图的ID,然后将ID传递给协议,(为什么传一个ID就可以了?因为服务器也会读取这张表,一来是为了安全,而来是为了节省传输量。)并最后发送给客户端。

internal void SendMapTelepoter(int telepoterID)
{
    Debug.LogFormat("MapTelepoterRequest: telepoterID:{0}", telepoterID);
    NetMessage message = new NetMessage();
    message.Request = new NetMessageRequest();
    message.Request.mapTeleport = new MapTeleportRequest();
    message.Request.mapTeleport.teleporterId = telepoterID;
    NetClient.Instance.SendMessage(message);
}

注:在制作的时候要记得多保存,默认情况并不会进行保存,需要手动进行保存。

已经完成了客户端传送地图的部分,接下来就应该转到服务端进行传送地图信息的接收。

public MapService()
{
    //MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<MapCharacterEnterRequest>(this.OnMapCharacterEnter);
    MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<MapEntitySyncRequest>(this.OnMapEntitySync);


    MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<MapTeleportRequest>(this.OnMapTeleport);
}

void OnMapTeleport(NetConnection<NetSession> sender, MapTeleportRequest request)
{
    // 获取当前角色,谁在请求传送
    Character character = sender.Session.Character;
    Log.InfoFormat("OnMapTeleport: characterID:{0}:{1} TeleporterID:{2}", character.Id, character.Data, request.teleporterId);

    // 传送点对不对,存不存在
    if (!DataManager.Instance.Teleporters.ContainsKey(request.teleporterId))
    {
        Log.InfoFormat("Source TeleporterID [{0}] not existed", request.teleporterId);
        return;
    }
    // 读取传送点信息
    TeleporterDefine source = DataManager.Instance.Teleporters[request.teleporterId];
    // 判断表中传送点的LinkTo是否存在
    if (source.LinkTo == 0 || !DataManager.Instance.Teleporters.ContainsKey(source.LinkTo))
    {
        Log.InfoFormat("Source TeleporterID [{0}] LinkTo ID [{1}] not existed", request.teleporterId, source.LinkTo);
    }
    // 获取目标传送点
    TeleporterDefine target = DataManager.Instance.Teleporters[source.LinkTo];
    // 先离开当前地图
    MapManager.Instance[source.MapID].CharacterLeave(character);
    character.Position = target.Position;
    character.Direction = target.Direction;
    // 进入目标地图
    MapManager.Instance[target.MapID].CharacterEnter(sender, character);
}

此时双端的传送已经做完,但是这时会发现配置表中并没有任何的位置信息,来到Data文件夹,这里是游戏相关数据的配置管理。

image-20250709210059494

先看一下传送点的数据:

image-20250709210123943

这些个数据怎么来的?通过上个文件夹中的Cmd生成的

image-20250709210200908

但这些里面都不包含位置,上面的这些是传送规则,然后查看一下Data文件夹:

image-20250710181448332

可以发现是有位置的,这是为什么呢?这就是将要写的编译器,在编译器中追加一个菜单项,让菜单项可以直接生成传送点。


要做自定义的编译器,先新建一个Editor的文件夹,只有这种目录下的编译器才不会影响游戏的运行。

image-20250710181803656

新建一个脚本MapTools,并在其中编写编译器代码。在做扩展功能的时候要写一个静态方法,所以一定哟啊先声明是静态方法,在写完脚本之后就可以回到unity去查看。

image-20250710184159271

在编译前希望它能够获取到主城四个传送点的位置信息,并保存到配置表中,那接下来写这个脚本:

public class MapTools 
{
    // 定义Unity的菜单项,Tools为1级菜单,Export Teleporters为2级菜单
    [MenuItem("Map Tools/Export Teleporters")]
    // 静态方法,做扩展功能要先声明是静态方法
    public static void ExportTeleporters()
    {
        // 先获取到DataManager,先加载到内存当中,原配置表规则先加载进来
        DataManager.Instance.Load();

        // 获取当前场景
        Scene current = EditorSceneManager.GetActiveScene();
        // 将当前场景的地图名称存储到变量中
        string currentScene = current.name;
        // 检查当前场景是否有未保存的更改
        if (current.isDirty)
        {
            EditorUtility.DisplayDialog("提示", "请先保存当前场景", "确定");
            return;
        }
        // 
        List<TeleporterObject> allTeleporters = new List<TeleporterObject>();
        // 遍历所有地图
        foreach (var map in DataManager.Instance.Maps)
        {
            // 先获取每张地图的初始场景文件路径
            string sceneFile = "Assets/Levels/" + map.Value.Resource + ".unity";
            // 检查场景文件是否存在
            if (!System.IO.File.Exists(sceneFile))
            {
                Debug.LogWarningFormat("Scene {0} not existed!", sceneFile);
                continue;
            }
            // 打开场景文件
            EditorSceneManager.OpenScene(sceneFile, OpenSceneMode.Single);
            // 获取当前场景中的所有 TeleporterObject,这样就找到了地图中所有的传送点
            TeleporterObject[] teleporters = GameObject.FindObjectsOfType<TeleporterObject>();
            // 遍历所有的传送点,将它们添加到列表中
            foreach (var teleport in teleporters)
            {
                // 检查传送点的ID是否已经存在
                if (!DataManager.Instance.Teleporters.ContainsKey(teleport.ID))
                {
                    EditorUtility.DisplayDialog("错误", string.Format("地图:{0} 中配置的 Teleporter:[{1}] 中不存在", map.Value.Resource, teleport.ID), "确定");
                    return;
                }

                TeleporterDefine def = DataManager.Instance.Teleporters[teleport.ID];
                // 检查传送点的MapID是否和当前地图的ID一致
                if (def.MapID != map.Value.ID)
                {
                    EditorUtility.DisplayDialog("错误", string.Format("地图:{0} 中配置的 Telepoter:[{1}] MapID:{2} 错误", map.Value.Resource, teleport.ID, def.MapID), "确定");
                    return;
                }
                // 从世界坐标转换为逻辑坐标
                def.Position = GameObjectTool.WorldToLogicN(teleport.transform.position);
                def.Direction = GameObjectTool.WorldToLogicN(teleport.transform.forward);
            }
        }
        // 保存所有传送点到DataManager
        DataManager.Instance.SaveTeleporters();
        EditorSceneManager.OpenScene("Assets/Levels/" + currentScene + ".unity");
        EditorUtility.DisplayDialog("提示", "传送点导出完成!", "确定");
    }
}

image-20250710192156562

这里可以看在DataManager中已经写好了SaveTeleporters()方法,并且也是一个在编译器中执行的代码,点击一下查看配置文件是否发生了改变。点击前:

image-20250710192552182

这里会遇到报错,原因是因为之前在DataManager中注释掉了两句代码,报错:

image-20250710192823362

报错代码行:

image-20250710192842943

只需要去Data Manager中将注释掉的那两行代码解放即可。

image-20250710192931354

然后回到unity点击我们刚写好的扩展内容,就会弹出传送点到处完成的字样:

image-20250710193029045

而这里的弹出的对话框就是我们之前写的EditorUtility那行代码:EditorUtility.DisplayDialog("提示", "传送点导出完成!", "确定");类似的之前所写的也都是对话框代码,内容简单,(标题,内容,确定按钮)。然后回头看我们的TeleporterDefine文件,传送点的位置已经有了:

image-20250710193400294

现在这个数据有了,就要给服务端复制一份过去,因为服务端也要用(一定要记得手动复制,这里并没有写直接复制过来的代码)。

image-20250710193626644


既然都弄好了,那就马上进行测试,所有系统全部启动!服务端已启动:

image-20250710193745953

回到Unity启动游戏

image-20250710194438781


小BUg

这里本人遇到了一个小BUG,那就是角色在碰撞到之后没有任何反应,即应当触发的碰撞函数没有触发,甚至没有任何消息,这里本人也是一脸懵逼。例如:当这里走到传入点3时应当有消息发出,这里是传送点3什么的,但是什么都没有:

image-20250710202613505

继续往前走,去传出传送点1,也是没有任何反应,也没有信息发出(正常情况应当是直接传送到另一张地图(Map01)的,但是这里就一直呆在原地,甚至我都走到传送门脸上了都没有反应:

image-20250710202732766

这里找了朋友帮忙,朋友也是很快就发现了问题所在,是碰撞矩阵的问题:

image-20250710202850367

image-20250710202925926

就是圈住的这个东西,据朋友所说,是因为Default和Default的碰撞没有勾选,导致的,勾选上之后角色就可以正常碰撞了。

image-20250710203041676

之后再次运行,正常有碰撞信息,也能传送到另一张地图了。

image-20250710203158030

image-20250710203208744

随后便马上去了解了一下这个碰撞矩阵:

基于层的 collision 检测是一种使一个 GameObject 与另一个设置在特定层或多个层上的 GameObject 发生碰撞的方式。在图片中,层碰撞矩阵设置成只有属于同一层的 GameObject 才能发生碰撞:

image-20250710210415772

  • Layer 1 is checked for Layer 1 only
    层 1 仅与层 1 碰撞

  • Layer 2 is checked for Layer 2 only
    层 2 仅与层 2 碰撞

  • Layer 3 is checked for Layer 3 only
    仅检查层 3

也是比较简单的一个知识点,更多详细可以去Unity官方文档查看:[Unity-Layer-based collision detection](Unity - Manual: Layer-based collision detection)


回到正题,继续进入野外地图,角色没有任何问题的进入了,但是问题出在了进入之后,摄像头却并不在正确的位置,而是我们之前设置的位置(如下图),接下来进行这个bug的修复。

image-20250711185807574

先看看服务端,有接收到角色进入野外的信息,客户端也有着进入野外的信息,同时网上翻看也有着角色进入1号传送点,并且告知了我们要进入的传送点是从哪里到哪里,

image-20250711190133446

image-20250711190238885

image-20250711190308526

同时有着发送消息,1号请求传送(MapTeleporterRequest),收到了角色离开的消息(OnMapCharacterLeave),移除角色(RemoveCharacter),收到角色进入地图二的消息(OnMapCharacterEnter),再第二个地图中增加角色(AddCharacter)

image-20250711190405568

加载这张地图(LoadLevel),加载地图后将老地图中的角色删掉(Character_1_小娜娜 _OnDestory),执行了一次位置的初始化(MapEntityUpdateRequest)

image-20250711190656498

至此,角色传送没有任何问题,那么为什么摄像机没有跟随角色?先来看脚本,开始下断点,先随便看一看

image-20250711194136161

先看看这个角色是不是当前角色:

image-20250711194308185

经过排查之后想起是我们在场景之中已经设置了一个摄像机,删除之后就没问题了。

image-20250711195308151

那么现在往回走,可以看到野外传入点信息已经有了。

image-20250711195449187

继续向前走,可以看到与传出主城时相似的信息,并且我们的角色已经传送回了主城。

image-20250711195912650

再次返回传送门,再次传送到了野外,但是因为我们是在内部删除的摄像机,所以又出现了刚才的bug,直接停掉游戏后将野外地图中的摄像机删掉。

image-20250711200033112

image-20250711200155689


总结:

本节课的内容便是:创建传送点➡传送点的配置➡编辑器扩展:传送点数据生成➡传送协议与实现。

本节已经实现了角色的交互,使得角色已经能够在地图中进行各种各样的交互了,接下来就该完善一些玩法相关的内容了,比如NPC交互,各种各样的UI,可以设置任务,任务系统,商店,以及别的基础功能。

小地图没有了

做到这里,传送点的脚本已经完成了,但是这里发现右上角的小地图没有了,那是因为在之前的制作中,只做了主城的小地图,并没有做野外的小地图,接下来将写野外小地图,那么最简单的方法就是将小地图(UIMainCity)做成单例。第一件事就是要记得将Start改写为OnStart,以免覆盖掉父类。

image-20250711201420149

这么改完之后,就可以去Unity查看有什么变化了。进到野外之后可以发现小地图已经更新了,但是数据没有刷新,还是主城的地图。

image-20250711201614782

怎么可以快速更新数据呢?只需要每次都让它重新拉取一下角色信息就好,但是现在这张地图还没有做,需要在做一个包围盒。角色位置已经变了,需要更新地图的名字,以及小地图的背景图。在哪里进行这些更新?在小地图管理器中进行更新(UIMainmap),这里只在最开始的时候进行了一次初始化,那现在这个小地图可能会切换,那现在就需要进行改造了。

image-20250711202024067

即在角色可以传送的情况下,更新小地图,至于后面的制作留给之后。

6 基础系统

6.1 UI系统框架设计

接下来就是系统功能的开发,系统功能通常指背包系统、好友系统、工会系统等一系列系统,这些系统是有很多共同点的,相当重要的一部分由UI来支撑的。很多系统的大部分都是进行UI交互来呈现给玩家信息,然后实现这种交互的一个功能,大部分的系统都是交互系统。这里涉及到的就是在之后的开发中想要省时省力,那么前期UI系统的结构设计就需要做的比较到位一点,这个地基是要好好搭建的,当然之前也有地基的搭建,如网络底层、日志、数据访问数据库这些都是地基的一部分,也是架构的一个基础,在一个好的地基之上能够让系统快速成型,而这里的UI就是拔高的一个部分,UI框架能够让整体框架更完善。在有了UI的基础之后,在这个基础之上,再去做不同的系统开发,然后会发现后面的系统好开发了。本节课的重点在于从零开始的一个UI框架。那么如何写出一个适合自己的需要的UI框架?首先要有一个基础的知识点,做UI之前要做一个大概的分类,虽然并不一定全面,因为在开发一款新游戏的时候,前期是不可能想的特别全面的,但是想的不全并不会影响开发,这里将UI分为两类,静态-动态。

静态
普通窗口 对话框 消息框
动态
Tips 公告 战斗飘字

如上,静态UI中有三种,动态UI中也有三种,有可能将来还会有更多的类型,但是这些更多的类型还是会填进这两个类型之中,在这两个框架之下是可以无限扩展的,只要符合对应的类型即可,这就起到了扩展性,一个框架订好了,并且易于扩展,那么就么有必要在前期便将所有的事情都想的特别完善。

静态UI:它弹出来我们要不操作它,它就始终都在这里,关掉它就关掉了,主要指需要手动打开和关闭。

动态UI:可以是当鼠标移动到上面时就显示出来了,移开就消失了,随时显示,随时消失,包括公告也是,但这里的公告指的是全服公告,又或者是战斗时的UI,打到敌人身上不断冒出的小数字,这都是动态的,生存周期很短,持续时间也比较短。

image-20250712191148396

暂时分为这几类,UIMain为主UI,左上角的头像,右上角的小地图,都属于主UI,因为只要进入游戏,他们就一定会显示,不管是那张地图。那么主UI包括什么?住UI包括了上面提到的左上角角色信息,右上角的小地图,以及将来在下面的技能栏,还有左边右边各种各样的小边框,工具栏什么的都可以是主UI。主UI是一个常驻UI,管理的是游戏桌面上的UI,特点是什么:永远存在,只可能隐藏,但不会销毁,除非退出游戏。

UIManager:各个游戏系统,例如商店对话框、NPC对话框、任务对话框。这个有什么特点,只要不去NPC或者商店,那么这些资源就不会加载,即用不到的时候,不会进行加载,用到之后才会进行加载,加载关掉之后就销毁掉了。

UITipsManager:动态UI,这里用管理器来管理动态UI,因为这个逻辑较为复杂,鼠标移动到哪里显示一下,随时有什么特殊事情也会弹出一个,极其动态,逻辑复杂。

UIMessageBox:

还会有更多UI,如新手引导UI也做一个新的管理器,因为新手引导通常在多个UI之间进行交互,不过这里我们暂时不考虑新手引导问题,暂时忽略。

这节课的要点:了解UI的类型了解UI的设计


6.1.1 小BUG解决

这里发现了之前遗留的一些小问题,如果直接关闭游戏,角色并不会自动退出,而是遗留在原地。

image-20250712214622446

不是点击角色选择而是直接关掉游戏,这个角色并不会移除,而是留在原地。

image-20250712214947496

那么本节就是为了解决这个小BUG,那么这个BUG怎么解决呢?在服务器断开的时候将角色删除掉。从下图看,服务器是知道什么时候断开的,所以只需要在断开的时候将角色删除掉就好了。

image-20250712215129629

搜索一下Disconnected,找到它在哪里

image-20250712215334791

在NetService里面,只要断开连接,这里就会发送断开连接的信息,在这里有Session,既然有,就可以在这里做一些事。

image-20250712215621371

看一看sender有什么,可以看到有Session,这个才是我们的角色。

image-20250712215722006

再看Session里面有什么,有角色(Character)、Entity还有User,那么如果这个类里面也有一个Disconnected的方法,这里调用一次,在方法里面写具体要做的事,就可以了,

image-20250712215813906

这里是一个空方法,修复一下,修复前前预览以下是不是自己需要的。

image-20250712220053256

修复完成后要记得过来检查一下是不是在需要的哪个类中。

image-20250712220220670

现在对这个方法进行修复,现在需要将角色删掉,只要在方法里将角色删除即可,而上方还有我们所需要的所有角色信息了,接下来就很简单了,先判断一下角色是否为空,如果不为空就将角色删除,这里的角色用的是UserService来触发的,角色在进入的时候,用的就是UserService,这里的CharacterLeave还没有写,先转到UserService补全这个方法。

internal void Disconnect()
{
    // 判断一下,角色如果不为空,
    if (this.Character != null)
    {
        UserService.Instance.CharacterLeave(this.Character);
    }
}

来到User Service,先看一下正常的角色退出是什么,先从manager中将角色删除,在从地图中将角色删除掉,要保证角色成功厉害,这两句代码必须顺利执行,在这里为什么要将这个呢?是为了将这两个方法提取出来,这里又快捷操作。

// 从决策列表中删除角色
CharacterManager.Instance.RemoveCharacter(character.Id);
// 从地图管理器中删除角色
MapManager.Instance[character.Info.mapId].CharacterLeave(character);

直接选取需要提取出来的代码,直接右键,可以看到有一个“快速操作和重构…”,点击,再点击提取方法。

image-20250712221217076

image-2025071222c1321850

这里先不要动,可以看到NewMethod是选中状态,而且下面有一个小框,里面正式方法的名字,直接输入我们需要的方法就好。

image-20250712221415589

image-20250712221559095

这样方法就改造完成了,但是这里默认生成是私有的,将它改为公有。

public void CharacterLeave(Character character)
{
    // 从决策列表中删除角色
    CharacterManager.Instance.RemoveCharacter(character.Id);
    // 从地图管理器中删除角色
    MapManager.Instance[character.Info.mapId].CharacterLeave(character);
}

image-20250712221707134

那么至此,暂时我们要做的事就完成了,在NetSession中增加删除方法,将UserService中的角色删除方法提取出来。在这里(服务器断开)只增加了删除角色的方法,那么如果之后还有别的其他功能,也要在这里追加代码,Disconnect代表的是玩家断开服务器的时候主动要做的事情,现在要解决的就是在服务器断开的时候让角色可以退出去,那么这个代码在哪里进行调用呢?

image-20250712221938715

在原来输出服务器断开日志的时候进行调用。

        static void Disconnected(NetConnection<NetSession> sender, SocketAsyncEventArgs e)
        {
            //Performance.ServerConnect = Interlocked.Decrement(ref Performance.ServerConnect);
            sender.Session.Disconnect();
            Log.WarningFormat("Client[{0}] Disconnected", e.RemoteEndPoint);
        }

image-20250712222242505

完成之后就马上进行试验,反复进入游戏会不会角色残留,进去之后给角色挪个位置,然后直接关掉游戏。

image-20250712222444520

服务端这里可以看到角色离开地图1和服务器断开连接的信息已经发送过来了,逻辑已经成功在跑了。

image-20250712222533778

再次进入游戏,在周围看看有没有“角色”还在里面。

image-20250712222725616

image-20250712222736436

转一圈之后也没有找到,那么至此角色残留的BUG已经解决了。


6.1.2 主UI

既然要开始做主UI先来看看我们的UI做到了什么程度,主城的UI都有了。

image-20250713150743760

野外的UI虽然也有了,但是右上角的小地图不会刷新,名字也没有进行更新 ,这是因为前期只是单纯的将主城小地图做成了单例,还没有进行完善。

image-20250713150838772

为了让它更像主UI,先将它的名字改了,改为UIMain,同时将类也重命名一下。

image-20250713151030819

image-20250713151121402

同时还要记得将里面的类名改一下,不然会引用不到。

image-20250713175056756

改完之后记得检查一下:

image-20250714153741399

那么接下来就要针对主UI以及相关逻辑更新下来,先从小地图开始,在进入野外场景后右上角的小地图并没没有进行更新,小地图没有更新的原因是因为切换场景之后并没有触发资源的重新加载(即初始化函数只执行一次,只有在进入主城的时候)

image-20250714154209212

然后进行初始化加载地图,设置初始位置等,但是以后就不会在执行了,只有这一次。

image-20250714154421591

在这里需要它执行多次,每次切换场景就需要进行一次更新,即让它知道是不是切换地图了,如果地图更换了就更新地图,先给地图初始化改个名,改为UpdateMap

image-20250714161347200

在之前的时候就已经写好了一个MinimapManager,现在来给它增加一些功能,现在有着不同的地图,不同的地图有着不同的小地图的包围盒,需要经常换的东西,都交给Manager来管理,这里用get来获取小地图的包围盒(小地图用它的时候只能get)。

image-20250714163233264

小地图流程是怎么样子的呢:UI层更新,从哪里请求数据,从MinimapManager来请求数据。

image-20250714163535039

而Manager这个数据又从哪里来呢?(谁给它做更新的?)其他系统来给它做更新。这样做是为什么呢?因为Manager是一个单例,而Minimap是个脚本对象,意味着这个Minimap是挂在场景当中的,没有办法随随便便从别的地方引用到它,如果要引用到它,就需要建立复杂的逻辑关联,所以用MinimapManager来做一个中介,让数据能够传递过去。此外还有一个变量,那就是小地图,别人可以不知道小地图是谁,但是MinimapManager必须要知道小地图是谁,那么怎么就能够知道呢?很简单,先新建一个变量,在回到Minimap中,让Minimap来告诉Manager小地图是谁即可。

image-20250714164429394

image-20250714164420582

这样MinimapManager就知道minimap是谁了,那么只要Minimap中有地图,那么MinimapManager就一定知道这个小地图是谁。当MinimapManager知道小地图是谁后,就可以给MinimapManager提供一个更新Minimap的方法。为什么要将minimapBoundingBox传进来,因为这里的更新是要在场景变化的时候进行更新,也就是要更新包围盒,包围盒是绑定在场景当中的,在这里管理器是没有办法直接知道的,没有办法直接从场景中找出来,效率太低了。所以开放一个接口,谁知道,谁告诉我,只管接受就好。

image-20250714165410008

这里要记得将UpdateMap方法公开一下,不然会报错。

image-20250714165448677

管理器,别人告诉他需要更新小地图了,然后管理器在告诉小地图该更新了。

那么小地图得到通知后怎么办?以前的包围盒是绑定的,那么这里还需要更新一下包围盒,包围盒的数据从管理器获取,同时要将角色清空掉。

image-20250714165817310

那是因为在下面的Update中,如果不清空角色的话,playerTransform是不会进行更新的,当然这里也可以删掉,只是需要单独再赋值,也没有大的问题,只是这样性能上会比判断差一点,这样小地图管理器与UI之间的协作关系就做好了。

image-20250714165847478

这里需要改掉,之前的时候小地图不会经常更新(只更新一次),就需要判断一下,但是现在需要更新,如果这里不删除的话,在调试过程中小地图就不会更新。

image-20250714183618096

这里可以发现,UI只调用唯一一个第三方即MinimapManager,也就是说UI只会调用管理器数据,而且是自己的UI调用自己的管理器。

image-20250714183843690

而管理器需要管它自己的组件,也需要从全局的单例里面来获取一些数据。

image-20250714184035309

这里我们开放了一个接口(UpdateMinimap),有应该有谁来调用呢?

image-20250714184147516

那自然是地图发生改变的时候,地图怎么就发生变化了?怎么能够知道地图发生变化呢?这里也有一个简单的方法,每个地图都有一个唯一的脚本那每个地图加载的时候,这个脚本就一定会执行,所以在这里加一个这样的脚本,首先先给加一个Mao的根节点。

image-20250714184441052

并在上面绑一个新脚本,MapController。

image-20250714184624481

进入这个脚本,让它通知MinimapManager该更新小地图了,更新的方法需要包围盒,我们就给一个包围盒,这里只需要让别人告诉脚本是那个地图的包围盒,再由MinimaManager通知小地图UI进行更新即可。

image-20250714185824149

那这里应当有谁来告诉小地图的包围盒是谁呢?那肯定是脚本自己,只需要将包围盒拖入到MapRoot中即可。

image-20250714190446088

image-20250714190504822

image-20250714193156760

这里地图控制器MapController只做了一件事,就是告诉小地图MinimapManager要更新小地图,小地图进来之后要更新什么?先告诉自己包装盒是谁(那个地图的包装盒),另一个是告诉UI要更新了。

image-20250714190946771

image-20250714191004318

逻辑:由MapRoot来告知包围盒是谁,再由MinimapManager来告知小地图UIMinimap应该更新那个地图。

那既然主城地图加控制器了,也就是说所有地图都要加这个控制器,先来到野外场景(Map01),然后发现有自带的MapRoot,但是这张地图还没有包围盒,所以先做个包围盒出来,改为MinimapBoundingBox,并将其拉到合适的大小,然后将包围盒拖入到它应该去的地方。

image-20250714193001180

image-20250714193049610

在继续调整位置和大小不能超过地面,并使其在地面下方,这里并不需要它来到地面上方。

image-20250714193430741

在上一节中将MinUI做成了单例,而MinUI的核心就是单例,只要做成单例就是MinUI的底子,在以后开发别的功能时,就可以在相应节点之下做子UI就好,接下来启动游戏,进行测试,因为MinUI已经是单例了,所以需要在下面找

image-20250714193919945

而左侧的也和UI一一对应,Button是返回按钮,UIAvatar是左上角角色信息,而Minimap是右上角小地图,而在之后开发别的功能,如技能栏,只需要在这个节点之下继续新建然后制作即可。

image-20250714194011662

然后接下来继续前进,看看小地图重构是不是能够达到我们预期的效果,这里可以看到右上角小地图的名字以及背景都改变了,

image-20250714194503026

但这里有个bug,那就是角色的位置和小地图有些不对等,

image-20250714194615373

在场景资源中找到这张地图,并在场景中查看地形,可以发现场景中篝火在左上角,而小地图资源篝火则在左下角,逆时针旋转了90°。

image-20250714194721866

image-20250714195234036

直接去文件夹中修改一下,将其旋转一下,使其和地形相同即可。

image-20250714195458275

回到Unity中,可以看到小地图资源已经和地形相同了。

image-20250714195610734

继续向前走,我们到达了营地,小地图也差不多到达了营地,小地图功能没有问题。

image-20250714195719107


这里在学习的时候我没有遇到这个问题,但还是记录一下,难免以后会出。

当离传送门很远时,传送门的特效会变得畸形,而离近了之后又没事了,这是因为特效导致的。

image-20250714200025589

找到场景中特效的位置(在传送门下),这里的特效是由几个环制作的,就像这样子,而会有这个问题也是因为huan3。

image-20250714200319051

正常情况下:image-20250714200335293

那为什么是huan3出现了问题呢?因为它使用的贴图是UI类型(这里本人Default是正确的),将其改为Default即可。

image-20250714200514203

image-20250714200538374

image-20250714200624360

另外一点,这张图其实是要做半透明的,但是这张图是RGB,在上面选中灰度比例。

image-20250714200747653

而在改好之后大块块是没有了,还有小块块(本人这里没有问题),只需要将别的特效图也改为Default,并且选为灰度比例即可。

image-20250714200940007

image-20250714201021841

先在unity的Scripts中新建一个UIManager脚本,并进入编辑,开始编写UIManager脚本。

image-20250715214934063

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;



public class UIManager : Singleton<UIManager>
{
    // 定义了一个UI元素
    class UIElement
    {
        public string Resources;    // UI资源路径
        public bool Cache;      // 是否缓存
        public GameObject Instance; // 如果要Cache,则存储实例
    }
    // 用来保存定义的一个UI信息
    private Dictionary<Type, UIElement> UIResources = new Dictionary<Type, UIElement>();

    public UIManager()
    {
        // 测试UI
        this.UIResources.Add(typeof(UITest), new UIElement() { Resources = "UI/UITest", Cache = true });
    }

    ~UIManager()
    {

    }

    /// <summary>
    /// Show UI
    /// <summary>
    /// 这里用了一个泛型方法,传入一个类型参数T,返回这个UI的组件
    public T Show<T>()
    {
        // 声音注释,没弹出一次就有一个声音,但是这里暂时没有,所以先注释
        // SoundManager.Instance.PlaySound("ui_open");
        Type type = typeof(T);
        // 判断一下是否有这个UI,如果有,拿出来。
        if (this.UIResources.ContainsKey(type))
        {
            UIElement info = this.UIResources[type];
            // 判断实例有了没,如果有了,直接激活
            if (info.Instance != null)
            {
                info.Instance.SetActive(true);
            }
            else
            {
                // 如果没有实例,加载资源
                UnityEngine.Object prefab = Resources.Load(info.Resources);
                if (prefab == null)
                {
                    return default(T);
                }
                // 实例化
                info.Instance = (GameObject)GameObject.Instantiate(prefab);
            }
            // 返回来这个UI的组件
            return info.Instance.GetComponent<T>();
        }
        // 如果没有这个UI,返回默认值
        return default(T);
    }

    public void Close(Type type)
    {
        // SoundManager.Instance.PlaySound("ui_open");
        // 判断要关闭的UI是否存在
        if (this.UIResources.ContainsKey(type))
        {
            UIElement info = this.UIResources[type];
            // Cache有没有启用,如果启用了,不用它,藏起来
            if (info.Cache)
            {
                info.Instance.SetActive(false);
            }
            else
            {
                // 如果没有启用Cache,直接销毁这个实例
                GameObject.Destroy(info.Instance);
                info.Instance = null;
            }
        }
    }
}


这里可能会报错,因为UITest脚本还没有编写,无伤大雅,先写一个父类脚本UIWindow,给所有的UI当父类用,那么这个父类做了一些什么事呢?写了一个内置结果类型,写了一个Close方法。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

public abstract class UIWindow : MonoBehaviour
{
    //
    public delegate void CloseHandler(UIWindow sender, WindowResult result);
    // 关闭窗口事件
    public event CloseHandler OnClose;
    // 用来获取类型,
    public virtual System.Type Type { get { return typeof(UIWindow); } }
    // 内置结果类型
    public enum WindowResult
    {
        None = 0,
        Yes,
        No,
    }
    // 写了一个Close方法。
    public void Close(WindowResult result = WindowResult.None)
    {
        // 对任何一个窗口调用了Close,其实就是调用了UIManager来Close,并且触发一些事件。
        UIManager.Instance.Close(this.Type);
        // 
        if (this.OnClose != null)
            this.OnClose(this, result);
        this.OnClose = null;
    }
    // 虚函数,当子类里面不重写,那么就会调用这个默认的CloseClick方法。(点击关闭就是关闭)
    public virtual void CloseClick()
    {
        this.Close();
    }
    public virtual void YesClick()
    {
        this.Close(WindowResult.Yes);
    }
    // 这是一个测试
    private void OnMouseDown()
    {
        Debug.Log(this.name + "Clicked");
    }

}

那么去Unity中做一个新的UI系统,比如点击某个按钮要弹出商店,接下来就做这个测试UI(UITest),先加入一个新的Image(右键黑色箭头),再弹出页面中找到UI(灰色箭头),然后选中Image(棕色箭头)

image-20250715231603380

将新的Canvas更名为UITest,并将其下方的Image改名为Bg,然后选中我们准备好的背景图,并将其修改到合适的大小。

image-20250715232212973

image-20250715232229597

然后再加入一个Image(更名为TitleBar),并在其下面加入一个按钮和文字。

image-20250715232400847

并将为Button选则合适的图片,调整到合适的大小。

image-20250715232534852

image-20250715232844657

将Text改名为Title,并设置合适大小,将其中内容改为标题栏,调整合适的字体大小。

image-20250715232937773

最后再加入一个确认按钮,同样选择合适的图片,并将其内容改为确认。

image-20250715233248862

给确认按钮绑脚本,先来看看UITest的脚本:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UITest : UIWindow
{
    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

除了一个父类UIWimdow之外,什么脚本都没有写,那它是如何工作的,回到Unity,首先再UITest上绑定了UITest的脚本。

image-20250715233727257

然后给ButtonOK绑上UITest组件(注意这里是组件,而不是脚本!),并为其榜上OnYesClick的方法。

image-20250715234023620

同样的,再关闭按钮绑定OnCloseClick方法。

image-20250715234154232

这样,这些常用的点击这个确认,点击这个关闭,就不需要再写代码了,减轻了代码工作量,也就是说再写一个新系统的时候,UITest里面只关心界面打开了,比如打开的是一个角色信息,在Star函数里面获取角色信息,只需要写逻辑就好了,至于什么时候确定,什么时候取消,不需要每一个界面都去写,这就是框架。

image-20250715234350474

然后将做好的UITest放入Assets/Resources/UI下,并保证每个Prefab与其对应脚本的名字相对应。

image-20250715234541791

image-20250715234726922

如何让脚本能够运行起来?一定要在UIManagaer中写这句代码this.UIResources.Add(typeof(UITest), new UIElement() { Resources = "UI/UITest", Cache = true });先添加到管理器当中(UIResources),管理器才会管理它,才可以使用管理器来使用它,加入的类型(UITest),构建一个新的元素结构(UIElement),将路径填进去(Resources),并告诉他Cache还不是Cache。

image-20250715235052082

测试一下,但是在这之前要先调用一下才行,先去MinCity中

已经做完了UI窗口,现在要进行UITest的调用,在主窗口上加个显示,将“返回角色选择按钮复制一个用来作为测试。

image-20250716161345794

不过这里用的依旧是原来的BackToCharacter函数,那就再UIMain里面再加一个测试方法。

image-20250716161530871

每次做完一个新的UI系统,要怎么让它能够显示出来呢?第一步UIManager,第一步UIManager,第二步Instance,第三步Show,这里Show是一个泛型方法

public void OnClickTest()
{
    UIManager.Instance.Show<UITest>();
}

随后将这个函数绑到脚本上,先找到UIMain,然后再找到相应的脚本(OnClickTest)

image-20250716162321591

随后运行游戏进行测试,可以看到测试按钮已经存在了。

image-20250716162632212

点击测试按钮,可以看见正常弹出了窗口,内容也和之前写的相对应。

image-20250716162648348

在点击右上角关闭窗口,就能关掉窗口了。


小BUG修复

但是这里本人在做的时候遇到了BUG,点击窗口没有反应,接下来就是修BUG的时间,首先先顺着这个没有反应找,窗口没有办法关闭,那就是OnCloseClick没有运行,而OnCloseClick又是调用的Close函数。

image-20250716163338060

在运行了Close函数后又会去调用真正做事的UIManager中的Close函数,既然不知道问题所在,索性全部打上断点。

image-20250716163439194

然后一个一个测试运行,看看是哪里出了问题,走了一圈下来后好像也没有发现问题,既没有报错也没有哪里没运行到,经过排查之后发现是上面有一句写错了,就是获取组件类型这里。

image-20250716165144428

按照上面这么写确实是没有问题的,但问题就出在这里,UITest是它的子类,而当UITest调用到OnCloseClick的时候,就会调用这里,而当UIWindow开始获取组件类型时,就只会获取到父类(UIWindow)上,而不是子类UITest上,所以这里正确的写法应该是return this.GetTpye()

image-20250716165420322

然后回到Unity进行测试,没有问题了。

image-20250716165441302


这里使用很简单,回到UIMain,可以发现这里UIManager.Instance.show<UITest>();是带返回值(UITest)的,那么这里就可以进行改造,那这里便意味着在这里可以使用任意的方法。

image-20250716170000856

比如说这里需要更新一下标题栏,回到UITest,要更新标题栏,就要先知道标题栏,新建一个title用来保存Text,用来绑定Text,在它启动的时候能够运行SetTitle方法来改变标题。

image-20250716170406242

回到UIMain,调用该方法,使其更改标题为“这是一个测试UI”

image-20250716175128915

而在父类里面也有很多方法,比如OnCloseClick、OnYesClick等方法,这里调用OnClose方法,用Set Title进行复制,用OnClose获取结果,这样的好处是可以让逻辑更清晰,谁调用,谁来获取反馈。

image-20250716175335286

回到Untiy后先记得给UITest绑上组件,并保存,随后运行程序。

image-20250716175759941

在点击新建的测试UI后,在点击关闭,就会弹出下面的框

image-20250716175916726

同样的,点击确定就是另一个内容的框

image-20250716175939380

那么UITest类里面有逻辑吗?有,只写与业务有关的逻辑。

image-20250716180026435

而对于调用者,则是可以对它进行Set(SetTitle)或者Get都是可以的,然后还能获取它的结果,对结果进行处理(MessageBox)。

image-20250716180157270

当然很多时候是不需要关心结果的,比如关闭商店,玩家关了就管理,而且商店上面只有关闭,那什么时候要对结果关心呢?创建公会的时候,要不要取消,这些时候。就以改名字为例,如果要获取到改了后的名字是什么,就可以通过sender来获得。具体代码可以类似于(send as UITest).name。调用前和调用后,就可以任意访问这个UI组件上的各种值,对UI的使用就几乎没有了限制。

image-20250716180444731

完成了主UI重构

6.2 NPC系统

6.2.1 了解系统

上一节中实现了一个简洁,但是比较好用的UI框架,那么这一节将正式开发第一个正经的游戏系统开发,NPC系统。

NPC:非玩家角色,不由玩家进行控制(怪物也是NPC);

PC:角色,p:player,c:character,

因为怪物比较特殊,通常在玩家的对立面,一般将怪物进行单独对待,又因为NPC和怪物的逻辑上不相同,所以回设计成两种不同的类型。

任务NPC
固定任务NPC 活动任务NPC
功能NPC
打开商店 打开仓库 打开副本 打开工会

在这里暂时先将NPC分为两类,任务NPC:承载各种各样的任务,与任务有直接关联的称之为任务NPC,任务NPC又分为固定任务和活动任务两种。功能NPC:其他各种各样的功能,有功能的NPC称之为功能NPC,这里只例举出四个。

那要怎么开发NPC系统呢?在每个游戏开发前,都会有一个策划案,而这里也有一个策划案,先看看NPC系统的策划案。

image-20250716200430971

image-20250716200509393

看着内容好像不多,但是这个策划案最主要的就是需要策划提供给我们一些必要信息:有几个NPC,职能是什么等image-20250716200539706

做NPC系统的核心是包含技术需要的信息就够了,而今天要做的NPC系统我们需要的是:

  • 每个NPC站在那里

  • 有几个NPC

  • 每个NPC的功能是什么

知道这些信息就够了,因为NPC本身是比较简单的,其实NPC只是一个入口,所以要将NPC系统放在第一个来开发,因为玩家要打开一个商店,要找某个NPC;要打开仓库,要找个NPC;要建立公会,也要去找个NPC,NPC其实会有很多的职能,本质上NPC是除了主UI之外的游戏系统的主入口,但这个入口本身的逻辑并不复杂,只是发起逻辑,到现在就了解好了策划案的内容。那么接下来就要从技术的角度来分析它,从技术的角度要怎么设计它。

几乎所有的游戏系统设计开发中,会分为客户端(Client)和服务端(Server),分的时候会从底层,网络协议层,做一个简单的约定;或者分开,客户端想一想客户端怎么实现,服务端想服务端,最后由领头人对一对有什么样的协议最后将方案定下来。这种方式简单的系统还好,但是如果很复杂的系统,这样的效率是很低的。这个时候就需要有那种双端的成员提出好的设计思想、设计架构非常有利于项目的开发。

接下来看看设计思路,先看客户端(Client),分了三层,而服务端(Server)只有两层,缺了游戏对象层(GameObject Layer),因为客户端是可以看到游戏角色的,Npc就是游戏对象,那么游戏对象就一定会绑定Controller,当然这只是一个简单的约定,客户端的大部分对象都是由Control来命名的,比如说玩家的Control、NPC的Control、地图的Control、各种各样的Control,各种各样的(Control)控制器。

管理器(Manager),既然要有游戏系统,就一定会有逻辑对象来管理他们,毕竟NPC不只有一个,所以要有一个管理器(NPCManager)来管理他们,服务端和客户端各有一份,因为服务端也要有一部分来处理一些NPC,这是一个NPC的管理,但是暂时还不需要,因为现在属于前期,从最简单先入手不会涉及一些复杂的NPC,也没有做任务系统,不会涉及到服务端的一些具体NPC的一些逻辑。但是注意中间框中的两个(Other Logic System),这个只是一种框架示意图,并不是平常的UML图,这个方块里面代表的是各种其他的逻辑系统(Other Logic System),它代表的是NPC系统和其他系统之间的一种交互关系,而这一块放到了中间,也就说明了它同时会和服务端、客户端都有交互。那么其他系统包括了商店系统(ShopSystem),点击一个NPC会打开商店,点击一个NPC会打开任务(TaskSystem)。

NpcController、NpcManager、NpcService三个之间的双向箭头代表了NpcController不会直接和其他系统(Other Logic System)交互,NpcService同理,也不会直接交互,和其它系统进行交互的只有NpcManager这个模块。

image-20250716224941701

6.2.2 要点:

  1. NPC的类型:商店、仓库,副本、公会
  2. NPC的配置及加载:一个新的配置表(从头开始),第一次手动增加配置表,还要再增加它的加载逻辑。
  3. NPC的资源制作:因为美术提供的是原型(不是Prefab),没办法直接使用,再者上面有动作,需要进行控制,需要动手来制作一下。
  4. NPCManager
  5. NPCController

6.2.3配置表

首先先查看策划案,通过策划案来写配置表,先打开配置表所在的文件夹,直接用现成的表格来直接复制一份,毕竟格式也好字体也好肯定是复制来的快,记得更名为NpcDefine(Npc定义表)。

image-20250718114256324

然后就进入到表内修改字段,那Npc需要的字段肯定有ID和Key,这两列肯定要有,而现在只有四个Npc,所以只留下四个。

image-20250718114742660

Npc肯定也是有名字的,所以Name也要留,将他们粘贴到NpcDefine相应的位置中去。

image-20250718114822654

image-20250718114937060

地图暂时是用不着的,先将描述提前至名字后,并加上类型(String)、功能(String)和参数(Integer),并将策划案中的内容加到相应的位置。

描述 类型 功能 参数
Integer String String String String Integer
Key ID Name Description Type Function Param
1 1 莉莉丝 任务NPC Task
2 2 埃布尔 杂货商人 Functional InvokeShop 1
3 3 多丽丝 副本NPC Functional InvokeInsrance
4 4 奥德里奇 装备NPC Functional InvokeShop 2

这里可以看到,除了第一个NPC是任务Task(任务),其他三个都是Functional(功能),而在功能(Function)中又定义了调用商店(InvokeShop)、调用副本(InvokeInsrance),而这时也可以发现后面还有一列参数(Param),参数1和参数2。

这里的两个商店都是商店,只是他们贩卖的商品不同而已,多丽丝是杂货商人,而奥德利奇是装备NPC,两人只是呈现出来的商品不同而已,本质上却都是商店,而二者的逻辑是相通的,所以在做商店系统时,就可以设计一个基础的商店系统,然后通过配置表来实现各种各样的商店,比如商店1是杂货店,商店2是武器店,即参数中的1、2。

那么现在对于基础NPC来说配置的这个结构已经足够了,那要想让代码加载这个结构要怎么办?还需要加Define,在Common里面,而Common在GameServer里面。Common里面又有Data,Data里面就有各种各样的数据结构以及定义,因为它是客户端和服务端公用的。

image-20250718151809701

这里可以看到其他几种类型已经在里面了,但是没有NPC的,继续复制一个出来,并记得将类名也改成NpcDefine。

image-20250718151957714

然后将配置表中的定义的类型加入到代码中,在配置表中可以看到类型和功能的类型是字符串,因为下面写的是字符,但是在代码中用的是枚举。

image-20250718152649867

 public class NpcDefine
 {
     public enum NpcType
     {
         None = 0,
         Functional= 1,
         Task,
     }
    public enum NpcFunction
     {
         None = 0,
         InvokeShop = 1,
         InvokeInsrance = 2,
     }
     public int ID { get; set; }
     public string Name { get; set; }
     public string Descript { get; set;}
     public NVector3 Position { get; set; }
     public NpcType Type { get; set; }
     public NpcFunction Function { get; set; }
     public int Param { get; set; }
 }

枚举值里面写的枚举定义,这样可以很好的解决看不懂这个问题,回看配置表能够清楚的知道里面的重要内容是要做什么,即使是新手来做也没问题,同样的在Common中使用枚举也可以很轻松和那边对应起来,这样代码看起来不乱,这是一种配表形式的设计。


上面配置表以及Define完成后,还需要加载这些数据,服务端和客户端的位置不同,两端是各有一份的,要分开用,先解决服务端的那份,在DataManager里。先将上面的代码复制一份,然后再修改前面得结构,因为这里得结构是一个单K得结构,类似于第一个Maps得定义,所以将第一行复制过来改成NpcDefine。

image-20250718161036467

image-20250718161218694

这里面是加上定义,然后在下面(Load)加上读取,先复制上一行代码(红色箭头),再将代码修改为读取NpcDefine文件(蓝色箭头)

image-20250718161449594

因为客户端也要用,直接将这两句代码复制粘贴使用,不过再转到客户端前要先记得重新生成一下解决方案,因为修改了Common和GameServer,所以直接从上一级重新生成(方便、快)。

image-20250718161630691

生成完之后看一下输出,没有失败即可。

image-20250718161752496

然后到GameServer文件夹下将Common.dll以及Common.pdb文件复制到客户端的相应位置(Assets/References)下。

image-20250718162035010

然后将复制得那两句粘贴到客户端的DataManager下得读取中,记得要将NPCs定义也要复制过来。

image-20250718165903890

再改完这些之后一定要记得调用Json生成工具,等到其完成。

image-20250718170006409

image-20250718170114241

然后将生成得数据手动粘贴到服务端一份去,而客户端已经自动复制了过来。

image-20250718170742712

因为修改过生成工具,可以直接让它再生成Txt文件得同时直接复制一份,只需要直接再下面复制代码,后修改文件名即可。(注:如果想要复制到服务端,只需要将这几行代码都复制一遍,修改相应得路径即可。)

image-20250718170945304

image-20250718171028992

然后将NpcDefine复制一份到服务端。

image-20250718171542328


6.2.4NPC的制作

刚才配置表什么的已经生成了,对不对暂时先不管,一会儿运行的时候看。首先要做的NPC是要放在主城里面的,先打开主城的场景。

image-20250718171757441

对比策划案,查看这几个NPC的站位,看看他们在哪儿站着。

image-20250718171830156

现在有了MapRoot,那就将NPC也放到下面,用来分类,新建一个空节点并改名为NPC。

image-20250718172108932

NPC的原始资源在Models下面,可以看到有着NPC的资源。

image-20250718172150597

先点开DeliveryNPC那个文件,并将DeliveryNPC的那个文件拖到NPC节点下,并将她拖到合适的位置。

image-20250718172337129

image-20250718172531040

然后将所有的NPC都拖入进来,因为策划案并没有规定那个NPC是哪个,先将所有的NPC拖入进来。

image-20250718172743318

但是这里好像少一个NPC,其实不然,先将第一个拖入进来的那个NPC复制一份,并将他俩编号1、2,并将其中一个拖到另一边一点。

image-20250718172927096

为什么要将这个NPC复制一份呢?在制作NPC的时候,对资源做了一点优化,游戏需要多一个NPC,但是并没有太多的模型,那就做两张贴图(Assets/Models/NPCs/DeliveryNPC/tga),一张黄色贴图,一张蓝色贴图。

image-20250718173528054

image-20250718173538244

那么找到其中一个NPC上的贴图(NPC2),tong通过它找到材质,并追踪到它的位置。

image-20250718173704242

在点击材质后发现不能修改,这是因为这个材质是由FBX里面导入进来的,不能改,Ctrl+d,或者直接拖出来复制一份,并改名为DeliveryNPC1,并将其复制一份,改名为DeliveryNPC2,这样就有两份材质了。

image-20250718173742804

image-20250718174015751

然后分别将1、2复制到和他们相应的材质位置。

image-20250718174130498

image-20250718174146497

然后将第二个NPC的贴图换成第二张,然后可以发现颜色改变了,当然,一个是NPC1(黄色),一个是NPC2(蓝色)。

image-20250718174236301

image-20250718174310658

这里会有尺寸问题,像这里的贴图,它会有一种自动的压缩,这种压缩只要是2的n次方,尤其是长宽高相等的情况下,显卡是可以直接支持的,绝大多的硬件GPU是可以直接支持这样的纹理尺寸以及压缩格式,如果不是这种尺寸,就不会自己懂压缩例如这里Windows下显示的是DXT1,如果是一个其他格式,就不是这个尺寸。当自己点进来的时候会自己变成这样是因为他会自己进行一个优化。

image-20250718174539394

像这种是NPOT的,它会提示NPOT,这会有什么缺点呢,在移动设备上不支持压缩,在这里看没有问题,但是在移动设备上压缩是不支持的。

image-20250718175428545

而这里是RGB,没有办法自动启动压缩,如果格式不对Unity也会有提示,宽和高要是4的倍数才可以被压缩成这种格式,诸如此类的提示会告诉制作者。

image-20250718175738664

一个模型除了可以使用不同的贴图外(材质的复用),是一种节省资源的方式,因为所有的NPC不止要有模型,还要有动作,而每一个模型做动作也很耗费时间,接下来给每个NPC配置状态机。(注:如果遇到NPC变成红色,就是丢了材质,如果遇到NPC变成灰色,就是丢了贴图。)


6.2.5NPC状态机

先创建一个状态机(New Animator Controller),然后这里只创建一个,然后两个双胞胎姐妹可以公用一个。

image-20250719170222762

然后将三个动作拖入到状态机中,同时选中后直接拖入其中即可。

image-20250719170507972

在这里面有三个动作,一个对话动作,一个默认动作,一个放松,这里将默认动作设为空闲动作,

image-20250719170621784

先创建几个Tigger动作,一个默认,一个对话,一个放松

image-20250719170919135

那么就可以开始创建联系了,Talk动作肯定是任何时候都可以进行,那么从AnyState制作过度动作到Talk,而Relax动作肯定是从默认动作才会进行的,所以从Idle制作过度动作到Relax,同时在Relax播放完后就要在回到默认动作,所以要在制作过度动作回去。

image-20250719171314645

而从Idle状态到Relax状态没有退出时间,Relax到Idle是有退出时间的,动画需要播放完整,而进入对话也不需要有退出时间(默认就没有)。

image-20250719171524047

image-20250719171534788

image-20250719171545837

同时,当Talk动作完成之后就可以回到Idle了,需要再从Talk到Idle在有一个过度。

image-20250719171715580

线拉上了还没有绑定事件,先来绑定Talk的事件,Talk就让它Talk的Tigger来触发。

image-20250719171758826

放松的时候放松

image-20250719171840094

随后将做好的动作绑到相应的NPC身上去。

image-20250719173051080

现在动作做好了,也绑定了,那么接下来就要错Controller了,先去GameObject(存在于场景中需要绑在某个物体上的脚本都在这个脚本下)下新建一个NpcController。

image-20250719173316230

然后将新建的脚本拖到他们四个NPC上。

image-20250719173559263

接下来就要做prefab了,先将所有的NPC位置固定到(0,0,0),这样便于操控,因为他们的位置都是由代码来控制的。

image-20250719173953487

在Resources下新建NPC文件夹,并将四个NPC拖入其中。(小Tips:能够直接使用拖拽来解决的,就用拖拽,不能用拖拽解决的,就用代码。)

image-20250719174040473

接下来开始将NPC放到他们应在地位置,先将他们一字排开到喷泉前,接下来查看一下策划案,并开始分配他们谁是谁。

image-20250720111012388

再看过策划案后便可以分配他们谁是谁了,要想让黄色NPC当莉莉丝,可以给他们分别给一个ID,在NpcContrller脚本中先加入一个ID变量,并要和配置表关联起来。

image-20250720111123926

这里可以避免写代码,在查看配置表,1号是莉莉丝,加了ID变量之后,就可以和配置表之间进行关联了。

image-20250720111457056

回到Unity给黄色NPC的角色赋上1号莉莉丝,给红色NPC赋上2号多丽丝,给男性角色赋上3号埃布尔,最后老鼠样貌的NPC赋上4号奥德利奇。

image-20250720111633975

赋好值后再看策划案,这次看他们四人的位置,先将商人放到左上角。

image-20250720112752531

image-20250720112905503

并将剩余三人也放到她们在策划案中的位置。

image-20250720113042268

然后调整他们的朝向,使得他们能够朝向另一边(可以按照自己的喜好来进行调整)。

image-20250720113426712

角色放到合适的位置,调整好角度后,就可以回到NpcController继续写脚本,接下来就需要控制角色的动作了,既然要控制动作,就要先知道访问的是那个对象 Animator anim;,与之相对应,既然要用Animator,就要在它启动的时候知道Animator是谁,既然如此,在Start的时候get一下,NpcController和Animator是绑在同一个节点之下,只需要这样既可以get到了。

void Start()
{
    anim = this.gameObject.GetComponent<Animator>();
}

现在做了NPC的ID,与配置表进行了关联,但是NPC的数据还没有,这里还需要它来维护一下配置表的数据,但是它又不需要直接和任何逻辑进行交互,所以新建一个NPCManager类,将交互逻辑卸载Manager中。

// 维护配置表
    NpcDefine npc;

image-20250720160258864

然后在里面新建一个方法GetNpcDefine,输入值为npcID,在这里便可以用DataManager的NPCs,方法,传送npcID,来get的到信息。并将NPCManager改为单例类,并将命名空间手动改为Manager。

image-20250720160820908

先完整从头运行一遍,看看他们在游戏中是什么样子的,可以看到他们已经在做默认动作了。

image-20250720165639236

但是此时点击它却并没有任何的反应,这也是接下来要做的事,能够与NPC互动。而一说起这个,第一个想到的是射线检测,这个没有问题。而众所周知,所有的游戏对象都有一个共同的父类MonoBehaviour(MonoBehaviour官网链接直达)而在这之下有一个Messages标签,其中就有鼠标点击(OnMouseDown)的使用,后面是描述(OnMouseDown is called when the user has pressed the mouse button while over the Collider.)意思是当用户在碰撞体(Collider)上按下鼠标时,会调用OnMouseDown。这说明了只要任何游戏对象只要上面有碰撞器,它就可以自动的获取鼠标点击的通知。

image-20250720170702674

那接下来就很简单了,只需要为NPC加碰撞器就好了,并将其调整到合适的位置,其他三个同理。

image-20250720172022659

(这里选择方形是因为它对点击友好,在选择碰撞器时要尽可能选择便利合适的。)

image-20250720172213370

然后回到NpcController,使用刚才的OnMouseDown方法,先做个小测试,点击后输出自己的名字,为了明显,输出一条错误日志。

image-20250720172844197

然后运行程序进行测试。当点击NPC时确实出现了错误信息,输出了自己的名字,这就表示点击是已经生效了。

image-20250720173010715

那这就代表只要使用OnMouseDown方法,就可以获得准确得点击,而且是非常准确得点击。而NPCManager与其他系统之间有交互,那NPC和其它系统得交互是通过事件来达成的。

// 先定义一个委托
		public delegate bool NpcActionHandler(NpcDefine npc);
// 再定义一个字典,一个Function,一个委托
        Dictionary<NpcFountion, NpcActionHandler> eventMap = new Dictionary<NpcFountion, NpcActionHandler>;
// 主要是管理事件,而事件是唯一的接口
        public NpcDefine GetNpcDefine(int npcID)
        {
            NpcDefine npc = null;
            DataManager.Instance.Npcs.TryGetValue(npcID, out npc);
            return npc;
        }
// 再来一个注册方法,可以通过哪一个function,对应那一个action因为再配置表中我们有类型也有方式,这个方法主要是用来管理事件,这个方法是别的系统调用,然后告诉NPC要关注什么事件
public void RegisterNpcEvent(NpcFunction function, NpcActionHandler action)
{
    if (!eventMap.ContainsKey(function))
    {
        eventMap[function] = action;
    }
    else
    {
        eventMap[function] += action;
    }
}
// 交互前判断:任何一个NPC交互前,先判断NPC是否存在,如果存在,执行交互
public bool Interactive(int npcID)
public bool Interactive(int npcID)
{
    if (DataManager.Instance.Npcs.ContainsKey(npcID))
    {
        var npc = DataManager.Instance.Npcs[npcID];
        return Interactive(npc);
    }
    return false;
}
// 交互: 先判断是否是任务型交互,如果是,走任务型交互(Task),如果是功能NPC就走功能性交互(Function),当然后续这里也可以继续扩展
public bool Interactive(NpcDefine npc)
{
    if(npc.Type == NpcType.Task)
    {
        return DoTaskInteractive(npc);
    }
    else if (npc.Type == NpcType.Functional)
    {
        return DoFunctionInteractive(npc);
    }
    return false;
}
// 任务NPC交互,显示个对话框,暂时先不做多余扩展,做个最简单的,能够知道是任务NPC就好
        private bool DoTaskInteractive(NpcDefine npc)
        {
            MessageBox.Show("点击了NPC:" + npc.Name, "NPC对话");
            return true;
        }
// 功能交互:先查询事件任务表中是否存在,如果存在直接传入到NPC定义的Function表中,将npcDefine传进来。
        private bool DoFunctionInteractive(NpcDefine npc)
        {
            if (npc.Type != NpcType.Functional)
                return false;
            if (!eventMap.ContainsKey(npc.Function))
                return false;

            return eventMap[npc.Function](npc);
        }

然后对NPCController进行重构。

    // NPC的唯一标识符
    public int npcID;

    SkinnedMeshRenderer renderer;
    // 用于控制NPC动画的Animator组件
    Animator anim;
    Color orignColor;

    private bool inInteractive = false;
    
    // 维护配置表
    NpcDefine npc;
    // Start is called before the first frame update
    void Start()
    {
        // 获取SkinnedMeshRenderer组件(获得模型)
        renderer = this.gameObject.GetComponentInChildren<SkinnedMeshRenderer>();
        // 获取Animator组件(获得动画)
        anim = this.gameObject.GetComponentInChildren<Animator>();
        // 储存原始颜色(用于高亮效果)
        orignColor = renderer.sharedMaterial.color;
        // 获取NPC的配置数据(获得NPC对象)
        npc = NPCManager.Instance.GetNpcDefine(this.npcID);
        // 随机动作
        this.StartCoroutine(Actions());
    }
    IEnumerator Actions()
    {
        // Npc在2秒或者5-10秒后开始随机动作
        while (true)
        {
            if (inInteractive)
                yield return new WaitForSeconds(2f);
            else
                yield return new WaitForSeconds(Random.Range(5f, 10f));


            this.Relax();
        }
    }
    // Update is called once per frame
    void Update()
    {
        
    }
    void Relax()
    {
        anim.SetTrigger("Relax");
    }
    void Interactive()
    {
        // 进行一个判断,避免重复交互
        if (!inInteractive)
        {
            // 如果NPC正在交互中,则不允许再次交互
            inInteractive = true;
            // 启动协程进行交互,在协程中处理NPC的面向玩家和交互逻辑
            StartCoroutine(DoInteractive());
        }
    }
    IEnumerator DoInteractive()
    {
        // 先面向玩家(做协程)点击转身
        yield return FaceToPlayer();
        // 进行Npc交互执行实际的交互逻辑,将交互请求发送给Manager
        if (NPCManager.Instance.Interactive(npc))
        {
            // 如果交互成功,播放NPC的对话动画
            anim.SetTrigger("Talk");
        }
        // 3秒内无法再次交互
        yield return new WaitForSeconds(3f);
        inInteractive = false;
    }

    IEnumerator FaceToPlayer()
    {
        // 计算玩家位置与NPC位置的向量
        Vector3 faceTo = (User.Instance.CurrentCharacterObject.transform.position - this.transform.transform.position).normalized;
        // 判断的是角度。
        while(Mathf.Abs(Vector3.Angle(this.transform.forward, faceTo)) > 5f)
        {
            // 慢慢转向玩家(差值Lerp函数)
            this.gameObject.transform.forward = Vector3.Lerp(this.gameObject.transform.forward, faceTo, Time.deltaTime * 5f);
            yield return null;
        }
    }


    void OnMouseDown()
    {
        // 交互NPC
        Interactive();
    }
    private void OnMouseOver()
    {
        // 鼠标悬停在NPC上时,触发高亮效果
        Highlighter(true);
    }
    private void OnMouseEnter()
    {
        // 鼠标进入NPC时,触发高亮效果
        Highlighter(true);
    }
    private void OnMouseExit()
    {
        // 鼠标离开NPC时,取消高亮效果
        Highlighter(false);
    }
    // 高亮效果
    private void Highlighter(bool highlighter)
    {
        if (highlighter)
        {
            if (renderer.sharedMaterial.color != Color.white)
                renderer.sharedMaterial.color = Color.white;
        }
        else
        {
            if (renderer.sharedMaterial.color != orignColor)
                renderer.sharedMaterial.color = orignColor;
        }
    }

那么既然有关NPC的相关脚本都写完了,就加一个小系统来进行测试吧。假设有一个游戏系统叫TestManager。

// TestManager一开始会注册两个方法,一个方法是打开商店,一个方法是打开副本
public void Init()
{
    NPCManager.Instance.RegisterNpcEvent(Common.Data.NpcFunction.InvokeShop, OnNpcInvokeShop);
    NPCManager.Instance.RegisterNpcEvent(Common.Data.NpcFunction.InvokeInsrance, OnNpcInvokeInsrance);
}
// 打开商店方法因为还没做商店系统,先打开上一届的测试UI,并让它以NPC的名字命名为标题。
private bool OnNpcInvokeShop(NpcDefine npc)
{
    Debug.LogFormat("TestManager.OnNpcInvokeShop: NPC:[{0}:{1}]Type:{2} Function:{3}", npc.ID, npc.Name, npc.Type, npc.Function);
    UITest test = UIManager.Instance.Show<UITest>();
    test.SetTtile(npc.Name);
    return true;
}
// 点击副本的时候就点击副本就好了。
private bool OnNpcInvokeInsrance(NpcDefine npc)
{
    Debug.LogFormat("TestManager.OnNpcInvokeInsrance: NPC:[{0}:{1}]Type:{2} Function:{3}", npc.ID, npc.Name, npc.Type, npc.Function);
    MessageBox.Show("点击了NPC:" + npc.Name, "NPC对话");
    return true;
}

现在单例做好了就需要初始化一下,初始化的话就需要在LoadingManager中进行初始化,将初始化代码加进去。

image-20250720195005051

做好这一切之后来测试一下,现在假设有了一个新的游戏系统,但这个系统并不需要多做什么,只是为了和NPC系统注册了一个交互事件,点击NPC会触发对应的事件来。

在进入后先测试高亮系统,可以看到没有问题。

image-20250720195955129

image-20250720200003641

然后点击他们,都可以弹出相应的对话框

image-20250720200849788

image-20250720200858798

image-20250720200906923

image-20250720200915688

那么测试完毕,至此可以了解到在NPC系统之下,如果要和其他系统做对接,需要写一个管理器,注册一个事件,接收一个事件,以及一个NPC功能入口。

image-20250720201359020

点了之后做什么,想做什么都可以,当前是任务系统,在这里便是打开任务系统对话框,用UITest或者UImissing,如果是个商店系统,这里就用UIShop。

image-20250720201430456

这样就写出了一个扩展性很强的NPC系统,并且代码量并不多。

6.3 道具系统

6.3.1 本节要点

  • 道具系统需求分析
  • 道具的分类
  • 道具的接口设计
  • 道具系统的组成

本届开始制作道具系统,先从策划案来看看道具系统要实现那些东西,那些功能。它不难做,但是会有很多和其他系统相互关联的地方。先来看看游戏内要实现的几种道具类型,分别有治疗药水,分为立即恢复和持续恢复两种;食物,经验、兑换、金钱、技能等类型。而其中,持续恢复药水一般不会以单独逻辑来实现,通常是要结合一些其他系统,比如说Buff,加上一个5秒内的回复buff,然后由buff系统来实现,这样会比较好一点,因为buff一定会做。

所以这样看下来道具系统更像是一个入口,道具的很多实现功能并非由自身来实现,而是由其他系统来实现的,表面上来看是道具功能,实际上它与别的逻辑有着很强的关联,与NPC系统一样,也是一个基础系统。但二者又有不同,NPC是大的功能系统入口,而道具是具体行为、效果的入口。相似的是二者都会弹出不同的窗口实现不同的效果。但道具也有需要注意的地方,道具是可以买卖的,而且每个道具还有最多允许叠加的数量。

其中和道具有着直接联系的是描述、ID和分类,以及有没有道具,有多少道具,道具要存下来,不要丢掉。

image-20250721155033942

接下来查看道具系统的需求分析,也是先看策划案,也没多少东西的样子。

image-20250721160052963

image-20250721161343513

刚才表中只有十种道具,但是只要一分类,就是很多种,这就需要分析需求了,要考虑到未来的扩展性,做一个道具的时候,除了已知的需求,还需要想到一些潜在的需求。

在这里将道具暂时分为可使用、可装备、任务、材料和通用几个类型。这也是从刚才的表中道具类型分析出的需求,暂时制定了这样一个道具的分类。

image-20250721190250126

接下来是道具系统的对外接口,而在游戏开发的前期,策划案必须要有足够高的完成度,例如在做到道具系统的时候,至少将来的合成装备、商店、背包各种系统的策划案已经都做出来才行,这样道具系统才能做的完整。

一般来说获取装备列表只有背包,背包相当于是道具系统的对外展示的一个接口。没有任何一个别的系统,道具系统在理论上是看不见的。


无论是什么系统,基本都是由Manager、Service组成的,所以来看整个道具系统,客户端一个Manager一个Service一个Item(道具本身),服务端一个Manager一个Service多了一个Item道具本身,而且要在数据库中加一个表,客户端会有一个配置表(ItemDefine)还有一个与之对应的协议(NItemInfo)。

image-20250721191703440

做系统的第一步是要先做好数据结构,即配置表、协议和数据库。

6.3.2 配置表

配置表完成后,先与策划表进行对比,看看策划与技术做表格有什么区别。

首先技术表格必须要有Key,这个是主键;ID就这么排下来即可;而策划表中的number则不知道;名字二者都有;描述也是相同即可;类型,技术标中的徽章;而在原表中药品这一列,在配置表中是类别(直接显示),但配置表中多了一列类型,是在协议里定的,正常的情况下有Normal或者是Material(它们都是枚举值,这里写枚举值是因为它们和游戏的逻辑代码有直接的关联,直接使用于代码);而策划案中的战斗恢复一列,是没办法直接用于代码的,所以要替换掉,在配置表中是功能和参数两栏(类似于NPC的配置表),例如恢复HP(Function),回复500HP(参数),并在之后预留了一个Params(数组类型),万一后面那个功能很复杂,需要多个参数;能不能使用(Boolean);使用的CD,多长时间使用一次,也单独列了一列;最后是两个价格(购买和出售)。

两种表有区别,这种区别主要是为了在技术角度比较容易去实现,差别最大的也只是道具的具体功能,别的并无大的区别,在制作配置表的时候要尽可能地去思考道具系统要做到事。

image-20250721194456091

image-20250721194404019

在做好配置表后就可以生成便于使用的配置表了。

在生成好txt的配置表后,第一件事就是要先到服务端将所有字段放到Common中,因为是新建的,直接新建一个。

image-20250721224021294

定义结构,用枚举值来定义功能(ItemFunction),考虑到将来有可能会把某一种道具通过网络发送到客户端里面,就将这个定义丢掉协议里面了。

image-20250721230030934

image-20250721230039968

6.3.3 协议

打开协议,并增加新的道具类型枚举(ITEM_TYPE)。

image-20250721231020133

道具信息包含ID和数量(因为客户端和服务端都有表,只需要拿到ID去配置表中查询即可,传输过程中数据越小越好,所以这里只用ID和Count就好)。

image-20250721231119451

在角色信息身上怎加一个道具信息,代表了角色身上的所有道具,repeated代表可重复的,意思是这是一个数组,repeated代表了这是一个可重复类型的数组,代表这里面可能有任意多个,在这里定义的原因是希望在用户登录的时候就会将他的初始道具发送给客户端,让客户端知道,如果以后要决策同步,也可以通过这种方式一次同步多种信息到客户端。

image-20250721231311048

然后找到角色进入游戏的响应,以前只返回了是否进入(result),以及错误信息(errormsg),现在就要将角色信息拉一份下来。以前没有,以前只有在进入地图时才会有,但这个只需要一次,因为进入地图和离开地图要进行多次,会有很多切换场景的情况,这个时候并不需要将道具信息清除掉或者刷新,清掉或者刷新只有角色选择进入游戏才需要,这个事件就是在这个时候发生的,而且只会发生一次。

image-20250721232014381

改完协议后记得生成一下,没有报错即可

image-20250721232543453

image-20250721232608099

配置表改完、协议改完,数据结构就已经改完了两个,接下来就只剩数据库了。


6.3.4 DB(数据库)

因为之前加过一个道具表,所以只需要在上面加入需要的字段等信息就好,当然也可以重新再加一遍,这里选择重新在加一便。

先新建一个实体,并修改名称。

image-20250722111610811

为其加入字段:角色ID(CharacterID,和那个角色做关联),道具ID(ItemID),道具数量(ItemCount)以及拥有者(Owner)

先新增ItemID和ItemCount,ItemID为整形(int32),ItemCount也是整形。

image-20250722112211932

image-20250722112221441

接下来就是建立关联,添加关联地时候要注意,右边是要关联地实体(TCharacterItem),左边是角色(TCharacterItem)。而一个角色要对应多种道具,所以多重性默认就好,导航属性的名字可以稍微改一下,角色是拥有者(Owner),角色的Items,通过访问角色身上的Items,就可以找到所有道具,关联也改成角色Items(CharacterItem)然后点击确定即可。

image-20250722112426338

再关联之后这里面就出现了一个角色ID和Owner,这就是对应的一种关联,而角色表出现了Items属性。

image-20250722112856129

然后就可以生成数据库了,注意是根据模型生成数据库,并等待弹出的界面走完。

image-20250722113006823

image-20250722113056713

image-20250722113134458

全部保存一次,然后到数据库那边将刚刚生成的语句执行一遍。先新建查询,然后将刚刚生成的SQL语句粘贴到里面,点击新建查询下面的执行。

image-20250722151934885

而这里数据库中道具只有ID和Count是因为在数据库中存,也是和协议一样,只存必要信息,只要知道ID后就可以查询是什么道具甚至是道具的各种属性功能,在知道数量就可以知道有几个就可以了。

image-20250722152112630

那么现在配置表制作好了,协议修改好了,数据库生成好了,数据结构就准备齐了。


6.3.5 管理器

先新建一个道具管理器类,再建一个道具类。

image-20250722153928320

先来到Item类,因为数据库的操作不应当太过于频繁,这里会一次性将道具从数据库中都拉出来,然后在内存中存一份,这样在客户端和服务端之间联系的时候只需要读内存就好了;先做一个封装。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace GameServer.Models
{
    class Item
    {
        // 数据库中对应的道具
        TCharacterItem dbItem;

        public int ItemID;

        public int Count;
        // 构造函数中做一下初始化
        public Item(TCharacterItem item)
        {
            this.dbItem = item;

            this.ItemID = (short)item.ItemID;
            this.Count = (short)item.ItemCount;
        }
        // 添加方法
        public void Add(int count)
        {
            this.Count += count;
            dbItem.ItemCount = this.Count;
        }
        // 删除方法
        public void Remove(int count)
        {
            this.Count -= count;
            dbItem.ItemCount = this.Count;
        }
        // 使用,因为其他系统还没有做,暂时留空
        public bool Use(int count = 1)
        {
            return false;
        }
        // 简化输出
        public override string ToString()
        {
            return string.Format("ID:{0},Count:{1}", this.ItemID, this.Count);
        }
    }
}

然后来看Item管理器,而这个管理器并不是单例,它随角色创建而创建,随角色销毁而销毁。

using Common;
using GameServer.Entities;
using GameServer.Models;
using GameServer.Services;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace GameServer.Managers
{
    class ItemManager
    {
        Character Owner;
        // 维护一个字典来管理所有角色身上的道具
        public Dictionary<int, Item> Items = new Dictionary<int, Item>();
        public ItemManager(Character owner)
        {
            // 知道是那个角色
            this.Owner = owner;
            // 得到角色身上的所有道具。
            foreach(var item in owner.Data.Items)
            {
                // 将拿到的道具添加到字典中
                this.Items.Add(item.ItemID, new Item(item));
            }
        }
        // 使用道具的流程,如果不传入后面的值,一次使用1个,
        public bool UseItem(int itemID, int count=1)
        {
            // 加个日志,看看使用的日志。
            Log.InfoFormat("[{0}]UserItem[{1}:{2}]", this.Owner.Data.ID, itemID, count);
            Item item = null;
            // 先判断一下有没有
            if(this.Items.TryGetValue(itemID, out item))
            {
                // 判断数量够不够
                if (item.Count < count)
                    return false;

                // TODO: 增加使用逻辑
                // 删除使用的道具
                item.Remove(count);

                return true;
            }
            return false;
        }
        // 判断道具是否存在
        public bool HastItem(int itemID)
        {
            Item item = null;
            if (this.Items.TryGetValue(itemID, out item))
                return item.Count > 0;
            return false;
        }
        // 获取道具
        public Item GetItem(int itemId)
        {
            Item item = null;
            this.Items.TryGetValue(itemId, out item);
            Log.InfoFormat("[{0}]GetItem[{1}:{2}]", this.Owner.Data.ID, itemId, item);
            return item;
        }
        // 增加道具
        public bool AddItem(int itemId, int count)
        {
            Item item = null;
            // 判断道具是否存在
            if (this.Items.TryGetValue(itemId,out item))
            {
                // 如果存在直接增加
                item.Add(count);
            }
            else
            {
                // 如果不存在,在数据表里插一条新数据;再插入时除了要插入到DB里面,还要插入到字典中,两个需要同时进行
                TCharacterItem dbItem = new TCharacterItem();
                dbItem.CharacterID = Owner.Data.ID;
                dbItem.Owner = Owner.Data;
                dbItem.ItemID = itemId;
                dbItem.ItemCount = count;
                Owner.Data.Items.Add(dbItem);
                item = new Item(dbItem);
                this.Items.Add(itemId, item);
            }
            Log.InfoFormat("[{0}]AddItem[{1}] addCount:{2}", this.Owner.Data.ID, item, count);
            // 插入完成后保存一下
            DBService.Instance.Save();
            return true;
        }
        // 道具移除
        public bool RemoveItem(int ItemId, int count)
        {
            // 如果不存在返回
            if (!this.Items.ContainsKey(ItemId))
            {
                return false;
            }
            Item item = this.Items[ItemId];
            // 如果数量不对返回
            if (item.Count < count)
                return false;
            // 移除
            item.Remove(count);
            Log.InfoFormat("[{0}]Remove[{1}] removeCount:{2}", this.Owner.Data.ID, item, count);
            // 保存到DB
            DBService.Instance.Save();
            return true;
        }
        // 数据转换
        public void GetItemInfos(List<NItemInfo> list)
        {
            // 从内存数据转换到网络数据上
            foreach(var item in this.Items)
            {
                list.Add(new NItemInfo() { Id = item.Value.ItemID, Count = item.Value.Count });
            }
        }
    }
}

当道具发生增删改,就一定会调用Save进行保存,这个Save会比较实时的发送给DB,如果10万个玩家同时增加或者使用了道具,那么服务器就会进行10万次请求,这样对服务器的压力是巨大的,所以Save并不会立即发生。

那么来到DBService来开放一个Save方法。

public void Save()
{
    entities.SaveChangesAsync();
}

这里用的是entities的SaveChanagesAsync,这里用这个是因为它是立刻返回的,不会等Save完成才返回回去,即调用之后,服务器慢慢存,可以不用管,游戏逻辑继续跑,但服务器这边的调用后台慢慢发生,因为DB保存很慢,数据积累得越多,存的越慢。

注:回档问题:为了避免短时间内有大量的保存,可以做一个简单的定时器,每次Sace的时候判断一下,用现在的时间减去定时器大于多少时间,才会Save一下。这样就设置了一个DB的存储间隔,而不是每秒都去存。一般是在1分钟或者更多保存一次。

那么管理器写好了,开始调用,调用要在角色上进行调用,也就是角色创建的位置。

        // 先让角色有道具(声明变量-道具)
        public ItemManager ItemManager;
         public Character(CharacterType type,TCharacter cha):
     base(new Core.Vector3Int(cha.MapPosX, cha.MapPosY, cha.MapPosZ),new Core.Vector3Int(100,0,0))
 {
     this.Data = cha;
     this.Info = new NCharacterInfo();
     this.Info.Type = type;
     this.Info.Id = cha.ID;
     this.Info.Name = cha.Name;
     this.Info.Level = 1;//cha.Level;
     this.Info.Tid = cha.TID;
     this.Info.Class = (CharacterClass)cha.Class;
     this.Info.mapId = cha.MapID;
     this.Info.Entity = this.EntityData;
     this.Define = DataManager.Instance.Characters[this.Info.Tid];

     // 构建角色道具管理器
     this.ItemManager = new ItemManager(this);
     // 填充网络数据(因为网络(协议)数据是在NCharacterInfo)
     this.ItemManager.GetItemInfos(this.Info.Items);
 }

那这个是在什么时候创建的?查看引用,是在添加角色(AddCharacter)的时候调用的,而添加角色又是在游戏进入的时候调用的。

image-20250722190618712

image-20250722190717128

当游戏一进入,先拿到角色信息,然后Add进入,Add的时候会创建道具管理器,然后道具管理器会将数据加载进来,把自己的数据填充好,然后送给网络协议,然后当协议走到后面的时候,返回到客户端的时候(sender)。

image-20250722190939277

但这里少了一条,少了一条Character赋值,加上这条赋值,当角色身上有道具时,登录到客户端后,就可以收到这个道具了(character.Info)。

image-20250722191103944

image-20250722191404554

做到现在就差不多了,但是有个很大的问题,要怎么进行测试?要怎么才能知道身上有道具了?没有背包看不到道具,没有其他系统也没有办法验证道具的效果,既没有道具的掉落,也没有商店能够买到,智能自己写测试用例,就在赋值这条语句后写

// 道具系统测试
//  假定一个道具ID是1
int itemID = 1;
// 先测试一下道具查询方法,判断一下身上有没有道具,并加个日志输出出来
bool hasItem = character.ItemManager.HastItem(itemID);
Log.InfoFormat("HasItem:[{0}]{1}", itemID, hasItem);
if (hasItem)
{
    // 如果有道具,就删除一个。(测试删除道具的方法)
    character.ItemManager.RemoveItem(itemID, 1);
}
else
{
    // 如果没有,就加两个道具
    character.ItemManager.AddItem(itemID, 2);
}
// 取个道具出来,看看身上有没有道具
Models.Item item = character.ItemManager.GetItem(itemID);

Log.InfoFormat("Item[{0}][{1}]", itemID, item);

然后启动系统测试一下服务端,客户端一会儿在测试,在点击进入游戏按钮的瞬间,服务端会有刚才写的测试案例。

image-20250722200342812

image-20250722200454837

然后现在是有道具的无道具的情况下,退出一下,在进入,查看道具删除功能是否可行,从服务端可以看到道具已经剩一个了。

image-20250722200642903

那么这样道具系统功能服务端测试没有问题,再去数据库看一下道具是否已经存在了,可以看到数据库里面已经有了道具信息,但是角色ID不是很对应,这个留到做完背包系统后进行优化。

image-20250722210701765

之后对ID进行优化时,表里的ID用ATMID,如果是DBID,则直接用ID,角色的话就直接用EntityID,具体情况到时候再定。


服务端的已经完成,现在就是客户端的,同样的,客户端也有ItemManager和Item,同样新建两个类,但是之前生成了新的Common和协议,需要先手动将这两个添加到客户端的位置,当然,还是要看自己改了那些,要是只改了Common或者协议,只替换一个即可,为了保险也可以全部。

image-20250722202048717

先来看Item类。

using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Models
{
    public class Item
    {
        public int ID;
        public int Count;
        // 与服务端的区别,这边用的是协议里面的NItemInfo,而服务端用的是DB里面的,唯一的区别就是一个来源于网络,一个来源于DB
        public Item(NItemInfo item)
        {
            this.ID = item.Id;
            this.Count = item.Count;
        }

        public override string ToString()
        {
            return string.Format("ID:{0},Count{1}", this.ID, this.Count);
        }
    }
}

然后来看Manager,这里用的是单例,因为这个管理器不可能管理得到别人的道具。

using Models;
using Common.Data;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEngine;

namespace Managers
{
    public class ItemManager : Singleton<ItemManager>
    {
        public Dictionary<int, Item> Items = new Dictionary<int, Item>();

        internal void Init(List<NItemInfo> items)
        {
            // 先清空一下
            this.Items.Clear();
            // 从网络填充过来(客户端的Item都是针对协议的,而服务端都是针对DB的)
            foreach(var info in items)
            {
                Item item = new Item(info);
                this.Items.Add(item.ID, item);

                Debug.LogFormat("ItemManager:Init[{0}]",item);
            }
        }
        public ItemDefine GetItem(int itemID)
        {
            return null;
        }
        public bool UseItem(int itemID)
        {
            return false;
        }
        public bool UseItem(ItemDefine item)
        {
            return false;
        }

    }
}

而初始化同样是在UserService中进行,空了好久的那个地方。

image-20250722203437650

但是怎么测试?在管理器中有道具输出,但是这还不至于能够直接输出, 可以在服务端再加入代码,使其多加入两个道具。

image-20250722203943180

将道具ID改为2,并将数量改为5。

image-20250722204205649

然后再次启动,看看道具信息是否正确,这里可以看到ID1,数量是2,这是因为服务端的数据是需要在下次启动的时候才能看到,所以退出后在进入一次。

image-20250722204448609

这一次,ID1,数量是2,ID2,数量是5。

image-20250722204559822

这样就完成了道具系统,如果现在有背包系统,就可以直接从ItemManager中拿到道具的信息和数量,直接显示到背包里。

道具系统本身没有UI,它主要是作为一个基础系统,为其它系统提供服务,也是基础的游戏系统,而且关联度很高。

6.4 背包系统

6.4.1 本节要点

  • 背包系统需求分析
  • 背包的作用
  • 背包UI的制作
  • 背包系统数据结构(核心)

先来看一下背包系统的策划案,首先是背包的面板,并且可以显示20行,而在实际的实现过程中,需要将需求稍微改一改,而下面还有仓库的面板。

image-20250723175118903

仓库的面板还有一个分页,可以选择第几页。

image-20250723175241202

点击了道具之后会弹出来一个使用面板,可以进行需要进行的操作。

image-20250723175355595

后面是一些别的道具使用或者丢弃的UI,而在最后一个注意到有不可使用,不可装备类道具,这个在道具系统的时候并没有要求,但是不打紧,因为在做道具系统的时候有预留相应的板块。

image-20250723175508912

这就是背包做出来的效果图,左边是仓库,右边是人物背包,但是考虑到背包系统本身已经很复杂,而又多出一个仓库,而这个仓库又是一个可以随时跟随背包立即打开的东西,这个仓库除了能够增加逻辑复杂度外,在玩家视角除了多了几页之外,并无太大的区别,要不然这俩会成为两个系统,而这俩系统又有着很高的相似度,将来想写的话可以把背包系统复制一下,换个名字就可以了,这里就不需要全部做出来了,这里稍微修改一下设定,只不过可能是位置的问题,毕竟仓库并不需要某个NPC,需要跑过去才能点开,它又不是完全独立,需要和背包系统绑定,所以最终决定做一个背包就好,加个分页,

image-20250723180652318

小Tips:想要管理背包里面的格子,这里面的这六个物品,可以分别拖拽到一个上面叠加起来,这就产生了一个问题,它与道具系统之间有什么关联?道具系统维护着一些道具,背包系统也会显示一些道具,这两者从设计上要怎么规划才好?

既然背包也是来显示道具,但是道具系统本身并不会呈现,如果说背包里面也维护一份和道具差不多的数据,这里将背包想象成一个道具的展示页面,即道具系统的UI层,也就是说背包的底层数据由道具系统来驱动,背包内的道具信息从道具系统来,背包自己不做任何的维护,但是背包系统维护有多少个格子,每个格子里面放了什么,放了多少个,用道具的ID来关联,在知道道具的个数,然后在维护身上的格子,就足够了。

但是随之而来的问题是,在数据库中怎么存放??

如果说也像传统的一样,有100个格子,一个人一条记录,数据库里面放一个仓库表,100个格子加100个字段,这样设计是不够优化的,如果说游戏上线之后,策划说要加到200个格子,那样的话数据库不就很痛苦了?所以不用多个字段来储存多个格子,那像这种复杂的数据要怎么储存?这里可能会有人想到用字符串,ID,数量来进行存储,这样的方式也可以,但是存储空间会变大,一个ID四个字节(21亿-10字符),性能方面会很差,储存容量会很大,这是一方面,另一方面逻辑中用的是数据,DB中存的是字符串,那么就需要频繁的做解析,转换、解析、转换,每次存续消耗性能是极大的。

那不能用字符串,方案也有很多,这里老师选择了一种,二进制数据,因为在DB当中是支持二进制存储的,当然这个也受限于数据库支持那种类型的存储,数据库一般支持字符串,各种数据类型,对于这种复杂的结构,他就有一种二进制类型BINARY,而且是可变长与定长两种,一般用这种类型可以存储任何类型的数据,因为这种情况下数据库不会关心里面存的是什么,只要能转成字节(任意数据)的东西,全部都可以丢到数据库中,那也就是说可以将整个背包的所有东西都存成一个字节数组,然后丢到一个字段当中去,这样将来格子无论增加多少或者减少多少,只不过是字节数组的长短变化,所以基于这种设计理念,定好了二进制存储。

image-20250723185134154

二进制具体要用什么样的形式?每个类型占用的字节如下图,而这里也会是一种特殊的数据结构,先继续,之后在内容中穿插。

image-20250723192540686

接下来看看背包的作用,先对背包起什么作用做一个简单的划分,一个是查看和管理(看看身上有什么道具),提供一个UI界面,界面上带一个分页功能(道具很多),还有一个整理功能,当道具很乱的时候,就会从第一个格子将道具全部重新排一遍,在排的过程中还会将能叠在一起的叠在一起。

另一个就是与道具系统的交互,在上一节中道具系统没有UI只能是写代码来实现,这节中有了背包系统,就可以做一部分交互了,另外一部分则是因为背包不能自动产生道具,背包只能消耗、使用、丢弃道具,而在平时的时候捡到道具之后会自动进入背包,那背包肯定要开放一个能够增加道具的接口,捡到东西后就追加到背包中,背包就基本是这些个功能。

image-20250723201945671

接下来再来看看道具系统的划分,和道具系统相比,服务端少了很多东西,服务端没有Manager了,因为背包没什么需要管的。

首先是协议,会有一个背包的协议和保存请求的协议,这里是为了简化背包系统的设计,将逻辑迁移到了客户端,让背包成为一个单机的背包,服务端有多少道具,要怎么管理,怎么分配,交给服务端就好,服务端不需要关心,服务端只需要关心背包那个格子放什么,背包在客户端拆完,分配好之后,将数据上传,服务端存一下就好了。

服务端就只做了一件事,数据的传送,把背包的数据传给客户端以及接受客户端新的背包数据,而做这个事需要有通讯,有通讯就有Service,所以背包在服务端就只需要Service。

DB这边,因为要加背包,就只需要加一张新表就可以了。

image-20250724181635504

6.4.2 背包UI制作

先将要用到的UI资源放到相应的文件夹中。

image-20250724183750471

背包UI的效果图在开始已经见过了,会有一个分页的按钮,需要用到一个type类似的视图,但是Unity并没有天生的type视图,或许某些第三方插件有,但是天生的,可供直接使用的type视图并没有,只是提供了一个基本的按钮,连一页都没有,是很松散的东西。在这里希望有类似于这种type,还能少写一些代码,能够比较好的维护的type标签,所以要自己手搓一个type类。

小Tips:写代码的时候可能会有不安全的代码,要在Unity中使用不安全的代码,需要在Unity中勾选一下Allow ‘unsafe‘ Code即可。

image-20250724191243758

image-20250724191302447

先加背包的协议,还是找到上次的位置,在最后加上NBagInfo,并新加一个NBagInfo的Message,只需要两个结构,一个是背包的格子数量,一个是背包放的的道具数据列表(背包里面放了多少道具)。bytes是字节类型,可变长,说是可变长,其实是根据Unlocked来的,Unlocked*4就是他的字节数量

image-20250724191850263

在加两个新协议,一个是背包保存,一个是背包。背包保存是信息发送,将信息发送给服务器,让服务器将修改后的背包数据保存下来。

image-20250724192639655

然后生成一下协议,没有报错就ok。

image-20250724192729276

image-20250724192738631

先在ItemDefine中加入背包系统需要的道具表和道具对应的图片资源。这次将会为道具增加图标,Icon就是用来对应道具以及它所对应的图标;而StackLimit是代表道具允许叠加多少个,这个是用于背包(背包会使用的数据)。

        public int StackLimit { get; set; } // 堆叠限制
        public string Icon { get; set; }    // 

然后去服务端将服务器编译一下,没有失败即可。

image-20250724194557741

因为Common会引用协议,所以在编译生成后记得将Common和协议复制到客户端当中。

image-20250724194707188

小Tips:Entities和Models的区别是,Models并不是在逻辑和服务器之间产生这种关联,包括数据传输这些,主要在于维护本地数据,也不需要在游戏世界当中与服务器同步,这种数据就都是Models。

接下来开始制作背包的UI,先起一个新Canvas改名为UIBag,在其下面添加一个Panel,并在其之下添加一个名为BG的Panel,然后调整它的格式大小,它就是背包的主背景。

image-20250725171236641

然后为其添加一个Image并改名为TitleBar,这个就是背包UI的标题栏,在其下面添加Button和Txt组件,分别改名为ButtonClose和Title,分别是关闭按钮和背包标题,为他们都选上合适的背景图,调整到合适的大小。

image-20250725171458576

在加入一个按钮组件,选好北京后,再将文字改为“整理”,并适当调节,使其看起来顺眼。

image-20250725171635311

然后是左下角的金钱数量,先加入一个Image,并为其添加Image和Text组件并分别添加金钱背景和999999999内容,同样调整到合适大小,放到合适的位置。

image-20250725171827033

然后是背包的核心,格子。先来一个空节点,在空姐点下分别添加两个按钮组件和一个Scroll View组件,并将两个按钮的内容改为背包1和背包2。然后插入图片,并挑选合适的背包背景图片,在这里加入Grid Layout Group这个Componet,然后按快捷键ctrl+d复制一些,然后就可以有序的对内部的格子进行排序。调整好格式位置等后将其复制一份作为背包2的格子,记得将背包2的先隐藏掉。

image-20250725181820334

image-20250725172309478

先查看TabView的代码,这里面维护着有几个Button,几页(通常情况下默认他们俩相等)。在初始化的时候先遍历一下所有的tabButton,而tabButton里面有一个tabView(这个Button是属于谁的,具体的逻辑在TabButton脚本中),

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TabView : MonoBehaviour
{
    public TabButton[] tabButtons;
    public GameObject[] tabPages;

    public int index = -1;
    IEnumerator Start ()
    {
        for (int i = 0; i < tabButtons.Length; i++)
        {
            // 初始化的时候指定一下所有者
            tabButtons[i].tabView = this;
            // 加一个索引(第几页)
            tabButtons[i].tabIndex = i;
        }
        // 等待一帧,确保UI元素都加载完毕
        yield return new WaitForEndOfFrame();
        // 默认选中第一个标签页
        SelectTab(0);
    }
    // 选择标签页
    public void SelectTab(int index)
    {
        // 判断一下当前是第几页
        if(this.index != index)
        {
            // 记录当前页
            for (int i = 0; i < tabButtons.Length; i++)
            {
                // this.index就是当前是第几页
                tabButtons[i].Select(i == index);
                // 切换到指定的标签页
                tabPages[i].SetActive(i == index);
            }
            
        }
    }
    // Start is called before the first frame update

    // Update is called once per frame
    void Update()
    {
        
    }
}

接下来再来看看TabButton的逻辑。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class TabButton : MonoBehaviour
{
    // 指定选中时候的图片
    public Sprite activeImage;
    // 指定正常时候的图片
    private Sprite normalImage;
    // 属于谁的。
    public TabView tabView;

    public int tabIndex = 0;
    public bool select = false;

    private Image tabImge;
    // Start is called before the first frame update
    void Start()
    {
        // 一启动的时候会从当前按钮身上将图片取出来
        tabImge = GetComponent<Image>();
        // 存下来
        normalImage = tabImge.sprite;
        // 动态的给按钮添加点击事件,点击事件的时候就切换标签
        this.GetComponent<Button>().onClick.AddListener(OnClick);
    }
    public void Select(bool select)
    {
        // 点击事件的时候就切换标签
        tabImge.overrideSprite = select ? activeImage : normalImage;
    }
    public void OnClick()
    {
        // 切换标签
        this.tabView.SelectTab(this.tabIndex);
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

先来看看UIIconItem,上面有两个动态标签,这样在初始化格子的时候,只需要找到这个格子上绑定的脚本,然后set一下就有了。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class UIIconItem : MonoBehaviour
{
    public Image mainImage;
    public Image secondImage;

    public Text mainText;
    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
    public void SetMainImage(string iconName,string text)
    {
        // 一个图标名字,一个文本
        this.mainImage.overrideSprite = Resloader.Load<Sprite>(iconName);   // 加载图标
        this.mainText.text = text;  // 显示文本
    }
}

至于其他的一些,留到后面正式开始写的时候再写。

6.4.3 配置表

先新增一个背包的表,然后为其增加新的标量属性,Item和UnLocked属性,注意其中Item是二进制属性(Binary),UnLocked是整形(int32)。

image-20250725190133516

然后给它和角色表建立关联,这里一定要注意,二者与之前的道具不同,是一对一的关联(一个角色只有一个背包),然后其他名字可以改成自己需要的名字。

image-20250725190343785

image-20250725190413146

然后就可以根据模型生成数据库了,弹出来的界面等到运行完后就点击完成。

image-20250725190553672

image-20250725190650077

然后就可以将生成出来的代码粘贴到数据库新增查询中运行,生成新的数据库表了。

image-20250725190711898

image-20250725190818017

刷新一下就可以看到刚刚增加的背包表了。

image-20250725190911212

在数据库生成成功后,记得重新生成一下解决方案,没有失败即可。

image-20250725195142377

并记得将Common和Protocol复制到客户端一份(为避免出问题)。

image-20250725201841411

6.4.4 背包服务

现在服务端新建一个BagService类,然后来编写服务端的背包服务。服务端的背包服务很简单,只需要维护背包数据,它只有一个方法,就是处理背包的保存。

using Common;
using GameServer.Entities;
using Network;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace GameServer.Services
{
    class BagService : Singleton<BagService>
    {
        public BagService()
        {
            MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<BagSaveRequest>(this.OnBagSave);
        }
        public void Init()
        {

        }
        void OnBagSave(NetConnection<NetSession> sender,BagSaveRequest request)
        {
            // 当客户端发送背包保存请求时,处理该请求
            Character character = sender.Session.Character;
            Log.InfoFormat("BagSaveRequest: character{0}: UnLocked{1} ", character.Id, request.BagInfo.Unlocked);

            if(request.BagInfo != null)
            {
                // 更新角色的背包信息
                character.Data.Bag.Items = request.BagInfo.Items;
                // 保存背包数据到数据库
                DBService.Instance.Save();
            }
        }
    }
}

然后就需要初始化背包,之前的初始化都有管理器,但是背包不需要,因为背包就一个结构,而且它是一对一的关系,并不是一对多或者多对一,所以并不需要管理器来管理,里面只需要赋值,一个UnLocked状态,一个是道具列表(Items)。它里面就只有这俩,所以能省则省,这三行代码就直接替代了一个类。

this.Info.Bag = new NBagInfo();
this.Info.Bag.Unlocked = this.Data.Bag.UnLocked;
this.Info.Bag.Items = this.Data.Bag.Items;

这些做完之后要将读表逻辑加一下,能够让服务端读取到配置表中的信息。先定义Items,然后再加入读取的逻辑即可。

image-20250725212212094

回到上节课的Item管理器,将两个保存先注销一下,在上一节的时候也有提,在保存的时候要做延时保存,但是还没有加,如果这里继续让它们直接保存,服务器会出问题。

image-20250725212526661

这里用的是异步的Save,会出现这样的问题,用同步的Save是可以的,先不要保存,在一次添加完大量数据后在进行保存。在什么时候,哪里保存?上一节中在UserService中时有测试道具系统的代码,一开始玩家并没有背包,背包内是没有任何道具的,所以在用户登陆的时候为用户追加道具,而在角色创建的时候,需要给角色初始化创建一个背包,所以要在两个地方追加两个代码。

先在OnCreateCharacter方法中的创建角色前,加入创建背包初始化,这里顺序不能错,因为背包和角色是一对一的关系,必须有角色就有背包,在保存数据之前一定要保证有,不然会报错;而道具和角色是一对多的关系,可能多,可能少,甚至没有也不会报错。

private void OnCreateCharacter(NetConnection<NetSession> sender, UserCreateCharacterRequest request)
{
    // 显示一下用户名
    Log.InfoFormat("UserCreateCharacterRequest: Name:{0}  Class:{1}", request.Name, request.Class);



    TCharacter character = new TCharacter()
    {
        Name = request.Name,
        Class = (int)request.Class,
        TID = (int)request.Class,
        MapID = 1,
        MapPosX = 5000,     // 初始出生位置x
        MapPosY = 4000,     // 初始出生位置y
        MapPosZ = 820,
    };
    // 先创建新的背包
    var bag = new TCharacterBag();
    // 让背包所有者等于自己
    bag.Owner = character;
    // 初始化一个背包格子,大小为0(因为一上来没有背包数据,但是又不能为空)
    bag.Items = new byte[0];
    // 先解锁20个背包格子
    bag.UnLocked = 20;
    // 将背包信息添加到数据库中,并赋给角色
    character.Bag = DBService.Instance.Entities.CharacterBags.Add(bag);
    // 然后创建角色
    // 这里的Class是枚举类型,转换成int存储到数据库中
    character = DBService.Instance.Entities.Characters.Add(character);
    // 在Add之后要将角色返回一下,并将返回的对象Add进去,保证是最新的版本
    sender.Session.User.Player.Characters.Add(character);
    // 进行修改后,保存到数据库
    DBService.Instance.Entities.SaveChanges(); 
        NetMessage message = new NetMessage();
    message.Response = new NetMessageResponse();
    message.Response.createChar = new UserCreateCharacterResponse();



    message.Response.createChar.Result = Result.Success;
    message.Response.createChar.Errormsg = "None";

    // 将当前已经添加的角色添加到列表中,这样就能看到当前有多少角色了
    foreach (var c in sender.Session.User.Player.Characters)
    {
        NCharacterInfo info = new NCharacterInfo();
        info.Id = 0;
        info.Name = c.Name;
        info.Type = CharacterType.Player;
        info.Class = (CharacterClass)c.Class;
        info.Tid = c.ID;
        message.Response.createChar.Characters.Add(info);
    }

    byte[] data = PackageHandler.PackMessage(message);
    sender.SendData(data, 0, data.Length);
}

而要对背包进行测试,一定要有足够的道具,所以在上一节中的道具测试这边加些逻辑,因为要有很多道具,之前有的道具就不删了,先将删除道具的代码注释掉。然后对几个不同的道具增加不同的数量。

image-20250727185847557

而在配置表中会增加叠加数量,这里写这么多是为了查看背包的拆分逻辑,而在一次性增加了这么多道具的情况下,在这里进行道具保存。当玩家进入时,如果没有道具,就会一次性增加这么多的道具。

image-20250727190159851

现在做完了背包服务,来到主城页面为背包添加弹出按钮,先新建一个Button按钮,选上背包图案,并放到合适的位置。

image-20250727211251924

然后先编写一下打开背包的脚本,在UIMain脚本中加一个方法就行了。

    public void OnClickBag()
    {
        UIManager.Instance.Show<UIBag>();
    }

然后到UIManager中查看一下,里面还没有加新UI的地址,加上。

    public UIManager()
    {
        // 所有UI系统的路径。
        this.UIResources.Add(typeof(UITest), new UIElement() { Resources = "UI/UITest", Cache = true });
        this.UIResources.Add(typeof(UIBag), new UIElement() { Resources = "UI/UIBag", Cache = true });
    }

6.4.5 背包逻辑(客户端)

现在UserService中讲初始化背包加上,与道具初始化的位置一样。

image-20250727213540462

当登录游戏时,会初始化一个空的背包,而服务器那边也会创建成功,也会有背包的数据,并将背包的数据发送过来,然后BagManager会将背包的数据进行解析,因为服务端放的是字节,所以客户端要将字节变成需要的结构,先来看看BagItem里面有什么。而这里为什么用结构体不用类呢?这里面是属于引用类型,还是直用类型?如果是引用类型的话,比如说Bagatem1和Bagatem2,是不能用a=b的形式交换的,而如果是直用类型,是可以交换两个格子,所以这个是为将来交换两个背包格子的时候方便。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace Models
{
    // 属性,这是一种结构布局,这代表了这个结构在内存中的存储格式
    [StructLayout(LayoutKind.Sequential, Pack = 1)]
    struct BagItem
    {
        // 客户端真正需要知道的只有ID和数量
        // ID
        public ushort ItemID;
        // 数量
        public ushort Count;

        public static BagItem zero = new BagItem { ItemID = 0, Count = 0 };

        public BagItem(int itemID,int count)
        {
            this.ItemID = (ushort)itemID;
            this.Count = (ushort)count;
        }
        public static bool operator ==(BagItem lhs, BagItem rhs)
        {
            return lhs.ItemID == rhs.ItemID && lhs.Count == rhs.Count;
        }
        public static bool operator !=(BagItem lhs, BagItem rhs)
        {
            return !(lhs == rhs);
        }
        ///<summary>
        ///   <para>Returns true if the object are equal.</para>
        ///</summary>
        /// <param name="other"></param>
        // 这个重载暂时没用,是为了有些时候方便和其他格子作比较
        public override bool Equals(object other)
        {
            if(other is BagItem)
            {
                return Equals((BagItem)other);
            }
            return false;
        }
        public bool Equals(BagItem other)
        {
            return this == other;
        }
        public override int GetHashCode()
        {
            return ItemId.GetHashCode() ^ (Count.GetHashCode() << 2);
        }
    }
}

再来创建一个新的BagManager类,用来管理背包,它会负责管理解锁到第几个格子,背包的格子信息,以及从服务端传回的消息,是一个极其重要的脚本。

using Models;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine.Analytics;

namespace Managers
{
    class BagManager : Singleton<BagManager>
    {
        // 解锁到第几个格子
        public int Unlocked;
        // 背包格子数量
        public BagItem[] Items;
        // 网络传回的消息
        NBagInfo Info;

        unsafe public void Init(NBagInfo info)
        {
        //
        this.Info = info;
        this.Unlocked = info.Unlocked;
        // 初始化背包格子数量(20)
        Items = new BagItem[this.Unlocked];
        // 如果Items不是空的并且Items的长度大于等于解锁的格子数
        if (info.Items != null && info.Items.Length >= this.Unlocked)
        {
            // 直接将字节数组转换成结构体数组
            Analyze(info.Items);
        }
        else
        {
            // 否则说明是第一次登录,从新建立一个数组,然后Reset一下。
            info.Items = new byte[sizeof(BagItem) * this.Unlocked];
            Reset();
        }
    }
        // 背包整理
        public void Reset()
        {
            int i = 0;
            // 直接使用背包管理器的数据,背包自己不维护数据,先遍历一下道具管理器。
            foreach(var kv in ItemManager.Instance.Items)
            {
                // 如果某一个道具的数量的小于等于道具的堆叠上限
                if (kv.Value.Count <= kv.Value.Define.StackLimit)
                {
                    // 直接将这个道具的ID和数量放到背包的第i个格子中
                    this.Items[i].ItemId = (ushort)kv.Key;
                    this.Items[i].Count = (ushort)kv.Value.Count;
                }
                else
                {
                    // 否则说明这个道具的数量大于道具的堆叠上限,就进行拆分
                    // 先记录当前道具的数量
                    int count = kv.Value.Count;
                    // 当前道具的数量大于道具的堆叠上限时
                    while (count > kv.Value.Define.StackLimit)
                    {
                        // 将道具的ID和堆叠上限放到背包的第i个格子中(只放99个(StackLimit))
                        this.Items[i].ItemId = (ushort)kv.Key;
                        this.Items[i].Count = (ushort)kv.Value.Define.StackLimit;
                        // 下一个格子
                        i++;
                        // 将当前道具的数量减去堆叠上限,然后继续循环
                        count -= kv.Value.Define.StackLimit;
                    }
                    // 当count小于等于道具的堆叠上限时,说明当前道具的数量小于等于堆叠上限,将最后的几个放到这个格子中
                    this.Items[i].ItemId = (ushort)kv.Key;
                    this.Items[i].Count = (ushort)count;
                }
                // 继续下一个道具
                i++;
            }
        }
        // 分析,讲字节转换成结构体需要的数组
        unsafe void Analyze(byte[] data)   // 传入的是数组(data),最终输出的是BagItem数组(Items)
        {
            fixed (byte* pt = data)    // 指针指向data,取到一个指向data的指针pt
            {
                // 拿到传回来的解锁格子数
                for (int i = 0; i < this.Unlocked; i++)
                {
                    // 将一个BagItem的指针指向数组的第i个格子
                    BagItem* item = (BagItem*)(pt + i * sizeof(BagItem));// sizeof代表的是一个格子占几个字节,i代表当前是第几个格子;开始的指针再加上第几个格子的大小
                    // 这里可以直接赋值的原因是因为它是结构体,结构体是值类型,所以可以直接赋值。(地址不会发生改变,值发生改变)这也是用结构体而不用Clas的一个原因。
                    Items[i] = *item;   // 第0个格子指向第1个格子的指针的值,Items[i]就是第i个格子,然后以此类推。
                }
            }
        }
        // 从结构组数组转换成字节数组
        unsafe public NBagInfo GetBagInfo()
        {
            fixed (byte* pt = Info.Items)
            {
                for(int i = 0; i < this.Unlocked; i++)
                {
                    BagItem* item = (BagItem*)(pt + i * sizeof(BagItem));
                    *item = this.Items[i];
                }
            }
            return this.Info;
        }
    }
}

字节数组相当于一个内存块,解析的代码(Analyze)是用过内存将数据映射到定义的背包数组(Items);而后面的返回方法(GetBagInfo)是将数组的值映射到内存里面复制过去。

接下来就是背包系统(UIBag),新建一个UI的文件夹Bag,然后新建脚本UIBag。

using Managers;
using Models;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class UIBag : UIWindow
{
    public Text money;
    // 两页
    public Transform[] pages;
    // 一个对象一个格子,放图标、文本
    public GameObject bagItem;
    // 槽
    List<Image> slots;


    // Start is called before the first frame update
    void Start()
    {
        // 槽是空的,
        if (slots == null)
        {
            // 新建一个
            slots = new List<Image>();
            // 一共有两页
            for (int page = 0; page < this.pages.Length; page++)
            {
                // 每一个里面有几个,它自己会获取到
                slots.AddRange(this.pages[page].GetComponentsInChildren<Image>(true));
            }
        }
        // 协程初始化一下背包
        StartCoroutine(InitBages());
    }
    // 数据包从背包管理器取数据
    IEnumerator InitBages()
    {
        // 背包管理器有几个道具,它自己算出来是几个就是几个,初始化UI先遍历一下
        for(int i = 0; i < BagManager.Instance.Items.Length; i++)
        {
            // 第i个格子,ID是否大于0
            var item = BagManager.Instance.Items[i];
            if(item.ItemId > 0)
            {
                // 当ID大于0时,根据bagItem实例化一个,并将它的父节点设置为槽(第0个格子创建在第0个槽上)
                GameObject go = Instantiate(bagItem, slots[i].transform);
                // 通过UIIconItem取得上面的UI组件
                var ui = go.GetComponent<UIIconItem>();
                // 先从道具管理系的表里拿一下道具(define)
                var def = ItemManager.Instance.Items[item.ItemId].Define;
                // 要两个字段,一个是图标(Icon),一个是数量(Count)然后将它们都设置给格子
                ui.SetMainImage(def.Icon, item.Count.ToString());
            }
        }
        // 当前背包解锁的道具有多长,当前解锁了多少格子,并将剩下的格子设置为灰色
        for(int i = BagManager.Instance.Items.Length; i < slots.Count; i++)
        {
            slots[i].color = Color.gray;
        }
        yield return null;
    }
    public void SetTitle(string title)
    {
        this.money.text = User.Instance.CurrentCharacter.Id.ToString();
    }
    // 重置
    public void OnReset()
    {
        BagManager.Instance.Reset();
    }
}

做好之后就可以进行测试,先将UI拖拽到场景中,启动场景,可以看到背包1是高亮,选到背包2,背包2是高亮,然后Page2被启用了,page1关掉了。

image-20250728164914920

image-20250728164945874

测试没有问题,那就正式运行起来进行测试。

image-20250728180941743


BUG解决

但是在测试的时候,本人遇到了背包中没有东西,且有空引用的问题,虽然极力排查但是没能解决,不过大概知道了问题在哪儿。

  • 读取Item表的逻辑没加(空引用)
  • UIBagItem的prefab做的不对

先解决读取Item表的问题,这个好解决,直接加就好。

public Dictionary<int, ItemDefine> Items = null;
public void Load()
{
    string json = File.ReadAllText(this.DataPath + "MapDefine.txt");
    this.Maps = JsonConvert.DeserializeObject<Dictionary<int, MapDefine>>(json);

    json = File.ReadAllText(this.DataPath + "CharacterDefine.txt");
    this.Characters = JsonConvert.DeserializeObject<Dictionary<int, CharacterDefine>>(json);

    json = File.ReadAllText(this.DataPath + "TeleporterDefine.txt");
    this.Teleporters = JsonConvert.DeserializeObject<Dictionary<int, TeleporterDefine>>(json);

    json = File.ReadAllText(this.DataPath + "NpcDefine.txt");
    this.Npcs = JsonConvert.DeserializeObject<Dictionary<int, NpcDefine>>(json);

    json = File.ReadAllText(this.DataPath + "ItemDefine.txt");
    this.Items = JsonConvert.DeserializeObject<Dictionary<int, ItemDefine>>(json);
}

这样就不会爆空引用了,然后是UIBagItem的问题,这个是因为创建prefab的时候将Canvas也一并加了进去,而UIBagItem的prefab是不需要Canvas的。

image-20250728182119826

记得在页面中调整道具的实际大小和字体大小,不然会让道具太小或者字体太小。


这里进行一个小调整,在数据库中直接修改数据,先找到道具表,然后编辑前200行

image-20250728182728418

随后为2号角色增加5道具3个,6道具120个。

image-20250728182756105

然后客户端重启一次,但是这里道具并没有发生任何变化这个可能是服务端的原因,将服务端重启一下,服务端是有缓存的,第一时间是不会去拉去服务器的数据的。

image-20250728183005127

再次重新启动服务端,查看道具变化,可以看到数据已经更新过来了,没有问题。

image-20250728183142448

接下来对单例进行小的优化,找到MonoSingleton,在单例里面写的是start方法。

image-20250728183422607

而这个就与执行顺序有关系了,Mono脚本的执行顺序是Awake、Enable,第三时间才会执行到Start,Star的优先级是比较低的。而Start在执行的时候会存在节点执行顺序并非100%的严格,例如一个场景中可能会有好多节点,这就会导致有的节点早,有的节点晚。所以要将单例的实例化代码提前到Awake中。

image-20250728183912471

然后再看小地图的脚本,像之前在Start中,就有可能小地图这边已经获取到了新创建的地图数据了,但是单例那边后开始执行,又会将新创建的小地图删除掉,然后小地图指向的Minimap不存在了,这样就出现了小地图没有刷新,因为小地图不存在了。

image-20250728184103099

再改完这些后,还要注意另一个地方,不然立马会报错,就是NetClient里面的网络部分,running方法写在Awake中。Mono的单例用法是如果重写了什么方法,子类里面不能直接用,所以要将这里的初始化放到OnStart中,原来的Awake就不能用了。

image-20250728184530059

小Tips:Mono单例要牺牲所有子类中的Awake,因为父类要用,子类一旦写了它就不属于单例了。所以在写好Mono单例类后查找一下所有引用,只要引用的方法中都是OnStart没有Awake就可以了。

6.5 商店系统

本节要点:

  • 商店需求分析
  • 商店系统的设计
  • 商店UI的制作
  • 商店系统实现
  • 状态通知系统

先来看看策划案,看看商店系统具体有些什么,有着道具的商品信息,一页能展示多个道具,还有翻页功能。

image-20250729171133401

而在点击商品后会弹出商品详细信息的UI界面。

image-20250729171351359

然后再来看一看商店的UI效果图,在图中可以看到,有两个商店,一个是杂货店,一个是装备铺,虽然名字不同,但是二者功能上并没有什么区别。而在商店的两侧有着详细的信息,这是之前提到的UITips,是一种提示信息,所有的提示信息现在先不做,等到基础系统开发差不多的时候,很容易就可以完成了,它并不影响玩法,先做核心的内容,先做重要的地方。

而商店系统最重要的就是如何实现一个商品的列表以及购买的核心逻辑。

商店效果图

在看到这个建议的策划案以及效果图后,需要从中提取出有用的与商店系统有关的信息。

image-20250729172553821

而从图中可以看出,商店有两种类型,这样在设计的时候就可以思考怎么样去设计,就以这两个商店来说,他们除了名称和卖的商品不一样,别的都一样,那么就可以考虑用一套逻辑来驱动两个商店(当然一个商店一套逻辑也没有任何问题,只是工作量更大而已)。商店和商店之间的差别只在于数据,即配置,只要配置不同,就可以实现不同的商店类型。

功能上也就是商品的列表和道具的购买,功能也是很简单明了的,这样就可以大概有一个设计思路图。

小Tips:自顶向下设计,先分析需求,然后画大框架,最后落地实现。

最上面是UI层(UIShop),而每个商店里面每一页会有一个一个的格子,而每个格子都是商店的商品(UIShopItem),

image-20250729174059526

再来看看商店系统的组成是什么样子的,既然是商店系统,肯定包含了购买,协议就一定要有一个购买道具的协议(回应和请求),还会有一个商店的定义和商店道具的定义。而客户端和服务端都是老样子,有管理和被管理,而在DB中需要为角色新增钱这一数据。

image-20250731165125040

6.5.1配置表

那么还是先从最基础的商店需要的配置表开始,先复制两个表,并非分别改名为ShopDefine和ShopItemDefine。

image-20250731190028592

然后进去加我们需要的商店信息,因为暂时只需要两个商店,所以商店表还是很简单的,除了名字和描述不一样外,二者没什么不同。这个商店表的作用是用来索引不同的商店,给每一个商店建立一个编号。

image-20250731191844085

再来看看道具表,道具表中有两个Key,第一个Key代表商店ID(商店ID),即1号商店有四个ID(ShopItemID)四种药水,道具ID与其相对应,数量都是1,每一个道具的价格都是200(总价,会随着数量的改变而改变),状态做预留(在商店显示为1,不显示为0),如果将来不需要某个道具的时候,就将其改为0,就让它不在商店显示了(为道具不合理或者别的BUG做预留)。

image-20250731194722322

表格做完后记得表格转换,没问题就可以了。

image-20250731195358749

到服务端,将配置表的读取先加上。新建ShopDefine和ShopItemDefine两个类,并将刚才表中的信息加入进去。

image-20250801142658051

image-20250801142846232

这个搞完之后就可以搞Common了,但是先不急,先将协议编写好。之前也提到了,为角色新增了钱这一概念,找到角色的那一栏,新增金币为游戏货币。

image-20250801143056878

然后是购买协议,也要加进来。

image-20250801143619413

不要忘记道具购买请求,道具购买请求需要发送那个商店(shopId)的那个shopItemId就可以了,改好后生成一下协议。

image-20250801144112841

image-20250801144325428

加好协议之后就去服务端消息分发的地方增加新的信息(记得重新编译一下)。

image-20250801144628030

然后在数据库Tcharacter中在增加金币字段,要记得给64位的数据类型。

image-20250801144835425

在任何字段加完之后都要记得从模型生成数据库。

image-20250801144939764

6.5.2配置表读取逻辑

在做5.1的步骤之后,就需要怎加配置表的读取逻辑了,先找到服务端的DataManager,然后在其中加入两张新商店表的定义和读取逻辑。商店道具是两行Key,所以要定义两个字典,这是唯一的一个小区别。

image-20250801162135468

再将读取文件逻辑加入,同样的逻辑加入到其中。

image-20250801195658422

小Tips:双Key是两个字典,单Key是一个字典,具体按照情况而定,这里商店里的道具是双Key。

然后再来客户端增加配置表的读取逻辑,同样是DataManager,同样是要先定义后读取(可以直接将服务端的代码粘贴过去)。

image-20250801201332096

image-20250801201552924

如果这里再将代码粘入后如果代码(ShopDefine、ShopItemDefine)爆红了,记得将Common复制到客户端一份。

image-20250801210836424

image-20250801210912739

6.5.3 UI制作

在制作新的商店UI前先去UIManager加一行,让UI能够找到新的UI的位置(可以先注释掉,因为UIShop类还没有写)。同时原有的UIBag后面Cache改为了false,原来Cache为true的时候,UIBag打开,关掉再打开的时候,它相当于是Disable和able(隐藏和显示),并不是销毁,不销毁的话,UIBag的Start函数就不会再次执行,那背包中的道具元素就不会刷新,暂时为了不在额外写刷新逻辑,先将true改为false,这样可以简化操作,UIBag只要关掉再打开数据就刷新了。

image-20250802192413383

然后就可以做商店的UI了,直接将背包的UI复制一份,然后进行修改,将标题,按钮,背包1、2的字换成商店所需要的内容,并将Content的内容清空,这里就偷懒不做分页了,直接用背包1、2来代替分页,并将其保存为prefab。

image-20250802211417719

然后为商店中的道具及其信息做一个prefab,只要形状内容详细即可,不需要多余细节,到时候绑定脚本或者别的时候绑对就好,它也不是直接托上去的,而是由代码控制生成的,所以无需担心。

image-20250802211613632

然后来写UIShop的脚本:

using Assets.Scripts.UI.Shop;
using Common.Data;
using Models;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class UIShop : UIWindow 
{
    public Text title;
    public Text money;

    public GameObject shopItem;
    ShopDefine shop;
    // 入层,直接初始化所有元素(itemRoot)
    public Transform[] itemRoot;

    void Start()
    {
        // 协程初始化所有Items
        StartCoroutine(InitItems());
    }
    // 初始化背包
    IEnumerator InitItems()
    {
        // 打开商店的时候会知道当前商店是哪一个
        foreach (var kv in DataManager.Instance.ShopItems[shop.ID])
        {
            // 当知道当前商店的ID时,遍历所有的商店物品
            // 如果物品状态大于0,说明可以购买
            if (kv.Value.Status > 0)
            {
                // 把item实例化一个
                GameObject go = Instantiate(shopItem, itemRoot[0]);
                UIShopItem ui = go.GetComponent<UIShopItem>();
                 // 创建商店的时候,告诉了我自己是谁,传入了(this)我是这个商店的,我创建的道具都属于我
                // 道具就知道谁是所有者了。this.shop = owner;
                ui.SetShopItem(kv.Key, kv.Value, this);
            }
        }
        yield return null;
    }
    
    public void SetShop(ShopDefine shop)
    {
        this.shop = shop;
        this.title.text = shop.Name;
        this.money.text = User.Instance.CurrentCharacter.Gold.ToString();
    }
    private UIShopItem selectedItem;
    // 看看当前选中了谁(配合UIShopItem的OnSelect方法)
    public void SelectShopItem(UIShopItem item)
    {
        // 谁被选中了,然后商店来保存一下当前选择的是谁
        // 如果当前有选中的道具,那么就取消选中状态
        if (selectedItem != null)
            selectedItem.Selected = false;
        // 否则就设置当前选中的道具为选中状态
        selectedItem = item;
    }
    public void OnClickBuy()
    {
        // 如果没有选中道具,那么就提示用户
        if (this.selectedItem == null)
        {
            MessageBox.Show("请选择要购买的道具", "购买提示");
            return;
        }
        // 将选中的商店ID和商品ID传给商店管理器
        if (!SHopManager.Instance.BuyItem(this.shop.ID,this.selectedItem.ShopItemID))
        {
            
        }
    }
}


在写好之后会有UIShopItem的代码,UIShopItem的脚本代码如下,UIShopItem脚本只做一件事情,就是初始化道具。这里的ISelectHandler是Unity的Selectable组件,只需要重写一个方法就可以当你的鼠标选中它的时候,它就会自己调用这个方法。

using Common.Data;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
// 对应UIShopItem上的Selectable组件(ISelectHandler)
public class UIShopItem : MonoBehaviour, ISelectHandler
{
    // 需要的组件
    public Image icon;
    public Text title;
    public Text price;
    public Text count;

    public Image background;
    public Sprite normalBag;
    public Sprite selectedBag;
    // 选中状态
    private bool selected;
    // 这个代表没有被选中
    public bool Selected
    {
        get { return selected; }
        set
        {
            selected = value;
            this.background.overrideSprite = selected ? selectedBag : normalBag;
        }
    }

    public int ShopItemID { get; set; }

    private UIShop shop;

    private ItemDefine item;
    private ShopItemDefine ShopItem { get; set; }

    void Start()
    {

    }

    public void SetShopItem(int id,ShopItemDefine shopItem,UIShop owner)
    {
        // 道具就知道所有这是谁了
        this.shop = owner;
        this.ShopItemID = id;
        this.ShopItem = shopItem;
        this.item = DataManager.Instance.Items[this.ShopItem.ItemID];

        this.title.text = this.item.Name;
        this.count.text = shopItem.Count.ToString();
        this.price.text = ShopItem.Price.ToString();
        this.icon.overrideSprite = Resloader.Load<Sprite>(item.Icon);
    }
    // Unity的可重写接口,如果你的这个对象是个可选择对象,并且重写了这个接口,那么Unity会在你点击的时候调用这个方法,这样就不需要做什么射线检测也不需要绑定一个button。
    public void OnSelect(BaseEventData eventData)
    {
        // 标记为自己是被选中状态
        this.Selected = true;
        // 告诉商店,选择了自己,
        this.shop.SelectShopItem(this);
    }
    // Start is called before the first frame update


    // Update is called once per frame
    void Update()
    {
        
    }
}

image-20250804192504499

有两种选择的方法,一种是上面这种,另一种是EventTigger,这个可以选择UI来进行操作,这里用不到,所以作为一个补充。

image-20250805185945814

UIShop需要绑定的组件:

image-20250805211818902

Content的GridLayoutGroup配置:

image-20250805211904276

UIShopItem的脚本绑定:

image-20250805212029115

6.5.4 商店制作客户端

写到这里,商店以及商店道具的逻辑已经完成了一大部分,还剩一些小部分没有完成,即打开商店,在之前的章节中就有提到过,是NPC来打开不同的商店,所以就需要回到ShopManager上来,新建一个ShopManager脚本。注:每当涉及到系统与系统之间的调用,最好还是走Manager,因为它是单例,UI并不是一直开着,而是随时会关掉。

再开始会初始化单例,初始化的时候是NPCManager注册NpcFunction的打开商店,然后OnOpenShop进行与NPCManager系统的对接。

using Assets.Scripts.UI.Shop;
using Common.Data;
using Managers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Managers
{
    class ShopManager : Singleton<ShopManager>
    {
        public void Init()
        {
            // 一句话完成对接
            // 初始化是NPCManager注册NpcFunction的打开商店,然后OnOpenShop
            NPCManager.Instance.RegisterNpcEvent(NpcFunction.InvokeShop, OnOpenShop);
        }
        private bool OnOpenShop(NpcDefine npc)
        {
            // 只要点击了NPC,这里就可以通过NPC的参数调用ShowShop方法,
            this.ShowShop(npc.Param);
            return true;
        }
        public void ShowShop(int shopId)
        {
            ShopDefine shop;
            // 当拿到商店ID时,先看看DataManager里是否有这个商店
            if (DataManager.Instance.Shops.TryGetValue(shopId, out shop))
            {
                // 如果有这个商店,就显示商店UI
                UIShop uiShop = UIManager.Instance.Show<UIShop>();
                // 将商店的定义传入UIShop
                if (uiShop != null)
                {
                    uiShop.SetShop(shop);
                }
            }
        }
        // 发送购买请求(只需要商店ID和商店物品ID)
        public bool BuyItem(int shopId, int shopItemId)
        {
            ItemService.Instance.SendBuyItem(shopId, shopItemId);
            return true;
        }
    }
}

那么如何得知商店的参数就成了问题的关键,回头看一下NPCDefine,后面参数中会有两个不同的参数,这就是不同商店的参数(ID)。

image-20250806171832883

然后来对ItemService脚本进行补全,ItemService脚本的代码比较简单,显示关于道具购买响应的注册(ItemService)和消除(Dispose),之后就是协议的构造和消息的响应。

using Network;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

namespace Services
{
    class ItemService : Singleton<ItemService>, IDisposable
    {
        // 响应的注册
        public ItemService()
        {
            MessageDistributer.Instance.Subscribe<ItemBuyResponse>(this.OnItemBuy);

        }
        // 响应的消除
        public void Dispose()
        {
            MessageDistributer.Instance.Unsubscribe<ItemBuyResponse>(this.OnItemBuy);
        }
        // 一个send,构造协议
        public void SendBuyItem(int shopId,int shopItemId)
        {
            Debug.LogFormat("SendBuyItem");

            NetMessage message = new NetMessage();
            message.Request = new NetMessageRequest();
            message.Request.itemBuy = new ItemBuyRequest();
            message.Request.itemBuy.shopId = shopId;
            // 填个ID上去发送给服务器
            message.Request.itemBuy.shopItemId = shopItemId;
            NetClient.Instance.SendMessage(message);
        }
        // 一个响应
        private void OnItemBuy(object sender, ItemBuyResponse message)
        {
            // 显示购买成功
            MessageBox.Show("购买结果:" + message.Result + "\n" + message.Errormsg, "购买完成");
        }
    }
}

那么至此,客户端从点击NPC打开商店,点击购买发送请求到服务器,这个过程的已经完成了。

现在完成了商店的制作,要购买的话还需要金币。接下来就需要初始化金币,并且一开始可以多给一点,因为还要测试购买功能。

先来到服务端,在Character脚本中加入这一句代码,从DB复制到网络,这样在客户端登陆的时候就能够看到金币了。

image-20250806185445577

然后要记得设置一下初始金币,在UserService中,角色创建(新创建)的时候给10万金币。

image-20250806185701918

在完成这些后,再写完Manager之后,一定要记得初始化一下,去LodingManager记得初始化一下。

image-20250806190054273

然后启动程序看看有无报错,商店正常显示

image-20250806192448628

也可以翻到第二页,就是选择完道具后点击购买没有反应,这是因为服务端还没有做相应的逻辑。

image-20250806192503673

先来看看客户端和服务端有没有消息发送。

image-20250806192619988

image-20250806192627023

这样客户端的逻辑就做完了。

6.5.5 商店制作服务端

那么接下来就可以制作服务端了,也向客户端一样先加一个ItemService,因为客户端只发了一个协议,那么服务端也只需要接受一个协议就好了。

服务端只会受到一个ItemBuy的协议,先获取到这个请求的信息,一个shopId,一个shopitemid,然后交给Manager去做(这里先爆红放着,之后再加)。

using Common;
using GameServer.Entities;
using Network;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace GameServer.Services
{
    class ItemService : Singleton<ItemService>
    {
        public ItemService() 
        {
            MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<ItemBuyRequest>(this.OnItemBuy);
        }

        private void OnItemBuy(NetConnection<NetSession> sender, ItemBuyRequest request)
        {
            Character character = sender.Session.Character;
            Log.InfoFormat("OnItemBuy: :character{0}: Shop {1}, ShopItem {2}", character.Id, request.shopId, request.shopItemId);
            var result = ShopManager.Instance.BuyItem(sender, request.shopId, request.shopItemId);
            sender.Session.Response.itemBuy = new ItemBuyResponse();
            sender.Session.Response.itemBuy.Result = result;
            sender.SendResponse();

        }
    }
}

来看看ShopManager里面有什么,购买道具(BuyItem),而这里要注意的是,商店Manager并不是为某一个人服务的,它是为所有人服务的,谁买东西,它就为谁服务,所以第一个参数就是发送者(谁来买东西),然后才是商店ID和道具ID。先判断一下商店ID是否存在,当两个ID都不存在的时候,返回失败。在确认商店ID存在且道具ID正确后,将道具添加到角色的道具管理器中,改掉金币,数据保存(DBService)。

using Common;
using Common.Data;
using GameServer.Services;
using Network;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace GameServer.Managers
{
    class ShopManager : Singleton<ShopManager>
    {
        public Result BuyItem(NetConnection<NetSession> sender, int shopId, int shopItemId)
        {
            if (!DataManager.Instance.Shops.ContainsKey(shopId))
                return Result.Failed;
            ShopItemDefine shopItem;
            if (DataManager.Instance.ShopItems[shopId].TryGetValue(shopItemId, out shopItem))
            {
                Log.InfoFormat("BuyItem::character:{0}:Item{1} Count:{2} Price:{3}",sender.Session.Character.Id, shopItem.ItemID, shopItem.Count, shopItem.Price);
                if (sender.Session.Character.Gold >= shopItem.Price)
                {
                    sender.Session.Character.ItemManager.AddItem(shopItem.ItemID, shopItem.Count);
                    sender.Session.Character.Gold -= shopItem.Price;
                    DBService.Instance.Save();
                	return Result.Success;
                }
            }
            return Result.Failed;
        }
    }
}

将角色身上的金币加进来,为什么金币要做赋值呢?每当取值的时候,将数据库中的金币返回过去,但如果是对它进行赋值,先判断双方是否相等,如果相等就不用管了;如果不相等,老金币减去旧的金币,传给状态管理器,然后将新金币的赋值给数据库的金币。也就是说只要给金币进行赋值,就会自动加入到状态管理器当中,即金币状态变了(至于增减,已经做了计算StatusManager的AddGoldChange方法)。

public long Gold
{
    get { return this.Data.Gold; }
    set
    {
        if (this.Data.Gold == value)
            return;
        this.StatusManager.AddGoldChange((int)(value - this.Data.Gold));
        this.Data.Gold = value;
    }
}

然后就需要通知客户端,要通知客户端的话,就会牵扯到一个问题,每有一个道具增加就会发送一条消息,每有金币增加也会有消息发送,一个购买协议就需要三个协议将消息发送回去,分别是:购买是否成功;增加了什么道具,增加了几个;金币变化;这样的话就会让工作量大幅增加,那只要一次全部发送过去就好了,那就涉及到了一种机制,将这些变化都作为一种状态,每次变化都进行一次状态同步。

6.5.5.1 状态同步

还是打开协议,新增状态相关的变量。状态,在里面增加三个定义,状态更新,增加以及删除。

enum STATUS_ACTION
{
	// 状态更新
	UPDATE = 0;
	// 增加一个道具,增加金币
	ADD = 1;
	// 删除道具,删除金币
	DELETE = 2;
}

然后来一个状态的类型定义,定义有几种状态,当未来有更多的状态时,只需要增加新的定义即可。

// 状态的类型
enum STATUS_TYPE
{
	MONEY = 0;
	EXP = 1;
	SKILL_POINT = 2;
	ITEM = 3;
}

随后来定义状态的结构,当有一个道具需要进行操作时,就会先有一个类型(道具),行为时什么(action是增加还是删除),id是多少,增加或者删除的数量是多少。

message NStatus
{
	// 类型是什么
	STATUS_TYPE type = 1;
	// 是增加还是删除
	STATUS_ACTION action = 2;
	// id是什么
	int32 id = 3;
	// 增加或者删除多少
	int32 value = 4;
}

最后定义一个状态通知,一个消息,用一个数组将所有的变化全加进去。

message StatusNotify
{
	repeated NStatus status = 1;
}

状态要记得加在响应里面。

image-20250807175607213

而这样又会出现一个新的问题,当从客户端购买过来的时候,返回的消息还是两次,状态回一次,道具购买也要回一次。

6.5.5.2 底层修改

针对上面的问题就要对底层进行修改,现在的消息发送都是要新建三个消息。

image-20250807180111035

三次构建之后再打包发送到客户端。

image-20250807180138607

那也就是说每次有新消息来,就必定要新建一个消息回去,那现在要统一起来,一个消息过去,一个消息回来,那么请求开始的时候理论上就是任何消息一进入的时候。

image-20250807180404771

同样的,当sender执行的时候就是一个请求的结束。

image-20250807180509568

现在要在接受和发送一个sender之间回多个消息到客户端,要怎么做?先来看看协议,Response和Request都只有根节点,因为设计成这样理论上就支持这一个Response就可以将所有协议都往客户端发一份。那么接下来就对服务端的一些内容做整合。

既然要做整合,第一件事就是从Session上开始入手,而Session和Connection是密不可分的,每次发送消息的时候都是先new一个数组,打包成Pack值(PackageHandler)然后在变成字节发送出去(SendData)。

image-20250807185111937

先将这个过程封装到Connection中,因为每次的发送就是一个Connection。直接无知无参数,直接调用sender就好了。

image-20250807190647303

image-20250807190635648

这样做有什么好处呢?回到ItemService中,这条报错就没有了。消息从哪里来?前面两条爆红的就是。

image-20250807190840476

回到NetSession,加一个加一个成员变量来存储响应消息(NetMessage response;)它是私有的,开放的是Response,这样在用的时候就可以随时拿来用,它是自动创建的(如果response是空的空的,则直接新建)。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using GameServer;
using GameServer.Entities;
using GameServer.Services;
using SkillBridge.Message;

namespace Network
{
    class NetSession : INetSession
    {
        public TUser User { get; set; }
        public Character Character { get; set; }
        public NEntity Entity { get; set; }

        public void Disconnected()
        {
            // 判断一下,角色如果不为空,
            if (this.Character != null)
                UserService.Instance.CharacterLeave(this.Character);
        }


        NetMessage response;
        public NetMessageResponse Response
        {
            get
            {
                if(response == null)
                {
                    response = new NetMessage();
                }
                if (response.Response == null)
                    response.Response = new NetMessageResponse();
                return response.Response;
            }
        }
        public byte[] GetResponse()
        {
            if(response != null)
            {
                if(this.Character!= null && this.Character.StatusManager.HasStatus)
                {
                    this.Character.StatusManager.ApplyResponse(Response);
                }
                byte[] data = PackageHandler.PackMessage(response);
                response = null; // 清空响应
                return data;
            }
            return null;
        }
    }
}

这样两个爆红就没有了;而且能够随时从Session身上取得一个Response,而Session是从接收到最早的消息(OnItemBuy)到SendResponse之间,是全程有效,而且是唯一的。再购买的中间也需要发送消息,就可以直接加入到Response中,将所有的东西都加入到Response中,一次就发送出去了。

image-20250807194010687

这个改造就是为了让Response不在一个方法周期,而是一个会话周期,会话周期是Session全局的。但这里还是有错误的,session.GetResponse还处于爆红状态,新增一个接口INetSession类,并加入方法。如果再发送完一个response之后又接收到一个怎么办?

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Network
{
    public interface INetSession
    {
        byte[] GetResponse();
    }
}

具体的接口实现是在NetSession中的,其中最核心的是打包data那句,随后就将response设置为空了,所以每当一个response发送出去的时候,马上就是空的。这个方式保证了再会话一开始创建、会话一结束清空,不会出现会被重复的问题,而且再会话期间可以对response进行多次赋值,让它包含多个消息。而这就提到了状态管理器。

image-20250807194928222

6.5.6 状态管理器

新增一个状态管理器,跟道具管理器类似,也是在角色身上,那它地初始化时机也一样,也和道具管理器在一起;先来一个构造函数里面会创建一个新的列表(Status)来维护一个状态。 一个Add,开放一个方法,指定一个类型、id、value和action。还封装了两个方法,为了有些时候直接加金币,只要金币。当金币大于0或小于0就直接生效。最后有一个状态应用响应方法,将当前所有状态列表中的状态应变化全部装到状态通知的消息里面,然后清空。

using GameServer.Entities;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace GameServer.Managers
{
    class StatusManager
    {
        // 跟道具管理器类似,也是在角色身上,那它地初始化时机也一样,也和道具管理器在一起
        Character Owner;
        // 创建一个新的列表来维护一个状态(Status)
        private List<NStatus> Status { get; set; }

        public bool HasStatus
        {
            get { return this.Status.Count > 0; }
        }
        // 一个构造函数,
        public StatusManager(Character owner)
        {
            this.Owner = owner;
            this.Status = new List<NStatus>();
        }
        // 一个Add,开放一个方法,指定一个类型、id、value和action
        public void AddStatus(StatusType type, int id,int value,StatusAction action)
        {
            this.Status.Add(new NStatus()
            {
                Type = type,
                Id = id,
                Value = value,
                Action = action
            });
        }
        // 封装了两个方法
        public void AddGoldChange(int goldDelta)
        {
            if(goldDelta > 0)
            {
                this.AddStatus(StatusType.Money, 0, goldDelta, StatusAction.Add);
            }
            if(goldDelta < 0)
            {
                this.AddStatus(StatusType.Money, 0, -goldDelta, StatusAction.Delete);
            }
        }
        public void AddItemChange(int id,int count, StatusAction action)
        {
            this.AddStatus(StatusType.Item, id, count, action);
        }
        public void ApplyResponse(NetMessageResponse message)
        {
            if (message.statusNotify == null)
                message.statusNotify = new StatusNotify();
            foreach(var status in this.Status)
            {
                message.statusNotify.Status.Add(status);
            }
            this.Status.Clear();
        }
    }
}

既然状态管理器在角色身上,就需要在角色脚本新增状态管理变量,也要记得在构造函数里面加一个赋值。

image-20250808144426018

image-20250808144437537

然后NetSession就不会报错了。这里为什么要判断角色是否为空?因为第一次登陆一定是空的,进入游戏后角色才不为空。然后再判断状态管理器是否有变化,有变化才加进去,否则不用加,最后打包发消息(byte、return)。

image-20250808144502503

然后来看看对于Item购买,和原来相比,再完成道具的购买之后(result),只用了itemBuy的message,而且new完之后,直接SendResponse就回去了。而在Send之前,一定会判断状态是否发生变化(GetResponse)。

image-20250808145009506

状态修改做完了,那么接下来就是道具了,道具是怎么改的(AddItem),之前的Add是直接在其中进行增加,什么都不做,但现在就需要多做一件事,把道具购买的信息,id、count、状态是增加调用一下。

image-20250808150233005

当然删除也是同样的道理,只需要id、count、状态就可以了。

image-20250808150409285

只要金币和道具发生了变化,只做一件事,将变化量记录到状态管理器就可以了,那么到此服务端的状态管理器就完成了,接下来就要处理客户端,服务端状态管理器已经好了,只要发送过来消息就可以了,但是客户端还没有对应的操作,所以要让客户端接收到消息并更新状态。记得将Common重新生成后给客户端复制一份。

image-20250808151408084

通过状态管理器告诉客户端,客户端需要做哪些事情,有这个状态通知后客户端做的也就很单纯了,用这样的一个机制,可以用很少的代码,完成非常复杂的业务。客户端做了什么,一个状态Service,单例(Singleton),跟NPC那边维护了一个一模一样的功能一个委托(delegate),一个列表(Dictionary)列表里面维护的也是注册,与NPC的代码并无二别。但是这里多监听一个状态协议(StatusService),OnStatusNotify就是遍历一下这个状态协议(Notify)干了什么。Notify里面进行判断状态变化,如果是金币就直接增加或减少(AddGold),如果不是金币,就发通知,谁在注册其他消息,道具系统要变化,道具系统要注册。

using Network;
using SkillBridge.Message;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Services
{
    class StatusService : Singleton<StatusService>, IDisposable
    {
        public delegate bool StatusNotifyHandler(NStatus status);

        Dictionary<StatusType, StatusNotifyHandler> eventMap = new Dictionary<StatusType, StatusNotifyHandler>();

        public void Init()
        {

        }
        public void RegisterStatusNotify(StatusType function, StatusNotifyHandler action)
        {
            if (!eventMap.ContainsKey(function))
            {
                eventMap[function] = action;
            }
            else
                eventMap[function] += action;
        }
        public StatusService()
        {
            MessageDistributer.Instance.Subscribe<StatusNotify>(this.OnStatusNotify);
        }

        private void OnStatusNotify(object sender, StatusNotify message)
        {
            throw new NotImplementedException();
        }

        public void Dispose()
        {
            throw new NotImplementedException();
        }
    }
}

完事后在User中增加加金币的方法。

image-20250808170829418

然后可以对Item进行重载,原来只能通过网络消息(NItemInfo)来构建一个Item,现在可以通过ID和Count来构建(在原有基础上加this)这样就可以重载构造函数。

image-20250808171718287

然后是ItemManager,ItemManager要注册,那么在构造的时候就将注册加入。

image-20250808173110378

这个方法(OnItemNotify)中做了道具增加或者减少的事情。

bool OnItemNotify(NStatus status)
{
    // 如果是增加,就调用自己写的道具增加
    if(status.Action == StatusAction.Add)
    {
        this.AddItem(status.Id, status.Value);
    }
    // 如果是删除,就调用删除
    if(status.Action == StatusAction.Delete)
    {
        this.RemoveItem(status.Id, status.Value);
    }
    return true;
}

道具增加的方法,但本地道具有一个不同的地方,本地的道具不能直接加进去,因为它是成组的,如果这个道具存在(if),先找到它然后修改数量,如果不存在,才需要加一个new,然后再添加到道具表里面(Item.Add)。BagManager这行很关键,道具系统更新了,背包也要做更新,所以同时将id和数量(count)加到背包里面,再背包里面会有一个Add和Remove。

void AddItem(int itemId, int count)
{
    Item item = null;
    if(this.Items.TryGetValue(itemId,out item))
    {
        item.Count += count;
    }
    else
    {
        item = new Item(itemId, count);
        this.Items.Add(itemId, item);
    }
    BagManager.Instance.AddItem(itemId, count);
}
void RemoveItem(int itemId, int count)
{
    if (!this.Items.ContainsKey(itemId))
    {
        return;
    }
    Item item = this.Items[itemId];
    if (item.Count < count)
        return;
    item.Count -= count;

    BagManager.Instance.RemoveItem(itemId, count);
}

然后再来修改背包系统的逻辑,先看看背包里面是否已经有道具了(for),如果有,并且再最大限制内,就进行道具增加。

public void AddItem(int itemId, int count)
{
    
    ushort addCount = (ushort)count;
    for(int i = 0;i < Items.Length; i++)
    {
        if (this.Items[i].ItemId == itemId)
        {
            ushort canAdd = (ushort)(DataManager.Instance.Items[itemId].StackLimit - this.Items[i].Count);
            if(canAdd >= addCount)
            {
                this.Items[i].Count += addCount;
                addCount = 0;
                break;
            }
            else
            {
                this.Items[i].Count += canAdd;
                addCount -= canAdd;
            }
        }
    }
    if(addCount > 0)
    {
        for(int i = 0; i < Items.Length; i++)
        {
            if (this.Items[i].ItemId == 0)
            {
                this.Items[i].ItemId = (ushort)itemId;
                this.Items[i].Count = addCount;
            }
        }
    }
}
public void RemoveItem(int itemId, int count)
{

}

这样的话,从购买再到服务器,三种状态发生变化(购买、金币、道具获取),三个东西做了一个合并,做完合并之后会通过一个消息回到客户端,而客户端会自动将它分发到各自得各个系统中,先会分发到道具购买,自己会收到一个购买成功得通知,另外一个就会分发到状态通知,在分发到ItemManager,然后ItemManager会再通知背包,然后整个调用就完成了。

运行程序测试一下,但是没有任何反应(指道具变化,并不是消息分发,消息分发是有的)。

image-20250808180254038

image-20250808180301164

新加协议之后,忘记加消息得分发了,再加一下再(Common/NetWork/MessageDispatch),记得完成后重新生成解决方案,并将相关信息给客户端复制一份。

image-20250808180558376

理论上来说,这次忘记加消息分发,虽然没有通知到,但是数据库里面已经有数据了,下次上线后就能看到。但是又出问题了,一点变化没有,好像是服务端没有收到任何消息。

image-20250808181050571

image-20250808181100223

可能是某一个系统没有初始化,挨个查询一下,GameService里面看看,再看看ItemService。

image-20250808181305806

真的没有初始化,ItemService没有初始化,那就加上。

image-20250808181403583

image-20250808181703240

所有系统得初始化都在GameService中,而现在就是缺少了道具服务得初始化,因为没有初始化,所以不会调用到MessageDistributer来进行注册,也没有办法进行接受。

image-20250808181841041

注:如果消息接收不到得几种情况:

  • 协议写了,但是没有加分发函数image-20250808182035084
  • 本身没有进行初始化,就是刚才这样,并没有进行服务得初始化image-20250808181703240

再次进行测试,这次没问题了。

image-20250808182847572

image-20250808182336941

image-20250808182344575

不过出现了一个BUG不知道为什么买一个大钱袋后出现这么多,倒是数量上没有问题,先留存,包括背包金币变化和商店金币实时变化,都先留存。

image-20250808182907486

本节重点:

  • NPC和商店之间的交互,
  • 商店购买流程和道具系统之间的交互,
  • 金币修改的交互
  • 如何通过状态系统分发消息(重点中得重点)

虽然状态系统很简单,只加了一个协议,一个推送,但是它解决了通常业务中很繁琐的消息返回问题直接变得不太需要上心了。

6.6 装备系统

本节要点:

  • 装备需求分析
  • 装备系统的设计
  • 装备UI的设计
  • 装备系统实现

先来看看装备的策划表,里面包含了很多信息,装备的id、数字、等级、装备名字、职业类型、以及一些与技能有关的信息(力量、智力、敏捷、HP等)、以及描述一类的,按照现有的这些信息,先将装备表格做出来,在做之前,要知道表格的大概划分,知道装备表的装备数据有哪些,需要知道那些是关键数据,那些是非关键数据,关键数据指的是那些影响逻辑实现,非关键数据仅存于UI上和逻辑无关的。就比如描述、来源和装备名是非关键数据;ID、职业种类(要真是使用的话肯定不是用战士这样的字段,而是会用枚举值)、以及后面的各种属性(力量、智力、敏捷等),但是因为还没有战斗系统,所以暂时不需要关系具体的数值,只需要将字段提供过来就好。

image-20250809150936024

但有这个表就有这些装备吗?并不是,而是要通过特定来源来获得之后才能穿,也就是说这些装备并不是直接能够获取到的,而要通过购买、兑换或者从怪物身上掉落来进行获取,接下来看看装备系统的策划案。

如下图,左侧是列出的所有可装备的道具列表,中间有叉叉的方块是穿着的装备,右边是当前角色的一些基础属性,(当穿上装备的时候一些属性会进行变化),但这里先将属性忽略掉,因为角色属性还没有做。那么现在要做的就是装备系统的核心功能即能将装备穿在身上、脱下来,而且是能够从商店购买到装备,再穿到身上,并且在下线回来之后,装备还在身上,这就表明要维护角色的数据。

image-20250809170823637

接下来看看效果图,大概就是这么个样子,但是这里也有问题,左下角的装备按钮布置其意义,因为这里是可以通过双击左边的装备来做到装备的,所以这个按钮就失去了它的意义。

那么接下来思考一下这个效果图体现出来那几块内容,实际可以分为两大块内容。

一块是左边的装备列表,这个列表从哪里来,这个列表一定是已经拥有的装备,那已经拥有的装备在没显示出来的时候会在道具系统中,理论上装备也是道具,因为都是从商店里面买来的,如果是一个道具的话,但如果要重新写一个装备来管理,那就是说商店要卖装备,装备和道具是两种东西,那么背包也要在对接装备,那之前对道具所进行的一些列都要再来一遍,所以,在这里将它们划分到一类,装备是道具的一个分支,二者虽然有些东西不一样,但是很多东西都一样。那么就将装备当作道具来实现,这样可以简化逻辑,商店不需要改动,只要把装备配置到道具表里,再把道具配置到商店表里,就可以直接从商店买到装备了,然后背包里面就可以有装备了,

第二块是中间这一块,中间这一块是穿在身上的道具,那怎么能知道哪个部位穿那个呢?每一个地方所穿戴的装备并不相同,也就意味着需要有个地方来保存这些数据,那个装备装在哪里,那这些数据肯定是要存在服务端的,这样就可以保证今天穿上装备之后,明天装备还在,关闭客户端也可以存在,那服务端就是不二的选择,服务端就一定是存在数据库中,那么中间这块的所有布局信息就一定是存在数据库中的。

那么分析到这里,左边列表的数据来源都是之前做的系统,只要做UI就可以了,唯一的就是要将表格配上,左边的好解决,而中间就需要做数据的管理。

image-20250809171648567

装备系统:1. 装备管理,2. 装备穿戴(核心)。那么这里就涉及到了穿戴数据的保存,该用什么方式来进行保存最合适,当然可以建立一张表创建7个字段来进行保存,也可以像之前的背包系统一样也用字节进行保存,二的区别在于背包的格子多,而装备的格子少,并且装备的每一个格子只会有一个装备,不会出现叠加的情况。

来看看装备系统的数据结构,假如这就是一个数组(RNT),枚举值代表了一个编号,从0-6七个格子,第一个格子(WEAPON)中一定是填一个武器的ID,而ACCESSORY一定是填一个副手的ID,接下来分别是头盔、护手、衣服、裤子、靴子的ID,这样的话位置就固定下来了。

image-20250809173709285

装备系统会有一个UI(UICharEquip),里面会有小格子(UICharEquipItem)用来存放列表中的元素,装备也会有一个管理器(EquipManager),既然也将装备分为了道具,将装装备和拆装备也交给Service来管理。

image-20250809175039786

既然装备可以穿戴也可以拆卸,那么先要加一个与之相关的协议(ItemEquipResquest,ItemEquipResponse)为什么制作一对响应和请求?因为装备只有穿戴和拆卸的区别,加一个字段0,1分别用来表示拆卸和穿戴,然后加一个道具系统的配置表(EquipDefine)。客户端和服务端要各做一个Manager,UI。数据库(DB)并不需要做新表,只需要加一个字段就ok。

image-20250809192617971

6.6.1 配置表的制作

针对新的装备配置表,要先对道具表进行修改,新的道具表对比之前的道具表多了很多东西,先将装备填到道具表中,从10开始,后面全部都是装备的信息,而在这基础之上还增加了道具类型(Type)的枚举值,上节中并没有增加。

image-20250810205252719

因为枚举在结构要用的位置很多,不仅是逻辑中要用,还要再协议中进行使用,所以直接将类型的枚举值加入到协议当中,这样它自己就可以生成代码,现在道具中新增加装备数据(EQUIO),下面的EQUIO_SLOT是装备的槽位,在这里定义一组枚举值来代表它放在那个槽上。

image-20250811115112078

定义一个字节数组来存储当前装备。

image-20250811162313756

因为装备还需要穿在身上和脱下来,还需要再增加装备的协议,一个装备请求协议(ItemEquipRequest),一个装备响应协议(ItemEquipResponse)。

image-20250811162546461

记得将协议的内容写上,在穿装备的时候,需要知道那个槽位(slot),和那个id,其实某种程度来讲在穿戴装备的时候只需要知道ID就可以,因为表中会定义这个ID属于那个槽位,它只能装在一个位置,这里的槽位是为了在脱装备的时候,只需要知道槽位就好了,不需要知道具体装备是什么,只要将槽位清空就好。

image-20250811162921460

然后再回到配置表上,道具的配置表也进行了修改,新增了一个字段限制职业,以前的道具是不限制职业的,但是装备呢,战士的只能给战士,法师只能给法师,弓箭手只能给弓箭手,所以必须要指明这个道具属于那个职业,所以增加了这么一个字段,还新增了等级和新的类型装备(EQUIP),这样就可以很好地利用这个道具表。

image-20250811163412673

既然道具表里进行了修改,那么自然而然地商店道具表里也需要进行修改,首先将原有的所有道具都分到杂货商店,而装备店中放了一组装备,会像刚才道具表一样有一组ID(1001、1002、2001等),按照key2的顺序排下来,还有数量,一次只能买一个,价格。

image-20250811163722999

然后新建一个装备表,但这个表现在还用不到,只是先加出来而已,但是Slot这个字段是需要在逻辑中使用的,在这里就固定了那件装备只能装在哪里。后面的属性(生命、法力、力量等)则是在之后的环节中才会使用的。

image-20250811164659187

既然增加了新的表,当然也要给新的表增加装备的定义了,新建一个EquipDefine类(服务端),将字段一一对应加进来。

image-20250811171000201

然后在道具表中增加等级和职业限制。

image-20250811171143833

既然新增加了协议,就一定不要忘记去加上分发逻辑,不然又会没有任何变化了。

image-20250811171506662

不要忘记重新生成解决方案后将Common复制到客户端一份。

image-20250811171844643

然后处理一下上节课的尾巴,背包需要重开一下才能够刷新数据,以及将背包原来格子上的那些道具全部清理掉(Clear),然后重新初始化一下(OnReset)。

image-20250811183619139

然后对商店的分页也进行了小的调整,使得商店每10个道具为一页。

商店

然后是商店道具的修改,增加了职业限制的显示,而下面的两行代码则是为了让信息能够显示在UI上。

image-20250811184130847

image-20250811184314750

完成这些后回到服务端,为数据库增加新的字段-装备(Equips),并将其改为二进制类型(Binary)。而这次这个属性与以往不同的是需要一个固定长度,最大长度为28(4*7)字节,可以装7个道具。

image-20250811195239793

再修改完之后一定要记得生成数据库,不然90%都会报错,然后到数据库中去生成一下,没有报错就可以了。

image-20250811195617487

然后就可以关掉了,(这里没有截到弹出是否保存)记得一定要保存修改。

image-20250811195717004

6.6.1.1 装备UI制作

既然是装备,那就定然会在UI上显示出来,去做一个UI界面,直接将背包或者商店的复制一份过来进行修改,样式差不多就可以了。

image-20250811204235616

然后在经过一点细节加工后,就差不多可以了,大概就是这么一个感觉。

image-20250811204310638

然后是左边的装备和中间的装备UI,药水这个直接将之前的复制过来用了,将多余的屏蔽掉,而装备的UI只是简单的一张图就好。

image-20250812105736772

image-20250812105814098

因为增加了一个新的UI界面,记得要去UIManager加入新的UI逻辑,这样就可以用UIManager来进行调用了。

image-20250812110021655

6.6.2 客户端,服务端逻辑增加

在加入完新的UI逻辑后,还需要有调用,装备UI肯定是希望在主城进行调用的,然后用一个小图标来启用它。别忘了绑上对应的脚本命令,不然打开的可能不是装备界面。

image-20250812110236801

image-20250812110730276

小Tips:换装系统和换装备是两个概念,换装备主要是为了改变属性,为了让角色变得更强,而不是为了变得更好看;但换装是为了与别人与众不同。换装有些时候只是更换外表,只是时装和皮肤变了,但是属性并没有改变。如果想要更换外观怎么办?每件装备上面定义一个部件的ID,给武器定义名字,然后当检测到角色身上有武器的时候,就将该武器对应的模型加载进来设置到角色身上。

接下来就给客户端的DataManager增加读取项,先来个定义,然后是读取逻辑。

image-20250812102703629

image-20250812102726014

image-20250812102840154

然后先挑简单的来做,之前数据库中新增了字段,所以一定要记得去Character脚本中初始化,只要初始化,装备数据就会随着用户登录自然而然地下发到客户端。

image-20250812190914041

然后是DataManager的读取逻辑,先加定义后加读取。

image-20250812191720096

image-20250812191740320

完成这些前置数据准备后,就先从道具开始写,先找到道具服务,先注册请求(MessageDistributer),然后实现方法(OnItemEquip)

image-20250812210543938

这里可能会因为EquipManager还没写,所以会报错。

image-20250816182130136

再去加一个EquipManager类,然后思考一下穿装备要考虑什么,穿装备发过来的信息,有什么槽位slot,什么道具ID(itemId),装备是穿还是脱(isEquip)。而这也就是说服务端要做的就是收到客户端的请求之后,将装备数据设置到装备里面(UpdateEquip),然后Save下来就行了。

image-20250813163218417

下面是更新装备的方法,只要知道当前是那个格子,用当前的指针(pt),加上槽子的id,乘以每个槽子占的大小(sizeof),如果是穿装备,将装备的Id给它(if),如果是脱装备,就将slotid清零。

image-20250813165516727

注:指针和固定大小反冲区,只能在不安全代码中进行使用。

那怎么解决呢,在服务端只需要在这个项目中右击属性,然后再左边侧边栏中找到生成,然后勾选允许不安全代码即可。

image-20250813170200358

image-20250813170227504

字段要在创建角色的时候创建,新建一个用于装备。

image-20250813171159773

而在新建角色的时候一般是会有新手大礼包的,但是这里还没有做这个功能,所以先直接给背包中增加各20瓶的血瓶和蓝瓶。

image-20250813171515971

然后可以将下面的道具测试代码删除了,因为当时道具系统的代码还没有写,所以临时做的,这里可以不要了,注意不要多删了就好。

image-20250813171645969

服务端最重要的就是EquipManager,处理好协议的解析数据生成(UpdateEquip),将数组生成好,并更新到数据库里面。这里再将保存代码(DBService)进行一个扩展,可以传递是同步保存和异步保存。

image-20250813172357304

接下来就专注于客户端的逻辑,先来处理一下上节课的尾巴,当购买一个道具后,如果一个格子是空的,会将新的道具追加到里面,但是加完之后继续执行,直到将整个背包填满,在下面加一个break就好了。

image-20250813172658755

在之前的时候将装备归入了道具之中,共用一个道具信息,所以要在道具中新增一个装备的定义。

image-20250813172948215

当然在道具加载的时候会同时加载道具信息和装备信息,这样就可以通过item随时访问到道具信息和装备信息了。

image-20250813173203149

刚才服务端的协议处理已经做好了,那客户端只要在做一个发送(Subscribe)就好了,当然协议发送成功之后也要接受返回(Unsubcribe)。

image-20250813173437904

然后类比上面的购买发送写一个装备道具发送。这里写了两个成员变量,但是重要的是第一个pendingEquip,用来保存当前穿的是那件装备,将装备信息记录一下(pendingEquip= equip),当消息返回的时候(OnItemEquip),就知道当前穿的是那件装备了。

image-20250813174336432

image-20250813174230941

响应的时候加了一个判定,是为了告诉前端(manager),穿上了那个装备(if(this.isEquip),或者脱下了那个装备。

image-20250813174911773

然后去EquipManager补全剩下的逻辑,先开放两个方法,一个穿装备,一个脱装备。因为装备道具的时候要发送到服务端,所以调用item的Service来Send一个道具,要装什么装备道具(equip),发送到服务器。

image-20250813202005755

发送成功之后,接收到响应之后,就到了这里,客户端收到服务器保存成功后,因为之前记录了一个临时变量(pendingEquip),记录了当前穿的是什么装备,然后再调用EquipManager的OnEquipItem。

image-20250813202710793

在收到穿戴装备和脱装备的消息之后又回到(EquipManager)的OnEquipItem,先做个检查检查这个槽位是不是已经穿上了(if),然后将道具从道具内存中拿出来,放到格子上,

image-20250813203331354

服务端保存数据用的是字节,而客户端保存使用了一个定长数组Equips,数组长度是7(可以转到定义看一下,是协议中定义的)。这也是装备Manager唯一会用到的数据。并且用了一个Data来维护两者之间的转换,因为装备要从服务端发过来,发过来的时候与背包相同都是字节,需要做初始化(Init)。

image-20250813203537518

image-20250813203618649

image-20250813204058387

再拿到数据之后,对数据进行解析。

image-20250813204213493

同样的,再从本地将信息打包成服务端所需要的数据类型。

image-20250813204327977

这个方法暂时没什么用,是为了给其他系统开放一个接口用来方便查询当前是什么装备。

image-20250813204420932

同样的,有查询当前是什么装备,就一定会有有没有穿什么装备的接口。

image-20250816230645813

6.6.3 UI逻辑

先来看看UICharEquip,每次启动的时候先刷新一边UI,同时给装备这里注册了一个事件,只要穿装备或者脱装备了,就刷新一下。

image-20250814210202295

关闭的时候就清理掉。

image-20250813205637670

刷新UI做了几件事,第一先将左边的装备列表清空了,第二初始化一边,将右边已装备的列表清空,第四步重新初始化,然后将金钱刷新一下。

image-20250813213651933

先遍历左边所有的道具列表,将所有的装备显示出来(foreach),显示的时候判断一下,已经穿在身上的不显示。如果没有,将itemPrefab初始化一下,然后将索引,item,当前界面,非装备的列表(代表左边的列表还是右边的列表,这里用布尔值来进行区分)SetEquipItem过去。

image-20250814210031607

清除简单,找到左边的每一个子元素,然后Destroy掉就好了。

image-20250813213818022

同理,右边的是找到每一个槽下面的,所有的子节点删掉,因为布局不一样,所以清除的方法也有所不同。

image-20250813214048366

创建右边的也简单,先将所有格子检查一遍,看看格子上有没有装备,如果有装备了,生成一个(GameObject),然后将装备信息设置进去(UIEquipItem)。

image-20250813214208540

还有两个事件,一个穿装备,一个脱装备。这两个事件是给点击事件用的,所以接下来来写UIEquIpItem。

image-20250813214337876

先定义一些组件,之后去Unity绑定,写一个是否选中的方法。

image-20250813222226245

再来一个索引,一个owner,一个维护装备的item,还有一个代表当前是装备列表还是非装备列表的布尔值(isEquiped)

image-20250813222643046

通过多出的布尔值,将其他信息,初始化到对应UI上,比如说owner,item,index。

image-20250814173610078

而再一开始就用了一个新的方法,指针点击处理器,这个处理的是点击,不再是选择,只要鼠标按一下就会执行了。

image-20250813223041591

因为这里需要点两次,如果是已经装备的道具,就执行脱装备的逻辑,会弹出一个是否要脱装备的提示框(MessageBox),如果点确认(OnYes),就将装备脱下来

image-20250813223248773

image-20250813223601585

穿装备也很好理解,这是一件非装备列表(左边的装备列表),就要先判断有无选中(this.selected),如果不是选中的,就降Selected设置为选中,当第二次点击的时候就能够走到选中(if),选中之后就穿装备(DoEquip),然后降转中状态改为false,为了再次点击的时候能再次执行逻辑。

image-20250813224643227

而穿装备会比脱装备多,第一件事是先询问是否要装备(msg),如果点了yes,如果原来有武器了(if(oldEquip)),要替换原来的武器吗?newmsg,如果确认,进行更换,如果说oldEquip是空的,不需要确认,直接就穿好装备。

image-20250813224849622

在注册通知这个地方(RegisterStatusNotify)是有BUG的,这个方法以前是在ItemManager的时候进行调用的,在初始化的时候进行注册,但ItemManager是单例,它初始化的时候是进入游戏(OnGameEnter)的时候进行初始化,也就是说要是游戏不退出,选择一个角色进去,再退出来再进一次,再退出再进一次,会导致这个函数(RegisterStatusNotify)进入3次,那就是说同样一个单例在这里面会有三份事件(action),那么当服务器通知要加装备的时候,那么它就会告诉道具管理器三次加一下装备,总共加了三次装备,所以装备会出现多次。

如何杜绝这样的清空,加一个哈希表(HashSet),它的查询性能高,可以很快地检索(action)有没有在集合(handles)里面。为什么不用Dictionary,而用HashSet是因为Dictionary必须要一个key和一个值,而HashSet只需要一个key就够了,只需要判断是否重复。

image-20250813225555912

image-20250813225741130

image-20250813225849329

6.6.4 装配UI脚本

首先先给UI挂上它们对应的脚本,因为装备列表和非装备列表都属于道具,所以它们挂的脚本是相同的,只是相应的信息少而已。将图片给icon,将UIEquipIcon给背景(background)。

image-20250814174044986

同理装备列表也要给相应的信息挂上脚本,将相应的组件给到相应的位置。

image-20250816231713223

最后是角色装备的UI界面,注意这里多了一个非装备列表的根节点(Item List Root),还有事角色地装备位置要一一对应。

image-20250814174613984

6.6.5 测试

在写完这些个代码之后,就可以进行测试了,先进去买点装备,如图,杂货商店的已经有装备了,并且可以进行正常得购买,能够弹出购买成功得弹窗。

image-20250816231522542

image-20250816231106500

来到背包也可以看到新买的装备,购买到背包显示也没有问题。

image-20250816231135217

来到角色装备界面,装备提示也没有问题。

image-20250816231328642

然后卸下装备,没有问题,代码测试没有问题。

image-20250816233820086

image-20250816233826861

posted @ 2025-07-14 11:44  叱咤月海鱼鱼猫  阅读(70)  评论(2)    收藏  举报