10. 打磨与发布
2026/4/13大约 12 分钟
打磨与发布
性能优化
为什么要做性能优化?
性能优化就是让游戏"跑得更流畅"。如果游戏卡顿、掉帧、加载慢,玩家体验就会很差——哪怕游戏内容再好。特别是横版射击游戏,屏幕上同时有几十颗子弹和十几个敌人,如果性能不好,玩起来会像放幻灯片。
性能优化清单
| 优化项 | 问题 | 解决方案 | 影响程度 |
|---|---|---|---|
| 对象池 | 反复创建销毁子弹导致卡顿 | 预创建子弹,循环利用 | 极高 |
| 视锥剔除 | 屏幕外的物体还在计算 | Godot自动处理,确保用Node3D | 高 |
| 碰撞层优化 | 所有东西都在互相碰撞 | 分层:玩家层、敌人层、子弹层 | 高 |
| 粒子数量 | 粒子太多拖慢GPU | 限制同时存在的粒子数 | 中等 |
| 阴影质量 | 高质量阴影很吃性能 | 适当降低阴影分辨率 | 中等 |
| 贴图压缩 | 大贴图占用内存过多 | 使用压缩格式(ETC2/ASTC) | 低 |
碰撞层设置
什么是碰撞层?
碰撞层就像"分组"——你把不同的东西分到不同的组里,然后设置"哪些组之间会碰撞"。比如:玩家子弹不应该和玩家碰撞(自伤没意义),但应该和敌人碰撞(打敌人)。合理设置碰撞层可以减少不必要的碰撞计算。
| 层编号 | 名称 | 说明 |
|---|---|---|
| 1 | 玩家 | 角色碰撞体 |
| 2 | 敌人 | 敌人碰撞体 |
| 3 | 玩家子弹 | 玩家发射的子弹 |
| 4 | 敌人子弹 | 敌人发射的子弹 |
| 5 | 地形 | 地面、平台、墙壁 |
| 6 | 道具 | 可拾取的道具 |
| 7 | 触发器 | 关卡触发区域 |
碰撞矩阵
| ↓碰撞体 / →遮罩→ | 玩家 | 敌人 | 玩家子弹 | 敌人子弹 | 地形 | 道具 | 触发器 |
|---|---|---|---|---|---|---|---|
| 玩家 | - | 碰撞 | - | 碰撞 | 碰撞 | 碰撞 | 碰撞 |
| 敌人 | 碰撞 | - | 碰撞 | - | 碰撞 | - | - |
| 玩家子弹 | - | 碰撞 | - | - | 碰撞 | - | - |
| 敌人子弹 | 碰撞 | - | - | - | 碰撞 | - | - |
| 地形 | 碰撞 | 碰撞 | 碰撞 | 碰撞 | - | - | - |
| 道具 | 碰撞 | - | - | - | - | - | - |
| 触发器 | 碰撞 | - | - | - | - | - | - |
手感调优
什么是"手感"?
"手感"(Game Feel)就是玩家操作游戏时的"感觉"。好的手感让操作变得"舒服"——跳跃有弹性、射击有反馈、移动有惯性。手感不是某个具体的功能,而是所有小细节加起来的整体感受。
射击手感
| 调优项 | 不好的手感 | 好的手感 | 实现方式 |
|---|---|---|---|
| 射击延迟 | 按下键后子弹延迟出现 | 按下键的瞬间子弹就出现 | 减少射击处理时间 |
| 射击反馈 | 子弹飞出去无声无息 | 有枪声+枪口火焰+屏幕微震 | 音效+粒子+震动 |
| 命中反馈 | 敌人血量减少但没变化 | 敌人闪白+数字弹出+小震动 | 闪白+伤害数字 |
| 后坐力 | 射击时角色不动 | 射击时角色微微后退 | 给角色施加反向力 |
跳跃手感
| 调优项 | 不好的手感 | 好的手感 | 实现方式 |
|---|---|---|---|
| 起跳速度 | 慢吞吞地离开地面 | 瞬间弹起 | 适当增加跳跃力 |
| 空中控制 | 跳起来后不能改方向 | 空中也能微调方向 | 允许空中改变水平速度 |
| 着陆缓冲 | "啪"一下突然停住 | 有轻微的缓冲 | 着陆时减速+蹲下动画 |
| 二段跳 | 按跳跃没反应 | 有明显的第二跳动画和音效 | 播放不同音效+粒子 |
跳跃手感优化代码
C#
using Godot;
/// <summary>
/// 手感优化组件 - 附加在玩家角色上
/// </summary>
public partial class GameFeelComponent : Node3D
{
[Export] public Player Player;
// ===== 射击后坐力 =====
[ExportGroup("射击手感")]
[Export] public float RecoilForce = 2.0f; // 后坐力大小
[Export] public float RecoilDuration = 0.05f; // 后坐力持续时间
// ===== 跳跃手感 =====
[ExportGroup("跳跃手感")]
[Export] public float CoyoteTime = 0.1f; // 土狼时间(离开平台后仍可跳跃)
[Export] public float JumpBufferTime = 0.1f; // 跳跃缓冲(提前按跳跃)
[Export] public float LandingDampening = 0.3f; // 着陆缓冲
// ===== 内部状态 =====
private float _coyoteTimer = 0.0f;
private float _jumpBufferTimer = 0.0f;
private bool _wasOnFloor = false;
public override void _PhysicsProcess(double delta)
{
float dt = (float)delta;
// 土狼时间:离开平台后短时间内仍可跳跃
if (Player.IsOnFloor)
{
_coyoteTimer = CoyoteTime;
_wasOnFloor = true;
}
else
{
_coyoteTimer -= dt;
}
// 跳跃缓冲:在着地前提前按跳跃,着地后自动跳
if (Input.IsActionJustPressed("jump"))
{
_jumpBufferTimer = JumpBufferTime;
}
else
{
_jumpBufferTimer -= dt;
}
// 着陆检测(从空中落到地面)
if (!_wasOnFloor && Player.IsOnFloor)
{
OnLanded();
}
_wasOnFloor = Player.IsOnFloor;
}
/// <summary>
/// 检查是否可以跳跃(包含土狼时间和跳跃缓冲)
/// </summary>
public bool CanJump()
{
// 土狼时间内 或 跳跃缓冲时间内 → 可以跳跃
return _coyoteTimer > 0 || _jumpBufferTimer > 0;
}
/// <summary>
/// 应用射击后坐力
/// </summary>
public void ApplyRecoil(Vector3 shootDirection)
{
Vector3 recoil = -shootDirection * RecoilForce;
var tween = CreateTween();
tween.TweenCallback(Callable.From(() =>
{
Player.ApplyCentralImpulse(recoil);
}));
tween.TweenCallback(Callable.From(() =>
{
Player.ApplyCentralImpulse(-recoil);
})).SetDelay(RecoilDuration);
}
/// <summary>
/// 着陆时的效果
/// </summary>
private void OnLanded()
{
// 着陆音效
if (AudioManager.Instance != null)
{
AudioManager.Instance.PlaySFXByName("land");
}
// 着陆粒子(灰尘)
CreateLandingDust();
// 着陆微震
var shake = GetNode<ScreenShake>("/root/GameWorld/ScreenShake");
shake?.SmallShake();
}
private void CreateLandingDust()
{
var particles = new CPUParticles3D();
particles.Amount = 10;
particles.Lifetime = 0.5f;
particles.OneShot = true;
particles.Position = Player.Position + Vector3.Down * 0.5f;
particles.Direction = Vector3.Up;
particles.Spread = 60.0f;
particles.Gravity = Vector3.Down * 2;
GetParent().AddChild(particles);
particles.Emitting = true;
// 自动销毁
var timer = GetTree().CreateTimer(1.0);
timer.Timeout += Callable.From(() => particles.QueueFree());
}
}GDScript
extends Node3D
## 手感优化组件 - 附加在玩家角色上
@export var player: Node
# ===== 射击手感 =====
@export_group("射击手感")
@export var recoil_force: float = 2.0 # 后坐力大小
@export var recoil_duration: float = 0.05 # 后坐力持续时间
# ===== 跳跃手感 =====
@export_group("跳跃手感")
@export var coyote_time: float = 0.1 # 土狼时间
@export var jump_buffer_time: float = 0.1 # 跳跃缓冲
@export var landing_dampening: float = 0.3 # 着陆缓冲
# ===== 内部状态 =====
var _coyote_timer: float = 0.0
var _jump_buffer_timer: float = 0.0
var _was_on_floor: bool = false
func _physics_process(delta):
# 土狼时间
if player.is_on_floor():
_coyote_timer = coyote_time
_was_on_floor = true
else:
_coyote_timer -= delta
# 跳跃缓冲
if Input.is_action_just_pressed("jump"):
_jump_buffer_timer = jump_buffer_time
else:
_jump_buffer_timer -= delta
# 着陆检测
if not _was_on_floor and player.is_on_floor():
_on_landed()
_was_on_floor = player.is_on_floor()
## 检查是否可以跳跃
func can_jump() -> bool:
return _coyote_timer > 0 or _jump_buffer_timer > 0
## 应用射击后坐力
func apply_recoil(shoot_direction: Vector3):
var recoil = -shoot_direction * recoil_force
var tween = create_tween()
tween.tween_callback(func():
player.apply_central_impulse(recoil)
)
tween.tween_callback(func():
player.apply_central_impulse(-recoil)
).set_delay(recoil_duration)
## 着陆时的效果
func _on_landed():
if AudioManager.instance:
AudioManager.instance.play_sfx_by_name("land")
_create_landing_dust()
var shake = get_node("/root/GameWorld/ScreenShake")
if shake:
shake.small_shake()
func _create_landing_dust():
var particles = CPUParticles3D.new()
particles.amount = 10
particles.lifetime = 0.5
particles.one_shot = true
particles.position = player.position + Vector3.DOWN * 0.5
particles.direction = Vector3.UP
particles.spread = 60.0
particles.gravity = Vector3.DOWN * 2
get_parent().add_child(particles)
particles.emitting = true
var timer = get_tree().create_timer(1.0)
timer.timeout.connect(particles.queue_free)土狼时间和跳跃缓冲
两个让跳跃"更好用"的技巧
- 土狼时间(Coyote Time):玩家从平台边缘走出去后,还有0.1秒的时间可以跳跃。这解决了"明明按了跳跃但没跳起来"的挫败感。
- 跳跃缓冲(Jump Buffer):玩家在空中时提前按了跳跃键,着地后自动触发跳跃。这解决了"明明提前按了但着地后才跳"的挫败感。
这两个技巧在很多经典平台游戏中都有使用,比如蔚蓝(Celeste)就是手感调优的教科书。
设置菜单
C#
using Godot;
/// <summary>
/// 设置菜单 - 音量、画质、控制等设置
/// </summary>
public partial class SettingsMenu : Control
{
[Export] public HSlider MasterVolumeSlider;
[Export] public HSlider SFXVolumeSlider;
[Export] public HSlider MusicVolumeSlider;
[Export] public OptionButton QualityOption;
[Export] public CheckButton FullscreenCheck;
public override void _Ready()
{
// 加载保存的设置
LoadSettings();
// 连接信号
MasterVolumeSlider.ValueChanged += OnMasterVolumeChanged;
SFXVolumeSlider.ValueChanged += OnSFXVolumeChanged;
MusicVolumeSlider.ValueChanged += OnMusicVolumeChanged;
QualityOption.ItemSelected += OnQualityChanged;
FullscreenCheck.Toggled += OnFullscreenToggled;
}
private void OnMasterVolumeChanged(float value)
{
if (AudioManager.Instance != null)
{
AudioManager.Instance.MasterVolume = value;
}
SaveSettings();
}
private void OnSFXVolumeChanged(float value)
{
if (AudioManager.Instance != null)
{
AudioManager.Instance.SFXVolume = value;
}
SaveSettings();
}
private void OnMusicVolumeChanged(float value)
{
if (AudioManager.Instance != null)
{
AudioManager.Instance.MusicVolume = value;
}
SaveSettings();
}
private void OnQualityChanged(long index)
{
// 画质设置
string[] qualities = { "Low", "Medium", "High" };
GD.Print($"画质设为: {qualities[index]}");
switch (index)
{
case 0: // Low
Quality.SetShaderParameter("detail", 0.5f);
break;
case 1: // Medium
Quality.SetShaderParameter("detail", 0.75f);
break;
case 2: // High
Quality.SetShaderParameter("detail", 1.0f);
break;
}
SaveSettings();
}
private void OnFullscreenToggled(bool pressed)
{
if (pressed)
{
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen);
}
else
{
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
}
SaveSettings();
}
private void SaveSettings()
{
var config = new ConfigFile();
config.SetValue("audio", "master_volume", MasterVolumeSlider.Value);
config.SetValue("audio", "sfx_volume", SFXVolumeSlider.Value);
config.SetValue("audio", "music_volume", MusicVolumeSlider.Value);
config.SetValue("video", "quality", QualityOption.Selected);
config.SetValue("video", "fullscreen", FullscreenCheck.ButtonPressed);
config.Save("user://settings.cfg");
}
private void LoadSettings()
{
var config = new ConfigFile();
var err = config.Load("user://settings.cfg");
if (err != Error.Ok) return;
MasterVolumeSlider.Value = (float)config.GetValue("audio", "master_volume", 1.0f);
SFXVolumeSlider.Value = (float)config.GetValue("audio", "sfx_volume", 0.8f);
MusicVolumeSlider.Value = (float)config.GetValue("audio", "music_volume", 0.5f);
QualityOption.Selected = (int)config.GetValue("video", "quality", 1);
FullscreenCheck.ButtonPressed = (bool)config.GetValue("video", "fullscreen", false);
}
}GDScript
extends Control
## 设置菜单 - 音量、画质、控制等设置
@export var master_volume_slider: HSlider
@export var sfx_volume_slider: HSlider
@export var music_volume_slider: HSlider
@export var quality_option: OptionButton
@export var fullscreen_check: CheckButton
func _ready():
# 加载保存的设置
_load_settings()
# 连接信号
master_volume_slider.value_changed.connect(_on_master_volume_changed)
sfx_volume_slider.value_changed.connect(_on_sfx_volume_changed)
music_volume_slider.value_changed.connect(_on_music_volume_changed)
quality_option.item_selected.connect(_on_quality_changed)
fullscreen_check.toggled.connect(_on_fullscreen_toggled)
func _on_master_volume_changed(value: float):
if AudioManager.instance:
AudioManager.instance.master_volume = value
_save_settings()
func _on_sfx_volume_changed(value: float):
if AudioManager.instance:
AudioManager.instance.sfx_volume = value
_save_settings()
func _on_music_volume_changed(value: float):
if AudioManager.instance:
AudioManager.instance.music_volume = value
_save_settings()
func _on_quality_changed(index: int):
var qualities = ["Low", "Medium", "High"]
print("画质设为: %s" % qualities[index])
_save_settings()
func _on_fullscreen_toggled(pressed: bool):
if pressed:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
_save_settings()
func _save_settings():
var config = ConfigFile.new()
config.set_value("audio", "master_volume", master_volume_slider.value)
config.set_value("audio", "sfx_volume", sfx_volume_slider.value)
config.set_value("audio", "music_volume", music_volume_slider.value)
config.set_value("video", "quality", quality_option.selected)
config.set_value("video", "fullscreen", fullscreen_check.button_pressed)
config.save("user://settings.cfg")
func _load_settings():
var config = ConfigFile.new()
var err = config.load("user://settings.cfg")
if err != OK:
return
master_volume_slider.value = config.get_value("audio", "master_volume", 1.0)
sfx_volume_slider.value = config.get_value("audio", "sfx_volume", 0.8)
music_volume_slider.value = config.get_value("audio", "music_volume", 0.5)
quality_option.selected = config.get_value("video", "quality", 1)
fullscreen_check.button_pressed = config.get_value("video", "fullscreen", false)场景切换
C#
using Godot;
/// <summary>
/// 场景管理器 - 管理场景之间的切换
/// </summary>
public partial class SceneManager : Node
{
[Export] public string MainMenuScene = "res://Scenes/UI/MainMenu.tscn";
[Export] public string GameScene = "res://Scenes/Levels/GameWorld.tscn";
/// <summary>
/// 切换到指定场景(带加载过渡)
/// </summary>
public void ChangeScene(string scenePath, float transitionDuration = 0.5f)
{
// 创建过渡动画
var canvasLayer = new CanvasLayer();
var colorRect = new ColorRect
{
Color = new Color(0, 0, 0),
AnchorLeft = 0,
AnchorTop = 0,
AnchorRight = 1,
AnchorBottom = 1
};
canvasLayer.AddChild(colorRect);
GetTree().Root.AddChild(canvasLayer);
// 淡入(黑屏)
var tween = CreateTween();
tween.TweenProperty(colorRect, "color:a", 1.0, transitionDuration / 2);
// 切换场景
tween.TweenCallback(Callable.From(() =>
{
GetTree().ChangeSceneToFile(scenePath);
}));
// 淡出(显示新场景)
tween.TweenProperty(colorRect, "color:a", 0.0, transitionDuration / 2);
// 移除过渡层
tween.TweenCallback(Callable.From(() =>
{
canvasLayer.QueueFree();
}));
}
/// <summary>
/// 回到主菜单
/// </summary>
public void GoToMainMenu()
{
ChangeScene(MainMenuScene);
}
/// <summary>
/// 开始游戏
/// </summary>
public void StartGame()
{
ChangeScene(GameScene);
}
/// <summary>
/// 重新开始当前关卡
/// </summary>
public void RestartLevel()
{
ChangeScene(GameScene);
}
}GDScript
extends Node
## 场景管理器 - 管理场景之间的切换
@export var main_menu_scene: String = "res://Scenes/UI/MainMenu.tscn"
@export var game_scene: String = "res://Scenes/Levels/GameWorld.tscn"
## 切换到指定场景(带加载过渡)
func change_scene(scene_path: String, transition_duration: float = 0.5):
# 创建过渡动画
var canvas_layer = CanvasLayer.new()
var color_rect = ColorRect.new()
color_rect.color = Color(0, 0, 0)
color_rect.anchor_left = 0
color_rect.anchor_top = 0
color_rect.anchor_right = 1
color_rect.anchor_bottom = 1
canvas_layer.add_child(color_rect)
get_tree().root.add_child(canvas_layer)
var tween = create_tween()
# 淡入(黑屏)
tween.tween_property(color_rect, "color:a", 1.0, transition_duration / 2)
# 切换场景
tween.tween_callback(func():
get_tree().change_scene_to_file(scene_path)
)
# 淡出(显示新场景)
tween.tween_property(color_rect, "color:a", 0.0, transition_duration / 2)
# 移除过渡层
tween.tween_callback(func():
canvas_layer.queue_free()
)
## 回到主菜单
func go_to_main_menu():
change_scene(main_menu_scene)
## 开始游戏
func start_game():
change_scene(game_scene)
## 重新开始当前关卡
func restart_level():
change_scene(game_scene)多平台导出
导出平台
| 平台 | 需要的工具 | 注意事项 |
|---|---|---|
| Windows | 无额外需求 | 最简单,直接导出.exe |
| Web (HTML5) | 无额外需求 | 需要压缩资源,注意文件大小 |
| Android | Android SDK + JDK | 需要配置触摸控制 |
| iOS | macOS + Xcode | 需要Apple开发者账号 |
| Linux | 无额外需求 | 和Windows类似 |
导出步骤(Windows为例)
- 打开 项目 → 导出
- 点击 添加 → 选择 Windows Desktop
- 在选项中设置:
- 应用名称:
Contra25D - 图标:选择你的游戏图标
- 启动画面:可选
- 应用名称:
- 点击 导出项目,选择保存位置
移动端适配
移动端需要额外工作
移动端不是简单导出就能玩的,需要:
- 添加虚拟摇杆和按钮
- 调整UI大小(手机屏幕小)
- 降低画质(手机性能有限)
- 适配不同屏幕比例
发布检查清单
发布前必查
发布游戏之前,逐项检查以下内容。漏掉任何一项都可能影响玩家体验。
功能检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 游戏能正常启动 | 待检查 | 没有崩溃、没有报错 |
| 所有关卡能正常通关 | 待检查 | 从第一关玩到最后一关 |
| Boss战正常运作 | 待检查 | 所有Boss的状态切换正确 |
| 道具掉落正常 | 待检查 | 武器升级、生命、护盾都有效 |
| 音效全部播放 | 待检查 | 没有缺失的音效文件 |
| 设置能保存和加载 | 待检查 | 关闭重开后设置不丢失 |
性能检查
| 检查项 | 目标 | 说明 |
|---|---|---|
| 帧率稳定60FPS | 目标设备上测试 | 没有明显掉帧 |
| 内存使用合理 | < 500MB | 没有内存泄漏 |
| 加载时间 | < 5秒 | 场景切换不要太慢 |
| 子弹数量峰值 | < 100颗 | 对象池工作正常 |
打包检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 图标正确 | 待检查 | 任务栏和桌面图标正常 |
| 版本号正确 | 待检查 | 比如v1.0.0 |
| 没有调试信息 | 待检查 | GD.Print 已移除或禁用 |
| 没有敏感信息 | 待检查 | 没有密码、API密钥等 |
| 文件大小合理 | 待检查 | 压缩后 < 100MB |
本章小结
恭喜你完成了魂斗罗2.5D的全部开发内容!本章我们完成了最后的打磨工作:
- 性能优化——对象池、碰撞层、视锥剔除
- 手感调优——射击反馈、跳跃手感、土狼时间、跳跃缓冲
- 设置菜单——音量、画质、全屏等设置
- 场景切换——带过渡动画的场景管理
- 多平台导出——Windows/Web/移动端适配
- 发布检查清单——确保游戏可以正式发布
全教程回顾
| 章节 | 内容 | 关键技能 |
|---|---|---|
| 1. 核心玩法设计 | 游戏设计理念、2.5D方案 | 游戏策划思维 |
| 2. 项目搭建 | Godot 4项目、正交摄像机 | 引擎基础操作 |
| 3. 角色移动 | CharacterBody3D、跳跃、摄像机 | 物理和移动控制 |
| 4. 射击系统 | 子弹、对象池、武器系统 | 对象池模式 |
| 5. 敌人AI | 状态机、波次系统 | AI基础 |
| 6. 关卡设计 | 视差滚动、触发器 | 关卡设计 |
| 7. 道具系统 | 掉落、升级、增益效果 | 系统设计 |
| 8. Boss战 | 多阶段Boss、弹幕 | 复杂状态机 |
| 9. 音效特效 | 粒子、震动、闪屏 | 感官反馈 |
| 10. 打磨发布 | 性能优化、多平台导出 | 发布流程 |
接下来做什么?
你现在已经有了一个完整的魂斗罗2.5D游戏框架。接下来你可以:
- 添加更多关卡——设计不同的地形和敌人组合
- 添加新武器——设计独特的子弹行为
- 添加新Boss——设计更多有趣的攻击模式
- 添加剧情——让游戏有故事线
- 发布游戏——分享给朋友,或者发布到游戏平台
祝你游戏开发愉快!
