引言
做游戏开发的朋友肯定都懂,积分系统简直是项目标配!不管是竞技场荣誉点、工会贡献度,还是赛季手册经验值,咱们绕不开一个灵魂拷问:这些积分到底该塞进背包当道具存,还是直接挂玩家身上当数值存?
最近我在新做一个积分系统,又双叒叕碰到这个经典选择题。作为用 ET 框架(C# ECS)和 Skynet(Lua/C actor 模型)的开发者,这俩完全不同的架构理念,直接让我把这个问题想透了!
今天咱们就不绕弯子,从宏观方案 对比聊到微观读数量操作 ,把积分设计的工程取舍扒得明明白白,看完直接能落地项目!注:以下所有代码为伪代码,实际开发中不这么写。。。。。
一、宏观视角:两种方案硬碰硬,优缺点一眼看懂
先把两个方案的核心逻辑、优缺点摊开说,不管用啥框架,先有个基础认知。
方案 A:数值存放(最简单直接的原始类型)
这是最直观的做法,就像给玩家贴个 "数字标签"。ET 框架里,直接在 Player 实体挂个 NumericComponent,用字典存积分;Skynet 里,就是玩家数据服务里一个普通变量,简单粗暴。
csharp
// ET框架示例
public class NumericComponent : Entity
{
public Dictionary<int, long> NumericDic = new();
// Key = 1001 代表雪花积分
}
✅ 优点(爽点拉满)
性能天花板:纯内存读写,快到没朋友,ET 自带事件机制还能自动同步客户端;
计算超方便:加减乘除一键搞定,排行榜、实时 UI 更新毫无压力;
事务简单:单个 Player 协程内操作,保证最终一致性就行,不用操心复杂逻辑。
❌ 缺点(硬伤明显)
没 "身份":就是个干巴巴的数字,没法加获得时间、来源这些元数据;
过期难处理:想让积分过期?只能开定时任务扫全表,或者获取时判断,代码侵入性极强;
无迹可查:不单独写流水日志的话,积分咋来的、咋没的,完全追溯不了。
方案 B:道具存放(把积分当成独立 "实体" 管理)
不管是 ET 的 ECS,还是 Skynet 的 actor 模型,咱们直接把积分看成一个独立道具,丢进背包系统统一管,相当于给积分发了张 "身份证"。
csharp
// ET框架思路(道具存放)
public class SnowCoinItem : Entity
{
public int Count { get; set; } // 积分数量
public long ExpireTime { get; set; } // 过期时间(单独设置)
public string Source { get; set; } // 来源(比如任务ID、活动产出)
}
✅ 优点(灵活度拉满)
细粒度控制:能实现 "部分积分先过期""部分积分不可交易",精准追踪每笔积分流向;
业务解耦:直接复用背包系统的增删改查逻辑,不用在各个活动模块重复写代码;
出问题好排查:每个积分道具都有 "身份信息",像钞票冠字号一样,bug 一查一个准。
❌ 缺点(代价不小)
性能开销大:管理成千上万个积分实体,比存一个 long 变量耗资源多了;
操作繁琐:简单加 1 积分,都要走完整的道具生产、入库流程,步骤翻倍;
事务复杂:尤其 Skynet 里,背包服务是独立 actor,跨服务操作得小心处理消息顺序和回滚。
二、框架专属视角:ET 和 Skynet,选法完全不一样
聊完通用逻辑,咱们结合实战框架说 ------ET 和 Skynet 的架构理念不同,最优解天差地别!
ET 框架(C# ECS):优先数值,特殊情况才用道具
ET 是全栈强一致性的 ECS 框架,数据靠组件组织,数值存放是 "亲儿子"!
数值方案:NumericComponent 是 ET 一等公民,自带数值同步、监听机制,单纯显示、兑换积分,用这个最快最稳;
道具方案:积分变成 Entity 后,每个玩家几百种积分,会导致 Entity 数量爆炸,加重 EntitySystem 管理负担。
👉 ET 框架结论:除非积分需要过期时间、独立来源等元数据,否则老老实实放 NumericComponent,别瞎折腾道具!
Skynet(Lua/C):道具方案反而更安全
Skynet 是多进程、消息驱动的 actor 框架,每个服务都是独立的,道具存放能天然解决并发问题!
数值方案:放玩家数据服务里,读写快,但多个服务同时修改,必须串行化处理,否则会覆盖;
道具方案:丢独立背包服务里,所有积分操作都发消息给背包服务,actor 队列天然保证原子性,不用操心锁问题。
👉 Skynet 框架结论:想做高并发、数据安全的积分系统,道具方案更优雅,压力全在背包服务内部,不影响其他逻辑。
三、微观实操:核心!"读数量" 操作的细节差异
咱们落地到最常用的场景:商店兑换商品,先查玩家有多少积分。这一步的性能、实现难度,直接决定方案好坏!
1. 数值方案:快到极致,O (1) 直接读取
ET 框架实现(秒级操作)
csharp
// 商店购买(数值版)
public static async ETTask BuyGoods(Player player, int goodsId)
{
var numeric = player.GetComponent<NumericComponent>();
// 直接字典查找,纳秒级操作,无任何开销
long currentPoints = numeric.GetAsLong(NumericType.SnowCoin);
if (currentPoints < goodsPrice) return;
// 直接扣减,一步到位
numeric.ApplyChange(NumericType.SnowCoin, -goodsPrice);
// 发放道具...
}
Skynet 实现(跨服务调用,但逻辑简单)
lua
-- 商店服务(数值版)
function CMD.buy(player_service, goods_id)
-- 向玩家数据服务查积分
local current = skynet.call(player_service, "lua", "get_currency", "snow_coin")
if current < price then return false, "积分不足" end
-- 直接扣减
skynet.send(player_service, "lua", "dec_currency", "snow_coin", price)
return true
end
性能总结
ET:纯内存操作,O (1) 复杂度,毫无性能压力;Skynet:一次跨服务调用,微秒级开销,天然避免脏读。
2. 道具方案:灵活但有性能坑,O (n) 遍历
ET 框架实现(需要遍历背包)
csharp
// 商店购买(道具版)
public static async ETTask BuyGoods(Player player, int goodsId)
{
var inventory = player.GetComponent<InventoryComponent>();
long totalPoints = 0;
// 遍历所有背包道具,累加积分数量(O(n)复杂度!)
foreach (var item in inventory.GetAllItems())
{
if (item.ItemType == ItemType.SnowCoin)
{
totalPoints += item.Count;
}
}
if (totalPoints < goodsPrice) return;
inventory.RemoveItem(ItemType.SnowCoin, goodsPrice);
// 发放道具...
}
Skynet 实现(调用背包服务,步骤翻倍)
lua
-- 商店服务(道具版)
function CMD.buy(player_id, goods_id)
-- 先查背包总积分(第一次调用)
local total = skynet.call("bag_service", "lua", "get_item_count", player_id, "snow_coin")
if total < price then return false, "积分不足" end
-- 再扣减积分(第二次调用)
local ok = skynet.call("bag_service", "lua", "remove_item", player_id, "snow_coin", price)
return ok
end
性能总结
ET:数值方案是 O (1),道具方案是 O (n),背包格子多了,高并发下直接成性能热点;Skynet:跨服务调用次数翻倍,背包内部还要遍历,容易成瓶颈。
3. 关键优化:缓存总数量,解决遍历问题
道具方案想优化读性能?加缓存!
ET:在 InventoryComponent 里维护CachedTotal字典,积分变化时同步更新缓存,读操作变回 O (1),但要保证原子性,避免缓存不一致;
Skynet:背包服务内部维护缓存,actor 消息串行处理,缓存一致性超好控制,唯一缺点是背包服务负载高了,响应会变慢。
4. 并发修改 & 持久化:别忽视这些隐形问题
并发安全:ET 数值方案用 CoroutineLock 保护临界区;道具方案要加锁 / 快照;Skynet 全靠 actor 串行化,天然安全;
持久化开销:数值方案只存一个 long 字段,加载保存超快;道具方案要存 N 个实体,数据量大了必须分页 / 增量保存,复杂度飙升。
四、实战折中方案:别死磕二选一,这样选最稳!
实际开发根本不是非黑即白,我总结了三层积分划分模型,直接照着选不出错!
1. 三层划分(直接复用)
① 硬通货型(必选数值方案)
比如钻石、金币,和角色同生命周期,永不过期,只需要增减。
做法:ET 放 NumericComponent,Skynet 放玩家数据字段;
理由:要的就是极致性能,别搞花里胡哨的。
② 活动票据型(必选道具方案)
比如端午节粽子、圣诞节雪花,有过期时间,需要追踪来源,甚至属性不同。
做法:全框架丢背包当道具;
理由:复用背包过期、展示逻辑,管理超方便。
③ 中间态:带标签的数值(ET 首选折中)
既想要数值的性能,又想要元数据?直接给数值加标签!
csharp
// 带标签的数值(ET示例)
public class TaggedNumeric
{
public long Value { get; set; } // 核心数值(保证计算快)
public long ExpireTime { get; set; } // 过期时间
public string Source { get; set; } // 来源
}
public class NumericComponent : Entity
{
public Dictionary<int, TaggedNumeric> NumericDic = new();
}
优点:计算快,还能存元数据;
缺点:没法单独操作单个标签积分(比如消耗最早的积分要额外排序)。
2. 最终选型(记牢!)
高频读写、无元数据 → 数值方案(无脑冲);
低频操作、需过期 / 追踪 → 道具方案(记得加缓存);
既要性能又要元数据 → 数值 + 标签折中(ET 首选)。
五、总结:不看框架,只看特征 ------ 这才是通用选型心法
积分到底存成数值还是存成道具,其实和你用 ET、Skynet、Unity、UE 还是自研框架没有必然关系。真正决定你怎么选的,只有一件事:这个积分具备哪些业务特征:
- 满足下面这些特征 → 直接存【数值】
只是一个单纯的数量,不需要记录它从哪来、什么时候获得
没有过期时间,和角色生命周期一致
读写极其频繁(买东西、切界面就查、实时刷新 UI)
只需要做:加、减、判断够不够用
追求:快、简单、稳定、开销小
一句话:它就是个数字,没有 "身份",存数值! - 满足下面这些特征 → 必须存【道具】
需要过期,而且不同批次过期时间不一样
需要追踪来源(任务送?活动送?充值送?)
需要特殊属性(不可交易、限时、绑定、只能买指定商品)
需要明细可查(出问题要追溯每一笔)
允许一定性能开销,换取极强的灵活性
一句话:它有 "身份、生命周期、特殊规则",存道具! - 两边都想要 → 用【折中方案】(通用版)
既想要数值的快,又想要道具的信息:
主数量存数值(保证 UI、消耗、判断速度极快)
流水 / 明细用道具 / 日志记录(用来查来源、过期、统计)
性能不丢,业务也能满足,这是绝大多数项目的最优解。
最终一句话结论(最强、最通用)
积分没有银弹,只看业务特征:无状态、高频、永久有效 → 数值有状态、低频、带属性 / 过期 → 道具既要快又要信息 → 数值 + 明细折中
不管你用什么框架、什么语言,这套判断逻辑永远适用。