多日签到功能设计目录
游戏业务——多日签到功能设计
运营需要我们设计一个签到功能,策划说的很简单,但是需求并不明朗,在我多次询问之后得出了相对明确的需求:支持多日签到,可能有多种签到类型,具体多少天不确定,有起止时间,不循环,但终止后可能会再重启新一轮。登录即代表签到,可以获取当日的签到奖励。若某日未登录,则该日的签到奖励无法获取,暂时不支持补签。每个玩家的签到首日不一定相同。
一.需求分析
根据需求可以发现一些设计上的关键点:
- 签到模块应该是尽量通用的模板,这样才能方便生成多个签到类型;
- 使用配置表给策划用于配置每日签到的信息如ID、奖励等,配置多少行就最多签到多少天;
- 要配置的起止时间表示一种签到的有效期;
- 某一类型签到过期后将来再重启的话,为了防止玩家上一周期的签到信息与当前周期混淆,需要添加周期标识;
- 因为登录就代表本日的签到,故无需提供客户端签到请求接口;
- 需要给客户端提供查询个人签到情况和申请某日签到奖励的接口;
- 要给每日签到情况设置多个状态,包括可领取、已领取、遗漏签到;
- 每个玩家的每种签到信息都要存盘,存盘字段至少包含上次签到日、上次签到天数索引、当前已过的日子的签到状态。
除此以外,这种跟时间戳关系密切的方案设计,应该尽量考虑到开发期方便调试。现在项目的服务端由c++编写框架和公共组件、lua编写业务逻辑,要注意方便代码reload,以及方便任意调整时间测试即保证时间回拨情况下的容错。尤其目前项目没有稳定可靠的类似时间任务调度的公共模块,设计上能通用尽量通用。
二.模块结构设计
我把签到模块称为SignInReward。总的来说要做的事:
- 需要注册网络handler处理玩家请求
- 每帧update检查时间,达到新一天的0点时更新签到类型的信息和在线玩家的签到数据。
- 数据库的读写。
在我看来如果策划要求的不是登录即签到,而是客户端主动请求签到,每日0点更新的时候是不用更新所有在线玩家的。而且我们项目目前要啥啥没有,工作量也更多一点。但是没办法,需求就是这么定的。
三.设计详情
(1) 数据结构设计
- 模块内部数据结构
每个符合条件的玩家都拥有一个SignInRewardComponent成员,全局有一个唯一的SignInRewardMgr管理对象。
-- 签到类型详情
SignInRewardMgr.tbSignInTypeInfo = {
-- 新手签到,类型名源自pb枚举
SIGN_IN_TYPE_ROOKIE = {
szXLSXName = "RookieSignInReward", -- 奖励配表名字
nPeriodCount = 1, -- 周期数
nStartTime = "2021-03-01", -- 开始时间
nEndTime = "2099-03-01", -- 结束时间
szTitle = "新手登录签到", -- 标题
szContent = "test", -- 其他显示信息
},
-- 其他类型...
}
-- 内存数据
SignInRewardMgr.tbSignInRewardInfo = {
szSignInType = "", -- 签到类型
bValid = false, -- 是否有效
bWaitingStart = false, -- 是否正在等待开始生效
nLocalDayMaxIndex = 0, -- 最大天数索引
nStartUnixSec = 0, -- 开始时间戳
nEndUnixSec = 0, -- 结束时间戳
nPeriodCount = 0, -- 周期数
szTitle = "", -- 标题
szContent = "", -- 其他显示信息
tbRewardDetailIndexMap = nil -- 配置表的奖励信息索引映射,奖励内容pb.CommonRewardInfo格式
}
function SignInRewardComponent:Reset()
self.bReady = false -- 准备好执行业务操作
self.tbPlayer = nil
self.szSignType = "" -- 签到类型
self.nRewardPeriodCount = 0 -- 所在的奖励周期
self.nLastSignInUnixSec = 0 -- 上次签到秒时间戳
self.nLastSignInLocalDay = 0 -- 上次签到日,0表示该周期首日,注意在线跨越一天的情况也要更新该字段
self.nLastSignInMaxIndex = 0 -- 上次签到的天数索引,0表示该周期首日,注意在线跨越一天的情况也要更新该字段
self.tbAllowIndexSet = nil -- 周期内允许获取奖励的天数索引
self.tbAlreadyIndexSet = nil -- 周期内已经获取奖励的天数索引
self.tbMissedIndexSet = nil -- 周期内错过获取奖励的天数索引
self.tbIndexStateMap = nil -- 以上各个Set的索引对应的状态
end
function SignInRewardMgr:Reset()
self.nNextTick = 0
self.nLastRecordUnixSec = 0 -- 最近记录的秒时间戳
self.nCurrentLocalDay = 0 -- 当前日,距离1970年1月1日已过的天数
self.nNextLocalDayUnixSec = 0 -- 次日零点的unix秒时间戳
self.tbNextDealingPlayerNode = nil -- 即将处理的在线玩家节点,更新时从尾向头遍历
self.tbValidSignInRewardInfoTypeMap = nil -- 有效的签到详情类型映射
self.tbWaitStartSignInRewardInfoTypeMap = nil -- 等待开始的签到详情类型映射
end
- 前后端通信结构
// 签到类型
enum SignInType {
SIGN_IN_TYPE_ROOKIE = 0; // 新手签到
}
// 通用奖励信息
message CommonRewardInfo {
repeated GrowthInfo growthInfos = 1;
repeated CurrencyInfo currencyInfos = 2;
repeated DropItemInfo itemInfos = 3;
}
// 一日签到信息
message SignInInfo {
enum PlayerApplyState {
APPLY_SIGN_IN_ALLOW = 0; // 允许领取
APPLY_SIGN_IN_ALREADY = 1; // 已经领取
APPLY_SIGN_IN_LOCKED = 2; // 不可领取(未解锁)
APPLY_SIGN_IN_MISSED = 3; // 不可领取(当日未签到)
}
uint32 index = 1;
PlayerApplyState applyState = 2;
CommonRewardInfo rewardInfo = 3;
}
// Sign In Reward
//请求签到类型详情
message ApplySignInInfoListReq {
SignInType signInType = 1;
}
//请求签到类型详情回复
message ApplySignInInfoListRsp {
SignInType signInType = 1;
string title = 2; // 标题
string description = 3; // 描述信息
repeated SignInInfo signInInfoArray = 4;
}
//请求签到类型某日奖励
message ApplySignInRewardReq {
SignInType signInType = 1;
uint32 index = 2;
}
//请求签到类型某日奖励回复
message ApplySignInRewardRsp {
enum PlayerApplyResult {
APPLY_SIGN_IN_REWARD_SUCCESS = 0; // 领取成功
APPLY_SIGN_IN_REWARD_ALREADY = 1; // 已经领取
APPLY_SIGN_IN_REWARD_NOT_ALLOW = 2; // 不可领取
APPLY_SIGN_IN_REWARD_INVALID_PARAMS = 3; // 无效的请求参数
APPLY_SIGN_IN_REWARD_INTERNAL_ERROR = 4; // 服务端内部错误
}
PlayerApplyResult result = 1;
SignInType signInType = 2;
uint32 index = 3;
}
- 数据库存盘结构
function SignInRewardComponent:SerializeToDB()
if self:IsReady() then
local tb = {
signInType = self:GetSignType(),
rewardPeriodCount = self:GetRewardPeriodCount(),
lastSignInUnixSec = self:GetLastSignInUnixSec(),
lastSignInLocalDay = self:GetLastSignInLocalDay(),
lastSignInMaxIndex = self:GetLastSignInMaxIndex(),
allowIndexArray = {
},
alreadyIndexArray = {
},
missedIndexArray = {
},
}
for index, _ in pairs(self.tbAllowIndexSet) do
table.insert(tb.allowIndexArray, index)
end
for index, _ in pairs(self.tbAlreadyIndexSet) do
table.insert(tb.alreadyIndexArray, index)
end
for index, _ in pairs(self.tbMissedIndexSet) do
table.insert(tb.missedIndexArray, index)
end
return tb
end
return nil
end
(2) 功能逻辑设计
- 管理类的配置加载
管理类在初始化时,填充当日的时间数据,并且将配置表内容复制一份到自己的内存管理。
function SignInRewardMgr:Init()
self:Reset()
self.tbNextDealingPlayerNode = nil
self.tbValidSignInRewardInfoTypeMap = self.tbValidSignInRewardInfoTypeMap or {
}
self.tbWaitStartSignInRewardInfoTypeMap = self.tbWaitStartSignInRewardInfoTypeMap or {
}
self:_UpdateCurrentLocalDay(KServerTime:UnixSec())
self:_LoadConfig()
return 1
end
首先要通过当前时间戳更新当日的一些信息,比如最近记录的时间戳、距离1970年1月1日的天数、次日0点的时间戳。这里有一个要注意的地方是,由于全世界各个地方的时区可能不一样,所以框架提供接口获取的时间戳跟当前所在的时区是相关的。比如我国在东八区,获得的时间戳是距离1970年1月1日早8点度过的秒数。
项目的公共库中有提供获取时差的接口,由此就可以计算出当前的天数和次日0点的时间戳。
这些数据在update过程中有用。
-- 获取时差(秒数)
function Lib:GetGMTSec()
if self.__localGmtSec then
return self.__localGmtSec;
else
self.__localGmtSec = os.difftime(GetTime(), os.time(os.date("!*t",GetTime())))
return self.__localGmtSec;
end
end
-- 根据秒数(UTC,GetTime()返回)计算当地天数
-- 1970年1月1日 返回0
-- 1970年1月2日 返回1
-- 1970年1月3日 返回2
-- ……依此类推
function Lib:GetLocalDay(nUtcSec)
local nLocalSec = (nUtcSec or GetTime()) + self:GetGMTSec();
return math.floor(nLocalSec / (3600 * 24));
end
-- 更新当日的时间信息
function SignInRewardMgr:_UpdateCurrentLocalDay(nCurrentUnixSec)
self:_SetNowUnixSec(nCurrentUnixSec)
self:_SetToday(Lib:GetLocalDay(self:GetNowUnixSec()))
self.nNextLocalDayUnixSec = (self:GetToday(<