下载 App

如何用TapTap 制造实现一套不丢档的云存档系统

精华修改于04/25311 浏览开发交流
TapTap 制造做的游戏以 WASM(WebAssembly)方式运行在 TapTap 平台上,本地存储依赖浏览器IndexedDB,清缓存或换设备就会丢失。所以云存档不是可选项,而是必须。
horizontal linehorizontal line
做 WASM 小游戏最怕的事:玩家换个设备,存档没了。
WASM 的本地存储(IndexedDB)既不可靠也不跨设备。所以我们必须把存档放到云端——但只用一条云端通道又不够稳,网络抖动就可能导致存档丢失。
这篇文章分享双通道云存档的完整实现:clientCloud(TapTap 平台)和 serverCloud(游戏服务器)独立运行、互为备份,加上本地缓存作为第三层保险。
核心指标
正常流程:数据变动后 0 帧延迟写本地,2 秒内上传 serverCloud,15 秒内同步 clientCloud
异常恢复:任一通道故障,另一通道 + 本地缓存兜底,0 丢档
horizontal linehorizontal line

一、整体架构

┌─────────────┐
│   玩家操作    │
└──────┬──────┘
       │ MarkDirty()
       ▼
┌─────────────┐    同步写入     ┌──────────────┐
│  PlayerData  │──────────────→│  本地 File     │  ← 第一层: 即时持久化
│  (内存单例)   │               │  + 校验和      │
└──────┬──────┘               └──────────────┘
       │ 异步上传
       ▼
┌─────────────────────────────────────┐
│           CloudSync 调度器           │
├─────────────┬───────────────────────┤
│ serverCloud │     clientCloud       │
│  节流 2s    │      节流 15s         │  ← 第二层: 双云备份
│  + 排行榜    │      + 排行榜         │
└─────────────┴───────────────────────┘
为什么两条通道?
| 通道 | 优点 | 缺点 |
|------|------|------|
| clientCloud (TapTap) | 平台托管,零运维 | 依赖 TapTap SDK,部分环境不可用 |
| serverCloud (游戏服务器) | 完全可控,延迟低 | 服务器宕机时不可用 |
| 本地 File | 零延迟,离线可用 | WASM 环境不持久,换设备丢失 |
三者任意一个存活,存档就不会丢。
horizontal linehorizontal line

二、模块划分(6 个文件)

```
network/
  SaveEvents.lua        ← 常量: 云端 Key、事件名、排行榜 Key
  server_save.lua        ← 服务端: serverCloud 读写 + 旧格式迁移
systems/
  save.lua               ← 本地持久化: File + 校验和
  cloudsync.lua          ← 客户端调度器: 双通道上传/下载
  savesystem.lua         ← 门面: 协调加载/保存/失焦自动保存
  migration.lua           ← 版本迁移: v1→v2→v3 增量升级
  player_data/
    _internal.lua         ← 内存单例 + MarkDirty
    persistence.lua       ← 加载/保存/云同步协调
```
依赖关系(单向,无循环):
SaveSystem → PlayerData/Persistence → CloudSync → ClientNet (延迟require)
                                    → Save (本地)
                                    → Migration
CloudSync 对 ClientNet 使用 pcall(require, ...) 延迟加载,避免启动时循环依赖。
horizontal linehorizontal line

三、核心实现

3.1 常量统一管理(SaveEvents.lua)

客户端和服务端共用同一份 Key 定义,杜绝拼写不一致:
```lua
SaveEvents.CLOUDKEYS = {
    CHARACTER      = "characterv1",      -- 角色存档
    AUDIOSETTINGS = "audiosettingsv1", -- 音频设置(独立Key)
    LEGACY         = "gamesave",         -- 旧版单Key(仅迁移读取)
}
SaveEvents.LBKEYS = {
    LEVEL = "playerlevel",  WAVE  = "highestwave",
    TOWER = "towerfloor",   ARENA = "arenapoints",
    NAME  = "playername",   RESONANCE = "resonance_time",
}
```
设计决策:音频设置独立 Key,因为它修改频率低、体积小,不需要跟着角色存档一起上传。

3.2 本地持久化 + 校验和防损坏(save.lua)

```lua
-- 写入流程: JSON编码 → 计算校验 → 备份旧文件 → 写入新文件
function Save.Write(data)
    data.saveTime = os.time()
    local json = cjson.encode(data)
-- 备份旧存档
if fileSystem:FileExists(SAVE_FILE) then
    fileSystem:Copy(SAVE_FILE, SAVE_BACKUP)
end
-- 附加 FNV-1a 校验和
local checksum = ComputeChecksum(json)
local payload = json .. "\n--CHECKSUM:" .. checksum
local file = File(SAVE_FILE, FILE_WRITE)
file:WriteString(payload)
file:Close()
end
```
校验和算法:FNV-1a 变体 + XOR 混淆。不是加密级安全,但能检测文件意外损坏(写入中断、磁盘错误),不涉及服务端验证:
lua
local function ComputeChecksum(str)
    local hash = 2166136261  -- FNV offset basis
    for i = 1, #str do
        hash = hash ~ string.byte(str, i)
        hash = (hash * 16777619) & 0xFFFFFFFF
    end
    return string.format("%08x", (hash ~ 0x5A3C) & 0xFFFFFFFF)
end
读取时双重容错:主文件损坏 → 读备份文件;校验不过 → 打印警告但仍尝试使用(比完全丢档好)。

3.3 双通道上传调度(cloud_sync.lua 核心)

```lua
function CloudSync.Upload(data, callback)
    -- 前置检查: 角色未创建(playerName空)时不上传
    if not hasValidCharacter(data) then return end
local isForce = (callback ~= nil)
local now = os.time()
-- clientCloud 路径: 15s 节流
if isCloudAvailable() then
    if isForce or (now - lastCloudSaveTime_) >= 15 then
        clientCloud:BatchSet()
            :Set(CK.CHARACTER, charData)
            :Set(CK.AUDIO_SETTINGS, audioData)
            :SetInt(LB.LEVEL, level)   -- 顺带更新排行榜
            :SetInt(LB.WAVE, wave)
            -- ...更多排行榜字段
            :Save("保存进度", { ok=..., error=..., timeout=... })
    end
end
-- serverCloud 路径: 独立于 clientCloud, 2s 节流
CloudSync.UploadToServer(data, isForce)
end
```
关键设计
1. 两条路径完全独立:clientCloud 超时不影响 serverCloud,反之亦然
2. 节流策略不同:serverCloud 2 秒(主通道,WASM 本地不持久),clientCloud 15 秒(平台限制)
3. 排行榜顺带写入:存档上传时 BatchSet 一并写入排行榜数据,省一次网络请求
4. 角色创建前不上传:防止空数据覆盖有效存档

3.4 serverCloud 上传与节流重试

```lua
function CloudSync.UploadToServer(data, force)
    if not hasValidCharacter(data) then return end
    local now = os.time()
-- 节流: 2s 内重复上传 → 暂存到 pendingUploadData_
if not force and (now - lastServerUploadTime_) < 2 then
    pendingUploadData_ = data
    return
end
-- 检查连接状态(延迟require避免循环依赖)
local ok, ClientNet = pcall(require, "network.client_main")
if not ok or not ClientNet.IsConnected() then
    pendingUploadData_ = data  -- 断线暂存, 等重连
    return
end
ClientNet.UploadSaveToServer(data)
lastServerUploadTime_ = now
pendingUploadData_ = nil
end
-- 每帧调用: 节流窗口过后自动重试
function CloudSync.FlushPending()
    if not pendingUploadData_ then return end
    if (os.time() - lastServerUploadTime) >= 2 then
        CloudSync.UploadToServer(pendingUploadData, true)
    end
end
```
被节流的数据不会丢失:暂存到 pendingUploadData_,由 FlushPending()(每帧调用)在窗口过后自动重试。

3.5 服务端接收与写入(server_save.lua)

服务端也有独立的节流,防止恶意客户端高频刷写:
```lua
function ServerSave.HandleUpload(eventData)
    local userId = info.userId
-- 服务端节流: 同一玩家 10s 内不重复写入
if (os.time() - (lastSaveTimes_[userId] or 0)) < 10 then
    -- 返回 Throttled ACK, 客户端不需重试
    connection:SendRemoteEvent(SAVE_ACK, true, { Success=true, Throttled=true })
    return
end
-- 解析 JSON → 写入 serverCloud (角色 + 音频 + 排行榜)
ServerSave.WriteToCloud(userId, saveData, audioData, function(ok, msg)
    lastSaveTimes_[userId] = os.time()
    -- 返回 ACK
    connection:SendRemoteEvent(SAVE_ACK, true, { Success=ok })
end)
end
```

3.6 启动时加载:三路竞速

游戏启动时的数据加载流程:
Start()
  │
  ├─ 1. 同步读本地 File          ──→ localSaveTime
  │
  ├─ 2. 异步拉 clientCloud       ──→ cloudSaveTime      ← 设置 LoadPending 锁
  │
  └─ 3. 异步等 serverCloud 推送  ──→ serverSaveTime     ← 设置 ServerLoadPending 锁
  │
  ▼ 三路数据到齐(或超时)
  比较 saveTime → 取最新的那份
核心比较逻辑(persistence.lua):
```lua
function Persistence.Load(onComplete)
    -- 第一阶段: 同步加载本地
    local localSaved = Save.Read()
    local isNew = restoreFromSaveData(localSaved)
    local localSaveTime = (localSaved and localSaved.saveTime) or 0
-- 第二阶段: 异步拉云端
CloudSync.SetLoadPending(true)
CloudSync.Download(function(cloudResult, isError)
    CloudSync.SetLoadPending(false)
    if cloudResult then
        local useCloud = (cloudResult.saveTime > localSaveTime)
                      or (isNew and cloudHasData)
        if useCloud then
            -- 云端更新 → 覆盖本地
            restoreFromSaveData(cloudResult.character)
            Save.Write(CollectSaveData())  -- 回写本地
        else
            -- 本地更新 → 上传云端
            CloudSync.Upload(CollectSaveData())
        end
    end
    onComplete(isNew)
end)
end
```
超时保护:加载锁有 8-10 秒自动释放,防止云端永远不响应时卡死:
lua
function CloudSync.IsLoadPending()
    if cloudLoadPending_ and (os.time() - cloudLoadStartTime_) > 10 then
        cloudLoadPending_ = false  -- 超时自动释放
    end
    -- serverLoadPending_ 同理, 8 秒超时
    return cloudLoadPending_ or serverLoadPending_
end

3.7 服务端存档下发与冲突解决

玩家连接时,服务端主动推送存档:
lua
-- server_save.lua
function ServerSave.LoadAndSend(userId, connection)
    serverCloud:BatchGet(userId)
        :Key(CK.CHARACTER)
        :Key(CK.AUDIO_SETTINGS)
        :Key(CK.LEGACY)          -- 兼容旧格式
        :Fetch({
            ok = function(scores)
                -- 优先 character_v1, 回退 game_save
                local saveData = scores[CK.CHARACTER] or scores[CK.LEGACY]
                connection:SendRemoteEvent(SAVE_LOAD, true, {
                    SaveJson = cjson.encode(saveData),
                    SaveTime = saveData.saveTime,
                    Migrated = (scores[CK.CHARACTER] == nil),  -- 标记是否旧格式
                })
                -- 旧格式自动迁移到新 Key
                if not scores[CK.CHARACTER] and scores[CK.LEGACY] then
                    ServerSave._migrateToNewKeys(userId, saveData, audioData)
                end
            end,
        })
end
客户端收到后,在 HandleServerSave
中做冲突解决:
```lua
-- 使用服务端的条件:
--   1. 服务端 playerName 非空 (有效角色)
--   2. 服务端时间戳更新 OR 本地为空(WASM丢失)
local useServer = serverHasValidChar
    and ((serverSaveTime > localSaveTime) or localIsNew)
if useServer then
    PlayerData.RestoreFromServerSave(serverSaveData)
elseif localSaveTime > serverSaveTime then
    -- 本地更新 → 反向上传到服务端
    CloudSync.UploadToServer(collectSaveData(), true)
end
```
WASM 场景特殊处理:本地 IndexedDB 被清空时 localIsNew=true,只要服务端有有效数据就直接恢复,不比较时间戳。
horizontal linehorizontal line

四、版本迁移系统

存档结构会随版本迭代变化。我们用类似数据库 migration 的方案:
```lua
-- migration.lua
local CURRENT_VERSION = 3
local migrations = {}
-- v2: 通用碎片 → 按品质分类碎片
migrations[2] = function(data)
    if data.universalFragments and data.universalFragments > 0 then
        data.qualityFragments = data.qualityFragments or { R=0, SR=0, SSR=0, UR=0 }
        local half = math.floor(data.universalFragments / 2)
        data.qualityFragments.R = data.qualityFragments.R + half
        data.qualityFragments.SR = data.qualityFragments.SR + (data.universalFragments - half)
        data.universalFragments = nil
    end
end
-- v3: 音频设置从 player.settings 独立到单独 Key
migrations[3] = function(data)
    if data.settings then
        AudioMgr.MigrateFromPlayerData(data)  -- 迁移到独立文件
        data.settings.musicEnabled = nil       -- 移除冗余字段
        data.settings.sfxEnabled = nil
    end
end
-- 顺序执行: fromVersion+1 到 CURRENTVERSION
function Migration.Run(data, fromVersion)
    for v = (fromVersion or 1) + 1, CURRENTVERSION do
        if migrations[v] then migrationsv end
    end
    return CURRENT_VERSION
end
```
加载时自动迁移:restoreFromSaveData
在恢复数据后立即调用 Migration.Run
lua
local function restoreFromSaveData(saved)
    I.data = DeepMerge(NewData(), saved.player) -- 合并默认值(新增字段自动填充)
    local toVer = Migration.Run(I.data, saved.migrationVersion or 1)
end
DeepMerge 的作用:新版本新增的字段在 NewData()
里有默认值,DeepMerge
把旧存档覆盖上去后,新字段自然保留默认值——不需要为每个新字段写迁移函数。
horizontal linehorizontal line

五、自动保存与失焦保护

5.1 脏标记 + 即时存档

```lua
-- _internal.lua
function M.MarkDirty()
    if not M.dirty then M.dirtyElapsed = 0 end
    M.dirty = true
end
-- persistence.lua (每帧 tick)
function Persistence.AutoSaveTick(dt)
    CloudSync.FlushPending()              -- 先处理被节流的上传
    if CloudSync.IsLoadPending() then return end  -- 加载中不存档
-- 周期性强制云端同步 (30s)
if (os.time() - lastForceCloudTime) >= 30 then
    Persistence.Save(true)
    return
end
-- 脏数据: AUTO_SAVE_DELAY=0, 下一帧立即存
if I.dirty then
    I.dirtyElapsed = I.dirtyElapsed + dt
    if I.dirtyElapsed >= 0 then  -- 实时保存
        Persistence.Save()
    end
end
end
```
AUTO_SAVE_DELAY = 0 意味着数据变动后下一帧就写本地。这在 WASM 环境是必要的——用户随时可能关闭标签页。

5.2 页面失焦强制保存

```lua
-- savesystem.lua
SubscribeToEvent("InputFocus", "HandleInputFocusSaveSystem")
function HandleInputFocus_SaveSystem(eventType, eventData)
    if not eventData["Focus"]:GetBool() then
        -- 失焦(切标签页/最小化) → 强制保存
        SaveSystem.Save(true)  -- force=true, 绕过节流
    end
end
```
horizontal linehorizontal line

六、踩过的坑

#1 WASM 的 os.clock() 不可靠

os.clock() 在 WASM 返回 CPU 时间而非墙钟时间,导致自动存档计时器永远不触发。解决:用Update 的 dt 累加替代 os.clock(),时间戳比较用 os.time()。

#2 空角色覆盖有效存档

玩家打开游戏但还没创角就触发了自动上传,空的 playerName="" 覆盖了服务端的有效存档。解决:上传前检查 hasValidCharacter(),playerName 为空直接跳过。

#3 clientCloud 延迟初始化

TapTap SDK 的 clientCloud 对象不是启动时立即可用,可能在几帧后才初始化完成。如果永久缓存"不可用"的结论,后续所有上传都会跳过。解决:只缓存 true,false 不缓存,每次重新检测。

#4 双锁加载防止提前进入游戏

联网模式下有两个异步加载源(clientCloud + serverCloud),如果只等一个就释放锁,另一个还没到时玩家可能已经进入创角页面。解决:两个独立加载锁,两个都释放后才允许正常存档。

#5 循环依赖

CloudSync 需要调用 ClientNet.UploadSaveToServer(),但 ClientNet 的初始化又依赖 CloudSync。解决:pcall(require, "network.client_main") 延迟加载,调用时才 require。

#6 节流丢数据

serverCloud 2 秒节流期间如果有新的存档请求,直接丢弃会导致最后几秒的改动丢失。解决:节流时暂存到 pendingUploadData_,FlushPending() 每帧检查,窗口过后自动重试。

#7 服务端也要节流

没有服务端节流时,一个频繁操作的客户端每 2 秒就往 serverCloud 写一次,50 个在线玩家 = 每秒 25 次写入。解决:服务端每用户 10 秒节流,返回 Throttled=true ACK,客户端不需重试。
horizontal linehorizontal line

七、完整数据流时序

正常启动

客户端                          服务端
  │                               │
  ├─ Save.Read() 同步读本地        │
  ├─ clientCloud.BatchGet() ─────→│ (TapTap 平台)
  │                               │
  │    ←── SAVE_LOAD 推送 ────────┤ serverCloud.BatchGet(userId)
  │                               │
  ├─ 比较三个 saveTime            │
  ├─ 取最新的覆盖本地              │
  └─ 释放加载锁, 进入游戏          │

正常游戏中

玩家操作 → MarkDirty()
  │
  ├─ 下一帧: Save.Write() 写本地 (即时)
  ├─ 2s 后: UploadToServer() → 服务端写 serverCloud
  └─ 15s 后: clientCloud.BatchSet() → 写 clientCloud + 排行榜

异常恢复

| 场景 | 恢复路径 |
|------|---------|
| WASM 本地存储被清 | serverCloud 推送 → 恢复 |
| TapTap SDK 不可用 | 本地 + serverCloud 双保险 |
| 游戏服务器宕机 | 本地 + clientCloud 双保险 |
| 存档 JSON 损坏 | 备份文件 _backup.json 恢复 |
| 校验和不匹配 | 警告日志 + 仍尝试使用(比丢档好) |
horizontal linehorizontal line

八、可扩展方向

1. 存档加密:当前只有校验和,可升级为 AES 加密存储
2. 多槽位存档:当前单角色,扩展 Key 命名为 character_v1_slot2
3. 存档差异同步:只上传变化的字段,减少带宽(当前是全量覆盖)
4. 存档回滚:serverCloud 保留最近 N 个版本,支持客服回档
5. 离线队列:断网时攒一批操作,重连后批量上传(当前只暂存最后一份)
我也不敢说完全不丢档,我有款游戏使用的正是这套存档系统目前为止还没丢过档。仅供参考,大佬们有任何补充或批量建议请评论区留言。
猜你想搜
taptap 制造本地持久化
我真要崩溃了(T^T)截图
我真要崩溃了(T^T)
[表情_豹哭][表情_豹哭][表情_豹哭]一个简单的排行榜功能,修了我5万积分花了我好几天时间,啥也没干就修这个功能! 反反复复没法正常运作,上线一直显示只有自己,云端接口失败,真的要崩溃了,为什么呀? 尝试了各种方法,全部删除重新构建,也是这样[表情_泪目]现在彻底没有积分了,l3也不知道能不能过。 现在塔啦啦消耗积分也非常多,好难受啊(。•́︿•̀。)真的很影响玩家游玩体验,除了吃饭睡觉其余时
7 赞
9 回复
01:29
官方阅,申请积分计划2,希望官方继续支持我完成这款游戏,截图
官方阅,申请积分计划2,希望官方继续支持我完成这款游戏,
视频内容只是一部分, 本游戏耗时3个月,目前完成进度百分之80, 是生存游戏,进入修仙界,走剧情找到宗门加入, 这时游戏才是真正开始, 提升实力境界后就可以变强,获取功法法术法宝, @视频内是游戏里挑战妖族入侵一种主流玩法,游戏就是围绕着这个来进行, 还有很多地方可以战斗,获得资源修炼变强,比如去藏书阁学习功法, 去法宝楼抽法宝,每一个宗门数十个法宝抽取 去修仙界探索,获得各自的资源,可能遇见妖兽
1 赞
3 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
121 赞
44 回复
本地部署 AI 对接 TapMaker 指南 —— 以 CodeBuddy 为例V1.1截图
本地部署 AI 对接 TapMaker 指南 —— 以 CodeBuddy 为例V1.1
写在前面 大家好,我是橘猫,和你一样也是TapMaker的萌新玩家。在"全能神"天哥的悉心指导下,我顺利完成了本地开发环境的部署。经过这段时间的实战测试,已经成功打通了「本地开发 → 同步至TapMaker」的完整流程。 以下内容全部来自我的个人实操经验,旨在为同样在探索本地部署的朋友提供一个参考。每个人的电脑环境、网络情况、AI工具版本都不尽相同,文中的步骤仅供交流学习。如果你照着操作后遇到问题
31 赞
14 回复
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
23 赞
6 回复
00:45
TapTap制造本地开发模式保姆级教程截图
TapTap制造本地开发模式保姆级教程
1.你可以使用 Codex、Cursor、Claude Code 等支持 MCP 的 AI 工具 最简单的就是腾讯的WorkBuddy 2.去官网下载WorkBuddy 3.对WorkBuddy说:我要安装个Agent 我给你安装方法 一、安装 Maker MCP npx -y @taptap/maker install --ide codex,cursor,claude 部分 Agent 安装
12 赞
8 回复
新人避坑指南第一期(核心必看)截图
新人避坑指南第一期(核心必看)
新手制作游戏项目三大避坑准则 一、前期地图规范要求 开启新项目之后,必须清晰交代需求:明确项目内容、运行环境、角色出生点位置,再交由助手制作第一张地图。 第一张地图禁止直接预览,先将生成内容存入素材库,核对样式是否符合自身预期。 核心判定方法:单独生成第一张地图预览图,第一张地图决定整体全局架构,UI、UX整体风格全部由它锁定。如果第一张地图的画面、UI、UX不符合自己的设想,立刻新建项目,不要在
15 赞
6 回复
00:45
一个在做的小项目截图
一个在做的小项目
这是7月11 创建的项目 内容不是很多,但是因为是菜鸡小白,所以很多东西进度很慢。 开发了7天,进度也仅仅才达到这样。 我是用慢慢推进的方式一点点做的,从开始界面,然后一点点往里面推。 点开的都是开发得差不多了,没点的那就是没做。 战斗场景也没做, 缺的东西很多。 但是都在规划中,我的思路是先做好UI布局,然后再完善内部数值系统。 玩法也是比较常用的刷宝玩法。 刷宝养成类 所以没有太复杂的系统,所
1 赞
5 回复
游戏墨染灵境,续一下计划二被说同质化截图
游戏墨染灵境,续一下计划二被说同质化
玩法如图,灵兽渡劫,灵武渡造(包括可以打造自定义灵武),炼丹引兽,12个挑战(每个boss都有各自特色),墨境轮回肉鸽玩法,基础秘境探索,拍卖玩法(可以抢夺可以尾随),灵田种植,还有异步pvp在线切磋。 真的有同质化吗[表情_生气][表情_生气][表情_生气]
4 赞
7 回复
00:48
我做了一款哥特风像素射击游戏|《失落庭院》实机演示截图
我做了一款哥特风像素射击游戏|《失落庭院》实机演示
序列帧实在是太好丸辣! 一直想做哥特风的游戏哈哈,目前做了三把武器,三个怪物和两个房间,不知道有生之年能不能做完😭
11 赞
6 回复