下载 App

背景音乐持久化方案

07/263 浏览

问题演进

轮次方案失败原因
1File("save.pref", FILE_WRITE)WASM MEMFS 刷新即丢失
2clientCloud:SetInt("music_enabled", 1/0)写 0 被后端吞掉;HTTP 请求刷新时被浏览器取消
3clientCloud:Set("music_enabled", "off")同上,HTTP 不可靠
4serverCloud via WebSocket 远程事件可靠但 2-3 秒延迟导致 Toggle 闪烁
5... 占位符等 serverCloud占位符卡死不消失
6服务端内存缓存 + 播放守卫 + 超时兜底最终方案

核心经验

  1. WASM 多人模式唯一可靠持久化路径serverCloud via WebSocket(非 HTTP)
  2. 服务端进程在 WASM 刷新时不重启:内存缓存跨刷新持久,可实现重连零延迟
  3. 播放守卫比占位符更好:Toggle 始终可见(允许短暂闪变),但音乐不能先响再停
  4. ApplyMusicFromServer 必须无条件调用 UpdateLobbyMusic():守卫刚解除时值可能没变,但仍需触发播放

关键代码

1. Shared.lua — 事件定义

lua
-- 客户端 → 服务端 SAVE_MUSIC_PREF = "BullBear_SaveMusicPref", -- 服务端 → 客户端 MUSIC_PREF = "BullBear_MusicPref", -- serverCloud key MUSIC_PREF_KEY = "friendship_boat_music_pref_v1",

2. Server.lua — 内存缓存 + 两层读取

lua
S.userIdMusicPrefCache_ = {} -- userId → tag(服务端进程级,WASM 刷新不丢失) function Server.LoadMusicPref(slot) local p = S.players_[slot] if not p or not p.connection or not p.userId then return end local userId = p.userId -- 第1层:内存缓存命中 → 同步发送(零延迟) local cached = S.userIdMusicPrefCache_[userId] if cached ~= nil then Server.SendMusicPrefToConnection(p.connection, cached, "缓存命中") end -- 第2层:异步 serverCloud 读取(首次连接或缓存刷新) if not serverCloud then if cached == nil then Server.SendMusicPrefToConnection(p.connection, "", "服务端云不可用") end return end serverCloud:Get(userId, Shared.CONFIG.MUSIC_PREF_KEY, { ok = function(scores) local current = S.players_[slot] if not current or current.userId ~= userId or not current.connection then return end local tag = tostring(scores and scores[Shared.CONFIG.MUSIC_PREF_KEY] or "") S.userIdMusicPrefCache_[userId] = tag -- 更新缓存 if cached == nil then -- 缓存未命中时才需要发(命中已在上面同步发了) Server.SendMusicPrefToConnection(current.connection, tag, tag ~= "" and "已从云端读取" or "云端无记录") end end, error = function(code, reason) local current = S.players_[slot] if not current or current.userId ~= userId or not current.connection then return end if cached == nil then Server.SendMusicPrefToConnection(current.connection, "", "云端读取失败") end end, }) end function Server.SaveMusicPref(slot, tag) local p = S.players_[slot] if not p or not p.connection or not p.userId then return end local userId = p.userId tag = tostring(tag or "") S.userIdMusicPrefCache_[userId] = tag -- 立即更新缓存(下次连接零延迟) if not serverCloud then Server.SendMusicPrefToConnection(p.connection, tag, "服务端云不可用") return end serverCloud:Set(userId, Shared.CONFIG.MUSIC_PREF_KEY, tag, { ok = function() local current = S.players_[slot] if not current or current.userId ~= userId or not current.connection then return end Server.SendMusicPrefToConnection(current.connection, tag, "已保存到云端") end, error = function(code, reason) local current = S.players_[slot] if not current or current.userId ~= userId or not current.connection then return end Server.SendMusicPrefToConnection(current.connection, tag, "云端保存失败") end, }) end

3. Client.lua — 状态变量

lua
lobbyMusicEnabled_ = true, -- 当前开关状态 serverMusicPrefLoaded_ = false, -- 服务端偏好是否已到达 musicPrefLoadTimer_ = 0, -- 超时计时器 lobbyMusicSaveStatus_ = "未保存", -- 保存状态文案

4. ClientCore.lua — 播放守卫 + 超时兜底

lua
function Client.UpdateLobbyMusic() -- 关键守卫:服务端偏好未到达前不播放也不停止 if not serverMusicPrefLoaded_ then return end if not lobbyMusicEnabled_ or not IsLobbyMusicPhase() then Client.StopLobbyMusic() return end if lobbyMusicSource_ and lobbyMusicSource_:IsPlaying() then return end -- ... 创建 SoundSource 播放 ... end -- Update 循环中的超时兜底 if not serverMusicPrefLoaded_ then musicPrefLoadTimer_ = (musicPrefLoadTimer_ or 0) + dt if musicPrefLoadTimer_ >= 5.0 then serverMusicPrefLoaded_ = true Client.UpdateLobbyMusic() if phase_ == Shared.PHASE.WAITING then Client.BuildWaitingUI() end end end

5. ClientStorage.lua — 接收 + 保存

lua
function Client.ApplyMusicFromServer(tag, status) serverMusicPrefLoaded_ = true -- 解除守卫 lobbyMusicSaveStatus_ = status or lobbyMusicSaveStatus_ if type(tag) == "string" and tag ~= "" then local serverEnabled = (tag ~= "off") if serverEnabled ~= lobbyMusicEnabled_ then lobbyMusicEnabled_ = serverEnabled -- 写回本地 File 缓存(同会话内有效) local file = File(MUSIC_PREF_PATH, FILE_WRITE) if file and file:IsOpen() then file:WriteString(lobbyMusicEnabled_ and "on" or "off") file:Close() end end end -- 关键:无论值是否变化都必须调用(守卫刚解除,需要立即播放/停止) Client.UpdateLobbyMusic() if phase_ == Shared.PHASE.WAITING then Client.BuildWaitingUI() end end function Client.SaveMusicPreference() -- 本地 File 缓存(同会话即时生效) local file = File(MUSIC_PREF_PATH, FILE_WRITE) if file and file:IsOpen() then file:WriteString(lobbyMusicEnabled_ and "on" or "off") file:Close() end -- WebSocket 远程事件 → 服务端 serverCloud(WASM 可靠) if serverConnection_ then lobbyMusicSaveStatus_ = "服务器保存中" local data = VariantMap() data["Tag"] = Variant(lobbyMusicEnabled_ and "on" or "off") serverConnection_:SendRemoteEvent(Shared.EVENTS.SAVE_MUSIC_PREF, true, data) else lobbyMusicSaveStatus_ = "等待服务器连接" end end

6. ClientCore.lua — 事件订阅 + _G 桥接

lua
-- Start() 中 SubscribeToEvent(Shared.EVENTS.MUSIC_PREF, "Client_HandleMusicPref") -- 处理函数 function Client_HandleMusicPref(eventType, eventData) local tag = eventData["Tag"]:GetString() local status = eventData["Status"]:GetString() if Client.ApplyMusicFromServer then Client.ApplyMusicFromServer(tag, status) end end -- 文件末尾 _G.Client_HandleMusicPref = Client_HandleMusicPref

数据流时序

首次连接:
  客户端启动 → 默认ON,守卫阻止播放 → Toggle显示ON
  WebSocket连接 → ClientIdentity → 服务端serverCloud异步读取(~0.05s)
  → MUSIC_PREF事件 → ApplyMusicFromServer → 守卫解除 → 播放/停止 → Toggle更新

WASM刷新(第2次+):
  客户端启动 → 默认ON,守卫阻止播放 → Toggle显示ON
  WebSocket连接 → ClientIdentity → 服务端内存缓存命中 → 同步发送(~0s)
  → MUSIC_PREF事件 → ApplyMusicFromServer → 守卫解除 → 立即正确播放/停止

最坏情况(事件丢失):
  5秒超时 → 守卫解除 → 用默认值播放

排查口诀(借鉴用户提供的方法论)

介质对不对?→ 写到没?→ ACK 收到没?→ Ready 了没?→ 白名单有没?
     ↓              ↓            ↓              ↓              ↓
 serverCloud    日志确认     回调触发     事件订阅+_G桥接   本引擎无白名单
 via WebSocket  "已保存"    "已发送"     SubscribeToEvent   但需RegisterRemoteEvent
牌面文字反复重新布局和渲染
调查结论 问题已查清:牌面文字本身没有异常,也不是 NanoVG 重复绘制。真正原因是每名玩家或 AI 投币后,客户端都会销毁并重新创建整个战斗 UI。 左右牌文字、牌框、顶部头像、底部状态栏都在同一棵 UI 树中。因此本来只需要更新底部投币槽,却连带让红框内的牌面文字反复重新布局和渲染,表现为“闪烁几下”。 直接证据 Client.BuildPlayingUI() 每次先销毁现有根节点: scr
官方
闪跳问题
下面是这轮三个问题(音量滑块、拖动闪跳、选角页闪跳/白屏)的可复用经验与对应关键代码。代码均为磁盘最终落地版本。 经验一:拖动控件别在回调里重建整棵树 滑块/开关这类"边拖边回调"的控件,onChange 里只能原地改值,绝不能触发 BuildXxxUI() 整树重建——否则正在拖的滑块会被销毁,拖动中断。音量百分比标签也是同理,用闭包持有引用做原地 SetText。 scripts/bullbe
官方
理性值跨局持久化
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 -- 药剂价格(宝
官方
卡牌:市场调研
skill_market_research 当前仅完成了: 加入随机技能池; 可以在技能节点被选中; 有新绘制图标; 有 Tooltip 设计说明; 能作为技能徽章显示。 但“选择后显示实时人数,并允许一次改选”的玩法逻辑尚未接入。 代码证据 1. Tooltip 明确标记为“设计中” scripts/bullbear/Client.lua:830 当前内容仍是: 技能 · 侦察 + 改选 · 设
官方
UI按键动画设计
一、本次反复失败的根因(三个坑) 现象 根因 按钮完全不可见(空白/小白点) 入场动画把 scale/alpha 初始设 0,靠 Update 驱动 Tween 补到 1;但该模板页面级 Update 时序不可靠,补间不推进 → 永远停在 0 背景/按钮颜色反转 用了 nvgRGBA(r,g,b,255) 整数版,出现通道映射异常 矩形在、文字消失 文字色≈填充色;或 DrawStrokedTex
官方
资源策略从“全量引用 + 全量预下载”切换为“增强引用 + DWP”
结论 目前 300/300MB 的主要原因已经明确,不是网络偶发问题,而是同时存在两层“全量加载”: 构建配置要求所有资源入包,并在启动前全部下载 客户端启动后,又主动创建了所有遭遇动画的 152 张纹理 所以当前行为本质上是:先把整个资源仓库下载完,再一次性把大量遭遇动画解码进内存。 1. 第一根因:配置为“全量引用 + 全量预下载” 当前配置是: groups.default = ["**"]
官方
【牌】兴登堡凶兆
这张卡的主题很适合《友谊的小船》,但按原始数值直接落地会有一个关键问题:右牌严格优于左牌,四人局会趋向全员选右,博弈不成立。 先评估原方案 选择 未扎堆 同侧≥3人 结论 左:追高 -15万 -40万 始终比右牌更痛 右:抄底 -10万 -25万 任意人数下都更优 无论同侧人数是多少,右牌都少亏: 未扎堆:右牌少亏 5万 扎堆后:右牌少亏 15万 所以理性玩家没有选择左牌的理由;最终通常会四人全选
官方
大厅校准模式
一、整体架构 校准系统由四层组成: 层 文件 职责 状态容器 Client.lua 保存 lobbyCalibrationMode_/Drag_/Slots_/RestorePending_/SaveStatus_、路径常量、管理员标记 拖拽 + UI ClientUILobby.lua 指针事件包装、归一化坐标、实时预览、设置面板 序列化 ClientStorage.lua 本地 pref 读写
官方
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
官方