牌面文字反复重新布局和渲染
调查结论
问题已查清:牌面文字本身没有异常,也不是 NanoVG 重复绘制。真正原因是每名玩家或 AI 投币后,客户端都会销毁并重新创建整个战斗 UI。
左右牌文字、牌框、顶部头像、底部状态栏都在同一棵 UI 树中。因此本来只需要更新底部投币槽,却连带让红框内的牌面文字反复重新布局和渲染,表现为“闪烁几下”。
直接证据
Client.BuildPlayingUI() 每次先销毁现有根节点:
scripts/bullbear/ClientUIPlaying.lua:14
luafunction Client.BuildPlayingUI() if uiRoot_ then uiRoot_:Destroy() end
随后重新创建两张牌的标题和效果文字:
scripts/bullbear/ClientUIPlaying.lua:175
最后替换整棵根节点:
scripts/bullbear/ClientUIPlaying.lua:694
luauiRoot_ = UI.Panel { ... } UI.SetRoot(uiRoot_)
UI.SetRoot() 会把布局标为脏数据,下一次 UI 渲染时重新执行整树 Yoga 布局,因此文字会重新生成布局并绘制。


为什么真人投币会闪两次以上
真人点击牌面后的调用链为:
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
luaplayerChoiceReady_[slot] = true if phase_ == Shared.PHASE.PLAYING then Client.BuildPlayingUI() end
这里没有判断该玩家是否已经处于锁定状态。因此本机刚刚做过乐观更新,收到自己的服务端确认后仍然会再销毁、重建一次。
所以单是本机投币,通常已经有:
- 本地点击后重建一次;
- 服务端确认本机选择后再重建一次。


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


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


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


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

