牌面文字反复重新布局和渲染

07/232 浏览

调查结论

问题已查清:牌面文字本身没有异常,也不是 NanoVG 重复绘制。真正原因是每名玩家或 AI 投币后,客户端都会销毁并重新创建整个战斗 UI。
左右牌文字、牌框、顶部头像、底部状态栏都在同一棵 UI 树中。因此本来只需要更新底部投币槽,却连带让红框内的牌面文字反复重新布局和渲染,表现为“闪烁几下”。

直接证据

Client.BuildPlayingUI() 每次先销毁现有根节点:
scripts/bullbear/ClientUIPlaying.lua:14
lua
function Client.BuildPlayingUI() if uiRoot_ then uiRoot_:Destroy() end
随后重新创建两张牌的标题和效果文字:
scripts/bullbear/ClientUIPlaying.lua:175
最后替换整棵根节点:
scripts/bullbear/ClientUIPlaying.lua:694
lua
uiRoot_ = UI.Panel { ... } UI.SetRoot(uiRoot_)
UI.SetRoot() 会把布局标为脏数据,下一次 UI 渲染时重新执行整树 Yoga 布局,因此文字会重新生成布局并绘制。
horizontal linehorizontal line

为什么真人投币会闪两次以上

真人点击牌面后的调用链为:
text
点击左牌或右牌 → Client.MakeChoice() → 本地把自己标记为已选择 → 向服务器发送 CHOOSE_CARD → Client.BuildPlayingUI()
本地第一次全量重建位于:
scripts/bullbear/ClientUIPlaying.lua:707-744
之后服务器确认选择并广播:
scripts/bullbear/ServerGame.lua:289-332
text
服务器收到 CHOOSE_CARD → BroadcastPlayerChoiceReady(slot) → 所有客户端收到 PLAYER_CHOICE_READY → Client.BuildPlayingUI()
客户端第二次全量重建位于:
scripts/bullbear/ClientCore.lua:777-786
lua
playerChoiceReady_[slot] = true if phase_ == Shared.PHASE.PLAYING then Client.BuildPlayingUI() end
这里没有判断该玩家是否已经处于锁定状态。因此本机刚刚做过乐观更新,收到自己的服务端确认后仍然会再销毁、重建一次。
所以单是本机投币,通常已经有:
  1. 本地点击后重建一次;
  2. 服务端确认本机选择后再重建一次。
horizontal linehorizontal line

为什么 AI 投币又会继续闪

每个 AI 完成选择时,服务器也单独广播一次 PLAYER_CHOICE_READY
scripts/bullbear/ServerGame.lua:366-377
每条广播到达客户端后,都会进入同一个处理器并再次执行:
lua
Client.BuildPlayingUI()
也就是说,截图中的 1 名真人加 3 名 AI,理论时序可能是:
时序触发原因整树重建
1真人本地点击1 次
2服务器确认真人选择1 次
3AI P2 投币1 次
4AI P3 投币1 次
5AI P4 投币1 次
6全员选择完毕、飞币结束进入揭示1 次
AI 在真人投币后会被压缩为最多再等待 1 秒:
scripts/bullbear/ServerGame.lua:323-329
所以多个 AI 的确认事件会集中到较短时间内到达,视觉上正好就是牌面文字连续闪几下,而不是偶尔随机闪一下。
horizontal linehorizontal line

截图中几个红框为什么同步异常

红框标出的内容全部属于同一个 uiRoot_
  • 左牌标题、说明、状态图标;
  • 右牌标题、说明;
  • P1~P4 底部投币槽。
PLAYER_CHOICE_READY 实际变化的只有某名玩家底部状态卡中的 isChoiceLocked
scripts/bullbear/ClientUIPlaying.lua:500-513
但当前实现没有局部更新该状态卡,而是把中央双牌、顶部头像、全部底栏一起销毁重建。因此:
  • 底部投币槽发生变化是正常需求;
  • 左右牌文字跟着重绘是不必要的副作用;
  • AI 每投一枚币,左右牌又被重建一次。
horizontal linehorizontal line

与 NanoVG 的关系

目前没有证据表明牌面文字由 UI 和 raw NanoVG 重复绘制:
  • 牌名和效果说明是 UI.Labelscripts/bullbear/ClientUIPlaying.lua:175-195
  • raw NanoVG 主要负责航线、事件气泡、飞币动画等;
  • ClientRender*.lua 中没有再次绘制 leftName/rightName/leftEffect/rightEffect
因此这不是:
  • DPR 或分辨率模式错误;
  • 字体每帧重新创建;
  • 两个渲染层同时绘制相同文字;
  • Update 每帧调用 BuildPlayingUI()
它是事件密集期间反复替换 UI 根节点造成的。
horizontal linehorizontal line

后续修复方向

推荐优先级如下:
  1. 最佳方案:保持中央牌面 UI 稳定,只局部更新对应玩家的投币槽。
  2. 通过控件引用或 FindById() 更新某个 P 位;
  3. 不再让 PLAYER_CHOICE_READY 调用 BuildPlayingUI()
  4. 这是从根本上消除闪烁的方案。
  5. 对服务端确认做幂等去重。
  6. playerChoiceReady_[slot] 已经为 true,不再刷新;
  7. 能消除本机“本地更新 + 服务端回执”的重复刷新;
  8. 但其他 AI 投币时仍会重建,所以只能缓解,不能根治。
  9. 保留揭示阶段的一次完整切换。
  10. 飞币结束后进入 REVEAL 的一次重建属于阶段切换,可以保留;
  11. 需要避免的是每个 PLAYER_CHOICE_READY 都重建中央牌面。
  12. 次选方案:合并同一帧或短时间内的多条刷新请求。
  13. 可以减少 AI 集中投币时的连续重建;
  14. 但仍然比局部更新代价大,不建议作为最终方案。
本轮仅调查,没有修改任何文件,也没有执行构建。

第一项“普通遭遇投币槽局部更新”已完成。

实现内容

  • 为每个玩家投币槽增加稳定 ID:
  • scripts/bullbear/ClientStatusCards.lua:190
  • 格式为 playing_coin_slot_<slot>
  • 自定义投币槽和 fallback 控件都支持定位。
  • 新增局部刷新接口:
  • scripts/bullbear/ClientUIPlaying.lua:14
  • 通过 uiRoot_:FindById() 定位指定 P 位投币槽;
  • 只调用 SetProp("spinning", false) 停止铜钱旋转;
  • 不销毁状态栏,不重建左右牌面。
  • 本机普通遭遇点击改为局部更新:
  • scripts/bullbear/ClientUIPlaying.lua:753
  • 普通遭遇不再调用 BuildPlayingUI()
  • Buff、技能节点仍保留全量更新,因为它们会改变实际卡片内容。
  • 服务端投币确认改为局部更新并增加幂等保护:
  • scripts/bullbear/ClientCore.lua:777
  • AI 或其他真人投币时,只更新对应玩家投币槽;
  • 重复收到同一 P 位确认不会再次刷新。

调用链复核

现在普通遭遇投币链为:
text
本机点击 / AI 投币确认 → playerChoiceReady_[slot] = true → RefreshPlayerChoiceCoin(slot) → coinSlot:SetProp("spinning", false)
不再经过:
text
uiRoot_:Destroy() → 重建左右牌文字 → UI.SetRoot()
揭示完成、玩家爆仓、Buff/技能节点等确实改变界面结构的路径仍保留完整重建。

验证结果

  • Lua LSP:0 Error
  • 官方多人构建:成功
  • 多人配置保持正确:
  • background_match=true
  • free_match_with_ai
  • immediately_start=true
  • match_timeout=3
  • player_number=4
  • 单机截图验证工具不支持多人项目,因此未用无服务端环境制造无效测试结果。
需要在多人预览中实际投一次币观察:预期底部对应铜钱停止旋转,左右牌文字保持稳定,不再随真人和三个 AI 的投币逐次闪烁。