Godot UI 模态栈与返回键治理:弹窗、侧栏和手柄 B 键要听同一个出口

从 Godot UI 架构出发,设计统一模态栈、返回键处理、焦点恢复和弹窗优先级,避免界面关闭顺序混乱。

返回键混乱是 UI 架构问题,不是按键问题

Godot 项目里的 UI 一多,Esc、手柄 B、Android 返回键、弹窗关闭按钮很容易各走各的。商店弹窗里打开确认框,按 B 关掉了整个商店;设置页改了画质未保存,按 Esc 直接退出;侧栏、Toast、教程遮罩和网络重连弹窗同时出现,谁先关闭没人说得清。统一模态栈的目的,就是让所有临时 UI 都登记到同一个出口。

模态层要有元数据

一个 ModalLayer 不只是节点引用。它至少要记录 layer id、类型、优先级、是否阻挡下层输入、是否可返回关闭、关闭守卫、打开前焦点、关闭结果、来源页面。确认框和加载遮罩都是模态,但确认框可关闭,加载遮罩通常不可关闭;教程遮罩可能吃掉返回键并显示提示;系统级强制更新弹窗优先级最高。

统一返回流程

返回键处理可以收口到 ModalStack:

flowchart TD
    A["Input Back / Esc / B"] --> B["ModalStack"]
    B --> C{"Top Layer can close?"}
    C -- "yes" --> D["Run close guard"]
    D --> E["Pop layer"]
    E --> F["Restore focus"]
    C -- "no" --> G["Show reason or ignore"]
    B --> H["Route to page navigator when stack empty"]
    H --> I["Previous page or pause menu"]

当栈不为空时,只处理栈顶。栈顶允许关闭则执行 close guard,例如未保存设置确认、购买二次确认、匹配中取消确认。关闭后恢复焦点到打开前的 Control。如果栈为空,再把返回交给 PageNavigator:返回上一页、打开暂停菜单或最小化应用。

焦点恢复比关闭更容易被忽略

手柄和键盘 UI 里,关闭弹窗后焦点必须回到合理位置。打开弹窗前记录当前 focus owner,关闭后如果该节点仍然可见且可聚焦,就恢复;如果节点已经消失,交给页面提供 fallback。动态列表和虚拟滚动需要额外处理,记录的不应只是节点路径,还要记录业务 key,例如商品 id、背包槽 id。

关闭守卫要异步友好

有些关闭需要等待:保存设置、取消匹配、提交购买、上传日志。can_close 不能只返回 bool,可以返回允许、拒绝、需要确认、等待异步。等待期间栈顶进入 closing 状态,避免玩家连续按返回触发多次请求。设置保存失败时页面应保留并显示错误,而不是直接关闭。

层级优先级和互斥

不是所有弹窗都能随便叠。购买确认上方可以叠网络等待,但不应该叠运营广告;强制实名弹窗出现时,应关闭或冻结普通页面;教程遮罩可能只允许指定弹窗出现。ModalStack 可以提供 policy:同类是否互斥、是否替换、是否排队、是否拒绝。任何会吃返回键、阻挡下层点击或需要焦点恢复的临时层,都应该登记。

移动端返回键

Android 返回键是高频入口。按返回时,如果有模态层,先关层;如果在二级页面,返回一级;如果在根页面,打开退出确认或暂停菜单。触屏还有遮罩点击关闭。点击遮罩和按返回应该走同一 close reason,但有些层允许返回关闭、不允许遮罩关闭,例如支付确认。元数据里分开写 close_on_back 和 close_on_scrim。

调试面板和日志

UI 栈问题很适合做可视化。开发包里按快捷键显示当前 ModalStack:从底到顶列出 layer id、类型、节点路径、可返回、阻挡输入、焦点来源、打开时间。返回键按下时打印处理者和结果。很多 B 键没反应其实是栈顶有一个透明层挡着,面板一看就明白。

和页面导航的边界

ModalStack 不应替代页面导航。背包页、商店页、任务页之间的切换属于 PageNavigator;确认框、筛选面板、物品详情弹层属于 ModalStack。页面可以打开模态层,但模态层不应直接改变页面栈,除非通过明确事件请求。页面历史和临时层历史混在一起,返回顺序就会失控。

QA 清单

测试模态栈要覆盖:连续弹窗、未保存关闭、异步关闭失败、手柄 B、键盘 Esc、Android 返回、遮罩点击、页面销毁时自动清层、网络断线弹窗压在普通弹窗上、教程遮罩期间返回、焦点恢复到虚拟列表项、语言切换后按钮宽度变化。还要快速连按返回 10 次,系统不应跳过确认或关到空白。

落地建议

先把返回键入口收口,即使还不改所有弹窗,也能减少混乱。新 UI 必须通过 ModalStack 打开临时层,旧 UI 逐步迁移。Godot 的 Control 自由度很高,越自由越需要统一出口。弹窗、侧栏和手柄 B 键听同一个栈,玩家才会觉得界面有记忆、有秩序。

典型事故:透明遮罩吃掉所有输入

有一次设置页偶发无法返回,最后发现是一个下拉菜单关闭后遮罩节点没有 queue_free,只是 alpha 变成 0。它仍然在树上,mouse_filter 还是 stop,也登记在旧页面下。玩家按 B 时页面以为没有弹窗,PageNavigator 想返回;鼠标点击时又被透明遮罩吃掉。没有 ModalStack 面板时,这类问题很难定位。

统一栈之后,每个临时层都有打开时间和 owner。页面销毁时,栈可以自动关闭属于这个页面的层;如果某层节点不可见但仍阻挡输入,开发包直接报警。透明遮罩不是不能用,但它必须被栈管理,而不是散落在页面脚本里。

焦点和鼠标并存

PC 上玩家可能用鼠标打开弹窗,再用手柄关闭;也可能用手柄选中按钮后,鼠标悬停到另一个区域。焦点恢复不能假设只有一种输入方式。可以记录 last_input_source,如果最近是手柄或键盘,关闭后恢复焦点;如果最近是鼠标,关闭后不强行跳焦点,只保证 hover 状态正确。否则鼠标用户会看到焦点框突然跳动。

手柄模式下,模态层打开时应指定默认焦点。确认框默认焦点通常放在取消而不是确认,避免连按误操作;奖励弹窗可以放在领取按钮;危险操作如删除存档必须放在安全按钮。这个默认焦点是 UX 规则,应该由 layer metadata 配置。

异步关闭失败的回滚

匹配中按返回取消匹配,是一个常见异步关闭。玩家按 B,确认取消,客户端发请求。请求成功后关闭等待层和匹配页;请求失败时,匹配页仍应保持,等待层关闭,并提示失败原因。如果代码只是先 pop 页面再发请求,失败后就没有地方恢复状态。

ModalStack 可以支持 close transaction。事务开始后,相关层进入 closing,输入被限制;成功时提交 pop;失败时回滚 closing 状态。这个机制对购买、保存设置、退出副本也适用。返回键不是简单关闭,它经常是一次业务请求。

多层弹窗的视觉规则

栈可以有很多层,但视觉上不应该无限叠。超过两层后,底层可以降低亮度或冻结动效;系统级弹窗出现时,普通弹窗可以暂时隐藏但保持栈状态。遮罩透明度也要按层级控制,不要每层叠 50% 黑,三层之后画面全黑。

可以定义 layer type:popover、panel、dialog、blocking、system。不同类型有不同遮罩、动画和输入策略。这样筛选菜单不会和系统错误弹窗用同一套表现。统一管理不等于所有层看起来一样。

自动化测试

UI 自动化可以模拟打开商店、打开详情、打开购买确认、按三次返回,检查当前页面和焦点。再模拟保存设置失败、网络等待、Android 返回。每个测试结束时断言 ModalStack 为空或只剩预期系统层。这个断言很有价值,能发现残留遮罩和重复层。

Godot 项目不一定有完整 UI 自动化框架,也可以写一个开发场景,按钮触发各种弹窗组合,脚本模拟 ui_cancel。至少在提交新弹窗组件前跑一遍,避免破坏全局返回秩序。

工程边界要写在代码之前

UI 模态栈最怕“先能跑再说”。能跑的脚本往往把弹窗、侧栏、确认框和返回键混在一个节点里,短期看起来省事,后期每个 bug 都要跨 UI、资源、网络和玩法一起查。开工前先写清楚边界:页面导航、临时层、焦点恢复和异步关闭分别由谁负责,谁只读数据,谁可以提交状态变化,谁只能播放表现。边界清楚以后,新增需求通常只是加一个策略或 profile,而不是改一串互相调用的节点。

在 Godot 里,这个边界可以通过 Resource、autoload 服务和场景节点组合表达。Resource 保存可调规则,autoload 提供跨场景的状态和队列,场景节点负责当前画面表现。不要把全局状态藏在某个 UI 控件或临时子节点里。只要场景一切换,这类状态就会丢,问题还很难复现。

失败恢复要比成功路径先评审

成功路径通常很顺:玩家点击、系统执行、界面刷新。真正决定质量的是失败路径。透明遮罩残留、保存失败、手柄 B 连按、Android 返回键这些情况都不是边角料,而是实际测试和上线后最容易出现的问题。每个失败都要回答三个问题:当前状态是否还能继续,是否需要回滚,玩家需要知道什么。没有答案时,就不要把功能当作完成。

失败恢复还要避免二次伤害。比如恢复时又触发一次旧请求,清理时误删仍在使用的资源,回滚时把玩家新操作覆盖。可以给关键操作加 transaction id 或 version,恢复时只处理当前版本。旧回调、旧异步任务、旧动画事件到达时,如果版本不匹配就丢弃。这个小机制能挡住很多偶现问题。

性能预算不能等卡顿后再补

UI 模态栈通常不是单次成本大,而是高频、叠加或峰值明显。预算要写成数字:每帧最多处理多少对象,每次扫描最多多少毫秒,本地队列最多多少条,缓存最多占多少空间,失败重试间隔如何退避。没有数字时,团队会凭感觉加功能,直到某个场景突然掉帧或磁盘暴涨。

预算也要有降级策略。低端设备、后台恢复、弱网、资源不完整时,系统应该知道哪些表现可以降低,哪些规则必须保持。表现层可以降,权威状态不能乱;调试信息可以少,关键错误不能吞;刷新频率可以降,玩家资产和输入边界不能省。预算不是单纯砍功能,而是把优先级提前讲清楚。

团队协作需要工具,而不是口头约定

UI 模态栈经常跨程序、美术、策划、QA 和运营。只靠口头说“这个资源别这么配”“这个按钮别这样关”很快会失效。更可靠的是做小工具:编辑器检查、运行时调试面板、资源报告、状态导出、固定压测场景。工具不一定复杂,但必须让非程序也能看到问题所在。

例如检查器可以扫出缺字段、错误引用、超过预算的资源、不可回滚的状态;调试面板可以显示当前 profile、版本、队列、耗时、错误码;固定测试场景可以一键复现高峰。工具越早出现,团队越容易在内容制作阶段修问题,而不是等集成测试时集中爆炸。

上线验收清单

上线前至少检查这些项:正常路径是否稳定,失败路径是否可恢复,切场景和切后台是否安全,低帧率或弱网下是否有明确降级,日志是否能定位问题,玩家提示是否具体,配置缺失时是否保守,旧版本数据是否兼容,重复操作是否幂等,调试开关是否不会进入正式表现。这个清单看起来普通,但每一项都对应真实线上事故。

还要留一个回看机制。上线后一周看聚合指标和玩家反馈,确认失败率、耗时、回滚次数或异常状态是否在预期内。没有指标的功能,只能等玩家投诉。UI 模态栈做得好,不是玩家会夸它,而是它在复杂场景里安静地工作,不把风险转嫁给玩家。

页面销毁时的栈清理

页面被切走时,它打开的临时层必须一起处理。比如玩家在背包里打开物品详情,此时收到组队邀请并跳到队伍页,如果详情层还留在栈里,下一次按返回可能会尝试关闭一个已经没有 owner 的节点。PageNavigator 在切页面前可以调用 ModalStack.close_by_owner(page_id),允许安全关闭的层直接关闭,不允许关闭的层阻止页面切换或转移到系统层。这个规则要统一,否则每个页面都会写自己的清理逻辑,最终又回到混乱状态。

继续阅读

探索更多技术文章

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

全部文章 返回首页