下载 App

大厅校准模式

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 写云 key(Server.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。
理性值跨局持久化
1. 数据常量(RationalityData.lua) M.INITIAL_VALUE = 100 -- 新玩家初始理性值 M.MAX_VALUE = 100 -- 理论上限 M.REMEDY_MAX_VALUE = 90 -- 药剂恢复封顶 M.REMEDY_REQUIRED_MAX_VALUE = 89 -- 药剂可用阈值(理性≤89) M.REMEDY_COST = 3 -- 药剂价格(宝
官方
闪跳问题
下面是这轮三个问题(音量滑块、拖动闪跳、选角页闪跳/白屏)的可复用经验与对应关键代码。代码均为磁盘最终落地版本。 经验一:拖动控件别在回调里重建整棵树 滑块/开关这类"边拖边回调"的控件,onChange 里只能原地改值,绝不能触发 BuildXxxUI() 整树重建——否则正在拖的滑块会被销毁,拖动中断。音量百分比标签也是同理,用闭包持有引用做原地 SetText。 scripts/bullbe
官方
牌面文字反复重新布局和渲染
调查结论 问题已查清:牌面文字本身没有异常,也不是 NanoVG 重复绘制。真正原因是每名玩家或 AI 投币后,客户端都会销毁并重新创建整个战斗 UI。 左右牌文字、牌框、顶部头像、底部状态栏都在同一棵 UI 树中。因此本来只需要更新底部投币槽,却连带让红框内的牌面文字反复重新布局和渲染,表现为“闪烁几下”。 直接证据 Client.BuildPlayingUI() 每次先销毁现有根节点: scr
官方
UI按键动画设计
一、本次反复失败的根因(三个坑) 现象 根因 按钮完全不可见(空白/小白点) 入场动画把 scale/alpha 初始设 0,靠 Update 驱动 Tween 补到 1;但该模板页面级 Update 时序不可靠,补间不推进 → 永远停在 0 背景/按钮颜色反转 用了 nvgRGBA(r,g,b,255) 整数版,出现通道映射异常 矩形在、文字消失 文字色≈填充色;或 DrawStrokedTex
官方
【牌】兴登堡凶兆
这张卡的主题很适合《友谊的小船》,但按原始数值直接落地会有一个关键问题:右牌严格优于左牌,四人局会趋向全员选右,博弈不成立。 先评估原方案 选择 未扎堆 同侧≥3人 结论 左:追高 -15万 -40万 始终比右牌更痛 右:抄底 -10万 -25万 任意人数下都更优 无论同侧人数是多少,右牌都少亏: 未扎堆:右牌少亏 5万 扎堆后:右牌少亏 15万 所以理性玩家没有选择左牌的理由;最终通常会四人全选
官方
拖到合成目标才留位
错误原因 我在上一轮加回收功能时,为了"修位置回正"加了 else 分支:当牌既没拖到回收区、也没合成目标时,强制把牌弹回原位。这破坏了 Stacklands 的基本交互——拖到空地就该留在那里。 -- ❌ 错误版本(多余的 else 导致拖到空地也弹回) if inRecycle and not card.locked then ...回收溶解... elseif self._mergeTar
官方
WASM 崩溃
nvgCreateImage 在 WASM 环境下无法正确加载图片文件(中文路径/资源未打包进虚拟文件系统),返回看似正数但实际无效的纹理句柄。Lua 中 0/-1 都是 truthy,if 判断通过后把无效句柄传给 nvgImagePattern,C 层访问空指针 → WASM 内存越界。 彻底解决方案:纯矢量绘制,零文件 I/O 完全放弃图片加载,用 NanoVG 基础图元(圆/椭圆/描边)绘
官方
背景音乐持久化方案
问题演进 轮次 方案 失败原因 1 File("save.pref", FILE_WRITE) WASM MEMFS 刷新即丢失 2 clientCloud:SetInt("music_enabled", 1/0) 写 0 被后端吞掉;HTTP 请求刷新时被浏览器取消 3 clientCloud:Set("music_enabled", "off") 同上,HTTP 不可靠 4 serverClo
官方
卡牌:市场调研
skill_market_research 当前仅完成了: 加入随机技能池; 可以在技能节点被选中; 有新绘制图标; 有 Tooltip 设计说明; 能作为技能徽章显示。 但“选择后显示实时人数,并允许一次改选”的玩法逻辑尚未接入。 代码证据 1. Tooltip 明确标记为“设计中” scripts/bullbear/Client.lua:830 当前内容仍是: 技能 · 侦察 + 改选 · 设
官方