UI、音频与游戏流程
给太空射击加上"游戏外壳":用 Control、锚点和容器搭界面,用 Theme 和主题变体统一风格,让键盘和手柄都能操作菜单;用自动加载管理标题、游戏、暂停、结算几个状态,用 get_tree().paused 和 process_mode 实现暂停;建立音频总线和效果器,用 Python 程序化生成音效,处理 Web 上的自动播放限制,最后把最高分存进 user://。
上一章的射击游戏只有"玩"的部分。一个完整的小游戏还需要标题界面、暂停菜单、结算画面、音量设置、存档,以及声音。这些东西不难,但细节多:界面要在不同窗口尺寸下都排得整齐,菜单要能只用手柄操作,暂停时哪些东西该停、哪些不该停,浏览器为什么不让游戏出声……这一章逐个讲清楚,用的还是同一个示例。
动手试试:点击运行后,先用 ↓ ↑ 在标题菜单里移动,注意每移动一次都有"嘀"的提示音、焦点框是粉色描边。选"音量设置"进去,用 ← → 调音量,Esc 返回。开始游戏后按 Esc 暂停:背景音乐会变闷、变小,敌人和子弹全部定住。
1. Control 节点与锚点
前面的游戏物体都是 Node2D:用 position 摆在世界坐标里。界面用的是另一套节点——Control(按钮、标签、面板、滑块……都继承自它)。Control 关心的不是"我在哪个坐标",而是"我相对父控件的哪条边",这样窗口变大变小时界面能自动跟着调整。
描述这件事的是四个锚点(anchor)和四个偏移(offset):
anchor_left / top / right / bottom:取值 0~1,表示父控件宽(或高)的比例。0 是左(上)边,1 是右(下)边,0.5 是中线。offset_left / top / right / bottom:从对应锚点出发再偏移多少像素。
控件四条边的位置就是:
左边 = anchor_left × 父宽 + offset_left 右边 = anchor_right × 父宽 + offset_right
(上下同理,用父高)
所以两个锚点相同(比如都是 0)时,控件的宽度固定为 offset_right − offset_left,它"钉"在某个位置;两个锚点不同(比如左 0 右 1)时,控件的宽度随父控件拉伸。编辑器顶部的"锚点预设"(左上、顶部宽、居中、全矩形……)就是这几个数的常用组合。
示例 HUD 的右侧面板就是"右侧竖条":左右锚点都在 1(右边),上下锚点 0 和 1(从顶到底),offset_left = -180 让它从右边往左伸出 180 像素。不管窗口多宽,它都贴着右边、宽 180、高度铺满:
[node name="RightPanel" type="PanelContainer" parent="."]
anchors_preset = 11
anchor_left = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -180.0
grow_horizontal = 0
grow_vertical = 2
theme_type_variation = &"HudPanel"示例的项目设置是 window/stretch/mode = "canvas_items"、aspect = "keep":游戏按 960×540 设计,窗口变化时整体缩放、保持比例,多出来的部分留黑边。在这种模式下界面的"逻辑尺寸"始终是 960×540,锚点的作用主要体现在编辑器里改设计分辨率时。如果改成 aspect = "expand",逻辑尺寸会随窗口比例变化,锚点就真正决定了界面在宽屏和窄屏上的样子——上面的小部件演示的就是这种情况。
2. 容器:让排版自己算
如果每个按钮都手动设锚点和偏移,加一个按钮就要挪一遍所有坐标。容器(Container)接管了子控件的位置和大小:子控件放进去以后,自己的锚点和偏移就失效了,由容器按规则排列。常用的几种:
| 容器 | 做什么 | 示例里用在 |
|---|---|---|
VBoxContainer / HBoxContainer | 子控件竖着 / 横着排成一列,间距由主题常量 separation 决定 | 菜单按钮、HUD 面板里的一行行数据 |
MarginContainer | 给唯一的子控件四周留边距 | 标题界面最外层(左右 64、上 48、下 40) |
CenterContainer | 把子控件放在正中间,保持它的最小尺寸 | 暂停菜单、结算面板 |
PanelContainer | 画一个背景面板(主题里的 panel 样式),子控件放在面板内边距里 | 所有带边框的面板 |
GridContainer | 按列数排成网格 | 音量设置(标签 / 滑块 / 数值三列)、结算的成绩表 |
子控件在容器里占多大,由它的 Size Flags 决定:size_flags_horizontal = 3(填充 + 扩展)表示"把剩余空间都要过来",0 表示"只要最小尺寸,靠左(上)"。标题界面的结构是:
Title (Control,全矩形锚点)
├─ Starfield、WorldEnvironment
└─ Margin (MarginContainer,全矩形)
└─ HBox (HBoxContainer)
├─ Left (VBoxContainer,size_flags_horizontal = 扩展)
│ ├─ Logo、Sub (Label)
│ ├─ Menu (VBoxContainer:四个 Button)
│ ├─ SettingsPanel (默认隐藏,和 Menu 互相切换)
│ ├─ Spacer (Control,size_flags_vertical = 扩展 → 把 Hint 推到底部)
│ └─ Hint
└─ Right (PanelContainer,最小宽 300):最高分列表 + 操作说明
没有一个坐标是手写的。一个常用技巧是"弹簧":放一个空的 Control,让它在容器方向上扩展,就能把后面的控件推到另一头(上面的 Spacer)。
容器里的子控件别手动设位置:在编辑器里拖动容器内的按钮,或者在代码里改 position/size,下一次容器重新排版(窗口变化、内容变化)就会被覆盖掉。想调整就改容器的参数、子控件的 Size Flags 或 custom_minimum_size。另外,Godot 3 里叫 margin_left 的属性在 4.x 改名为 offset_left,rect_position/rect_size 也变成了 position/size。
3. Theme:统一风格
界面上几十个按钮和标签,如果每个都单独设颜色、字号、边框,改一次配色就要改几十处。Theme 是一份"样式表":按控件类型登记颜色(colors)、常量(constants)、字体(fonts)、字号(font_sizes)、图标(icons)和样式盒(styles)。一个控件找样式的顺序是:自己的 theme_override_* → 最近的祖先设置的 theme → 项目设置里的全局主题(gui/theme/custom)→ 引擎默认主题。
示例的全局主题是 ui/theme.tres。按钮的五种状态各有一个 StyleBoxFlat:
[resource]
default_font = ExtResource("font")
default_font_size = 18
Button/colors/font_color = Color(0.82, 0.9, 1, 1)
Button/colors/font_hover_color = Color(1, 1, 1, 1)
Button/colors/font_pressed_color = Color(0.02, 0.05, 0.1, 1)
Button/colors/font_focus_color = Color(1, 1, 1, 1)
Button/colors/font_hover_pressed_color = Color(0.02, 0.05, 0.1, 1)
Button/colors/font_disabled_color = Color(0.4, 0.45, 0.55, 1)
Button/font_sizes/font_size = 20
Button/styles/normal = SubResource("btn_normal")
Button/styles/hover = SubResource("btn_hover")
Button/styles/pressed = SubResource("btn_pressed")
Button/styles/hover_pressed = SubResource("btn_pressed")
Button/styles/disabled = SubResource("btn_disabled")
Button/styles/focus = SubResource("btn_focus")
DangerButton/base_type = &"Button"
DangerButton/styles/normal = SubResource("danger_normal")
DangerButton/styles/hover = SubResource("danger_hover")
Label/colors/font_color = Color(0.85, 0.92, 1, 1)
Label/colors/font_shadow_color = Color(0, 0, 0, 0.6)
Label/constants/shadow_offset_x = 0其中焦点样式值得单独看一下:它不画底色(draw_center = false),只画一圈粉色描边,并向外扩出 3 像素。焦点框会叠加在按钮当前的样式上面,用键盘或手柄操作时一眼就能看出"现在选中的是哪一个":
[sub_resource type="StyleBoxFlat" id="btn_focus"]
content_margin_left = 16.0
content_margin_top = 8.0
content_margin_right = 16.0
content_margin_bottom = 8.0
draw_center = false
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 0.35, 0.8, 1)
corner_radius_top_left = 8主题变体
同样是 Label,HUD 里的小标题、数值、标题画面的大字,样式都不一样。与其在每个节点上覆盖字号和颜色,不如定义主题变体(Theme Type Variation):在主题里新建一个类型名(比如 TitleLabel),指定它的 base_type 是 Label,只写和 Label 不同的那几项。节点上把 theme_type_variation 设成 &"TitleLabel" 就行,没写的项会回落到 Label 的设置:
TitleLabel/base_type = &"Label"
TitleLabel/colors/font_color = Color(0.55, 1.7, 2.3, 1)
TitleLabel/colors/font_shadow_color = Color(1, 0.2, 0.7, 0.55)
TitleLabel/constants/shadow_offset_x = 3
TitleLabel/constants/shadow_offset_y = 3
TitleLabel/font_sizes/font_size = 60标题字的颜色是 Color(0.55, 1.7, 2.3),分量大于 1。标题界面放了 WorldEnvironment 并开启了 HDR 2D(上一章讲过),这个颜色会发光。HUD 在 CanvasLayer 的第 1 层,不受发光影响,同样的颜色只会被截到 1.0。
4. 焦点导航:键盘和手柄
鼠标点按钮不需要任何设置;键盘和手柄就不一样了,它们靠焦点(focus)工作:同一时刻只有一个控件拥有焦点,ui_up/ui_down/ui_left/ui_right 动作把焦点移到相邻控件,ui_accept 按下当前按钮,滑块拥有焦点时左右键改变数值。这些 ui_* 动作是引擎内置的,默认已经绑定了方向键、Enter/空格、手柄十字键和 A 键。
要让它正常工作,只需要注意三点:
- 界面出现时主动给焦点。没有任何控件拥有焦点时,方向键什么也不做,玩家会以为菜单坏了。标题界面在
_ready()里start_button.grab_focus(),暂停菜单在显示时给"继续游戏",音量面板打开时给第一个滑块。 - 相邻关系通常不用手写。Godot 会按控件在屏幕上的位置自动找"上面/下面最近的那个"。VBoxContainer 里的按钮天然排成一列,自动导航完全够用;布局很特殊时,再用
focus_neighbor_top等属性手动指定。 - 鼠标和键盘的"当前项"保持一致。鼠标悬停时不会自动转移焦点,于是可能出现"鼠标指着 A、焦点框在 B"的情况。示例在鼠标进入按钮时让它抢到焦点:
## 鼠标移到按钮上时也把焦点给它,这样鼠标、键盘、手柄三种操作的"当前项"始终是同一个。
func _on_button_hover(b: Button) -> void:
b.grab_focus()菜单音效也挂在焦点上:focus_entered 信号播"嘀",pressed 播"叮"。ui/ui_sounds.gd 用 find_children() 一次给整棵界面树里的按钮和滑块都连上,新加按钮不用再写代码。
手柄的游戏操作(移动、射击)是在 project.godot 的输入映射里自己加的:每个动作同时绑定键盘键、手柄按钮(InputEventJoypadButton)和摇杆轴(InputEventJoypadMotion)。示例工程的 gen/make_project.py 用脚本拼出这些条目,因为手写很长。读输入时用 Input.get_vector("move_left", "move_right", "move_up", "move_down"),键盘和摇杆走同一条路,斜向长度也已经归一化。
5. CanvasLayer:界面和游戏分层
上一章的屏幕震动是移动 Camera2D 的 offset。如果 HUD 和游戏物体在同一个画布上,HUD 也会跟着抖。CanvasLayer 是一个独立的绘制层:它有自己的变换,不受摄像机影响;layer 数值大的画在上面。示例的分层是:
| layer | 内容 | 说明 |
|---|---|---|
| 0(默认画布) | 星空、敌人、子弹、玩家、粒子 | 受 Camera2D 影响;受 WorldEnvironment 发光影响 |
| 1 | HUD(左右面板、Boss 血条、波次横幅、炸弹闪白) | 不抖、不发光 |
| 10 | 暂停菜单 | 盖住 HUD |
| 11 | 结算界面 | 最上层 |
6. 游戏状态与场景切换
"现在是标题、游戏中、暂停还是结算"这个信息不能放在某个场景里:切换场景时整棵场景树会被替换,旧场景里的变量全没了。示例把它放在自动加载 Flow 里——自动加载的节点挂在根节点下,和当前场景平级,场景切换时不会被释放。最高分列表也在这里。
标题和游戏是两个独立的场景,用 change_scene_to_file() 切换:
func start_game(at_boss := false) -> void:
start_at_boss = at_boss
get_tree().paused = false
Audio.set_muffled(false)
_set_state(State.PLAYING)
# change_scene_to_file 会在本帧结束时释放旧场景、加载新场景,
# 旧场景里的节点(包括它们的 Tuning 绑定、信号连接)都会跟着消失。
get_tree().change_scene_to_file(GAME_SCENE)
func go_to_title() -> void:
get_tree().paused = false
Audio.set_muffled(false)
_set_state(State.TITLE)
get_tree().change_scene_to_file(TITLE_SCENE)change_scene_to_file() 不会立即生效:它在本帧结束时释放旧场景、实例化新场景。旧场景里绑定的 Tuning 参数、连接的信号都跟着节点一起消失,新场景的 _ready() 会重新绑定。暂停菜单和结算界面则不是独立场景,而是作为 CanvasLayer 实例放在游戏场景里,平时隐藏——它们需要盖在游戏画面上,游戏场景不能被释放。
场景切换后的"悬空协程":如果某个节点里写了 await get_tree().create_timer(2.0).timeout,等待期间场景被切走、节点被释放,计时器到点时协程要恢复到一个已经不存在的对象上,Godot 会报错。示例的做法是把计时器的信号连接到节点的方法上(节点释放时连接自动断开),或者干脆用节点自己的时钟(见第 13 章的波次调度)。比如游戏结束后等 1.6 秒再弹结算:get_tree().create_timer(1.6, false).timeout.connect(result_screen.open.bind(...))。
暂停:get_tree().paused 和 process_mode
get_tree().paused = true 会让所有"可暂停"的节点停下:不再调用 _process/_physics_process/_input,物理停止,Tween 和 AnimationPlayer 停止,create_timer(t, false)(第二个参数 process_always = false)创建的计时器也停止。每个节点按自己的 process_mode 决定是否受影响:
| process_mode | 暂停时 | 示例里谁用 |
|---|---|---|
INHERIT(默认) | 跟父节点一样;根节点默认是可暂停的 | 游戏里的一切 |
PAUSABLE | 停 | — |
WHEN_PAUSED | 只在暂停时运行 | 暂停菜单 |
ALWAYS | 一直运行 | Flow、Audio、Tuning(参数桥)、结算界面 |
DISABLED | 永远不运行 | 池里的空闲子弹 |
[node name="PauseMenu" type="CanvasLayer"]
process_mode = 2
layer = 10
script = ExtResource("1")func set_paused(paused: bool) -> void:
if state != State.PLAYING and state != State.PAUSED:
return
# get_tree().paused 会停掉所有 process_mode 为 INHERIT/PAUSABLE 的节点:
# _process、_physics_process、物理、动画、计时器(create_timer 的 process_always=false 时)都停。
# 暂停菜单把自己设成 WHEN_PAUSED,Flow 和 Audio 是 ALWAYS,所以它们还能动。
get_tree().paused = paused
Audio.set_muffled(paused)
_set_state(State.PAUSED if paused else State.PLAYING)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("pause"):
set_paused(state == State.PLAYING)
get_viewport().set_input_as_handled()Flow 是 ALWAYS,所以暂停后它仍然能收到 Esc 再把游戏恢复;暂停菜单是 WHEN_PAUSED,游戏进行时它的按钮不会抢输入。
游戏暂停时,暂停菜单里的按钮需要响应手柄,而游戏中的敌人要停下。暂停菜单节点的 process_mode 应该设成什么?
INHERITPAUSABLEWHEN_PAUSED或ALWAYS
INHERIT 会继承根节点的"可暂停",暂停时菜单自己也不处理输入,玩家就再也回不来了。WHEN_PAUSED 和 ALWAYS 都能在暂停时工作,前者还能保证游戏进行时菜单完全不占用。
7. 音频总线和效果器
Godot 的声音不是直接送到扬声器的,而是先进总线(Bus)。每条总线有音量、静音、独奏开关,可以挂一串效果器(AudioEffect),最后"发送"到另一条总线,最终汇入 Master。示例建了三条:
Music ──(低通滤波)──┐
├──> Master ──(硬限幅)──> 扬声器
SFX ───(压缩器)─────┘
- Music:背景音乐。挂一个低通滤波器,平时截止频率 20000 Hz(等于没开),暂停时降到 900 Hz,音乐听起来像隔了一堵墙。
- SFX:所有音效。挂一个压缩器:几十个爆炸叠在一起时,把超过阈值的部分压下来,避免忽大忽小。
- Master:硬限幅器(HardLimiter),保证最终输出不超过 −0.5 dB,不会破音。
总线布局存在 audio/bus_layout.tres,由项目设置 audio/buses/default_bus_layout 指定。编辑器底部的"音频"面板就是它的可视化编辑器:
[gd_resource type="AudioBusLayout" format=3 uid="uid://bgrbiwgujfvx0"]
[sub_resource type="AudioEffectHardLimiter" id="limiter"]
resource_name = "HardLimiter"
ceiling_db = -0.5
[sub_resource type="AudioEffectLowPassFilter" id="music_lowpass"]
resource_name = "LowPassFilter"
cutoff_hz = 20000.0
[sub_resource type="AudioEffectCompressor" id="sfx_comp"]
resource_name = "Compressor"
threshold = -14.0
ratio = 3.0
[resource]
bus/0/effect/0/effect = SubResource("limiter")
bus/0/effect/0/enabled = true
bus/1/name = &"Music"
bus/1/solo = false
bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = &"Master"
bus/1/effect/0/effect = SubResource("music_lowpass")
bus/1/effect/0/enabled = true
bus/2/name = &"SFX"
bus/2/solo = false
bus/2/mute = false
bus/2/bypass_fx = false
bus/2/volume_db = 0.0
bus/2/send = &"Master"
bus/2/effect/0/effect = SubResource("sfx_comp")
bus/2/effect/0/enabled = true音量设置界面的滑块是 0~1 的线性值,但总线音量是分贝。人耳对响度的感觉接近对数,所以用 linear_to_db() 换算:0.5 ≈ −6 dB,0.1 = −20 dB,0 是负无穷(示例直接把总线静音)。
## 暂停时把音乐"闷"住:打开 Music 总线上的低通滤波器,并压低音量。
## 低通是总线效果器,Web 默认的 Sample 播放模式不支持效果器,所以再叠一个降音量,两边都能听出变化。
func set_muffled(on: bool) -> void:
_muffled = on
var bus := AudioServer.get_bus_index(&"Music")
var lowpass := AudioServer.get_bus_effect(bus, 0) as AudioEffectLowPassFilter
lowpass.cutoff_hz = 900.0 if on else 20000.0
music_volume = music_volume # 重新走一遍 setter,按 _muffled 算音量AudioStreamPlayer 和 AudioStreamPlayer2D
| AudioStreamPlayer | AudioStreamPlayer2D | |
|---|---|---|
| 继承自 | Node | Node2D(有位置) |
| 声像 | 没有,左右声道一样响 | 根据它和摄像机(或 AudioListener2D)的相对位置算左右声像 |
| 衰减 | 没有 | 距离越远越小,超过 max_distance 听不到 |
| 适合 | 音乐、UI 音效、玩家自己的射击声 | 世界里的声源:爆炸、敌人开火 |
示例里爆炸用 AudioStreamPlayer2D:左边的敌人炸了,声音偏左。8 个 2D 播放器轮流使用,每次把下一个移到爆炸位置再播放——又是"池"的思路。
## 播放一个非定位音效(射击、拾取、UI)。
func play(sfx_name: String, volume_db := 0.0) -> void:
var p: AudioStreamPlayer = _players.get(sfx_name)
if p == null:
return
_plays_counter += 1
p.volume_db = volume_db
# 随机音高:同一个声音连发几十次也不会像"机关枪录音"。
# 注意 pitch_scale 是整个节点的属性,会影响它正在响的其他声部;短音效听不出来。
p.pitch_scale = 1.0 + randf_range(-random_pitch, random_pitch)
p.play() # max_polyphony > 1 时,play() 会新开一个声部;满了就掐掉最老的
## 在某个位置播放(爆炸)。AudioStreamPlayer2D 会根据它和摄像机的相对位置算左右声像和衰减。
func play_at(sfx_name: String, global_pos: Vector2, volume_db := 0.0) -> void:
_plays_counter += 1
var p := _voices_2d[_next_voice]
_next_voice = (_next_voice + 1) % _voices_2d.size() # 轮流使用,最老的被复用
p.stream = SFX[sfx_name]
p.global_position = global_pos
p.volume_db = volume_db
p.pitch_scale = 1.0 + randf_range(-random_pitch, random_pitch)
p.play()同一个音效被大量触发
射速 30 时每秒 30 次射击音效,Boss 每 0.07 秒发一次螺旋弹。直接每次 play() 有两个问题:
- 声部数:AudioStreamPlayer 默认
max_polyphony = 1,再次play()会把上一个掐断从头放,连发时只听到"哒哒哒"的开头。设成大于 1,新的play()会新开一个声部;声部满了,最早的那个被掐掉。示例默认 6——超过 6 个同样的声音叠在一起,耳朵也分不出来了,多了只会让音量堆高、浪费混音。 - 听觉疲劳:完全相同的声音重复几十次,听起来像机关枪录音。每次播放前把
pitch_scale随机偏一点(示例默认 ±8%),每一声都略有不同。
另外在源头也要控制:Boss 的螺旋弹不是每次发射都播音效,而是单独用一个 0.6 秒的计时器;敌人的螺旋发射只有 20% 的概率出声。示例的实时数据里有一项"音效请求/秒",可以看到实际调用了多少次。
pitch_scale 是整个播放器节点的属性,改它会影响这个节点正在响的其他声部。对 0.1 秒的射击声来说听不出来;如果需要每个声部独立的随机音高,可以用 AudioStreamRandomizer(它有 random_pitch 和 random_volume_offset_db)包一层,或者每个声部用独立的播放器。注意 AudioStreamRandomizer 不能作为 Web 上的 Sample 播放(见下文),在 Web 上会走 Stream 混音路径。
动手试试:开始游戏,勾上"自动射击",把"最大复音数"调到 1:射击声变成短促、被不停掐断的"哒哒"声。调到 16 再听,声音变"厚"、也更响。然后把"随机音高幅度"拉到 0.5,射击声变成忽高忽低的乱响;拉到 0,就是完全一样的重复。0.05~0.1 之间最自然。
8. 程序化音效
示例里所有声音都是用 Python 合成的(gen/gen_audio.py),不需要任何录音或外部素材。一个音效 = 振荡器(产生波形)× 包络(控制音量随时间变化),再加一点音高滑动、噪声或滤波。
振荡器:相位从 0 到 1 循环,每个采样前进 频率 / 采样率。用相位的累加(而不是 sin(2π × f × t))的好处是频率可以逐采样变化,滑音时波形依然连续,不会有"咔哒"声:
def osc(kind, freq, n, sr=SR):
"""振荡器。freq 可以是常数,也可以是逐采样的数组(用来做音高滑动)。
相位 = 频率的累加,所以变频时波形是连续的,不会"咔哒"响。"""
f = np.broadcast_to(np.asarray(freq, dtype=float), (n,))
phase = np.cumsum(f / sr) % 1.0
if kind == "sine":
return np.sin(2 * np.pi * phase)
if kind == "square":
return np.where(phase < 0.5, 1.0, -1.0)
if kind == "saw":
return 2 * phase - 1
if kind == "triangle":
return 1 - 4 * np.abs(phase - 0.5)
if kind == "noise":
return rng.uniform(-1, 1, n)
raise ValueError(kind)包络用经典的 ADSR:起音(Attack)从 0 升到最大,衰减(Decay)降到持续电平,持续(Sustain)保持,释放(Release)降到 0。短促的音效 A 很短(几毫秒);"啵"的一声 D 短、S 低;爆炸 D 长、R 长:
def adsr(n, a, d, s, r, sr=SR):
"""ADSR 包络:起音 a 秒升到 1 → 衰减 d 秒降到持续电平 s → 保持 → 最后 r 秒释放到 0。
n 是总采样数;返回长度为 n 的增益曲线。"""
A, D, R = int(a * sr), int(d * sr), int(r * sr)
S = max(0, n - A - D - R) # 持续段占剩下的时间
env = np.concatenate([
np.linspace(0, 1, A, endpoint=False), # Attack
np.linspace(1, s, D, endpoint=False), # Decay
np.full(S, s), # Sustain
np.linspace(s, 0, R), # Release
])
return np.pad(env, (0, max(0, n - len(env))))[:n]def make_shoot():
n = sec(0.09)
tone = osc("square", sweep(1400, 520, n, 0.6), n) * 0.5 + osc("noise", 0, n) * 0.15
return tone * adsr(n, 0.002, 0.04, 0.25, 0.04)
def make_explosion(length=0.55, low=55):
n = sec(length)
noise = lowpass(osc("noise", 0, n), sweep(5000, 180, n, 0.5))
thump = osc("sine", sweep(160, low, n, 0.4), n)
return (noise * 1.2 + thump * 0.9) * adsr(n, 0.003, length * 0.35, 0.35, length * 0.6)
def make_pickup():
notes = [523.25, 659.25, 783.99, 1046.5] # C5 E5 G5 C6 琶音
parts = []
for f in notes:
n = sec(0.065)
parts.append((osc("square", f, n) * 0.4 + osc("sine", f * 2, n) * 0.3) * adsr(n, 0.002, 0.03, 0.6, 0.02))
return np.concatenate(parts)
def make_hurt():
n = sec(0.42)
tone = osc("saw", sweep(520, 70, n, 0.7), n) * 0.7 + osc("noise", 0, n) * 0.25
return lowpass(tone, 2400) * adsr(n, 0.004, 0.12, 0.5, 0.25)
def make_enemy_shot():
n = sec(0.07)
return osc("triangle", sweep(900, 600, n), n) * adsr(n, 0.002, 0.03, 0.3, 0.03)
def make_ui_move():
n = sec(0.035)
return osc("square", 1200, n) * adsr(n, 0.001, 0.015, 0.2, 0.015)
def make_ui_ok():
a, b = sec(0.06), sec(0.11)
return np.concatenate([osc("square", 880, a) * adsr(a, 0.001, 0.02, 0.6, 0.02),
osc("square", 1320, b) * adsr(b, 0.001, 0.03, 0.5, 0.07)])背景音乐也是这个脚本生成的:140 BPM,A 小调 i–VI–III–VII 和弦进行,低音、琶音和鼓三轨,8 小节约 13.7 秒,用 ffmpeg 编码成 OGG Vorbis,只有 170 KB 左右。生成时把超出结尾的音尾绕回开头叠加,循环播放时接缝处听不出断开。
WAV 还是 OGG?短音效用 WAV:无需解码,播放延迟最低,几十 KB 的体积可以接受。音乐用 OGG:压缩率高(同样 13.7 秒的立体声 WAV 要 2.4 MB),解码开销对一条音轨来说不算什么。循环在导入设置里打开(OGG 的 loop,WAV 的 edit/loop_mode),或者在代码里设置流的 loop 属性。
9. Web 上的音频
浏览器有自动播放策略:在用户和页面交互(点击、按键、触摸)之前,网页不允许发出声音。Web 音频的入口 AudioContext 在这之前处于 suspended(挂起)状态,必须在用户手势的事件处理里调用 resume() 才会变成 running。上面的合成器小部件就演示了这一点:第一次点"播放"时才创建 AudioContext 并 resume。
Godot 的 Web 导出已经处理了 resume:收到第一次输入事件时会自动恢复 AudioContext。游戏这边要做的是别在那之前开始放重要的声音。如果在标题界面的 _ready() 里直接放音乐,玩家第一次按键时前奏可能已经"无声地"放过去了。示例的 Audio 记住"想放音乐"这个意图,等到第一次输入再真正开始:
## 浏览器的自动播放策略:用户和页面交互之前,声音不允许播放。
## Godot 收到第一次输入时会恢复 AudioContext;这里也等到那一刻再开始放音乐,
## 这样玩家不会错过开头,也不会在后台"无声地"播完前奏。
func play_music() -> void:
_want_music = true
if _unlocked and not _music.playing:
_music.play()
func _input(event: InputEvent) -> void:
if _unlocked:
return
if (event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton
or event is InputEventScreenTouch) and event.is_pressed():
_unlocked = true
if _want_music:
_music.play()标题界面"按方向键选择、按 Enter 开始"本身就是一次交互,所以音乐会在玩家第一次按键时响起。
Web 默认用 Sample 播放,效果器不生效。Godot 4.3 起,Web 平台的 audio/general/default_playback_type.web 默认是 Sample:声音直接交给浏览器的 Web Audio API 播放,延迟低,单线程导出也不会卡顿;代价是总线上的 AudioEffect 不支持(总线音量、静音仍然有效)。所以示例的"暂停时低通"在桌面版上能听到,在网页上听不到——这也是为什么 set_muffled() 还额外把音乐音量压低了一截,两边都有变化。可以把某个播放器的 playback_type 强制设为 Stream 让效果器生效,但官方文档警告这在 Web 上可能导致高延迟和爆音。
网页版游戏一加载完就在 _ready() 里 $Music.play(),玩家过了 5 秒才第一次点击。最可能发生什么?
- 浏览器报错,游戏崩溃
- 前 5 秒没有声音;点击后 AudioContext 恢复,才开始听到
- 音乐正常从头播放,浏览器不限制游戏
AudioContext 在用户手势之前是挂起的,不会报错,只是没有声音输出。示例的做法是把"开始放音乐"推迟到第一次输入之后。
10. 最高分存档
存档用 ConfigFile:INI 格式的文本文件,按"段 / 键"存取任意 Variant(包括数组和字典),足够存最高分列表和音量设置:
## 存档用 ConfigFile(INI 格式的文本)。user:// 在桌面上是系统的应用数据目录,
## 在 Web 上是浏览器的 IndexedDB——刷新页面后还在,换浏览器或清除网站数据就没了。
func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("scores", "list", high_scores)
cfg.set_value("audio", "music_volume", Audio.music_volume)
cfg.set_value("audio", "sfx_volume", Audio.sfx_volume)
var err := cfg.save(SAVE_PATH)
if err != OK:
push_warning("存档失败:%s" % error_string(err))
func load_save() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
return # 第一次运行,还没有存档
high_scores.assign(cfg.get_value("scores", "list", []))
Audio.music_volume = cfg.get_value("audio", "music_volume", Audio.music_volume)
Audio.sfx_volume = cfg.get_value("audio", "sfx_volume", Audio.sfx_volume)user:// 是 Godot 为每个项目准备的可写目录。桌面上它在系统的应用数据目录里(macOS 是 ~/Library/Application Support/Godot/app_userdata/项目名/);res:// 在导出后是只读的,不能用来存档。在 Web 上,user:// 映射到浏览器的 IndexedDB:刷新页面、关掉再打开都还在,但换浏览器、用隐私窗口或清除网站数据就没了。
动手试试:在示例里点"直接到 Boss",把它打掉(嫌难就打开第 13 章示例里的"无敌模式",或者在这里点几次"清屏炸弹")。结算界面会显示排行榜,第一名标"新纪录"。然后点"重新开始"、甚至刷新整个网页,再回到标题界面,最高分仍然在右侧列表里。
小结
- Control 用锚点(比例)+ 偏移(像素)定位:边的位置 = 锚点 × 父尺寸 + 偏移。容器接管子控件的排版,加"弹簧"Control 把东西推到另一头。
- Theme 按控件类型登记样式;主题变体用
base_type继承,只写不同的部分。焦点样式要醒目。 - 界面出现时
grab_focus(),鼠标悬停也转移焦点,键盘、手柄、鼠标就是同一套逻辑。 - CanvasLayer 让 HUD 不受摄像机和发光影响。
- 跨场景的状态放在自动加载里。暂停 =
get_tree().paused+ 每个节点的process_mode。 - 音乐、音效分总线;同一音效限制声部数、随机音高;2D 播放器负责有位置的声源。
- Web 上等第一次交互再放声音;Web 默认 Sample 播放,总线效果器不生效。
ConfigFile+user://存档,Web 上落在 IndexedDB。