下载 App
10/16案例分享
【案例分享】本地存档看着能用,刷新就没了:File 存档的两个静默失效,以及 clientCloud 的正确时机
一句话结论
本地 File 存档在桌面端和原生移动端能正常跑通,但在 WASM 上刷新即清零、在服务端模式下所有文件操作返回 nil/false —— 这两种情况都不报错、不崩溃、控制台干净,只是安静地什么都没存下来。
一、隔离是白送的,别自己去拼路径
星火编辑器的文件 API 跟标准 Urho3D 一致(File、FileSystem 用法不变),差别在于引擎自动给你套了两层隔离:
项目隔离 —— 项目 A 读不到项目 B 的存档
用户隔离 —— 同一项目内,用户 A 的存档与用户 B 互不可见
两层都不用写代码。脚本里直接写文件名就行:
local file = File("save.json", FILE_WRITE)
引擎自己把 save.json 落到「当前项目 + 当前用户」的专属目录里。你不需要知道那个目录在哪,也不该知道 —— FileSystem:GetProgramDir() 这类路径 getter 已经被沙箱拿掉了,调用返回空字符串,想拼绝对路径也拼不出来。
二、JSON 存档:读写两段完整代码
-- ============ 保存 ============
local function SaveGame(data)
local file = File("save.json", FILE_WRITE)
if not file:IsOpen() then
print("[存档] 文件打开失败")
return false
end
file:WriteString(cjson.encode(data))
file:Close()
return true
end
-- ============ 读取 ============
local function LoadGame()
if not fileSystem:FileExists("save.json") then
return nil -- 没有存档,正常情况
end
local file = File("save.json", FILE_READ)
if not file:IsOpen() then
return nil
end
local raw = file:ReadString()
file:Close()
local ok, data = pcall(cjson.decode, raw)
if not ok then
print("[存档] JSON 解析失败,按无存档处理")
return nil
end
return data
end
两个细节别省:pcall 包住 decode,存档文件被写坏时返回 nil 而不是让整个游戏挂掉;先 FileExists 再读,避免拿一段空字符串去 decode。
用起来是这样:
SaveGame({
level = 5,
score = 1200,
inventory = { "sword", "shield" }
})
local data = LoadGame()
if data then
print("关卡:", data.level, "分数:", data.score)
end
三、多存档槽位
槽位就是把「一个文件名」变成「一个目录 + 编号」。CreateDir 不会自动递归建目录,动手前先确认目录在。
local SAVE_DIR = "saves/"
local SLOT_COUNT = 3
local function EnsureSaveDir()
if not fileSystem:DirExists(SAVE_DIR) then
fileSystem:CreateDir(SAVE_DIR)
end
end
function SaveToSlot(slotId, data)
if slotId < 1 or slotId > SLOT_COUNT then return false end
EnsureSaveDir()
data.slot = slotId
data.savedAt = os.date("%Y-%m-%d %H:%M:%S") -- os.date 在沙箱里保留
local file = File(SAVE_DIR .. "slot" .. slotId .. ".json", FILE_WRITE)
if not file:IsOpen() then return false end
file:WriteString(cjson.encode(data))
file:Close()
return true
end
function ListSlots()
EnsureSaveDir()
local names = fileSystem:ScanDir(SAVE_DIR, "*.json", SCAN_FILES, false)
local slots = {}
for _, name in ipairs(names) do
local file = File(SAVE_DIR .. name, FILE_READ)
if file:IsOpen() then
local ok, data = pcall(cjson.decode, file:ReadString())
file:Close()
if ok then
table.insert(slots, {
file = name,
level = data.level,
savedAt = data.savedAt
})
end
end
end
return slots
end
ScanDir 第 3 个参数 SCAN_FILES 只列文件不列目录,第 4 个参数传 false 表示不递归子目录。返回的 name 是文件名、不带路径,拼的时候自己带上 SAVE_DIR 那个斜杠。
删除槽位这件事没有 API —— os.remove 被沙箱移除了。想「删档」只能往那个槽位写一个空 JSON,或者写 { deleted = true } 标记,然后在 ListSlots 里跳过它。
四、踩过的坑:两种「静默失效」
坑 1:WASM 上的存档活在内存里,刷新就是重启
我拿一个推币机项目在浏览器里测存档。3 个槽位,手动存了 7 次,每次读回来都对,ListSlots 也能老老实实列出 3 个文件。界面正常、控制台干净、没有任何警告。
按了一下 F5。
槽位列表空了。ScanDir 返回 0 个文件。第 8 次读档返回 nil。
根因写在文档里:WASM 平台的 savedata 落在内存文件系统中。进程一死,这个文件系统跟着死。刷新页面 = 进程重启 = 存档清零。
这条最阴的地方在于——它骗过了所有常规功能测试。存一次读一次,对得上;连存三个槽位再全部读回来,也对得上。整条链路里没有一行会告诉你「这东西不持久」。只有跨过一次页面刷新,问题才现形。偏偏项目在原生端跑的时候完全正常,于是很容易得出「存档功能已经做完了」的结论,然后打包上网页端,上线当天收到第一封「我的存档没了」。
修法只有一个方向:WASM 上要持久化,走 clientCloud。把整个存档当成一个 table 塞进 values 表:
-- 存到云端
clientCloud:Set("save_slot_1", saveData, {
ok = function() print("[云存档] 写入成功") end,
error = function(code, reason) print("[云存档] 失败", code, reason) end
})
-- 读回来
clientCloud:Get("save_slot_1", {
ok = function(values, iscores)
local data = values.save_slot_1 -- table 自动 JSON 编解码,读出来就是原 table
if data then
RestoreGame(data)
end
end
})
Set 写进 values 表,table 和数组自动走 JSON 编解码,读出来直接是 Lua table —— 你不需要自己 cjson.encode 一遍。需要排行榜排序的整数(最高分、通关数)用 SetInt / Add 写进 iscores,只有这一张表能参与排名。
坑 2:服务端模式把文件读写整个屏蔽
服务端模式下,File(...) 打开的句柄 IsOpen() 恒为 false,fileSystem:FileExists(...) 恒为 false,ScanDir 返回空表。全部返回 nil 或 false,不抛错。
于是这段代码在服务端会安静地走完,一个字节都没落盘:
local file = File("save.json", FILE_WRITE)
if file:IsOpen() then -- 服务端:永远是 false
file:WriteString(cjson.encode(data))
file:Close()
end
-- 函数返回了,没有报错,没有日志,调用方以为存好了
如果存档逻辑写在一个客户端和服务端都会加载的共享模块里,服务端那一份会一直空转。它不报错,你也就一直不知道。
修法:存档逻辑只放客户端。服务端要存东西,用 serverCloud(客户端专用的是 clientCloud,两个 API 不通用);或者让客户端自己存完,再把结果通知服务端。
五、被沙箱移除的 API 清单
io 库(io.open / io.write / io.lines 等)→ 用 File
loadfile() / dofile() → 用 require
os.execute() → 无
os.remove() / os.rename() → 无(只能覆盖写空内容)
NamedPipe / PackageFile → 无
FileSystem:SystemCommand() 等系统命令 → 无
FileSystem:GetProgramDir() 等路径 getter → 返回空字符串
保留下来的:os.clock、os.time、os.date,还有 require。写存档时间戳用 os.date("%Y-%m-%d %H:%M:%S") 没问题。
这张表里前两行是能用别的写法绕过去的,后五行没有等价替代 —— 碰到它们说明你的方案本身有问题,不是写法问题。比如想用 io.open 读配置表,那就该改用 require 或者直接把数据放进存档 JSON 里。
六、什么场景用本地,什么场景必须上云
桌面 / 原生移动端,单机,多个存档槽位 → 本地 File。隔离白送,零网络开销,读写同步返回。
WASM / 网页端,任何需要跨刷新的数据 → clientCloud。内存文件系统刷新即丢。
排行榜、最高分、通关数 → clientCloud。只有 iscores 的整数能排序。
跨设备续档 → clientCloud。本地 File 只活在这台机器上。
服务端脚本里想存东西 → 都不行。文件 API 全屏蔽,走 serverCloud 或交给客户端。
单局临时状态(本局分数、临时 buff)→ 内存变量。落盘纯属浪费。
上云的代价得算清楚:读写各 300 次/分钟,数据量 48 MB/分钟,超了 error 回调返回 -429。所以别把它当高频状态同步用 —— 每帧写一次分数,5 秒就能把配额打满。攒着批量提交:
-- 一次提交多个 key,比连着调 5 次 Set 省配额
clientCloud:BatchSet()
:SetInt("high_score", 9999)
:SetInt("coins", 320)
:Set("save_slot_1", saveData)
:Save("结算存档", {
ok = function() print("已保存") end
})
还有个容易踩的:clientCloud 只限客户端(Standalone / Client 模式)用。服务端要读同一个排行榜,得用 serverCloud,key 对得上、API 对不上。
七、一条可迁移的判断方法
写完任何「存数据」的代码,先跑一次刷新测试:存一次 → 读回来确认 → 刷新页面 → 再读一次。三步缺一步,你分不清自己写的是存档还是缓存。
这个测试不挑引擎,也不挑平台。凡是把数据写到「某个地方」的代码,都该先回答一句话:它活得过我按一次 F5 吗?
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取截图
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取
HELLO 大家好,我是阿木木牧。最近第一版的内部小范围(几人)第一个图的测试已经结束了。 整体游戏以装备数值的崩坏而告终~ (虽然大家都很欢快。。。) 现在准备开始开发下个阶段的内容啦,就是将游戏模式拆分出来。分成单人模式,多人组队模式(合作PVE),多人匹配模式(对抗模式)。那么,怎么配置呢?说来也很简单哈~ 1,在游戏编辑器中,数据编辑器,找到地图设置。 2,编辑地图设置。比如我希望最大数量
14 赞
3 回复
不支持当前设备截图
不支持当前设备
荣耀400pro
2 赞
3 回复
00:18
修复一下这个挂起过。有概率用不了的bug吧截图
修复一下这个挂起过。有概率用不了的bug吧
太绝望了[表情_豹哭][表情_豹哭][表情_豹哭][表情_豹哭]
1 赞
1 回复
00:11
人物有小概率会卡着不往前走截图
人物有小概率会卡着不往前走
3 赞
1 回复
无须申请,星火编辑器现已公测开放下载截图
无须申请,星火编辑器现已公测开放下载
—————————————————— 各位关注星火编辑器的小伙伴: 感谢大家一直以来对编辑器支持! 破晓已去,曙光降临! 星火编辑器现已开启公测! · 关于下载和资格申请 现已无需申请,请去下面的官方首页直接下载! 缘起测试是星火编辑器自诞生以来第一次对外测试! 从2022年八月开始到十一月,历时三个月。 缘起之后缘聚测试,历时又七个月! 缘聚之后,破晓降临,历时又十二个月! 曾经微弱的星火现已渐
官方
331 赞
375 回复
05:19
1截图
1
1.完成后,获得信用点的增益依旧有效,可以卡无限信用点。理论上 2.部分天赋和在一起可以实现永续,那些天赋,我下一个发,因为只能传一个视频。 3.卡,人多点就卡,知道卡死,但是手机不会卡就游戏卡
2 赞