穿透层修复
经验一:CSS transform 穿透层修复模式
问题本质
当容器使用 CSS transform: translateX/Y/scale 平移/缩放子树时,即使父容器有 overflow = "hidden",引擎的命中检测可能不裁剪越界区域。如果 transform 后的子树有更高的 zIndex,它会在视觉范围外拦截其他兄弟节点的指针事件。
受影响的场景
地图编辑器(右键平移/滚轮缩放后):
┌─ zIndex=50 ────────────────────┐ ← 顶栏按钮
│ │
│ ┌─ zIndex=40 ──┐ ┌─ zIndex=1 ──────────┐
│ │ 左栏工具 │ │ 地图工作区 │
│ │ │ │ (transform 平移后 │
│ │ │ │ 子树越界到顶/底) │
│ └──────────────┘ └─────────────────────┘
│ │
└─ zIndex=50 ────────────────────┘ ← 底栏状态栏修复模式:固定工具条三件套
顶栏 CreateHeaderActions (地图编辑模式.lua:674):
luaUI.Panel { width = "100%", position = "relative", -- 必须,zIndex 才生效 zIndex = 50, -- 必须高于地图工作区的 zIndex=1 pointerEvents = "auto", -- 必须,确保命中 onPointerDown = StopPointerPropagation, onPointerMove = StopPointerPropagation, onPointerUp = StopPointerPropagation, onPointerCancel = StopPointerPropagation, onWheel = StopPointerPropagation, onClick = StopClickPropagation, backgroundColor = { 28, 30, 30, 245 }, -- ... 内容 }
底栏 CreateBottomStatusBar (地图编辑模式.lua:752):
luaUI.Panel { width = "100%", position = "relative", zIndex = 50, pointerEvents = "auto", onPointerDown = StopPointerPropagation, onPointerMove = StopPointerPropagation, onPointerUp = StopPointerPropagation, onPointerCancel = StopPointerPropagation, onWheel = StopPointerPropagation, onClick = StopClickPropagation, -- ... 内容 }
左栏 (已有,zIndex = 40):
luaUI.Panel { width = SIDEBAR_W, flexShrink = 0, position = "relative", zIndex = 40, pointerEvents = "auto", onPointerDown = StopPointerPropagation, -- ... 全套事件 }
关键要点
| 要素 | 作用 | 缺一不可的原因 |
| position = "relative" | 建立 stacking context | 无此属性 zIndex 在 Yoga 中不生效 |
| zIndex 高于地图区 | 命中优先级 | 地图区 zIndex=1,工具条必须 >1 |
| pointerEvents = "auto" | 显式启用命中 | Yoga 某些嵌套下默认值可能不同 |
| 六种事件 stop 冒泡 | 防止事件穿透到地图 | pointerDown/Move/Up/Cancel/Wheel/Click |


经验二:网关服务模块模式
数据源三层优先级
lua-- 系统/网关服务.lua -- 1. 公式默认值:永远能算出合理坐标 local function FormulaDefault() local w = MapData.data.width or 240 local h = MapData.data.height or 160 return { id = "main_gate", x = math.floor(w * 0.5 - DEFAULT_GATE_W * 0.5), -- 水平居中 y = h - DEFAULT_GATE_H, -- 贴底部 w = DEFAULT_GATE_W, -- 20格宽 h = DEFAULT_GATE_H, -- 4格深 direction = "up", -- 面朝校园内部 label = "正门", } end -- 2. 读取时 clamp:防止地图尺寸变化后越界 local function ClampZone(zone) local mw = MapData.data.width or 240 local mh = MapData.data.height or 160 local zw = math.min(zone.w or DEFAULT_GATE_W, mw) local zh = math.min(zone.h or DEFAULT_GATE_H, mh) local zx = math.max(0, math.min(zone.x or 0, mw - zw)) local zy = math.max(0, math.min(zone.y or 0, mh - zh)) return { x = zx, y = zy, w = zw, h = zh } end -- 3. 用户数据优先,空时公式兜底 function GateService.GetAllGates() local zones = MapData.data.gateZones if not zones or #zones == 0 then local def = FormulaDefault() local clamped = ClampZone(def) return { { id = def.id, x = clamped.x, y = clamped.y, w = clamped.w, h = clamped.h, direction = def.direction, label = def.label } } end -- ... 遍历用户数据并 clamp end
消费方:随机散布代替单点堆叠
lua-- 系统/学生位置服务.lua local function ResolveLocation(location) if location == "gate" then -- 在校门区域内随机散布,返回朝向 local gx, gy, gdir = GateService.GetRandomPointInGate() return gx, gy, gdir end -- ... 其他回退 end -- EnsurePosition 用 resolvedDir 作为朝向兜底 local position = ActorSpatialService.Ensure(student.id, { x = x, y = y, direction = student.direction or resolvedDir, })
关键要点
| 设计决策 | 理由 |
| 数组结构从第一天用 | 避免未来从单对象迁移到数组的痛苦 |
| 字段精简到 6 个 (id/x/y/w/h/direction/label) | 不加 isOpen/priority 等死字段——没有消费者就是负担 |
| 公式默认 + clamp + 随机散布 | 三重防御:永不 nil、永不越界、永不堆叠 |
| direction 随坐标返回 | 入校即面朝校园内,离校逻辑反过来用 |
| 坐标服务与玩法服务分离 | 网关服务.lua(坐标)vs 校门服务.lua(违禁搜查/离校) |


经验三:避雷清单(踩坑记录)
1. transform 穿透不能靠 overflow = "hidden" 解决,必须高 zIndex sibling
2. 不能只靠 pointerEvents 和 stopPropagation,transform 越界子树先命中让你收不到事件
3. EmmyLua @return 注解格式是 --- @return number name,不是 --- @return name number
4. 带 nil 洞的稀疏表 cjson.encode 会失败(背包定长槽位要填 false 哨兵)
5. onClick 回调的 event 没有 StopPropagation 方法,必须 if event.StopPropagation then 守卫
6. event.button 守卫用 MOUSEB_RIGHT 不用 MOUSEB_LEFT(后者未暴露为 nil)
7. event.x/event.y 已经是 widget 本地坐标,不要加减 UI.GetVisualRect 的 x/y
