思考篇:积分是存成道具还是直接存数值?——ET/Skynet 框架下,从架构权衡到代码实现全解析

引言

做游戏开发的朋友肯定都懂,积分系统简直是项目标配!不管是竞技场荣誉点、工会贡献度,还是赛季手册经验值,咱们绕不开一个灵魂拷问:这些积分到底该塞进背包当道具存,还是直接挂玩家身上当数值存?

最近我在新做一个积分系统,又双叒叕碰到这个经典选择题。作为用 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 还是自研框架没有必然关系。真正决定你怎么选的,只有一件事:这个积分具备哪些业务特征:

  1. 满足下面这些特征 → 直接存【数值】
    只是一个单纯的数量,不需要记录它从哪来、什么时候获得
    没有过期时间,和角色生命周期一致
    读写极其频繁(买东西、切界面就查、实时刷新 UI)
    只需要做:加、减、判断够不够用
    追求:快、简单、稳定、开销小
    一句话:它就是个数字,没有 "身份",存数值!
  2. 满足下面这些特征 → 必须存【道具】
    需要过期,而且不同批次过期时间不一样
    需要追踪来源(任务送?活动送?充值送?)
    需要特殊属性(不可交易、限时、绑定、只能买指定商品)
    需要明细可查(出问题要追溯每一笔)
    允许一定性能开销,换取极强的灵活性
    一句话:它有 "身份、生命周期、特殊规则",存道具!
  3. 两边都想要 → 用【折中方案】(通用版)
    既想要数值的快,又想要道具的信息:
    主数量存数值(保证 UI、消耗、判断速度极快)
    流水 / 明细用道具 / 日志记录(用来查来源、过期、统计)
    性能不丢,业务也能满足,这是绝大多数项目的最优解。
    最终一句话结论(最强、最通用)
    积分没有银弹,只看业务特征:无状态、高频、永久有效 → 数值有状态、低频、带属性 / 过期 → 道具既要快又要信息 → 数值 + 明细折中
    不管你用什么框架、什么语言,这套判断逻辑永远适用。
相关推荐
CDN3602 小时前
CSDN 技术分享|360CDN SDK 游戏盾集成与常见问题
运维·游戏
学嵌入式的小杨同学2 小时前
STM32 进阶封神之路(十八):RTC 实战全攻略 —— 时间设置 + 秒中断 + 串口更新 + 闹钟功能(库函数 + 代码落地)
c++·stm32·单片机·嵌入式硬件·mcu·架构·硬件架构
fajianchen2 小时前
如何设计微服务统一认证中心
微服务·云原生·架构·iam
于先生吖2 小时前
基于 Java 开发短剧系统:完整架构与核心功能实现
java·开发语言·架构
我是唐青枫2 小时前
深入理解 C#.NET Task.Run:调度原理、线程池机制与性能优化
性能优化·c#·.net
阿蒙Amon2 小时前
C#常用类库-详解NModbus4
开发语言·c#
LFly_ice2 小时前
C# Web 开发从入门到实践
开发语言·前端·c#
jaysee-sjc3 小时前
十六、Java 网络编程全解析:UDP/TCP 通信 + BS/CS 架构
java·开发语言·网络·tcp/ip·算法·架构·udp