背景音乐持久化方案

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