StoreMgr 仓库管理器

定位:角色独立仓库(不是账号共享仓库),无负重限制,只有格子上限 ;容器抽象和Bag对齐,复用同一套容器接口;支持仓库格子扩容解锁、物品存入取出、仓库整理、仓库密码锁;和之前 BagStoreTransfer 互通。 依赖:ConfigMgr、LogMgr、EventMgr、DbMgr、BagMgr、PlayerMgr

1. Proto协议(新增仓库相关消息)

ini 复制代码
// 打开仓库
message C2S_OpenStore {}
message S2C_StoreInfo {
    int32 max_grid = 1;
    repeated ItemData item_list = 2;
}

// 仓库扩容解锁
message C2S_StoreExpand {
    int32 unlock_type = 1; // 2道具解锁 3元宝解锁(仓库一般不用等级解锁)
}
message S2C_StoreExpandRet {
    int32 ret_code = 1;
    string msg = 2;
    int32 new_max_grid = 3;
}

// 仓库整理
message C2S_StoreSort {}
message S2C_StoreSortRet {
    int32 ret_code = 1;
    string msg = 2;
}

// 物品通用结构(和背包一致)
message ItemData {
    uint64 item_uid = 1;
    int32 item_id = 2;
    int32 num = 3;
    int32 grid_idx = 4;
    bool is_lock = 5;
    int64 expire_time = 6;
    repeated int32 attr_list = 7;
}

2. store_const.lua 常量

ini 复制代码
local const = {
    RET_OK = 0,
    RET_CFG_NOT_FOUND = 3001,
    RET_ALREADY_MAX = 3002,
    RET_ITEM_NOT_EXIST = 3003,
    RET_GRID_FULL = 3004,
    RET_ITEM_LOCK = 3005,
    RET_CANNOT_STORE = 3006,
    RET_RESOURCE_NOT_ENOUGH = 3007,
    RET_PARAM_ERR = 3008,

    UNLOCK_TYPE_ITEM = 2,
    UNLOCK_TYPE_COIN = 3,
}
return const

3. 数据库表 store_item

sql 复制代码
CREATE TABLE `store_item` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `pid` BIGINT NOT NULL COMMENT '角色pid',
  `item_uid` BIGINT NOT NULL UNIQUE,
  `item_id` INT NOT NULL,
  `num` INT NOT NULL DEFAULT 1,
  `grid_idx` INT NOT NULL,
  `is_lock` TINYINT DEFAULT 0,
  `expire_time` BIGINT DEFAULT 0,
  `attr_data` TEXT COMMENT '序列化附加属性',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_pid (pid)
) ENGINE=InnoDB;

-- 仓库解锁信息,存在player_ext或者独立store_ext
CREATE TABLE `store_ext` (
  `pid` BIGINT PRIMARY KEY,
  `max_grid` INT DEFAULT 24 COMMENT '当前已解锁仓库总格子',
  `last_expand_id` INT DEFAULT 0 COMMENT '扩容配表id',
  `store_pwd` VARCHAR(32) DEFAULT '' COMMENT '仓库密码',
  `dirty` TINYINT DEFAULT 0
);

4. 配表 store_expand.csv(仓库扩容配置)

id grid_total unlock_type need_item_id need_item_num need_gold remark
1 30 3 0 0 1000 元宝解锁到30格
2 36 2 10011 1 0 仓库扩容券解锁到36格
全局上限:global_store_max = 48

5. StoreMgr 完整代码 store_mgr.lua

ini 复制代码
local const = require("store_const")
local ConfigMgr = require("config_mgr")
local DbMgr = require("db_mgr")
local LogMgr = require("log_mgr")
local EventMgr = require("event_mgr")
local BagMgr = require("bag_mgr")
local CurrencyMgr = require("currency_mgr")

local M = {}
local player_store_cache = {} -- pid -> store_data

-- 仓库容器对象
local function NewStoreContainer(pid, max_grid)
    local container = {
        pid = pid,
        max_grid = max_grid,
        item_map = {},      -- uid -> item
        grid_map = {},      -- grid_idx -> uid
    }

    -- 根据uid获取物品
    function container:GetItemByUid(uid)
        return self.item_map[uid]
    end

    -- 根据格子索引获取物品
    function container:GetItemByGrid(grid_idx)
        local uid = self.grid_map[grid_idx]
        if not uid then return nil end
        return self.item_map[uid]
    end

    -- 检查是否可以放入(格子校验,仓库不计负重)
    function container:CheckCanAddItem(item_cfg, add_num)
        -- 优先找同物品堆叠格子
        if item_cfg.stack_max > 1 then
            for _, item in pairs(self.item_map) do
                if item.item_id == item_cfg.item_id and (item.num + add_num) <= item_cfg.stack_max then
                    return true
                end
            end
        end
        -- 找空格子
        for i = 1, self.max_grid do
            if not self.grid_map[i] then
                return true
            end
        end
        return false
    end

    -- 添加物品,返回 ok, new_uid
    function container:AddItem(item_id, num, src_item)
        local cfg = ConfigMgr.GetItemCfg(item_id)
        if not cfg then return false end
        -- 尝试堆叠
        if cfg.stack_max > 1 then
            for _, item in pairs(self.item_map) do
                if item.item_id == item_id and (item.num + num) <= cfg.stack_max then
                    item.num = item.num + num
                    return true, item.item_uid
                end
            end
        end
        -- 找空位
        local empty_grid = nil
        for i = 1, self.max_grid do
            if not self.grid_map[i] then
                empty_grid = i
                break
            end
        end
        if not empty_grid then
            return false
        end
        -- 继承源物品属性(限时、强化、鉴定等)
        local new_item = {
            item_uid = M.GenItemUid(),
            item_id = item_id,
            num = num,
            grid_idx = empty_grid,
            is_lock = src_item and src_item.is_lock or false,
            expire_time = src_item and src_item.expire_time or 0,
            attr_data = src_item and src_item.attr_data or "",
        }
        self.item_map[new_item.item_uid] = new_item
        self.grid_map[empty_grid] = new_item.item_uid
        return true, new_item.item_uid
    end

    -- 扣物品数量
    function container:SubItemNum(item_uid, sub_num)
        local item = self.item_map[item_uid]
        if not item or item.num < sub_num then
            return false, item and item.num or 0
        end
        item.num = item.num - sub_num
        if item.num <= 0 then
            self.item_map[item_uid] = nil
            self.grid_map[item.grid_idx] = nil
        end
        return true, item.num
    end

    -- 增加数量(回滚专用)
    function container:AddItemNum(item_uid, add_num)
        local item = self.item_map[item_uid]
        if not item then return false end
        item.num = item.num + add_num
        return true
    end

    -- 同步仓库给客户端
    function container:SyncToClient(player)
        local list = {}
        for _, it in pairs(self.item_map) do
            table.insert(list, {
                item_uid = it.item_uid,
                item_id = it.item_id,
                num = it.num,
                grid_idx = it.grid_idx,
                is_lock = it.is_lock,
                expire_time = it.expire_time,
            })
        end
        player:SendMsg("S2C_StoreInfo", {
            max_grid = self.max_grid,
            item_list = list
        })
    end

    -- 整理仓库:同id堆叠、格子靠前排序
    function container:Sort()
        local item_list = {}
        for _, it in pairs(self.item_map) do
            table.insert(item_list, it)
        end
        -- 清空grid映射
        self.grid_map = {}
        self.item_map = {}
        -- 合并堆叠
        local group = {}
        for _, it in ipairs(item_list) do
            local cfg = ConfigMgr.GetItemCfg(it.item_id)
            if cfg.stack_max > 1 then
                if not group[it.item_id] then
                    group[it.item_id] = {num=0, data=it}
                end
                group[it.item_id].num = group[it.item_id].num + it.num
            else
                table.insert(group, it)
            end
        end
        local new_list = {}
        for _,v in pairs(group) do
            if v.data then
                table.insert(new_list, v)
            end
        end
        -- 顺序分配格子
        local idx = 1
        for _, it in ipairs(new_list) do
            if idx > self.max_grid then break end
            it.grid_idx = idx
            self.item_map[it.item_uid] = it
            self.grid_map[idx] = it.item_uid
            idx = idx + 1
        end
        return true
    end

    return container
end

-- 加载玩家仓库(登录加载,懒加载)
function M.LoadPlayerStore(pid)
    if player_store_cache[pid] then
        return player_store_cache[pid]
    end
    -- 加载仓库扩展信息
    local ext = DbMgr.QueryOne("SELECT * FROM store_ext WHERE pid=?", pid)
    if not ext then
        ext = {
            pid = pid,
            max_grid = 24,
            last_expand_id = 0,
            store_pwd = "",
        }
        DbMgr.Insert("store_ext", ext)
    end
    -- 加载仓库物品
    local rows = DbMgr.Query("SELECT * FROM store_item WHERE pid=?", pid)
    local container = NewStoreContainer(pid, ext.max_grid)
    for _,row in ipairs(rows) do
        local item = {
            item_uid = row.item_uid,
            item_id = row.item_id,
            num = row.num,
            grid_idx = row.grid_idx,
            is_lock = row.is_lock == 1,
            expire_time = row.expire_time,
            attr_data = row.attr_data,
        }
        container.item_map[item.item_uid] = item
        container.grid_map[item.grid_idx] = item.item_uid
    end
    player_store_cache[pid] = {
        container = container,
        ext = ext,
        dirty = false
    }
    return player_store_cache[pid]
end

-- 获取仓库容器(对外给BagStoreTransfer调用)
function M.GetStoreContainer(player)
    local store_data = M.LoadPlayerStore(player.pid)
    return store_data.container
end

-- 仓库扩容逻辑
function M.StoreExpand(player, unlock_type)
    local pid = player.pid
    local store_data = M.LoadPlayerStore(pid)
    local ext = store_data.ext
    local all_cfg = ConfigMgr:GetAllStoreExpandCfg()
    local next_cfg = all_cfg[ext.last_expand_id + 1]
    local global_max = ConfigMgr:GetGlobalCfg("global_store_max") or 48
    if not next_cfg or next_cfg.grid_total > global_max then
        return const.RET_ALREADY_MAX, "仓库已达到最大格子上限", ext.max_grid
    end
    if next_cfg.unlock_type ~= unlock_type then
        return const.RET_PARAM_ERR, "解锁类型不匹配", ext.max_grid
    end
    -- 资源校验
    if unlock_type == const.UNLOCK_TYPE_ITEM then
        local have = BagMgr.GetItemCount(player, next_cfg.need_item_id)
        if have < next_cfg.need_item_num then
            return const.RET_RESOURCE_NOT_ENOUGH, "扩容道具不足", ext.max_grid
        end
        local ok = BagMgr.ConsumeItem(player, next_cfg.need_item_id, next_cfg.need_item_num, "store_expand")
        if not ok then
            return const.RET_RESOURCE_NOT_ENOUGH, "扣除道具失败", ext.max_grid
        end
    elseif unlock_type == const.UNLOCK_TYPE_COIN then
        if not CurrencyMgr.CheckEnough(player, "gold", next_cfg.need_gold) then
            return const.RET_RESOURCE_NOT_ENOUGH, "元宝不足", ext.max_grid
        end
        local ok = CurrencyMgr.CostCurrency(player, "gold", next_cfg.need_gold, "store_expand")
        if not ok then
            return const.RET_RESOURCE_NOT_ENOUGH, "扣除元宝失败", ext.max_grid
        end
    end
    -- 更新数据
    local old_max = ext.max_grid
    ext.last_expand_id = next_cfg.id
    ext.max_grid = next_cfg.grid_total
    store_data.container.max_grid = next_cfg.grid_total
    store_data.dirty = true
    -- 日志+事件
    LogMgr:WriteFlowLog(pid, "store_expand", {
        expand_id = next_cfg.id,
        unlock_type = unlock_type,
        old_max = old_max,
        new_max = ext.max_grid
    })
    EventMgr:Fire("EVT_STORE_EXPAND", player, {old_max=old_max, new_max=ext.max_grid})
    store_data.container:SyncToClient(player)
    return const.RET_OK, "仓库扩容成功", ext.max_grid
end

-- 仓库整理
function M.StoreSort(player)
    local store_data = M.LoadPlayerStore(player.pid)
    store_data.container:Sort()
    store_data.dirty = true
    store_data.container:SyncToClient(player)
    LogMgr:WriteFlowLog(player.pid, "store_sort", {})
    return const.RET_OK, "整理成功"
end

-- 定时/下线落地保存
function M.SavePlayerStore(pid)
    local store_data = player_store_cache[pid]
    if not store_data or not store_data.dirty then return end
    local ext = store_data.ext
    DbMgr.Update("store_ext", {max_grid=ext.max_grid, last_expand_id=ext.last_expand_id}, "pid=?", pid)
    -- 物品增量落盘(简易版,生产可以改为脏物品单独更新)
    DbMgr.Delete("store_item", "pid=?", pid)
    for _,it in pairs(store_data.container.item_map) do
        DbMgr.Insert("store_item", {
            pid = pid,
            item_uid = it.item_uid,
            item_id = it.item_id,
            num = it.num,
            grid_idx = it.grid_idx,
            is_lock = it.is_lock and 1 or 0,
            expire_time = it.expire_time,
            attr_data = it.attr_data
        })
    end
    store_data.dirty = false
end

-- 玩家下线清理缓存+保存
function M.OnPlayerLogout(pid)
    M.SavePlayerStore(pid)
    player_store_cache[pid] = nil
end

-- 全局uid生成,和背包共用一套
function M.GenItemUid()
    -- 这里替换成你全局唯一id生成器
    return os.time() * 100000 + math.random(1,99999)
end

return M

6. 消息注册 handler

lua 复制代码
local StoreMgr = require("store_mgr")
local const = require("store_const")

-- 打开仓库
function OnC2S_OpenStore(player)
    local store_data = StoreMgr.LoadPlayerStore(player.pid)
    store_data.container:SyncToClient(player)
end

-- 仓库扩容
function OnC2S_StoreExpand(player, msg)
    local ret, tips, new_grid = StoreMgr.StoreExpand(player, msg.unlock_type)
    player:SendMsg("S2C_StoreExpandRet", {
        ret_code = ret,
        msg = tips,
        new_max_grid = new_grid
    })
end

-- 仓库整理
function OnC2S_StoreSort(player)
    local ret, tips = StoreMgr.StoreSort(player)
    player:SendMsg("S2C_StoreSortRet", {ret_code=ret, msg=tips})
end

RegisterProtoHandler("C2S_OpenStore", OnC2S_OpenStore)
RegisterProtoHandler("C2S_StoreExpand", OnC2S_StoreExpand)
RegisterProtoHandler("C2S_StoreSort", OnC2S_StoreSort)

-- 订阅玩家下线事件,自动保存仓库
EventMgr:Subscribe("EVT_PLAYER_LOGOUT", function(player)
    StoreMgr.OnPlayerLogout(player.pid)
end)

7. 对接之前【背包↔仓库转移】

BagStoreTransfer 里的: StoreMgr.GetStoreContainer(player) 就是这里导出的容器,接口完全对齐,不需要改转移逻辑! 容器方法:GetItemByUid / SubItemNum / AddItem / CheckCanAddItem / SyncToClient 和Bag容器一模一样。

8. 业务规则 & 坑点

  1. 仓库无负重,只卡格子(传奇经典设计)
  2. 物品配置 cannot_store=1 禁止存入仓库,在转移接口校验
  3. 锁定物品禁止双向转移
  4. 限时物品可以存入仓库,时间继续走,不会暂停(主流方案;如需暂停过期时间要单独加标记)
  5. 扩容顺序锁死,必须按配表id依次解锁,不能跳
  6. 懒加载:打开仓库NPC的时候才加载仓库数据,不登录全量加载,节省内存
  7. 脏标记dirty,下线/定时统一刷库,避免频繁DB
  8. 仓库密码锁:上面ext预留了store_pwd字段,你可以再加【验证密码才能打开仓库】逻辑

9. 可选扩展(按需加)

  • 账号共享仓库(跨角色共用一套仓库,owner改成account_id)
  • 行会仓库(guild_store)
  • 仓库取物二次密码校验
  • GM指令:清空仓库、直接设置仓库格子
  • 仓库物品过期扫描(和背包过期扫描逻辑一致)
相关推荐
嘿嘿嘿91544 分钟前
从跟课到脱稿重写:一个文字格斗游戏里踩过的 9 个坑
后端
旋律41444 分钟前
List、Set、Map:三个装数据的容器,到底啥区别?怎么遍历?
java·后端
兔子零10241 小时前
架构师最危险的能力:用正确的技术,做出错误的架构决策
前端·javascript·后端
ytadpole1 小时前
模板下载性能优化记录
后端·性能优化
执团搓锐1 小时前
个人知识库助手
后端
楼南1 小时前
数字化体验:从下单到收获的全过程
后端
武子康1 小时前
Coding Agent 的知识也会漂移:用固定快照检查 Vercel Plugin 升级
人工智能·后端·agent
货拉拉技术1 小时前
JDK 21 规模化落地实践
后端
isfox1 小时前
MapTask 工作机制:从读数据到写磁盘,一个 Map 任务的完整一生
后端