下载 App

闪跳问题

07/261 浏览综合
下面是这轮三个问题(音量滑块、拖动闪跳、选角页闪跳/白屏)的可复用经验与对应关键代码。代码均为磁盘最终落地版本。

经验一:拖动控件别在回调里重建整棵树

滑块/开关这类"边拖边回调"的控件,onChange 里只能原地改值,绝不能触发 BuildXxxUI() 整树重建——否则正在拖的滑块会被销毁,拖动中断。音量百分比标签也是同理,用闭包持有引用做原地 SetText。
scripts/bullbear/ClientUILobby.lua(设置面板音量行):
lua
local volumeSliderWidget = nil -- ...在 panelChildren 里,紧跟"背景音乐"开关行: (function() local volumeLabel = UI.Label { text = string.format("%d%%", math.floor((lobbyMusicVolume_ or 0.6) * 100 + 0.5)), fontSize = 11, fontColor = {220, 226, 240, 255}, } local volumeSlider = UI.Slider { value = lobbyMusicVolume_ or 0.6, min = 0, max = 1, step = 0.1, flexGrow = 1, height = 22, thumbSize = 16, disabled = not lobbyMusicEnabled_, onChange = function(_, value) volumeLabel:SetText(string.format("%d%%", math.floor(value * 100 + 0.5))) Client.SetLobbyMusicVolume(value) -- 只改 gain + 存盘,不重建面板 end, } volumeSliderWidget = volumeSlider return UI.Panel { width = "100%", flexDirection = "row", alignItems = "center", gap = 8, children = { UI.Label { text = "音量", fontSize = 11, fontColor = {220, 226, 240, 255} }, volumeSlider, volumeLabel, }, } end)(),
开关与滑块联动置灰(同样不重建,只 SetDisabled):
lua
UI.Toggle { value = lobbyMusicEnabled_, ... onChange = function(self, enabled) Client.SetLobbyMusicEnabled(enabled) if volumeSliderWidget then volumeSliderWidget:SetDisabled(not enabled) end end, },

经验二:保存→回包是"回声环",要静默吞掉自己发出去的回声

凡是"客户端 SendRemoteEvent 保存 → 服务端存完再广播回来"的链路,回包内容和本地必然一致。若回包回调里无条件刷新 UI,每拖一格就闪一次。解法是 pending 回声计数:发请求 +1,回包 -1 并直接 return;只有跨设备、数值真变化的更新才允许重建。
scripts/bullbear/ClientStorage.lua:
lua
-- 保存时标记一次回声 if serverConnection_ then lobbyMusicSaveStatus_ = "服务器保存中" musicPrefPendingEcho_ = (musicPrefPendingEcho_ or 0) + 1 local data = VariantMap() data["Tag"] = Variant(prefStr) data["Volume"] = Variant(vol) serverConnection_:SendRemoteEvent(Shared.EVENTS.SAVE_MUSIC_PREF, true, data) end -- 回包时:先识别回声,命中则静默吞掉(不重建 UI) function Client.ApplyMusicFromServer(tag, status, volume) serverMusicPrefLoaded_ = true local srvEnabled, srvVolume = ParseMusicPref(tag) local finalVolume = srvVolume if type(volume) == "number" and volume >= 0 and volume <= 1 then finalVolume = math.floor(volume * 10 + 0.5) / 10 end if musicPrefPendingEcho_ and musicPrefPendingEcho_ > 0 then musicPrefPendingEcho_ = musicPrefPendingEcho_ - 1 lobbyMusicSaveStatus_ = status or lobbyMusicSaveStatus_ Client.UpdateLobbyMusic(); Client.UpdateGameplayMusic() return -- 关键:自身回声,绝不 BuildWaitingUI end lobbyMusicSaveStatus_ = status or lobbyMusicSaveStatus_ local changed = (srvEnabled ~= lobbyMusicEnabled_) or (finalVolume ~= lobbyMusicVolume_) ... -- 真变化才写本地 + 改 gain Client.UpdateLobbyMusic(); Client.UpdateGameplayMusic() if changed and phase_ == Shared.PHASE.WAITING then -- 加 changed 守卫 Client.BuildWaitingUI() end end
要点:step 保证拖一格只发一次请求,回包 FIFO 一一对应,计数不会错位累积。

经验三:拖动时音频要"原地改 gain",别依赖 Update 重播

UpdateXxxMusic 在 source:IsPlaying() 时会早退,所以改音量不能指望它重播,必须直接改正在播放的 SoundSource.gain。
scripts/bullbear/ClientCore.lua:
lua
function Client.SetLobbyMusicVolume(vol) vol = tonumber(vol) or 0.6 if vol ~= vol then vol = 0.6 end -- NaN 防御 vol = math.floor(math.max(0, math.min(1, vol)) * 10 + 0.5) / 10 -- clamp + 对齐 step lobbyMusicVolume_ = vol if lobbyMusicSource_ then lobbyMusicSource_.gain = vol * LOBBY_MUSIC_GAIN end if gameplayMusicSource_ then gameplayMusicSource_.gain = vol * GAMEPLAY_BGM_GAIN end Client.SaveMusicPreference() end -- 首次起播时同样按音量缩放(两处对称) source.gain = (lobbyMusicVolume_ or 0.6) * LOBBY_MUSIC_GAIN -- 大厅 BGM source.gain = (lobbyMusicVolume_ or 0.6) * GAMEPLAY_BGM_GAIN -- 玩法 BGM
持久化用管道编码 on|0.6 兼容旧裸 on/off,云端另加独立 Volume Variant 透传,pcall 包 :GetFloat() 防旧包缺字段。

经验四:进页面"闪跳一下"= 异步回调二次全量重建 + 入场动画重播

带 opacity=0 → StaggerIn 入场动画的页面,进页面后 PLAYER_INFO/云端计数等回调常以相同状态又触发一次 BuildXxxUI,于是整树 Destroy、所有行重新从透明淡入 = 闪跳。解法是状态签名去重:状态没变就直接跳过重建。
scripts/bullbear/ClientUISelect.lua:
lua
function Client.BuildSelectingUI() Client.LoadPickCounts() Client.SyncMyCharacterFromTaken() -- 纯数据同步,无 UI 副作用,提前到顶部 -- 用固定顺序(Shared.CHARACTERS)算签名,保证稳定可比 local _selSig = {} for _, _c in ipairs(Shared.CHARACTERS) do local _t = takenCharacters_[_c.id] _selSig[#_selSig + 1] = _c.id .. ":" .. (pickCounts_[_c.id] or 0) .. ":" .. (_t and (_t.slot or 0) or 0) end _selSig[#_selSig + 1] = "M" .. tostring(myCharId_ or "") .. ":" .. tostring(mySlot_) .. ":" .. tostring(maxPlayers_) local _selSigStr = table.concat(_selSig, "|") if uiRoot_ and uiRoot_._selectingSig == _selSigStr then return -- 同状态二次回调:不重建、不重播动画 → 不闪 end if uiRoot_ then uiRoot_:Destroy() end -- ⚠️ 必须在守卫之后 ... 建树 ... UI.SetRoot(uiRoot_) uiRoot_._selectingSig = _selSigStr -- 记在新根上,供下次比对 Client.Anim.StaggerIn(selectAnimGen, rows, 0.05) end
顺手给排序加稳定 tiebreaker,杜绝计数相同时行序抖动:
lua
table.sort(sortedChars, function(a, b) local ca, cb = pickCounts_[a.id] or 0, pickCounts_[b.id] or 0 if ca ~= cb then return ca > cb end return a.id < b.id end)

这次踩的最深的坑(务必记住)

去重守卫必须放在 uiRoot_:Destroy() 之前。 我第一次把它放在 Destroy 之后,导致"先销毁旧树 → 算签名相同 → return → 新树没建"= 白屏。教训:任何"提前 return 跳过重建"的逻辑,都不能位于会销毁现有 UI 的语句之后。 正确顺序永远是「先比对、再决定是否销毁重建」。

可复用清单

  1. 拖控回调只原地改值,不重建整树;标签用闭包引用 SetText。
  2. "保存→广播回包"链路加 pending 回声计数,回包静默吞掉自身回声。
  3. 音频改音量直接改 SoundSource.gain,不依赖会早退的 Update 重播。
  4. 带入场动画的页面用"状态签名去重"挡掉同状态二次重建。
  5. 排序加稳定 tiebreaker。
  6. 去重守卫永远写在 Destroy 之前,否则白屏。
每次改完都跑了 Lua LSP(Error 0)+ 官方多人构建(后台匹配/4 人 AI 秒开/3 秒超时)通过;按项目铁律未执行 Git。
理性值跨局持久化
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 -- 药剂价格(宝
官方
UI按键动画设计
一、本次反复失败的根因(三个坑) 现象 根因 按钮完全不可见(空白/小白点) 入场动画把 scale/alpha 初始设 0,靠 Update 驱动 Tween 补到 1;但该模板页面级 Update 时序不可靠,补间不推进 → 永远停在 0 背景/按钮颜色反转 用了 nvgRGBA(r,g,b,255) 整数版,出现通道映射异常 矩形在、文字消失 文字色≈填充色;或 DrawStrokedTex
官方
背景音乐持久化方案
问题演进 轮次 方案 失败原因 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
官方
大厅校准模式
一、整体架构 校准系统由四层组成: 层 文件 职责 状态容器 Client.lua 保存 lobbyCalibrationMode_/Drag_/Slots_/RestorePending_/SaveStatus_、路径常量、管理员标记 拖拽 + UI ClientUILobby.lua 指针事件包装、归一化坐标、实时预览、设置面板 序列化 ClientStorage.lua 本地 pref 读写
官方
卡牌:市场调研
skill_market_research 当前仅完成了: 加入随机技能池; 可以在技能节点被选中; 有新绘制图标; 有 Tooltip 设计说明; 能作为技能徽章显示。 但“选择后显示实时人数,并允许一次改选”的玩法逻辑尚未接入。 代码证据 1. Tooltip 明确标记为“设计中” scripts/bullbear/Client.lua:830 当前内容仍是: 技能 · 侦察 + 改选 · 设
官方
牌面文字反复重新布局和渲染
调查结论 问题已查清:牌面文字本身没有异常,也不是 NanoVG 重复绘制。真正原因是每名玩家或 AI 投币后,客户端都会销毁并重新创建整个战斗 UI。 左右牌文字、牌框、顶部头像、底部状态栏都在同一棵 UI 树中。因此本来只需要更新底部投币槽,却连带让红框内的牌面文字反复重新布局和渲染,表现为“闪烁几下”。 直接证据 Client.BuildPlayingUI() 每次先销毁现有根节点: scr
官方
【牌】兴登堡凶兆
这张卡的主题很适合《友谊的小船》,但按原始数值直接落地会有一个关键问题:右牌严格优于左牌,四人局会趋向全员选右,博弈不成立。 先评估原方案 选择 未扎堆 同侧≥3人 结论 左:追高 -15万 -40万 始终比右牌更痛 右:抄底 -10万 -25万 任意人数下都更优 无论同侧人数是多少,右牌都少亏: 未扎堆:右牌少亏 5万 扎堆后:右牌少亏 15万 所以理性玩家没有选择左牌的理由;最终通常会四人全选
官方
WASM 崩溃
nvgCreateImage 在 WASM 环境下无法正确加载图片文件(中文路径/资源未打包进虚拟文件系统),返回看似正数但实际无效的纹理句柄。Lua 中 0/-1 都是 truthy,if 判断通过后把无效句柄传给 nvgImagePattern,C 层访问空指针 → WASM 内存越界。 彻底解决方案:纯矢量绘制,零文件 I/O 完全放弃图片加载,用 NanoVG 基础图元(圆/椭圆/描边)绘
官方
拖到合成目标才留位
错误原因 我在上一轮加回收功能时,为了"修位置回正"加了 else 分支:当牌既没拖到回收区、也没合成目标时,强制把牌弹回原位。这破坏了 Stacklands 的基本交互——拖到空地就该留在那里。 -- ❌ 错误版本(多余的 else 导致拖到空地也弹回) if inRecycle and not card.locked then ...回收溶解... elseif self._mergeTar
官方