定位:角色独立仓库(不是账号共享仓库),无负重限制,只有格子上限 ;容器抽象和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. 业务规则 & 坑点
- 仓库无负重,只卡格子(传奇经典设计)
- 物品配置
cannot_store=1禁止存入仓库,在转移接口校验 - 锁定物品禁止双向转移
- 限时物品可以存入仓库,时间继续走,不会暂停(主流方案;如需暂停过期时间要单独加标记)
- 扩容顺序锁死,必须按配表id依次解锁,不能跳
- 懒加载:打开仓库NPC的时候才加载仓库数据,不登录全量加载,节省内存
- 脏标记dirty,下线/定时统一刷库,避免频繁DB
- 仓库密码锁:上面ext预留了store_pwd字段,你可以再加【验证密码才能打开仓库】逻辑
9. 可选扩展(按需加)
- 账号共享仓库(跨角色共用一套仓库,owner改成account_id)
- 行会仓库(guild_store)
- 仓库取物二次密码校验
- GM指令:清空仓库、直接设置仓库格子
- 仓库物品过期扫描(和背包过期扫描逻辑一致)