基于 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
| public class C2R_LoginHandler : AMRpcHandler<C2R_Login, R2C_Login> { protected override async ETTask Run(Session session, C2R_Login request, R2C_Login response, Action reply) { StartSceneConfig config = RealmGateAddressHelper.GetGate(session.DomainZone(), 1); 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
|
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); } 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
|
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
|
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
| 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
| [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; if (args.NumericType == NumericType.PhysicalStrength) unit.GetComponent<NumericComponent>()[NumericType.HpPct] += 1 * 10000; if (args.NumericType == NumericType.Agile) unit.GetComponent<NumericComponent>()[NumericType.ArmorFinalAdd] += 5; if (args.NumericType == NumericType.Spirit) unit.GetComponent<NumericComponent>()[NumericType.MPFinalPct] += 1 * 10000; } }
|
要点:这就是文章要表达的一级/二级/百分比数值三层设计——一级数值是”底”(力量/体力/敏捷/精神),通过 NumericWatcher 事件联动出二级(攻击/护甲)和百分比(生命%/魔法%)加成,最终叠加成面板。Numeric 用事件监听而非硬编码,加新属性扩展容易。
6. 战斗副本
放置类游戏的战斗常为自动/托管,服务器负责核算:
- 根据配置生成怪物数量:副本配置定义怪物波次与数量;
- 根据怪物 id 生成怪物:读取怪物配置实例化;
- 伤害计算:攻防公式结算;
- 战斗漂字:客户端表现伤害数字;
- 服务器战斗核算:核心设计——服务器根据初始数据与随机数种子进行战斗模拟计算,即”确定性战斗“:给定相同输入与随机种子,战斗结果可完全复现,天然防作弊、便于回放与断线续战;
- 战斗结果由服务器权威下发。
说明:战斗副本/装备/任务/背包/打造等玩法系统为项目的规划模块,仓库当前以登录、区服、角色、数值等 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
| 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); } 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
| public static async ETTask<IActorResponse> Call(this ActorMessageSenderComponent self, long actorId, IActorRequest request, bool needException = true) { request.RpcId = self.GetRpcId(); (ushort _, MemoryStream stream) = MessageSerializeHelper.MessageToStream(request); return await self.Call(actorId, request.RpcId, stream, needException); }
self.requestCallback.Add(rpcId, new ActorMessageSender(actorId, memoryStream, tcs, needException)); self.Send(actorId, memoryStream); IActorResponse response = await tcs;
|
关键点:
- 每个进程的 Actor 都有一个唯一
actorId(编码了进程号),Send 用 NetInnerComponent 路由到对应进程;
- 每次请求带
RpcId,响应回来用 requestCallback[rpcId] 精确配对;
- 超时检测:组件定时器每秒
Check(),超过 TIMEOUT_TIME 未响应就 SetException(ERR_ActorTimeout),防止 RPC 永久挂起。
3. Numeric 数值组件:五值合成公式 + 万倍放大
这是放置类数值系统的底层引擎。ET 的 Numeric 有几个精妙设计:
① 浮点用 10000 放大存储,避免精度误差:
1 2 3
| 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
| 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; 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); } }
|
解读:攻击、生命这类”最终值”不是存一个数,而是由 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
| 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——正是放置类高写入场景”缓存 + 定时/批量落库”的框架级实现。
更多作品见:本人作品