问题演进
| 轮次 | 方案 | 失败原因 |
| 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 | serverCloud via WebSocket 远程事件 | 可靠但 2-3 秒延迟导致 Toggle 闪烁 |
| 5 | ... 占位符等 serverCloud | 占位符卡死不消失 |
| 6 | 服务端内存缓存 + 播放守卫 + 超时兜底 | 最终方案 |
核心经验
- WASM 多人模式唯一可靠持久化路径:serverCloud via WebSocket(非 HTTP)
- 服务端进程在 WASM 刷新时不重启:内存缓存跨刷新持久,可实现重连零延迟
- 播放守卫比占位符更好:Toggle 始终可见(允许短暂闪变),但音乐不能先响再停
- 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