下载 App
槽位变更只走原子方法
经验
- 下标 = 视觉格子,空槽是合法状态。定长 9 槽,inventory[i]=nil 即第 i 格空着,中间允许空。任何"把物品往前挪/压缩/重排"的操作都会让"你看到的格子"和"数组真实下标"脱节 → 物品消失。渲染直映 inventory[i],数据层绝不重排。
- 稀疏表禁用 #。带 nil 洞的表 #inv 是未定义的(可能=0 直接读成空包,或=洞前边界)。所有遍历上界一律 PlayerComponent.GetInventorySlots()。
- 槽位变更只走原子方法,UI 不直接 poke 数组;拖拽落点用「命中槽=槽位操作矩阵 / 未命中槽=丢脚底」分流,命中靠全局像素坐标反查(底部物品栏无 zoom,与 event.x/y 同空间,不需要二次加减 GetVisualRect 偏移)。


关键代码
一、数据层:非压缩规整(消失 bug 的核心修复点)
scripts/系统/主角组件服务.lua — EnsureSlots(每个背包方法入口都调它,所以它"压缩"=全链路压缩)
lualocal INVENTORY_SLOTS = 9 -- 定长槽位数 --- 规整为定长数组(非压缩):保留每个槽位的位置与 nil,仅截断超长、补 nil。 --- 绝不前移——下标即视觉格子,空槽必须保留。禁止用 #inv。 local function EnsureSlots(player) local inv = player.inventory if type(inv) ~= "table" then inv = {} end local slots = {} for s = 1, INVENTORY_SLOTS do slots[s] = inv[s] -- ★ 原样复制,nil 也复制,位置不动 end player.inventory = slots return slots end
旧版是 for i=1,#inv do if inv[i] then valid[#valid+1]=inv[i] end end —— 既用了不可靠的 #inv,又把非 nil 项前移,正是元凶。
scripts/系统/主角组件服务.lua — SanitizeInventory(启动调一次,原地清坏条目,不重排)
luafunction PlayerComponent.SanitizeInventory(player) if not player then return end local inv = player.inventory if type(inv) ~= "table" then inv = {} end for s = 1, INVENTORY_SLOTS do -- ★ 固定 9 次,不用 #inv local item = inv[s] if item then local def = ItemRegistry.GetDef(item.id) if not def or not def.image or def.image == "" then inv[s] = nil -- ★ 原地置 nil,好条目位置不变 end end end local slots = {} for s = 1, INVENTORY_SLOTS do slots[s] = inv[s] end player.inventory = slots end
二、槽位操作原子方法(落点矩阵的算子)
lua-- 移动到空槽(目标非空返回 false,调用方改走 Swap) function PlayerComponent.MoveInventoryToSlot(player, fromSlot, toSlot) local inv = EnsureSlots(player) if fromSlot == toSlot then return true end local item = inv[fromSlot] if not item then return false end if inv[toSlot] ~= nil then return false end inv[fromSlot] = nil inv[toSlot] = item return true end -- 两槽互换 function PlayerComponent.SwapInventorySlots(player, a, b) local inv = EnsureSlots(player) if a == b then return true end inv[a], inv[b] = inv[b], inv[a] return true end -- 手持 ↔ 指定槽 互换(原子):手持物入槽;槽有可装备物则上手,否则回退/清手持 function PlayerComponent.SwapHeldWithSlot(player, slot) local inv = EnsureSlots(player) local heldId, heldObj = player.heldObjectType, player.heldObjectId if not heldId then return false end local slotItem = inv[slot] local def = ItemRegistry.GetDef(heldId) inv[slot] = { id = heldId, name = def and def.name or heldId, category = def and def.category or "其他", contrabandLevel = def and def.contrabandLevel or 0, sourceObjectId = heldObj } if slotItem then local sdef = ItemRegistry.GetDef(slotItem.id) if not sdef or not sdef.equipSlot then inv[slot] = slotItem; return false end player.heldObjectId = slotItem.sourceObjectId or ("inv:" .. slotItem.id) player.heldObjectType = slotItem.id else player.heldObjectId = nil; player.heldObjectType = nil end return true end
配套:AddToInventory 返回实际槽位(满=nil)、RemoveFromInventory/EquipFromInventory 置 nil 不前移——与铁律 1 一致。
三、UI 层:渲染直映 + 命中反查 + 落点矩阵
scripts/界面组件/底部物品栏.lua — 槽身份注册 + 命中反查
lualocal slotRefs_ = {} -- 每次 Hotbar.Create 重建:{kind,idx,widget} local function HitTestSlot(gx, gy) for i = 1, #slotRefs_ do local ref = slotRefs_[i] local vr = UI.GetVisualRect(ref.widget) -- 与 event.x/y 同坐标空间 if vr and gx >= vr.x and gx <= vr.x + vr.w and gy >= vr.y and gy <= vr.y + vr.h then return ref end end return nil end -- 渲染:直映 inventory[1..9],nil 画空槽,并把每个槽 widget 登记身份 for i = 1, INVENTORY_SLOTS do local item = inventory[i] local def = item and ItemRegistry.GetDef(item.id) or nil if def and (not def.image or def.image == "") then def = nil end local slotWidget = CreateSlot(def, false, false, def and { fromInventory = i } or nil, onBagClick, context) slotRefs_[#slotRefs_ + 1] = { kind = "bag", idx = i, widget = slotWidget } bagChildren[#bagChildren + 1] = slotWidget end local handSlot = CreateSlot(heldDef, true, heldDef ~= nil, "held", onHandClick, context) slotRefs_[#slotRefs_ + 1] = { kind = "hand", widget = handSlot } -- 手持也算拖放目标
scripts/界面组件/底部物品栏.lua — 拖拽中四态颜色(pointermove)
lualocal color = GHOST_BORDER_VALID -- 默认绿=丢脚底 local slotHit = HitTestSlot(gx, gy) if slotHit then local src = dragState_.source local sameBag = slotHit.kind == "bag" and type(src)=="table" and src.fromInventory == slotHit.idx local sameHand = slotHit.kind == "hand" and src == "held" color = (sameBag or sameHand) and GHOST_BORDER_INVALID or GHOST_BORDER_SLOT -- 红/蓝 end -- 金色 GHOST_BORDER_TARGET 预留给"拖到小人=赠与"(未来) ghost:SetBorderColor(color)
scripts/界面组件/底部物品栏.lua — 落点矩阵(pointerup,核心)
lualocal src = dragState_.source local player = PlayerComponent.GetPlayer(state) local slotHit = HitTestSlot(gx, gy) if slotHit and player then if slotHit.kind == "bag" then if src == "held" then success = PlayerComponent.SwapHeldWithSlot(player, slotHit.idx) -- 手持→背包 elseif type(src) == "table" then if src.fromInventory == slotHit.idx then action = "cancel" -- 同槽 elseif PlayerComponent.GetInventoryItem(player, slotHit.idx) then success = PlayerComponent.SwapInventorySlots(player, src.fromInventory, slotHit.idx) -- 有物→换 else success = PlayerComponent.MoveInventoryToSlot(player, src.fromInventory, slotHit.idx) -- 空→移 end end elseif slotHit.kind == "hand" then if src == "held" then action = "cancel" -- 手持→手持 elseif type(src) == "table" then if PlayerComponent.HasHeldItem(player) then success = PlayerComponent.SwapHeldWithSlot(player, src.fromInventory) -- 背包↔手持(有物) else success = PlayerComponent.EquipFromInventory(player, src.fromInventory) -- 背包→手持(空) end end end elseif not slotHit then -- 未命中任何槽 → 丢玩家脚底(读空间实时坐标,不反解屏幕) local fx, fy = ActorSpatialService.GetPosition(player.id) if fx and fy then success = require("系统.主角交互服务").DropItemAt(context, src, fx, fy) end end
四、纪律:遍历上界 + 启动归一化
lua-- 交互菜单(赠送/装备列表):上界用槽位数,禁用 #player.inventory for idx = 1, PlayerComponent.GetInventorySlots() do local item = player.inventory[idx] if item then ... end -- nil 槽跳过 end -- main.lua 启动:一次性清脏数据并归一化为定长(之后运行期不再重排) local _pchar = PlayerComponent.GetPlayer(context_.store.GetState()) if _pchar then PlayerComponent.SanitizeInventory(_pchar) end


排错复盘:为什么"第 9 格、有时第 4 格"会消失
| 步骤 | 发生了什么 |
|---|---|
| 显示 | 螺丝刀在视觉第 5 格 = inv[5](渲染直映) |
| 拖向第 9 格松手 | pointerup 先 GetInventoryItem(player,9) 判目标空否 |
| 副作用 | 该调用入口触发旧 EnsureSlots → 把螺丝刀从 5 压缩回 1 |
| 下一步 | MoveInventoryToSlot(player,5,9) 读 inv[5] 已是 nil → 失败 |
| 结果 | 物品既没到 9 也不在 5,被悄悄挪到第 1 格 → "消失" |
| 为何格子随机 | 压缩程度 + #inv 在稀疏表上的未定义长度,随当前洞位变化 |
修掉 EnsureSlots 的压缩和 #inv 后,下标恒等于视觉格子,矩阵每一步读写都在"你看到的那一格",落位即稳。
验证
LSP 45 文件 0 Error → 官方 Build 成功 → Headless Validate 60 帧 run=PASS、lua_errors=0。
一句话成功公式:定长槽位 = 渲染直映 + 数据层只"规整长度、绝不重排" + 遍历上界用常量 + 变更只走原子方法 + 落点按命中槽/未命中槽二分。守住"下标=格子"这一不变量,拖拽到任意格都不会再丢。

