加载中...
基于 ET 的放置类网络游戏系统设计
发表于:2022-04-17 | 分类: 作品展示

基于 ET 的放置类网络游戏系统设计

一款基于 ET(ET6.0)框架的放置类网络游戏实践项目。放置类(挂机)游戏的核心是”轻操作、重成长、数值驱动“——玩家上线点点、下线也成长,靠一套完整的数值与系统闭环支撑长线体验。下文核心模块附项目仓库真实代码。


项目概述

放置类游戏的系统盘根错节:账号、区服、角色、数值、战斗、装备、任务、背包、打造、聊天、排行榜环环相扣。本文从玩家生命周期出发,梳理这套放置类网络游戏的核心系统设计。

1
账号登录 → 区服/角色选择 → 角色数据/数值 → 战斗副本(挂机) → 装备/任务/背包 → 打造 → 排行榜/聊天

核心系统模块

1. 账号登录

登录是玩家身份的起点。项目采用 Realm(登录服)→ Gate(网关)→ Account(账号服) 的分服链路。

① 登录服分配网关(Realm) —— 客户端登录时,Realm 随机分配一个 Gate,并向 Gate 请求一个 key,客户端拿 key 连 Gate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Server/Hotfix/Demo/C2R_LoginHandler.cs(精简)
public class C2R_LoginHandler : AMRpcHandler<C2R_Login, R2C_Login>
{
protected override async ETTask Run(Session session, C2R_Login request, R2C_Login response, Action reply)
{
// 随机分配一个Gate
StartSceneConfig config = RealmGateAddressHelper.GetGate(session.DomainZone(), 1);
// 向gate请求一个key,客户端可以拿着这个key连接gate
G2R_GetLoginKey g2RGetLoginKey = (G2R_GetLoginKey) await ActorMessageSenderComponent.Instance.Call(
config.InstanceId, new R2G_GetLoginKey() {Account = request.Account});
response.Address = config.OuterIPPort.ToString();
response.Key = g2RGetLoginKey.Key;
reply();
}
}

② 账号服校验与注册(Account) —— 账号规范(大小写+数字)、密码校验、防重复、生成 Token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Server/Hotfix/Demo/Account/Handler/C2A_LoginAccountHandler.cs(精简)
// 账号必须同时含小写、大写、数字,6-15 位
if (!Regex.IsMatch(request.AccountName.Trim(), @"^(?=.*[0-9].*)(?=.*[A-Z].*)(?=.*[a-z].*).{6,15}$"))
{ response.Error = ErrorCode.Err_LoginInfoAccountError; reply(); session.Disconnect().Coroutine(); return; }

using (session.AddComponent<SessionLockingComponent>()) // 防止重复请求
{
using (await CoroutineLockComponent.Instance.Wait(CoroutineLockType.LoginAccount, request.AccountName.Trim().GetHashCode()))
{
var accountinfolist = await DBManagerComponent.Instance.GetZoneDB(session.DomainZone())
.Query<Account>(d => d.AccountName.Equals(request.AccountName.Trim()));
if (accountinfolist != null && accountinfolist.Count > 0)
{
account = accountinfolist[0];
if (!account.password.Equals(request.Password)) { response.Error = ErrorCode.Err_LoginInfoPassWorkFlaseError; ... }
}
else // 新账号:自动注册入库
{
account = session.AddChild<Account>();
account.AccountName = request.AccountName.Trim();
account.password = request.Password;
await DBManagerComponent.Instance.GetZoneDB(session.DomainZone()).Save<Account>(account);
}
// 生成 Token(时间戳+随机数),实现单点登录
string Token = TimeHelper.ServerNow().ToString() + RandomHelper.RandomNumber(int.MinValue, int.MaxValue).ToString();
...
}
}

要点:

  • 账号规范用正则强约束(大小写+数字+长度),不可重复由 DB 唯一查询 + 协程锁保证;
  • 密码加密:注册/登录前对密码做哈希(项目里存密文),不落明文;
  • 本地保存:客户端保存登录返回的 Token,后续请求携带 Token 免重复登录;
  • 单点登录:新登录会踢掉旧 session(otherSession?.Disconnect())。

2. 区服管理

不同区服独立世界。登录后向 Account 服拉取区服列表,供玩家选择:

1
2
3
4
5
6
// Server/Hotfix/Demo/ServerInfo/Handler/C2A_GetServerInfoHandler.cs(精简)
// 校验 Token 后,返回全部区服信息
foreach (var serverInfo in session.DomainScene().GetComponent<ServerInfoManagerComponent>().ServerInfos)
{
response.ServerInfosProto.Add(serverInfo.ToMessage());
}

区服由 ServerInfoManagerComponent 统一管理,每个区服独立承载世界与数据;玩家选定区服后绑定,区服之间数据隔离,便于运营开新服/合服。

3. 账号角色管理

一个账号绑定多个角色、角色名不可重复。创建角色时做 Token 校验 + 重名校验 + 协程锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Server/Hotfix/Demo/Role/Handler/C2A_CreatRoleHandler.cs(精简)
// 校验 Token,防止越权创建
string token = session.DomainScene().GetComponent<TokenComponent>().Get(request.AccountId);
if (token == null || token != request.Token) { response.Error = ErrorCode.Err_TokenError; ... return; }

using (await CoroutineLockComponent.Instance.Wait(CoroutineLockType.CreatRole, request.AccountId))
{
// 同区服角色名唯一
var roleInfos = await DBManagerComponent.Instance.GetZoneDB(session.DomainZone())
.Query<RoleInfo>(d => d.Name == request.Name && d.ServerID == request.ServerId);
if (roleInfos != null && roleInfos.Count > 0) { response.Error = ErrorCode.Err_RoleNameSame; reply(); return; }

RoleInfo roleInfo = session.AddChildWithId<RoleInfo>(IdGenerater.Instance.GenerateUnitId(request.ServerId));
roleInfo.Name = request.Name;
roleInfo.AccountId = request.AccountId; // 账号→角色绑定(一个账号多个角色)
roleInfo.ServerID = request.ServerId;
await DBManagerComponent.Instance.GetZoneDB(session.DomainZone()).Save<RoleInfo>(roleInfo);
}

要点:角色名唯一用 DB 条件查询(Name == 请求名 && ServerID == 请求服)在创建前校验;账号与角色是 1:N,靠 AccountId 关联。

4. 角色数据管理

  • 账号数据:等级、经验、货币、签到等玩家级数据;
  • 装备数据:角色穿戴的装备列表;
  • 持久化到 MongoDB。数据按热区/冷区分层,热数据常驻内存、定期落库(配合下方”缓存服务器”)。

5. 角色数值

放置类游戏的数值体系是核心。项目用 ET Numeric 数值组件实现”加点消耗 → 属性值 → 二级/百分比联动“:

① 加点处理(消耗属性点、扣点、写值、立即存库):

1
2
3
4
5
6
7
8
9
10
11
12
13
// Server/Hotfix/Demo/Numeric/Handler/C2M_AddAttributePointHnadler.cs(精简)
NumericComponent numericComponent = unit.GetComponent<NumericComponent>();
// 校验该属性类型是否可加点
PlayerNumericConfig config = PlayerNumericConfigCategory.Instance.Get(targetNumericType);
if (config.isAddPoint == 0) { response.Error = ErrorCode.Err_NumericTypeNoExist; reply(); return; }

int AttributePointCount = numericComponent.GetAsInt(NumericType.AttributePoint);
if (AttributePointCount <= 0) { response.Error = ErrorCode.AddPointNoEnough; reply(); return; }

--AttributePointCount;
numericComponent.Set(NumericType.AttributePoint, AttributePointCount);
numericComponent.Set(targetNumericType, numericComponent.GetAsInt(targetNumericType) + 1);
await numericComponent.AddOrUpdateUnitCache(); // 关键数据立即存库

② 数值联动(属性点变动 → 一级数值派生出二级/百分比属性):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Server/Hotfix/Demo/Numeric/Event/NumericWatcher_AddAttributePoint.cs(精简)
[NumericWatcher(NumericType.Spirit)]
[NumericWatcher(NumericType.Agile)]
[NumericWatcher(NumericType.PhysicalStrength)]
[NumericWatcher(NumericType.Power)]
public class NumericWatcher_AddAttributePoint : INumericWatcher
{
public void Run(NumbericChange args)
{
if (args.NumericType == NumericType.Power) // 力量
unit.GetComponent<NumericComponent>()[NumericType.DamageValueAdd] += 5; // 二级:攻击力+5
if (args.NumericType == NumericType.PhysicalStrength) // 体力
unit.GetComponent<NumericComponent>()[NumericType.HpPct] += 1 * 10000; // 百分比:生命+1%
if (args.NumericType == NumericType.Agile) // 敏捷
unit.GetComponent<NumericComponent>()[NumericType.ArmorFinalAdd] += 5; // 二级:护甲+5
if (args.NumericType == NumericType.Spirit) // 精神
unit.GetComponent<NumericComponent>()[NumericType.MPFinalPct] += 1 * 10000; // 百分比:魔法+1%
}
}

要点:这就是文章要表达的一级/二级/百分比数值三层设计——一级数值是”底”(力量/体力/敏捷/精神),通过 NumericWatcher 事件联动出二级(攻击/护甲)和百分比(生命%/魔法%)加成,最终叠加成面板。Numeric 用事件监听而非硬编码,加新属性扩展容易。

6. 战斗副本

放置类游戏的战斗常为自动/托管,服务器负责核算:

  1. 根据配置生成怪物数量:副本配置定义怪物波次与数量;
  2. 根据怪物 id 生成怪物:读取怪物配置实例化;
  3. 伤害计算:攻防公式结算;
  4. 战斗漂字:客户端表现伤害数字;
  5. 服务器战斗核算:核心设计——服务器根据初始数据与随机数种子进行战斗模拟计算,即”确定性战斗“:给定相同输入与随机种子,战斗结果可完全复现,天然防作弊、便于回放与断线续战;
  6. 战斗结果由服务器权威下发。

说明:战斗副本/装备/任务/背包/打造等玩法系统为项目的规划模块,仓库当前以登录、区服、角色、数值等 ET 基础框架实现为主,故此处为设计讲解,不贴代码。

7. 个人装备模块

  • 装备数据:装备实例的属性、穿戴状态;
  • 装备属性:攻击/防御等加成,影响最终战力;
  • 装备来源:副本掉落、打造、任务奖励。

8. 任务模块

  • 任务 id / 类型:主线、日常、活动等;
  • 任务奖励:金币、经验、装备等;
  • 任务进度推进与奖励领取由服务端校验。

9. 背包模块

  • item / 装备:背包格子管理,装备与道具分类;
  • item 售卖:出售道具获得货币,管理背包空间。

10. 装备打造模块

  • 打造条件:等级、前置任务等门槛;
  • 打造所需材料:消耗材料合成装备;
  • 打造定时器:打造是耗时行为(如倒计时完成),由定时器驱动完成事件,是放置类”挂机等待”体验的一部分。

平台与社交系统

缓存服务器与定时数据存储

放置类游戏数据写入频繁(等级、资源不断变化),用缓存服务器做中间层:

  • 高频数据先写缓存(内存),定时批量落库到 MongoDB;
  • 减少数据库压力、提升吞吐,是放置类高写入场景的标配方案。

世界聊天

  • 全服/频道聊天消息广播;
  • 通过消息系统分发,支持世界频道等。

排行榜

  • 定时刷新排行榜:周期性重算排名,避免每次变化都全量排序;
  • 根据等级进行排行榜:按等级/战力等维度排行;
  • 等级变化回调刷新该等级区域排行榜:玩家等级变化时,只增量刷新受影响区段的排名,而非全榜重排——这是排行榜的性能关键。

小结

这套放置类游戏覆盖了账号、区服、角色、数值、战斗、装备、任务、背包、打造、缓存、聊天、排行榜的完整闭环。值得借鉴的设计:

  • 分服登录链路:Realm 分配 Gate + 拿 key 连接,Account 服做校验/注册/Token;
  • 账号/角色安全:正则规范账号、协程锁防并发、Token 校验防越权、角色名 DB 查重;
  • 数值三分层 + 事件联动:一级属性 → 二级/百分比由 NumericWatcher 派生,成长曲线易调;
  • 确定性战斗(随机数种子):服务器模拟可复现,防作弊;
  • 缓存服务器 + 定时落库:扛住放置类高频写入;
  • 排行榜增量刷新:只重算受影响区段。

附录:ET 框架机制深挖

上面的登录、角色、数值代码背后,其实是 ET 框架的几套核心机制。把它们讲透,就明白为什么 ET 适合做这类多进程、高并发的网络游戏。

1. 协程锁(CoroutineLock):用协程队列替代线程锁

ET 是单线程驱动模型,不能像多线程那样用 lock(会阻塞整个游戏循环)。ET 的做法是:同一把”锁”的请求排队,前一个释放后唤醒下一个,全程不阻塞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Unity/Codes/Model/Module/CoroutineLock/CoroutineLockComponent.cs(精简)
public static async ETTask<CoroutineLock> Wait(this CoroutineLockComponent self, int coroutineLockType, long key, int time = 60000)
{
CoroutineLockQueueType queueType = self.list[coroutineLockType];
if (!queueType.TryGetValue(key, out CoroutineLockQueue queue))
{
// 第一个请求:直接创建锁并返回(拿到锁)
queueType.Add(key, self.AddChildWithId<CoroutineLockQueue>(++self.idGenerator, true));
return self.CreateCoroutineLock(coroutineLockType, key, time, 1);
}
// 已有锁被占用:进队列等待,await 挂起;前一个释放后由 Notify 唤醒
ETTask<CoroutineLock> tcs = ETTask<CoroutineLock>.Create(true);
queue.Add(tcs, time);
return await tcs;
}

public static void Notify(this CoroutineLockComponent self, int coroutineLockType, long key, int level)
{
...
CoroutineLockInfo info = queue.Dequeue();
info.Tcs.SetResult(self.CreateCoroutineLock(coroutineLockType, key, info.Time, level)); // 唤醒下一个
}

关键点:

  • 锁按 (锁类型, key) 分组(如登录锁 CoroutineLockType.LoginAccount + 账号名 hash),同 key 互斥、不同 key 并行;
  • Update 里每帧做超时检测(默认 60000ms),超时的锁直接放行下一个,避免死锁;
  • 单线程下天然安全,是 ET 替代传统锁的核心原语。

2. Actor 消息 / RPC(ActorMessageSender):跨进程通信

登录链路里那行 ActorMessageSenderComponent.Instance.Call(...),底层是 ET 的 Actor RPC——跨进程发请求、await 等响应:

1
2
3
4
5
6
7
8
9
10
11
12
// Server/Hotfix/Module/Actor/ActorMessageSenderComponentSystem.cs(精简)
public static async ETTask<IActorResponse> Call(this ActorMessageSenderComponent self, long actorId, IActorRequest request, bool needException = true)
{
request.RpcId = self.GetRpcId(); // 分配唯一的 RpcId
(ushort _, MemoryStream stream) = MessageSerializeHelper.MessageToStream(request);
return await self.Call(actorId, request.RpcId, stream, needException);
}

// 内部核心:注册回调 → 发送到目标进程 → await 等待响应
self.requestCallback.Add(rpcId, new ActorMessageSender(actorId, memoryStream, tcs, needException));
self.Send(actorId, memoryStream); // 经 NetInnerComponent 发给 actorId 所在进程
IActorResponse response = await tcs; // 等响应回来,RunMessage 里 SetResult 唤醒

关键点:

  • 每个进程的 Actor 都有一个唯一 actorId(编码了进程号),Send 用 NetInnerComponent 路由到对应进程;
  • 每次请求带 RpcId,响应回来用 requestCallback[rpcId] 精确配对;
  • 超时检测:组件定时器每秒 Check(),超过 TIMEOUT_TIME 未响应就 SetException(ERR_ActorTimeout),防止 RPC 永久挂起。

3. Numeric 数值组件:五值合成公式 + 万倍放大

这是放置类数值系统的底层引擎。ET 的 Numeric 有几个精妙设计:

① 浮点用 10000 放大存储,避免精度误差:

1
2
3
// Unity/Codes/Model/Module/Numeric/NumericComponent.cs(精简)
public static float GetAsFloat(this NumericComponent self, int nt) => (float)self.GetByKey(nt) / 10000;
public static void Set(this NumericComponent self, int nt, float value) => self[nt] = (int)(value * 10000);

② 一个最终值由 5 个值合成(对应前面”一级/二级/百分比”的底层):

1
2
3
4
5
6
7
8
9
10
11
// 数值 key 按组组织:final = nt/10, bas=final*10+1, add=+2, pct=+3, finalAdd=+4, finalPct=+5
public static void Update(this NumericComponent self, int numericType, bool isPublicEvent)
{
int final = numericType / 10; int bas = final * 10 + 1; int add = final * 10 + 2;
int pct = final * 10 + 3; int finalAdd = final * 10 + 4; int finalPct = final * 10 + 5;
// final = (((base + add) * (100 + pct) / 100) + finalAdd) * (100 + finalPct) / 100
long result = (long)(((self.GetByKey(bas) + self.GetByKey(add))
* (100 + self.GetAsFloat(pct)) / 100f + self.GetByKey(finalAdd))
* (100 + self.GetAsFloat(finalPct)) / 100f);
self.Insert(final, result, isPublicEvent);
}

③ 值变化发布事件,驱动 NumericWatcher 联动:

1
2
3
4
5
6
7
8
9
10
11
12
public static void Insert(this NumericComponent self, int numericType, long value, bool isPublicEvent = true)
{
long oldValue = self.GetByKey(numericType);
if (oldValue == value) return;
self.NumericDic[numericType] = value;
if (isPublicEvent)
{
EventType.NumbericChange args = EventType.NumbericChange.Instance;
args.Parent = self.Parent; args.NumericType = numericType; args.Old = oldValue; args.New = value;
Game.EventSystem.PublishClass(args); // 发布事件 → 触发 NumericWatcher
}
}

解读:攻击、生命这类”最终值”不是存一个数,而是由 base(基础) + add(加成) + pct(百分比) + finalAdd(最终加成) + finalPct(最终百分比) 5 个分量合成——加 Buff 只动 add/pct,不会污染基础值;改成长曲线只调 base。这就是放置类游戏数值”好调、可扩展”的底层保证,也是前面 NumericWatcher_AddAttributePoint(力量→攻击、体力→生命%…)被自动触发的源头。

4. Unit 缓存(UnitCache):关键数据落库

角色、数值等 IUnitCache 组件由 UnitCacheComponent 统一缓存与落库(对应前文”缓存服务器/定时存储”的思路):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Server/Hotfix/Demo/UnitCache/UnitCacheComponentSystem.cs(精简)
public static async ETTask AddOrUpdate(this UnitCacheComponent self, long id, ListComponent<Entity> entityLists)
{
using (ListComponent<Entity> list = ListComponent<Entity>.Create())
{
foreach (Entity entity in entityLists)
{
string key = entity.GetType().Name;
if (!self.UnitCaches.TryGetValue(key, out UnitCache unitCache))
{ unitCache = self.AddChild<UnitCache>(); unitCache.key = key; self.UnitCaches.Add(key, unitCache); }
unitCache.AddOrUpdate(entity); // 先写内存缓存
list.Add(entity);
}
if (list.Count > 0)
await DBManagerComponent.Instance.GetZoneDB(self.DomainZone()).Save(id, list); // 再落库
}
}

要点:AddOrUpdateUnitCache()(加点里那行)把高频改动先更新到内存 UnitCache,再批量写 MongoDB——正是放置类高写入场景”缓存 + 定时/批量落库”的框架级实现。


更多作品见:本人作品

上一篇:
基于 ET 的双端战斗系统
下一篇:
本人作品
本文目录
本文目录