游戏业务——多日签到功能设计

游戏业务——多日签到功能设计

运营需要我们设计一个签到功能,策划说的很简单,但是需求并不明朗,在我多次询问之后得出了相对明确的需求:支持多日签到,可能有多种签到类型,具体多少天不确定,有起止时间,不循环,但终止后可能会再重启新一轮。登录即代表签到,可以获取当日的签到奖励。若某日未登录,则该日的签到奖励无法获取,暂时不支持补签。每个玩家的签到首日不一定相同。

一.需求分析

根据需求可以发现一些设计上的关键点:

  1. 签到模块应该是尽量通用的模板,这样才能方便生成多个签到类型;
  2. 使用配置表给策划用于配置每日签到的信息如ID、奖励等,配置多少行就最多签到多少天;
  3. 要配置的起止时间表示一种签到的有效期;
  4. 某一类型签到过期后将来再重启的话,为了防止玩家上一周期的签到信息与当前周期混淆,需要添加周期标识;
  5. 因为登录就代表本日的签到,故无需提供客户端签到请求接口;
  6. 需要给客户端提供查询个人签到情况和申请某日签到奖励的接口;
  7. 要给每日签到情况设置多个状态,包括可领取、已领取、遗漏签到;
  8. 每个玩家的每种签到信息都要存盘,存盘字段至少包含上次签到日、上次签到天数索引、当前已过的日子的签到状态。

除此以外,这种跟时间戳关系密切的方案设计,应该尽量考虑到开发期方便调试。现在项目的服务端由c++编写框架和公共组件、lua编写业务逻辑,要注意方便代码reload,以及方便任意调整时间测试即保证时间回拨情况下的容错。尤其目前项目没有稳定可靠的类似时间任务调度的公共模块,设计上能通用尽量通用。

二.模块结构设计

在这里插入图片描述

我把签到模块称为SignInReward。总的来说要做的事:

  1. 需要注册网络handler处理玩家请求
  2. 每帧update检查时间,达到新一天的0点时更新签到类型的信息和在线玩家的签到数据。
  3. 数据库的读写。

在我看来如果策划要求的不是登录即签到,而是客户端主动请求签到,每日0点更新的时候是不用更新所有在线玩家的。而且我们项目目前要啥啥没有,工作量也更多一点。但是没办法,需求就是这么定的。

三.设计详情

(1) 数据结构设计
  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

  1. 前后端通信结构
// 签到类型
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;
}
  1. 数据库存盘结构
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) 功能逻辑设计
  1. 管理类的配置加载

管理类在初始化时,填充当日的时间数据,并且将配置表内容复制一份到自己的内存管理。

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() + 1) * 3600 * 24 - math.floor(Lib:GetGMTSec())
end

然后才开始加载配置内容。先清空原配置内容,因为本次加载可能是程序运行过程中reload脚本,避免前后两次数据混淆。接着给每一种类型的签到,填充运行过程中需要用到的配置数据,其中细节无需一一说明。

-- 加载配置内容
function SignInRewardMgr:_LoadConfig()
    self.tbValidSignInRewardInfoTypeMap = {}
    self.tbWaitStartSignInRewardInfoTypeMap = {}
    self:_LoadSignRewardTypeConfig()
    self:_OnUpdateNecessaryInfoByNewConfig()
end

-- 加载签到类型配置
function SignInRewardMgr:_LoadSignRewardTypeConfig()
    for szSignInType, tbInfo in pairs(self.tbSignInTypeInfo or Lib:GetEmptyTable()) do
        local _, tbRewardInfo = self:_LoadOneSignRewardInfoConfig(szSignInType, tbInfo)
        if tbRewardInfo.bValid then
            self.tbValidSignInRewardInfoTypeMap[szSignInType] = tbRewardInfo
        elseif tbRewardInfo.bWaitingStart then
            self.tbWaitStartSignInRewardInfoTypeMap[szSignInType] = tbRewardInfo
        end
    end
end

-- 加载具体类型签到描述配置
function SignInRewardMgr:_LoadOneSignRewardInfoConfig(szSignInType, tbInfo)
    assert(tbInfo)
    local result = false
    local tbRewardInfo = Lib:NewClass(self.tbSignInRewardInfo)
    tbRewardInfo.szSignInType = szSignInType
    tbRewardInfo.nPeriodCount = tbInfo.nPeriodCount
    tbRewardInfo.szTitle = tbInfo.szTitle
    tbRewardInfo.szContent = tbInfo.szContent
    local szFuncName = "Get" .. tbInfo.szXLSXName .. "Map"
    local fn = ConfigureManager[szFuncName]
    if not fn or Lib:IsEmptyTB(fn(ConfigureManager)) then
        -- 表格没有任何数据
        goto EXIT0
    end

    if tbRewardInfo.nPeriodCount <= 0 then
        -- 周期数必须大于0
        goto EXIT0
    end

    if not type(tbInfo.nStartTime) == "string" or not type(tbInfo.nEndTime) == "string" then
        goto EXIT0
    end

    tbRewardInfo.nStartUnixSec = Lib:GetStringDate2Time(tbInfo.nStartTime .. self.DATE_FORMAT_SUFFIX)
    tbRewardInfo.nEndUnixSec = Lib:GetStringDate2Time(tbInfo.nEndTime .. self.DATE_FORMAT_SUFFIX)

    if tbRewardInfo.nStartUnixSec >= tbRewardInfo.nEndUnixSec then
        goto EXIT0
    end

    if tbRewardInfo.nStartUnixSec > self:GetNowUnixSec() then
        tbRewardInfo.bWaitingStart = true
        goto EXIT0
    end

    if tbRewardInfo.nEndUnixSec <= self:GetNowUnixSec() then
        goto EXIT0
    end

    szFuncName = tbInfo.szXLSXName .. "ByIndex"
    fn = ConfigureManager[szFuncName]
    if fn then
        repeat
            local nCurrIndex = tbRewardInfo.nLocalDayMaxIndex + 1
            local tbRow = fn(ConfigureManager, nCurrIndex)
            if not tbRow then
                break
            end
            tbRewardInfo.nLocalDayMaxIndex = nCurrIndex
            tbRewardInfo.tbRewardDetailIndexMap = tbRewardInfo.tbRewardDetailIndexMap or {}
            tbRewardInfo.tbRewardDetailIndexMap[nCurrIndex] = self:_GenerateCommonRewardInfo(tbRow)
        until (false)
    end
    
    result = true
    ::EXIT0::
    tbRewardInfo.bValid = result
    return result, tbRewardInfo
end

function SignInRewardMgr:_GenerateCommonRewardInfo(tbConfigRow)
    assert(type(tbConfigRow) == "table")
    local tbRewardDetail = {}
    
    --[[
    -- exp
    if tbConfigRow.exp and tbConfigRow.exp > 0 then
        local tbGrowthInfo = {
            growthType = SignInRewardMgr.GROWTH_TYPE_EXP,
            growthNumber = tbConfigRow.exp,
        }
        tbRewardDetail.growthInfos = tbRewardDetail.growthInfos or {}
        table.insert(tbRewardDetail.growthInfos, tbGrowthInfo)
    end

    -- 货币
    local tbRet = Lib:SplitStr(tbConfigRow.currency, ",")
    assert(math.fmod(#tbRet, 2) == 0) -- 被2整除
    for i = 1, #tbRet, 2 do
        local tbCurrencyInfo = {
            currencyID = tonumber(tbRet[i]),
            currencyNumber = tonumber(tbRet[i + 1]),
        }
        tbRewardDetail.currencyInfos = tbRewardDetail.currencyInfos or {}
        table.insert(tbRewardDetail.currencyInfos, tbCurrencyInfo)
    end
    --]]
    
    -- 道具
    local tbRet = Lib:SplitStr(tbConfigRow.items, ",")
    assert(math.fmod(#tbRet, 2) == 0) -- 被2整除
    for i = 1, #tbRet, 2 do
        local tbDropItemInfo = {
            itemTemplateID = tonumber(tbRet[i]),
            count = tonumber(tbRet[i + 1]),
        }
        tbRewardDetail.itemInfos = tbRewardDetail.itemInfos or {}
        table.insert(tbRewardDetail.itemInfos, tbDropItemInfo)
    end

    return tbRewardDetail
end

更新完配置信息对应的内存数据后,还要更新与运行逻辑相关的内容。比如检查运营中的各个签到是否过期、检查等待开启运营的各个签到是否等够时间、更新用于分帧处理在线玩家签到信息的指针。

无论是在进入新的一天,还是配置内容更新,都希望将所有的在线玩家的签到信息都更新至当前时刻的状态。因此需要获取到所有在线玩家并一一处理。但是gameserver是分帧运行,如果在线玩家数量太多并且一口气处理所有玩家,就会导致一帧执行的时间特别长,进而导致网络数据处理和其他模块update工作滞后,严重的话可能引起套接字接受缓冲区堵满、网络延迟放大、带宽激增。避免这么严重的后果的最好方法就是分帧处理:首先保证有一个链表连接所有的在线玩家,保留一个指针,逐个遍历并处理,一帧处理几十个,保存指针,下一帧接着处理。其次保证玩家下线时能通知到该模块,比如一个尚未处理的在线玩家下线时,要检查该指针是不是指向它,如果是,将指针移动到下一个处理节点。

这里是获取在线玩家链表的最后一个节点,从后向前遍历。原因是从此以后进入游戏的玩家,在登录成功后就会有相应的检查和更新。如果从前向后遍历的话会重复处理,而且处理的个数有可能会随着大量玩家登录而增加。

-- 根据配置内容更新各个类型签到必要信息
function SignInRewardMgr:_OnUpdateNecessaryInfoByNewConfig()
    self:_UpdateExpiredSignInRewardInfo(self:GetNowUnixSec())
    self:_UpdateWaitStartSignInRewardInfo(self:GetNowUnixSec())
    self:_PrepareUpdateOnlinePlayerSignInRewardComponentOnNextDay()
end

-- 更新可能过期的签到信息
function SignInRewardMgr:_UpdateExpiredSignInRewardInfo(nCurrentUnixSec)
    local tbExpiredRewardInfos = nil
    for szSignInType, tbRewardInfo in pairs(self.tbValidSignInRewardInfoTypeMap) do
        if tbRewardInfo.nEndUnixSec <= nCurrentUnixSec then
            tbExpiredRewardInfos =  tbExpiredRewardInfos or {}
            tbExpiredRewardInfos[szSignInType] = tbRewardInfo
        end
    end
    for szSignInType, tbRewardInfo in pairs(tbExpiredRewardInfos or Lib:GetEmptyTable()) do
        tbRewardInfo.bValid = false
        tbRewardInfo.bWaitingStart = false
        self.tbValidSignInRewardInfoTypeMap[szSignInType] = nil
    end
end

-- 更新等待开始的签到信息
function SignInRewardMgr:_UpdateWaitStartSignInRewardInfo(nCurrentUnixSec)
    local tbStartedRewardInfos = nil
    for szSignInType, tbRewardInfo in pairs(self.tbWaitStartSignInRewardInfoTypeMap) do
        if tbRewardInfo.nStartUnixSec <= nCurrentUnixSec then
            tbStartedRewardInfos =  tbStartedRewardInfos or {}
            tbStartedRewardInfos[szSignInType] = tbRewardInfo
        end
    end
    for szSignInType, tbRewardInfo in pairs(tbStartedRewardInfos or Lib:GetEmptyTable()) do
        tbRewardInfo.bValid = true
        tbRewardInfo.bWaitingStart = false
        self.tbWaitStartSignInRewardInfoTypeMap[szSignInType] = nil
        self.tbValidSignInRewardInfoTypeMap[szSignInType] = tbRewardInfo
    end
end

-- 进入下一天时准备更新在线玩家的签到信息
function SignInRewardMgr:_PrepareUpdateOnlinePlayerSignInRewardComponentOnNextDay()
    -- 原值可能不为空,说明玩家的数据没处理完,但可能性极低,因此允许丢失
    self.tbNextDealingPlayerNode = nil
    local player = PlayerMgr:GetLatestPlayerInGamingList()
    if player then
        self.tbNextDealingPlayerNode = player:GetHostNode()
    end
end
  1. 管理类的update

update方法会每帧调用,但是除了处理在线玩家签到更新内容,其他的信息更新并不需要每帧都检查。所以根据当前业务情况,每秒检查一次足够了。每秒更新当前时间戳,并判断是否进入次日,如果是,执行进入次日的操作。

function SignInRewardMgr:Update(tick)
    self:_UpdateOnlinePlayerSignInRewardComponentPerFrame()
    if self.nNextTick > tick then
        return
    end
    self.nNextTick = tick + self.CHECK_NEXT_DAY_UPDATE_INTERVAL
    self:_SetNowUnixSec(KServerTime:UnixSec())
    if self:GetNowUnixSec() >= self:GetNextLocalDayUnixSec() then
        self:_OnEnterNextLocalDay(self:GetNowUnixSec())
    end
end


-- 每帧更新在线玩家的签到信息
function SignInRewardMgr:_UpdateOnlinePlayerSignInRewardComponentPerFrame()
    if not self.tbNextDealingPlayerNode then
        return
    end

    local nDealCount = 0
    local tbCurrentNode = nil
    local player = nil
    repeat
        tbCurrentNode = self.tbNextDealingPlayerNode
        self.tbNextDealingPlayerNode = tbCurrentNode:GetPrev()
        player = tbCurrentNode:GetHost()
        if not player then
            assert(false)
            break
        end

        -- 通过Player拿到对应组件,更新组件签到信息
        for _, tbComponent in pairs(player:GetAllSignInRewardComponents() or Lib:GetEmptyTable()) do
            tbComponent:CheckAndUpdate()
        end

        if nDealCount >= self.DEAL_ONLINE_PLAYER_COUNT_PER_FRAME then
            break
        end
    until(not self.tbNextDealingPlayerNode)
end

-- 步入新的一天回调
function SignInRewardMgr:_OnEnterNextLocalDay(nCurrentUnixSec)
    self:_UpdateCurrentLocalDay(nCurrentUnixSec)
    self:_OnUpdateNecessaryInfoByNewConfig()
end

在每帧处理在线玩家签到更新的方法中,如果更新的玩家个数超过规定的一帧处理上线或者所有玩家处理完毕,就不会再进入这个操作中。否则会对每个玩家的所有签到组件进行更新,具体更新细节后面再说。

管理类步入新的一天,要做的事无非就是前边介绍过的,更新当日时间信息、更新与运行逻辑相关的签到配置信息。

  1. 签到组件的创建

一种类型签到组件只要在DB有签到数据或在有效期的情况下才会创建。相应的创建方法有2个。

function SignInRewardMgr:NewSignInRewardComponentFromDB(player, dbData)
    if dbData then
        local tbRewardInfo = self.tbValidSignInRewardInfoTypeMap[dbData.signInType]
        if tbRewardInfo then 
            local tbComponent = self:_MakeSignInRewardComponent()
            tbComponent:Init(player, dbData.signInType, dbData)
            return tbComponent
        end
    end
    return nil
end

function SignInRewardMgr:NewSignInRewardComponent(player, szSignInType)
    local tbRewardInfo = self.tbValidSignInRewardInfoTypeMap[szSignInType]
    if tbRewardInfo then 
        local tbComponent = self:_MakeSignInRewardComponent()
        tbComponent:Init(player, szSignInType)
        return tbComponent
    end
    return nil
end

function SignInRewardMgr:DeleteSignInRewardComponent(tbComponent)
    if type(tbComponent) == "table" then
        local player = tbComponent:GetBelongPlayer()
        if player then
            if self.tbNextDealingPlayerNode == player:GetHostNode() then
                self.tbNextDealingPlayerNode = player:GetHostNode():GetPrev()
            end
            if tbComponent:GetSignType() == "SIGN_IN_TYPE_ROOKIE" then
                player:SetRookieSignInComponent(nil)
            end
        end
        tbComponent:UnInit()
        self:_FreeSignInRewardComponent(tbComponent)
        tbComponent = nil
    end
end

其中所有需要通过判断组件类型而选择执行的操作都属于无奈之举,如果开发时间更多一点的话,我会把不同类型的相同模板组件统一封装起来。

组件创建的行为比较简单,有DB数据就填充,没有就取默认值。

function SignInRewardComponent:Init(tbPlayer, szSignInType, dbData)
    assert(type(tbPlayer) == "table")
    assert(type(szSignInType) == "string")
    self:Reset()
    self.tbPlayer = tbPlayer
    self.szSignType = szSignInType
    self.tbAllowIndexSet = self.tbAllowIndexSet or {}
    self.tbAlreadyIndexSet = self.tbAlreadyIndexSet or {}
    self.tbMissedIndexSet = self.tbMissedIndexSet or {}
    self.tbIndexStateMap = self.tbIndexStateMap or {}
    if type(dbData) == "table" then
        self.nRewardPeriodCount = dbData.rewardPeriodCount or 0
        self.nLastSignInUnixSec = dbData.lastSignInUnixSec or 0
        self.nLastSignInLocalDay = dbData.lastSignInLocalDay or 0
        self.nLastSignInMaxIndex = dbData.lastSignInMaxIndex or 0
        for _, index in pairs(dbData.allowIndexArray or Lib:GetEmptyTable()) do
            self:_SetAllowIndex(index, true)
        end

        for _, index in pairs(dbData.alreadyIndexArray or Lib:GetEmptyTable()) do
            self:_SetAlreadyIndex(index, true)
        end

        for _, index in pairs(dbData.missedIndexArray or Lib:GetEmptyTable()) do
            self:_SetMissedIndex(index, true)
        end
    end
end

签到组件的准备根据玩家登录过程分为2个阶段。第一个阶段是玩家初始化,第二个阶段是玩家真正进入游戏。

玩家初始化时如果有签到DB数据,就会调用AddRookieSignInComponentByDB创建组件。即使DB的签到类型可能此时已经过期,在SignInRewardMgr:NewSignInRewardComponentFromDB也会检查出来并且不会继续创建组件。

function Player:AddRookieSignInComponentByDB(dbData)
    self:SetRookieSignInComponent(SignInRewardMgr:NewSignInRewardComponentFromDB(self, dbData))
end

function Player:SetRookieSignInComponent(tbComponent)
    self:PlayerInfo().rookieSignInComponent = tbComponent
end

如果第一个阶段已经成功创建了签到组件,第二个阶段就会更新组件的信息。反之如果没有创建签到组件,第二个阶段就会根据当前有效的签到类型,创建签到组件并填充组件信息。

-- TODO 代码有待优化
function SignInRewardMgr:OnPlayerEnterGame(player)
    local tbRookieSignInRewardComponent = nil
    for szSignInType, tbRewardInfo in pairs(self.tbValidSignInRewardInfoTypeMap) do
        if szSignInType == "SIGN_IN_TYPE_ROOKIE" then
            tbRookieSignInRewardComponent = player:GetRookieSignInRewardComponent()
            if not tbRookieSignInRewardComponent then
                player:SetRookieSignInComponent(self:NewSignInRewardComponent(player, szSignInType))
            end
        end
    end
    -- 更新新手签到
    tbRookieSignInRewardComponent = player:GetRookieSignInRewardComponent()
    if tbRookieSignInRewardComponent then
        tbRookieSignInRewardComponent:OnPlayerEnterGame()
        tbRookieSignInRewardComponent = nil
    end
end

-- TODO 代码有待优化
function SignInRewardMgr:OnPlayerReconnect(player)
    local tbRookieSignInRewardComponent = nil
    for szSignInType, tbRewardInfo in pairs(self.tbValidSignInRewardInfoTypeMap) do
        if szSignInType == "SIGN_IN_TYPE_ROOKIE" then
            tbRookieSignInRewardComponent = player:GetRookieSignInRewardComponent()
            if not tbRookieSignInRewardComponent then
                player:SetRookieSignInComponent(self:NewSignInRewardComponent(player, szSignInType))
            end
        end
    end
    -- 更新新手签到
    tbRookieSignInRewardComponent = player:GetRookieSignInRewardComponent()
    if tbRookieSignInRewardComponent then
        tbRookieSignInRewardComponent:OnPlayerReconnect()
        tbRookieSignInRewardComponent = nil
    end
end

玩家进入游戏和玩家重连游戏的时候都要做相应的处理,因为玩家断线时已经从在线玩家的链表中移除了,这个过程中如果签到内容有更新并遍历在线玩家链表的话,是不会被处理到的。再重连后,必须做一次检查。

-- 用于进入游戏
function SignInRewardComponent:OnPlayerEnterGame()
    self:CheckAndUpdate()
end

-- 用于重连
function SignInRewardComponent:OnPlayerReconnect()
    self:CheckAndUpdate()
end

检查更新的详细操作后面再说。

  1. 签到组件的更新逻辑

组件针对自己的检查:
判断当前是否有效,若有效则检查更新签到周期,更新当日的签到信息。否则,销毁自己。

-- 检查是否需要更新自己的周期以及天数索引,或者删除自己
function SignInRewardComponent:CheckAndUpdate()
    if SignInRewardMgr:IsValidSignInType(self:GetSignType()) then
        self.bReady = true
        self:_CheckCurrentPeriodCount()
        self:_UpdateTodayRewardInfo()
    else
        SignInRewardMgr:DeleteSignInRewardComponent(self)
    end
end

-- 检查当日奖励周期
function SignInRewardComponent:_CheckCurrentPeriodCount()
    local nRewardPeriodCount = SignInRewardMgr:GetCurrentRewardPeriodCount(self:GetSignType())
    assert(nRewardPeriodCount > 0)
    if self:GetRewardPeriodCount() == nRewardPeriodCount then
        return
    end

    if self:GetRewardPeriodCount() > nRewardPeriodCount then
        LogErrWithFields({playerID = self:GetBelongPlayer():GetID(), playerRewardPeriodCount = self:GetRewardPeriodCount(), currentRewardPeriodCount = nRewardPeriodCount}, "player reward period is more than current !")
    end

    self:_ClearPeriodInfo()
    self.nRewardPeriodCount = nRewardPeriodCount
end

-- 更新当日奖励信息
function SignInRewardComponent:_UpdateTodayRewardInfo()
    assert(self:GetRewardPeriodCount() > 0)
    local nToday = SignInRewardMgr:GetToday()
    local nNowUnixSec = SignInRewardMgr:GetNowUnixSec()
    local nTodayRewardIndex = 0
    -- self:DebugShowInfo()
    -- 该周期首次进入游戏
    if self:GetLastSignInLocalDay() == 0 then
        nTodayRewardIndex = 1
    else
        local nMissedDay = nToday - self:GetLastSignInLocalDay() - 1    -- 不能包含当日
        if nMissedDay > 0 then
            -- 有遗漏签到
            local nMaxRewardIndex = SignInRewardMgr:GetMaxRewardIndexBySignType(self:GetSignType())
            for i = 1, nMissedDay do
                local nMissedIndex = self:GetLastSignInMaxIndex() + i
                if nMissedIndex > nMaxRewardIndex then
                    break
                end
                self:AddMissedIndex(nMissedIndex)
                nTodayRewardIndex = nMissedIndex + 1
            end
            -- 避免超过最大签到日
            if nTodayRewardIndex > nMaxRewardIndex then
                nTodayRewardIndex = 0
            end
        elseif nMissedDay == 0 then
            -- 当日为上次签到的次日
            nTodayRewardIndex = self:GetLastSignInMaxIndex() + 1
            local nMaxRewardIndex = SignInRewardMgr:GetMaxRewardIndexBySignType(self:GetSignType())
            -- 避免超过最大签到日
            if nTodayRewardIndex > nMaxRewardIndex then
                nTodayRewardIndex = 0
            end
        elseif nMissedDay == -1 then
            -- 当日已经签到过
            -- do nothing
        else
            -- 检测到时间回拨过
            LogErrWithFields({playerID = self:GetBelongPlayer():GetID(), playerRewardPeriodCount = self:GetRewardPeriodCount(), lastSignInLocalDay = self:GetLastSignInLocalDay()}, "player reward time has been turn back !")
            nTodayRewardIndex = self:GetLastSignInMaxIndex() + (nMissedDay + 1)
            if nTodayRewardIndex <= 0 then
                -- 时间回拨到首次签到以前
                -- do nothing
            end
        end
    end

    if nTodayRewardIndex > 0 then
        self:AddAllowIndex(nTodayRewardIndex)
    end
    self:_SetLastSignInInfo(nNowUnixSec, nToday, self:GetLastSignInMaxIndex())
    
    -- self:DebugShowInfo()
end

更新当日的签到信息的大概步骤:

  • 上次签到日如果是0说明是首次签到,更新上次签到日为当天,添加当天到允许领取奖励的天数。
  • 如果当日是上次签到的次日,取上次签到的下一天数,注意不能超过该类型最大签到日。
  • 如果当日就是上次签到日,说明已经签到过,什么也不做。
  • 如果当日超过上次签到日大于1天,说明中间有遗漏的天数,把这些添加到遗漏的集合。同时将当日添加到允许领取奖励的天数。还要注意不能超过该类型最大签到日。
  • 如果当日提前于上次签到日,说明时间回拨过,打个日志,只要更新上次签到日就行。

其中,涉及到的方法如下,都带有必要的检查。类似添加允许领取的天数的话,那么天数肯定是正数并且不能在已领取的集合中,其他的不一一细说了:

-- 添加允许领取的天数索引,会检查索引是否合理
function SignInRewardComponent:AddAllowIndex(nIndex)
    local result = false
    if not self:IsReady() then
        goto EXIT0
    end

    if nIndex <= 0 or self.tbAlreadyIndexSet[nIndex] then
        goto EXIT0
    end

    self:_SetMissedIndex(nIndex, false)
    self:_SetAllowIndex(nIndex, true)
    if self:GetLastSignInMaxIndex() < nIndex then
        self:_SetLastSignInInfo(self:GetLastSignInUnixSec(), self:GetLastSignInLocalDay(), nIndex)
    end

    result = true
    ::EXIT0::
    return result
end

-- 添加已经领取的天数索引,会检查索引是否合理
function SignInRewardComponent:AddAlreadyIndex(nIndex)
    local result = false
    if not self:IsReady() then
        goto EXIT0
    end

    if nIndex <= 0 then
        goto EXIT0
    end

    if not self.tbAllowIndexSet[nIndex] and not self.tbMissedIndexSet[nIndex] then
        goto EXIT0
    end

    self:_SetAllowIndex(nIndex, false)
    self:_SetMissedIndex(nIndex, false)
    self:_SetAlreadyIndex(nIndex, true)

    result = true
    ::EXIT0::
    return result
end

-- 添加错过领取的天数索引,会检查索引是否合理
function SignInRewardComponent:AddMissedIndex(nIndex)
    local result = false
    if not self:IsReady() then
        goto EXIT0
    end

    if nIndex <= 0 or self.tbAllowIndexSet[nIndex] or self.tbAlreadyIndexSet[nIndex] then
        goto EXIT0
    end

    self:_SetMissedIndex(nIndex, true)

    result = true
    ::EXIT0::
    return result
end


function SignInRewardComponent:_SetAllowIndex(nIndex, bNotReset)
    assert(type(nIndex) == "number")
    if bNotReset then
        self.tbAllowIndexSet[nIndex] = true
        self.tbIndexStateMap[nIndex] = SignInRewardMgr.APPLY_SIGN_IN_ALLOW
    else
        self.tbAllowIndexSet[nIndex] = nil
        self.tbIndexStateMap[nIndex] = nil
    end
end

function SignInRewardComponent:_SetAlreadyIndex(nIndex, bNotReset)
    assert(type(nIndex) == "number")
    if bNotReset then
        self.tbAlreadyIndexSet[nIndex] = true
        self.tbIndexStateMap[nIndex] = SignInRewardMgr.APPLY_SIGN_IN_ALREADY
    else
        self.tbAlreadyIndexSet[nIndex] = nil
        self.tbIndexStateMap[nIndex] = nil
    end
end

function SignInRewardComponent:_SetMissedIndex(nIndex, bNotReset)
    assert(type(nIndex) == "number")
    if bNotReset then
        self.tbMissedIndexSet[nIndex] = true
        self.tbIndexStateMap[nIndex] = SignInRewardMgr.APPLY_SIGN_IN_MISSED
    else
        self.tbMissedIndexSet[nIndex] = nil
        self.tbIndexStateMap[nIndex] = nil
    end
end

  1. 客户端请求处理

目前只接受2种请求,获取签到状态和领取奖励。涉及到的很多判断条件就略过了,这里主要说下领取奖励功能逻辑:

  • 将请求的索引天数添加到已领取。
  • 得到奖励道具并添加到玩家背包。
    这里多做了一步错误处理是,如果已经更改了索引天数的状态,但是添加道具时失败。应将状态回退。
-- 处理玩家领取签到奖励
function SignInRewardMgr:_HandlePlayerApplySignInReward(tbComponent, tbRewardInfo, nIndex)
    assert(type(tbComponent) == "table")
    assert(type(tbRewardInfo) == "table")
    assert(type(nIndex) == "number")
    -- 将该天数索引状态改为已领取
    if not tbComponent:AddAlreadyIndex(nIndex) then
        return false
    end

    local player = tbComponent:GetBelongPlayer()
    local tbRewardDetail = tbRewardInfo.tbRewardDetailIndexMap[nIndex]
    if player and tbRewardDetail and tbRewardDetail.itemInfos then
        local tbAddItemInfoArray = nil
        for _, tbDropItemInfo in pairs(tbRewardDetail.itemInfos) do
            tbAddItemInfoArray = tbAddItemInfoArray or {}
            table.insert(tbAddItemInfoArray, {
                templateID = tbDropItemInfo.itemTemplateID,
                count = tbDropItemInfo.count,
            })
        end
        -- 判断背包是否有剩余空间,注意多个道具要一起判断,背包模块提供多个物品添加接口,且具备原子性
        if tbAddItemInfoArray and not player:GetBags():AddMultiItem(tbAddItemInfoArray) then
            assert(tbComponent:RemoveAlreadyIndex(nIndex, false, false), "can not lose !")
            return false
        end
    end
    return true
end

-- @bClear 是否完全清除该索引的存在
-- @bToMissed 不完全清除该索引的情况下生效,true转移到miss集合,false转移到allow集合
function SignInRewardComponent:RemoveAlreadyIndex(nIndex, bClear, bToMissed)
    local result = false
    if not self:IsReady() then
        goto EXIT0
    end

    if nIndex <= 0 then
        goto EXIT0
    end

    if not self.tbAlreadyIndexSet[nIndex] then
        goto EXIT0
    end

    self:_SetAlreadyIndex(nIndex, false)
    if not bClear then
        if bToMissed then
            self:_SetMissedIndex(nIndex, true)
        else
            self:_SetAllowIndex(nIndex, true)
        end
    end

    result = true
    ::EXIT0::
    return result
end

四.不足与展望

  1. 有些地方要根据签到类型做对应的操作,不利于拓展。可以仿照unity实现个类似的组件系统,不同组件对象有相似部分可以打同一个tag,方便批量处理。
  2. 签到类型无效的情况下,并没有机制去清除DB内的组件数据。这本身并不困难,但是如果可以实现一个组件系统的话,组件数据读写的方式和结构可能都会有些变化,现在做太多可能意义不大,而且策划需求说变就变也不是没可能。
  3. 时间检查的机制可以抽出来做一个公共的中间层,不同功能模块可以共用。否则每个人有涉及到时间的开发任务都要自己再做一遍。
  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值