下载 App

槽位变更只走原子方法

07/27综合

经验

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

关键代码

一、数据层:非压缩规整(消失 bug 的核心修复点)

scripts/系统/主角组件服务.luaEnsureSlots(每个背包方法入口都调它,所以它"压缩"=全链路压缩)
lua
local 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/系统/主角组件服务.luaSanitizeInventory(启动调一次,原地清坏条目,不重排)
lua
function 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 — 槽身份注册 + 命中反查
lua
local 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
lua
local 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,核心)
lua
local 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
horizontal linehorizontal line

排错复盘:为什么"第 9 格、有时第 4 格"会消失

步骤发生了什么
显示螺丝刀在视觉第 5 格 = inv[5](渲染直映)
拖向第 9 格松手pointerupGetInventoryItem(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=PASSlua_errors=0
一句话成功公式:定长槽位 = 渲染直映 + 数据层只"规整长度、绝不重排" + 遍历上界用常量 + 变更只走原子方法 + 落点按命中槽/未命中槽二分。守住"下标=格子"这一不变量,拖拽到任意格都不会再丢。
拾取 / 丢弃物品
拾取的坑是「点击被遮挡」,丢弃的坑是「坐标反解会偏」。两者解法都是绕开复杂机制、用最直接的方式。 问题 根因 解法 走到物品旁点不动、捡不起来 物品命中层和主角标记同层、都无 zIndex,数组顺序靠后的主角标记盖在上面,点击命中的是主角而非物品 给可交互物品显式 zIndex = 20 拖拽丢东西飞到远处 用 ScreenToWorld 把鼠标像素反解成世界坐标当落点,依赖 zoom/offse
官方
4 赞
精灵平滑移动、墙碰撞体积
一、 渲染层用连续坐标(scripts/页面/校园地图.lua,三处统一): -- NPC 定位(第408-410行) local contLeft = (character.x or 0) * 5 - 18 - charLeft local contTop = (character.y or 0) * 5 - 36 - charTop -- 主角定位(第504-506行) local con
官方
1 赞
右键拖拽平移
一、问题本质 不是平移公式写错,而是 UrhoX UI 库指针事件派发的两个不对称陷阱叠加: 陷阱 现象 根因文件 OnPointerMove 不向父冒泡 右键按下 isPanning_=true,但 move 事件永远到不了 mapView urhox-libs/UI/Core/Widget.lua(OnPointerDown/Up 有 parent 冒泡,OnPointerMove 没有) H
官方
4 赞
图片加载三层优化设计
目标场景:地图编辑器"物品/墙体/地面/房间/人员"调色板弹窗的图片首帧显示 文档目的:沉淀"图片为什么慢、怎么治、发布后玩家体验如何、未来加资源时 preload_groups 怎么取舍"的完整结论,避免重复踩坑。 一句话结论 WASM 下图片"慢"不是单一问题,而是下载 / 解码 / 上传 GPU 三件事全堆在"玩家点弹窗那一刻"做导致的。优化的本质是把这三件事提前到启动期做(让玩家点的时候图
官方
1 赞
自动寻路赠送
核心功能 携带模式(点击物品跟鼠标)下: ≤8格:点击小人直接赠送(金色边框) >8格:点击远距离小人 → 自动寻路走向目标 → 到达8格内自动赠送 取消:玩家按方向键/WASD/摇杆手动移动 → 立即取消,恢复物品 关键经验(3条踩坑教训) 经验1:类型标识不是中文,是英文枚举值 地图编辑器放置的角色 type/job 字段是英文标识(teacher_3、doctor、cleaner_2、tea
官方
1 赞
地图编辑器仿真 tick 重建抑制
UI 重建频率 = 事件分发的可靠性上限。 只要有一帧 SetRoot 销毁了正在处理 pointer 事件的控件,跨重建边界的 Down/Up 对就会被吞。高频仿真 tick 必须在 UI 边界按页面过滤。 关键代码(scripts/main.lua HandleUpdate) 四步走:先原地处理人员 → 聚合所有仿真 changed → 地图页强制压制 → 只放行显式/登录态变化。 -- ma
官方
4 赞
以鼠标为锚点缩放
核心数学原理 缩放前后,鼠标指向的那个世界坐标点必须保持在同一屏幕位置。 已知:旧缩放 oldZoom、新缩放 newZoom、鼠标锚点屏幕坐标 (ax, ay)、当前偏移 (panX, panY) 步骤1:算出旧缩放下鼠标指向的世界坐标 wx = (ax - panX) / oldZoom wy = (ay - panY) / oldZoom 步骤2:新缩放后反解新偏移,让 wx/wy
官方
1 赞
修复"地图编辑器刷新数据丢失"
问题诊断过程 用户报告:编辑器建好房间 → 保存 → 刷新页面 → 进编辑器 → 数据全没了。 关键线索:用户发现"校园日"模式刷新后能看到房间,但一进编辑器就没了。 这直接指向了编辑器页面自身的初始化逻辑,而非持久化链路。 根因 地图编辑模式.lua 的 EnsureBlankEditorLayout() 设计意图是"首次打开编辑器提供空白画布",用模块级布尔 editorLayoutClear
官方
4 赞
小人的认知系统
这是一个很好的思路。RND 的核心——用"预测误差"度量新奇度,驱动探索——可以直接映射到校园小人的认知系统上,而且不需要真的跑神经网络。下面是方案讨论。 RND 思想到校园小人的映射 RND 原始结构 vs 校园简化版 RND 组件 原始含义 校园小人对应 目标网络(固定随机) 为每个状态生成固定特征 环境的客观快照:房间类型、物件数、人员密度、卫生度、当前时段 预测网络(可训练) 学习预测目标
官方
4 赞
地图编辑器地面交互与视觉优化
一、问题链 用户截图反馈了三个递进的问题: 问题 1:点击地面类型(如食堂地砖),弹窗不关闭,挡住画布无法操作 ↓ 修复 v0.10.94 问题 2:地面贴图太大,一块地砖比小人还宽,比例失调 ↓ 修复 v0.10.96(改配置值) 问题 3:改了配置值后刷新仍然太大——旧存档的 tileSize=256 优先级高于新 config ↓ 修复 v0.10.97(反转渲染优先级) 二、关键代码
官方
4 赞