从分辨率设置出发,设计一个可扩展的 Godot 设置系统
在做游戏设置界面时,很多人一开始会把问题理解成“做一个下拉框就行了”。
但实际开发后很快会发现,设置系统远不止一个下拉框:
- 需要有设置面板的打开和关闭
- 需要在不同页签之间切换
- 需要把分辨率、显示模式等配置动态加载到 UI
- 需要在点击保存后立即生效
- 需要把配置保存到本地,保证下次启动仍然有效
- 还要考虑未来继续扩展音频、按键设置、语言切换等功能
如果一开始只是把逻辑零散写在几个按钮回调里,后面几乎一定会变乱。 更稳妥的做法,是把它当成一个小型的“设置系统”来设计。
这篇文章就以 分辨率设置 为例,讲清楚一套可扩展的实现思路。
一、先明确这个设置系统到底要解决什么问题
以分辨率为例,用户的完整操作链路其实是这样的:
- 在主菜单点击“设置”
- 弹出设置窗口
- 切换到“图像”页签
- 在分辨率下拉框里选择一个值,比如 1920x1080
- 点击“保存”
- 游戏窗口立即调整到对应分辨率
- 该设置写入本地配置
- 下次启动游戏时自动恢复
看起来只是改一个数字,实际上涉及四层职责:
- 主菜单层:负责打开设置界面
- 设置界面层:负责切页、显示当前值、收集用户改动
- 设置管理层:负责读取、保存、应用设置
- 系统接口层:真正调用 DisplayServer 去修改窗口
这四层如果不分开,设置一多就会互相缠在一起。
二、为什么设置界面本身适合用“状态机”来设计
这里说的“状态机”,不一定是很复杂的战斗 AI 状态机。 对于设置界面来说,最简单、最实用的状态机,就是:
- 当前处于音频页
- 当前处于图像页
- 当前处于按键设置页
也就是说,设置界面在任意时刻只处于一个明确状态。
用代码表示就是:
const TAB_AUDIO := 0const TAB_VIDEO := 1const 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 resizedEmbedded window can't be moved这不是设置系统失败,而是当前运行方式不允许真实修改窗口。
所以如果要测试分辨率是否真的变化,应该:
- 用独立窗口运行游戏
- 不要只看编辑器里的嵌入预览区域
这一点在写博客时一定值得强调。 否则很多读者会以为代码有问题,实际上只是测试环境不对。
八、为什么这里要强调“状态机”而不是只谈配置保存
如果只从功能角度看,分辨率设置好像只是:
- 列表
- 保存
- 生效
但真正把系统做稳之后会发现,设置界面的复杂度不是来自“某一个设置项”,而是来自“整个界面如何组织”。
这也是状态机的重要性所在。
例如当前这个设置界面的状态机非常简单:
TAB_AUDIOTAB_VIDEOTAB_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()写入本地文件- 下次启动游戏时自动恢复该配置
如果用一句话概括,这条链路就是:
用户在界面上的选择,经过状态机管理和配置管理器,最终转化为系统级窗口行为。
十二、结语
分辨率设置看起来只是一个很小的功能,但它非常适合作为设置系统设计的切入口。 因为它刚好能把几个关键问题串在一起:
- 设置界面的状态切换
- 动态列表的加载与回显
- 配置数据的统一管理
- 系统接口的真实应用
- 本地配置的持久化
评论