Godot 离线请求队列:断网时玩家操作不该直接消失

设计 Godot 客户端离线请求队列,处理弱网、重试、幂等、冲突和 UI 可见状态。

移动端和掌机环境里,断网不是异常,而是日常。玩家领取奖励、修改装扮、提交任务、发送邮件附件领取请求时,网络可能正好抖一下。如果客户端把失败直接弹成“请重试”,玩家会觉得操作不可靠;如果客户端盲目本地成功,恢复网络后又可能和服务器状态冲突。离线请求队列的价值,就是在“不能立刻确认”和“不能假装成功”之间建立一条可解释的中间路径。

Godot 项目常常把 HTTP 请求封装成一个 APIClient,但 APIClient 只解决怎么发请求,不解决玩家意图如何保存、重试如何幂等、UI 如何展示等待状态。真正可用的离线队列要记录操作意图、生成幂等键、持久化队列、按策略重试,并在冲突时给出可恢复路径。

项目里的真实问题

一个常见事故是玩家在地铁里完成每日任务并点击领奖。客户端请求超时,UI 弹出失败;玩家又点一次,恢复网络后服务器收到两次领奖请求。另一个场景是玩家离线更换头像,客户端本地显示成功,但服务器拒绝了这个头像资源,重连后头像又变回旧的。玩家不理解发生了什么,客服也很难判断到底有没有发奖。

这些问题说明弱网请求不能只靠重试。每个可延迟提交的操作都需要队列项,队列项需要业务类型、幂等键、创建时间、过期时间、当前状态、失败次数和 UI 摘要。UI 展示的是“等待同步”或“同步失败可重试”,而不是简单成功或失败。

设计目标

  • 意图可持久化:断网、切后台、重启后仍能知道玩家提交过什么操作。
  • 提交可幂等:重复发送不会重复领奖、重复扣费或重复提交状态。
  • 状态可见:UI 能显示等待同步、同步成功、冲突和失败。
  • 冲突可恢复:服务器拒绝或状态变化时,能回滚本地表现或引导玩家选择。

这些目标不是为了把系统做重,而是为了让 Godot 客户端在真实设备、真实网络和真实内容量下仍然可控。很多功能原型只需要一个脚本,但进入发布流程后,必须回答状态从哪里来、失败怎么恢复、UI 如何同步、日志能否说明问题。下面的设计会围绕这些问题展开。

推荐架构

flowchart TD
    A["输入事件/业务意图"] --> B["OfflineRequestQueue"]
    B --> C["意图记录"]
    B --> D["幂等键"]
    B --> E["重试调度"]
    B --> F["冲突处理"]
    C --> H["状态快照"]
    D --> H
    E --> H
    F --> H
    H --> I["UI反馈和日志"]

图里的每个模块都可以按项目规模合并或拆分。小团队可以用一个 Autoload 承担管理器职责,大项目可以拆成服务、Resource 配置和 UI ViewModel。关键是调用方向要稳定:业务层提交意图,管理器判断状态,执行层接触 Godot 节点、资源或网络,最后统一反馈给 UI 和日志。

关键实现细节

队列项记录的是业务意图,不是原始 HTTP 请求。比如“领取任务 123 的奖励”,而不是 /claim URL 加一段 body。业务意图更稳定,后续 API 版本调整时,队列仍可迁移。每个意图生成幂等键,服务端也应识别这个键,确保重复提交不会造成重复效果。
不是所有操作都适合离线队列。领取免费奖励、修改本地偏好、提交非关键统计可以排队;购买、抽卡、消耗货币、排行榜提交通常必须等待确认。客户端要给每类操作配置 offline_policy,而不是统一排队。
队列状态要持久化到 user://,但不能无限增长。每个 item 有过期时间和最大重试次数。过期后进入 failed_expired,UI 可以提示玩家重新操作。对于已经成功的 item,可以保留短时间用于 UI 展示,再清理。
重试调度要退避。网络恢复后不要一口气提交几十个请求,可以按优先级和创建时间分批发送。玩家当前正在看的页面优先,同步不紧急的统计类请求延后。

失败处理和恢复路径

服务器返回冲突时,不要只显示“同步失败”。如果任务奖励已经领取,队列项可以标记为 resolved_by_server;如果头像资源被拒绝,客户端回滚头像并提示原因;如果本地状态过期,需要刷新页面并让玩家重新操作。
队列文件损坏时,客户端要能隔离坏文件并启动。可以把坏队列移动到诊断目录,创建新队列,并提示部分离线操作无法恢复。不要因为队列 JSON 解析失败导致游戏无法进入。
切账号时必须隔离队列。A 账号的离线操作不能在 B 账号登录后继续提交。队列路径或队列项里要包含账号 id 摘要,并在登录切换时暂停旧队列。

GDScript 接口草图

class_name OfflineRequestQueue
extends Node

signal state_changed(snapshot: Dictionary)
signal operation_failed(code: String, detail: Dictionary)

var _version := 0
var _snapshot := {}

func submit(intent: Dictionary) -> void:
    _version += 1
    var token := _version
    _snapshot = {"phase": "pending", "intent": intent}
    emit_signal("state_changed", _snapshot)
    _execute(intent, func(result: Dictionary):
        if token != _version:
            return
        if result.get("ok", false):
            _snapshot = result
            emit_signal("state_changed", _snapshot)
        else:
            emit_signal("operation_failed", result.get("code", "unknown"), result)
    )

func current_snapshot() -> Dictionary:
    return _snapshot.duplicate(true)

这段代码只表达接口边界。真实项目里,intent 可以替换成 typed Resource 或明确的 Dictionary schema,_execute 里也要接入超时、取消和错误码。保留 _version 的原因,是客户端经常出现旧异步结果晚于新操作返回的情况。没有版本保护,UI 快速切换、网络重试和资源加载都会把状态改回旧值。

数据契约和协作接口

队列项 schema 要稳定:idaccount_idintent_typepayloadidempotency_keystatecreated_atexpires_atretry_countlast_error。新增字段要有默认值,旧队列才能迁移。
业务系统只提交 intent,不直接操作队列文件。OfflineRequestQueue 负责持久化、重试和状态广播。UI 订阅队列快照,显示同步徽标、失败提示或重试按钮。
服务端需要配合幂等键。没有服务端幂等,客户端再小心也无法保证重复请求安全。客户端至少要在接口文档里标明哪些 intent 支持离线提交。

分阶段落地

第一阶段只支持低风险操作,例如已获得奖励的确认领取和偏好设置同步。
第二阶段加入持久化、账号隔离、退避重试和 UI 等待状态。
第三阶段接入冲突处理、队列迁移和诊断导出。

自动化验证和人工验收

断网领取奖励,重启游戏,恢复网络,确认只提交一次且 UI 状态正确。
模拟服务器返回已领取、资源拒绝、版本过旧三类冲突。
切换账号后确认旧账号队列不会在新账号下提交。

观测指标

  • 队列长度、待同步时长和重试次数。
  • 各 intent_type 的成功率、冲突率和过期率。
  • 网络恢复后队列清空耗时。
  • 队列文件损坏或迁移失败次数。

指标不必一开始就全部上报。开发包可以展示完整调试面板,内测包采样关键字段,正式包只保留错误码和聚合计数。重要的是每个异常都能留下足够证据,团队能判断它是内容问题、网络问题、平台问题还是客户端状态机问题。

上线前检查清单

  • 每类离线操作有明确 offline_policy。
  • 队列项包含幂等键、账号和过期时间。
  • UI 能显示等待同步、失败和冲突状态。
  • 服务端接口支持幂等或明确禁止排队。
  • 切账号、重启、切后台都有恢复测试。

清单最好能逐步脚本化。不能自动检查的内容,也要明确由谁在什么阶段确认。Godot 项目里的客户端系统经常横跨程序、策划、美术、运营和 QA,如果验收口径只停留在口头,下一次类似问题还会以不同名字回来。

现场演练

现场演练可以在领奖页面打开飞行模式,点击领取,再关闭游戏。重新进入后,页面应显示该奖励等待同步,而不是可再次点击。恢复网络后,队列提交一次,奖励状态变成已领取。如果服务器返回已领取,客户端应把它视为成功收敛,而不是再弹错误。

案例复盘

一次真实演练中,玩家在断网状态下连续领取每日奖励、修改头像、提交任务进度。恢复网络后,领取奖励必须按幂等键收敛成一次成功,头像如果被服务器拒绝要回滚,任务进度如果服务器已经更新则合并为最新状态。这个案例能同时验证队列持久化、重试顺序、冲突处理和 UI 状态。复盘时要重点看玩家是否一直知道当前处于“等待同步”还是“同步失败”。如果 UI 只在角落转圈,没有说明操作是哪一个,队列就还没有真正服务体验。

上线后的维护策略

离线队列上线后,维护重点是 intent 白名单。每次新增接口都要判断它是否允许离线、是否需要幂等、是否有冲突处理。不要让业务为了“体验顺滑”随手把高风险操作放进队列。队列越强,越需要边界。

灰度阶段要有回退开关。回退不是把功能粗暴关闭,而是退回更简单但完整的玩家路径:离线队列可以暂停新入队但继续处理已有队列,改键系统可以回到默认档案,地图标记可以关闭聚合但保留任务目标,邮件可以禁用批量领取但保留单封领取。每个系统上线前都应该写清楚“降级后玩家还能做什么”。

责任边界也要明确。谁维护配置,谁看指标,谁处理内容接入,谁判断是否回滚,都要写在系统说明里。Godot 客户端功能经常横跨多个岗位,如果只有实现者知道细节,后续每次活动、版本或平台接入都会重新踩坑。文档不需要很长,但必须包含接入示例、常见错误和验收步骤。

灰度验收脚本

灰度验收可以设计三组脚本。第一组断网提交,覆盖领取、修改头像、提交任务;第二组重启恢复,确认队列文件能恢复并继续同步;第三组冲突返回,让服务端分别返回已处理、拒绝和版本过旧。每组脚本都要记录队列长度、UI 文案和最终服务器状态。只要其中一项无法解释,就不要扩大灰度。

验收脚本要同时面向人和机器。机器负责断言状态、错误码、数量和耗时;人负责判断文案是否能理解、视觉反馈是否打扰、操作路径是否顺手。很多客户端系统的失败不是“没有执行”,而是“执行了但玩家不知道发生了什么”。因此每个验收步骤都应该包含预期 UI、预期日志和预期状态快照三部分。

灰度结束后要做一次小复盘。指标是否符合预期,玩家是否使用了降级路径,QA 是否发现难以描述的问题,配置是否需要收紧。复盘结论要回写到检查清单里。这样下一批内容或下一次平台接入时,团队不需要重新摸索同一类边界。

小团队接入版本

小团队可以先做内存队列加少量持久化,不必一开始覆盖所有接口。选两个低风险 intent 跑通状态展示和幂等键。等 UI 和服务端都适应这个模型,再扩展到更多弱网操作。

交付边界

交付标准是断网时玩家操作有明确去向,重连后不会重复提交,冲突时能解释发生了什么。离线队列不应该让所有操作都假装成功,它的价值是让不确定状态变得可见、可恢复。

结语

Godot 客户端面对弱网时,不能只在 APIClient 里加重试。玩家提交的是业务意图,意图需要记录、持久化、幂等和冲突处理。离线请求队列做好后,断网不再等于操作消失,重连也不再等于状态混乱。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页