大厅校准模式

07/272 浏览综合

一、整体架构

校准系统由四层组成:
文件职责
状态容器Client.lua保存 lobbyCalibrationMode_/Drag_/Slots_/RestorePending_/SaveStatus_、路径常量、管理员标记
拖拽 + UIClientUILobby.lua指针事件包装、归一化坐标、实时预览、设置面板
序列化ClientStorage.lua本地 pref 读写 + 服务端云存档、管道分隔文本
协议Shared.lua / Server.lua远程事件 SAVE_LOBBY_CALIBRATION / LOBBY_CALIBRATION / ADMIN_IDENTITY,云 key friendship_boat_lobby_calibration_v1

二、关键设计经验

1. 状态字段最小集 (scripts/bullbear/Client.lua:776-785)
lua
lobbyCalibrationMode_ = false, -- 是否在校准模式 lobbyCalibrationDrag_ = nil, -- 当前拖拽上下文 lobbyCalibrationSlots_ = nil, -- { window={[i]={x,y,w,h}}, cannon={[key]={...}} } lobbyCalibrationRestorePending_ = true, -- 云存档未到达前阻塞大厅渲染 lobbyCalibrationSaveStatus_ = "未保存", isAdmin_ = false, LOBBY_CALIBRATION_PATH = "friendship_boat_lobby_calibration.pref",
2. 归一化坐标系(与分辨率/素材尺寸解耦)
  • 所有槽位用 0~1 相对 shipSize(船体基准边长)存储。
  • 炮孔按钮使用 inset=0.10 内缩,通过 ResolveInsetSlot 在「可拖动逻辑框」和「实际按钮渲染框」之间换算:
lua
ResolveInsetSlot = function(slot) local inset = slot.inset or 0 local x = slot.x + slot.w * inset local y = slot.y + slot.h * inset local w = slot.w * (1 - inset * 2) local h = slot.h * (1 - inset * 2) return x, y, w, h end
→ 用户拖的是逻辑大框,渲染出来是内缩 10% 的按钮,手指/鼠标更好抓。
3. 指针事件透明包装(不破坏原有 onClick) 关键是把拖拽能力挂到已有按钮上,而不是重写按钮:
lua
local function AttachCalibrationHandlers(widget, calibrationHandlers) if not calibrationHandlers then return widget end local baseDown, baseMove, baseUp, baseCancel = widget.OnPointerDown, widget.OnPointerMove, widget.OnPointerUp, widget.OnPointerCancel function widget:OnPointerDown(event) if baseDown then baseDown(self, event) end calibrationHandlers.down(event, self) end function widget:OnPointerMove(event) if baseMove then baseMove(self, event) end calibrationHandlers.move(event, self) end function widget:OnPointerUp(event) if baseUp then baseUp(self, event) end calibrationHandlers.up(event, self) end function widget:OnPointerCancel(event) if baseCancel then baseCancel(self, event) end calibrationHandlers.up(event, self) -- cancel 走 up 收尾,避免残留拖拽 end return widget end
同时 onClick 里第一行 if calibrationHandlers then return end,保证校准模式下点击不触发建/入房间等业务逻辑。
4. 拖拽三阶段(模式门控 + 边界夹取 + 实时预览)
lua
local function BeginCalibrationDrag(kind, key, slot, event, widget) if not lobbyCalibrationMode_ then return end local startX, startY = GetCalibrationPointerBasePosition(event, widget) lobbyCalibrationDrag_ = { kind=kind, key=key, startX=startX, startY=startY, slotX=slot.x, slotY=slot.y, slotW=slot.w, slotH=slot.h, inset=slot.inset, widget=widget, } end local function UpdateCalibrationDrag(event, widget) local drag = lobbyCalibrationDrag_ if not lobbyCalibrationMode_ or not drag then return end local currentX, currentY = GetCalibrationPointerBasePosition(event, widget or drag.widget) local dx = (currentX - drag.startX) / shipSize local dy = (currentY - drag.startY) / shipSize local nextSlot = { x = Clamp(drag.slotX + dx, 0, 1 - drag.slotW), y = Clamp(drag.slotY + dy, 0, 1 - drag.slotH), w = drag.slotW, h = drag.slotH, } SetCalibrationSlot(drag.kind, drag.key, nextSlot) -- cannon 要反向 inset 换算回按钮真实坐标 local leftSlot = nextSlot if drag.kind == "cannon" then local xNorm, yNorm = ResolveInsetSlot({ x=nextSlot.x, y=nextSlot.y, w=nextSlot.w, h=nextSlot.h, inset=drag.inset }) leftSlot = { x=xNorm, y=yNorm } end drag.widget:SetStyle({ left = math.floor(shipSize * leftSlot.x), top = math.floor(shipSize * leftSlot.y), }) end local function EndCalibrationDrag() if lobbyCalibrationDrag_ then FillAllCalibrationSlots() end lobbyCalibrationDrag_ = nil end
注意三点:
  • GetCalibrationPointerBasePosition 先尝试 UI.Input.GetPointer(DPR/scale 一致的全局坐标),失败再回落到 widget:GetAbsoluteLayout() + event.xy,兼容触摸与鼠标。
  • Clamp(..., 0, 1 - size) 防止按钮被拖出船体;尺寸 w/h 保持不变(只支持平移,不支持缩放,简化心智)。
  • 拖拽结束不自动保存(本次 UX 修复点),只把当前 UI 位置回灌进 lobbyCalibrationSlots_,等用户点「保存」。
5. 工厂只在校准模式时注入 handlers
lua
local function MakeCalibrationHandlers(kind, key, slot) if not lobbyCalibrationMode_ then return nil end return { down = function(event, widget) BeginCalibrationDrag(kind, key, slot, event, widget) end, move = function(event, widget) UpdateCalibrationDrag(event, widget) end, up = function() EndCalibrationDrag() end, } end
调用点(ClientUILobby.lua:712-729):
lua
children[#children + 1] = CreateRoomWindow(lobbyRooms_[i], i, slot, shipSize, SendCreateRoom, SendJoinRoom, MakeCalibrationHandlers("window", i, slot)) ... children[#children + 1] = CreateShipActionButton(createSlot, SendCreateRoom, nil, MakeCalibrationHandlers("cannon", "create", createSlot)) children[#children + 1] = CreateShipActionButton(ticketSlot, ..., MakeCalibrationHandlers("cannon", "ticket", ticketSlot)) children[#children + 1] = CreateShipActionButton(aiSlot, ..., MakeCalibrationHandlers("cannon", "ai", aiSlot)) children[#children + 1] = CreateShipActionButton(refreshSlot, ...,MakeCalibrationHandlers("cannon", "refresh", refreshSlot)) children[#children + 1] = CreateShipActionButton(shopSlot, ..., MakeCalibrationHandlers("cannon", "shop", shopSlot))
MakeCalibrationHandlers 返回 nil 时 AttachCalibrationHandlers 直接透传,零开销。nil 也用作 UI 视觉分支(非校准走金色 hover 效果,校准走红框高亮)。
6. 序列化:键名白名单 = 真相来源(本次修 bug 的核心)
lua
local function SerializeLobbyCalibrationSlots(slots) slots = slots or {} local lines = {} if slots.window then local indices = {} for index in pairs(slots.window) do indices[#indices+1] = index end table.sort(indices, function(a,b) return tonumber(a) < tonumber(b) end) for _, index in ipairs(indices) do local s = slots.window[index] if s then lines[#lines+1] = table.concat({"window", tostring(index), tostring(s.x), tostring(s.y), tostring(s.w), tostring(s.h)}, "|") end end end if slots.cannon then local keys = { "create", "ticket", "ai", "refresh", "shop" } -- ← bug 就是漏了 "shop" for _, key in ipairs(keys) do local s = slots.cannon[key] if s then lines[#lines+1] = table.concat({"cannon", key, tostring(s.x), tostring(s.y), tostring(s.w), tostring(s.h)}, "|") end end end return table.concat(lines, "\n") end
经验:可校准元素集合和序列化 key 列表必须单一真相来源。一旦 UI 多加了一个按钮而忘了加 key,拖拽看似生效、存档永远丢。最好把 { "create", "ticket", "ai", "refresh", "shop" } 提到文件顶部常量,FillAllCalibrationSlots 与序列化共用同一份。
7. 双层存档 + 恢复门控
lua
function Client.LoadLobbyCalibration() lobbyCalibrationRestorePending_ = true -- 1) 本地 pref 立即生效,避免云存档慢导致闪默认位置 if fileSystem and fileSystem:FileExists(LOBBY_CALIBRATION_PATH) then local file = File(LOBBY_CALIBRATION_PATH, FILE_READ) if file and file:IsOpen() then ApplyLobbyCalibrationText(file:ReadString(), "local"); file:Close() end end -- 2) 服务端 serverCloud 后到,覆盖本地(跨设备同步) end function Client.SaveLobbyCalibration() local text = SerializeLobbyCalibrationSlots(lobbyCalibrationSlots_) local file = File(LOBBY_CALIBRATION_PATH, FILE_WRITE) -- 写本地 if file and file:IsOpen() then file:WriteString(text); file:Close() end if serverConnection_ then local data = VariantMap() data["Text"] = Variant(text) serverConnection_:SendRemoteEvent(Shared.EVENTS.SAVE_LOBBY_CALIBRATION, true, data) -- 上行云存档 end end
  • 本地优先:进大厅先用 pref 定位,云存档到达后再覆盖。
  • 恢复门控lobbyCalibrationRestorePending_=true 期间渲染 CreateLobbyCalibrationLoadingPanel(),防止默认位置先闪一下再跳。
  • 服务端只在管理员 UID 写云 keyServer.lua 硬编码 ADMIN_UIDS["1051336128"]=true),普通玩家的保存只写本地,避免互相覆盖全局模板。
8. UX 三条铁律(本次重点打磨)
  1. 拖拽结束 ≠ 保存EndCalibrationDrag 只做 FillAllCalibrationSlots(),不调 Save。用户必须点「保存」才提交,避免误碰一下就写脏。
  2. 设置面板不自动关闭:开启校准、保存、重置都不要 layoutSettingsOpen_ = false,让用户能"拖→看效果→再拖→保存"循环操作。
  3. 文案两态自解释
  4. 开启:"校准模式已开启:拖动船舱窗口和右侧按钮调整位置,完成后点击「保存」。"
  5. 关闭:"校准已关闭:点击「开启校准」后可拖动船舱窗口和右侧按钮自定义位置。"
9. 默认值 + override 覆盖合成
lua
local function GetWindowSlot(index) local override = lobbyCalibrationSlots_ and lobbyCalibrationSlots_.window and lobbyCalibrationSlots_.window[index] return ApplySlotOverride(SHIP_WINDOW_SLOTS[index], override) end
ApplySlotOverride(default, override) 把 override 按字段并入默认值。这样默认布局改了之后(例如美术调整船体),老存档里缺的字段自动回落到默认,而不是出现 nil 报错。
10. 入口隔离:校准期间屏蔽业务 onClick CreateRoomWindow / CreateShipActionButton 里:
lua
onClick = function() if calibrationHandlers then return end -- 校准模式下点击不生效 Client.PlaySfx("click") ... end,
同时校准模式下按钮边框改为红色 {255,72,72,230}、关闭 hover 缩放动画,用户一眼能看出处于编辑态。
horizontal linehorizontal line

三、踩坑清单(已修)

根因解法
"店"按钮拖完刷新又回去序列化 cannon key 列表漏了 "shop"key 列表提为常量,UI 构造和序列化共用
拖一下就被保存EndCalibrationDrag 里自动调 SaveLobbyCalibration移除,改为显式「保存」按钮
点保存/重置后设置面板消失三个按钮末尾都 layoutSettingsOpen_ = false全部移除
文案让用户「关闭设置再拖」旧文案和实际流程矛盾改为"开启后直接拖,点保存结束"
高 DPI 下坐标错位直接用 event.x/event.y统一走 UI.Input.GetPointer 拿全局 DPR 校正后的坐标
这套模式可直接复用到任何「让玩家自定义 UI 布局」的需求:战斗 HUD、技能栏、牌位、手柄键位提示等,只需要替换默认槽位表 SHIP_WINDOW_SLOTS/SHIP_CANNON_SLOTS 和序列化 key。