从分辨率设置出发,设计一个可扩展的 Godot 设置系统

在做游戏设置界面时,很多人一开始会把问题理解成“做一个下拉框就行了”。

但实际开发后很快会发现,设置系统远不止一个下拉框:

  • 需要有设置面板的打开和关闭
  • 需要在不同页签之间切换
  • 需要把分辨率、显示模式等配置动态加载到 UI
  • 需要在点击保存后立即生效
  • 需要把配置保存到本地,保证下次启动仍然有效
  • 还要考虑未来继续扩展音频、按键设置、语言切换等功能

如果一开始只是把逻辑零散写在几个按钮回调里,后面几乎一定会变乱。 更稳妥的做法,是把它当成一个小型的“设置系统”来设计。

这篇文章就以 分辨率设置 为例,讲清楚一套可扩展的实现思路。

一、先明确这个设置系统到底要解决什么问题

以分辨率为例,用户的完整操作链路其实是这样的:

  • 在主菜单点击“设置”
  • 弹出设置窗口
  • 切换到“图像”页签
  • 在分辨率下拉框里选择一个值,比如 1920x1080
  • 点击“保存”
  • 游戏窗口立即调整到对应分辨率
  • 该设置写入本地配置
  • 下次启动游戏时自动恢复

看起来只是改一个数字,实际上涉及四层职责:

  • 主菜单层:负责打开设置界面
  • 设置界面层:负责切页、显示当前值、收集用户改动
  • 设置管理层:负责读取、保存、应用设置
  • 系统接口层:真正调用 DisplayServer 去修改窗口

这四层如果不分开,设置一多就会互相缠在一起。

二、为什么设置界面本身适合用“状态机”来设计

这里说的“状态机”,不一定是很复杂的战斗 AI 状态机。 对于设置界面来说,最简单、最实用的状态机,就是:

  • 当前处于音频页
  • 当前处于图像页
  • 当前处于按键设置页

也就是说,设置界面在任意时刻只处于一个明确状态。

用代码表示就是:

const TAB_AUDIO := 0
const TAB_VIDEO := 1
const TAB_KEYBIND := 2

然后通过统一的方法切换状态:

func _switch_tab(tab_index: int) -> void:
page_audio.visible = tab_index == TAB_AUDIO
page_video.visible = tab_index == TAB_VIDEO
page_keybind.visible = tab_index == TAB_KEYBIND

这就是一个非常轻量但很实用的状态机。

它的好处在于,后续无论你增加多少设置项,都不会影响“当前页签是什么”这个核心控制逻辑。 比如你以后加:

  • 语言页
  • 游戏玩法页
  • 辅助功能页

也只是继续扩展状态枚举,而不是把整套 UI 切换逻辑推倒重来。

三、主菜单层:只负责打开设置,不负责处理设置内容

一个常见错误,是在主菜单脚本里直接写设置逻辑。 比如点“设置”按钮时,就开始处理分辨率、音量、显示模式。 这会让主菜单脚本变得越来越臃肿。

更合理的职责划分是:

  • main_menu.gd 只负责按钮绑定和打开设置弹窗
  • settings_popup.gd 只负责设置界面的展示和交互
  • SettingsManager.gd 只负责配置数据和应用逻辑

例如主菜单里只需要这样:

func _on_btn_settings_pressed() -> void:
if settings_popup and settings_popup.has_method("open_popup"):
settings_popup.open_popup()

这里的重点是: 主菜单不处理分辨率本身,它只负责把用户带到“设置界面”。 这种设计在项目变大之后会非常省心。

四、设置界面的核心:状态切换 + 动态加载 + 回填当前值

设置界面最关键的,不是“画了几个按钮”,而是这三个能力:

  • 状态切换
  • 动态加载选项
  • 根据当前配置回填显示

以分辨率为例,完整逻辑分成三步最清晰。

1. 动态加载分辨率列表

先定义一组常见分辨率:

const RESOLUTION_LIST := [
"1280x720",
"1366x768",
"1600x900",
"1920x1080",
"2560x1440"
]

然后在设置界面初始化时,把这组数据写进 OptionButton:

func _setup_resolution_options() -> void:
opt_resolution.clear()
for item in RESOLUTION_LIST:
opt_resolution.add_item(item)

这里有两个细节很重要。

第一,先 clear()。 这能避免界面反复初始化时出现重复选项。

第二,列表数据不要写死在编辑器里。 代码驱动的好处是后续扩展非常方便,比如:

  • 不同平台加载不同分辨率列表
  • 根据当前显示器能力过滤掉不支持的项
  • 统一复用同一组配置

2. 打开设置界面时,回填当前保存的分辨率

用户不是每次都从默认值开始。 如果上次已经保存过 1920x1080,再次打开设置界面时,就应该自动选中这一项。

这一步通常分为两层:

  • 先从设置管理器里读取当前值:
func _load_current_resolution() -> void:
var current_resolution: String = str(
SettingsManager.get_value("display", "resolution", "1600x900"))
_select_resolution(current_resolution)

然后根据字符串选中对应项:

func _select_resolution(target_text: String) -> void:
for i in range(opt_resolution.item_count):
if opt_resolution.get_item_text(i) == target_text:
opt_resolution.select(i)
return
opt_resolution.select(0)

这一步很关键。 很多初学者只会把列表显示出来,却忘了“当前状态回显”,导致设置界面每次打开都像默认状态,体验会很差。

3. 点击保存时,把用户选择写回配置

这一步是设置真正生效的起点。

func _on_save_pressed() -> void:
var selected_resolution: String = opt_resolution.get_item_text(opt_resolution.selected)
SettingsManager.set_value("display", "resolution", selected_resolution)
SettingsManager.apply_all()
SettingsManager.save_settings()
close_popup()

这几行代码背后其实对应了一个很清晰的数据流:

  • 从 UI 组件里取到用户选中的值
  • 写入配置管理器
  • 立即应用到当前游戏窗口
  • 保存到本地文件
  • 关闭设置界面

只要这条链路打通,分辨率设置就不再只是“显示在界面上”,而是真正变成了一个完整功能。

五、显示模式为什么比单纯分辨率更适合用“显示文本 + 实际值”设计

分辨率用字符串数组就够了,但显示模式更适合用“文本和值分离”的方式。 原因很简单:

  • 给玩家显示的是中文:窗口 / 无边框 / 全屏
  • 实际逻辑里保存和判断的是英文值:windowed / borderless / fullscreen

所以用下面这种结构更合理:

const DISPLAY_MODE_LIST := [
{"text": "窗口", "value": "windowed"},
{"text": "无边框", "value": "borderless"},
{"text": "全屏", "value": "fullscreen"}
]

初始化下拉框时,给用户看到的是 text:

func _setup_display_mode_options() -> void:
opt_display_mode.clear()
for item in DISPLAY_MODE_LIST:
opt_display_mode.add_item(item["text"])

但真正保存时,用的是 value:

func _get_selected_display_mode_value() -> String:
var index: int = opt_display_mode.selected
if index < 0 or index >= DISPLAY_MODE_LIST.size():
return "windowed"
return DISPLAY_MODE_LIST[index]["value"]

这种方式非常通用。 以后你做语言设置、画质设置、难度设置,几乎都适合沿用这套设计。

六、SettingsManager:配置系统的真正核心

如果说设置界面是用户看到的部分,那么 SettingsManager 就是整套设置系统的中枢。

它至少要承担四个职责:

  • 提供默认值
  • 读取本地配置
  • 保存本地配置
  • 把配置应用到游戏运行环境

一个简化后的结构大致是这样:

const DEFAULTS := {
"audio": {
"master_volume": 0.8,
"music_volume": 0.7,
"sfx_volume": 0.8
},
"display": {
"resolution": "1600x900",
"display_mode": "windowed"
},
"keybind": {}
}

这里的好处是:

  • 配置有统一入口
  • 默认值集中管理
  • UI 层不需要知道配置文件细节

后续扩展时不容易乱

1. 读取和保存配置

通常可以直接用 ConfigFile:

func load_settings() -> void:
data = DEFAULTS.duplicate(true)
var cfg := ConfigFile.new()
var err := cfg.load(SAVE_PATH)
if err != OK:
return
data["display"]["resolution"] = cfg.get_value("display", "resolution", DEFAULTS["display"]["resolution"])
data["display"]["display_mode"] = cfg.get_value("display", "display_mode", DEFAULTS["display"]["display_mode"])

保存时则是反向写回:

func save_settings() -> void:
var cfg := ConfigFile.new()
for section in data.keys():
for key in data[section].keys():
cfg.set_value(section, key, data[section][key])
cfg.save(SAVE_PATH)

这里其实实现了一个重要能力:

设置界面只是操作内存中的配置,真正的持久化由 SettingsManager 统一负责。

这样后面无论有多少设置项,保存逻辑都不用散落在各个 UI 脚本里。

2. 应用分辨率设置

真正让分辨率变化的,不是 OptionButton,也不是“保存按钮”,而是 SettingsManager._apply_display() 里对 DisplayServer 的调用。

例如:

func _apply_display() -> void:
var resolution := String(data["display"].get("resolution", DEFAULTS["display"]["resolution"]))
var display_mode := String(data["display"].get("display_mode", DEFAULTS["display"]["display_mode"]))
var size := _parse_resolution(resolution)
match display_mode:
"fullscreen":
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, false)
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
"borderless":
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
DisplayServer.window_set_size(size)
_center_window()
_:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, false)
DisplayServer.window_set_size(size)
_center_window()

这里值得注意的是:

resolution 是字符串,比如 “1920x1080”

需要先解析成 Vector2i

然后再通过 DisplayServer.window_set_size() 作用到窗口上

也就是说,设置的真实变化路径是:

UI 选择 → 配置更新 → 解析配置 → 调用系统接口

七、为什么有时候“保存了分辨率”但窗口没变

这是很多人第一次做分辨率设置时最困惑的地方。

最常见的原因不是代码错了,而是运行环境不对。

如果游戏运行在 Godot 编辑器的嵌入式窗口里,窗口大小和位置通常不能像独立窗口那样自由改变。 这时你可能会看到类似提示:

Embedded window can't be resized
Embedded window can't be moved

这不是设置系统失败,而是当前运行方式不允许真实修改窗口。

所以如果要测试分辨率是否真的变化,应该:

  • 用独立窗口运行游戏
  • 不要只看编辑器里的嵌入预览区域

这一点在写博客时一定值得强调。 否则很多读者会以为代码有问题,实际上只是测试环境不对。

八、为什么这里要强调“状态机”而不是只谈配置保存

如果只从功能角度看,分辨率设置好像只是:

  • 列表
  • 保存
  • 生效

但真正把系统做稳之后会发现,设置界面的复杂度不是来自“某一个设置项”,而是来自“整个界面如何组织”。

这也是状态机的重要性所在。

例如当前这个设置界面的状态机非常简单:

TAB_AUDIO
TAB_VIDEO
TAB_KEYBIND

但它已经明确保证了:

  • 当前只显示一个页面
  • 页签切换逻辑有唯一入口
  • 不同设置项之间不会互相干扰

一旦以后你继续扩展:

  • 语言
  • 辅助功能
  • 游戏玩法
  • 控制器设置

这种设计会比“直接堆 UI”稳得多。

所以这篇文章虽然以分辨率为例,但本质上讲的是:

如何用状态机思想组织一个设置系统。

九、这套设计后续还能扩展到哪些地方

分辨率只是最容易理解的入口。 一旦这个框架搭起来,后续很多功能都能自然接进去。

1. 音频设置

例如:

  • 总音量
  • 音乐音量
  • 音效音量

这些设置与分辨率类似,都是:

  • UI 取值
  • 写入 SettingsManager
  • 立即应用
  • 保存配置

不同点只是应用逻辑换成了 AudioServer

2. 显示设置

例如:

  • 显示模式
  • 垂直同步
  • 帧率上限
  • 画质档位
  • 阴影开关

其中很多都适合“文本和值分离”的列表设计。

3. 输入设置

例如:

  • 键位绑定
  • 手柄按键映射
  • 鼠标灵敏度

这时 keybind 字段就会真正派上用场。

4. 语言与本地化

例如:

  • 简体中文
  • English
  • 日本語

这类设置也很适合做成动态列表,而且通常同样需要保存和回显。

5. 游戏玩法偏好

例如:

  • 自动跳过已读对话
  • 卡牌悬停放大
  • 结束回合二次确认
  • 战斗动画速度

这类配置往往不直接调用系统接口,但一样适合纳入统一设置管理。

十、这套设计真正有价值的地方

很多教程会教“怎么把一个下拉框做出来”,但项目里真正难的从来不是下拉框本身,而是:

  • 怎样让 UI 和数据解耦
  • 怎样让设置变更立即生效
  • 怎样让配置持久化

怎样在后续扩展时不推翻原有结构

这也是为什么“状态机 + 设置管理器 + 动态加载列表”这三件事要一起看。

它们分别解决的是:

  • 状态机:当前页面显示什么
  • 动态加载列表:当前页面里有哪些可选项
  • 设置管理器:用户的选择怎么保存、怎么生效

一旦这三层理顺,设置系统就不再是临时拼出来的按钮集合,而是一个真正可维护的模块。

十一、完整的数据流回顾

最后用“修改分辨率”为例,把整条链路再串一次:

  • 用户点击主菜单“设置”
  • 主菜单打开 SettingsPopup
  • 设置界面进入图像页这个状态
  • OptionButton 动态加载分辨率列表
  • 根据当前配置自动选中已保存的分辨率
  • 用户选择新的分辨率
  • 点击保存
  • settings_popup.gd 读取当前选项
  • 调用 SettingsManager.set_value()
  • SettingsManager.apply_all() 执行 _apply_display()
  • _apply_display() 解析字符串并调用 DisplayServer.window_set_size()
  • SettingsManager.save_settings() 写入本地文件
  • 下次启动游戏时自动恢复该配置

如果用一句话概括,这条链路就是:

用户在界面上的选择,经过状态机管理和配置管理器,最终转化为系统级窗口行为。

十二、结语

分辨率设置看起来只是一个很小的功能,但它非常适合作为设置系统设计的切入口。 因为它刚好能把几个关键问题串在一起:

  • 设置界面的状态切换
  • 动态列表的加载与回显
  • 配置数据的统一管理
  • 系统接口的真实应用
  • 本地配置的持久化