UI:皮、总表、左右选
Sep 10
贪吃蛇没有游戏画面要画(纯色方块就是它的经典视觉语言),美术的功夫全花在 UI 上——这反而最值得练:UI 是每个游戏都要的,游戏画面每个游戏都不一样。 而且这一章一张图都不用找:圆角、边框、渐变全是 Godot 自己画的。
写法约定:菜单路径写中文;属性名照检查器写英文,括号里是中文意思。
皮:StyleBoxFlat
控件的"皮"叫 StyleBox。Godot 画按钮时先画皮,再在皮上写字。StyleBoxFlat 是纯几何画的皮,StyleBoxTexture 是用图片当皮。
文件系统 → theme/ 右键 → 新建资源 → 搜 StyleBoxFlat(要点一下让它高亮,只搜不点建出来的是空白 Resource)→ btn_normal.tres:
| 检查器里的名字 | 中文 | 填 |
|---|---|---|
| Draw Center | 画填充 | 取消(空心) |
| Border Width | 边框粗细 | 四边 2 |
| Border Color | 边框色 | #3a4064 |
| Corner Radius | 圆角 | 四角 12 |
| Content Margins | 内边距(文字离框多远) | 左右 24,上下 12 |
| Expand Margins | 外扩 | 全部保持 0 |
Content Margins 和 Expand Margins 长得像,意思相反:Content 是内边距,框会变大;Expand 是把皮画到框外面,框不变、画面溢出、点击区比看到的小、相邻按钮互相压。我第一次填错了这一排,然后发现"按钮间隔比设的小",把容器间距调到 50 去补——补错了地方。
然后右键复制出其余的皮:btn_hover(边框换强调色)、btn_pressed(实心强调色,无边)、btn_hover_pressed(实心再亮一点)、focus(空心,白边 3)。为什么没选中是空心、选中是实心?人眼对"有没有填充"比"填的什么颜色"敏感得多。两态都实心只换底色,反差永远不够。
按钮的 11 个槽
选一个按钮 → 检查器 → Theme Overrides(主题覆盖)→ Styles,11 个槽各是什么:
| 槽 | 什么时候显示 | 放哪张皮 |
|---|---|---|
| Normal | 平时 | btn_normal |
| Hover | 鼠标停在上面 | btn_hover |
| Pressed | 鼠标按住;开关按钮被选中时一直是它 | btn_pressed |
| Hover Pressed | 已选中的按钮被鼠标停住 | btn_hover_pressed |
| Focus | 手柄 / 键盘选到它,叠加在上面四种之上 | focus |
| Disabled、5 个 * Mirrored | 禁用;从右往左的语言 | 不用 |
两个细节:Godot 没有单独的"选中"槽,开关按钮被按下后就一直处于"按下"状态,所以选中态就是 Pressed。Focus 不算一种状态——一个选中的按钮同时有焦点,画的是 Pressed 皮 + Focus 白边,所以 focus.tres 要"不画中心、只画白边",才能叠在任何状态上。

总表:Theme
覆盖只属于那一个节点。菜单、暂停面板、结算面板几十个控件一个个贴?Theme 是一张"类型 → 样式"的总表:告诉 Godot"凡是 Button 都长这样",全项目一次生效。
theme/ 右键 → 新建资源 → Theme → main.tres → 双击,底部出现主题编辑器 → 「+」添加类型 → Button → 每一项旁边点「+」才算加进总表:Styles 五个槽拖对应的 .tres,Colors 四个字色,Font Sizes。再加 Label、HBoxContainer / VBoxContainer 的 separation。选中 main.tres 本身,检查器里 Default Font 拖入字体文件——所有控件从此都用它。
最后 项目设置 → GUI → Theme → Custom 填这个文件。为什么装在项目设置里,不是拖到根节点上?Theme 沿 Control 父子链往下传,但 UI 挂在 CanvasLayer 下,CanvasLayer 不是 Control,链在这断了。项目级主题不走链,直接生效。
回到那个按钮,把 Theme Overrides 全部右键恢复默认——它长得和刚才一模一样,只是皮来自总表了。F5,暂停面板和结算面板的按钮一个字没改就换了皮。
想要第二种长相(主按钮)?用类型变体(Type Variation):主题编辑器里手动输入一个类型名如 PrimaryButton、基类型选 Button、换几张皮,然后在那个节点的 Theme → Type Variation 填名字。同一类控件的第二种长相 → 变体;只有这一个节点不一样 → 覆盖。
左右选:自己封装一个组件
菜单里我不想用下拉(OptionButton 点开的列表是独立的 PopupMenu 窗口,字号改不动、手柄还要多按一层),要的是难度选择那种 ◀ 撞墙模式 ▶——游戏里最手柄友好的选项形式:一个焦点,左右键切值。Godot 没有文字版的左右选(SpinBox 只能选数字),所以自己拼:
OptionSelector (Button) ← 根:能拿焦点、自动穿 Theme 的皮;Text 留空
└── Row (HBoxContainer) ← Anchors Preset = Full Rect;Mouse Filter = Pass
├── LeftArrow (Button) ← ◀;Flat;Focus Mode = None
├── Value (Label) ← 居中;Fill + Expand
└── RightArrow (Button) ← ▶;Flat;Focus Mode = None
根用 Button,因为它能拿焦点、自动有 Theme 的悬停色和焦点白边。两个箭头也是 Button,但 Focus Mode = None:能点,拿不到焦点——手柄玩家看到的永远是"整个选项框亮着",而不是焦点在三个小东西之间跳。
extends Button
## 左右选:◀ 值 ▶。手柄左右 / 点箭头切换;按 A 或点中间 = 下一个
signal changed(index: int)
@export var options: Array[String] = ["选项 A", "选项 B"]
@export var index := 0
var _tween: Tween
func _ready() -> void:
text = "" # 根按钮不显示字,字由 Value 显示
%LeftArrow.pressed.connect(_step.bind(-1))
%RightArrow.pressed.connect(_step.bind(1))
pressed.connect(_step.bind(1)) # 点中间 / 手柄 A = 下一个
%Value.text = options[index]
func _gui_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_left"):
_step(-1)
accept_event() # 吃掉事件,不然焦点会跳到左边的控件
elif event.is_action_pressed("ui_right"):
_step(1)
accept_event()
## 往某个方向走一步;到头了绕回去
func _step(dir: int) -> void:
index = wrapi(index + dir, 0, options.size())
%Value.text = options[index]
changed.emit(index)
_nudge(dir)
## 文字往切换方向弹一下再回来
func _nudge(dir: int) -> void:
if _tween:
_tween.kill()
%Value.offset_transform_enabled = true
%Value.offset_transform_position.x = 16 * dir
_tween = create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
_tween.tween_property(%Value, "offset_transform_position:x", 0.0, 0.18)
_gui_input:只有拿到焦点的控件才会收到键盘 / 手柄事件——和_input的"全世界都听"不同accept_event():宣布"这个事件我处理完了"。不写,Godot 接着拿ui_left去做焦点导航——你按一下左,值切了、焦点也飞了wrapi(x, 0, n):整数绕回,最后一个再往右 = 第一个。和第 7 章穿墙的posmod是同一个思路offset_transform_*:Godot 4.7 新增的 Control 属性,视觉上挪、转、缩,布局不受影响(以前容器一重排就把你改的 scale 抹掉)。默认绕中心、默认只改画面不改鼠标判定——和 CSS 的transform一个意思
菜单里放一个实例,检查器 Options 填 ["撞墙模式", "穿墙模式"],menu.gd 缩成:
func _ready() -> void:
%ModeSelector.grab_focus()
func _on_start_button_pressed() -> void:
GameManager.wrap_mode = %ModeSelector.index == 1
get_tree().change_scene_to_file("res://scenes/main.tscn")
前端把它叫组件,Godot 把它叫场景,一回事。你的组件库就是 scenes/ui/ 这个文件夹,它会原样搬进下一个游戏。

UI 重搭之后,回去把第 9 章的五条再跑一遍——焦点最容易丢。