弹窗内做"搜索 + 分类 + 网格"


经验 1:弹窗内做"搜索 + 分类 + 网格",用局部刷新保住输入焦点
为什么有效:搜索框是 UI.TextField,如果每次输入都 RebuildUI 重建整棵树,焦点会丢、光标乱跳。正确做法是只把"网格容器"这一棵子树的子节点 ClearChildren + AddChild,搜索框和分类标签不动。模块级变量 objectCategory_ 跨弹窗开关不重置,天然实现"记住上次分类"。
关键代码(scripts/页面/地图编辑模式.lua):
lua-- 模块级状态:跨弹窗持久,满足"记住上次分类" local objectSearchQuery_ = "" local objectCategory_ = "全部" local objectGridContainer_ = nil -- 网格容器引用,供局部刷新 local objectOverlayPanel_ = nil -- 外壳引用,供动态改高度 local objectCatTabRefs_ = {} -- 分类标签引用,供改高亮 local OBJECT_CATEGORY_ORDER = { "全部", "家具", "教学", "生活设施", "清洁", "随身物品", "电子设备" } -- 局部刷新:只动网格 + 改标签色 + 改外壳高度,绝不 RebuildUI RefreshObjectGrid = function(context) if not objectGridContainer_ then return end local filtered = FilterObjectItems(PALETTE_ITEMS.object or {}) objectGridContainer_:ClearChildren() for i = 1, #filtered do local card = CreatePaletteCard(filtered[i], i, context, 0) -- 0 = 相对 grid 定位 card.props.zIndex = 3 objectGridContainer_:AddChild(card) end local gridH = math.max(1, math.ceil(#filtered / 4)) * 82 objectGridContainer_.props.height = gridH if objectOverlayPanel_ then objectOverlayPanel_.props.height = OBJECT_HEADER_H + gridH + 12 end for _, tab in ipairs(objectCatTabRefs_) do local activeTab = tab._cat == objectCategory_ tab:SetStyle({ -- 原地改色,不重建 backgroundColor = activeTab and { 74, 126, 164, 246 } or { 52, 50, 44, 236 }, borderColor = activeTab and { 255, 232, 110, 255 } or { 120, 104, 74, 255 }, }) end end
搜索框 + 分类行的构造(isObject 分支内):
luaoverlay:AddChild(UI.TextField { position = "absolute", left = 12, top = 10, width = contentWidth - 24, height = 30, fontSize = 13, value = objectSearchQuery_ or "", placeholder = "搜索物品...", onChange = function(self, value) objectSearchQuery_ = value or "" RefreshObjectGrid(context) -- 只刷网格,焦点不丢 end, }) -- 分类标签:onPointerDown 切分类后只刷网格 local tab = UI.Panel { onPointerDown = function(event) if event then event:StopPropagation() end -- 见经验 6 的 onClick 守卫区别 objectCategory_ = catName RefreshObjectGrid(context) end, children = { UI.Label { text = catName, pointerEvents = "none", ... } }, }
踩坑:分类行 / 搜索框这类"覆盖在地图上的悬浮 UI",onPointerDown 必须 StopPropagation,否则事件会穿透到底层地图触发平移/放置。


经验 2:flex 列里的 Label 想水平居中,必须 width="100%" + textAlign="center"
为什么有效:UI.Label 不传 width 时走 autoWidth,宽度=文字实际宽,放进 flex 列里默认左对齐,于是所有卡片名称偏左。
关键代码(CreatePaletteCard):
luaUI.Label { text = item.text, width = "100%", -- 禁用 autoWidth,撑满父宽 textAlign = "center", -- 此时居中才有可视效果 fontSize = 14, fontColor = { 246, 236, 200, 255 }, maxLines = 1, },
踩坑:textAlign 只在 Label 宽 > 文字宽时才有用,所以 width 不可省。同理定高小容器里的 Label 还要控制 fontSize,否则触发 nvgTextBox 自动换行被裁切。


经验 3:运行时拾取"零代码自动接通"——靠 text == name 逐字匹配
这是本次最有价值的发现。新物品想运行时可拾取,不用改交互服务、不用在编辑器目录写 interactable 字段,只要满足两个条件:① 物品注册表有条目;② 编辑器目录的 text 与注册表 def.name 逐字相等。
自动接通的链路(NormalizeObjects 在启动/进图时跑):
lua-- scripts/数据/地图/校园主地图.lua function M.NormalizeObjects() for i = 1, #map.objects do local object = map.objects[i] local itemId = ItemRegistry.ResolveItemId(object.type) -- object.type = 编辑器目录的 text if itemId then object.interactable = true -- 强制补齐,不看编辑器目录 object.carryable = true object.interactionType = "pickup" object.itemId = itemId end end end -- 匹配桥梁:中文名或 itemId 都能命中 function ItemRegistry.ResolveItemId(objectType) for id, def in pairs(ITEMS) do if def.name == objectType or id == objectType then return id end end return nil end
补齐后 CreateObjectDot 自动加金色圆圈 + zIndex=20 + onClick 拾取,PickupAny 过门槛后查注册表执行 on_pickup/on_throw/on_use。固定家具(课桌/黑板/摄像头…)故意不进注册表,所以保持不可拾取——这是设计,不是 bug。
致命约束:一旦为显示把 text 改名(如"数学书"→"数学课本")而注册表 name 没同步,ResolveItemId 返回 nil,物品静默变不可拾取(无金圈、点击无反应、不报错)。所以新增物品时两处名称必须复制粘贴保证一致。


经验 4:手持/地面双尺寸,用一个"尺寸漏斗"收敛
为什么有效:地面要"看得见点得到"(≥3×3),手持要"和身体成比例"(2×2 起步)。两套尺寸不应让渲染层去判断,而是收敛进 GetHeldItemVisual 这一个函数,渲染层 (CreatePlayerMarker) 完全无感知。
关键代码(scripts/系统/主角交互服务.lua):
lua-- 单一漏斗:held 优先 → 地面回退 → 兜底 3 function PlayerInteractionService.GetHeldItemVisual() local player = GetPlayer() if not player or not player.heldObjectId then return nil end local object = FindObject(player.heldObjectId) if not object or not object.image then return nil end local def = ResolveItemDef(object) local w = (def and def.heldVisualWidth) or object.visualWidth or 3 local h = (def and def.heldVisualHeight) or object.visualHeight or 3 return { image = object.image, visualWidth = w, visualHeight = h } end
注册表只加两个字段,想让某件物品手持=地面大小,直接不写这两个字段(回退链自动用 object 的地面尺寸)——这就是经验 5 扫把的处理方式,零特殊分支。
luascrewdriver = { ..., heldVisualWidth = 2, heldVisualHeight = 4, ... }, -- 放大 2 倍 broom = { ..., /* 不写 heldVisual,回退地面 4×4 */ },


经验 5:手持垂直定位用"躯干锚点",别用几何中心
为什么有效:角色头占顶部约 30%,若物品中心取容器几何中心 ((contH-itemH)/2),正好落在脖子/上胸,视觉"怪"。改成锚定角色高度的 65% 处即躯干中部。
关键代码(scripts/页面/校园地图.lua CreatePlayerMarker):
lualocal CHAR_W, CHAR_H = 36, 36 local HELD_ITEM_Y_ANCHOR = 0.65 -- 0.5=几何中心,0.65=躯干,0.7=腰,0.8=腿侧 local itemLeft, itemTop = 0, 0 if heldVisual then itemLeft = math.floor((contW - itemW) / 2) -- 水平仍居中 local anchorY = charTop + CHAR_H * HELD_ITEM_Y_ANCHOR -- 锚到躯干 itemTop = math.floor(anchorY - itemH / 2) -- 物品中心对齐锚点 end
一个常量管全部手持物品的垂直位置,微调只动它,转向不跳变(水平居中 + 容器尺寸逻辑没动)。


经验 6:Lua 前向声明必须 name = function,绝不能 local function name
本次真实崩溃:RecordObjectUse 在 line 94 做了 local RecordObjectUse 前向声明,但定义处写成 local function RecordObjectUse(...),这新建了一个同名局部变量,遮蔽了前向声明,导致 line 122 的 SelectPaletteItem 调用到的还是 nil → 运行时 attempt to call a nil value,"最近使用排序"也从未生效。
正确写法:
lualocal RecordObjectUse -- line 94:前向声明,值 nil ... RecordObjectUse = function(itemText) -- 定义处:赋值,复用上面的 upvalue if not itemText then return end for i = 1, #objectRecentUse_ do if objectRecentUse_[i] == itemText then table.remove(objectRecentUse_, i); break end end table.insert(objectRecentUse_, 1, itemText) if #objectRecentUse_ > 20 then objectRecentUse_[#objectRecentUse_] = nil end end
附带的"最近使用优先"排序("全部" + 无搜索时生效):
luaif cat == "全部" and query == "" and #objectRecentUse_ > 0 then local rankMap = {} for i = 1, #objectRecentUse_ do rankMap[objectRecentUse_[i]] = i end table.sort(out, function(a, b) return (rankMap[a.text] or 9999) < (rankMap[b.text] or 9999) end) end


复用清单:新增一件"可拾取物品"的标准 4 步
把上面 6 条经验压成一张可照做的清单:
- 物品注册表加条目:id / name / image / category / 效果积木块,需要手持缩小时加 heldVisualWidth/Height,想手持=地面就不写。
- 编辑器目录 object 表加条目:text 必须逐字等于注册表 name,加 category(控制分类标签),image 指 image/交互/*.png。不要写 interactable。
- 若分类是新的,在 OBJECT_CATEGORY_ORDER 追加名称。
- LSP 0 Error → 官方 Build。运行时拾取/金圈/效果全部自动生效,无需改交互服务或渲染层。


一句话总纲:本次能又快又稳,核心是"数据驱动 + 单一漏斗 + 局部刷新"三件事——可拾取性靠 NormalizeObjects 数据自动接通,手持尺寸靠 GetHeldItemVisual 一个函数收敛,搜索/分类靠只刷网格子树保住焦点;而两个最容易静默失败的坑(text≠name 断链、local function 遮蔽前向声明)都已写进项目避雷清单。

